Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[774] Add visual feedback for spoken content #776

Merged
merged 1 commit into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Vocable.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@
A9E665A7241A785F00FE577A /* CarouselGridCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E665A6241A785F00FE577A /* CarouselGridCollectionViewController.swift */; };
A9FD68A528008AD3006ABA3F /* AddPhraseCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FD68A428008AD3006ABA3F /* AddPhraseCollectionViewCell.swift */; };
A9FD68A728009646006ABA3F /* NSAttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FD68A628009646006ABA3F /* NSAttributedString+Helpers.swift */; };
B2458E602BFE4C35007AF53D /* HighlightableContentCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2458E5F2BFE4C35007AF53D /* HighlightableContentCell.swift */; };
B24F02352BEBEF4E003C6BF9 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B24F02342BEBEF4E003C6BF9 /* Localizable.xcstrings */; };
B24F02372BEBEF4E003C6BF9 /* Presets.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B24F02362BEBEF4E003C6BF9 /* Presets.xcstrings */; };
B24F02392BEBEF4E003C6BF9 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B24F02382BEBEF4E003C6BF9 /* InfoPlist.xcstrings */; };
Expand Down Expand Up @@ -534,6 +535,7 @@
A9E665A6241A785F00FE577A /* CarouselGridCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselGridCollectionViewController.swift; sourceTree = "<group>"; };
A9FD68A428008AD3006ABA3F /* AddPhraseCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPhraseCollectionViewCell.swift; sourceTree = "<group>"; };
A9FD68A628009646006ABA3F /* NSAttributedString+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+Helpers.swift"; sourceTree = "<group>"; };
B2458E5F2BFE4C35007AF53D /* HighlightableContentCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightableContentCell.swift; sourceTree = "<group>"; };
B24F02332BEBEF4E003C6BF9 /* mul */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = mul; path = mul.lproj/LaunchScreen.xcstrings; sourceTree = "<group>"; };
B24F02342BEBEF4E003C6BF9 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
B24F02362BEBEF4E003C6BF9 /* Presets.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Presets.xcstrings; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1138,6 +1140,7 @@
6B9DFA7323E8D1270037673E /* TrackingContainerViewController.swift */,
6B17CD7F23EA045D0050BCB8 /* ViewControllerWrapperView.swift */,
B838200E23F4B011005A79CD /* VocableCollectionViewCell.swift */,
B2458E5F2BFE4C35007AF53D /* HighlightableContentCell.swift */,
30B979A424254CBC00309D7C /* WarningView.swift */,
71DBE5EA240DE4D000BA8D01 /* WarningView.xib */,
);
Expand Down Expand Up @@ -1418,6 +1421,7 @@
B2BC6CA92BD94F5A00D459F6 /* VocableListContentConfiguration+VoiceProfileItem.swift in Sources */,
B2BC6CB52BDAD1B500D459F6 /* VoiceProfileItem.swift in Sources */,
4E70DA6A27FE859F0027B40B /* ContentHuggingPriority.swift in Sources */,
B2458E602BFE4C35007AF53D /* HighlightableContentCell.swift in Sources */,
64A54545242A490700218BE8 /* GazeEatingView.swift in Sources */,
2745402B2460DD490056EF98 /* Presets.swift in Sources */,
A94C3FB027F4A5DC001D4F01 /* PhraseEditorConfigurationProvider.swift in Sources */,
Expand Down
14 changes: 12 additions & 2 deletions Vocable/Common/PagingCarouselViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,22 @@ import AVFoundation

// Speaking + highlighting consolidation

@MainActor
private func cellForItem(at indexPath: IndexPath) -> UICollectionViewCell? {
collectionView.cellForItem(at: indexPath)
}

func speak(
_ string: String,
forItemAt indexPath: IndexPath
) {
Task {
await VocableSpeechSynthesizer.shared.speak(string, language: AppConfig.activePreferredLanguageCode)
Task { [weak self] in
let ranges = await VocableSpeechSynthesizer.shared.speak(string)
guard let cell = self?.cellForItem(at: indexPath) as? HighlightableContentCell else { return }
for await range in ranges {
cell.setHighlightRange(range)
}
cell.setHighlightRange(nil)
}
}
}
23 changes: 22 additions & 1 deletion Vocable/Common/PresetItemCollectionViewCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@

import UIKit

class PresetItemCollectionViewCell: VocableCollectionViewCell {
class PresetItemCollectionViewCell: VocableCollectionViewCell, HighlightableContentCell {

let textLabel = UILabel(frame: .zero)

private var highlightRange: NSRange?

override func updateContent() {
super.updateContent()

Expand All @@ -29,6 +31,12 @@ class PresetItemCollectionViewCell: VocableCollectionViewCell {
textLabel.backgroundColor = borderedView.fillColor
textLabel.isOpaque = true
textLabel.font = UIFont.systemFont(ofSize: 28, weight: .bold)

if let highlightRange, let attributedString = textLabel.attributedText {
let attr = NSMutableAttributedString(attributedString: attributedString)
attr.addAttribute(.foregroundColor, value: UIColor.cellBorderHighlightColor, range: highlightRange)
textLabel.attributedText = attr
}
}

override init(frame: CGRect) {
Expand Down Expand Up @@ -73,6 +81,19 @@ class PresetItemCollectionViewCell: VocableCollectionViewCell {
textLabel.text = title
}
}

@MainActor
func setHighlightRange(_ range: NSRange?) {
highlightRange = range
UIView.transition(
with: contentView,
duration: 0.1,
options: .transitionCrossDissolve
) { [weak self] in
self?.setNeedsUpdateContent()
self?.updateContent()
}
}
}

class CategoryItemCollectionViewCell: PresetItemCollectionViewCell {
Expand Down
8 changes: 7 additions & 1 deletion Vocable/Common/UITraitCollection+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ struct SizeClass: OptionSet {
}
}

extension UITraitCollection {
var sizeClass: SizeClass {
SizeClass(self)
}
}

protocol TraitCollectionProvider {

var traitCollection: UITraitCollection { get }
Expand All @@ -79,7 +85,7 @@ protocol TraitCollectionProvider {
extension TraitCollectionProvider {

var sizeClass: SizeClass {
return .init(traitCollection)
SizeClass(traitCollection)
}
}

Expand Down
15 changes: 15 additions & 0 deletions Vocable/Common/Views/HighlightableContentCell.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// HighlightableContentCell.swift
// Vocable
//
// Created by Chris Stroud on 5/22/24.
// Copyright © 2024 WillowTree. All rights reserved.
//

import Foundation
import UIKit

protocol HighlightableContentCell: UICollectionViewCell {
@MainActor
func setHighlightRange(_ range: NSRange?)
}
5 changes: 5 additions & 0 deletions Vocable/Common/VocableSpeechSynthesizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import Combine
protocol VocableSpeechSynthesizerDelegate: AnyObject {
func voiceProfilePreviewDidBegin(_: AVSpeechSynthesisVoice?)
func voiceProfilePreviewDidEnd()
func voiceSpeechSynthesisWillSpeakRange(_ range: NSRange, utterance: AVSpeechUtterance)
}

actor VocableSpeechSynthesizer: NSObject, AVSpeechSynthesizerDelegate {
Expand Down Expand Up @@ -161,6 +162,10 @@ actor VocableSpeechSynthesizer: NSObject, AVSpeechSynthesizerDelegate {
utterance: utterance,
range: characterRange
)
delegate?.voiceSpeechSynthesisWillSpeakRange(
characterRange,
utterance: utterance
)
}
}
}
9 changes: 9 additions & 0 deletions Vocable/Extensions/UIFont+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,13 @@ extension UIFont {
static func settingsCellTitle() -> UIFont {
UIFont.systemFont(ofSize: 22, weight: .bold)
}

static func textEditor(
satisfying traitCollection: UITraitCollection = .current
) -> UIFont {
let fontSize: CGFloat = (traitCollection.sizeClass == .hRegular_vRegular) ? 40 : 28
let desiredFont = UIFont.systemFont(ofSize: fontSize, weight: .bold)
return desiredFont
}

}
18 changes: 18 additions & 0 deletions Vocable/Features/Keyboard/KeyboardKeyCollectionViewCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,31 @@ class KeyboardKeyCollectionViewCell: VocableCollectionViewCell {
textLabel.isHidden = false
imageView.isHidden = true
textLabel.text = title

if #available(iOS 17.0, *) {
imageView.removeAllSymbolEffects()
}
}

func setup(with image: UIImage?) {
textLabel.isHidden = true
imageView.isHidden = false
imageView.image = image?.withRenderingMode(.alwaysTemplate)
imageView.contentMode = .center

if #available(iOS 17.0, *) {
imageView.removeAllSymbolEffects()
}
}

@available(iOS 17.0, *)
func setup(
with image: UIImage?,
effect: some DiscreteSymbolEffect & SymbolEffect,
options: SymbolEffectOptions = .default
) {
setup(with: image)
imageView.addSymbolEffect(effect, options: options, animated: true)
}
}

Expand Down
39 changes: 36 additions & 3 deletions Vocable/Features/Keyboard/KeyboardViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import UIKit
import AVKit
import Combine

class KeyboardViewController: UICollectionViewController {
class KeyboardViewController: UICollectionViewController, VocableSpeechSynthesizerDelegate {

private typealias DataSource = UICollectionViewDiffableDataSource<Section, ItemWrapper>
private typealias Snapshot = NSDiffableDataSourceSnapshot<Section, ItemWrapper>
Expand Down Expand Up @@ -46,6 +46,21 @@ class KeyboardViewController: UICollectionViewController {
}
}

private var isSpeaking: Bool = false {
didSet {
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([.functionKey(.speak)])
dataSource.apply(snapshot)
}
}

private var speakingRange: NSRange? {
didSet {
guard oldValue != speakingRange else { return }
self.setTextTransaction(self.textTransaction.withSpeakingRange(speakingRange))
}
}

@PublishedValue
var attributedText: NSAttributedString?

Expand Down Expand Up @@ -76,7 +91,7 @@ class KeyboardViewController: UICollectionViewController {
override func viewDidLoad() {
super.viewDidLoad()

speechSynthesizer = VocableSpeechSynthesizer()
speechSynthesizer = VocableSpeechSynthesizer(delegate: self)

$attributedText
.receive(on: DispatchQueue.main)
Expand Down Expand Up @@ -125,7 +140,11 @@ class KeyboardViewController: UICollectionViewController {
cell.accessibilityID = .shared.keyboard.key(itemIdentifier.accessibilityID)
}
let speakKeyRegistration = SpeakKeyCellRegistration { cell, _, itemIdentifier in
cell.setup(with: itemIdentifier.image)
if #available(iOS 17.0, *), self.isSpeaking {
cell.setup(with: itemIdentifier.image, effect: .variableColor.iterative.dimInactiveLayers.reversing, options: .repeating)
} else {
cell.setup(with: itemIdentifier.image)
}
cell.accessibilityID = .shared.keyboard.key(itemIdentifier.accessibilityID)
}

Expand Down Expand Up @@ -291,5 +310,19 @@ class KeyboardViewController: UICollectionViewController {
self?.updateSnapshot()
}
}

// MARK: VocableSpeechSynthesizerDelegate

func voiceProfilePreviewDidBegin(_: AVSpeechSynthesisVoice?) {
isSpeaking = true
}

func voiceProfilePreviewDidEnd() {
isSpeaking = false
speakingRange = nil
}

func voiceSpeechSynthesisWillSpeakRange(_ range: NSRange, utterance: AVSpeechUtterance) {
speakingRange = range
}
}
16 changes: 14 additions & 2 deletions Vocable/Features/Keyboard/Models/TextTransaction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ struct TextTransaction: CustomDebugStringConvertible {
private let lastTokenRange: NSRange
private let intent: Intent
let isHint: Bool

var debugDescription: String {
return "TextDescription(text: \(text), lastCharacterRange: \(String(describing: lastChararacterRange)), lastTokenRange: \(lastTokenRange), changeType: \(intent), isHint: \(isHint))"
}

init(text: String, intent: Intent = .none, isHint: Bool = false) {
init(text: String, intent: Intent = .none, isHint: Bool = false, speakingRange: NSRange? = nil) {
if text.count == 1 {
self.text = text.uppercased()
} else {
Expand All @@ -49,6 +49,14 @@ struct TextTransaction: CustomDebugStringConvertible {
break
}

if let speakingRange {
attributedText.addAttribute(.foregroundColor, value: UIColor.cellBorderHighlightColor, range: speakingRange)
}
attributedText.addAttribute(
.font,
value: UIFont.textEditor(),
range: NSRange(location: 0, length: attributedText.length)
)
self.intent = intent
}

Expand Down Expand Up @@ -117,6 +125,10 @@ struct TextTransaction: CustomDebugStringConvertible {
return newText
}

func withSpeakingRange(_ range: NSRange?) -> TextTransaction {
.init(text: self.text, intent: self.intent, isHint: self.isHint, speakingRange: range)
}

// To keep track of when to delete the last word or the full word (after selecting a text suggestion) when pressing backspace
// on the keyboard.
enum Intent {
Expand Down
4 changes: 1 addition & 3 deletions Vocable/Features/Keyboard/OutputTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,7 @@ class OutputTextView: UITextView {
}

private func updateFontForTraitCollection() {
let sizeClass = (traitCollection.horizontalSizeClass, traitCollection.verticalSizeClass)
let fontSize: CGFloat = sizeClass == (.regular, .regular) ? 40 : 28
let desiredFont = UIFont.systemFont(ofSize: fontSize, weight: .bold)
let desiredFont = UIFont.textEditor(satisfying: traitCollection)
let attributedFont: UIFont? = {
guard let len = attributedText?.length, len > 0 else { return nil }
return attributedText?.attribute(.font, at: 0, effectiveRange: nil) as? UIFont
Expand Down