TekinAli ⎇ main

Keeping text fields above the keyboard in UIKit and SwiftUI

How the software keyboard interacts with your layout, and the modern tools for it: UIKeyboardLayoutGuide in UIKit, the keyboard safe area in SwiftUI, and @FocusState, onSubmit and scrollDismissesKeyboard for focus and dismissal.

Few things make an app feel broken faster than a keyboard that covers the field you are typing into. A login form with the "Sign in" button at the bottom, a chat input bar, a long registration form: as soon as the keyboard slides up, part of the screen disappears behind it. On iPad a floating keyboard can cover a different part of the screen each time.

UIKit and SwiftUI both have built-in tools for this today, and they replace a lot of hand-written code. This article explains how the keyboard relates to your layout, how UIKeyboardLayoutGuide and SwiftUI's keyboard safe area work, how to handle focus and dismissal, and the pitfalls that still catch people.

How the keyboard affects your layout

The software keyboard is not part of your view hierarchy. It slides in over your window, and if a text field or a "Continue" button sits in the bottom part of the screen, the keyboard covers it. The system will not rearrange your layout unless you tell it how.

Before iOS 15, telling it how meant the same ritual: observe keyboardWillShow / keyboardWillHide (or better, keyboardWillChangeFrameNotification), read the end frame from userInfo, convert it into your view's coordinate space, subtract the safe area, then either change a constraint constant or adjust a scroll view's contentInset, ideally while matching the keyboard's animation curve. It is easy to get at least one detail wrong: the iPad's floating keyboard, rotation, or removing the observer.

What changed in iOS 14 and iOS 15

Since iOS 15, every UIView has a keyboardLayoutGuide (a UIKeyboardLayoutGuide) that tracks the keyboard for you. Since iOS 14, SwiftUI treats the keyboard as a safe-area region (SafeAreaRegions.keyboard), so the layout system already avoids it. If your deployment target is iOS 15 or later, and in 2026 it almost certainly is, the notification-based approach is legacy code.

UIKit: UIKeyboardLayoutGuide

The idea is simple: the guide's top edge is the top of the keyboard. When the keyboard is hidden it rests at the bottom of the safe area. Pin your content to it and Auto Layout does the rest, including the animation.

import UIKit

final class LoginViewController: UIViewController {
    private let emailField = UITextField()
    private let loginButton = UIButton(configuration: .filled()) // iOS 15+

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        emailField.borderStyle = .roundedRect
        emailField.placeholder = "Email"
        loginButton.configuration?.title = "Sign in"

        let stack = UIStackView(arrangedSubviews: [emailField, loginButton])
        stack.axis = .vertical
        stack.spacing = 12
        stack.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(stack)

        NSLayoutConstraint.activate([
            stack.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
            stack.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
            // The whole trick: sit 16 points above the keyboard (or the safe area).
            stack.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -16)
        ])

        // iPad: also follow a floating or undocked keyboard (iOS 15+).
        view.keyboardLayoutGuide.followsUndockedKeyboard = true
    }
}

Two properties are worth knowing:

  • followsUndockedKeyboard (iOS 15+) is false by default. Then the guide only follows a docked keyboard. Set it to true if you want the guide to follow a floating or undocked keyboard on iPad as well.
  • usesBottomSafeArea (iOS 17+) controls whether the guide respects the bottom safe area while the keyboard is hidden. Set it to false if you want an input bar to sit flush with the bottom edge.

Scroll views: shrink the frame or adjust the inset

Forms usually live in a scroll view. There are two approaches, and I recommend the first one by default.

Pin the scroll view to the guide. The scroll view's frame ends at the keyboard, so every field is still reachable by scrolling. If you want the field being edited to scroll into view on its own, call scrollRectToVisible yourself:

final class FormViewController: UIViewController, UITextFieldDelegate {
    private let scrollView = UIScrollView()

    override func viewDidLoad() {
        super.viewDidLoad()
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        scrollView.keyboardDismissMode = .interactive // swipe down to dismiss
        view.addSubview(scrollView)

        NSLayoutConstraint.activate([
            scrollView.topAnchor.constraint(equalTo: view.topAnchor),
            scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            scrollView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor)
        ])
    }

    func textFieldDidBeginEditing(_ textField: UITextField) {
        let rect = textField.convert(textField.bounds, to: scrollView)
        scrollView.scrollRectToVisible(rect.insetBy(dx: 0, dy: -16), animated: true)
    }
}

Adjust contentInset. This is the classic approach. It is still the right choice when content has to keep drawing behind the keyboard, for example a translucent chat background. Use keyboardWillChangeFrameNotification rather than separate show and hide notifications, and update verticalScrollIndicatorInsets (iOS 11.1+) together with the inset:

@objc private func keyboardWillChangeFrame(_ note: Notification) {
    guard let endFrame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
    let frameInView = view.convert(endFrame, from: view.window)
    let overlap = max(0, view.bounds.maxY - frameInView.minY - view.safeAreaInsets.bottom)
    scrollView.contentInset.bottom = overlap
    scrollView.verticalScrollIndicatorInsets.bottom = overlap
}

If you do not have a specific reason for the inset approach, pin to the guide. It is less code and handles more edge cases.

SwiftUI: keyboard safe area and focus

On iOS 14 and later, SwiftUI already moves content out of the keyboard's way, because the keyboard is a safe-area region. Custom "move the field up" code written for iOS 13 tends to fight the framework on current systems.

What you actually need in SwiftUI is focus management. Here is a complete registration form:

import SwiftUI

struct SignUpForm: View {
    enum Field: Hashable { case name, email, password }

    @State private var name = ""
    @State private var email = ""
    @State private var password = ""
    @FocusState private var focusedField: Field?          // iOS 15+

    var body: some View {
        NavigationStack {                                  // iOS 16+
            ScrollView {
                VStack(spacing: 16) {
                    TextField("Full name", text: $name)
                        .focused($focusedField, equals: .name)
                        .submitLabel(.next)                // iOS 15+

                    TextField("Email", text: $email)
                        .keyboardType(.emailAddress)
                        .textInputAutocapitalization(.never)
                        .focused($focusedField, equals: .email)
                        .submitLabel(.next)

                    SecureField("Password", text: $password)
                        .focused($focusedField, equals: .password)
                        .submitLabel(.join)
                }
                .textFieldStyle(.roundedBorder)
                .padding()
            }
            .scrollDismissesKeyboard(.interactively)       // iOS 16+
            .onSubmit {                                    // iOS 15+
                switch focusedField {
                case .name: focusedField = .email
                case .email: focusedField = .password
                default:
                    focusedField = nil                     // dismisses the keyboard
                    register()
                }
            }
            .toolbar {
                ToolbarItemGroup(placement: .keyboard) {   // iOS 15+
                    Spacer()
                    Button("Done") { focusedField = nil }
                }
            }
            .navigationTitle("Sign up")
        }
    }

    private func register() { /* ... */ }
}

A few notes:

  • Dismissing the keyboard is just focusedField = nil. You no longer need the old trick of sending resignFirstResponder to UIApplication.shared, which is common in older SwiftUI examples.
  • .onSubmit applies to the text fields inside the view it is attached to, so one handler can manage the whole form.
  • .scrollDismissesKeyboard also accepts .immediately and .never. .interactively is the one that feels like Messages.

Opting out: ignoresSafeArea(.keyboard)

Sometimes you do not want things to move: a full-screen background image, or a layout where the keyboard should simply cover the bottom part. Apply .ignoresSafeArea(.keyboard) (iOS 14+) to those views:

ZStack {
    Image("background")
        .resizable()
        .scaledToFill()
        .ignoresSafeArea(.keyboard)   // the background stays put
    VStack {
        Spacer()
        TextField("Message", text: $draft)
            .padding()
    }
}

Common pitfalls

  • Mixing approaches. A constraint on keyboardLayoutGuide plus an old notification handler that also changes contentInset moves the content twice. When you migrate, delete the old observer.
  • Constraint conflicts. If a view is pinned to the keyboard guide and also has a fixed top constraint, something has to give when the keyboard appears. Give one of them a lower priority, or let a scroll view absorb the height.
  • The modifier is on the wrong view. Where you attach .ignoresSafeArea(.keyboard) matters. Put it on the container whose layout should not change, not on some leaf deep inside it, and check the result on a device.
  • iPad. Test the floating keyboard and a hardware keyboard. With a hardware keyboard you may only see the shortcuts bar, and hand-written frame math can easily get that case wrong.
  • Old SwiftUI workarounds. GeometryReader plus keyboard publishers, or an "adaptive keyboard" modifier written for early SwiftUI, will usually conflict with the built-in avoidance on iOS 14 and later. Remove them before you debug anything else.

Checklist

  1. UIKit, iOS 15+: pin content or the scroll view's bottom to view.keyboardLayoutGuide.topAnchor.
  2. Set followsUndockedKeyboard = true if floating keyboards on iPad matter to you. On iOS 17+, check usesBottomSafeArea.
  3. Use contentInset with keyboardWillChangeFrameNotification only if content must stay visible behind the keyboard.
  4. SwiftUI, iOS 14+: trust the keyboard safe area and use .ignoresSafeArea(.keyboard) where you need to opt out.
  5. Use @FocusState plus .submitLabel / .onSubmit (iOS 15+) for moving between fields and dismissing.
  6. Use .scrollDismissesKeyboard(.interactively) (iOS 16+) instead of tap-gesture hacks.
  7. Delete the old keyboard notification code when you migrate.

Most of what used to be hand-written keyboard code is now a system feature. For new code, a single constraint or a single @FocusState usually does the job.

tekinali.db · read-only · ● connected

SQLite 3.53.3 · 8 tables, 2 views.

Click a table on the left, pick a query below or write your own. Help: \?

↑↓ select · Enter open · Esc close