Skip to content

Soft Keyboard

The keyboard covers close to half the screen, the half where the field and its Send button live. If the layout doesn't move, people type blind. If it moves late, the composer jumps after the keys have landed and the screen feels broken.

Tap the field in each mode.

SKSam
Are we still on for Saturday?Yes! Trailhead at 9?Perfect. I'll bring coffee.You're the bestWeather says clear skies all morningWhat's your parking plan?

Tap the message field, type and send. Tap the messages to close the keyboard.

Covered leaves the composer under the keys. Jumps waits for the keyboard to finish, then snaps the composer up, and on the way down leaves it floating over an empty gap. Follows reads the keyboard's position every frame, so the composer rides on top of the keys and the newest message stays just above it.

Keep the focused field and its action above the keyboard, and move them with the keyboard's own animation, frame by frame.

Let the platform move it

iOS, Android and react-native-keyboard-controller report where the keyboard is on every frame. Read that instead of guessing a height and a curve.

  • iPhone: SwiftUI lays views out around the keyboard by default. Don't add your own keyboardWillShow offset on top; it shifts twice. And don't put .ignoresSafeArea() on a form, which turns the avoidance off. In UIKit, pin the bottom bar to view.keyboardLayoutGuide (iOS 15) and it follows the keyboard, docked or floating.
  • Android: Every app targeting API 36 is edge-to-edge with no way out, so the keyboard arrives as an inset you handle yourself. Modifier.imePadding() reads WindowInsets.ime, which Compose updates on every frame of the keyboard's animation. Inside a Scaffold, add it once: the default insets leave out the keyboard, and padding both the Scaffold and its child counts the keyboard twice.
  • React Native: KeyboardAvoidingView makes you compute the header offset by hand and isn't synced to the keyboard's frames. Use react-native-keyboard-controller: KeyboardStickyView for a composer, KeyboardAwareScrollView for forms, and KeyboardChatScrollView for a message list. Its useReanimatedKeyboardAnimation().height is negative, 0 down to minus the keyboard height, so translateY: height lifts content.
  • Flutter: Scaffold shrinks its body above the keyboard by default (resizeToAvoidBottomInset: true). Inside a sheet or a custom layout, pad by MediaQuery.viewInsetsOf(context).bottom yourself.

Let people put it away

People expect to push the keyboard down the way they pulled the content up. Drag the messages down in each mode.

SKSam
Perfect. I'll bring coffee.You're the bestWeather says clear skies all morningWhat's your parking plan?

Drag the messages down into the keyboard, slowly and then with a flick. Tap the field to bring it back.

With Done only, you hunt for a small button above the keys. With interactive dismissal, the keyboard follows the finger once it reaches the composer, and the release speed decides: a flick down closes it, a move back up keeps it.

  • iPhone: scrollDismissesKeyboard(.interactively) (iOS 16) gives you the Messages behavior. By default a TextEditor is interactive and every other scroll view dismisses the keyboard as soon as it scrolls.
  • Android: there's no built-in drag-to-dismiss for scroll views. Compose's Modifier.imeNestedScroll() opens and closes the keyboard as a list scrolls past its end; otherwise close it when the list is dragged or on a tap outside the field.
  • React Native: keyboardDismissMode="interactive" is iOS only; Android treats it as "none", so pass "on-drag" there.
  • Flutter: keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag on a scroll view closes the keyboard as soon as the list is dragged.

Tapping outside to dismiss is fine as long as it doesn't eat the tap. In React Native, keyboardShouldPersistTaps defaults to "never": the first tap on Save only closes the keyboard, and a second tap saves. Set it to "handled".

Move with the return key

The return key should take people to the next field and submit from the last one, so they never have to reach above the keyboard. Label it for what it does: submitLabel(.next) with @FocusState in SwiftUI, imeAction = ImeAction.Next with FocusManager in Compose, returnKeyType="next" in React Native. Picking the right keyboard for each field is covered in Input Details.

Usage

ContentView.swift
struct ChatView: View {
    @State private var draft = ""

    var body: some View {
        ScrollView {
            LazyVStack { ForEach(messages) { MessageRow(message: $0) } }
        }
        .defaultScrollAnchor(.bottom)              // iOS 17+: open on the newest
        .scrollDismissesKeyboard(.interactively)   // drag down to put it away
        .safeAreaInset(edge: .bottom) {            // sits on top of the keyboard
            HStack {
                TextField("Message", text: $draft)
                    .submitLabel(.send)
                    .onSubmit(send)
                Button("Send", systemImage: "arrow.up", action: send)
                    .disabled(draft.isEmpty)
            }
            .padding()
            .background(.bar)
        }
    }
}

// UIKit: the bar moves with the keyboard, docked or floating.
// composer.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor)
// Edge-to-edge: the keyboard is an inset you handle yourself.
Column(Modifier.fillMaxSize().imePadding()) {   // follows the keyboard each frame
    LazyColumn(Modifier.weight(1f), reverseLayout = true) {
        items(newestFirst, key = { it.id }) { MessageRow(it) }
    }
    Composer()
}

// Forms: Return moves to the next field.
val focusManager = LocalFocusManager.current
OutlinedTextField(
    state = email,
    label = { Text("Email") },
    keyboardOptions = KeyboardOptions(
        keyboardType = KeyboardType.Email,
        imeAction = ImeAction.Next,
    ),
    onKeyboardAction = { focusManager.moveFocus(FocusDirection.Down) },
)
// Wrap the app in <KeyboardProvider> from react-native-keyboard-controller.
import { KeyboardAwareScrollView, KeyboardStickyView } from "react-native-keyboard-controller";

// Form: the focused field scrolls above the keys; Return moves on.
<KeyboardAwareScrollView bottomOffset={24} keyboardShouldPersistTaps="handled">
  <TextInput returnKeyType="next" submitBehavior="submit"
    onSubmitEditing={() => email.current?.focus()} />
  <TextInput ref={email} keyboardType="email-address" returnKeyType="done"
    onSubmitEditing={save} />
  <Button title="Save" onPress={save} />
</KeyboardAwareScrollView>

// Chat: the composer rides the keyboard without resizing the screen.
<View style={{ flex: 1 }}>
  <FlatList inverted data={messages} renderItem={renderMessage}
    keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"} />
  <KeyboardStickyView>
    <Composer />
  </KeyboardStickyView>
</View>
Scaffold(
  // resizeToAvoidBottomInset defaults to true: the body ends at the keyboard.
  body: Column(children: [
    Expanded(
      child: ListView.builder(
        reverse: true, // newest at the bottom, just above the composer
        keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
        itemCount: messages.length,
        itemBuilder: (context, i) => MessageRow(messages[i]),
      ),
    ),
    const SafeArea(top: false, child: Composer()),
  ]),
);

// Forms: Return moves to the next field.
TextField(
  textInputAction: TextInputAction.next,
  onSubmitted: (_) => FocusScope.of(context).nextFocus(),
);

Don't hand-animate to the keyboard. Apple doesn't document the curve and duration it uses, so a copied 250 ms ease-out drifts away from the keys. Under reduced motion the system still slides the keyboard; your layout should keep following it exactly, with no extra spring or delay of its own. A hardware keyboard or a floating iPad keyboard can mean no bottom inset at all, so test with the keyboard both docked and gone.

Resources

Search concepts

Search for a command to run...