From 1f1ae4b5999bb2d14e566e236135da864ab09ae2 Mon Sep 17 00:00:00 2001 From: Rico Crescenzio Date: Fri, 15 Apr 2022 14:17:23 +0200 Subject: [PATCH 1/2] Implemented FormatPolicy --- .../SUITextFieldExample/ContentView.swift | 1 + .../SwiftUITextField.docc/SwiftUITextField.md | 1 - .../TextField/FormatPolicy.swift | 24 +++ .../TextField/SUITextField.swift | 175 +++++++++++++++--- 4 files changed, 170 insertions(+), 31 deletions(-) create mode 100644 Sources/SwiftUITextField/TextField/FormatPolicy.swift diff --git a/SUITextFieldExample/SUITextFieldExample/ContentView.swift b/SUITextFieldExample/SUITextFieldExample/ContentView.swift index db64956..905ee4a 100644 --- a/SUITextFieldExample/SUITextFieldExample/ContentView.swift +++ b/SUITextFieldExample/SUITextFieldExample/ContentView.swift @@ -87,6 +87,7 @@ struct ContentView: View { SUITextField( value: $myDouble, format: .number, + formatPolicy: .onChange, placeholder: AttributedString("Hey there") .mergingAttributes(AttributeContainer(placeholderAttributes)) ) diff --git a/Sources/SwiftUITextField/SwiftUITextField.docc/SwiftUITextField.md b/Sources/SwiftUITextField/SwiftUITextField.docc/SwiftUITextField.md index d337de3..28104d0 100644 --- a/Sources/SwiftUITextField/SwiftUITextField.docc/SwiftUITextField.md +++ b/Sources/SwiftUITextField/SwiftUITextField.docc/SwiftUITextField.md @@ -84,7 +84,6 @@ formatters. Check all the provided initializers in ``SUITextField``! - ``ResponderState`` - ``ResponderChainCoordinator`` - ### Miscellaneous - ``CharacterReplacement`` diff --git a/Sources/SwiftUITextField/TextField/FormatPolicy.swift b/Sources/SwiftUITextField/TextField/FormatPolicy.swift new file mode 100644 index 0000000..321d66c --- /dev/null +++ b/Sources/SwiftUITextField/TextField/FormatPolicy.swift @@ -0,0 +1,24 @@ +// +// FormatPolicy.swift +// +// +// Created by Rico Crescenzio on 15/04/22. +// + +import Foundation + +/// Indicates when the bound value is updated on a ``SUITextField`` with format/formatter. +/// +/// When using ``SUITextField/init(value:format:formatPolicy:placeholder:)`` or +/// ``SUITextField/init(value:formatter:formatPolicy:placeholder:defaultValue:)`` +/// the bound value is updated after the format/formatter parses it. +/// +/// Policy can specify when the conversion is made, either during typing or when text field did end editing. +public enum FormatPolicy { + + /// Parses the text to bound value on every text insertion. + case onChange + + /// Parses the text to bound value when text field end editing. + case onCommit +} diff --git a/Sources/SwiftUITextField/TextField/SUITextField.swift b/Sources/SwiftUITextField/TextField/SUITextField.swift index 411c4da..f6beab0 100644 --- a/Sources/SwiftUITextField/TextField/SUITextField.swift +++ b/Sources/SwiftUITextField/TextField/SUITextField.swift @@ -138,6 +138,7 @@ where InputView: View, InputAccessoryView: View, LeftView: View, RightView: View private var onReturnKeyPressedAction: (() -> Void)? = nil private var onSelectionChangedAction: ((UITextField) -> Void)? = nil private var shouldChangeCharactersInAction: ((CharacterReplacement) -> Bool)? = nil + private var _onEndEditingAction: ((UITextField) -> Void)? = nil init( text: Binding, @@ -201,17 +202,26 @@ public extension SUITextField { public extension SUITextField { - private init(value: Binding, formatter: Formatter, placeholder: TextType?, defaultValue: V?) - where InputView == EmptyView, InputAccessoryView == EmptyView, LeftView == EmptyView, RightView == EmptyView { - let binding = Binding { - formatter.string(for: value.wrappedValue) ?? "" - } set: { newValue in + private init( + value: Binding, + formatter: Formatter, + formatPolicy: FormatPolicy, + placeholder: TextType?, + defaultValue: V? + ) where InputView == EmptyView, InputAccessoryView == EmptyView, LeftView == EmptyView, RightView == EmptyView { + let set = { (text: String) -> Void in var newObj: AnyObject? = nil - formatter.getObjectValue(&newObj, for: newValue, errorDescription: nil) + formatter.getObjectValue(&newObj, for: text, errorDescription: nil) guard let newObj = (newObj as? V) ?? defaultValue else { return } value.wrappedValue = newObj } - self.init( + let binding = Binding { + formatter.string(for: value.wrappedValue) ?? "" + } set: { newValue in + guard formatPolicy == .onChange else { return } + set(newValue) + } + let view = SUITextField( text: binding, placeholder: placeholder, autoSizeInputView: false, @@ -220,13 +230,24 @@ public extension SUITextField { inputView: { EmptyView() }, inputAccessoryView: { EmptyView() } ) + if formatPolicy == .onCommit { + self = view.apply( + value: { textField in + set(textField.text ?? "") + }, + to: \._onEndEditingAction + ) + } else { + self = view + } } /// Creates a text field that applies a format style to a bound value and a plain placeholder. /// /// Use this initializer to create a text field that binds to a bound value, using a `Foundation.Formatter` to convert to and from this type. /// Changes to the bound value update the string displayed by the text field. - /// Editing the text field updates the bound value, as long as the formatter can parse the text. + /// Editing the text field updates the bound value (depending on ``FormatPolicy`` either on text change or on editing end), + /// as long as the formatter can parse the text. /// If the formatter can’t parse the input, the bound value remains unchanged. /// /// The following example uses a Double as the bound value, and a `NumberFormatter` @@ -259,13 +280,22 @@ public extension SUITextField { /// - formatter: A subclasses of foundation `Formatter` to use when converting between the string the user edits and the /// underlying value of type `V`. If formatter can’t perform the conversion, the text field leaves binding.value unchanged. /// If the user stops editing the text in an invalid state, the text field updates the field’s text to the last known valid value. + /// - formatPolicy: Indicates when the bound value is updated. Default is ``FormatPolicy/onCommit``, to match + /// same `SwiftUI.TextField` behavior. /// - placeholder: The plain string placeholder. /// - defaultValue: A default value to apply if conversion fails. Default is `nil` which mean bound value doesn't update in case of failure. - init(value: Binding, formatter: Formatter, placeholder: String? = nil, defaultValue: V? = nil) + init( + value: Binding, + formatter: Formatter, + formatPolicy: FormatPolicy = .onCommit, + placeholder: String? = nil, + defaultValue: V? = nil + ) where InputView == EmptyView, InputAccessoryView == EmptyView, LeftView == EmptyView, RightView == EmptyView { self.init( value: value, formatter: formatter, + formatPolicy: formatPolicy, placeholder: placeholder.map { .plain($0) }, defaultValue: defaultValue ) @@ -275,7 +305,8 @@ public extension SUITextField { /// /// Use this initializer to create a text field that binds to a bound value, using a `Foundation.Formatter` to convert to and from this type. /// Changes to the bound value update the string displayed by the text field. - /// Editing the text field updates the bound value, as long as the formatter can parse the text. + /// Editing the text field updates the bound value (depending on ``FormatPolicy`` either on text change or on editing end), + /// as long as the formatter can parse the text. /// If the formatter can’t parse the input, the bound value remains unchanged. /// /// The following example uses a Double as the bound value, and a `NumberFormatter` @@ -308,13 +339,22 @@ public extension SUITextField { /// - formatter: A subclasses of foundation `Formatter` to use when converting between the string the user edits and the /// underlying value of type `V`. If formatter can’t perform the conversion, the text field leaves binding.value unchanged. /// If the user stops editing the text in an invalid state, the text field updates the field’s text to the last known valid value. + /// - formatPolicy: Indicates when the bound value is updated. Default is ``FormatPolicy/onCommit``, to match + /// same `SwiftUI.TextField` behavior. /// - placeholder: The `NSAttributedString` placeholder. /// - defaultValue: A default value to apply if conversion fails. Default is `nil` which mean bound value doesn't update in case of failure. - init(value: Binding, formatter: Formatter, placeholder: NSAttributedString, defaultValue: V? = nil) + init( + value: Binding, + formatter: Formatter, + formatPolicy: FormatPolicy = .onCommit, + placeholder: NSAttributedString, + defaultValue: V? = nil + ) where InputView == EmptyView, InputAccessoryView == EmptyView, LeftView == EmptyView, RightView == EmptyView { self.init( value: value, formatter: formatter, + formatPolicy: formatPolicy, placeholder: .attributed(placeholder), defaultValue: defaultValue ) @@ -334,16 +374,25 @@ public extension SUITextField { self.init(text: text, placeholder: .init(placeholder)) } - private init(value: Binding, format: F, placeholder: TextType? = nil, defaultValue: F.FormatInput? = nil) - where F: ParseableFormatStyle, F.FormatOutput == String, InputView == EmptyView, + private init( + value: Binding, + format: F, + formatPolicy: FormatPolicy, + placeholder: TextType?, + defaultValue: F.FormatInput? = nil + ) where F: ParseableFormatStyle, F.FormatOutput == String, InputView == EmptyView, InputAccessoryView == EmptyView, LeftView == EmptyView, RightView == EmptyView { + let set = { (text: String) -> Void in + guard let newValue = (try? format.parseStrategy.parse(text)) ?? defaultValue else { return } + value.wrappedValue = newValue + } let binding = Binding { format.format(value.wrappedValue) } set: { newValue in - guard let newValue = (try? format.parseStrategy.parse(newValue)) ?? defaultValue else { return } - value.wrappedValue = newValue + guard formatPolicy == .onChange else { return } + set(newValue) } - self.init( + let view = SUITextField( text: binding, placeholder: placeholder, autoSizeInputView: false, @@ -352,13 +401,24 @@ public extension SUITextField { inputView: { EmptyView() }, inputAccessoryView: { EmptyView() } ) + if formatPolicy == .onCommit { + self = view.apply( + value: { textField in + set(textField.text ?? "") + }, + to: \._onEndEditingAction + ) + } else { + self = view + } } /// Creates a text field that applies a format style to a bound value and a plain placeholder. /// /// Use this initializer to create a text field that binds to a bound value, using a `ParseableFormatStyle` to convert to and from this type. /// Changes to the bound value update the string displayed by the text field. - /// Editing the text field updates the bound value, as long as the format style can parse the text. + /// Editing the text field updates the bound value (depending on ``FormatPolicy`` either on text change or on editing end), + /// as long as the formatter can parse the text. /// If the format style can’t parse the input, the bound value remains unchanged. /// /// The following example uses a Double as the bound value, and a `FloatingPointFormatStyle` @@ -385,14 +445,23 @@ public extension SUITextField { /// - format: A format style of type F to use when converting between the string the user edits and the /// underlying value of type F.FormatInput. If format can’t perform the conversion, the text field leaves binding.value unchanged. /// If the user stops editing the text in an invalid state, the text field updates the field’s text to the last known valid value. + /// - formatPolicy: Indicates when the bound value is updated. Default is ``FormatPolicy/onCommit``, to match + /// same `SwiftUI.TextField` behavior. /// - placeholder: The plain string placeholder /// - defaultValue: A default value to apply if conversion fails. Default is `nil` which mean bound value doesn't update in case of failure. - init(value: Binding, format: F, placeholder: String? = nil, defaultValue: F.FormatInput? = nil) + init( + value: Binding, + format: F, + formatPolicy: FormatPolicy = .onCommit, + placeholder: String? = nil, + defaultValue: F.FormatInput? = nil + ) where F: ParseableFormatStyle, F.FormatOutput == String, InputView == EmptyView, InputAccessoryView == EmptyView, LeftView == EmptyView, RightView == EmptyView { self.init( value: value, format: format, + formatPolicy: formatPolicy, placeholder: placeholder.map { .plain($0) }, defaultValue: defaultValue ) @@ -402,7 +471,8 @@ public extension SUITextField { /// /// Use this initializer to create a text field that binds to a bound value, using a `ParseableFormatStyle` to convert to and from this type. /// Changes to the bound value update the string displayed by the text field. - /// Editing the text field updates the bound value, as long as the format style can parse the text. + /// Editing the text field updates the bound value (depending on ``FormatPolicy`` either on text change or on editing end), + /// as long as the formatter can parse the text. /// If the format style can’t parse the input, the bound value remains unchanged. /// /// The following example uses a Double as the bound value, and a `FloatingPointFormatStyle` @@ -430,28 +500,45 @@ public extension SUITextField { /// - format: A format style of type F to use when converting between the string the user edits and the /// underlying value of type F.FormatInput. If format can’t perform the conversion, the text field leaves binding.value unchanged. /// If the user stops editing the text in an invalid state, the text field updates the field’s text to the last known valid value. + /// - formatPolicy: Indicates when the bound value is updated. Default is ``FormatPolicy/onCommit``, to match + /// same `SwiftUI.TextField` behavior. /// - placeholder: An `AttributedString` placeholder. /// - defaultValue: A default value to apply if conversion fails. Default is `nil` which mean bound value doesn't update in case of failure. - init(value: Binding, format: F, placeholder: AttributedString, defaultValue: F.FormatInput? = nil) + init( + value: Binding, + format: F, + formatPolicy: FormatPolicy = .onCommit, + placeholder: AttributedString, + defaultValue: F.FormatInput? = nil + ) where F: ParseableFormatStyle, F.FormatOutput == String, InputView == EmptyView, InputAccessoryView == EmptyView, LeftView == EmptyView, RightView == EmptyView { self.init( value: value, format: format, + formatPolicy: formatPolicy, placeholder: .attributed(.init(placeholder)), defaultValue: defaultValue ) } - private init(value: Binding, format: F, placeholder: TextType? = nil) - where F: ParseableFormatStyle, F.FormatOutput == String, InputView == EmptyView, + private init( + value: Binding, + format: F, + formatPolicy: FormatPolicy, + placeholder: TextType? = nil + ) where F: ParseableFormatStyle, F.FormatOutput == String, InputView == EmptyView, InputAccessoryView == EmptyView, LeftView == EmptyView, RightView == EmptyView { - let binding = Binding.init { + let set = { (text: String) -> Void in + value.wrappedValue = try? format.parseStrategy.parse(text) + } + let binding = Binding { value.wrappedValue.map { format.format($0) } ?? "" } set: { newValue in + guard formatPolicy == .onChange else { return } value.wrappedValue = try? format.parseStrategy.parse(newValue) } - self.init( + let view = SUITextField( text: binding, placeholder: placeholder, autoSizeInputView: false, @@ -460,13 +547,24 @@ public extension SUITextField { inputView: { EmptyView() }, inputAccessoryView: { EmptyView() } ) + if formatPolicy == .onCommit { + self = view.apply( + value: { textField in + set(textField.text ?? "") + }, + to: \._onEndEditingAction + ) + } else { + self = view + } } /// Creates a text field that applies a format style to a bound value and a plain placeholder. /// /// Use this initializer to create a text field that binds to a bound value, using a `ParseableFormatStyle` to convert to and from this type. /// Changes to the bound value update the string displayed by the text field. - /// Editing the text field updates the bound value, as long as the format style can parse the text. + /// Editing the text field updates the bound value (depending on ``FormatPolicy`` either on text change or on editing end), + /// as long as the formatter can parse the text. /// If the format style can’t parse the input, the bound value is set to `nil` /// /// The following example uses a Double as the bound value, and a `FloatingPointFormatStyle` @@ -491,13 +589,20 @@ public extension SUITextField { /// - format: A format style of type F to use when converting between the string the user edits and the /// underlying value of type F.FormatInput. If format can’t perform the conversion, the text field set binding.value to `nil`. /// If the user stops editing the text in an invalid state, the text field updates the field’s text to the last known valid value. + /// - formatPolicy: Indicates when the bound value is updated. Default is ``FormatPolicy/onCommit``, to match + /// same `SwiftUI.TextField` behavior. /// - placeholder: The plain string placeholder. - init(value: Binding, format: F, placeholder: String? = nil) - where F: ParseableFormatStyle, F.FormatOutput == String, InputView == EmptyView, + init( + value: Binding, + format: F, + formatPolicy: FormatPolicy = .onCommit, + placeholder: String? = nil + ) where F: ParseableFormatStyle, F.FormatOutput == String, InputView == EmptyView, InputAccessoryView == EmptyView, LeftView == EmptyView, RightView == EmptyView { self.init( value: value, format: format, + formatPolicy: formatPolicy, placeholder: placeholder.map { .plain($0) } ) } @@ -506,7 +611,8 @@ public extension SUITextField { /// /// Use this initializer to create a text field that binds to a bound value, using a `ParseableFormatStyle` to convert to and from this type. /// Changes to the bound value update the string displayed by the text field. - /// Editing the text field updates the bound value, as long as the format style can parse the text. + /// Editing the text field updates the bound value (depending on ``FormatPolicy`` either on text change or on editing end), + /// as long as the formatter can parse the text. /// If the format style can’t parse the input, the bound value is set to `nil` /// /// The following example uses a Double as the bound value, and a `FloatingPointFormatStyle` @@ -532,13 +638,20 @@ public extension SUITextField { /// - format: A format style of type F to use when converting between the string the user edits and the /// underlying value of type F.FormatInput. If format can’t perform the conversion, the text field set binding.value to `nil`. /// If the user stops editing the text in an invalid state, the text field updates the field’s text to the last known valid value. + /// - formatPolicy: Indicates when the bound value is updated. Default is ``FormatPolicy/onCommit``, to match + /// same `SwiftUI.TextField` behavior. /// - placeholder: The attributed string placeholder. - init(value: Binding, format: F, placeholder: AttributedString) - where F: ParseableFormatStyle, F.FormatOutput == String, InputView == EmptyView, + init( + value: Binding, + format: F, + formatPolicy: FormatPolicy = .onCommit, + placeholder: AttributedString + ) where F: ParseableFormatStyle, F.FormatOutput == String, InputView == EmptyView, InputAccessoryView == EmptyView, LeftView == EmptyView, RightView == EmptyView { self.init( value: value, format: format, + formatPolicy: formatPolicy, placeholder: .attributed(.init(placeholder)) ) } @@ -743,6 +856,7 @@ public extension SUITextField { .apply(value: textField.onReturnKeyPressedAction, to: \.onReturnKeyPressedAction) .apply(value: textField.onSelectionChangedAction, to: \.onSelectionChangedAction) .apply(value: textField.shouldChangeCharactersInAction, to: \.shouldChangeCharactersInAction) + .apply(value: textField._onEndEditingAction, to: \._onEndEditingAction) } private func apply(value: T, to path: WritableKeyPath) -> Self { @@ -1036,6 +1150,7 @@ public extension SUITextField { } public func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) { + uiKitTextField._onEndEditingAction?(textField) removeCustomViews(to: textField) onViewResignFirstResponder() uiKitTextField.onEndEditingAction?(reason) From 3a8ba92deb918aa9e9f1a02e40d2fd3e1d081477 Mon Sep 17 00:00:00 2001 From: Rico Crescenzio Date: Fri, 15 Apr 2022 15:31:17 +0200 Subject: [PATCH 2/2] Updated docc --- .../SwiftUITextField.docc/SUITextField.md | 19 +++++++++++++++++++ .../TextField/FormatPolicy.swift | 4 ---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/Sources/SwiftUITextField/SwiftUITextField.docc/SUITextField.md b/Sources/SwiftUITextField/SwiftUITextField.docc/SUITextField.md index 5bcd572..4892fbd 100644 --- a/Sources/SwiftUITextField/SwiftUITextField.docc/SUITextField.md +++ b/Sources/SwiftUITextField/SwiftUITextField.docc/SUITextField.md @@ -2,6 +2,25 @@ ## Topics +### Creating a text field with a string + +- ``SUITextField/init(text:placeholder:)-5btu0`` +- ``SUITextField/init(text:placeholder:)-7334w`` +- ``SUITextField/init(text:placeholder:)-9p6by`` + +### Creating a text field with a value (iOS 15) + +- ``SUITextField/init(value:format:formatPolicy:placeholder:defaultValue:)-3i36v`` +- ``SUITextField/init(value:format:formatPolicy:placeholder:defaultValue:)-5fdb3`` +- ``SUITextField/init(value:format:formatPolicy:placeholder:)-9l23l`` +- ``SUITextField/init(value:format:formatPolicy:placeholder:)-2bhgs`` +- ``FormatPolicy`` + +### Creating a text field with a value (pre-iOS 15) + +- ``SUITextField/init(value:formatter:formatPolicy:placeholder:defaultValue:)-3wopi`` +- ``SUITextField/init(value:formatter:formatPolicy:placeholder:defaultValue:)-1cub1`` + ### Modify style and behaviors - diff --git a/Sources/SwiftUITextField/TextField/FormatPolicy.swift b/Sources/SwiftUITextField/TextField/FormatPolicy.swift index 321d66c..e9b9b81 100644 --- a/Sources/SwiftUITextField/TextField/FormatPolicy.swift +++ b/Sources/SwiftUITextField/TextField/FormatPolicy.swift @@ -9,10 +9,6 @@ import Foundation /// Indicates when the bound value is updated on a ``SUITextField`` with format/formatter. /// -/// When using ``SUITextField/init(value:format:formatPolicy:placeholder:)`` or -/// ``SUITextField/init(value:formatter:formatPolicy:placeholder:defaultValue:)`` -/// the bound value is updated after the format/formatter parses it. -/// /// Policy can specify when the conversion is made, either during typing or when text field did end editing. public enum FormatPolicy {