Created
August 27, 2024 17:25
-
-
Save DandyLyons/80312b225934b79fc895cd0b924566a3 to your computer and use it in GitHub Desktop.
OptionalTextField.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // OptionalTextField.swift | |
| // | |
| // | |
| // Created by Daniel Lyons on 2024-08-12. | |
| // | |
| import Foundation | |
| import SwiftUI | |
| /// A `TextField` with a `Toggle` which allows the user to include or remove the `TextField`. | |
| /// | |
| /// If the user sets the `Toggle` to `false` then the `TextField` will be replaced with the `noTextFieldView` | |
| /// and the `optionalString` will be set to `nil`. | |
| @available(iOS 15.0, macOS 12.0, *) | |
| public struct OptionalTextField<AddTextFieldLabel: View, RemoveTextFieldLabel: View>: View { | |
| @Binding var optionalString: String? | |
| @State private var hasString: Bool | |
| /// a string to assign to ``optionalString`` if ever the user toggles the string off and then on again | |
| /// | |
| /// This cache will only live as long as the View | |
| @State private var cachedString: String | |
| let titleKey: LocalizedStringKey | |
| // the label of the add text field button | |
| let addTextFieldLabel: AddTextFieldLabel | |
| // the label of the remove text field button | |
| let removeTextFieldLabel: RemoveTextFieldLabel | |
| let prompt: Text? | |
| /// Creates an OptionalTextField | |
| /// | |
| /// >Note: `prompt` will be ignored pre-iOS 15 and macOS 12 | |
| public init( | |
| _ titleKey: LocalizedStringKey, | |
| text optionalString: Binding<String?>, | |
| prompt: Text? = nil, | |
| @ViewBuilder addTextFieldLabel: () -> AddTextFieldLabel, | |
| @ViewBuilder removeTextFieldLabel: () -> RemoveTextFieldLabel | |
| ) { | |
| self.titleKey = titleKey | |
| self._optionalString = optionalString | |
| self.prompt = prompt | |
| self.cachedString = optionalString.wrappedValue ?? "" | |
| self.hasString = optionalString.wrappedValue != nil | |
| self.addTextFieldLabel = addTextFieldLabel() | |
| self.removeTextFieldLabel = removeTextFieldLabel() | |
| } | |
| @FocusState var isFocusedOnTextField: Bool | |
| public var body: some View { | |
| Group { | |
| if let string = optionalString { | |
| if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { | |
| HStack { | |
| Button { | |
| withAnimation { | |
| isFocusedOnTextField = false // we must remove the focus first, otherwise the focus will be on the TextField, which will write to the String which will cause optionalString to not be nil | |
| hasString = false | |
| } | |
| } label: { | |
| removeTextFieldLabel | |
| } | |
| TextField( | |
| titleKey, | |
| text: $optionalString.toNonOptional(defaultValue: string), // it should not be possible for this default value to ever be used | |
| prompt: prompt | |
| ) | |
| .focused($isFocusedOnTextField) | |
| } | |
| .buttonStyle(.plain) | |
| } else { | |
| TextField( | |
| titleKey, | |
| text: $optionalString.toNonOptional(defaultValue: string) // it should not be possible for this default value to ever be used | |
| ) | |
| } | |
| } else { | |
| Button { | |
| withAnimation { | |
| isFocusedOnTextField = true | |
| hasString = true | |
| } | |
| } label: { | |
| addTextFieldLabel | |
| } | |
| } | |
| } | |
| .onChange(of: hasString) { newValue in | |
| optionalString = if newValue { cachedString } else { nil } | |
| } | |
| .onChange(of: optionalString) { newValue in | |
| guard let newValue else { | |
| return | |
| } | |
| cachedString = newValue | |
| } | |
| } | |
| } | |
| @available(iOS 15.0, macOS 12.0, *) | |
| struct OptionalTextFieldPreview: View { | |
| @State var string: String? = "DandyLyons" | |
| var body: some View { | |
| OptionalTextField( | |
| "Nickname", | |
| text: $string.animation(), | |
| addTextFieldLabel: { | |
| Label("Add Nickname", systemImage: "plus.circle.fill") | |
| }, | |
| removeTextFieldLabel: { | |
| Label("Remove Nickname", systemImage: "minus.circle.fill") | |
| .labelStyle(.iconOnly) | |
| } | |
| ) | |
| } | |
| } | |
| #Preview { | |
| Form { | |
| if #available(iOS 15.0, macOS 12.0, *) { | |
| OptionalTextFieldPreview() | |
| } else { | |
| // Fallback on earlier versions | |
| } | |
| } | |
| } | |
| @available(iOS 15.0, macOS 12.0, *) | |
| extension OptionalTextField where AddTextFieldLabel == Text, RemoveTextFieldLabel == Text { | |
| public init( | |
| _ titleKey: LocalizedStringKey, | |
| text optionalString: Binding<String?>, | |
| prompt: LocalizedStringKey, | |
| addTextField: LocalizedStringKey, | |
| removeTextField: LocalizedStringKey | |
| ) { | |
| self.titleKey = titleKey | |
| self._optionalString = optionalString | |
| self.cachedString = optionalString.wrappedValue ?? "" | |
| self.hasString = optionalString.wrappedValue != nil | |
| self.prompt = Text(prompt) | |
| self.addTextFieldLabel = Text(addTextField) | |
| self.removeTextFieldLabel = Text(removeTextField) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment