diff --git a/Vocable.xcodeproj/project.pbxproj b/Vocable.xcodeproj/project.pbxproj index 56b142ae..67bb1b19 100644 --- a/Vocable.xcodeproj/project.pbxproj +++ b/Vocable.xcodeproj/project.pbxproj @@ -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 */; }; @@ -534,6 +535,7 @@ A9E665A6241A785F00FE577A /* CarouselGridCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselGridCollectionViewController.swift; sourceTree = ""; }; A9FD68A428008AD3006ABA3F /* AddPhraseCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPhraseCollectionViewCell.swift; sourceTree = ""; }; A9FD68A628009646006ABA3F /* NSAttributedString+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+Helpers.swift"; sourceTree = ""; }; + B2458E5F2BFE4C35007AF53D /* HighlightableContentCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightableContentCell.swift; sourceTree = ""; }; B24F02332BEBEF4E003C6BF9 /* mul */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = mul; path = mul.lproj/LaunchScreen.xcstrings; sourceTree = ""; }; B24F02342BEBEF4E003C6BF9 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; B24F02362BEBEF4E003C6BF9 /* Presets.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Presets.xcstrings; sourceTree = ""; }; @@ -1138,6 +1140,7 @@ 6B9DFA7323E8D1270037673E /* TrackingContainerViewController.swift */, 6B17CD7F23EA045D0050BCB8 /* ViewControllerWrapperView.swift */, B838200E23F4B011005A79CD /* VocableCollectionViewCell.swift */, + B2458E5F2BFE4C35007AF53D /* HighlightableContentCell.swift */, 30B979A424254CBC00309D7C /* WarningView.swift */, 71DBE5EA240DE4D000BA8D01 /* WarningView.xib */, ); @@ -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 */, diff --git a/Vocable/Common/PagingCarouselViewController.swift b/Vocable/Common/PagingCarouselViewController.swift index 2ea4ebfd..691695c6 100644 --- a/Vocable/Common/PagingCarouselViewController.swift +++ b/Vocable/Common/PagingCarouselViewController.swift @@ -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) } } } diff --git a/Vocable/Common/PresetItemCollectionViewCell.swift b/Vocable/Common/PresetItemCollectionViewCell.swift index 4af6c466..94a8d4d4 100644 --- a/Vocable/Common/PresetItemCollectionViewCell.swift +++ b/Vocable/Common/PresetItemCollectionViewCell.swift @@ -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() @@ -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) { @@ -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 { diff --git a/Vocable/Common/UITraitCollection+Helpers.swift b/Vocable/Common/UITraitCollection+Helpers.swift index a670853f..62c3d4a8 100644 --- a/Vocable/Common/UITraitCollection+Helpers.swift +++ b/Vocable/Common/UITraitCollection+Helpers.swift @@ -71,6 +71,12 @@ struct SizeClass: OptionSet { } } +extension UITraitCollection { + var sizeClass: SizeClass { + SizeClass(self) + } +} + protocol TraitCollectionProvider { var traitCollection: UITraitCollection { get } @@ -79,7 +85,7 @@ protocol TraitCollectionProvider { extension TraitCollectionProvider { var sizeClass: SizeClass { - return .init(traitCollection) + SizeClass(traitCollection) } } diff --git a/Vocable/Common/Views/HighlightableContentCell.swift b/Vocable/Common/Views/HighlightableContentCell.swift new file mode 100644 index 00000000..2d3dc21e --- /dev/null +++ b/Vocable/Common/Views/HighlightableContentCell.swift @@ -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?) +} diff --git a/Vocable/Common/VocableSpeechSynthesizer.swift b/Vocable/Common/VocableSpeechSynthesizer.swift index c09391df..dc7f93b4 100644 --- a/Vocable/Common/VocableSpeechSynthesizer.swift +++ b/Vocable/Common/VocableSpeechSynthesizer.swift @@ -15,6 +15,7 @@ import Combine protocol VocableSpeechSynthesizerDelegate: AnyObject { func voiceProfilePreviewDidBegin(_: AVSpeechSynthesisVoice?) func voiceProfilePreviewDidEnd() + func voiceSpeechSynthesisWillSpeakRange(_ range: NSRange, utterance: AVSpeechUtterance) } actor VocableSpeechSynthesizer: NSObject, AVSpeechSynthesizerDelegate { @@ -161,6 +162,10 @@ actor VocableSpeechSynthesizer: NSObject, AVSpeechSynthesizerDelegate { utterance: utterance, range: characterRange ) + delegate?.voiceSpeechSynthesisWillSpeakRange( + characterRange, + utterance: utterance + ) } } } diff --git a/Vocable/Extensions/UIFont+Helpers.swift b/Vocable/Extensions/UIFont+Helpers.swift index c9c3147a..0970316d 100644 --- a/Vocable/Extensions/UIFont+Helpers.swift +++ b/Vocable/Extensions/UIFont+Helpers.swift @@ -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 + } + } diff --git a/Vocable/Features/Keyboard/KeyboardKeyCollectionViewCell.swift b/Vocable/Features/Keyboard/KeyboardKeyCollectionViewCell.swift index 6d92223a..b99a5335 100644 --- a/Vocable/Features/Keyboard/KeyboardKeyCollectionViewCell.swift +++ b/Vocable/Features/Keyboard/KeyboardKeyCollectionViewCell.swift @@ -75,6 +75,10 @@ class KeyboardKeyCollectionViewCell: VocableCollectionViewCell { textLabel.isHidden = false imageView.isHidden = true textLabel.text = title + + if #available(iOS 17.0, *) { + imageView.removeAllSymbolEffects() + } } func setup(with image: UIImage?) { @@ -82,6 +86,20 @@ class KeyboardKeyCollectionViewCell: VocableCollectionViewCell { 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) } } diff --git a/Vocable/Features/Keyboard/KeyboardViewController.swift b/Vocable/Features/Keyboard/KeyboardViewController.swift index 6d0bb548..f1d53df2 100644 --- a/Vocable/Features/Keyboard/KeyboardViewController.swift +++ b/Vocable/Features/Keyboard/KeyboardViewController.swift @@ -10,7 +10,7 @@ import UIKit import AVKit import Combine -class KeyboardViewController: UICollectionViewController { +class KeyboardViewController: UICollectionViewController, VocableSpeechSynthesizerDelegate { private typealias DataSource = UICollectionViewDiffableDataSource private typealias Snapshot = NSDiffableDataSourceSnapshot @@ -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? @@ -76,7 +91,7 @@ class KeyboardViewController: UICollectionViewController { override func viewDidLoad() { super.viewDidLoad() - speechSynthesizer = VocableSpeechSynthesizer() + speechSynthesizer = VocableSpeechSynthesizer(delegate: self) $attributedText .receive(on: DispatchQueue.main) @@ -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) } @@ -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 + } } diff --git a/Vocable/Features/Keyboard/Models/TextTransaction.swift b/Vocable/Features/Keyboard/Models/TextTransaction.swift index f4100e18..1e3fd2fb 100644 --- a/Vocable/Features/Keyboard/Models/TextTransaction.swift +++ b/Vocable/Features/Keyboard/Models/TextTransaction.swift @@ -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 { @@ -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 } @@ -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 { diff --git a/Vocable/Features/Keyboard/OutputTextView.swift b/Vocable/Features/Keyboard/OutputTextView.swift index 1d789104..95876a72 100644 --- a/Vocable/Features/Keyboard/OutputTextView.swift +++ b/Vocable/Features/Keyboard/OutputTextView.swift @@ -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