swiftuiuiviewcontrolleruitextfielduitextfielddelegate

iOS - Control Input to UITextField from SwiftUI Buttons


I have a custom keypad that works with a custom text field. It looks great on the iPhone but not so great on the iPad. I want to control the text field with the buttons of the keypad but not have the keypad as a custom keyboard.

Here is my custom text field:

struct WrappedTextField: UIViewRepresentable {
    final class ViewModel {
        var placeholder: String
        var text: Binding<String>
        var font: UIFont?
        var textColor: UIColor?
        var viewController: WrappedTextFieldViewController?
        
        init(_ placeholder: String, _ text: Binding<String>) {
            self.placeholder = placeholder
            self.text = text
        }
    }
    
    private var model: ViewModel
    
    init(_ placeholder: String, text: Binding<String>) {
        model = ViewModel(placeholder, text)
    }
    
    // MARK: Modifiers
    func font(_ font: UIFont) -> WrappedTextField {
        model.font = font
        return self
    }
    
    func textColor(_ textColor: Color) -> WrappedTextField {
        model.textColor = UIColor(textColor)
        return self
    }
    
    func viewController(_ viewController: WrappedTextFieldViewController) -> WrappedTextField {
        model.viewController = viewController
        return self
    }
    
    // MARK: Lifecycle Methods
    func makeUIView(context: Context) -> UITextField {
        let textField = UITextField()
        textField.inputView = UIView()
        textField.autocapitalizationType = .allCharacters
        textField.autocorrectionType = .no
        textField.textAlignment = .right
        textField.delegate = context.coordinator
        textField.becomeFirstResponder()
        
        if let viewController = model.viewController {
            textField.inputView = viewController.view
            viewController.addTextField(textField)
        }
        
        return textField
    }
    
    func updateUIView(_ uiView: UITextField, context: Context) {
        uiView.placeholder = model.placeholder
        uiView.text = model.text.wrappedValue
        uiView.font = model.font
        uiView.textColor = model.textColor
        uiView.setContentHuggingPriority(.defaultHigh, for: .vertical)
        uiView.setContentHuggingPriority(.defaultLow, for: .horizontal)
    }
    
    func makeCoordinator() -> WrappedTextField.Coordinator {
        return Coordinator(self)
    }
}

extension WrappedTextField {
    final class Coordinator: NSObject, UITextFieldDelegate {
        var parent: WrappedTextField
        
        init(_ parent: WrappedTextField) {
            self.parent = parent
        }
        
        func textFieldDidChangeSelection(_ textField: UITextField) {
            if let text = textField.text {
                parent.model.text.wrappedValue = text
            }
        }
    }
}

Here is the view controller that implements the custom keyboard:

final class WrappedTextFieldViewController: UIHostingController<KeypadView> {
    convenience init() {
        self.init(rootView: KeypadView())
    }

    private override init(rootView: KeypadView) {
        super.init(rootView: rootView)
        view.frame = CGRect(x: 0, y: 0, width: 0, height: 370)
    }

    @objc required dynamic init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented. Exiting...")
    }
    
    func addTextField(_ textField: UITextField) {
        rootView.wrappedTextField = textField
    }
}

Here is my keypad view:

struct KeypadView: View {
    @State private var isDisabled: Bool
    
    var wrappedTextField: UITextField
    
    init() {
        wrappedTextField = UITextField()
        
        if let text = wrappedTextField.text {
            _isDisabled = State(initialValue: text.isEmpty)
        } else {
            _isDisabled = State(initialValue: true)
        }
    }
    
    var body: some View {
        HStack(spacing: 8) {
            VStack(spacing: 8) {
                Button("N") { clickButton("N") }
                Button("S") { clickButton("S") }
                Button("E") { clickButton("E") }
                Button("W") { clickButton("W") }
            }
            
            VStack(spacing: 8) {
                Button("1") { clickButton("1") }
                Button("4") { clickButton("4") }
                Button("7") { clickButton("7") }
                Button(".") { clickButton(".") }
            }
            
            VStack(spacing: 8) {
                Button("2") { clickButton("2") }
                Button("5") { clickButton("5") }
                Button("8") { clickButton("8") }
                Button("0") { clickButton("0") }
            }
            
            VStack(spacing: 8) {
                Button("3") { clickButton("3") }
                Button("6") { clickButton("6") }
                Button("9") { clickButton("9") }
                Button("Del") { clickDeleteButton() }
                    .disabled(isDisabled)
            }
        }
    }
    
    func clickButton(_ buttonText: String) {
        if let text = wrappedTextField.text {
            // Make sure the number is inserted at the cursor.
            if let selectedRange = wrappedTextField.selectedTextRange {
                let cursorStart = wrappedTextField.offset(from: wrappedTextField.beginningOfDocument, to: selectedRange.start)
                let cursorEnd = wrappedTextField.offset(from: wrappedTextField.beginningOfDocument, to: selectedRange.end)

                wrappedTextField.text = String(text.prefix(cursorStart)) + buttonText + String(text.suffix(text.count - cursorEnd))
                
                if let newPosition = wrappedTextField.position(from: selectedRange.start, offset: 1) {
                    wrappedTextField.selectedTextRange = wrappedTextField.textRange(from: newPosition, to: newPosition)
                }

                isDisabled = disableDeleteButton()
            }
        }
    }
    
    func clickDeleteButton() {
        self.wrappedTextField.deleteBackward()
        
        isDisabled = disableDeleteButton()
    }
    
    func disableDeleteButton() -> Bool {
        if let text = wrappedTextField.text {
            return text.isEmpty
        } else {
            return true
        }
    }
}

My content view for testing:

struct ContentView: View {
    @State private var text: String = ""
    
    var body: some View {
        VStack {
            HStack {
                Text("Label Here:")
                
                WrappedTextField("Press Buttons", text: $text)
                    .viewController(WrappedTextFieldViewController())
            }
            
            KeypadView()
        }
    }
}

What I want is a single view with a hidden/disabled keyboard. I know I can keep the keyboard hidden by changing textField.inputView = viewController.view to textField.inputView = UIView(). But the buttons in the view don't interact with the UITextField.

I don't understand how to link the buttons from the KeypadView to the WrappedTextField when they're not part of the input view as set by textField.inputView = viewController.view. The wrappedTextField variable updates properly in the clickButton function but never propagates to the actual WrappedTextField. I suspect I need to change from a UIHostingController to something else, but I have very little UIKit experience; I started iOS programming with SwiftUI.


Solution

  • Well, another day of working on it and fiddling around and I solved it myself. Posted here so people can see how to create an input view with a custom UITextField and input from a SwiftUI Button (or more than one). The code below creates a fully working custom text field that takes input from multiple SwiftUI buttons.

    I wound up referencing these answers to create this solution:

    SwiftUI, change the TextField string from the toolbar button

    Getting and Setting Cursor Position of UITextField and UITextView in Swift

    TextHolder is the model that allows communication between the SwiftUI Button and the custom UITextField:

    final class TextHolder: ObservableObject {
        // The shared instance of `TextHolder` for access across the frameworks.
        static let shared: TextHolder = .init()
        
        // The currently user selected text range.
        @Published var start: Int = 0
        @Published var end: Int = 0
        
        @Published var insertionPoint: Int? = nil
    }
    

    TextFieldRepresentable is the SwiftUI wrapper for the UITextField:

    struct TextFieldRepresentable: UIViewRepresentable {
        final class ViewModel {
            var placeholder: String
            var text: Binding<String>
            var font: UIFont?
            var textColor: UIColor?
            
            init(_ placeholder: String, _ text: Binding<String>) {
                self.placeholder = placeholder
                self.text = text
            }
        }
        
        private var model: ViewModel
        
        init(_ placeholder: String, text: Binding<String>) {
            model = ViewModel(placeholder, text)
        }
        
        // MARK: Modifiers
        func font(_ font: UIFont) -> TextFieldRepresentable {
            model.font = font
            return self
        }
        
        func textColor(_ textColor: UIColor) -> TextFieldRepresentable {
            model.textColor = textColor
            return self
        }
        
        // MARK: Lifecycle Methods
        func makeUIView(context: Context) -> UITextField {
            let textField = UITextField()
            
            textField.placeholder = model.placeholder
            textField.text = model.text.wrappedValue
            textField.inputView = UIView()
            textField.inputAccessoryView = UIView()
            textField.autocapitalizationType = .allCharacters
            textField.autocorrectionType = .no
            textField.textAlignment = .right
            textField.delegate = context.coordinator
            textField.becomeFirstResponder()
            
            return textField
        }
        
        func updateUIView(_ uiView: UITextField, context: Context) {
            // Update the actual TextField
            uiView.text = model.text.wrappedValue
            uiView.font = model.font
            uiView.textColor = model.textColor
            uiView.setContentHuggingPriority(.defaultHigh, for: .vertical)
            uiView.setContentHuggingPriority(.defaultLow, for: .horizontal)
            
            DispatchQueue.main.async {
                // Move the cursor forward one if SwiftUI just changed the value
                if let insertionPoint = TextHolder.shared.insertionPoint {
                    // only if the new position is valid
                    if let newPosition = uiView.position(from: uiView.beginningOfDocument, offset: insertionPoint + 1) {
                        // set the new position
                        uiView.selectedTextRange = uiView.textRange(from: newPosition, to: newPosition)
                    }
                    
                    TextHolder.shared.insertionPoint = nil
                }
            }
        }
        
        func makeCoordinator() -> Coordinator {
            Coordinator(self)
        }
        
        class Coordinator: NSObject, UITextFieldDelegate {
            var parent: TextFieldRepresentable
            var textFieldChangedHandler: ((String)->Void)?
            
            init(_ parent: TextFieldRepresentable) {
                self.parent = parent
            }
            
            func updateTextHolder(_ textField: UITextField) {
                DispatchQueue.main.async {
                    if let selectedRange = textField.selectedTextRange {
                        TextHolder.shared.start = textField.offset(from: textField.beginningOfDocument, to: selectedRange.start)
                        TextHolder.shared.end = textField.offset(from: textField.beginningOfDocument, to: selectedRange.end)
                    }
                }
            }
            
            func textFieldDidBeginEditing(_ textField: UITextField) {
                // Start with all text selected.
                textField.selectedTextRange = textField.textRange(from: textField.beginningOfDocument, to: textField.endOfDocument)
                
                updateTextHolder(textField)
            }
            
            func textFieldDidChangeSelection(_ textField: UITextField) {
                updateTextHolder(textField)
            }
            
            func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
                if let currentValue = textField.text as NSString? {
                    let proposedValue = currentValue.replacingCharacters(in: range, with: string)
                    textFieldChangedHandler?(proposedValue as String)
                }
                
                return true
            }
        }
    }
    

    KeypadView is the input view that I plan to use in the popover:

    struct KeypadView: View {
        @StateObject private var holder: TextHolder = .shared
        @State private var isDisabled: Bool
        
        var label: String
        @Binding var inputText: String
        var placeholder: String
        
        init(_ label: String, text: Binding<String>, placeholder: String = "Required") {
            self._isDisabled = State(initialValue: false)
            self.label = label
            self._inputText = text
            self.placeholder = placeholder
        }
        
        var body: some View {
            VStack {
                HStack {
                    Text(label)
                    
                    TextFieldRepresentable(placeholder, text: $inputText)
                        .textColor(.systemBlue)
                }
                .frame(width: 284)
                .padding(.vertical)
                
                HStack(spacing: 8) {
                    VStack(spacing: 8) {
                        Button("N") { clickButton("N") }
                        Button("S") { clickButton("S") }
                        Button("E") { clickButton("E") }
                        Button("W") { clickButton("W") }
                    }
                    
                    VStack(spacing: 8) {
                        Button("1") { clickButton("1") }
                        Button("4") { clickButton("4") }
                        Button("7") { clickButton("7") }
                        Button(".") { clickButton(".") }
                    }
                    
                    VStack(spacing: 8) {
                        Button("2") { clickButton("2") }
                        Button("5") { clickButton("5") }
                        Button("8") { clickButton("8") }
                        Button("0") { clickButton("0") }
                    }
                    
                    VStack(spacing: 8) {
                        Button("3") { clickButton("3") }
                        Button("6") { clickButton("6") }
                        Button("9") { clickButton("9") }
                        Button("x") { clickDeleteButton() }
                            .disabled(isDisabled)
                    }
                }
                .padding(.vertical)
            }
        }
        
        func clickButton(_ buttonText: String) {
            // Necessary to move cursor to correct location
            let insertionPoint = inputText.index(inputText.startIndex, offsetBy: holder.start)
            TextHolder.shared.insertionPoint = inputText.distance(from: inputText.startIndex, to: insertionPoint)
            
            // Insert the text
            inputText = String(inputText.prefix(holder.start)) + buttonText + String(inputText.suffix(inputText.count - holder.end))
            
            isDisabled = disableDeleteButton()
        }
        
        func clickDeleteButton() {
            let deleteStart = inputText.index(inputText.startIndex, offsetBy: holder.start)
            let deleteEnd = inputText.index(inputText.startIndex, offsetBy: holder.end)
            
            if deleteStart == deleteEnd {
                let correctOffset = holder.start - 1 > 0 ? holder.start - 1 : 0
                let deleteOne = inputText.index(inputText.startIndex, offsetBy: correctOffset)
                
                inputText.remove(at: deleteOne)
            } else {
                inputText.removeSubrange(deleteStart..<deleteEnd)
            }
            
            isDisabled = disableDeleteButton()
        }
        
        func disableDeleteButton() -> Bool {
            inputText.isEmpty
        }
    }
    

    Here is an example usage:

    struct ContentView: View {
        @State private var text: String = "678"
        
        var body: some View {
            KeypadView("Test", text: $text)
        }
    }