
TextField 포커스가 자꾸 풀립니다 (재렌더링의 함정)
글자 하나 칠 때마다 키보드가 내려가고 포커스가 풀리나요? 당신이 저지른 '컨트롤러 초기화' 실수를 찾아드립니다. TextEditingController와 build() 메서드의 관계를 완벽하게 파헤칩니다.

글자 하나 칠 때마다 키보드가 내려가고 포커스가 풀리나요? 당신이 저지른 '컨트롤러 초기화' 실수를 찾아드립니다. TextEditingController와 build() 메서드의 관계를 완벽하게 파헤칩니다.
로그인 화면을 만들었는데 키보드가 올라오니 노란 줄무늬 에러가 뜹니다. resizeToAvoidBottomInset부터 스크롤 뷰, 그리고 채팅 앱을 위한 reverse 팁까지, 키보드 대응의 모든 것을 정리해봤습니다.

안드로이드는 오는데 iOS는 조용합니다. 혹은 앱이 켜져 있을 때만 옵니다. Background/Terminated 상태 처리, APNs 인증서, 그리고 Notification Channel 설정까지 완벽하게 해결합니다.

분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

안드로이드는 Xcode보다 낫다고요? Gradle 지옥에 빠져보면 그 말이 쏙 들어갈 겁니다. minSdkVersion 충돌, Multidex 에러, Namespace 변경(Gradle 8.0), JDK 버전 문제, 그리고 의존성 트리 분석까지 완벽하게 해결해 봅니다.

Flutter 개발자들이 가장 황당해하는 버그 1위입니다. 검색창에 "Flutter"를 치려고 "F"를 누르는 순간, 키보드가 스르륵 내려가고 입력창의 커서(Focus)가 사라집니다.
다시 입력창을 눌러서 "l"을 칩니다. 또 사라집니다. "u"... 사라짐. "t"... 사라짐. 마치 앱이 저와 "무궁화 꽃이 피었습니다" 놀이를 하는 것 같았습니다.
이 현상은 단순한 버그가 아닙니다. "너는 지금 플러터의 생명주기(Lifecycle)를 거스르고 있다"는 경고입니다.
build() 안에 있다99%의 확률로, 여러분은 코드를 이렇게 짰을 겁니다.
class SearchPage extends StatelessWidget { // 1. Stateless
@override
Widget build(BuildContext context) {
// 2. 😱 build 안에 컨트롤러 생성
final controller = TextEditingController();
return TextField(
controller: controller,
onChanged: (text) {
// 3. 상태 변경 -> 리빌드 유발 (예: Provider나 GetX 등)
someState.update(text);
},
);
}
}
이 코드가 실행되는 순서를 봅시다.
onChanged가 실행되고, 어떤 상태(State)가 바뀝니다.build() 메서드가 다시 실행됩니다.final controller = TextEditingController(); 줄이 다시 실행됩니다.TextField는 "어? 새 컨트롤러네?" 하고 갈아끼웁니다.TextEditingController는 상태(State)입니다.
상태는 build() 할 때마다 초기화되면 안 됩니다. 위젯의 수명 동안 계속 살아있어야 합니다.
그래서 반드시 StatefulWidget을 써야 합니다.
class SearchPage extends StatefulWidget {
@override
_SearchPageState createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
// ✅ 1. 클래스 멤버 변수로 선언 (State 안에 저장)
late TextEditingController _controller;
@override
void initState() {
super.initState();
// ✅ 2. 초기화는 딱 한 번만!
_controller = TextEditingController();
}
@override
void dispose() {
// ✅ 3. 메모리 해제 (필수)
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller, // build가 여러 번 실행돼도 이 변수는 그대로임
...
);
}
}
이제 build()가 백 번, 천 번 실행되어도 _controller 객체는 initState()에서 만들어진 그 녀석 그대로 유지됩니다. 포커스도 유지됩니다.
"매번 Stateful 만들고 init/dispose 짜기 귀찮은데..."
React 개발자라면 익숙할 Hooks 패턴을 Flutter에서도 쓸 수 있습니다.
flutter_hooks 패키지를 쓰면 코드가 마법처럼 줄어듭니다.
class SearchPage extends HookWidget { // HookWidget 상속
@override
Widget build(BuildContext context) {
// ✅ useTextEditingController: 알아서 init/dispose 해줌
final controller = useTextEditingController();
return TextField(
controller: controller,
...
);
}
}
useTextEditingController는 내부적으로 상태를 저장해두고, 리빌드될 때도 기존 컨트롤러를 반환해줍니다. 우아하죠.
Key 문제 (GlobalKey의 오남용) 제대로 이해하기드물지만, 컨트롤러를 잘 썼는데도 포커스가 풀리는 경우가 있습니다.
바로 위젯의 Key가 바뀔 때입니다.
Flutter는 트리 구조에서 위젯을 식별할 때 runtimeType과 Key를 봅니다.
만약 부모 위젯이 리빌드되면서 TextField의 Key가 바뀌면, Flutter는 "어? 다른 위젯이네?"라고 판단하고 기존 TextField를 버리고 새로 만듭니다. 당연히 포커스도 날아갑니다.
// ❌ 나쁜 예: build 할 때마다 새로운 Key 생성
TextField(
key: GlobalKey(), // 매번 새로운 키! -> 매번 새로운 위젯!
)
GlobalKey는 꼭 필요한 경우(외부에서 상태 접근 등)가 아니면 쓰지 마세요. 쓴다면 반드시 State 안에 변수로 final key = GlobalKey(); 처럼 선언해서 고정시켜야 합니다.
TextEditingController만 있는 게 아닙니다. FocusNode도 관리해야 합니다.
"다음 버튼 누르면 비밀번호 입력창으로 포커스 이동" 같은 기능을 만들 때 필수죠.
class _LoginPageState extends State<LoginPage> {
late FocusNode _emailFocus;
late FocusNode _pwFocus;
@override
void initState() {
super.initState();
_emailFocus = FocusNode();
_pwFocus = FocusNode();
}
@override
void dispose() {
// 💀 이거 까먹으면 메모리 누수!
_emailFocus.dispose();
_pwFocus.dispose();
super.dispose();
}
void _nextField() {
_emailFocus.unfocus();
FocusScope.of(context).requestFocus(_pwFocus);
}
}
FocusNode도 상태입니다. build 안에 만들면 입력할 때마다 포커스가 초기화되어(날아가서) 키보드가 내려갑니다.
로그인 화면 맨 밑에 "로그인" 버튼이 있습니다. 이메일을 입력하려고 키보드를 올리면, 버튼이 키보드 뒤로 숨어버립니다. (Bottom Overflow Error)
SingleChildScrollView + Reverse화면을 스크롤 가능하게 만들면 됩니다.
resizeToAvoidBottomInsetScaffold 속성입니다. 기본값은 true인데, 이걸 false로 하면 키보드가 올라와도 화면이 찌그러지지 않고 키보드 뒤에 깔립니다. (배경 이미지가 있을 때 유용)
"키보드가 올라오면 로고를 숨기자!"
// MediaQuery로 키보드 높이 확인
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
return Column(
children: [
if (!isKeyboardVisible) BigLogo(), // 키보드 없을 때만 보여줌
TextField(...),
],
);
공간 절약의 마법입니다.
사용자는 "빈 공간 터치 = 완료"라고 생각합니다. 이걸 구현 안 해주면 짜증 냅니다.
GestureDetector(
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: Scaffold(...),
)
SystemChannels.textInput.invokeMethod('TextInput.hide') 같은 저수준 API보다, FocusManager를 쓰는 게 훨씬 Flutter스럽고 안전합니다.
"입력창 포커스가 풀린다" = "TextField가 다시 만들어졌다"
TextEditingController를 어디서 선언했는지 확인하라.
build() 안? -> ❌ 당신이 범인입니다.State 클래스 멤버 변수? -> ✅ 정답.StatefulWidget을 귀찮아하지 마라.
flutter_hooks를 써라.이 원리만 알면, 다시는 키보드와 숨바꼭질할 일이 없습니다.
showModalBottomSheet 안에 TextField를 넣으면 키보드가 올라올 때 시트가 가려지거나, 포커스가 이상하게 튀는 경우가 있습니다.
isScrollControlled: true & Padding
showModalBottomSheet(
context: context,
isScrollControlled: true, // 1. 전체 화면 높이 사용 허용
builder: (context) => Padding(
// 2. 키보드 높이만큼 패딩 주기
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom
),
child: MyTextFieldWidget(),
),
);
이렇게 안 하면 OS 키보드가 시트를 덮어버립니다.
StatefulWidget to flutter_hooks문제 (Problem):
StatefulWidget으로 짠 코드가 50줄이 넘습니다. init, dispose 등 보일러플레이트가 너무 많습니다.
도전 (Challenge):
이걸 flutter_hooks를 써서 15줄로 줄여보세요.
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);
}
}
Hooks의 위력을 직접 체험해보세요. 코드가 아름다워집니다.
Q: autofocus: true를 썼는데 왜 포커스가 안 가나요?
A: 화면 전환 애니메이션이 끝나기 전에 포커스를 요청해서 그렇습니다. WidgetsBinding.instance.addPostFrameCallback 안에 요청하거나, 약간의 delay를 주세요.
Q: 포커스를 잃었을 때(Blur) 저장을 하고 싶어요.
A: FocusNode에 리스너를 다세요.
focusNode.addListener(() {
if (!focusNode.hasFocus) {
saveData();
}
});