From e02f08b41d8c5adaac41812810f9fcbb86bb04a4 Mon Sep 17 00:00:00 2001 From: Pavel Holec Date: Fri, 18 Aug 2023 08:56:47 +0200 Subject: [PATCH 1/2] Update TextField comment --- Sources/Orbit/Components/InputField.swift | 2 ++ Sources/Orbit/Support/TextFields/TextField.swift | 11 ++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Sources/Orbit/Components/InputField.swift b/Sources/Orbit/Components/InputField.swift index d60d4a74725..78a012e1323 100644 --- a/Sources/Orbit/Components/InputField.swift +++ b/Sources/Orbit/Components/InputField.swift @@ -5,6 +5,8 @@ import UIKit /// /// When you have additional information or helpful examples, include prompt text to help users along. /// +/// The custom Orbit version of ``TextField`` component is used internally. +/// /// - Note: [Orbit definition](https://orbit.kiwi/components/inputfield/) /// - Important: Component expands horizontally unless prevented by `fixedSize` modifier. public struct InputField: View, TextFieldBuildable { diff --git a/Sources/Orbit/Support/TextFields/TextField.swift b/Sources/Orbit/Support/TextFields/TextField.swift index ec033e2cac3..11e2f891096 100644 --- a/Sources/Orbit/Support/TextFields/TextField.swift +++ b/Sources/Orbit/Support/TextFields/TextField.swift @@ -1,7 +1,16 @@ import SwiftUI import UIKit -/// Orbit wrapper over `UITextField` with larger touch area and action handling. +/// Orbit control that displays an editable text interface, a replacement for native `TextField` component. +/// +/// The component uses UIKit implementation to support these feature for older iOS versions: +/// - focus changes +/// - UITextField event handling +/// - full UITextField configuration +/// - font and text override +/// - larger and configurable touch area +/// +/// The component is compatible with native `@FocusState` modifier to support focus changes in later iOS versions. public struct TextField: UIViewRepresentable, TextFieldBuildable { @Environment(\.identifier) private var identifier From 5b07374aa07a9d17bce8dd063f92edaf74d3e586 Mon Sep 17 00:00:00 2001 From: Pavel Holec Date: Thu, 17 Aug 2023 19:23:19 +0200 Subject: [PATCH 2/2] Update text fields focus modifier --- .../InputFieldReturnActionKey.swift | 8 +- .../Orbit/Support/TextFields/TextField.swift | 245 ++++-------------- .../TextFields/TextFieldCoordinator.swift | 245 ++++++++++++++++++ 3 files changed, 305 insertions(+), 193 deletions(-) create mode 100644 Sources/Orbit/Support/TextFields/TextFieldCoordinator.swift diff --git a/Sources/Orbit/Support/Environment Keys/InputFieldReturnActionKey.swift b/Sources/Orbit/Support/Environment Keys/InputFieldReturnActionKey.swift index e7b1227660b..2c718d88f83 100644 --- a/Sources/Orbit/Support/Environment Keys/InputFieldReturnActionKey.swift +++ b/Sources/Orbit/Support/Environment Keys/InputFieldReturnActionKey.swift @@ -1,23 +1,23 @@ import SwiftUI struct InputFieldReturnActionKey: EnvironmentKey { - static let defaultValue: (() -> Void)? = nil + static let defaultValue: () -> Void = {} } struct InputFieldReturnIdentifiableActionKey: EnvironmentKey { - static let defaultValue: ((AnyHashable) -> Void)? = nil + static let defaultValue: (AnyHashable) -> Void = { _ in } } public extension EnvironmentValues { /// An Orbit `inputFieldReturnAction` action for `InputField` stored in a view’s environment. - var inputFieldReturnAction: (() -> Void)? { + var inputFieldReturnAction: () -> Void { get { self[InputFieldReturnActionKey.self] } set { self[InputFieldReturnActionKey.self] = newValue } } /// An Orbit `inputFieldReturnIdentifiableAction` action for identifiable `InputField` stored in a view’s environment. - var inputFieldReturnIdentifiableAction: ((AnyHashable) -> Void)? { + var inputFieldReturnIdentifiableAction: (AnyHashable) -> Void { get { self[InputFieldReturnIdentifiableActionKey.self] } set { self[InputFieldReturnIdentifiableActionKey.self] = newValue } } diff --git a/Sources/Orbit/Support/TextFields/TextField.swift b/Sources/Orbit/Support/TextFields/TextField.swift index 11e2f891096..282fae22e0d 100644 --- a/Sources/Orbit/Support/TextFields/TextField.swift +++ b/Sources/Orbit/Support/TextFields/TextField.swift @@ -64,47 +64,53 @@ public struct TextField: UIViewRepresentable, TextFieldBuildable { } public func updateUIView(_ uiView: InsetableTextField, context: Context) { - // Prevent unwanted delegate calls when updating values from bindings + // Prevent delegate call cycle when updating values from SwiftUI context.coordinator.isBeingUpdated = true - uiView.insets.left = leadingPadding - uiView.insets.right = trailingPadding - uiView.isSecureTextEntry = isSecureTextEntry + uiView.updateIfNeeded(\.insets.left, to: leadingPadding) + uiView.updateIfNeeded(\.insets.right, to: trailingPadding) + uiView.updateIfNeeded(\.isSecureTextEntry, to: isSecureTextEntry) // Keyboard related - uiView.returnKeyType = returnKeyType - uiView.keyboardType = keyboardType - uiView.textContentType = textContentType + uiView.updateIfNeeded(\.returnKeyType, to: returnKeyType) + uiView.updateIfNeeded(\.keyboardType, to: keyboardType) + uiView.updateIfNeeded(\.textContentType, to: textContentType) + + let autocorrectionType: UITextAutocorrectionType if let isAutocorrectionDisabled { - uiView.autocorrectionType = isAutocorrectionDisabled ? .no : .yes + autocorrectionType = isAutocorrectionDisabled ? .no : .yes } else { switch textContentType { case UITextContentType.emailAddress, UITextContentType.password, UITextContentType.newPassword: // If not specified, disable autocomplete for these content types - uiView.autocorrectionType = .no + autocorrectionType = .no default: - uiView.autocorrectionType = .default + autocorrectionType = .default } } - uiView.autocapitalizationType = autocapitalizationType + uiView.updateIfNeeded(\.autocorrectionType, to: autocorrectionType) + uiView.updateIfNeeded(\.autocapitalizationType, to: autocapitalizationType) uiView.shouldDeleteBackwardAction = shouldDeleteBackwardAction if resolvedTextSize != context.coordinator.fontSize || resolvedTextWeight != context.coordinator.fontWeight { - uiView.font = .orbit(size: resolvedTextSize, weight: resolvedTextWeight) + uiView.font = UIFont.orbit(size: resolvedTextSize, weight: resolvedTextWeight) context.coordinator.fontSize = resolvedTextSize context.coordinator.fontWeight = resolvedTextWeight } - uiView.textColor = isEnabled ? (textColor ?? state.textColor).uiColor : .cloudDarkActive - uiView.isEnabled = isEnabled - - uiView.attributedPlaceholder = .init( - string: prompt, - attributes: [ - .foregroundColor: isEnabled ? state.placeholderColor.uiColor : .cloudDarkActive - ] + uiView.updateIfNeeded(\.textColor, to: isEnabled ? (textColor ?? state.textColor).uiColor : .cloudDarkActive) + uiView.updateIfNeeded(\.isEnabled, to: isEnabled) + + uiView.updateIfNeeded( + \.attributedPlaceholder, + to: .init( + string: prompt, + attributes: [ + .foregroundColor: isEnabled ? state.placeholderColor.uiColor : .cloudDarkActive + ] + ) ) // Check if the binding value is different to replace the text content @@ -112,28 +118,31 @@ public struct TextField: UIViewRepresentable, TextFieldBuildable { uiView.replace(withText: value) } - // Become/Resign first responder if needed - if let inputFieldFocus, let value = identifier { - switch (uiView.isFirstResponder, inputFieldFocus.binding.wrappedValue == value) { - case (false, true): - // Needs to be dispatched - Task { @MainActor in - _ = uiView.becomeFirstResponder() - context.coordinator.isBeingUpdated = false - } - return - case (true, false): - _ = uiView.resignFirstResponder() - default: - break + // Become/Resign first responder if needed. + // Only relevant to fields with identifier and Orbit focus modifier applied. + // Not relevant for fields driven by iOS15+ @FocusState + if let inputFieldFocus, let identifier { + + if let valueToUpdate = context.coordinator.valueToUpdate.first, valueToUpdate != inputFieldFocus.binding.wrappedValue { + // Updated binding value from UIKit was not yet updated, ignoring + context.coordinator.isBeingUpdated = false + return + } + + if uiView.isFirstResponder == false && inputFieldFocus.binding.wrappedValue == identifier { + uiView.toggleKeyboardFocus(true, coordinator: context.coordinator) + return + } else if uiView.isFirstResponder && inputFieldFocus.binding.wrappedValue != identifier { + uiView.toggleKeyboardFocus(false, coordinator: context.coordinator) + return } } context.coordinator.isBeingUpdated = false } - public func makeCoordinator() -> Coordinator { - Coordinator( + public func makeCoordinator() -> TextFieldCoordinator { + TextFieldCoordinator( identifier: identifier, value: $value, inputFieldFocus: inputFieldFocus, @@ -157,159 +166,6 @@ public struct TextField: UIViewRepresentable, TextFieldBuildable { private var resolvedTextWeight: UIFont.Weight { (textFontWeight ?? .regular).uiKit } - - public final class Coordinator: NSObject, UITextFieldDelegate, ObservableObject { - - let identifier: AnyHashable? - @Binding var value: String - - var fontSize: CGFloat = 0 - var fontWeight: UIFont.Weight = .regular - - let inputFieldFocus: InputFieldFocus? - let inputFieldBeginEditingAction: () -> Void - let inputFieldBeginEditingIdentifiableAction: (AnyHashable) -> Void - let inputFieldEndEditingAction: () -> Void - let inputFieldEndEditingIdentifiableAction: (AnyHashable) -> Void - let inputFieldReturnAction: (() -> Void)? - let inputFieldReturnIdentifiableAction: ((AnyHashable) -> Void)? - let inputFieldShouldReturnAction: (() -> Bool)? - let inputFieldShouldReturnIdentifiableAction: ((AnyHashable) -> Bool)? - let inputFieldShouldChangeCharactersAction: ((NSString, NSRange, String) -> InputFieldShouldChangeResult)? - let inputFieldShouldChangeCharactersIdentifiableAction: ((AnyHashable, NSString, NSRange, String) -> InputFieldShouldChangeResult)? - - // Required to distinguish SwiftUI (`updateUIView`) from UIKit change - fileprivate var isBeingUpdated = false - - init( - identifier: AnyHashable?, - value: Binding, - inputFieldFocus: InputFieldFocus?, - inputFieldBeginEditingAction: @escaping () -> Void, - inputFieldBeginEditingIdentifiableAction: @escaping (AnyHashable) -> Void, - inputFieldEndEditingAction: @escaping () -> Void, - inputFieldEndEditingIdentifiableAction: @escaping (AnyHashable) -> Void, - inputFieldReturnAction: (() -> Void)?, - inputFieldReturnIdentifiableAction: ((AnyHashable) -> Void)?, - inputFieldShouldReturnAction: (() -> Bool)?, - inputFieldShouldReturnIdentifiableAction: ((AnyHashable) -> Bool)?, - inputFieldShouldChangeCharactersAction: ((NSString, NSRange, String) -> InputFieldShouldChangeResult)?, - inputFieldShouldChangeCharactersIdentifiableAction: ((AnyHashable, NSString, NSRange, String) -> InputFieldShouldChangeResult)? - ) { - self.identifier = identifier - self._value = value - self.inputFieldFocus = inputFieldFocus - self.inputFieldBeginEditingAction = inputFieldBeginEditingAction - self.inputFieldBeginEditingIdentifiableAction = inputFieldBeginEditingIdentifiableAction - self.inputFieldEndEditingAction = inputFieldEndEditingAction - self.inputFieldEndEditingIdentifiableAction = inputFieldEndEditingIdentifiableAction - self.inputFieldReturnAction = inputFieldReturnAction - self.inputFieldReturnIdentifiableAction = inputFieldReturnIdentifiableAction - self.inputFieldShouldReturnAction = inputFieldShouldReturnAction - self.inputFieldShouldReturnIdentifiableAction = inputFieldShouldReturnIdentifiableAction - self.inputFieldShouldChangeCharactersAction = inputFieldShouldChangeCharactersAction - self.inputFieldShouldChangeCharactersIdentifiableAction = inputFieldShouldChangeCharactersIdentifiableAction - } - - public func textFieldDidBeginEditing(_ textField: UITextField) { - if isBeingUpdated { return } - - Task { @MainActor in - if let inputFieldFocus { - inputFieldFocus.binding.wrappedValue = identifier - } - - inputFieldBeginEditingAction() - - if let identifier { - inputFieldBeginEditingIdentifiableAction(identifier) - } - } - } - - public func textFieldDidEndEditing(_ textField: UITextField) { - if isBeingUpdated { return } - - Task { @MainActor in - if let inputFieldFocus { - inputFieldFocus.binding.wrappedValue = nil - } - - inputFieldEndEditingAction() - - if let identifier { - inputFieldEndEditingIdentifiableAction(identifier) - } - } - } - - public func textFieldShouldReturn(_ textField: UITextField) -> Bool { - if isBeingUpdated { return true } - - let shouldReturn: Bool - - if let inputFieldShouldReturnIdentifiableAction, let identifier { - shouldReturn = inputFieldShouldReturnIdentifiableAction(identifier) - } else if let inputFieldShouldReturnAction { - shouldReturn = inputFieldShouldReturnAction() - } else { - shouldReturn = true - } - - if shouldReturn { - textField.resignFirstResponder() - - Task { @MainActor in - if let inputFieldReturnIdentifiableAction, let identifier { - inputFieldReturnIdentifiableAction(identifier) - } else if let inputFieldReturnAction { - inputFieldReturnAction() - } - } - } - - return shouldReturn - } - - public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { - if isBeingUpdated { return true } - - let text = ((textField.text ?? "") as NSString) - let result: InputFieldShouldChangeResult - - if let inputFieldShouldChangeCharactersIdentifiableAction, let identifier { - result = inputFieldShouldChangeCharactersIdentifiableAction(identifier, text, range, string) - } else if let inputFieldShouldChangeCharactersAction { - result = inputFieldShouldChangeCharactersAction(text, range, string) - } else { - result = .accept - } - - switch result { - case .accept: - return true - case .replace(let modifiedValue): - // Refuse the proposed change, replace the text with modified value - textField.text = modifiedValue - return false - case .reject: - return false - } - } - - public func textFieldDidChangeSelection(_ textField: UITextField) { - if isBeingUpdated { return } - - let newValue = textField.text ?? "" - - if value != newValue { - // This is a safer place to report the actual value, as it can be modified by system silently. - // Example: `emailAddress` type being hijacked by system when using autocomplete - // https://github.com/lionheart/openradar-mirror/issues/18086 - value = newValue - } - } - } } // MARK: - Inits @@ -345,6 +201,17 @@ public enum InputFieldShouldChangeResult { case replace(_ replacementValue: String) } +private extension InsetableTextField { + + @discardableResult + func updateIfNeeded(_ path: ReferenceWritableKeyPath, to value: Value) -> Self { + if value != self[keyPath: path] { + self[keyPath: path] = value + } + return self + } +} + // MARK: - Previews struct TextFieldPreviews: PreviewProvider { diff --git a/Sources/Orbit/Support/TextFields/TextFieldCoordinator.swift b/Sources/Orbit/Support/TextFields/TextFieldCoordinator.swift new file mode 100644 index 00000000000..7d9e4a944d5 --- /dev/null +++ b/Sources/Orbit/Support/TextFields/TextFieldCoordinator.swift @@ -0,0 +1,245 @@ +import SwiftUI + +public final class TextFieldCoordinator: NSObject, UITextFieldDelegate, ObservableObject { + + static var textFieldToBecomeResponder: UITextField? + static var coordinatorToBecomeResponder: TextFieldCoordinator? + static var textFieldToResignResponder: UITextField? + static var coordinatorToResignResponder: TextFieldCoordinator? + + static func debounceBecomeResponder(coordinator: TextFieldCoordinator) { + coordinatorToBecomeResponder = coordinator + debounceResponderUpdate() + } + + static func debounceResignResponder(coordinator: TextFieldCoordinator) { + coordinatorToResignResponder = coordinator + debounceResponderUpdate() + } + + static func debounceResponderUpdate() { + responderDebounceTimer?.invalidate() + + if textFieldToResignResponder != nil && textFieldToBecomeResponder != nil { + switchResponders() + } else { + responderDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: false) { _ in + switchResponders() + } + } + } + + static func switchResponders() { + // The inverted order is important for keyboard to stay fixed when switching responders + _ = textFieldToBecomeResponder?.becomeFirstResponder() + textFieldToBecomeResponder = nil + _ = textFieldToResignResponder?.resignFirstResponder() + textFieldToResignResponder = nil + + coordinatorToResignResponder?.onEndEditing(updateBinding: false) + coordinatorToResignResponder?.isBeingUpdated = false + coordinatorToResignResponder = nil + + coordinatorToBecomeResponder?.onBeginEditing(updateBinding: false) + coordinatorToBecomeResponder?.isBeingUpdated = false + coordinatorToBecomeResponder = nil + } + + static var responderDebounceTimer: Timer? + + @Binding var value: String + var valueToUpdate = [AnyHashable?]() + + var fontSize: CGFloat = 0 + var fontWeight: UIFont.Weight = .regular + + let identifier: AnyHashable? + let inputFieldFocus: InputFieldFocus? + let inputFieldBeginEditingAction: () -> Void + let inputFieldBeginEditingIdentifiableAction: (AnyHashable) -> Void + let inputFieldEndEditingAction: () -> Void + let inputFieldEndEditingIdentifiableAction: (AnyHashable) -> Void + let inputFieldReturnAction: () -> Void + let inputFieldReturnIdentifiableAction: (AnyHashable) -> Void + let inputFieldShouldReturnAction: (() -> Bool)? + let inputFieldShouldReturnIdentifiableAction: ((AnyHashable) -> Bool)? + let inputFieldShouldChangeCharactersAction: ((NSString, NSRange, String) -> InputFieldShouldChangeResult)? + let inputFieldShouldChangeCharactersIdentifiableAction: ((AnyHashable, NSString, NSRange, String) -> InputFieldShouldChangeResult)? + + // Required to distinguish delegate calls as a result of changes from SwiftUI (`updateUIView`) + // from changes coming directly from UIKit. + var isBeingUpdated = false + + init( + identifier: AnyHashable?, + value: Binding, + inputFieldFocus: InputFieldFocus?, + inputFieldBeginEditingAction: @escaping () -> Void, + inputFieldBeginEditingIdentifiableAction: @escaping (AnyHashable) -> Void, + inputFieldEndEditingAction: @escaping () -> Void, + inputFieldEndEditingIdentifiableAction: @escaping (AnyHashable) -> Void, + inputFieldReturnAction: @escaping () -> Void, + inputFieldReturnIdentifiableAction: @escaping (AnyHashable) -> Void, + inputFieldShouldReturnAction: (() -> Bool)?, + inputFieldShouldReturnIdentifiableAction: ((AnyHashable) -> Bool)?, + inputFieldShouldChangeCharactersAction: ((NSString, NSRange, String) -> InputFieldShouldChangeResult)?, + inputFieldShouldChangeCharactersIdentifiableAction: ((AnyHashable, NSString, NSRange, String) -> InputFieldShouldChangeResult)? + ) { + self.identifier = identifier + self._value = value + self.inputFieldFocus = inputFieldFocus + self.inputFieldBeginEditingAction = inputFieldBeginEditingAction + self.inputFieldBeginEditingIdentifiableAction = inputFieldBeginEditingIdentifiableAction + self.inputFieldEndEditingAction = inputFieldEndEditingAction + self.inputFieldEndEditingIdentifiableAction = inputFieldEndEditingIdentifiableAction + self.inputFieldReturnAction = inputFieldReturnAction + self.inputFieldReturnIdentifiableAction = inputFieldReturnIdentifiableAction + self.inputFieldShouldReturnAction = inputFieldShouldReturnAction + self.inputFieldShouldReturnIdentifiableAction = inputFieldShouldReturnIdentifiableAction + self.inputFieldShouldChangeCharactersAction = inputFieldShouldChangeCharactersAction + self.inputFieldShouldChangeCharactersIdentifiableAction = inputFieldShouldChangeCharactersIdentifiableAction + } + + public func textFieldDidBeginEditing(_ textField: UITextField) { + if isBeingUpdated { return } + onBeginEditing(updateBinding: true) + } + + public func textFieldDidEndEditing(_ textField: UITextField) { + if isBeingUpdated { return } + onEndEditing(updateBinding: true) + } + + public func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if isBeingUpdated { return true } + + let shouldReturn = shouldReturn + + if shouldReturn { + onReturn(textField) + } + + return shouldReturn + } + + public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + if isBeingUpdated { return true } + + let text = ((textField.text ?? "") as NSString) + let result: InputFieldShouldChangeResult + + if let inputFieldShouldChangeCharactersIdentifiableAction, let identifier { + result = inputFieldShouldChangeCharactersIdentifiableAction(identifier, text, range, string) + } else if let inputFieldShouldChangeCharactersAction { + result = inputFieldShouldChangeCharactersAction(text, range, string) + } else { + result = .accept + } + + switch result { + case .accept: + return true + case .replace(let modifiedValue): + // Refuse the proposed change, replace the text with modified value + textField.text = modifiedValue + return false + case .reject: + return false + } + } + + public func textFieldDidChangeSelection(_ textField: UITextField) { + if isBeingUpdated { return } + + let newValue = textField.text ?? "" + + if value != newValue { + // This is a safer place to report the actual value, as it can be modified by system silently. + // Example: `emailAddress` type being hijacked by system when using autocomplete + // https://github.com/lionheart/openradar-mirror/issues/18086 + value = newValue + } + } + + fileprivate func onBeginEditing(updateBinding: Bool) { + inputFieldBeginEditingAction() + + if let identifier { + inputFieldBeginEditingIdentifiableAction(identifier) + + if updateBinding { + updateInputFieldFocusBindingIfNeeded(identifier) + } + } + } + + fileprivate func onEndEditing(updateBinding: Bool) { + inputFieldEndEditingAction() + + if let identifier { + inputFieldEndEditingIdentifiableAction(identifier) + + if updateBinding { + updateInputFieldFocusBindingIfNeeded(nil) + } + } + } + + fileprivate func onReturn(_ textField: UITextField) { + DispatchQueue.main.async { + textField.resignFirstResponder() + self.inputFieldReturnAction() + + if let identifier = self.identifier { + self.inputFieldReturnIdentifiableAction(identifier) + } + } + } + + private var shouldReturn: Bool { + if let inputFieldShouldReturnIdentifiableAction, let identifier { + return inputFieldShouldReturnIdentifiableAction(identifier) + } else if let inputFieldShouldReturnAction { + return inputFieldShouldReturnAction() + } else { + return true + } + } + + private func updateInputFieldFocusBindingIfNeeded(_ value: AnyHashable?) { + valueToUpdate = [value] + + if value != inputFieldFocus?.binding.wrappedValue { + // Update binding for SwiftUI + inputFieldFocus?.binding.wrappedValue = value + + // The binding update is not reflected in the immediate `updateUIView` cycle + // that follows. Needs to be dispatched and cleared. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.valueToUpdate = [] + } + } + + } +} + +extension UITextField { + + func toggleKeyboardFocus(_ focus: Bool, coordinator: TextFieldCoordinator) { + guard self.window != nil else { return } + + if focus { + TextFieldCoordinator.textFieldToBecomeResponder = self + + DispatchQueue.main.async { + TextFieldCoordinator.debounceBecomeResponder(coordinator: coordinator) + } + } else { + TextFieldCoordinator.textFieldToResignResponder = self + + DispatchQueue.main.async { + TextFieldCoordinator.debounceResignResponder(coordinator: coordinator) + } + } + } +}