/ SWIFTUI

Keyboard Avoidance for SwiftUI Views

The iOS system keyboard appears whenever a user taps an element that accepts text input. It can be a TextField component from SwiftUI, a UITextField and UITextView from UIKit, or a text input field inside a web view.

Whenever the iOS keyboard appears, it overlaps parts of your interface. The common approach to keyboard management is to move up the focused part of the view to avoid its overlapping. In this article, let’s learn how we can solve this problem by making our SwiftUI views keyboard-aware.

Propagating Keyboard Height Changes with Combine

The code samples were created with Xcode 11.4, iOS 13.4, Swift 5.2, the Combine and SwiftUI frameworks.

Whenever the keyboard appears, the iOS system sends the following notifications:

We can use the willShow and willHide notifications to track the keyboard height before and after it appears on the screen. We’ll propagate keyboard height changes using the Combine framework since it integrates seamlessly with SwiftUI:

You can familiarize yourself with the Combine framework by reading this bird’s-eye overview.

extension Publishers {
    // 1.
    static var keyboardHeight: AnyPublisher<CGFloat, Never> {
        // 2.
        let willShow = NotificationCenter.default.publisher(for: UIApplication.keyboardWillShowNotification)
            .map { $0.keyboardHeight }
        
        let willHide = NotificationCenter.default.publisher(for: UIApplication.keyboardWillHideNotification)
            .map { _ in CGFloat(0) }
        
        // 3.
        return MergeMany(willShow, willHide)
            .eraseToAnyPublisher()
    }
}

Here is what we are doing:

  1. Declare a keyboard height publisher in the Publishers namespace. The publisher has two types – CGFloat and Never – which means that it emits values of type CGFloat and can never fail with an error.
  2. Wrap the willShow and willHide notifications into publishers. Whenever the notification center broadcasts a willShow or willHide notification, the corresponding publisher will also emit the notification as its value. We also use the map operator since we are only interested in keyboard height.
  3. Combine multiple publishers into one by merging their emitted values.

This uses notification’s userInfo to get keyboard height:

extension Notification {
    var keyboardHeight: CGFloat {
        return (userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect)?.height ?? 0
    }
}

Moving SwiftUI View Up When Keyboard Appears

Let’s implement a SwiftUI view and make it keyboard-aware:

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

    var body: some View {
        VStack {
            Spacer()
            
            TextField("Enter something", text: $text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
        }
        .padding()
    }
}

This shows a problem with the keyboard overlaying on the text field:

Next, we need to move the view up when the keyboard appears:

struct ContentView: View {
    @State private var text = ""
    // 1.
    @State private var keyboardHeight: CGFloat = 0

    var body: some View {
        VStack {
            Spacer()
            
            TextField("Enter something", text: $text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
        }
        .padding()
        // 2.
        .padding(.bottom, keyboardHeight)
        // 3.
        .onReceive(Publishers.keyboardHeight) { self.keyboardHeight = $0 }
    }
}

Here is what the code does:

  1. Create keyboard height state. SwiftUI will automatically update the view whenever the keyboard height changes.
  2. Add padding to the bottom of the view, which will make it move up and down with the keyboard.
  3. Update the view state with the latest value emitted by the keyboardHeight publisher.

Now, when focusing and defocusing the text field, the view will move up and down:

Keyboard Avoidance in SwiftUI with Swift and Combine

Extracting Keyboard Avoidance Behavior into SwiftUI ViewModifier

Every time we add a text field to our app, chances high that will we need to manage keyboard appearances. Not to repeat ourselves every time, let’s extract the keyboard avoidance behavior into SwiftUI ViewModifier.

ViewModifier is a pre-made set of view configurations and state.

struct KeyboardAdaptive: ViewModifier {
    @State private var keyboardHeight: CGFloat = 0

    func body(content: Content) -> some View {
        content
            .padding(.bottom, keyboardHeight)
            .onReceive(Publishers.keyboardHeight) { self.keyboardHeight = $0 }
    }
}

extension View {
    func keyboardAdaptive() -> some View {
        ModifiedContent(content: self, modifier: KeyboardAdaptive())
    }
}

Now we apply the modifier to get the same result as in the previous section:

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

    var body: some View {
        VStack {
            Spacer()
            
            TextField("Enter something", text: $text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
        }
        .padding()
        .keyboardAdaptive() // Apply the modifier
    }
}

Avoiding Over-scroll

There is one problem left with the KeyboardAdaptive modifier. It always moves the view up, regardless of whether the keyboard overlaps the focused text field or not. The example below shows unwanted behavior:

How to Move a SwiftUI View When the iOS System Keyboard Covers a TextField using Swift and Combine

Let’s extend our modifier to avoid the unnecessary padding. First, detect a focused text input field using the good old UIKit:

// From https://stackoverflow.com/a/14135456/6870041

extension UIResponder {
    static var currentFirstResponder: UIResponder? {
        _currentFirstResponder = nil
        UIApplication.shared.sendAction(#selector(UIResponder.findFirstResponder(_:)), to: nil, from: nil, for: nil)
        return _currentFirstResponder
    }

    private static weak var _currentFirstResponder: UIResponder?

    @objc private func findFirstResponder(_ sender: Any) {
        UIResponder._currentFirstResponder = self
    }

    var globalFrame: CGRect? {
        guard let view = self as? UIView else { return nil }
        return view.superview?.convert(view.frame, to: nil)
    }
}

Since there can be at most one first responder in an iOS application, calling the UApplications sendAction() method directs the invocation to the correct responder (if any). The globalFrame property calculates the responder frame in the global coordinate space.

Then configure the KeyboardAdaptive modifier to move the view only as much as necessary so that the keyboard does not overlap the view:

struct KeyboardAdaptive: ViewModifier {
    @State private var bottomPadding: CGFloat = 0
    
    func body(content: Content) -> some View {
        // 1.
        GeometryReader { geometry in
            content
                .padding(.bottom, self.bottomPadding)
                // 2.
                .onReceive(Publishers.keyboardHeight) { keyboardHeight in
                    // 3.
                    let keyboardTop = geometry.frame(in: .global).height - keyboardHeight
                    // 4.
                    let focusedTextInputBottom = UIResponder.currentFirstResponder?.globalFrame?.maxY ?? 0
                    // 5.
                    self.bottomPadding = max(0, focusedTextInputBottom - keyboardTop - geometry.safeAreaInsets.bottom)
            }
            // 6.
            .animation(.easeOut(duration: 0.16))
        }
    }
}

Here is what we are doing:

  1. Access information about the size of the content view using GeometryReader.
  2. In the onReceive() callback, we calculate how far should we move the content view to position the focused text field above the keyboard.
  3. Calculate the top-most position of the keyboard in the global coordinate space.
  4. Calculate the bottom-most position of the focused text field.
  5. Calculate the intersection size between the keyboard and text field. This is the value of the content view’s bottom padding. Note that we take into account the bottom safe area inset.
  6. Match the motion animation with the speed of the keyboard sliding up and down.

Note that the KeyboardAdaptive modifier wraps your view in a GeometryReader, which attempts to fill all the available space, potentially increasing content view size. Thanks to Coffeemate for pointing this out.

This fixes the over-scroll issue:

Keyboard Avoidance in SwiftUI with Swift and Combine

And adds the motion animation to existing behavior:

Keyboard Avoidance in SwiftUI with Swift and Combine

Source Code

You can find the final project here. It is published under the “Unlicense”, which allows you to do whatever you want with it.


Thanks for reading!

If you enjoyed this post, be sure to follow me on Twitter to not miss any new content.

Vadim Bulavin

Creator of Yet Another Swift Blog. Lead iOS Engineer at EPAM. Coding for fun since 2008, for food since 2012.

Follow