skip to content

In a Flutter mobile app, how do you dismiss the soft keyboard when the user taps outside a TextField or scrolls?

level: juniorimportance: must knowfreq 68%

answer

  1. keyboard follows text-field focus
  2. primaryFocus?.unfocus()
  3. onTapOutside default differs by platform
  4. keyboardDismissBehavior: onDrag
  5. hiding is not unfocusing

basics

~10 s

Move focus off the text field with FocusManager.instance.primaryFocus?.unfocus(), called from TextField.onTapOutside or a background tap, and set keyboardDismissBehavior to onDrag on scroll views. On touch Android and iOS, Flutter's defaults do neither.

solid answer

~40 s

The soft keyboard stays up while an editable text field holds focus, so dismissing it means moving focus away: `FocusManager.instance.primaryFocus?.unfocus()`, or `FocusScope.of(context).unfocus()`. Flutter's tap-outside default depends on the platform: a focused `TextField` sits in a `TextFieldTapRegion`, and its default action unfocuses on desktop, on the web and for mouse or stylus taps, but ignores touch taps in Android and iOS apps. So pass `onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus()`, or wrap the page in a `GestureDetector` whose `onTap` unfocuses. For lists, set `keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag`; the default is `manual`. `SystemChannels.textInput.invokeMethod('TextInput.hide')` hides the keyboard but leaves the field focused, so it is rarely the right call.

code

dart · 22 lines
dart
import 'package:flutter/material.dart';

class OrderNotesPage extends StatelessWidget {
  const OrderNotesPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView(
        // Close the keyboard as soon as the user drags the list.
        keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
        children: <Widget>[
          TextField(
            // Touch taps outside do not unfocus on Android and iOS by default.
            onTapOutside: (PointerDownEvent event) =>
                FocusManager.instance.primaryFocus?.unfocus(),
          ),
        ],
      ),
    );
  }
}

go deeper

for a junior

Remember that the keyboard follows focus: call FocusManager.instance.primaryFocus?.unfocus(), and set keyboardDismissBehavior to onDrag on lists.

for a middle

Explain why touch taps outside a TextField do not unfocus on Android and iOS by default, and where focus goes after unfocus().

for a senior

Choose between per-field onTapOutside and a screen-wide tap handler, and spot keyboard flicker caused by focus nodes recreated on rebuild.

for a principal

Decide an app-wide policy, such as a custom ScrollBehavior for dismissal, so every screen behaves the same without per-field code.

## Why the keyboard is tied to focus On Android and iOS the on-screen (soft) keyboard is an operating-system component. Flutter asks for it when an editable text widget — `TextField`, `TextFormField`, `CupertinoTextField`, all built on `EditableText` — gains **focus**, and releases it when that widget loses focus. So "dismiss the keyboard" in Flutter really means **move focus away from the text field**. Hiding the keyboard any other way leaves the field believing it is still being edited. Focus itself lives in the **focus tree**: one `FocusNode` holds the primary focus at a time (`FocusManager.instance.primaryFocus`), and `FocusScopeNode`s group nodes — every route pushed by a `Navigator` has its own scope, and `WidgetsApp` (behind `MaterialApp` and `CupertinoApp`) adds one at the top. ## The calls that remove focus | Call | What it does | Notes | |---|---|---| | `FocusManager.instance.primaryFocus?.unfocus()` | unfocuses whatever node has primary focus | works from any context, including callbacks with no `BuildContext` | | `FocusScope.of(context).unfocus()` | unfocuses the nearest enclosing scope of `context` | a no-op if that scope does not contain the focus | | `someNode.unfocus()` | unfocuses one specific node | only acts if that node or a descendant has focus | | `SystemChannels.textInput.invokeMethod('TextInput.hide')` | asks the platform to hide the keyboard | leaves the field focused | **There is always a primary focus.** `unfocus()` does not leave "nothing" focused; it hands focus to the nearest enclosing `FocusScopeNode` (the default `UnfocusDisposition.scope`), or to the scope's previously focused child with `UnfocusDisposition.previouslyFocusedChild`. Because the route's scope is not a text field, the keyboard closes. ## Tapping outside: the platform defaults `EditableText` wraps a focused field in a `TextFieldTapRegion`, which reports pointer-downs that land outside it. When you do not pass `onTapOutside`, the default action is platform-dependent: - **Windows, macOS, Linux:** the field unfocuses. - **Web (any platform):** the field unfocuses, touch included. - **Android and iOS apps:** mouse, stylus and unknown pointers unfocus, but **touch taps do not**. That last line is why "tap outside to close the keyboard" needs code on phones. Two common fixes: 1. Pass `onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus()` to each `TextField`. 2. Wrap the page body in a `GestureDetector` (or `Listener`) with `onTap` that unfocuses, so taps on empty areas close the keyboard for every field on the screen. The first is precise and keeps buttons working normally; the second is one widget for the whole screen, but taps that a descendant button wins never reach it. ## Scrolling: keyboardDismissBehavior Scroll views take `keyboardDismissBehavior`, an enum with two values: - `ScrollViewKeyboardDismissBehavior.manual` — the **default** from the base `ScrollBehavior`; scrolling leaves the keyboard alone. - `ScrollViewKeyboardDismissBehavior.onDrag` — the scroll view unfocuses as soon as a drag begins, closing the keyboard. `ListView`, `GridView`, `CustomScrollView` and `SingleChildScrollView` all accept it. If it is null, the value falls back to the scroll view's `scrollBehavior`, then to the ambient `ScrollConfiguration`, so an app-wide `ScrollBehavior` subclass can override `getKeyboardDismissBehavior` once. ## Pitfalls - **`TextInput.hide` alone.** The field keeps focus, so the caret stays, hardware key events still route to it, and focus-dependent UI still thinks editing is active. - **Unfocusing with the wrong context.** `FocusScope.of(context)` finds the scope *above that context*; from a widget outside the route that holds the field, it does nothing. `primaryFocus?.unfocus()` avoids the question. - **Losing traversal.** If there is no scope between the node and the root, focus falls back to `FocusManager.rootScope` and Tab traversal can stop working; apps built on `WidgetsApp` always have a scope, so this mostly bites custom setups. - **Rebuilding the field with a new node.** A `FocusNode` created in `build()` makes the keyboard flicker closed on rebuild, which looks like a dismissal bug but is a lifecycle bug.

  • Why is SystemChannels.textInput.invokeMethod('TextInput.hide') not a substitute for unfocus() in Flutter?
    It only asks the platform to hide the keyboard. The text field still holds primary focus, so it keeps its caret, hardware key events still go to it, and anything listening to its focus node still sees it as focused. Moving focus with `unfocus()` closes the keyboard as a consequence and leaves the focus tree consistent.
  • In Flutter, where does focus go after calling unfocus() on a focused TextField's node?
    With the default `UnfocusDisposition.scope`, focus moves to the nearest enclosing `FocusScopeNode`, usually the route's scope, and that scope's focus history is cleared, so the next Tab starts from the first node. `UnfocusDisposition.previouslyFocusedChild` instead returns focus to the scope's previously focused child. Some node always keeps primary focus.

saying these in an interview costs you the question

  • TextField closes the keyboard on outside taps on every platform by default
  • TextInput.hide is the proper way, since it also removes focus
  • ListView already dismisses the keyboard on drag by default
  • After unfocus() no node in the app has focus at all
  • FocusScope.of(context).unfocus() works from any context on the screen