SwiftUI custom TextField. Part 3: InputAccessoryView


Greetings, traveler!

Welcome to the third article in our series about creating a custom and flexible SwiftUI text field. In the previous articles, we covered the basics of UITextField and how to customize its appearance. Here we will discuss how to add an input accessory view to a text field universally and conveniently. 

Let’s start by understanding the inputAccessoryView property of UITextField. This property allows us to display a custom view above the keyboard when the text field becomes the first responder. In simpler terms, it’s a way to add a custom toolbar or button above the keyboard for a better user experience. 

Now, we must get our hands dirty and create a toolbar with a done button that dismisses the keyboard. We have two options: We can build it directly within our CustomTextField body, orfor added versatility, we can create it from the outside and provide our CustomTextField with an instance of this view. We’ll leave this responsibility to a specific factory.

Let’s create a factory protocol. This protocol is a blueprint that defines the structure and behavior of our factory, which is a component responsible for creating instances of our toolbar view. It will have one primary function designed to create a view. Then, we can create a class that conforms to this protocol. This class contains a closure for performing an action, which will be provided within the initializer. We will make a toolbar within this function.

import UIKit

protocol InputAccessoryViewFactoryProtocol: AnyObject {
    func inputAccessoryView() -> UIView
}

final class InputAccessoryViewFactory: InputAccessoryViewFactoryProtocol {
    
    private var action: () -> Void
    
    init(action: @escaping () -> Void) {
        self.action = action
    }
    
   func inputAccessoryView() -> UIView {
        let actionHandler = UIAction { [weak self] _ in
            self?.action()
        }
        let buttons = [
            UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
            UIBarButtonItem(systemItem: .done, primaryAction: actionHandler)
        ]
        let bar = UIToolbar()
        bar.items = buttons
        bar.sizeToFit()
        
        return bar
    }
    
}

First, let’s define a CustomTextField property that will hold the implementation of our protocol. We will create a custom view for the text field using this property inside the makeUIView method.

struct CustomTextField: UIViewRepresentable {
    
    var isUserInteractionEnabled = true
    var isSecureTextEntry = false
    var inputAccessoryViewFactory: InputAccessoryViewFactory?
    
...
func makeUIView(context: Context) -> UITextField {
      let textField = UITextField()
      textField.delegate = context.coordinator
        
      if isFirstResponder {
          textField.becomeFirstResponder()
      }
        
      textField.inputAccessoryView = inputAccessoryViewFactory?.inputAccessoryView()
        
      return textField
}

Secondly, we should handle setting the value for this property. Like last time, we can create a function within the extension of our CustomTextField.

extension CustomTextField {
    
    func disabled(_ disabled: Bool) -> CustomTextField {
        var view = self
        view.isUserInteractionEnabled = !disabled
        return view
    }
    
    func inputAccessoryViewFactory(_ inputAccessoryViewFactory: InputAccessoryViewFactoryProtocol) -> CustomTextField {
        var view = self
        view.inputAccessoryViewFactory = inputAccessoryViewFactory
        return view
    }
    
}

Thirdly, we need to address one issue. After the first successful use, our factory will be immediately deallocated. To avoid this, we require a class that can store a reference to it. Our coordinator would be a suitable candidate for this purpose.

final class Coordinator: NSObject, UITextFieldDelegate {
        
        @Binding var text: String
        private var inputAccessoryViewFactory: InputAccessoryViewFactoryProtocol?
        
        init(
            text: Binding<String>,
            inputAccessoryViewFactory: InputAccessoryViewFactoryProtocol?
        ) {
            
            self._text = text
            self.inputAccessoryViewFactory = inputAccessoryViewFactory
        }
        
        func textField(
            _ textField: UITextField,
            shouldChangeCharactersIn range: NSRange,
            replacementString string: String
        ) -> Bool {
            
           true
        }
        
    }
func makeCoordinator() -> Coordinator {
      Coordinator(
          text: $text,
          inputAccessoryViewFactory: inputAccessoryViewFactory
      )
}
func makeUIView(context: Context) -> UITextField {
      let textField = UITextField()
      textField.delegate = context.coordinator
        
      if isFirstResponder {
          textField.becomeFirstResponder()
      }
        
      textField.inputAccessoryView = inputAccessoryViewFactory?.inputAccessoryView()
      context.coordinator.inputAccessoryViewFactory = inputAccessoryViewFactory
        
      return textField
}

Finally, we can add a toolbar to CustomTextField.

struct ContentView: View {
    @State private var text = ""
    @State private var isTextFieldDisabled = false
    @State private var isFirstResponder = false
    
    var body: some View {
        CustomTextField(text: $text, isFirstResponder: $isFirstResponder)
            .disabled(isTextFieldDisabled)
            .inputAccessoryViewFactory(InputAccessoryViewFactory(action: {
                isFirstResponder = false
            }))
    }
}

Alright then. We’ve worked hard, and we deserve some time off. I would be delighted to see you at the next stage of this tutorial.

Ah, by the way! The complete code is here on GitHub.