Lighter Fare

Handling the On-Screen Keyboard in SwiftUI

A common task in iOS app development is creating UI's that adjust gracefully to the appearance and disappearance of the on-screen keyboard. I have recently been working on a SwiftUI app that has a view at the bottom of the screen that needs to adjust to the keyboard, and I tried a few different approaches.

Attempt #1: use pure SwiftUI

My first attempt was to handle the whole thing directly in SwiftUI by observing keyboard notifications. That means creating an ObservableObject that publishes the keyboard endFrame and updates it with the proper animation:

final class KeyboardListener: ObservableObject {
    @Published var endFrame: CGRect = .zero

    init(notificationCenter: NotificationCenter = .default) {
        [
            UIResponder.keyboardWillChangeFrameNotification,
            UIResponder.keyboardWillHideNotification
        ]
        .forEach {
            notificationCenter.addObserver(
                self,
                selector: #selector(keyboard(notification:)),
                name: $0, object: nil)
        }
    }

    @objc private func keyboard(notification: Notification) {
        withAnimation(Animation(curve: notification.animationCurve, duration: notification.duration)) {
            endFrame = notification.endFrame
        }
    }
}

I'm using an extension on Notification to pull the relevant values from userInfo:

fileprivate extension Notification {
    var animationCurve: UIView.AnimationCurve {
        guard let rawValue = (userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int) else {
            return UIView.AnimationCurve.linear
        }

        return UIView.AnimationCurve(rawValue: rawValue)!
    }

    var duration: TimeInterval {
        userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0
    }

    var endFrame: CGRect {
        userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect ?? .zero
    }
}

And I'm using this extension to try to map the UIKit animation curve to SwiftUI:

fileprivate extension Animation {
    init(curve: UIView.AnimationCurve, duration: TimeInterval) {
        switch curve {
        case .easeInOut:
            self = .easeInOut(duration: duration)
        case .easeIn:
            self = .easeIn(duration: duration)
        case .easeOut:
            self = .easeOut(duration: duration)
        case .linear:
            self = .linear(duration: duration)
        @unknown default:
            self = .easeInOut(duration: duration)
        }
    }
}

We can now create a standard SwiftUI view modifier to add the proper padding to the bottom of a view in response to keyboard frame changes:

struct KeyboardAdjustingModifier: ViewModifier {
    @EnvironmentObject private var listener: KeyboardListener

    func body(content: Content) -> some View {
        GeometryReader { proxy in
            content.padding(.bottom, self.bottomPadding(in: proxy))
        }
    }

    private func bottomPadding(in proxy: GeometryProxy) -> CGFloat {
        if listener.endFrame == .zero {
            return 0
        } else {
            let viewBottomY = proxy.frame(in: .global).maxY
            let keyboardTopY = self.listener.endFrame.minY
            return max(0, viewBottomY - keyboardTopY)
        }
    }
}

GeometryProxy.frame(in:) is very helpful here, as it allows us to easily get our view's frame within the global coordinate space. All we need to do is subtract the keyboard frame's minY from our view's maxY to calculate how much padding we need.

Let's give it a try:

struct ContentView: View {
    @State private var text = "hi"

    var body: some View {
        VStack(spacing: 0) {
            Color.red
            TextField("", text: $text)
        }
        .modifier(KeyboardAdjustingModifier())
    }
}
Animation Curve Mismatch

Ok, this isn't terrible. The padding is being correctly added and removed in response to the keyboard notifications. If all we cared about was adjusting a view's padding within a scroll view then this would mostly be fine.

But the animation is not syncing up at all. In our example, we want the TextField at the bottom of the screen to keep hugging the keyboard as it appears, and the mismatched animation is jarring at full speed. It turns out the iOS keyboard animation is not a simple ease-in-out curve as implied by the keyboard notification's userInfo after all, but rather a CASpringAnimation.

I spent a bit of time trying to create SwiftUI spring animation to match the keyboard animation, but it turns out the animation curve is not the only problem. Even with an identical animation curve, the SwiftUI animation was inconsistently synced to the keyboard animation; sometimes the SwiftUI animation would start slightly late and sometimes it would start slightly early. My thinking is that the animation systems are completely distinct and it's likely impossible to sync a SwiftUI animation to the keyboard animation.

Attempt #2: drop into UIKit to handle the animation

We know it's possible to animate a UIView in sync with keyboard animations because we've been doing it for years in UIKit apps. So let's use a technique from the SwiftUI Lab's The Power of the Hosting+Representable Combo where we wrap a UIHostingController in a UIViewControllerRepresentable in order to add a bit of UIKit functionality into an otherwise pure SwiftUI view hierarchy.

Let's start with the UIKit code:

/// A view controller that embeds a SwiftUI view, adjusting it's layout for
/// the on-screen keyboard.
fileprivate final class KeyboardAdjustingViewController<Content: View>: UIViewController {
    private let notificationCenter: NotificationCenter
    private let rootView: Content

    private lazy var hostingController = UIHostingController(rootView: rootView)
    private var bottomConstraint: NSLayoutConstraint?

    private var storage = [AnyCancellable]()

    init(rootView: Content, notificationCenter: NotificationCenter = .default) {
        self.rootView = rootView
        self.notificationCenter = notificationCenter

        super.init(nibName: nil, bundle: nil)
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        // Embed the SwiftUI view by using view controller containment with a `UIHostingController`
        addChild(hostingController)
        view.addSubview(hostingController.view)
        hostingController.didMove(toParent: self)

            // Pin the hosted SwiftUI view to our view's edge, but keep a reference to the
            // bottom constraint so we can adjust it later for keyboard notifications.
        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        let bottomConstraint = view.bottomAnchor.constraint(equalTo: hostingController.view.bottomAnchor)
        NSLayoutConstraint.activate([
            view.leadingAnchor.constraint(equalTo: hostingController.view.leadingAnchor),
            view.trailingAnchor.constraint(equalTo: hostingController.view.trailingAnchor),
            view.topAnchor.constraint(equalTo: hostingController.view.topAnchor),
            bottomConstraint
        ])
        self.bottomConstraint = bottomConstraint

        [
            UIResponder.keyboardWillChangeFrameNotification,
            UIResponder.keyboardWillHideNotification
        ]
        .forEach {
            notificationCenter.addObserver(
                self,
                selector: #selector(keyboard(notification:)),
                name: $0, object: nil)
        }
    }

    @objc required dynamic init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    @objc private func keyboard(notification: Notification) {
        var options = UIView.AnimationOptions(rawValue: UInt(notification.animationCurve.rawValue) << 16)
        options.update(with: .layoutSubviews)

        let contentAbsoluteFrame = view.convert(hostingController.view.frame, to: nil)
        let offset = contentAbsoluteFrame.maxY - notification.endFrame.minY
        let keyboardHeight = max(0, offset)

        view.layoutIfNeeded()
        UIView.animate(
            withDuration: notification.duration,
            delay: 0.0,
            options: options,
            animations: { [weak self] in
                self?.bottomConstraint?.constant = keyboardHeight
                self?.view.layoutIfNeeded()
            }, completion: nil)
    }
}

Note this code is using the same Notification extension we used earlier to pull data from the userInfo.

Now we create a very simple UIViewControllerRepresentable that just wraps our KeyboardAdjustingViewController to make it a SwiftUI view:

private struct KeyboardAdjustingView<Content: View>: UIViewControllerRepresentable {
    private let content: () -> Content

    init(@ViewBuilder _ content: @escaping () -> Content) {
        self.content = content
    }

    func makeUIViewController(context: Context) -> KeyboardAdjustingViewController<Content> {
        return KeyboardAdjustingViewController(rootView: content())
    }

    func updateUIViewController(_ uiViewController: KeyboardAdjustingViewController<Content>, context: Context) {
        // Nothing to do
    }
}

With all of this in place, our view modifier becomes:

struct KeyboardAdjustingModifier: ViewModifier {
    func body(content: Content) -> some View {
        KeyboardAdjustingView {
            content
        }
    }
}

You could also easily use KeyboardAdjustingView directly rather than as a modifier.

Let's give it a try:

struct ContentView: View {
    @State private var text = "hi"

    var body: some View {
        VStack(spacing: 0) {
            Color.red
            TextField("", text: $text)
        }
        .modifier(KeyboardAdjustingModifier())
    }
}
Animation Curve Match

Looking good! The animation matches the keyboard perfectly. Unfortunately, I ran into a problem trying to use this in a real application. Consider the following scenario:

struct ContentView: View {
    @State private var text = "hi"
    @State private var textFieldVisible = true

    var body: some View {
        VStack(spacing: 0) {
            Color.red
            HStack {
                if textFieldVisible {
                    TextField("", text: $text)
                }
                Spacer()
                Button(
                    action: {
                        self.textFieldVisible.toggle()
                    },
                    label: {
                        Text(self.textFieldVisible ? "Hide" : "Show")
                    })
            }
        }
        .modifier(KeyboardAdjustingModifier())
    }
}

There's nothing crazy here. We're conditionally showing the text field based on some local state, and we have a button to toggle whether it's visible. But if the text field is the first responder when a state change causes it to disappear from the view hierarchy, we get a crash at at layoutIfNeeded when we try to respond to the keyboard notification:

EXC_BAD_INSTRUCTION

#0    0x00007fff2c80a4aa in ViewRendererHost.render(interval:updateDisplayList:) ()
#1    0x00007fff2c96a0c2 in _UIHostingView.layoutSubviews() ()
#2    0x00007fff2c96a0e5 in @objc _UIHostingView.layoutSubviews() ()
#3    0x00007fff4911aae8 in -[UIView(CALayerDelegate) layoutSublayersOfLayer:] ()
#4    0x00007fff2b4c0180 in -[CALayer layoutSublayers] ()
#5    0x00007fff2b4c630b in CA::Layer::layout_if_needed(CA::Transaction*) ()
#6    0x00007fff49105b68 in -[UIView(Hierarchy) layoutBelowIfNeeded] ()
#7    0x0000000109c3d420 in KeyboardAdjustingViewController.keyboard(notification:)

After banging my head against this crash for a while, I finally found a workaround. But it's painful. You have to manually resign first responder prior to changing a state that will cause the text field to disappear from the view hierarchy. For example:

struct ContentView: View {
    @State private var text = "hi"
    @State private var textFieldVisible = true

    var body: some View {
        VStack(spacing: 0) {
            Color.red
            HStack {
                if textFieldVisible {
                    TextField("", text: $text)
                }
                Spacer()
                Button(
                    action: {
                        UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder),
                                                        to: nil,
                                                        from: nil,
                                                        for: nil)

                        self.textFieldVisible.toggle()
                    },
                    label: {
                        Text(self.textFieldVisible ? "Hide" : "Show")
                    })
            }
        }
        .modifier(KeyboardAdjustingModifier())
    }
}

With this in place, everything works fine. But in a real application, I'm finding that it can be difficult or impossible to know whether a given state change will cause a currently focused TextField to disappear from the view hierarchy. SwiftUI doesn't even have a way to know if a given TextField is currently the first responder. At any rate, this workaround obviates one of SwiftUI's greatest strengths: allowing your view to be determined entirely by its current state.

Conclusion

If you just need to adjust bottom padding for scrollable content then the pure SwiftUI approach is fine, as getting the animation to sync up with the keyboard is probably not essential. But if you want to have a bottom view that hugs the keyboard as it appears and disappears, there isn't really a good way to do that right now within SwiftUI without introducing a basket of hacks that mostly defeat the purpose of using SwiftUI in the first place.

This is something I'm seeing a lot as I try to create a real SwiftUI application with complex behaviors. I keep running in to SwiftUI limitations, which is perhaps understandable given its youth. But while I can usually drop into UIKit to solve my immediate issues, I often end up with a Frankenstein's monster of hacks that don't play nicely together.

Tagged with: