Scroll TextField into visible range in SwiftUI by wrapping UITextField

Update: This article was written for iOS 13. Starting from iOS 14 TextField will be scrolled into visible range automatically when keyboard appears on screen. We no longer need to wrap UITextField for this purpose.

It's quite common to have a text field inside a scroll view in iOS apps. In order to provide a nice user experience we should scroll the text field into visible range when it becomes active and the keyboard appears on screen. In UIKit we can call scrollRectToVisible() method on the scroll view to achieve that. What would be the simplest way to get this functionality in SwiftUI?

iPhone screenshot with a list of text fields iPhone screenshot with a list of text fields and keyboard on screen where the active text field was scrolled into visible range

We wanted to wrap as few UIKit views as possible, so we decided to only wrap UITextField. This approach works when the text field is a child (direct or nested) of SwiftUI ScrollView, List view or Form.

We will look into a simple example of a List view with multiple text fields inside. We are using our custom view TextFieldWithKeyboardObserver instead of a regular SwiftUI TextField.

List {
    ForEach(0...textStrings.count - 1, id: \.self) { index in
        TextFieldWithKeyboardObserver(
            text: self.$textStrings[index],
            placeholder: "TextField number \(index + 1)"
        )
    }
}

You can find the full code for TextFieldWithKeyboardObserver file in our GitHub repository. You can read more about wrapping UIKit views in Apple Documentation on UIViewRepresentable.

The first interesting part is that instead of returning a regular UITextField in makeUIView() method, we are returning our subclass UITextFieldWithKeyboardObserver and we are calling its method setupKeyboardObserver().

func makeUIView(context: Context) -> UITextField {
    let view = UITextFieldWithKeyboardObserver()
    view.delegate = context.coordinator
    view.text = text
    view.placeholder = placeholder
    view.setupKeyboardObserver()
    
    return view
}

In that method our view is subscribing to keyboard updates.

func setupKeyboardObserver() {
    keyboardPublisher = KeybordManager
        .shared
        .$keyboardFrame
        .receive(on: DispatchQueue.main)
        .sink { [weak self] keyboardFrame in
            if
                let strongSelf = self,
                let keyboardFrame = keyboardFrame
            {
                strongSelf.update(with: keyboardFrame)
            }
    }
}

And in the update() method we are checking if our text field is a child of a UIScrollView. It doesn't have to be a direct child, just anywhere in the UIScrollView's hierarchy. Then we set the insets and tell the parent scroll view to scroll the frame of our text field into visible range.

func update(with keyboardFrame: CGRect) {
    if
        let parentScrollView = superview(of: UIScrollView.self),
        isFirstResponder
    {
        
        let keyboardFrameInScrollView = parentScrollView.convert(
            keyboardFrame,
            from: UIScreen.main.coordinateSpace
        )
        
        let scrollViewIntersection = parentScrollView.bounds
            .intersection(keyboardFrameInScrollView).height
            
        let contentInsets = UIEdgeInsets(
            top: 0.0, left: 0.0,
            bottom: scrollViewIntersection, right: 0.0
        )
        
        parentScrollView.contentInset = contentInsets
        parentScrollView.scrollIndicatorInsets = contentInsets
        
        parentScrollView.scrollRectToVisible(frame, animated: true)
    }
}

Another thing to remember is to set the content inset back to zero when the text field ends editing. We do that in the Coordinator class which acts as a delegate of our text field.

func textFieldDidEndEditing(_ textField: UITextField) {
    if let parentScrollView = textField.superview(of: UIScrollView.self) {
        if !(parentScrollView.currentFirstResponder() is UITextFieldWithKeyboardObserver) {
            parentScrollView.contentInset = .zero
        }
    }
}

You can find the full example project with code for the keyboard observer and other supporting functionality in our GitHub folder for this article.