SwiftUI - Adding a keyboard toolbar button for only one TextField adds it for all TextFields

11.2k Views Asked by At

Background

I have two TextFields, one of which has a keyboard type of .decimalPad.

Given that there is no 'Done' button when using a decimal pad keyboard to close it, rather like the return key of the standard keyboard, I would like to add a 'Done' button within a toolbar above they keypad only for the decimal keyboard in SwiftUI.

Problem

Adding a .toolbar to any TextField for some reason adds it to all of the TextFields instead! I have tried conditional modifiers, using focussed states and checking for the Field value (but for some reason it is not set when checking, maybe an ordering thing?) and it still adds the toolbar above the keyboard for both TextFields.

How can I only have a .toolbar for my single TextField that accepts digits, and not for the other TextField that accepts a string?

Code

Please note that I've tried to make a minimal example that you can just copy and paste into Xcode and run it for yourself. With Xcode 13.2 there are some issues with displaying a keyboard for TextFields for me, especially within a sheet, so maybe simulator is required to run it properly and bring up the keyboard with cmd+K.

import SwiftUI

struct TestKeyboard: View {
    @State var str: String = ""
    @State var num: Float = 1.2

    @FocusState private var focusedField: Field?
    private enum Field: Int, CaseIterable {
        case amount
        case str
    }

    var body: some View {
        VStack {
            Spacer()
            
            // I'm not adding .toolbar here...
            TextField("A text field here", text: $str)
                .focused($focusedField, equals: .str)

            // I'm only adding .toolbar here, but it still shows for the one above..
            TextField("", value: $num, formatter: FloatNumberFormatter())
                .keyboardType(.decimalPad)
                .focused($focusedField, equals: .amount)
                .toolbar {
                    ToolbarItem(placement: .keyboard) {
                        Button("Done") {
                            focusedField = nil
                        }
                    }
                }

            Spacer()
        }
    }
}

class FloatNumberFormatter: NumberFormatter {
    override init() {
        super.init()
        
        self.numberStyle = .currency        
        self.currencySymbol = "€"
        self.minimumFractionDigits = 2
        self.maximumFractionDigits = 2
        self.locale = Locale.current
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
}

// So you can preview it quickly
struct TestKeyboard_Previews: PreviewProvider {
    static var previews: some View {
        TestKeyboard()
    }
}

7

There are 7 best solutions below

2
On

There is work. But the other TextField will still display toolbar.

--- update --- Hi, I updated the code to use ViewModifier to make the code easier to use and this time the code does compile and run >_<

struct ToolbarItemWithShow<Toolbar>: ViewModifier where Toolbar: View {
    var show: Bool
    let toolbar: Toolbar
    let placement: ToolbarItemPlacement
    
    func body(content: Content) -> some View {
        content.toolbar {
            ToolbarItemGroup(placement: placement) {
                ZStack(alignment: .leading) {
                    if show {
                        HStack { toolbar }
                            .frame(width: UIScreen.main.bounds.size.width - 12)
                    }
                }
            }
        }
    }
}

extension View {
    func keyboardToolbar<ToolBar>(_ show: Bool, @ViewBuilder toolbar: () -> ToolBar) -> some View where ToolBar: View {
        modifier(ToolbarItemWithShow(show: show, toolbar: toolbar(), placement: .keyboard))
    }
}


struct ContentView: View {
    private enum Field: Hashable {
        case name
        case age
        case gender
    }
    
    @State var name = "Ye"
    @State var age = "14"
    @State var gender = "man"
    
    @FocusState private var focused: Field?

    
    var body: some View {
        VStack {
            TextField("Name", text: $name)
                .focused($focused, equals: .name)
                .keyboardToolbar(focused == .name) {
                    Text("Input Name")
                }
            
            TextField("Age", text: $age)
                .focused($focused, equals: .age)
                .keyboardToolbar(focused == .age) {
                    Text("Input Age")
                }

            TextField("Gender", text: $gender)
                .focused($focused, equals: .gender)
                .keyboardToolbar(focused == .gender) {
                    Text("Input Sex")
                }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

--- old ---

struct TextFieldWithToolBar<Label, Toolbar>: View where Label: View, Toolbar: View {
    @Binding public var text: String
    public let toolbar: Toolbar?

    @FocusState private var focus: Bool

    var body: some View {
        TextField(text: $text, label: { label })
            .focused($focus)
            .toolbar {
                ToolbarItemGroup(placement: .keyboard) {
                    ZStack(alignment: .leading) {
                        if focus {
                            HStack {
                                toolbar
                                Spacer()
                                Button("Done") {
                                    focus = false
                                }
                            }
                            .frame(width: UIScreen.main.bounds.size.width - 12)
                        }
                    }
                }
            }
    }
}

TextFieldWithToolBar("Name", text: $name)
TextFieldWithToolBar("Name", text: $name){
    Text("Only Brand")
}
TextField("Name", "Set The Name", text: $name)

with Done with Toolbar without

2
On

Try to make toolbar content conditional and move toolbar outside, like below. (No possibility to test now - just idea)

Note: test on real device

var body: some View {
    VStack {
        Spacer()
        
        TextField("A text field here", text: $str)
            .focused($focusedField, equals: .str)

        TextField("", value: $num, formatter: FloatNumberFormatter())
            .focused($focusedField, equals: .amount)
            .keyboardType(.decimalPad)

        Spacer()
    }
    .toolbar {          // << here !!
        ToolbarItem(placement: .keyboard) {
            if field == .amount {             // << here !!
               Button("Done") {
                  focusedField = nil
               }
            }
        }
    }

}
1
On

I've found wrapping each TextField in its own NavigationView gives each its own context and thus a unique toolbar. It feels not right and I've seen constraint warnings in the console. Use something like this:

   var body: some View {
    VStack {
        Spacer()
        
        // I'm not adding .toolbar here...
        NavigationView {
          TextField("A text field here", text: $str)
            .focused($focusedField, equals: .str)
        }
        // I'm only adding .toolbar here, but it still shows for the one above..
        NavigationView {
          TextField("", value: $num, formatter: FloatNumberFormatter())
            .keyboardType(.decimalPad)
            .focused($focusedField, equals: .amount)
            .toolbar {
                ToolbarItem(placement: .keyboard) {
                    Button("Done") {
                        focusedField = nil
                    }
                }
            }
        }
        Spacer()
    }
}
1
On

Using introspect you can do something like this in any part in your View:

.introspectTextField { textField in
                    textField.inputAccessoryView = UIView.getKeyboardToolbar {
                        textField.resignFirstResponder()
                    }
            }

and for the getKeyboardToolbar:

extension UIView {

static func getKeyboardToolbar( _ callback: @escaping (()->()) ) -> UIToolbar {
        let toolBar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.size.width, height: 44))
        let doneButton = CustomBarButtonItem(title: "Done".localized, style: .done) { _ in
            callback()
        }
        
        let space = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
        toolBar.items = [space, doneButton]
        
        return toolBar
    }

}

and for the CustomBarButtonItem this is a bar button item that takes a closure

import UIKit

class CustomBarButtonItem: UIBarButtonItem {
    typealias ActionHandler = (UIBarButtonItem) -> Void

    private var actionHandler: ActionHandler?

    convenience init(image: UIImage?, style: UIBarButtonItem.Style, actionHandler: ActionHandler?) {
        self.init(image: image, style: style, target: nil, action: #selector(barButtonItemPressed(sender:)))
        target = self
        self.actionHandler = actionHandler
    }

    convenience init(title: String?, style: UIBarButtonItem.Style, actionHandler: ActionHandler?) {
        self.init(title: title, style: style, target: nil, action: #selector(barButtonItemPressed(sender:)))
        target = self
        self.actionHandler = actionHandler
    }

    convenience init(barButtonSystemItem systemItem: UIBarButtonItem.SystemItem, actionHandler: ActionHandler?) {
        self.init(barButtonSystemItem: systemItem, target: nil, action: #selector(barButtonItemPressed(sender:)))
        target = self
        self.actionHandler = actionHandler
    }

    @objc func barButtonItemPressed(sender: UIBarButtonItem) {
        actionHandler?(sender)
    }
}
1
On

This is my solution:

func textFieldSection(title: String,
                      text: Binding<String>,
                      keyboardType: UIKeyboardType,
                      focused: FocusState<Bool>.Binding,
                      required: Bool) -> some View {
    TextField(
        vm.placeholderText(isRequired: required),
        text: text
    )
    .focused(focused)
    .toolbar {
        ToolbarItemGroup(placement: .keyboard) {
            if focused.wrappedValue {
                Spacer()
                Button {
                    focused.wrappedValue = false
                } label: {
                    Text("Done")
                }
            }
        }
    }
}

For my project I have five TextField views on one View, so I created this method in the View's extension.

I pass the unique FocusState<Bool>.Binding value and use it in the ToolbarItemGroup closure to determine if we should display the content (Spacer, Button). If the particular TextField is focused, we display the toolbar content (all other unfocused TextFields won't).

0
On

Number Pad return solution in SwiftUI, Tool bar button over keyboard, Focused Field

struct NumberOfBagsView:View{
   @FocusState var isInputActive: Bool
   @State var phoneNumber:String = ""
   TextField("Place holder",
                  text: $phoneNumber,
                  onEditingChanged: { _ in
                
            //do actions while writing something in text field like text limit
            
        })
        .keyboardType(.numberPad)
        .focused($isInputActive)
        .toolbar {
            ToolbarItem(placement: .keyboard) {

                Button("Done") {
                    print("done clicked")
                    isInputActive = false
                }
                
            }
        }
}
1
On

I tried it a lot but I ended up in the below one.

    .focused($focusedField, equals: .zip)
                .toolbar{
                    ToolbarItem(placement: .keyboard) {
                        switch focusedField{
                        case .zip:
                            HStack{
                                Spacer()
                                Button("Done"){
                                    focusedField = nil
                                }
                            }
                        default:
                                Text("")
                        }
                    }
                }