Search code examples
iosswiftkeyboardcustom-keyboard

A Swift example of Custom Views for Data Input (custom in-app keyboard)


Goal

I want to make a custom keyboard that is only used within my app, not a system keyboard that needs to be installed.

What I have read and tried

Documentation

The first article above states:

Make sure a custom, systemwide keyboard is indeed what you want to develop. To provide a fully custom keyboard for just your app or to supplement the system keyboard with custom keys in just your app, the iOS SDK provides other, better options. Read about custom input views and input accessory views in Custom Views for Data Input in Text Programming Guide for iOS.

That is what led me to the second article above. However, that article did not have enough detail to get me started.

Tutorials

I was able to get a working keyboard from the second tutorial in the list above. However, I couldn't find any tutorials that showed how to make an in app only keyboard as described in the Custom Views for Data Input documentation.

Stack Overflow

I also asked (and answered) these questions on my way to answering the current question.

Question

Does anyone have a minimal example (with even one button) of an in app custom keyboard? I am not looking for a whole tutorial, just a proof of concept that I can expand on myself.


Solution

  • The key is to use the existing UIKeyInput protocol, to which UITextField already conforms. Then your keyboard view need only to send insertText() and deleteBackward() to the control.

    The following example creates a custom numeric keyboard:

    class DigitButton: UIButton {
        var digit: Int = 0
    }
    
    class NumericKeyboard: UIView {
        weak var target: (UIKeyInput & UITextInput)?
        var useDecimalSeparator: Bool
    
        var numericButtons: [DigitButton] = (0...9).map {
            let button = DigitButton(type: .system)
            button.digit = $0
            button.setTitle("\($0)", for: .normal)
            button.titleLabel?.font = .preferredFont(forTextStyle: .largeTitle)
            button.setTitleColor(.black, for: .normal)
            button.layer.borderWidth = 0.5
            button.layer.borderColor = UIColor.darkGray.cgColor
            button.accessibilityTraits = [.keyboardKey]
            button.addTarget(self, action: #selector(didTapDigitButton(_:)), for: .touchUpInside)
            return button
        }
    
        var deleteButton: UIButton = {
            let button = UIButton(type: .system)
            button.setTitle("⌫", for: .normal)
            button.titleLabel?.font = .preferredFont(forTextStyle: .largeTitle)
            button.setTitleColor(.black, for: .normal)
            button.layer.borderWidth = 0.5
            button.layer.borderColor = UIColor.darkGray.cgColor
            button.accessibilityTraits = [.keyboardKey]
            button.accessibilityLabel = "Delete"
            button.addTarget(self, action: #selector(didTapDeleteButton(_:)), for: .touchUpInside)
            return button
        }()
    
        lazy var decimalButton: UIButton = {
            let button = UIButton(type: .system)
            let decimalSeparator = Locale.current.decimalSeparator ?? "."
            button.setTitle(decimalSeparator, for: .normal)
            button.titleLabel?.font = .preferredFont(forTextStyle: .largeTitle)
            button.setTitleColor(.black, for: .normal)
            button.layer.borderWidth = 0.5
            button.layer.borderColor = UIColor.darkGray.cgColor
            button.accessibilityTraits = [.keyboardKey]
            button.accessibilityLabel = decimalSeparator
            button.addTarget(self, action: #selector(didTapDecimalButton(_:)), for: .touchUpInside)
            return button
        }()
    
        init(target: UIKeyInput & UITextInput, useDecimalSeparator: Bool = false) {
            self.target = target
            self.useDecimalSeparator = useDecimalSeparator
            super.init(frame: .zero)
            configure()
        }
    
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
    }
    
    // MARK: - Actions
    
    extension NumericKeyboard {
        @objc func didTapDigitButton(_ sender: DigitButton) {
            insertText("\(sender.digit)")
        }
    
        @objc func didTapDecimalButton(_ sender: DigitButton) {
            insertText(Locale.current.decimalSeparator ?? ".")
        }
    
        @objc func didTapDeleteButton(_ sender: DigitButton) {
            target?.deleteBackward()
        }
    }
    
    // MARK: - Private initial configuration methods
    
    private extension NumericKeyboard {
        func configure() {
            autoresizingMask = [.flexibleWidth, .flexibleHeight]
            addButtons()
        }
    
        func addButtons() {
            let stackView = createStackView(axis: .vertical)
            stackView.frame = bounds
            stackView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
            addSubview(stackView)
    
            for row in 0 ..< 3 {
                let subStackView = createStackView(axis: .horizontal)
                stackView.addArrangedSubview(subStackView)
    
                for column in 0 ..< 3 {
                    subStackView.addArrangedSubview(numericButtons[row * 3 + column + 1])
                }
            }
    
            let subStackView = createStackView(axis: .horizontal)
            stackView.addArrangedSubview(subStackView)
    
            if useDecimalSeparator {
                subStackView.addArrangedSubview(decimalButton)
            } else {
                let blank = UIView()
                blank.layer.borderWidth = 0.5
                blank.layer.borderColor = UIColor.darkGray.cgColor
                subStackView.addArrangedSubview(blank)
            }
    
            subStackView.addArrangedSubview(numericButtons[0])
            subStackView.addArrangedSubview(deleteButton)
        }
    
        func createStackView(axis: NSLayoutConstraint.Axis) -> UIStackView {
            let stackView = UIStackView()
            stackView.axis = axis
            stackView.alignment = .fill
            stackView.distribution = .fillEqually
            return stackView
        }
    
        func insertText(_ string: String) {
            guard let range = target?.selectedRange else { return }
    
            if let textField = target as? UITextField, textField.delegate?.textField?(textField, shouldChangeCharactersIn: range, replacementString: string) == false {
                return
            }
    
            if let textView = target as? UITextView, textView.delegate?.textView?(textView, shouldChangeTextIn: range, replacementText: string) == false {
                return
            }
    
            target?.insertText(string)
        }
    }
    
    // MARK: - UITextInput extension
    
    extension UITextInput {
        var selectedRange: NSRange? {
            guard let textRange = selectedTextRange else { return nil }
    
            let location = offset(from: beginningOfDocument, to: textRange.start)
            let length = offset(from: textRange.start, to: textRange.end)
            return NSRange(location: location, length: length)
        }
    }
    

    Then you can:

    textField.inputView = NumericKeyboard(target: textField)
    

    That yields:

    enter image description here

    Or, if you want a decimal separator, too, you can:

    textField.inputView = NumericKeyboard(target: textField, useDecimalSeparator: true)
    

    The above is fairly primitive, but it illustrates the idea: Make you own input view and use the UIKeyInput protocol to communicate keyboard input to the control.

    Also please note the use of accessibilityTraits to get the correct “Spoken Content” » “Speak Screen” behavior. And if you use images for your buttons, make sure to set accessibilityLabel, too.