Engineering

Building a Customizable UITextField with Combine

Our guide to text field overlays, customizations, and more

a-uitextfield-appears--2-6b50fb.gif

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:

Simulator Screen Shot - iPhone X - 2021-02-23 at 21.32.27.png

Building a Customizable Text Field

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

We made an Xcode project with all of the examples shown in this post. You can find it right here.

To achieve setting custom insets we’ll need to override a few methods, specifically the bounds of the text field’s label. Overriding textRect(forBounds:) and editingRect(forBounds:) will yield the results we want:

/// 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
}

Simulator Screen Shot - iPhone X - 2021-02-23 at 21.35.44.png

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 rightViewRect(forBounds:) like this:

/// 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. UITextField doesn’t have a method like setImage to instantiate an image view for us like a UIButton would. Setting the right view with a UIButton will suffice. Here’s what that would look like with some optional properties like text and tint color:

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 rightViewMode will notify the text field when the right view overlay should appear. We also need to update the insetTextRect(forBounds:) with the additional right view padding and width of the right view:

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:

/// 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:

Screen Shot 2021-02-22 at 12.55.32 PM.png

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 textPublisher for text changes in the the text field and controlEventPublisher(for:) for control events from the button.

These publishers can be added at initialization:

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 ControlProperty property publisher offers a very useful solution for this. We could initialize this generic publisher with a specific control event and key path:

/// 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 rightAccessoryButton’s publisher we need to subscribe to the values it emits. In the DemoViewController this can be set up at viewDidLoad():

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

customizable-textfield-0ce46c.gif

Adding @IBInspectable

After all these additions to customize the UITextField 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 @IBInspectable. This will allow us to edit those properties in the attributes inspector in Interface Builder. In code, that looks like this:

/// 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:

Screen Shot 2021-02-25 at 12.43.34 PM.png

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:

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

Screen Shot 2021-02-25 at 12.48.36 PM.png 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:

Simulator Screen Shot - iPhone X - 2021-02-22 at 23.26.59.png

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 IBInspectable 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.

Are you building an iOS app? We want to help! Get in touch.