diff --git a/Vocable.xcodeproj/project.pbxproj b/Vocable.xcodeproj/project.pbxproj index 77ab5a48..56b142ae 100644 --- a/Vocable.xcodeproj/project.pbxproj +++ b/Vocable.xcodeproj/project.pbxproj @@ -238,7 +238,6 @@ A98E8A2523F1DBF200BF1F22 /* SettingsFooterCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = A98E8A2423F1DBF200BF1F22 /* SettingsFooterCollectionViewCell.xib */; }; A99AF23123FC8BF700BE1184 /* TextTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99AF23023FC8BF700BE1184 /* TextTransaction.swift */; }; A99AF23323FDA8B600BE1184 /* SuggestionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99AF23223FDA8B600BE1184 /* SuggestionCollectionViewCell.swift */; }; - A99AF23523FDA8DC00BE1184 /* SuggestionCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = A99AF23423FDA8DC00BE1184 /* SuggestionCollectionViewCell.xib */; }; A99B5324282C0D5D004FEFDA /* AnalyticsReportable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99B5323282C0D5D004FEFDA /* AnalyticsReportable.swift */; }; A99B5326282C0E9D004FEFDA /* TimeInterval+AnalyticsReportable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99B5325282C0E9D004FEFDA /* TimeInterval+AnalyticsReportable.swift */; }; A9B32C8C240EB6250084E151 /* KeyboardModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B32C8B240EB6250084E151 /* KeyboardModel.swift */; }; @@ -513,7 +512,6 @@ A98E8A2423F1DBF200BF1F22 /* SettingsFooterCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SettingsFooterCollectionViewCell.xib; sourceTree = ""; }; A99AF23023FC8BF700BE1184 /* TextTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextTransaction.swift; sourceTree = ""; }; A99AF23223FDA8B600BE1184 /* SuggestionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCollectionViewCell.swift; sourceTree = ""; }; - A99AF23423FDA8DC00BE1184 /* SuggestionCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SuggestionCollectionViewCell.xib; sourceTree = ""; }; A99B5323282C0D5D004FEFDA /* AnalyticsReportable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsReportable.swift; sourceTree = ""; }; A99B5325282C0E9D004FEFDA /* TimeInterval+AnalyticsReportable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+AnalyticsReportable.swift"; sourceTree = ""; }; A9B32C8B240EB6250084E151 /* KeyboardModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardModel.swift; sourceTree = ""; }; @@ -1227,7 +1225,6 @@ A94A6CCB2412D33F00625275 /* Models */, 6B14DC722419590E0050C287 /* OutputTextView.swift */, A99AF23223FDA8B600BE1184 /* SuggestionCollectionViewCell.swift */, - A99AF23423FDA8DC00BE1184 /* SuggestionCollectionViewCell.xib */, ); path = Keyboard; sourceTree = ""; @@ -1379,7 +1376,6 @@ A9CF2AF5242D4EF1005633A7 /* SensitivityCollectionViewCell.xib in Resources */, 71DBE5EB240DE4D000BA8D01 /* WarningView.xib in Resources */, B24F02352BEBEF4E003C6BF9 /* Localizable.xcstrings in Resources */, - A99AF23523FDA8DC00BE1184 /* SuggestionCollectionViewCell.xib in Resources */, A96D61D724229F7400577144 /* SettingsCollectionViewCell.xib in Resources */, 71B856442416CEB600CB163B /* ToastView.xib in Resources */, A98E8A2523F1DBF200BF1F22 /* SettingsFooterCollectionViewCell.xib in Resources */, diff --git a/Vocable/Common/Views/GazeableButton.swift b/Vocable/Common/Views/GazeableButton.swift index ab05bad7..12bce281 100644 --- a/Vocable/Common/Views/GazeableButton.swift +++ b/Vocable/Common/Views/GazeableButton.swift @@ -105,7 +105,7 @@ class GazeableButton: UIButton { UIView.animate( withDuration: 0.2, delay: 0, - options: [.beginFromCurrentState, .curveEaseOut], + options: [.beginFromCurrentState, .curveEaseOut, .allowUserInteraction], animations: actions, completion: nil ) diff --git a/Vocable/Common/Views/VocableCollectionViewCell.swift b/Vocable/Common/Views/VocableCollectionViewCell.swift index fed38e02..b6b16c65 100644 --- a/Vocable/Common/Views/VocableCollectionViewCell.swift +++ b/Vocable/Common/Views/VocableCollectionViewCell.swift @@ -136,11 +136,13 @@ class VocableCollectionViewCell: UICollectionViewCell { } if UIView.inheritedAnimationDuration == 0 { - UIView.animate(withDuration: 0.2, - delay: 0, - options: [.beginFromCurrentState, .curveEaseOut], - animations: actions, - completion: nil) + UIView.animate( + withDuration: 0.2, + delay: 0, + options: [.beginFromCurrentState, .curveEaseOut, .allowUserInteraction], + animations: actions, + completion: nil + ) } else { actions() } diff --git a/Vocable/Features/Keyboard/KeyboardViewController.swift b/Vocable/Features/Keyboard/KeyboardViewController.swift index ee2c278c..6d0bb548 100644 --- a/Vocable/Features/Keyboard/KeyboardViewController.swift +++ b/Vocable/Features/Keyboard/KeyboardViewController.swift @@ -12,9 +12,17 @@ import Combine class KeyboardViewController: UICollectionViewController { - private var dataSource: UICollectionViewDiffableDataSource! - + private typealias DataSource = UICollectionViewDiffableDataSource + private typealias Snapshot = NSDiffableDataSourceSnapshot + + private typealias SuggestionCellRegistration = UICollectionView.CellRegistration + private typealias KeyCellRegistration = UICollectionView.CellRegistration + private typealias FunctionKeyCellRegistration = UICollectionView.CellRegistration + private typealias SpeakKeyCellRegistration = UICollectionView.CellRegistration + private var speechSynthesizer: VocableSpeechSynthesizer! + private var dataSource: DataSource! + private var disposables = Set() private var _textTransaction = TextTransaction(text: "") { @@ -31,17 +39,25 @@ class KeyboardViewController: UICollectionViewController { private var suggestions: [TextSuggestion] = [] { didSet { - updateSnapshot() + var snapshot = dataSource.snapshot() + let suggestionItems = snapshot.itemIdentifiers(inSection: .suggestions) + snapshot.reconfigureItems(suggestionItems) + dataSource.apply(snapshot, animatingDifferences: true) } } @PublishedValue var attributedText: NSAttributedString? - + + private var suggestionCellRegistration: SuggestionCellRegistration! + private var keyCellRegistration: KeyCellRegistration! + private var functionKeyCellRegistration: FunctionKeyCellRegistration! + private var speakCellRegistration: SpeakKeyCellRegistration! + private enum ItemWrapper: Hashable { case key(String) - case keyboardFunctionButton(KeyboardFunctionButton) - case suggestionText(TextSuggestion) + case functionKey(KeyboardFunctionKey) + case suggestionCell(Int) } private enum Section: Int, CaseIterable { @@ -62,12 +78,19 @@ class KeyboardViewController: UICollectionViewController { speechSynthesizer = VocableSpeechSynthesizer() - $attributedText.receive(on: DispatchQueue.main).sink { [weak self] (newAttributedText) in - guard let self = self, let newAttributedText = newAttributedText, - newAttributedText.string != self._textTransaction.text else { return } - self._textTransaction = TextTransaction(text: newAttributedText.string, intent: .lastCharacter) - }.store(in: &disposables) - + $attributedText + .receive(on: DispatchQueue.main) + .sink { [weak self] (newAttributedText) in + guard + let self, let newAttributedText, + newAttributedText.string != self._textTransaction.text + else { + return + } + self._textTransaction = TextTransaction(text: newAttributedText.string, intent: .lastCharacter) + } + .store(in: &disposables) + setupCollectionView() configureDataSource() } @@ -80,51 +103,67 @@ class KeyboardViewController: UICollectionViewController { private func setupCollectionView() { collectionView.delaysContentTouches = false collectionView.isScrollEnabled = false - - collectionView.register( - KeyboardKeyCollectionViewCell.self, - forCellWithReuseIdentifier: KeyboardKeyCollectionViewCell.reuseIdentifier - ) - collectionView.register( - SpeakFunctionKeyboardKeyCollectionViewCell.self, - forCellWithReuseIdentifier: SpeakFunctionKeyboardKeyCollectionViewCell.reuseIdentifier - ) - collectionView.register(UINib(nibName: "SuggestionCollectionViewCell", bundle: nil), forCellWithReuseIdentifier: SuggestionCollectionViewCell.reuseIdentifier) let layout = createLayout() collectionView.collectionViewLayout = layout collectionView.backgroundColor = UIColor.collectionViewBackgroundColor collectionView.allowsMultipleSelection = true - collectionView.register(PresetPageControlReusableView.self, forSupplementaryViewOfKind: "footerPageIndicator", withReuseIdentifier: "PresetPageControlView") collectionView.accessibilityID = .shared.keyboard.collectionView } private func configureDataSource() { - dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView, cellProvider: { (collectionView: UICollectionView, indexPath: IndexPath, identifier: ItemWrapper) -> UICollectionViewCell? in - - switch identifier { - case .suggestionText(let predictiveText): - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: SuggestionCollectionViewCell.reuseIdentifier, for: indexPath) as! SuggestionCollectionViewCell - cell.setup(title: predictiveText.text) - return cell + let suggestionRegistration = SuggestionCellRegistration { cell, _, itemIdentifier in + cell.setup(title: itemIdentifier.text) + } + let keyCellRegistration = KeyCellRegistration { cell, _, char in + cell.setup(title: char) + cell.accessibilityID = .shared.keyboard.key(char) + } + let functionKeyRegistration = FunctionKeyCellRegistration { cell, _, itemIdentifier in + cell.setup(with: itemIdentifier.image) + cell.accessibilityID = .shared.keyboard.key(itemIdentifier.accessibilityID) + } + let speakKeyRegistration = SpeakKeyCellRegistration { cell, _, itemIdentifier in + cell.setup(with: itemIdentifier.image) + cell.accessibilityID = .shared.keyboard.key(itemIdentifier.accessibilityID) + } + + dataSource = DataSource(collectionView: collectionView) { [weak self] (collectionView: UICollectionView, indexPath: IndexPath, identifier: ItemWrapper) -> UICollectionViewCell? in + guard let self else { return nil } + return switch identifier { + case .suggestionCell(let index): + collectionView.dequeueConfiguredReusableCell( + using: suggestionRegistration, + for: indexPath, + item: suggestions[safe: index] ?? .init(text: "") + ) case .key(let char): - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: KeyboardKeyCollectionViewCell.reuseIdentifier, for: indexPath) as! KeyboardKeyCollectionViewCell - cell.setup(title: char) - cell.accessibilityID = .shared.keyboard.key(char) - return cell - case .keyboardFunctionButton(let functionType): + collectionView.dequeueConfiguredReusableCell( + using: keyCellRegistration, + for: indexPath, + item: char + ) + case .functionKey(let functionType): if functionType == .speak { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: SpeakFunctionKeyboardKeyCollectionViewCell.reuseIdentifier, for: indexPath) as! SpeakFunctionKeyboardKeyCollectionViewCell - cell.setup(with: functionType.image) - return cell + collectionView.dequeueConfiguredReusableCell( + using: speakKeyRegistration, + for: indexPath, + item: functionType + ) + } else { + collectionView.dequeueConfiguredReusableCell( + using: functionKeyRegistration, + for: indexPath, + item: functionType + ) } - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: KeyboardKeyCollectionViewCell.reuseIdentifier, for: indexPath) as! KeyboardKeyCollectionViewCell - cell.setup(with: functionType.image) - return cell } - }) - + } + self.keyCellRegistration = keyCellRegistration + self.functionKeyCellRegistration = functionKeyRegistration + self.speakCellRegistration = speakKeyRegistration + self.suggestionCellRegistration = suggestionRegistration updateSnapshot() } @@ -149,31 +188,20 @@ class KeyboardViewController: UICollectionViewController { // Snapshot construction snapshot.appendSections([.suggestions]) - - if suggestions.isEmpty { - snapshot.appendItems([.suggestionText(TextSuggestion(text: "")), - .suggestionText(TextSuggestion(text: "")), - .suggestionText(TextSuggestion(text: "")), - .suggestionText(TextSuggestion(text: ""))]) - } else { - snapshot.appendItems([.suggestionText(TextSuggestion(text: (suggestions[safe: 0]?.text ?? ""))), - .suggestionText(TextSuggestion(text: (suggestions[safe: 1]?.text ?? ""))), - .suggestionText(TextSuggestion(text: (suggestions[safe: 2]?.text ?? ""))), - .suggestionText(TextSuggestion(text: (suggestions[safe: 3]?.text ?? "")))]) - } + snapshot.appendItems((0...3).map { ItemWrapper.suggestionCell($0)}) snapshot.appendSections([.keyboard]) - if traitCollection.horizontalSizeClass == .compact && traitCollection.verticalSizeClass == .regular && !AppConfig.isCompactQWERTYKeyboardEnabled { + if sizeClass == .hCompact_vRegular && !AppConfig.isCompactQWERTYKeyboardEnabled { snapshot.appendItems(KeyboardLocale.current.compactPortraitKeyMapping.map { ItemWrapper.key("\($0)") }) } else { snapshot.appendItems(KeyboardLocale.current.landscapeKeyMapping.map { ItemWrapper.key("\($0)") }) } - snapshot.appendItems([.keyboardFunctionButton(.clear), .keyboardFunctionButton(.space), .keyboardFunctionButton(.backspace), .keyboardFunctionButton(.speak)]) + snapshot.appendItems([.functionKey(.clear), .functionKey(.space), .functionKey(.backspace), .functionKey(.speak)]) dataSource.apply(snapshot, animatingDifferences: animated) } - + // MARK: - Collection View Delegate override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let selectedItem = dataSource.itemIdentifier(for: indexPath) else { return } @@ -184,7 +212,7 @@ class KeyboardViewController: UICollectionViewController { } switch selectedItem { - case .keyboardFunctionButton(let functionType): + case .functionKey(let functionType): switch functionType { case .space: setTextTransaction(textTransaction.appendingCharacter(with: " ")) @@ -206,8 +234,10 @@ class KeyboardViewController: UICollectionViewController { } case .key(let char): setTextTransaction(textTransaction.appendingCharacter(with: char)) - case .suggestionText(let suggestion): - setTextTransaction(textTransaction.insertingSuggestion(with: suggestion.text)) + case .suggestionCell(let index): + if let suggestion = suggestions[safe: index] { + setTextTransaction(textTransaction.insertingSuggestion(with: suggestion.text)) + } } if collectionView.indexPathForGazedItem != indexPath { @@ -219,10 +249,10 @@ class KeyboardViewController: UICollectionViewController { guard let item = dataSource.itemIdentifier(for: indexPath) else { return false } switch item { - case .keyboardFunctionButton, .key: + case .functionKey, .key: return true - case .suggestionText(let suggestion): - return !suggestion.text.isEmpty + case .suggestionCell(let index): + return suggestions.indices.contains(index) } } @@ -230,10 +260,10 @@ class KeyboardViewController: UICollectionViewController { guard let item = dataSource.itemIdentifier(for: indexPath) else { return false } switch item { - case .keyboardFunctionButton, .key: + case .functionKey, .key: return true - case .suggestionText(let suggestion): - return !suggestion.text.isEmpty + case .suggestionCell(let index): + return suggestions.indices.contains(index) } } diff --git a/Vocable/Features/Keyboard/Models/KeyboardModel.swift b/Vocable/Features/Keyboard/Models/KeyboardModel.swift index 179b3c03..c41bfb64 100644 --- a/Vocable/Features/Keyboard/Models/KeyboardModel.swift +++ b/Vocable/Features/Keyboard/Models/KeyboardModel.swift @@ -56,7 +56,7 @@ struct KeyboardLocale { } } -enum KeyboardFunctionButton { +enum KeyboardFunctionKey { case clear case backspace case space @@ -74,4 +74,13 @@ enum KeyboardFunctionButton { return UIImage(systemName: "person.wave.2.fill")! } } + + var accessibilityID: String { + return switch self { + case .clear: "clear" + case .backspace: "backspace" + case .space: "space" + case .speak: "speak" + } + } } diff --git a/Vocable/Features/Keyboard/SuggestionCollectionViewCell.swift b/Vocable/Features/Keyboard/SuggestionCollectionViewCell.swift index b144ca19..6288072f 100644 --- a/Vocable/Features/Keyboard/SuggestionCollectionViewCell.swift +++ b/Vocable/Features/Keyboard/SuggestionCollectionViewCell.swift @@ -10,13 +10,33 @@ import UIKit class SuggestionCollectionViewCell: VocableCollectionViewCell { - @IBOutlet var textLabel: UILabel! - + private let textLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + private func commonInit() { + installContentView(textLabel) + } + func setup(title: String) { - if title.isEmpty { - textLabel.text = title - } else { - textLabel.text = "\"" + title + "\"" + UIView.transition( + with: self.contentView, + duration: 0.2, + options: [.transitionCrossDissolve] + ) { [weak textLabel] in + if title.isEmpty { + textLabel?.text = title + } else { + textLabel?.text = "\"" + title + "\"" + } } } @@ -24,17 +44,35 @@ class SuggestionCollectionViewCell: VocableCollectionViewCell { super.traitCollectionDidChange(previousTraitCollection) adjustBackgroundColorForSizeClass() } - + + private func installContentView(_ view: UIView) { + view.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(view) + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), + view.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + view.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor).withPriority(999), + view.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor).withPriority(999), + view.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + view.centerXAnchor.constraint(equalTo: contentView.centerXAnchor) + ]) + } + override func updateContent() { super.updateContent() - borderedView.fillColor = isSelected ? .cellSelectionColor : .categoryBackgroundColor - adjustBackgroundColorForSizeClass() - - guard let textLabel = textLabel else { return } - textLabel.textColor = isSelected ? .selectedTextColor : .defaultTextColor - textLabel.backgroundColor = borderedView.fillColor - textLabel.isOpaque = true - textLabel.font = .systemFont(ofSize: 22, weight: .bold) + UIView.performWithoutAnimation { + borderedView.fillColor = isSelected ? .cellSelectionColor : .categoryBackgroundColor + adjustBackgroundColorForSizeClass() + textLabel.backgroundColor = borderedView.fillColor + textLabel.textColor = isSelected ? .selectedTextColor : .defaultTextColor + textLabel.isOpaque = true + textLabel.font = .systemFont(ofSize: 22, weight: .bold) + textLabel.textAlignment = .center + textLabel.numberOfLines = 2 + textLabel.allowsDefaultTighteningForTruncation = true + textLabel.minimumScaleFactor = 0.5 + textLabel.adjustsFontSizeToFitWidth = true + } } private func adjustBackgroundColorForSizeClass() { @@ -44,5 +82,4 @@ class SuggestionCollectionViewCell: VocableCollectionViewCell { borderedView.backgroundColor = .categoryBackgroundColor } } - } diff --git a/Vocable/Features/Keyboard/SuggestionCollectionViewCell.xib b/Vocable/Features/Keyboard/SuggestionCollectionViewCell.xib deleted file mode 100644 index e7de3f47..00000000 --- a/Vocable/Features/Keyboard/SuggestionCollectionViewCell.xib +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Vocable/Features/Root/RootViewController.swift b/Vocable/Features/Root/RootViewController.swift index f16ca1e3..10bc2a49 100644 --- a/Vocable/Features/Root/RootViewController.swift +++ b/Vocable/Features/Root/RootViewController.swift @@ -172,13 +172,15 @@ import SwiftUI } prepare() - UIView.animate(withDuration: 0.6, - delay: 0, - usingSpringWithDamping: 0.8, - initialSpringVelocity: 1.0, - options: .beginFromCurrentState, - animations: actions, - completion: finalize) + UIView.animate( + withDuration: 0.6, + delay: 0, + usingSpringWithDamping: 0.8, + initialSpringVelocity: 1.0, + options: [.beginFromCurrentState, .allowUserInteraction], + animations: actions, + completion: finalize + ) } private func installViewController(_ viewController: UIViewController, in layoutGuide: UILayoutGuide) { diff --git a/Vocable/HeadTracking/UIVirtualCursorView.swift b/Vocable/HeadTracking/UIVirtualCursorView.swift index e977b541..aa90246b 100644 --- a/Vocable/HeadTracking/UIVirtualCursorView.swift +++ b/Vocable/HeadTracking/UIVirtualCursorView.swift @@ -119,7 +119,7 @@ class UIVirtualCursorView: UIView { delay: 0, usingSpringWithDamping: 0.4, initialSpringVelocity: 0.4, - options: .beginFromCurrentState, + options: [.beginFromCurrentState, .allowUserInteraction], animations: actions, completion: nil) } else {