How to handle keyboard for UITextField in scrolling UIStackView in iOS
Tags: ios, swift, keyboard, texfield
Published: 2019-06-26 10:37:22

---
title: How to handle keyboard for UITextField in scrolling UIStackView in iOS
published: true
description: 
tags: ios,swift,keyboard,texfield
---

Firstly, to make UIStackView scrollable, embed it inside UIScrollView. Read [How to embed UIStackView inside UIScrollView in iOS](https://github.com/onmyway133/blog/issues/324)

It's best to listen to `keyboardWillChangeFrameNotification` as it contains frame changes for Keyboard in different situation like custom keyboard, languages.

> Posted immediately prior to a change in the keyboard’s frame.

```swift
class KeyboardHandler {
    let scrollView: UIScrollView
    let stackView: UIStackView
    var observer: AnyObject?
    var keyboardHeightConstraint: NSLayoutConstraint!

    struct Info {
        let frame: CGRect
        let duration: Double
        let animationOptions: UIView.AnimationOptions
    }

    init(scrollView: UIScrollView, stackView: UIStackView) {
        self.scrollView = scrollView
        self.stackView = stackView
    }
}
```

To make scrollView scroll beyond its `contentSize`, we can change its `contentInset.bottom`. Another way is to add a dummy view with certain height to `UIStackView` and alter its `NSLayoutConstraint` `constant`

We can't access `self` inside init, so it's best to have setup function

```swift
func setup() {
    let space = UIView()
    keyboardHeightConstraint = space.heightAnchor.constraint(equalToConstant: 0)
    NSLayoutConstraint.on([keyboardHeightConstraint])
    stackView.addArrangedSubview(spa
    observer = NotificationCenter.default.addObserver(
        forName: UIResponder.keyboardWillChangeFrameNotification,
        object: nil,
        queue: .main,
        using: { [weak self] notification in
            self?.handle(notification)
        }
    )
}
```

Convert `Notification` to a convenient `Info` struct

```swift
func convert(notification: Notification) -> Info? {
    guard
        let frameValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] NSValue,
        let durationotification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber,
        let raw = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] NSNumber
    else {
        return nil
  
    return Info(
        frame: frameValue.cgRectValue,
        duration: duration.doubleValue,
        animationOptions: UIView.AnimationOptions(rawValue: raw.uintValue)
    )
}
```

Then we can compare with UIScreen to check if Keyboard is showing or hiding

```swift
func handle(_ notification: Notification) {
    guard let info = convert(notification: notification) else {
        return
  
    let isHiding = info.frame.origin.y == UIScreen.main.bounds.height
    keyboardHeightConstraint.constant = isHiding ? 0 : info.frame.hei
    UIView.animate(
        withDuration: info.duration,
        delay: 0,
        options: info.animationOptions,
        animations: {
            self.scrollView.layoutIfNeeded()
            self.moveTextFieldIfNeeded(info: info)
    }, completion: nil)
}
```

To move `UITextField` we can use [`scrollRectToVisible(_:animated:)`](https://developer.apple.com/documentation/uikit/uiscrollview/1619439-scrollrecttovisible) but we have little control over how much we want to scroll

> This method scrolls the content view so that the area defined by rect is just visible inside the scroll view. If the area is already visible, the method does nothing.

Another way is to check if keyboard overlaps `UITextField`. To do that we use [`convertRect:toView:`](https://developer.apple.com/documentation/uikit/uiview/1622504-convertrect) with `nil` target so it uses window coordinates. Since keyboard frame is always relative to window, we have frames in same coordinate space.

> Converts a rectangle from the receiver’s coordinate system to that of another view.

> rect: A rectangle specified in the local coordinate system (bounds) of the receiver.
> view: The view that is the target of the conversion operation. If view is nil, this method instead converts to window base coordinates. Otherwise, both view and the receiver must belong to the same UIWindow object.

```swift
func moveTextFieldIfNeeded(info: Info) {
    guard let input = stackView.arrangedSubviews
        .compactMap({ $0 as? UITextField })
        .first(where: { $0.isFirstResponder })
    else {
        return
  
    let inputFrame = input.convert(input.bounds, to: nil)
    if inputFrame.intersects(info.frame) {
        scrollView.setContentOffset(CGPoint(x: 0, y: inputFrame.height), animated: true)
    } else {
        scrollView.setContentOffset(.zero, animated: true)
    }
}
```

---

Original post https://github.com/onmyway133/blog/issues/329