When building text-intensive screens in Flutter, one of the most overlooked UX detail is how users dismiss the iOS keyboard, especially when dealing with multiline or numeric input fields. Android keyboards typically provide a visible “Done” or “Close” action but iOS often omits this, leaving users stuck unless they tap outside the input or scroll awkwardly.
Fortunately, adding a custom “Done” button overlay above the iOS keyboard is easier than many developers realize. In this article, we’ll explore why this feature matters, how it improves text-input UX and how to implement a clean, reusable solution using a Flutter mixin.
Why Does iOS Need a Custom “Done” Button?
Flutter’s cross platform abstraction doesn’t change the fact that iOS keyboards behave differently from Android keyboards.
On iOS:
- Multiline fields do not show a keyboard dismissal button
- Numeric keyboards do not include a return/done key
- Bottom sheets often get blocked by the keyboard
- Tapping outside to dismiss is not always obvious or possible
- Keyboard overlays can hide critical CTA buttons
This creates UX friction, especially in content creation screens, forms, note-taking interfaces or anywhere users type more than a few words.
A custom overlay solves these issues by placing a clear, intuitive Done button right above the keyboard. The user taps it, and the keyboard disappears immediately.
The Cleanest Solution: A Reusable Flutter Mixin
Instead of embedding dismissal logic inside widgets, this approach encapsulates the behavior in a reusable mixin. Any page that needs a Done button simply adds the mixin and assigns its focus node.
Benefits of using a mixin:
- No boilerplate duplication
- Keeps your widget tree clean
- Reusable across multiple pages
- Automatically manages overlay insertion and removal
- Fully iOS-safe (runs only when Platform.isIOS is true)
The keyboard_mixin.dart file
mixin KeyboardDoneOverlayMixin<T extends StatefulWidget> on State<T> {
OverlayEntry? _overlayEntry;
final FocusNode numericFocusNode = FocusNode();
VoidCallback? _focusListener;
@override
void initState() {
super.initState();
// Enable only on iOS devices.
if (!Platform.isIOS) return;
_focusListener = () {
numericFocusNode.hasFocus
? showDoneButtonOverlay(focusNode: numericFocusNode)
: removeOverlay();
};
numericFocusNode.addListener(_focusListener!);
}
void showDoneButtonOverlay({required FocusNode focusNode}) {
if (_overlayEntry != null) return;
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
_overlayEntry = OverlayEntry(
builder: (context) => Positioned(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 0,
right: 0,
child: Container(
color: isDarkMode ? Colors.black54 : Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
CupertinoButton(
padding: EdgeInsets.zero,
onPressed: dismissKeyboardAndOverlay,
child: const Text('Done'),
),
],
),
),
),
);
Overlay.of(context).insert(_overlayEntry!);
}
void removeOverlay() {
_overlayEntry?.remove();
_overlayEntry = null;
}
void dismissKeyboardAndOverlay() {
numericFocusNode.unfocus();
removeOverlay();
}
@override
void dispose() {
// Always clean up the listener if it was added
if (_focusListener != null) {
numericFocusNode.removeListener(_focusListener!);
}
// Overlay is iOS-only but safe to call regardless
removeOverlay();
numericFocusNode.dispose();
super.dispose();
}
}
Using the Mixin in a Widget
Applying this mixin is effortless. Here’s an example that shows the key integration points.
class TextInputPage extends StatefulWidget {
@override
State<TextInputPage> createState() => _TextInputPageState();
}
class _TextInputPageState extends State<TextInputPage>
with KeyboardDoneOverlayMixin {
final TextEditingController _articleController = TextEditingController();
@override
Widget build(BuildContext context) {
return TextFormField(
controller: _articleController,
focusNode: numericFocusNode, // Connects the mixin to this field.
maxLines: null,
keyboardType: TextInputType.multiline,
);
}
}
Once connected, the mixin automatically:
Detects focus
Displays the Done overlay
Removes it when the field loses focus
Cleans up in dispose()
Zero extra logic required inside the page itself.
How the Overlay Works Internally
Here’s a breakdown of the UI behavior:
Overlay Activation: When the text field gains focus, the mixin inserts an OverlayEntry widget positioned just above the keyboard.
Dynamic Positioning: MediaQuery.of(context).viewInsets.bottom provides keyboard height in real-time, ensuring perfect alignment.
Safe Removal: When focus is lost or when the user taps the “Done” button, the overlay is dismissed cleanly.
iOS-only-behaviour: The overlay never appears on Android, Web, or desktop platforms.
Why This Pattern Is Preferred in Production Apps
This technique is widely used across:
Chat apps
AI text editors
CMS like tools
Note input pages
Form heavy applications
Bottom-sheet based screens
I personally prefer this method because it:
Provides full styling control
Prevents accidental UI glitches
Keeps business logic separate from UI
Uses Flutter’s Overlay system in the correct idiomatic way
Avoids messy GestureDetectors for tapping outside inputs
Optional Enhancements (If You Want to Go Further)
You can extend this to build a premium UX:
Animate the overlay
Add “Next” and “Previous” buttons
Support multiple focus nodes automatically
Include custom shortcuts (Clear, Undo, Paste)
Build a toolbar-style input accessory (like native iOS apps)
Finally
A small detail like a Done button above the keyboard can dramatically improve the text-input experience on iOS, especially in content creation or form-driven workflows. The mixin approach keeps your codebase clean, scalable and maintainable, while delivering a native feeling UX.
If you found this article useful, please don’t forget to clap and leave a comment. If you have any questions too, please leave a comment, I will reply everyone of them. Thanks for reading.
In case you want to reach me on other social media accounts, these are my profiles:
Github: https://github.com/frankdroid7
Twitter: https://twitter.com/Frankdroid77
LinkedIn: https://www.linkedin.com/in/franklin-oladipo
Instagram: https://www.instagram.com/mobiledevgenie


Top comments (1)
This came at the right time, thank you bro 🤲🤲🤲