Skip to content

vinceplusplus/uikit-textfield

Repository files navigation

uikit-textfield

uikit-textfield offers UIKitTextField which makes using UITextField in SwiftUI a breeze.

From data binding, focus binding, handling callbacks from UITextFieldDelegate, custom UITextField subclass, horizontal/vertical stretching, inputView/inputAccessoryView, to extra configuration, UIKitTextField is the complete solution.

Installation

To install through Xcode, follow the official guide to add the following your Xcode project

https://github.com/vinceplusplus/uikit-textfield.git

To install through Swift Package Manager, add the following as package dependency and target dependency respectively

.package(url: "https://github.com/vinceplusplus/uikit-textfield.git", from: "2.0")
.product(name: "UIKitTextField", package: "uikit-textfield")

Usage

All configurations are done using UIKitTextField.Configuration and are in turn passed to UIKitTextField's .init(config: )

Data binding

Text

func value(text: Binding<String>) -> Self
@State var name: String = ""

var body: some View {
  VStack(alignment: .leading) {
    UIKitTextField(
      config: .init()
        .value(text: $name)
    )
    .border(Color.black)
    Text("\(name.isEmpty ? "Please enter your name above" : "Hello \(name)")")
  }
  .padding()
}

same height

Formatted value

@available(iOS 15.0, *)
func value<F>(value: Binding<F.FormatInput>, format: F) -> Self
where
  F: ParseableFormatStyle,
  F.FormatOutput == String

@available(iOS 15.0, *)
func value<F>(value: Binding<F.FormatInput?>, format: F) -> Self
where
  F: ParseableFormatStyle,
  F.FormatOutput == String
  
func value<V>(value: Binding<V>, formatter: Formatter) -> Self

func value<V>(value: Binding<V?>, formatter: Formatter) -> Self

When the text field is not the first responder, it will take value from the binding and display in the specified formatted way

When the text field is the first responder, every change will try to update the binding. If it's a binding of a non optional value, an invalid input will preserve the last value. If it's a binding of an optional value, an invalid input will set the value to nil.

@State var value: Int = 0

// ...

Text("Enter a number:")

UIKitTextField(
  config: .init()
    .value(value: $value, format: .number)
    //.value(value: $value, formatter: NumberFormatter())
)
.border(Color.black)

// NOTE: avoiding the formatting behavior which comes from Text()
Text("Your input: \("\(value)")")

same height

Custom Data Binding

func value(
  updateViewValue: @escaping (_ textField: UITextFieldType) -> Void,
  onViewValueChanged: @escaping (_ textField: UITextFieldType) -> Void
) -> Self

Placeholder

func placeholder(_ placeholder: String?) -> Self
UIKitTextField(
  config: .init()
    .placeholder("some placeholder...")
)

Font

func font(_ font: UIFont?) -> Self

Note that since there are no ways to convert back from a Font to UIFont, the configuration can only take a UIFont

UIKitTextField(
  config: .init()
    .font(.systemFont(ofSize: 16))
)

Text Color

func textColor(_ color: Color?) -> Self
UIKitTextField(
  config: .init()
    .textColor(.red)
)

Text Alignment

func textAlignment(_ textAlignment: NSTextAlignment?) -> Self
UIKitTextField(
  config: .init()
    .textAlignment(.center)
)

Clear on Begin Editing

func clearsOnBeginEditing(_ clearsOnBeginEditing: Bool?) -> Self
UIKitTextField(
  config: .init()
    .clearsOnBeginEditing(true)
)

Clear on Insertion

func clearsOnInsertion(_ clearsOnInsertion: Bool?) -> Self
UIKitTextField(
  config: .init()
    .clearsOnInsertion(true)
)

Clear Button Mode

func clearButtonMode(_ clearButtonMode: UITextField.ViewMode?) -> Self
UIKitTextField(
  config: .init()
    .clearButtonMode(.always)
)

Focus Binding

Similar to @FocusState, we could use an orindary @State to do a 2 way focus binding

Bool

func focused(_ binding: Binding<Bool>) -> Self
@State var name = ""
@State var isFocused = false

VStack {
  Text("Your name:")
  UIKitTextField(
    config: .init()
      .value(text: $name)
      .focused($isFocused)
  )
  Button {
    if name.isEmpty {
      isFocused = true
    } else {
      isFocused = false
    }
  } label: {
    Text("Submit")
  }
}

Hashable

func focused<Value>(_ binding: Binding<Value?>, equals value: Value?) -> Self where Value: Hashable
enum Field {
  case firstName
  case lastName
}

@State var firstName = ""
@State var lastName = ""
@State var focusedField: Field?

VStack {
  Text("First Name:")
  UIKitTextField(
    config: .init()
      .value(text: $firstName)
      .focused(focusedField, equals: .firstName)
  )
  Text("Last Name:")
  UIKitTextField(
    config: .init()
      .value(text: $lastName)
      .focused(focusedField, equals: .lastName)
  )
  Button {
    if firstName.isEmpty {
      focusedField = .firstName
    } else if lastName.isEmpty {
      focusedField = .lastName
    } else {
      focusedField = nil
    }
  } label: {
    Text("Submit")
  }
}

Stretching

By default, UIKitTextField will stretch horizontally but not vertically

func stretches(horizontal: Bool, vertical: Bool) -> Self
UIKitTextField(
  config: .init {
    PaddedTextField()
  }
    .stretches(horizontal: true, vertical: false)
)
.border(Color.black)

Note that PaddedTextField is just a simple internally padded UITextField, see more in custom init

horizontal stretching

UIKitTextField(
  config: .init {
    PaddedTextField()
  }
    .stretches(horizontal: false, vertical: false)
)
.border(Color.black)

no stretching

Input View / Input Accessory View

Supporting UITextField.inputView and UITextField.inputAccessoryView by accepting a user defined SwiftUI view for each of them

func inputView(content: InputViewContent<UITextFieldType>) -> Self

func inputAccessoryView(content: InputViewContent<UITextFieldType>) -> Self
VStack(alignment: .leading) {
  UIKitTextField(
    config: .init {
      PaddedTextField()
    }
      .placeholder("Enter your expression")
      .value(text: $expression)
      .focused($isFocused)
      .inputView(content: .view { uiTextField in
        KeyPad(uiTextField: uiTextField, onEvaluate: onEvaluate)
      })
      .shouldReturn { _ in
        onEvaluate()
        return false
      }
  )
  .padding(4)
  .border(Color.black)
  
  Text("Result: \(result)")
  
  Divider()

  Button {
    isFocused = false
  } label: {
    Text("Dismiss")
  }
}
.padding()

Implementation of KeyPad can be found in InputViewPage from the example code. But the idea is to accept a UITextField parameter and render some buttons that do uiTextField.insertText("...") or uiTextField.deleteBackward(), like the following:

struct CustomKeyboard: View {
  let uiTextField: UITextField
  var body: some View {
    VStack {
      HStack {
        Button {
          uiTextField.insertText("1")
        } label: {
          Text("1")
        }
        Button {
          uiTextField.insertText("2")
        } label: {
          Text("2")
        }
        Button {
          uiTextField.insertText("3")
        } label: {
          Text("3")
        }
      }
      HStack { /* ... */ }
      // ...
    }
  }
}

input view

Custom Text Field Class

init(_ makeUITextField: @escaping () -> UITextFieldType)

A common use case of a UITextField subclass is to provide some internal padding which is also tappable. The following example demonstrates some extra leading padding to accomodate even an icon image

class CustomTextField: BaseUITextField {
  let padding = UIEdgeInsets(top: 4, left: 8 + 32 + 8, bottom: 4, right: 8)
  public override func textRect(forBounds bounds: CGRect) -> CGRect {
    super.textRect(forBounds: bounds).inset(by: padding)
  }
  public override func editingRect(forBounds bounds: CGRect) -> CGRect {
    super.editingRect(forBounds: bounds).inset(by: padding)
  }
}

UIKitTextField(
  config: .init {
    CustomTextField()
  }
    .focused($isFocused)
    .textContentType(.emailAddress)
    .keyboardType(.emailAddress)
    .autocapitalizationType(UITextAutocapitalizationType.none)
    .autocorrectionType(.no)
)
.background(alignment: .leading) {
  HStack(spacing: 0) {
    Color.clear.frame(width: 8)
    ZStack {
      Image(systemName: "mail")
    }
    .frame(width: 32)
  }
}
.border(Color.black)

custom text field class

UITextFieldType needs to conform to UITextFieldProtocol which is shown below:

public protocol UITextFieldProtocol: UITextField {
  var inputViewController: UIInputViewController? { get set }
  var inputAccessoryViewController: UIInputViewController? { get set }
}

Basically, it needs have inputViewController and inputAccessoryViewController writable so the support for custom input view and custom input accessory view will work

For most use cases, BaseUITextField, which provides baseline implementation of UITextFieldProtocol, can be subclassed to add more user defined behavior

Extra Configuration

func configure(handler: @escaping (_ uiTextField: UITextFieldType) -> Void) -> Self

If there are configurations that UIKitTextField doesn't support out of the box, this is the place where we could add them. The extra configuration will be executed at the end of updateUIView() after applying all supported configuration (like data binding, etc)

class PaddedTextField: BaseUITextField {
  var padding = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) {
    didSet {
      setNeedsLayout()
    }
  }
  
  public override func textRect(forBounds bounds: CGRect) -> CGRect {
    super.textRect(forBounds: bounds).inset(by: padding)
  }
  
  public override func editingRect(forBounds bounds: CGRect) -> CGRect {
    super.editingRect(forBounds: bounds).inset(by: padding)
  }
}

@State var text = "some text..."
@State var pads = true

var body: some View {
  VStack {
    Toggle("Padding", isOn: $pads)
    UIKitTextField(
      config: .init {
        PaddedTextField()
      }
        .value(text: $text)
        .configure { uiTextField in
          uiTextField.padding = pads ? .init(top: 4, left: 8, bottom: 4, right: 8) : .zero
        }
    )
    .border(Color.black)
  }
  .padding()
}

The above example provides a button to toggle internal padding

padding toggled on

padding toggled off

UITextFieldDelegate

UITextFieldDelegate is fully supported

func shouldBeginEditing(handler: @escaping (_ uiTextField: UITextFieldType) -> Bool) -> Self

func onBeganEditing(handler: @escaping (_ uiTextField: UITextFieldType) -> Void) -> Self

func shouldEndEditing(handler: @escaping (_ uiTextField: UITextFieldType) -> Bool) -> Self

func onEndedEditing(handler: @escaping (_ uiTextField: UITextFieldType, _ reason: UITextField.DidEndEditingReason) -> Void) -> Self

func shouldChangeCharacters(handler: @escaping (_ uiTextField: UITextFieldType, _ range: NSRange, _ replacementString: String) -> Bool) -> Self

func onChangedSelection(handler: @escaping (_ uiTextField: UITextFieldType) -> Void) -> Self

func shouldClear(handler: @escaping (_ uiTextField: UITextFieldType) -> Bool) -> Self

func shouldReturn(handler: @escaping (_ uiTextField: UITextFieldType) -> Bool) -> Self

UITextInputTraits

Most of the commonly used parts of UITextInputTraits are supported

func keyboardType(_ keyboardType: UIKeyboardType?) -> Self

func keyboardAppearance(_ keyboardAppearance: UIKeyboardAppearance?) -> Self

func returnKeyType(_ returnKeyType: UIReturnKeyType?) -> Self

func textContentType(_ textContentType: UITextContentType?) -> Self

func isSecureTextEntry(_ isSecureTextEntry: Bool?) -> Self

func enablesReturnKeyAutomatically(_ enablesReturnKeyAutomatically: Bool?) -> Self

func autocapitalizationType(_ autocapitalizationType: UITextAutocapitalizationType?) -> Self

func autocorrectionType(_ autocorrectionType: UITextAutocorrectionType?) -> Self

func spellCheckingType(_ spellCheckingType: UITextSpellCheckingType?) -> Self

func smartQuotesType(_ smartQuotesType: UITextSmartQuotesType?) -> Self

func smartDashesType(_ smartDashesType: UITextSmartDashesType?) -> Self

func smartInsertDeleteType(_ smartInsertDeleteType: UITextSmartInsertDeleteType?) -> Self

Examples

Examples/Example is an example app that demonstrate how to use UIKitTextField

Additional Notes

UITextField.isEnabled is actually supported by the vanilla .disabled(/* ... */) which might not be very obvious