Skip to content

Building a Customizable UITextField with Combine

Our guide to text field overlays, customizations, and more

by Daisy Ramos
Mini architectural building blocks
Pokemon style “A UITextField appears” animation

Customizing text fields is a critical feature of almost every mobile app. We often use them when we need a user to fill out text as part of a form. Because we use them so often, we’re always trying to find ways to optimize implementing them in our app’s user interface.

Here is an example of a customized text field that has additionally inset text and an icon on the right:

Building a Customizable Text Field

​ To help us build a similar text field that is customizable, we’ll start by subclassing UITextField.

To achieve setting custom insets we‘ll need to override a few methods, specifically the bounds of the text field’s label. Overriding text​Rect(for​Bounds:) and editing​Rect(for​Bounds:) will yield the results we want:

Swift
/// Used to inset the bounds of the text field. The x values is applied as horizontal insets and the y value is applied as vertical insets.
private var centerInset: CGPoint = .zero {
    didSet {
        setNeedsLayout()
    }
}

// MARK: - UITextField

override func textRect(forBounds bounds: CGRect) -> CGRect {
    insetTextRect(forBounds: bounds)
}

override func editingRect(forBounds bounds: CGRect) -> CGRect {
    insetTextRect(forBounds: bounds)
}

private func insetTextRect(forBounds bounds: CGRect) -> CGRect {
    let insetBounds = bounds.insetBy(dx: centerInset.x, dy: centerInset.y)
    return insetBounds
}

We talked about adding custom views in the text field as well. Since we modified the bounds of the text field‘s label we’ll need to consider the adjusted insets. Similarly to the overridden methods above, we‘ll need to override right​View​Rect(for​Bounds:) like this:

Swift
/// Sets padding for the `rightView` from the text field's edge.
private var rightViewPadding: CGFloat = 0 {
    didSet {
        setNeedsLayout()
    }
}

override func rightViewRect(forBounds bounds: CGRect) -> CGRect {
    var rightViewRect = super.rightViewRect(forBounds: bounds)
    rightViewRect.origin.x -= rightViewPadding
    return rightViewRect
}

The frame of the right view‘s rectangle is also adjusted to include some padding. Since we have a foundation set up for the right view all that is left is to populate it. UI​Text​Field doesn‘t have a method like set​Image to instantiate an image view for us like a UI​Button would. Setting the right view with a UI​Button will suffice. Here‘s what that would look like with some optional properties like text and tint color:

Swift
private let rightAccessoryButton: UIButton = UIButton(frame: .zero)

/// Sets padding for the `rightView` from the text field's edge.
private var rightViewPadding: CGFloat = 0 {
    didSet {
        setNeedsLayout()
    }
}

/// Sets an image for the `rightView`.
private var rightViewImage: UIImage? {
    didSet {
        rightAccessoryButton.setImage(rightViewImage, for: .normal)
        rightViewMode = rightViewImage != nil ? .always : .never
    }
}

/// Sets text for the `rightView`.
private var rightViewText: String? {
    didSet {
        rightAccessoryButton.setTitle(rightViewText, for: .normal)
        rightAccessoryButton.setTitleColor(rightViewTintColor, for: .normal)
        rightViewMode = rightViewText != nil ? .always : .never
    }
}

/// Sets tintColor for the `rightView`.
private var rightViewTintColor: UIColor? {
    didSet {
        if rightViewImage != nil {
            rightAccessoryButton.tintColor = rightViewTintColor
        }
        
        if rightViewText != nil {
            rightAccessoryButton.setTitleColor(rightViewTintColor, for: .normal)
        }
    }
}

private func commonInit() {
    rightView = rightAccessoryButton
    rightViewMode = .never
}

Setting the right​View​Mode will notify the text field when the right view overlay should appear. We also need to update the inset​Text​Rect(for​Bounds:) with the additional right view padding and width of the right view:

Swift
private func insetTextRect(forBounds bounds: CGRect) -> CGRect {
    var insetBounds = bounds.insetBy(dx: centerInset.x, dy: centerInset.y)
    insetBounds.size.width -= rightViewPadding + rightAccessoryButton.bounds.width
    return insetBounds
}

Configuring the customized text field

Applications typically use a standardized design pattern (such as MVP or MVVM), which mostly derive from conforming to the separation of concerns principle. How do we handle text field customization and responding to user initiated actions while separating logic?

The core of this subclass contains several properties. It looks like we can make use of a view model as the main source of configuration for the text field. So far we have the center inset and padding properties but we could benefit from adding a right view object along with the text field properties:

Swift
/// The view model containing information necessary for configuring the display of the view. 
struct ViewModel { 
  /// The properties that can be applied to the right view. 
  struct RightView { 
    /// The optional image for the right view. 
    var image: UIImage? 
    /// The optional text for the right view. 
    var text: String? 
    /// The tint color of the text and image. 
    let tintColor: UIColor? 
    /// The padding between the accessory text and edge of the text field. 
    let padding: CGFloat } 
  /// The text. 
  var text: String? 
  /// The color of the text. 
  let textColor: UIColor? 
  /// The placeholder text. 
  let placeholder: String? 
  /// Sets the center inset of the text field. 
  let centerInset: CGPoint 
  /// The optional `RightView` to display on the `rightView` of a `UITextField`. 
  var rightView: RightView? } 

This is also a perfect use case for taking advantage of Apple‘s Combine framework. Pre-Combine, the text field responses could have been handled with closures or delegates. UIKit does not provide Combine support by default, so to simplify some of the following logic we‘ll use a 3rd party framework, CombineCocoa, for the UIKit publishers we need.

Installing CombineCocoa with Swift Package Manager

Adding a dependency via Apple‘s built in Swift Package Manager is quite easy. In Xcode, simply select File → Swift Packages → Add Package Dependency. There, you will be prompted to enter a package repository url where you can add the CombineCocoa GitHub url. Selecting next will default to the latest release tag from the repository. After confirming the package options you should now see the dependency added in the project navigator:

Adding Combine publishers

We did a lot of customization with the text field subclass we created. Let‘s address event handling and responses to the right view button.

Since CombineCocoa is a wrapper around UIKit, there are built in publishers we can use. We‘ll focus on text​Publisher for text changes in the the text field and control​Event​Publisher(for:) for control events from the button.

These publishers can be added at initialization:

Swift
private var cancellables = Set<AnyCancellable>() 
private func commonInit() { 
  rightView = rightAccessoryButton 
  rightViewMode = .never 
  
  rightAccessoryButton.controlEventPublisher(for: .primaryActionTriggered) 
  .sink { 
         self.rightAccessoryButton.isSelected.toggle() 
  } 
  .store(in: &cancellables) 
  
  textPublisher 
  .sink { text in 
         self.viewModel?.text = text 
        } 
  .store(in: &cancellables) 
} 

Since the publishers are exclusive to private properties we‘ll need another one for accessing the selected state of the button. CombineCocoa’s Control​Property property publisher offers a very useful solution for this. We could initialize this generic publisher with a specific control event and key path:

Swift
/// Called when the right accessory's enabled value has changed. 
var rightAccessoryButtonEnabled: AnyPublisher<Bool, Never> {
                                                            Publishers.ControlProperty(control: rightAccessoryButton, events: .primaryActionTriggered, keyPath: \.isSelected) 
  .eraseToAnyPublisher() 
} 

Now that we‘ve included the right​Accessory​Button‘s publisher we need to subscribe to the values it emits. In the Demo​View​Controller this can be set up at view​Did​Load():

Swift
override func viewDidLoad() { 
  super.viewDidLoad() 
  
  customTextField.rightAccessoryButtonEnabled.sink { [weak self] isSelected in 
    guard let self = self else { 
      return 
    } 
    self.customTextField.viewModel?.rightView?.image = isSelected ? .unlocked : .locked
    self.customTextField.isSecureTextEntry = !isSelected 
} 
  .store(in: &cancellables) 
} 

Adding @IBInspectable

After all these additions to customize the UI​Text​Field subclass there is another option to extend these properties to Interface Builder. To better visualize this in Interface Builder, let‘s mark the properties as @IB​Inspectable. This will allow us to edit those properties in the attributes inspector in Interface Builder. In code, that looks like this:

Swift
/// Sets the center inset of the text field..
@IBInspectable private var centerInset: CGPoint = .zero {
    didSet {
        setNeedsLayout()
    }
}

/// Sets padding for the `rightView` from the text field's edge.
@IBInspectable private var rightViewPadding: CGFloat = 0 {
    didSet {
        setNeedsLayout()
    }
}

/// Sets an image for the `rightView`.
@IBInspectable private var rightViewImage: UIImage? {
    didSet {
        rightAccessoryButton.setImage(rightViewImage, for: .normal)
        rightViewMode = rightViewImage != nil || rightViewText != nil ? .always : .never
    }
}

/// Sets text for the `rightView`.
@IBInspectable private var rightViewText: String? {
    didSet {
        rightAccessoryButton.setTitle(rightViewText, for: .normal)
        rightAccessoryButton.setTitleColor(rightViewTintColor, for: .normal)
        
        rightViewMode = rightViewText != nil || rightViewImage != nil ? .always : .never
    }
}

/// Sets tintColor for the `rightView`.
@IBInspectable private var rightViewTintColor: UIColor? {
    didSet {
        if rightViewImage != nil {
            rightAccessoryButton.tintColor = rightViewTintColor
        }
        
        if rightViewText != nil {
            rightAccessoryButton.setTitleColor(rightViewTintColor, for: .normal)
        }
    }
}

And in Interface Builder, it should look like this:

Since we have all the inspectables set up in Interface Builder, we can go a step further and have those values set up as the default view model, and then take a look at what the project‘s Interface Builder generates from the inspectables:

Swift
override func awakeFromNib() {
    super.awakeFromNib()
    viewModel = ViewModel(text: text, textColor: textColor, placeholder: placeholder, centerInset: centerInset, rightView: .init(image: rightViewImage, text: rightViewText, tintColor: rightViewTintColor, padding: rightViewPadding))
}

Results in Interface Builder

Running the project without having modified the view controller will reveal that the properties have been set directly from the Interface Builder:

Summary

Being able to set up inspectable properties directly in Interface Builder allows for more flexibility and clarity when composing reusable views. The source of truth in the view model also allows us to set default properties. In this post, we learned how simple it is to set up a chain of Combine publishers. We also leveraged an older feature like IB​Inspectable and paired it with a newer technology like Combine. We hope this provides useful insight on how to implement these reactive changes in your existing apps.