✅ 기본 문제 - HelloTextField
✅ 심화 문제 - CatchGreen
오늘 기본 문제는 이런 TextField를 만드는 것이다. TextField에서 enter를 누르면 다음 필드로 이동하고, 텍스트가 없는 상태에서 backspace를 누르면 이전 필드로 이동한다.
나는 FocusNode를 사용해서 구현했는데, 지금 보니 플러터에는 foucs와 관련된 다양한 클래스들이 있다. 전반적으로 좀 살펴보자.
플러터의 포커스 시스템은 키보드를 제어하고, 키보드 입력값을 앱의 특정 위치에 전달한다. 이를 위해 사용자는 앱의 특정 UI를 클릭 또는 탭 하여 포커스를 설정해야 한다. 그러면 그 다음 포커스 이동이 있기 전까지 키보드의 입력을 애플리케이션이 감지하고, 저장할 수 있다. 포커스의 이동은 Tab키 등의 단축키를 사용해서 일어나기도 하는데, 이를 "tab traversal(탭 탐색)"이라고 한다.
Focus tree
위젯 트리의 요소들 중 포커스가 가능한 위젯을 모아놓은 트리다.
Focus node
Focus tree의 단일 노드다. 이 노드는 포커스를 받을 수 있으며, Focus chain의 일부일 때 "포커스를 받았다"라고 얘기하며, 포커스를 받았을 때만 key 이벤트를 처리할 수 있다.
Primary focus
Focus tree의 루트 노드에서 가장 멀리 있는 노드로, 상위 노드로의 key 이벤트 propagating이 시작되는 노드다.
Focus chain
Primary focus에서 루트까지 이어지는 Focus node의 정렬된 리스트다.
Focus scope
각기 다른 Focus node들을 그룹으로 만들어서, 해당 그룹의 노드들만 포커스 될 수 있도록 하는 특별한 Focus node다. 해당 서브트리 내에서 어떤 노드가 포커스 되었었는지에 대한 정보를 담고 있다.
Focus traversal
예측 가능한 순서에 따라서 포커스 되고 있는 노드를 순회하는 과정이다. Tab 키 등을 통해 다음 필드로 이동할 때 많이 나타난다.
더 자세한 내용은 다음 페이지를 참고!
--> Understanding Flutter's keyboard focus system
Focus와 FocusScope는 각각 FocusNode와 FocusScopeNode를 관리하는 위젯이다. 보통 노드를 직접 관리해야만 하는 상황들이 아니라면, Focus와 FocusScope 위젯을 사용하여 노드를 관리한다.
이들을 관리한다는 것은, 노드의 수명 주기를 관리하고, 포커스 변경 사항을 수신하고, 포커스 트리와 위젯 트리의 동기화를 위해 부모를 다시 지정하는 등의 작업을 수행하는 것이다.
FocusScope는 Focus와 유사하지만, 해당 하위 항목의 범위를 지정하여 Focus traversal을 제한한다. 예를 들어, 경로가 바뀌면 새로운 FocusScope가 자동으로 생성되어 이전 경로의 노드로 순회하는 것을 방지한다.
사실 나는 Focus나 FocusScope 위젯을 사용하지 않고, FocusNode를 냅다 생성해서 사용했다. 어떤 게 맞는 방법인가?는 잘 모르겠다. 하지만 글을 작성하면서 또 다른 내용을 배우게 되어서 좋다. 다음에는 꼭 적용해 봐야지 하는 다짐을 하게 된다!
final TextEditingController _leftInputController =
TextEditingController(text: 'Hello');
final TextEditingController _rightInputController =
TextEditingController(text: 'FlutterBoot!');
final FocusNode _leftInputFocusNode = FocusNode();
final FocusNode _rightInputFocusNode = FocusNode();
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Hello TextField!'),
),
body: SafeArea(
child: Center(
child: Row(
children: [
const SizedBox(width: 20),
Expanded(
child: TextField(
controller: _leftInputController,
focusNode: _leftInputFocusNode,
onEditingComplete: () => _rightInputFocusNode.requestFocus(),
),
),
const SizedBox(width: 20),
Expanded(
child: TextField(
controller: _rightInputController,
focusNode: _rightInputFocusNode,
onEditingComplete: () => _leftInputFocusNode.requestFocus(),
),
),
const SizedBox(width: 20),
],
),
),
),
);
}
이렇게 각각의 TextField를 위한 컨트롤러와 노드를 할당해 줬다. enter 키를 입력해 줬을 때 다음 노드로 포커스를 이동시키는 것은 쉬웠다. onEditingComplete
속성으로, 입력이 끝나면 다음 포커스로 이동하는 함수가 호출되도록 했다.
문제는 backspace였다. 문제에서 요구하는 것은 텍스트가 없을 때 backspace 키가 입력되면 포커스를 이동하는 것이었다.
TextField 컨트롤러에 리스너를 달면 필드의 마지막 텍스트가 지워지는 동시에 포커스가 이동된다.
TextField의 onChanged
함수는 isEmpty
일 때 호출되지 않기 때문에, 텍스트가 없을 때 입력되는 backspace 키를 감지하지 못한다.
그래서 사용한 것이 다음의 FocusNode.onKey이다!
FocusNode의 onKey
는 포커스 된 노드가 key 이벤트를 수신했을 때 실행되는 콜백 함수다. 즉, TextField에 key 이벤트가 수신될 때마다 해당 콜백 함수가 실행된다. 콜백 함수의 원형은 이렇다.
FocusOnKeyCallback = KeyEventResult Function(
FocusNode node,
RawKeyEvent event
)
콜백에서 받은 FocusNode와 RawKeyEvent를 사용하여 _handleBackspaceKeyEvent()
라는 포커스를 이동시키는 함수를 구현했다.
void initState() {
super.initState();
_leftInputFocusNode.onKey = (node, event) => _handleBackspaceKeyEvent(
node: node,
event: event,
controller: _leftInputController,
prevNode: _rightInputFocusNode,
);
_rightInputFocusNode.onKey = (node, event) => _handleBackspaceKeyEvent(
node: node,
event: event,
controller: _rightInputController,
prevNode: _leftInputFocusNode,
);
}
이렇게 각각의 노드에 _handleBackspaceKeyEvent()
함수를 넣어줬다. 저 함수가 뭐 하는 친구인지는 다음 항목에서!
RawKeyEvent는 key 이벤트에 대한 인터페이스를 정의한다. 대표적으로 다음과 같은 종류의 이벤트가 있다.
- RawKeyDownEvent: 키보드 키가 눌렸을 때 발생하는 이벤트다.
- RawKeyUpEvent: 키보드 키가 눌린 상태에서 해제될 때 발생하는 이벤트다.
- RawKeyRepeatEvent: 키가 길게 눌린 경우 반복되는 이벤트다.
이 외에도 정말 다양한 이벤트들이 있다. 위의 세 이벤트는 모두 LogicalKeyboardKey를 통해 어떤 키가 눌렸는지 확인할 수 있다.
KeyEventResult _handleBackspaceKeyEvent({
required FocusNode node,
required RawKeyEvent event,
required TextEditingController controller,
required FocusNode prevNode,
}) {
if (event is RawKeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.backspace &&
controller.text.isEmpty) {
prevNode.requestFocus();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
_handleBackspaceKeyEvent()
함수에서는 RawKeyEvent의 정보와, TextField 컨트롤러에서 텍스트를 확인하고, 조건이 맞는 경우 이전 노드로 포커스가 이동하게 했다.
심화 문제는 "Start!" 버튼을 누르면 타이머가 시작되면서 랜덤한 위치에 초록색 공이 뜨고, 공을 누르면 타이머가 멈추는 게임을 만드는 것이다.
공을 눌렀을 때 타이머를 멈추는 함수를 호출하기 위해서 GestureDetector 위젯을 사용했다. 이 위젯은 말 그대로 제스처를 감지하는 위젯이다.
GestureDetector는 탭, 드래그, 스케일 등 다양한 제스처를 감지한다. 나는 탭을 감지할 때 호출되는 콜백 onTap()
을 사용했다.
Widget _buildGreenWidget() {
return GestureDetector(
onTap: () => setState(() {
_stopTimer();
}),
child: _showGreen
? Stack(
children: [
Align(
alignment: _green.position,
child: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.lightGreenAccent.shade400,
shape: BoxShape.circle,
),
),
),
],
)
: const SizedBox.shrink(),
);
}
위의 초록색 공은, "Start!" 버튼을 누른 뒤 일정 시간이 지난 뒤에 나타난다. 때문에 버튼을 누르고 일정 공이 나타날 때까지 딜레이 시켜줄 함수가 필요했다.
Future<T>.delayed(
Duration duration,
[FutureOr<T> computation()?]
)
Future.delayed 클래스는 duration
만큼의 시간이 지난 후 computation()
을 실행한다.
저번에 타이머 공부할 때 이 문구는 귀신같이 쏙 빼놓고 봤더라.
Note: If Dart code using Timer is compiled to JavaScript, the finest granularity available in the browser is 4 milliseconds.
위에 있는 이미지를 보면, 타이머가 밀리 초까지 측정을 해준다. 하지만 웹 시뮬레이터를 사용하면, 브라우저에서 4밀리초 단위로밖에 프레임을 나누지 못한다는 것이다.
Timer _timer;
double _elapsedTime = 0.0;
_timer = Timer.periodic(const Duration(milliseconds: 1), (timer) {
setState(() {
_elapsedTime++;
});
});
이런 식으로 Duration을 1 millisecond로 주면, 밀리 초 단위로 타이머를 표시할 수 있을 거라 생각했는데, 어쩐지 시간이 계속 느리게 가는 듯한 기분이더라.
그래서 사용한 것이 Stopwatch 클래스다. Stopwatch는 작동하는 동안 시간을 측정해 준다. elapsed
, elapsedMilliseconds
, elapsedMicroseconds
등의 속성으로 각 형식에 맞는 시간을 구할 수 있다.
Timer _timer;
final Stopwatch _stopwatch = Stopwatch();
String _elapsedTime = '0:00.000';
void startTimer() {
_stopwatch.start();
_timer = Timer.periodic(const Duration(milliseconds: 4), (Timer timer) {
setState(() {
_elapsedTime =
getFormattedTime(milliseconds: _stopwatch.elapsedMilliseconds);
});
});
}
void stopTimer() {
_stopwatch.stop();
_timer.cancel();
setState(() {
_elapsedTime =
getFormattedTime(milliseconds: _stopwatch.elapsedMilliseconds);
});
}
String getFormattedTime({required int milliseconds}) {
int seconds = (milliseconds / 1000).truncate();
int minutes = (seconds / 60).truncate();
String milliSecondsStr = (milliseconds % 1000).toString().padLeft(3, '0');
String minutesStr = (minutes % 60).toString().padLeft(2, '0');
String secondsStr = (seconds % 60).toString().padLeft(2, '0');
return '$minutesStr:$secondsStr.$milliSecondsStr';
}
이렇게 _stopwatch.elapsedMilliseconds
로 스톱워치의 밀리 초를 구해서, String으로 변환해 줬다.
이 방식으로 구현하면 시간이 4밀리 초마다 업데이트되기 때문에, 정확성이 떨어진다는 문제가 있다. 때문에 마지막에 초록색 버튼을 클릭해서 _stopwatch.stop();
을 했을 때, _elapsedTime
을 정확한 값으로 다시 업데이트해 줬다.
이렇게 또 플랫폼별 컴파일 환경을 잘 고려해야 한다는 것을 배웠다! 🥦🥦🥦
오늘은 Timer 때문에 꽤나 애먹었다... 그래도 해결해서 너무 다행이다. 😂
아 그리고 사실 Focus도 이것저것 시도해 보느라 시간을 굉장히 많이 썼다. 심화문제까지 풀 시간이 점점 부족해질지도 모른다는 생각이 든다. 그래도~ 할 수 있는 데까지는 열심히 해봐야지!
https://flutter-ko.dev/development/ui/advanced/focus
https://api.flutter.dev/flutter/widgets/GestureDetector-class.html
https://api.flutter.dev/flutter/dart-core/Stopwatch-class.html
와... Flutter 5년차인데 Timer가 웹에서 4밀리초 단위로 동작하는건 첨 알았네요. 사이드 프로젝트로 웹 플러터 할 때 이상하다고 느꼈는데 이제서야 깨닫고 갑니다.