"로그인 버튼이 어디 갔죠?"
모바일 앱 개발을 시작하고 처음으로 "로그인 화면"을 만들 때의 일입니다. 디자인은 완벽했습니다.
- 상단에 멋진 로고
- 중간에 이메일/비밀번호 입력창
- 하단에 '로그인' 버튼
그런데 시뮬레이터에서 이메일 입력창을 누르는 순간, 화면 아래에서 키보드가 치고 올라오면서 모든 UI를 위로 밀어버렸습니다. 그리고 화면 상단에 로고는 찌그러지고, 하단에는 그 공포의 노란색/검은색 줄무늬(Overflow Error)가 떴습니다.
Bottom overflowed by 150 pixels
"아니, 키보드가 올라온다고 화면이 왜 깨져? 그냥 키보드 위로 겹쳐지면 안 되나?" 이것은 모바일 OS(Android/iOS)와 Flutter의 ViewInsets 개념을 모르고 덤비던 시절의 제 절규였습니다.
원리 이해 - ViewInsets와 Safe Area
모바일에서 소프트 키보드(가상 키보드)는 단순한 오버레이(Overlay)가 아닙니다. 키보드가 올라오는 순간, 시스템은 앱이 사용할 수 있는 화면 영역(Window) 자체를 줄여버립니다.
- 평소: 전체 높이 800px 사용 가능.
- 키보드 등장: 키보드 높이(약 300px)만큼 시스템이 가져감. -> 앱은 남은 500px 안에서 다시 레이아웃을 그려야 함.
이때 Flutter의 Scaffold는 기본적으로 이 변화에 맞춰 자신의 크기를 줄이려 노력합니다.
그래서 높이 800px에 딱 맞춰서 디자인해 놓은 UI가 갑자기 500px 안에 구겨지게 되고, 공간이 부족해 터져버리는(Overflow) 것입니다.
이것이 MediaQuery.of(context).viewInsets.bottom의 정체입니다.
해결책 1 - resizeToAvoidBottomInset (가장 쉬운 회피)
"나는 키보드가 올라오든지 말든지, 내 UI 크기를 줄이기 싫어! 그냥 키보드 뒤에 가려지게 놔둬!"
라고 외치고 싶다면, Scaffold에게 명령하면 됩니다.
Scaffold(
resizeToAvoidBottomInset: false, // 👈 핵심!
body: Column(
children: [
Expanded(child: Logo()),
TextField(),
LoginButton(),
],
),
)
장점:
- UI가 찌그러지거나 오버플로우가 나지 않습니다. 레이아웃이 견고하게 유지됩니다. "배경 이미지"가 있는 로그인 화면에서 특히 유용합니다.
단점:
- 치명적입니다. 만약 입력창(
TextField)이나 로그인 버튼이 화면 하단에 있었다면? 키보드가 그 위를 덮어버립니다. 사용자는 자기가 뭘 입력하는지 볼 수도 없고, 로그인 버튼을 누를 수도 없습니다. 키보드를 내려야만 버튼이 보입니다. UX 점수 0점입니다.
해결책 2 - SingleChildScrollView (표준 해법)
가장 정석적인 방법은 "화면이 좁아지면 스크롤 할 수 있게 만드는 것"입니다. 이러면 줄어든 500px 영역 안에서도 사용자가 스크롤을 해서 가려진 위쪽 로고나 아래쪽 버튼을 다 볼 수 있습니다.
Scaffold(
body: SingleChildScrollView( // 👈 전체를 감싸기
child: SizedBox(
height: MediaQuery.of(context).size.height, // 전체 높이 확보
child: Column(
children: [
Spacer(), // 빈 공간
Logo(),
Spacer(),
TextField(),
TextField(),
SizedBox(height: 20),
LoginButton(),
Spacer(),
],
),
),
),
)
여기서 팁은 child에 SizedBox(height: screenHeight)를 주는 것입니다. 그래야 내용물이 적을 때도 화면 꽉 차게 디자인을 잡을 수 있습니다. (물론 키보드가 올라오면 높이가 부족해지니 스크롤이 활성화됩니다.)
이 방법의 유일한 단점은, 사용자가 입력창을 누르면 키보드가 올라오면서 포커스된 입력창을 가릴 때가 있다는 점입니다. Flutter가 자동으로 스크롤을 맞춰주긴 하지만, 가끔 빗나갑니다.
해결책 3 - Padding + viewInsets (수동 제어)
채팅 앱처럼 입력창이 항상 키보드 바로 위에 붙어 있어야 하는 경우는 어떨까요?
Scaffold의 자동 조정에 맡기지 않고, 우리가 직접 패딩(Padding)을 줘서 움직일 수 있습니다.
// 1. resizeToAvoidBottomInset를 끄고
Scaffold(
resizeToAvoidBottomInset: false,
body: Column(
children: [
Expanded(child: ChatList()), // 채팅 목록
// 2. 입력창 영역
Container(
padding: EdgeInsets.only(
// 3. 키보드 높이만큼 하단 패딩 줌
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Row(
children: [
TextField(),
SendButton(),
],
),
),
],
),
)
이렇게 하면 부드러운 애니메이션을 우리가 직접 제어할 수 있습니다. 키보드 높이(viewInsets.bottom)는 키보드가 올라오면 300, 내려가면 0으로 변합니다. 이 값이 변할 때 자연스럽게 Container의 패딩이 늘어나면서 입력창이 키보드 위로 쑥 올라옵니다.
Android WindowSoftInputMode (네이티브 설정) 뜯어보기
가끔 Flutter 코드만으로는 해결이 안 될 때가 있습니다.
안드로이드 네이티브 설정(AndroidManifest.xml)이 우선하기 때문입니다.
activity 태그 안에 android:windowSoftInputMode 속성을 확인하세요.
adjustResize(권장): 뷰의 크기를 줄여서 키보드 자리를 만듭니다. Flutter의viewInsets가 정상 작동합니다.adjustPan: 뷰의 크기는 그대로 두고, 전체 화면을 위로 밀어 올립니다(Pan). 키보드가 포커스된 입력창을 가리지 않게 해주지만, 상단 타이틀바(AppBar)가 화면 밖으로 잘려 나갑니다.
앱바가 잘리는 게 싫다면 무조건 adjustResize를 쓰고, Flutter 안에서 스크롤로 해결해야 합니다.
7. Case Study: iOS 숫자 키보드엔 '완료' 버튼이 없다?
아이폰에서 keyboardType: TextInputType.number를 쓰면 숫자 패드가 뜹니다.
그런데 안드로이드와 달리 '완료(Done/Return)' 버튼이 없습니다.
사용자는 키보드를 내릴 방법이 없어서 갇혀버립니다(Trapped).
해결책: 커스텀 툴바 (Keyboard Actions)
keyboard_actions 같은 패키지를 써서 숫자 패드 위에 '완료' 버튼이 있는 바(Bar)를 붙여줘야 합니다.
KeyboardActions(
config: KeyboardActionsConfig(
actions: [
KeyboardActionsItem(
focusNode: _focusNode,
toolbarButtons: [
(node) {
return GestureDetector(
onTap: () => node.unfocus(), // 키보드 내리기
child: Padding(
padding: EdgeInsets.all(8.0),
child: Text("DONE"),
),
);
}
],
),
],
),
child: TextField(...),
)
이 디테일 하나가 "아이폰도 신경 쓴 앱"과 "대충 포팅한 앱"을 가릅니다.
8. 채팅 앱의 역설 (Reverse Scrolling) 자세히 살펴보기
채팅 앱을 만들 때 초보자들이 가장 많이 하는 실수는, 키보드가 올라올 때 채팅 목록이 가려지는 것입니다. 카카오톡을 보세요. 키보드가 올라오면 스크롤이 위로 밀리면서 가장 최근 메시지가 키보드 바로 위에 딱 붙습니다.
이걸 구현하려면 ListView의 reverse 속성을 써야 합니다.
ListView.builder(
reverse: true, // 👈 역순 스크롤 (바닥이 시작점)
itemCount: messages.length,
itemBuilder: (context, index) {
// 주의: index 0이 가장 최신 메시지임!
return MessageBubble(messages[index]);
},
)
reverse: true를 쓰면 리스트의 기준점(Anchor)이 화면 바닥이 됩니다.
그래서 키보드가 올라와서 화면 높이가 줄어들면, 기준점(바닥)도 같이 올라오기 때문에, 자연스럽게 메시지들도 딸려 올라옵니다. 별도의 스크롤 제어 코드가 필요 없습니다. 이것이 채팅 UX의 마법입니다.
9. Refactoring Challenge: FocusNode로 폼(Form) 제어하기
문제: 회원가입 화면에서 '이메일' 입력 후 엔터를 치면 키보드가 내려갑니다. 사용자는 다시 '비밀번호' 창을 터치해야 합니다. (불편함)
목표: '이메일'에서 엔터(Next)를 누르면 자동으로 '비밀번호'로 포커스가 이동하게 만드세요.
힌트:
FocusNode 두 개를 만들고, TextField의 onSubmitted 콜백에서 FocusScope.of(context).requestFocus(nextNode)를 호출하세요.
// 의사 코드(Pseudo Code)
final emailNode = FocusNode();
final passwordNode = FocusNode();
TextField(
focusNode: emailNode,
textInputAction: TextInputAction.next,
onSubmitted: (_) => FocusScope.of(context).requestFocus(passwordNode), // 다음으로 토스!
);
10. Tip: 아무 데나 눌러서 키보드 내리기 (GestureDetector)
사용자는 입력하다가 빈 화면을 터치하면 키보드가 내려가길 기대합니다. 하지만 플러터는 기본적으로 안 내려갑니다.
앱 전체를 GestureDetector로 감싸주세요.
GestureDetector(
onTap: () => FocusManager.instance.primaryFocus?.unfocus(), // 마법의 코드
child: Scaffold(...),
)
이 코드는 main.dart 레벨이나 Scaffold 상위에 두면 앱 전체에 적용됩니다. UX 만족도가 200% 상승합니다.
11. 마무리 - 키보드는 적이 아니다
키보드 레이아웃 문제는 모바일 개발의 통과의례입니다. "왜 내 디자인을 망치는 거야!"라고 화내지 마세요. 키보드는 사용자와 앱이 대화하는 가장 중요한 수단입니다.
- 일반적인 폼(로그인/회원가입):
SingleChildScrollView로 감싸라. 사용자는 스크롤 할 수 있어야 한다. - 배경이 중요한 디자인:
resizeToAvoidBottomInset: false를 고려하되, 입력창이 가려지지 않는지 체크하라. - 채팅 앱:
reverse: true를 활용하고viewInsets로 입력창 위치를 정밀 제어하라.
이 3가지 패턴만 익히면, 어떤 상황에서도 부드럽고 전문적인 입력 경험을 줄 수 있습니다.