"I Typed One Letter, and the Keyboard Vanished."
It's the #1 most baffling bug for Flutter devs. You tap the Search bar, type "F". The keyboard slides down instantly, and the cursor (Focus) disappears.
You tap again, type "l". Gone. "u"... Gone. "t"... Gone. The app is playing "Red Light, Green Light" with me.
This isn't just a bug. It's a warning: "You are defying the Flutter Lifecycle."
The Principle: The Culprit is Inside build()
99% of the time, your code looks like this:
class SearchPage extends StatelessWidget { // 1. Stateless
@override
Widget build(BuildContext context) {
// 2. 😱 Create Controller INSIDE build
final controller = TextEditingController();
return TextField(
controller: controller,
onChanged: (text) {
// 3. State Change -> Trigger Rebuild (e.g., Provider/GetX)
someState.update(text);
},
);
}
}
Let's trace the execution:
- User types "A".
onChangedruns, forcing a State update elsewhere.- State changed -> Screen needs update -> Rebuild.
build()method runs AGAIN.final controller = TextEditingController();runs AGAIN.- A NEW Controller Object is born.
TextFieldsees a new controller and swaps it in.- The Focus State held by the OLD controller is lost.
- Result: Keyboard closes.
Solution 1: Promote to Stateful Widget
TextEditingController IS State.
State must not be reset on every build. It must outlive the build cycles.
So, you MUST use StatefulWidget.
class SearchPage extends StatefulWidget {
@override
_SearchPageState createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
// ✅ 1. Declare as Class Member (Stored in State)
late TextEditingController _controller;
@override
void initState() {
super.initState();
// ✅ 2. Initialize ONLY ONCE
_controller = TextEditingController();
}
@override
void dispose() {
// ✅ 3. Cleanup Memory (Required)
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller, // This variable stays same across builds
...
);
}
}
Now, even if build() runs a thousand times, _controller remains the exact same instance created in initState(). Focus is preserved.
Solution 2: Use flutter_hooks (The Pro Way)
"Boilerplate is annoying. init? dispose? Too much code."
If you like React Hooks, use them in Flutter with flutter_hooks.
It magically reduces code.
class SearchPage extends HookWidget { // Extend HookWidget
@override
Widget build(BuildContext context) {
// ✅ useTextEditingController: Handles lifecycle automatically
final controller = useTextEditingController();
return TextField(
controller: controller,
...
);
}
}
useTextEditingController stores the state internally and returns the existing instance on rebuilds. Elegant.
Deep Dive: The Key Issue (Abuse of GlobalKey)
Rarely, focus gets lost even with proper controllers.
This happens when the Widget's Key changes.
Flutter identifies widgets in the tree using runtimeType and Key.
If a parent rebuilds and the TextField gets a different Key, Flutter thinks, "Oh, this is a DIFFERENT widget," destroys the old one, and creates a fresh one. Focus is lost.
// ❌ Bad: New Key on every build
TextField(
key: GlobalKey(), // Every build -> New Key -> New Widget!
)
Don't use GlobalKey unless absolutely necessary (e.g., accessing state from outside). If you do use it, declare it as a final variable in your State/Class, never inline in build().
7. Deep Dive: Lifecycle of FocusNode
It's not just TextEditingController. You must manage FocusNode too.
Essential for "Next" button functionality.
class _LoginPageState extends State<LoginPage> {
late FocusNode _emailFocus;
late FocusNode _pwFocus;
@override
void initState() {
super.initState();
_emailFocus = FocusNode();
_pwFocus = FocusNode();
}
@override
void dispose() {
// 💀 Memory Leak if you forget this!
_emailFocus.dispose();
_pwFocus.dispose();
super.dispose();
}
void _nextField() {
_emailFocus.unfocus();
FocusScope.of(context).requestFocus(_pwFocus);
}
}
FocusNode IS State. Creating it inside build will reset focus on every keypress.
8. Case Study: Keyboard Covering the Button
Login button looks great at the bottom. Tap email -> Keyboard rises -> Button executes a disappearing act (or Bottom Overflow Error).
Rx 1: SingleChildScrollView
Make the screen scrollable.
Rx 2: resizeToAvoidBottomInset
A Scaffold property. Default is true.
Set to false if you want the background image to stay still (behind the keyboard) instead of squishing up.
Rx 3: Detect Keyboard Visibility
"Hide the Logo when typing to save space."
// Check keyboard height
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
return Column(
children: [
if (!isKeyboardVisible) BigLogo(), // Hide logo to save space
TextField(...),
],
);
UX Magic.
9. Tip: Tap Outside to Dismiss Keyboard
Users expect "Tap Background == Done".
GestureDetector(
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: Scaffold(...),
)
Use FocusManager. It's cleaner and safer than calling low-level SystemChannels.
Summary
"Focus Lost" = "TextField was Re-created"
- Check where
TextEditingControlleris declared.- Inside
build()? -> ❌ You are the culprit. - Member of
Stateclass? -> ✅ Correct.
- Inside
- Don't be lazy about
StatefulWidget.- If it has state, it needs to be Stateful.
- Or use
flutter_hooksif you hate boilerplate.
- Don't swap Keys.
- Random keys kill widgets.
Once you respect the Lifecycle, the keyboard will stop playing hide-and-seek with you.
13. Troubleshooting: Keyboard issues in Dialog/BottomSheet
Using TextField inside showModalBottomSheet often results in the keyboard covering the input.
The Fix: isScrollControlled: true & Padding
showModalBottomSheet(
context: context,
isScrollControlled: true, // 1. Allow full height
builder: (context) => Padding(
// 2. Add padding for keyboard
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom
),
child: MyTextFieldWidget(),
),
);
Without this, the OS keyboard will brutally cover your bottom sheet.
14. Refactoring Challenge: StatefulWidget to flutter_hooks
Problem:
Your StatefulWidget code is 50+ lines long with noisy init and dispose logic.
Challenge:
Reduce it to 15 lines using flutter_hooks.
Before:
class Search extends StatefulWidget { ... }
class _SearchState extends State<Search> {
late TextEditingController _c;
@override
void initState() { super.initState(); _c = TextEditingController(); }
@override
void dispose() { _c.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) { return TextField(controller: _c); }
}
After:
class Search extends HookWidget {
@override
Widget build(BuildContext context) {
final c = useTextEditingController();
return TextField(controller: c);
}
}
Experience the elegance of Hooks.
15. FAQ
Q: autofocus: true doesn't work!
A: You likely requested focus before the page transition animation finished. Use addPostFrameCallback or a small delay.
Q: I want to save data on Blur (Focus Lost).
A: Add a listener to FocusNode.
focusNode.addListener(() {
if (!focusNode.hasFocus) {
saveData();
}
});