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.
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)
}
}