简体   繁体   中英

SwiftUI Binding not triggering when changed from within a button action

I built a custom textfield with the following additional functionality:

  1. can be secure/insecure
  2. has a show/hide password button (if secure)
  3. has a clear text button
  4. has an error text label underneath.
  5. the placeholder animates above the textfield when text is entered (similar to android material textfields)
  6. has an onChange closure where you can run validation

The button action sets my text binding to ""

When I enter text into the textfield, the binding triggers properly.

But when I tap on the button to clear the text, the binding does not trigger.

Is there a reason for this?

Here is my struct. See the comments for where the issues are occurring.

public struct CustomTextField: View {
    // MARK: - Property Wrappers

    @Environment(\.isEnabled) private var isEnabled: Bool
    @Binding private var text: String
    @Binding private var errorText: String
    @State private var secureTextHidden: Bool = true

    // MARK: - Struct Properties

    private var placeholder: String
    private var isSecure: Bool
    private var hasLabel: Bool = false
    private var hasErrorLabel: Bool = false
    private var onChange: ((String) -> Void)?

    // MARK: - Computed Properties

    private var borderColor: Color {
        guard isEnabled else {
            return .gray
        }

        if !errorText.isEmpty {
            return .red
        } else if !text.isEmpty {
            return .blue
        } else {
            return .gray
        }
    }

    private var textColor: Color {
        guard isEnabled else {
            return Color.gray.opacity(0.5)
        }

        return .black
    }

    private var textField: some View {
        let binding = Binding<String> {
            self.text
        } set: {
            // This is triggered correctly when text changes, but not when text is changed within my button action.
            self.text = $0
            onChange?($0)
        }

        if isSecure && secureTextHidden {
            return SecureField(placeholder, text: binding)
                .eraseToAnyView()
        } else {
            return TextField(placeholder, text: binding)
                .eraseToAnyView()
        }
    }

    private var hasText: Bool { !text.isEmpty }
    private var hasError: Bool { !errorText.isEmpty }

    // MARK: - Init

    /// Initializes a new CustomTextField
    /// - Parameters:
    ///   - placeholder: the textfield placeholder
    ///   - isSecure: if true, textfield will behave like a password field
    ///   - hasLabel: Show placeholder as a label when text is entered
    ///   - hasErrorLabel: Visible Error Label underneath
    ///   - onChange: code that will run on each keystroke (optional)
    public init(
        placeholder: String,
        text: Binding<String>,
        errorText: Binding<String> = .constant(""),
        isSecure: Bool = false,
        hasLabel: Bool = false,
        hasErrorLabel: Bool = false,
        onChange: ((String) -> Void)? = nil
    ) {
        self.placeholder = placeholder
        _text = text
        _errorText = errorText
        self.isSecure = isSecure
        self.hasLabel = hasLabel
        self.hasErrorLabel = hasErrorLabel
        self.onChange = onChange
    }

    // MARK: - Body

    public var body: some View {
        VStack(alignment: .leading, spacing: .textMargin) {
            if hasLabel {
                Text("\(placeholder)")
                    .foregroundColor(textColor)
                    .offset(
                        x: hasText ? 0 : 16,
                        y: hasText ? 0 : 30
                    )
                    .opacity(hasText ? 1 : 0)
                    .animation(.easeOut(duration: 0.3))
            } else {
                EmptyView()
            }

            HStack(alignment: .center, spacing: 8) {
                ZStack(alignment: Alignment(horizontal: .trailing, vertical: .center)) {
                    HStack(alignment: .center, spacing: 16) {
                        textField

                        HStack(alignment: .center, spacing: 16) {
                            if hasText && isEnabled {
                                Button {
                                    text = ""
                                    // I had to trigger onChange manually as setting my text above is not triggering my binding block.
                                    onChange?(text)
                                } label: {
                                    Image(systemName: "xmark.circle.fill")
                                }
                                .buttonStyle(BorderlessButtonStyle())
                                .foregroundColor(.black)
                            }

                            if isSecure && isEnabled {
                                Button {
                                    secureTextHidden.toggle()
                                } label: {
                                    Image(systemName: secureTextHidden ? "eye.fill" : "eye.slash.fill")
                                }
                                .buttonStyle(BorderlessButtonStyle())
                                .foregroundColor(Color.gray.opacity(0.5))
                            }
                        }
                    }
                }
                .padding(.margin)
                .frame(height: .textFieldHeight, alignment: .center)
                .background(
                    ZStack {
                        RoundedRectangle(cornerRadius: 4)
                            .fill(.white)
                        RoundedRectangle(cornerRadius: 4)
                            .strokeBorder(borderColor, lineWidth: 2)
                    }
                )
            }

            if hasErrorLabel && isEnabled {
                Text(errorText)
                    .lineLimit(2)
                    .font(.caption)
                    .foregroundColor(.red)
                    .offset(y: hasError ? 0 : -.textFieldHeight)
                    .opacity(hasError ? 1 : 0)
                    .animation(.easeOut(duration: 0.3))
            } else {
                EmptyView()
            }
        }
        .foregroundColor(textColor)
        .onAppear {
            if hasText {
                onChange?(text)
            }
        }
    }
}

Changing text binding doesn't affect binding binding that is passed to the textfield. You should know the relationship between the two bindings correctly. set: block is only triggered when binding has been changed by the textfield.

If your minimum target iOS is 14, you can also use onChange modifier, not your handler, to observe any change of text binding in order to call your onChange as the binding changed. And, you don't have to make binding binding for the textfield. just pass text binding directly.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM