Flutter 튜토리얼 16편: 키보드 포커스와 단축키
요약
핵심 요지
문서가 설명하는 범위
- Flutter 포커스 시스템과 포커스 트리
- FocusNode와 FocusScope로 포커스 제어
- Tab 순서와 포커스 그룹 설정
- Shortcuts와 Actions로 키보드 단축키 구현
읽는 시간: 18분 | 난이도: 중급
참고 자료
- Understanding Flutter’s focus system - 포커스 시스템 개요
- Using Actions and Shortcuts - 단축키 시스템
문제 상황
데스크톱과 웹 애플리케이션에서는 키보드만으로 앱을 조작할 수 있어야 합니다. Tab 키로 요소 간 이동, 단축키로 빠른 실행, Enter로 버튼 활성화 등이 필요합니다.
키보드 접근성 요구사항
Tab: 다음 포커스 가능한 요소로 이동Shift+Tab: 이전 요소로 이동Enter/Space: 포커스된 버튼 활성화Ctrl+S: 저장Ctrl+Z: 실행 취소문제는 다음과 같습니다.
- 포커스 가능한 요소를 정의해야 한다.
- Tab 이동 순서를 제어해야 한다.
- 키보드 단축키를 등록하고 처리해야 한다.
- 포커스 상태에 따른 시각적 피드백이 필요하다.
해결 방법
Flutter는 포커스 시스템으로 키보드 입력 대상을 관리하고, Shortcuts/Actions로 단축키를 처리합니다.
챕터 1: 포커스 시스템 기본 개념
Why
NOTE키보드 입력은 화면의 특정 요소로 전달되어야 합니다.
포커스 시스템은 어떤 위젯이 키보드 입력을 받을지 결정합니다.키보드 입력 → 포커스된 위젯 → 이벤트 처리
What
NOTE포커스 시스템은 포커스 트리를 통해 키보드 입력을 라우팅합니다.
주요 개념은 다음과 같습니다.
개념 설명 포커스 트리 위젯 트리를 미러링하는 포커스 노드 트리 포커스 노드 포커스를 받을 수 있는 단일 노드 주 포커스 현재 키보드 입력을 받는 노드 포커스 스코프 포커스 노드 그룹을 관리하는 특수 노드 포커스 순회 Tab 키로 포커스를 이동하는 과정
How
TIP기본 Focus 위젯 사용
import 'package:flutter/material.dart';class FocusableBox extends StatefulWidget {const FocusableBox({super.key});@overrideState<FocusableBox> createState() => _FocusableBoxState();}class _FocusableBoxState extends State<FocusableBox> {bool _isFocused = false;@overrideWidget build(BuildContext context) {return Focus(onFocusChange: (hasFocus) {setState(() {_isFocused = hasFocus;});},child: GestureDetector(onTap: () {// 탭 시 포커스 요청Focus.of(context).requestFocus();},child: AnimatedContainer(duration: const Duration(milliseconds: 200),width: 200,height: 100,decoration: BoxDecoration(color: _isFocused ? Colors.blue[100] : Colors.grey[200],border: Border.all(color: _isFocused ? Colors.blue : Colors.grey,width: _isFocused ? 2 : 1,),borderRadius: BorderRadius.circular(8),),child: Center(child: Text(_isFocused ? '포커스됨!' : '클릭하세요',style: TextStyle(fontWeight: _isFocused ? FontWeight.bold : FontWeight.normal,),),),),),);}}Focus 위젯 주요 속성
속성 설명 onFocusChange포커스 상태 변경 콜백 onKeyEvent키 이벤트 핸들러 autofocus자동 포커스 여부 canRequestFocus포커스 요청 가능 여부 skipTraversalTab 순회에서 제외 여부
Watch out
WARNING
FocusNode를build()메서드 안에서 생성하면 안 됩니다.
매번 새로운 노드가 생성되어 포커스가 유실됩니다.// ❌ 잘못된 방법@overrideWidget build(BuildContext context) {return Focus(focusNode: FocusNode(), // 매번 새 노드 생성!child: Container(),);}// ✅ 올바른 방법class _MyWidgetState extends State<MyWidget> {late FocusNode _focusNode;@overridevoid initState() {super.initState();_focusNode = FocusNode(debugLabel: 'MyFocusNode');}@overridevoid dispose() {_focusNode.dispose();super.dispose();}@overrideWidget build(BuildContext context) {return Focus(focusNode: _focusNode,child: Container(),);}}
결론: Focus 위젯으로 포커스 가능한 요소를 만들고, onFocusChange로 상태 변화를 감지합니다.
챕터 2: FocusNode로 포커스 제어
Why
NOTE프로그래밍 방식으로 포커스를 제어해야 할 때가 있습니다.
검증 실패 시 해당 필드로 포커스 이동, 대화상자 열릴 때 자동 포커스 등이 필요합니다.이벤트 발생 → focusNode.requestFocus() → 포커스 이동
What
NOTE
FocusNode는 포커스 상태를 관리하는 장수명 객체입니다.
requestFocus(),unfocus(),hasFocus등의 메서드와 속성을 제공합니다.
How
TIPFocusNode 활용
class FocusControlDemo extends StatefulWidget {const FocusControlDemo({super.key});@overrideState<FocusControlDemo> createState() => _FocusControlDemoState();}class _FocusControlDemoState extends State<FocusControlDemo> {late FocusNode _redFocus;late FocusNode _greenFocus;late FocusNode _blueFocus;@overridevoid initState() {super.initState();_redFocus = FocusNode(debugLabel: 'Red');_greenFocus = FocusNode(debugLabel: 'Green');_blueFocus = FocusNode(debugLabel: 'Blue');}@overridevoid dispose() {_redFocus.dispose();_greenFocus.dispose();_blueFocus.dispose();super.dispose();}@overrideWidget build(BuildContext context) {return Column(children: [Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly,children: [_buildFocusableBox(_redFocus, Colors.red),_buildFocusableBox(_greenFocus, Colors.green),_buildFocusableBox(_blueFocus, Colors.blue),],),const SizedBox(height: 24),Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly,children: [ElevatedButton(onPressed: () => _redFocus.requestFocus(),child: const Text('빨강 포커스'),),ElevatedButton(onPressed: () => _greenFocus.requestFocus(),child: const Text('초록 포커스'),),ElevatedButton(onPressed: () => _blueFocus.requestFocus(),child: const Text('파랑 포커스'),),],),const SizedBox(height: 16),ElevatedButton(onPressed: () {// 현재 포커스 해제FocusManager.instance.primaryFocus?.unfocus();},child: const Text('포커스 해제'),),],);}Widget _buildFocusableBox(FocusNode focusNode, Color color) {return Focus(focusNode: focusNode,child: Builder(builder: (context) {final isFocused = Focus.of(context).hasFocus;return Container(width: 80,height: 80,decoration: BoxDecoration(color: isFocused ? color : color.withOpacity(0.3),border: Border.all(color: isFocused ? Colors.black : Colors.grey,width: isFocused ? 3 : 1,),borderRadius: BorderRadius.circular(8),),);},),);}}FocusNode 주요 API
API 설명 requestFocus()이 노드로 포커스 요청 unfocus()포커스 해제 hasFocus포커스 보유 여부 hasPrimaryFocus주 포커스 보유 여부 addListener()포커스 변경 리스너 등록 debugLabel디버깅용 레이블
Watch out
WARNING
unfocus()호출 시 포커스가 완전히 사라지지 않습니다.
다른 노드로 포커스가 이동합니다.// unfocus의 disposition 옵션focusNode.unfocus(// 부모 스코프로 포커스 이동 (기본값)disposition: UnfocusDisposition.scope,);focusNode.unfocus(// 이전에 포커스되었던 자식으로 이동disposition: UnfocusDisposition.previouslyFocusedChild,);포커스 트리에 다른 스코프가 없으면 루트 스코프로 이동하여 Tab 순회가 깨질 수 있습니다.
결론: FocusNode로 포커스를 프로그래밍 방식으로 제어하고, 반드시 dispose()에서 해제합니다.
챕터 3: 키 이벤트 처리
Why
NOTE특정 키 입력에 반응하는 커스텀 동작이 필요합니다.
방향키로 선택 이동, Escape로 대화상자 닫기 등을 구현해야 합니다.키 입력 → onKeyEvent → 처리 또는 전파
What
NOTE
Focus위젯의onKeyEvent콜백으로 키 이벤트를 처리합니다.
KeyEventResult.handled를 반환하면 이벤트가 더 이상 전파되지 않습니다.
How
TIP키 이벤트 처리 기본
class KeyEventDemo extends StatefulWidget {const KeyEventDemo({super.key});@overrideState<KeyEventDemo> createState() => _KeyEventDemoState();}class _KeyEventDemoState extends State<KeyEventDemo> {String _lastKey = '키를 누르세요';int _selectedIndex = 0;final List<String> _items = ['사과', '바나나', '체리', '딸기', '포도'];@overrideWidget build(BuildContext context) {return Focus(autofocus: true,onKeyEvent: (node, event) {// KeyDownEvent만 처리 (반복 입력 방지)if (event is! KeyDownEvent) {return KeyEventResult.ignored;}setState(() {_lastKey = event.logicalKey.keyLabel;});// 방향키 처리if (event.logicalKey == LogicalKeyboardKey.arrowUp) {setState(() {_selectedIndex = (_selectedIndex - 1).clamp(0, _items.length - 1);});return KeyEventResult.handled;}if (event.logicalKey == LogicalKeyboardKey.arrowDown) {setState(() {_selectedIndex = (_selectedIndex + 1).clamp(0, _items.length - 1);});return KeyEventResult.handled;}// Enter 키로 선택 확인if (event.logicalKey == LogicalKeyboardKey.enter) {ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${_items[_selectedIndex]} 선택됨!')),);return KeyEventResult.handled;}return KeyEventResult.ignored;},child: Column(children: [Text('마지막 키: $_lastKey'),const SizedBox(height: 16),...List.generate(_items.length, (index) {return Container(padding: const EdgeInsets.all(8),margin: const EdgeInsets.symmetric(vertical: 4),decoration: BoxDecoration(color: index == _selectedIndex ? Colors.blue[100] : null,border: Border.all(color: index == _selectedIndex ? Colors.blue : Colors.grey,),borderRadius: BorderRadius.circular(4),),child: Text(_items[index]),);}),const SizedBox(height: 16),const Text('↑↓ 이동, Enter 선택'),],),);}}KeyEventResult 옵션
값 설명 handled이벤트 처리됨, 전파 중단 ignored이벤트 무시, 부모로 전파 skipRemainingHandlers나머지 핸들러 건너뜀
Watch out
WARNING키 이벤트는 주 포커스에서 시작하여 루트까지 전파됩니다.
handled를 반환하면 네이티브 컨트롤도 이벤트를 받지 못합니다.onKeyEvent: (node, event) {// TextField 내부에서 이 핸들러가 호출되면// handled 반환 시 TextField에 입력이 안 됨// 특정 조합만 처리하고 나머지는 무시if (HardwareKeyboard.instance.isControlPressed &&event.logicalKey == LogicalKeyboardKey.keyS) {_save();return KeyEventResult.handled;}// 일반 입력은 TextField로 전달되도록 ignored 반환return KeyEventResult.ignored;}
결론: onKeyEvent로 키 입력을 처리하고, KeyEventResult로 이벤트 전파를 제어합니다.
챕터 4: 포커스 순회와 그룹화
Why
NOTETab 키로 논리적인 순서로 포커스가 이동해야 합니다.
관련 요소들을 그룹화하여 그룹 내에서 먼저 순회하도록 할 수 있습니다.그룹 A (버튼1 → 버튼2) → 그룹 B (입력1 → 입력2) → 그룹 C
What
NOTE
FocusTraversalGroup은 포커스 순회 그룹을 정의합니다.
FocusTraversalPolicy로 순회 순서를 커스터마이징할 수 있습니다.
How
TIP기본 포커스 순회 그룹
class FocusTraversalDemo extends StatelessWidget {const FocusTraversalDemo({super.key});@overrideWidget build(BuildContext context) {return Column(children: [// 그룹 1: 네비게이션FocusTraversalGroup(child: Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly,children: [ElevatedButton(onPressed: () {}, child: const Text('홈')),ElevatedButton(onPressed: () {}, child: const Text('검색')),ElevatedButton(onPressed: () {}, child: const Text('설정')),],),),const Divider(),// 그룹 2: 폼 입력FocusTraversalGroup(child: Column(children: [const TextField(decoration: InputDecoration(labelText: '이름')),const SizedBox(height: 8),const TextField(decoration: InputDecoration(labelText: '이메일')),const SizedBox(height: 8),ElevatedButton(onPressed: () {}, child: const Text('제출')),],),),],);}}커스텀 순회 순서
class CustomOrderDemo extends StatelessWidget {const CustomOrderDemo({super.key});@overrideWidget build(BuildContext context) {return FocusTraversalGroup(policy: OrderedTraversalPolicy(),child: Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly,children: [// 숫자가 작은 순서대로 순회: 2 → 1 → 3FocusTraversalOrder(order: const NumericFocusOrder(2),child: ElevatedButton(onPressed: () {},child: const Text('TWO'),),),FocusTraversalOrder(order: const NumericFocusOrder(1),child: ElevatedButton(onPressed: () {},child: const Text('ONE'),),),FocusTraversalOrder(order: const NumericFocusOrder(3),child: ElevatedButton(onPressed: () {},child: const Text('THREE'),),),],),);}}FocusTraversalPolicy 종류
정책 설명 ReadingOrderTraversalPolicy읽기 순서 (기본값) OrderedTraversalPolicy명시적 순서 지정 WidgetOrderTraversalPolicy위젯 트리 순서
Watch out
WARNING
skipTraversal을true로 설정하면 Tab 순회에서 제외되지만,requestFocus()로는 여전히 포커스할 수 있습니다.Focus(skipTraversal: true, // Tab으로 이동 불가child: MyWidget(),)// 하지만 프로그래밍 방식으로는 포커스 가능focusNode.requestFocus();// 완전히 포커스 불가능하게 하려면Focus(canRequestFocus: false, // 어떤 방식으로도 포커스 불가child: MyWidget(),)
결론: FocusTraversalGroup으로 포커스 그룹을 만들고, OrderedTraversalPolicy로 순서를 커스터마이징합니다.
챕터 5: Shortcuts와 Actions 기본
Why
NOTECtrl+S로 저장, Ctrl+Z로 실행 취소 같은 단축키가 필요합니다.
Shortcuts와Actions는 키 조합과 동작을 분리하여 관리합니다.키 조합 (Shortcuts) → Intent → Action (Actions) → 실행
What
NOTE
Shortcuts위젯은 키 조합을Intent로 매핑합니다.
Actions위젯은Intent를Action으로 매핑하여 실행합니다.
How
TIPIntent와 Action 정의
// 1. Intent 정의 - 사용자의 의도를 표현class IncrementIntent extends Intent {const IncrementIntent();}class DecrementIntent extends Intent {const DecrementIntent();}class ResetIntent extends Intent {const ResetIntent();}// 2. Action 정의 - 실제 동작 구현class IncrementAction extends Action<IncrementIntent> {IncrementAction(this.onIncrement);final VoidCallback onIncrement;@overridevoid invoke(covariant IncrementIntent intent) {onIncrement();}}class DecrementAction extends Action<DecrementIntent> {DecrementAction(this.onDecrement);final VoidCallback onDecrement;@overridevoid invoke(covariant DecrementIntent intent) {onDecrement();}}Shortcuts와 Actions 연결
class ShortcutsDemo extends StatefulWidget {const ShortcutsDemo({super.key});@overrideState<ShortcutsDemo> createState() => _ShortcutsDemoState();}class _ShortcutsDemoState extends State<ShortcutsDemo> {int _counter = 0;void _increment() => setState(() => _counter++);void _decrement() => setState(() => _counter--);void _reset() => setState(() => _counter = 0);@overrideWidget build(BuildContext context) {return Shortcuts(shortcuts: const <ShortcutActivator, Intent>{// 키 조합 → Intent 매핑SingleActivator(LogicalKeyboardKey.arrowUp): IncrementIntent(),SingleActivator(LogicalKeyboardKey.arrowDown): DecrementIntent(),SingleActivator(LogicalKeyboardKey.keyR, control: true): ResetIntent(),},child: Actions(actions: <Type, Action<Intent>>{// Intent → Action 매핑IncrementIntent: IncrementAction(_increment),DecrementIntent: DecrementAction(_decrement),ResetIntent: CallbackAction<ResetIntent>(onInvoke: (intent) => _reset(),),},child: Focus(autofocus: true,child: Column(mainAxisAlignment: MainAxisAlignment.center,children: [Text('$_counter',style: const TextStyle(fontSize: 48),),const SizedBox(height: 24),const Text('↑: 증가, ↓: 감소, Ctrl+R: 초기화'),],),),),);}}단축키 정의 방식
방식 사용 시점 SingleActivator단일 키 + 수정자 (권장) LogicalKeySet여러 키 조합 CharacterActivator문자 기반 입력
Watch out
WARNING
Shortcuts와Actions는 포커스된 위젯의 조상에 있어야 합니다.
포커스가 다른 곳에 있으면 단축키가 작동하지 않습니다.// ❌ Focus가 Actions 바깥에 있어서 작동 안 함Column(children: [Focus(autofocus: true, child: TextField()),Actions(actions: {...},child: Shortcuts(shortcuts: {...},child: Container(),),),],)// ✅ Focus가 Actions 안에 있어서 작동함Actions(actions: {...},child: Shortcuts(shortcuts: {...},child: Focus(autofocus: true,child: Column(children: [TextField(), Container()],),),),)
결론: Intent로 의도를, Action으로 동작을 분리하고, Shortcuts와 Actions로 연결합니다.
챕터 6: CallbackShortcuts와 고급 기능
Why
NOTE간단한 단축키는 Intent와 Action을 정의하는 것이 번거로울 수 있습니다.
CallbackShortcuts는 직접 콜백을 연결하여 코드를 간소화합니다.키 조합 → 콜백 함수 (직접 실행)
What
NOTE
CallbackShortcuts는 키 조합에 콜백을 직접 매핑합니다.
FocusableActionDetector는 포커스, 마우스, 단축키를 한 번에 처리합니다.
How
TIPCallbackShortcuts 사용
class SimpleShortcutsDemo extends StatefulWidget {const SimpleShortcutsDemo({super.key});@overrideState<SimpleShortcutsDemo> createState() => _SimpleShortcutsDemoState();}class _SimpleShortcutsDemoState extends State<SimpleShortcutsDemo> {int _count = 0;@overrideWidget build(BuildContext context) {return CallbackShortcuts(bindings: <ShortcutActivator, VoidCallback>{const SingleActivator(LogicalKeyboardKey.arrowUp): () {setState(() => _count++);},const SingleActivator(LogicalKeyboardKey.arrowDown): () {setState(() => _count--);},const SingleActivator(LogicalKeyboardKey.escape): () {setState(() => _count = 0);},},child: Focus(autofocus: true,child: Center(child: Text('Count: $_count', style: const TextStyle(fontSize: 32)),),),);}}FocusableActionDetector 사용
class CustomButton extends StatefulWidget {const CustomButton({super.key,required this.label,required this.onPressed,});final String label;final VoidCallback onPressed;@overrideState<CustomButton> createState() => _CustomButtonState();}class _CustomButtonState extends State<CustomButton> {bool _isFocused = false;bool _isHovered = false;@overrideWidget build(BuildContext context) {return FocusableActionDetector(onFocusChange: (focused) => setState(() => _isFocused = focused),onShowFocusHighlight: (show) => setState(() => _isFocused = show),onShowHoverHighlight: (show) => setState(() => _isHovered = show),actions: <Type, Action<Intent>>{ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: (intent) => widget.onPressed(),),},shortcuts: const <ShortcutActivator, Intent>{SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),SingleActivator(LogicalKeyboardKey.space): ActivateIntent(),},child: GestureDetector(onTap: widget.onPressed,child: AnimatedContainer(duration: const Duration(milliseconds: 150),padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),decoration: BoxDecoration(color: _isHovered ? Colors.blue[100] : Colors.grey[100],border: Border.all(color: _isFocused ? Colors.blue : Colors.grey,width: _isFocused ? 2 : 1,),borderRadius: BorderRadius.circular(8),),child: Text(widget.label,style: TextStyle(fontWeight: _isFocused ? FontWeight.bold : FontWeight.normal,),),),),);}}LoggingActionDispatcher로 디버깅
class LoggingActionDispatcher extends ActionDispatcher {@overrideObject? invokeAction(covariant Action<Intent> action,covariant Intent intent, [BuildContext? context,]) {print('Action: $action, Intent: $intent');return super.invokeAction(action, intent, context);}}// 사용Actions(dispatcher: LoggingActionDispatcher(),actions: {...},child: child,)
Watch out
WARNING플랫폼별 단축키 충돌에 주의해야 합니다.
macOS는 Cmd를, Windows/Linux는 Ctrl을 사용합니다.// 플랫폼별 수정자 처리SingleActivator(LogicalKeyboardKey.keyS,// macOS에서는 Cmd, 그 외에서는 Ctrlmeta: Platform.isMacOS,control: !Platform.isMacOS,)// 또는 ControlOrMeta 사용shortcuts: const <ShortcutActivator, Intent>{SingleActivator(LogicalKeyboardKey.keyS,control: true, // Windows/Linuxmeta: true, // macOS에서 Cmd로 처리됨): SaveIntent(),}
결론: 간단한 단축키는 CallbackShortcuts로, 복합 위젯은 FocusableActionDetector로 구현합니다.
한계
키보드 포커스와 단축키 시스템에는 몇 가지 한계가 있습니다.
- 플랫폼 차이: 키보드 레이아웃과 단축키 관례가 플랫폼마다 다릅니다.
- 네이티브 단축키 충돌: 시스템 단축키와 충돌할 수 있습니다.
- 접근성: 스크린 리더 사용자를 위한 추가 고려가 필요합니다.
- 모바일 제한: 물리적 키보드가 없는 환경에서는 적용되지 않습니다.
Footnotes
-
Focus(포커스): 키보드 입력을 받을 위젯을 지정하는 시스템이다. 포커스된 위젯만 키 이벤트를 받는다. ↩
-
Shortcuts(단축키): 키 조합을 Intent로 매핑하는 위젯이다. ↩
-
Actions(액션): Intent를 Action으로 매핑하여 실행하는 위젯이다. ↩
-
FocusNode(포커스 노드): 포커스 상태를 관리하는 장수명 객체다. requestFocus()로 포커스를 요청한다. ↩
-
Intent(인텐트): 사용자의 의도를 추상화한 클래스다. Action과 연결되어 실제 동작을 수행한다. ↩
-
FocusTraversalGroup(포커스 순회 그룹): Tab 키로 포커스가 이동하는 그룹을 정의하는 위젯이다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!