Search
Duplicate

CH.5 사용자 입력: 폼과 제스처

목차

CHAPTER POINT

제스처 감지기를 이용한 사용자와의 상호작용
Dismissible 위젯 같은 특별한 상호작용 위젯
텍스트 입력, 드롭다운, 리스트 등 다양한 폼 요소
플러터에서 폼 만들기
키로 플러터 폼 관리하기

1. 사용자 상호작용과 제스처

탭(tap), 드래그(drag), 팬(pan)등의 이벤트를 감지하고 응답하는 방법에 대해 알아보자.

1.1 GestureDetector 위젯

위젯들을 감싸 사용자와 상호작용을 할 수 있게 해주는 위젯
상호작용을 감지하면 지정된 콜백을 실행한다.
GestureDetector( onTap: () => print('tapped!"), child: Text("Tap Me"), );
Dart
복사
위처럼 제스처가 발생할 때 실행할 콜백과 child에 들어갈 위젯을 전달해야 한다.
이 GestureDetector 위젯은 기본적으로는 하나의 제스처 콜백을 요구하지만 감지한 제스처 종류에 따라 반응을 다르게 하도록 콜백을 여러 개 전달할 수 있다.

제스처 종류

onTap
onTapDown
onDoubleTap
onVerticalDragDown
onScaleStart
onTapUp
onLongPress
onHorizontalDragStart
onPanDown
이 중에서, 몇몇 콜백은 세부정보를 인수로 전달한다.
ex: onTapUp은 globalPosition이나 화면에서 탭이 발생한 위치 정보를 포함하는 TapUpDetail을 전달한다.

1.2 GestureDetector 사용하기

구체적인 예제 코드로 제스처 감지기를 살펴보자. 날씨 앱에서는 forecast 페이지에서 GestureDetector 위젯을 사용한다. 하면을 두 번 탭하면 온도 단위가 바뀐다.
코드를 살펴보자
body: GestureDetector( // --- (1) onDoubleTap: () { // --- (2) setState(() { widget.settings.selectedTemperature == TemperatureUnit.celsius ? widget.settings.selectedTemperature = TemperatureUnit.fahrenheit : widget.settings.selectedTemperature = TemperatureUnit.celsius; }); }, onVerticalDragUpdate: (DragUpdateDetails details) {// --- (3) _handleDragEnd(details, context); }, child: ColorTransitionBox(...),// --- (4) ),
Dart
복사
1.
GestureDetector위젯은 사용자의 동작을 기다린다.
2.
더블클릭(더블탭) 제스처를 기다린다.
3.
제스처 세부 정보를 제공하는 더 복잡한 제스처이다.
4.
GestureDetector는 위젯을 자식으로 받아 자식은 제스처에 반응할 수 있게 된다.
이 코드에서 onVerticalDragUpdate를 좀 더 살펴보자.
사용자가 손가락을 위나 아래로 드래거할 때마다 onVerticalDragUpdate로 전달한 콜백을 호출한다. 콜백을 호출할 때 드래그 정보를 DragUpdateDetails 클래스의 인스턴스로 전달한다.
DragUpdateDetails
DragUpdateDetails에는 위 사진과 같이 여러 정보들이 있지만 예제에서는 globalPosition정보만 이용한다. 해당 정보로 드래그가 발생한 정확한 위치를 알 수 있기에 이에 맞는 동작 수행이 가능하다. 위 코드에서는 onVerticalDragUpdate메서드가 호출되면 ForecastPageState._handleDragEnd를 호출한다. 이 함수는 화면을 위아래로 드래그해서 시간을 설정한다.
날씨앱은 화면을 가상의 8개의 행으로 구분한다. 위 이미지에 나와있는 시간(3:00~24:00)과 동일하다. 그래서 최상단은 3:00시를 가리키고 최하단은 오후 24:00시를 가리킨다.
그래서 화면에서 손가락을 수직으로 드래그해서 여덟 구간을 이동하면 각각 영역에 맞는 시간의 날씨 예보로 바뀐다.
void _handleDragEnd(DragUpdateDetails d, BuildContext context) { var screenHeight = MediaQuery.of(context).size.height; // --- (1) var dragEnd = d.globalPosition.dy; // --- (2) var percentage = (dragEnd / screenHeight) * 100.0; // --- (3) var scaleToTimesOfDay = (percentage ~/ 12).toInt(); // --- (4) if (scaleToTimesOfDay > 7) scaleToTimesOfDay = 7; _handleStateChange(scaleToTimesOfDay); }
Dart
복사
1.
MediaQuery로 화면 높이를 얻은 뒤 화면을 여덟개의 가상 영역으로 계산한다.
2.
onVerticalDragUpdate가 호출되었을 때 globalPosition.dy는 화면 포인터의 y좌표를 가리킨다.
3.
기존의 두 숫자(dragEnd, screenHeight)를 이용해 위치를 0에서 100사이의 숫자로 변환한다.(백분율)
4.
이 숫자를 0~7사이의 숫자로 변환하는데 이는 선택할 수 있는 총 여덟 개의 시간옵션을 가르킨다.

날씨 앱의 가상 데이터 생성

날씨 앱에서 매 시간별 날씨 정보들은 어떻게 만들까?
dailyForecastGenerator 메서드가 해당 작업을 수행하는데, 이를 TimerPickerRow가 이 데이터를 이용해 옵션을 제공한다.
당장 플러터학습과 관련은없지만 날씨앱을 좀 더 이해하고자한다면 참고하자.
ForecastDay dailyForecastGenerator(City city, int low, int high) { List<Weather> forecasts = []; // --- (1) int runningMin = 555; int runningMax = -555; for (var i = 0; i < 8; i++) { // --- (2_ startDateTime = startDateTime.add(Duration(hours: 3));// --- (3) final temp = _random.nextInt(high); WeatherDescription randomDescription = generateTimeAwareWeatherDescription(startDateTime); final tempBuilder = Temperature( current: temp, temperatureUnit: TemperatureUnit.celsius, ); forecasts.add(// --- (4) Weather( city: city, dateTime: startDateTime, description: randomDescription, cloudCoveragePercentage: generateCloudCoverageNum(randomDescription), temperature: tempBuilder, ), ); runningMin = math.min(runningMin, temp); runningMax = math.max(runningMax, temp); } final forecastDay = ForecastDay( hourlyWeather: forecasts, min: runningMin, max: runningMax, date: dailyDate, ); dailyDate.add(Duration(days: 1)); return forecastDay; }
Dart
복사
1.
시간별 날씨 정보를 포함하는 컬렉션
2.
데이터가 8행에 맞게 8개 필요하기에 반복한다.
3.
루프를 순회할 때마다 3시간씩 추가한다.
4.
앱에서 임의로 만든 새로운 Weather 인스턴스를 리스트로 추가한다.

1.3 Dismissible 위젯

날씨앱의 Dismissible 위젯 예제
Dismissible 위젯은 좀 까다로운데, 날씨앱에서는 위 그림처럼 도시명 Row를 touchdown한 상태로 좌측으로 밀면 해당 Row가 삭제되는것을 볼 수 있다. 이러한 기능을 내장위젯인 Dismissible로 구현하는데, 날씨앱의 코드를 통해 살펴보자.
child: ListView.builder( shrinkWrap: true, itemCount: allAddedCities.length, itemBuilder: (BuildContext context, int index) { final city = allAddedCities[index]; return Dismissible( // --- (1) onDismissed: (DismissDirection dir) => _handleDismiss(dir, city), // --- (2) confirmDismiss: (DismissDirection dir) async => dir == DismissDirection.endToStart, background: Container(// --- (3) child: Icon(Icons.delete_forever), decoration: BoxDecoration(color: Colors.red[700]), ), key: ValueKey(city), // --- (4) child: CheckboxListTile(// --- (5) value: city.active, title: Text(city.name), onChanged: (bool b) => _handleCityActiveChange(b, city), ), ); }, ),
Dart
복사
1.
특정 사용자 제스처에 응답하는 내장위젯이다.
2.
Dismissible의 주요 액션으로 버튼의 onTap과 비슷한 기능을 제공한다. 콜백은 DismissDirection 형식의 enum을 포함하므로 이를 이용해 사용자의 동작 방향에 따라 필요한 동작을 수행한다.
3.
해당 항목을 화면에서 없애는 동안 배경 피드백으로 보여줄 위젯이다.
4.
Dismissible이 리스트 항목일 경우 반드시 고유 키를 가져야 한다. Dismissible은 대부분 리스트 항목에 사용한다.
5.
어떤 위젯이든 자식으로 사용할 수 있다.
여기서 Dismissible에 고유키가 없다면 어떻게 될까?
위 코드에서 두 번째 항목을 없앤다면 어떻게 될까?
1.
Dismissible 을 밀어 위젯트리에서 제거
2.
위젯이 제거될 때 setState를 호출하기에 플러터는 위젯을 다시 빌드
3.
이 시점에서 Dismissible위젯은 하나 삭제된 상태이다. 플러터가 다시 빌드할 때 실행하는 작업인, 요소 트리의 모든 요소가 자신과 관련된 위젯을 찾기 시작한다. 키가 없다면 모든 요소는 다시 그리기 전의 트리 위치에 있던 위젯과 현재 위젯을 비교한다.
4.
그 중 한 요소는 다시 그리기전 트리 위치에 있던 위젯이 안보인다고 말할 것이다.
5.
삭제되지않은 위젯을 비교하는 나머지 요소들은 문제가 없지만 삭제된 위젯을 가리켜야하는 요소는 가리킬 요소가 없어 오류가 발생한다.
하지만, 모든 위젯이 키를 가지고 있다면 요소는 자신이 가리키던 위젯이 사라졌음을 알 수 있기 때문에 오류를 발생시키지 않는다.
Dismissible위젯은 다른 모든 위젯이 가지는 기본 규칙
이들은 상호 작용을 하지 않는 위젯을 감싸 제스처 감지 기능을 추가한다.
데이터를 알맞게 처리할 수 있도록 상호작용 이벤트 정보를 전달할 콜백을 제공한다.
외에 Dismissible 위젯이 화면에서 움직이는동안 배경이 될 위젯(background에 전달될 인수)이 있어야 한다.

2. 플러터 폼

우리가 손쉽게 접하는 폼을 구현할 때 고려해야하는 작업은 생각보다 많다.
우선, 상태를 처리해야 하고, 이벤트를 기다려야 하며, 입력값을 데이터베이스가 처리할 수 있는 형태로 변환해야 한다.
날씨앱에서는 도시를 추가하는 페이지인 add_city_page.dart에서 폼을 제공한다.
해당 폼 기능 구현에 필요한 요구사항은 다음과 같다.
사용자가 데이터를 추가할 수 있는 필드를 포함하는 폼 UI 생성
사용자가 폼을 제출하면 모든 데이터를 추출하는 기능 구현 각각의 필드를 하나하나 꺼내기보다 모든 필드를 한 번에 처리하는게 좋다(hint: key)
사용자가 입력한 데이터의 유효성을 확인한다.
데이터를 데이터베이스로 추가할 수 있도록 비즈니스 로직으로 데이터를 전달한다.
우선은 플러터 폼에 대략적인 개념을 알아본 뒤 예제코드를 살펴보자.

2.1 Form 위젯

모든 폼 필드 위젯을 서브트리에 포함하며 폼 작업에 필요한 메서드를 제공하는 일종의 래퍼(wrapper)위젯.
폼 안에 있는 필드들의 상태는 폼이 관리하기에 앱이 상태를 따로 관리해줄 필요가 없다.
Form 예제
위 Form예제 그림을 보면 앱 화면에 있는 여러 항목과 작업들을 Form이 다 관리함을 알 수 있다.
FormState 키 형식을 전달해서 Form과 상호작용을 한다.
위젯은 전달받은 특정 폼 상태 객체를 GlobalKey와 연결하기 때문에 어디서든 상태 객체에 접근할 수 있다. 날씨앱 예제에서 전역 키 사용을 추천하는 유일한 경우이다.
요소 트리에 저장된 폼 상태
이 폼 요소는 플러터가 내부에 만든 FormState 객체의 참조를 갖는데 이 상태 객체를 키 참조 덕분에 이용할 수 있다.

2.2 GlobalKey<FormState>

폼 키(FormState 서브형식의 전역 키)는 다른 위젯에서 컨트롤러를 사용하는 것과 비슷하다.
FormState는 폼 로직 관리에 필요한 메서드를 제공하는데 일반적으로 다음과 같은 메서드를 자주 사용한다.
FormState.save
FormState.reset
FormState.validate
폼을 만들떄는 키(FormState객체의 참조 제공)를 사용하며, FormState 객체의 모든 로직과 프로퍼티는 이 키로 접근하며 폼의 모든 위젯과 그 자식도 FormState 객체를 이용한다.

2.3 AddCityPage 폼의 구조

날씨앱의 add_city_page.dart 폼 코드를 개략적으로나마 살펴보며 전반적인 API와 이동 기능을 알아보자.
class AddNewCityPage extends StatefulWidget { final AppSettings settings; const AddNewCityPage({Key key, this.settings}) : super(key: key); _AddNewCityPageState createState() => _AddNewCityPageState(); } class _AddNewCityPageState extends State<AddNewCityPage> { City _newCity = City.fromUserInput(); // --- (1) bool _formChanged = false; // --- (2) bool _isDefaultFlag = false; final GlobalKey<FormState> _formKey = GlobalKey<FormState>(); // --- (3) FocusNode focusNode; void initState() {...} void dispose() {...} bool validateTextFields() {...} Widget build(BuildContext context) { return Scaffold( appBar: AppBar(...), body: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: Form( // --- (4) key: _formKey, // --- (13) onChanged: _onFormChange, onWillPop: _onWillPop, child: ListView( shrinkWrap: true, children: <Widget>[ Padding( // --- (5) padding: const EdgeInsets.symmetric(vertical: 8.0), child: TextFormField( onSaved: (String val) => _newCity.name = val, decoration: InputDecoration( border: OutlineInputBorder(), helperText: "Required", labelText: "City name", ), autofocus: true, autovalidate: _formChanged, validator: (String val) { if (val.isEmpty) return "Field cannot be left blank"; return null; }, ), ), Padding(// --- (6) padding: const EdgeInsets.symmetric(vertical: 8.0), child: TextFormField(...), ), CountryDropdownField(...), // --- (7) FormField(...),// --- (8) Divider(...), Row(// --- (9) mainAxisAlignment: MainAxisAlignment.end, children: <Widget>[ Padding( padding: const EdgeInsets.all(8.0), child: FlatButton(...),// --- (10) ), Padding( padding: const EdgeInsets.all(8.0), child: RaisedButton(// --- (11) color: Colors.blue[400], child: Text("Submit"), onPressed: _formChanged ? () { if (_formKey.currentState.validate()) { _formKey.currentState.save(); _handleAddNewCity(); Navigator.pop(context); } else { FocusScope.of(context).requestFocus(focusNode); } } : null, ), ) ], ), ], ), ), ), ); } void _onFormChange() {...} void _handleAddNewCity() {...} Future<bool> _onWillPop() { if (!_formChanged) return Future<bool>.value(true); return showDialog<bool>( context: context, builder: (BuildContext context) { // --- (12) return AlertDialog(...); } }
Dart
복사
1.
저장소로 제출할 값이다.
2.
폼의 UI상태를 관리하는데 사용한다.
3.
폼의 현재 상태를 관리하는데 사용되는 키
FormState 서브형식을 갖는데 플러터는 키처럼 접근할 수 있는 FormState객체를 만든다.
4.
폼의 루트, 모든 폼 필드는 이 위젯의 자식이다.
새 Form 위젯을 만드는 부분인데, Form 위젯은 상태를 가지며 상태 객체는 키 객체로 전달한 키와 연결된다.
5.
City폼 필드
6.
State나 Territory폼 필드
7.
폼의 드롭다운 필드
8.
기본 도시를 설정하는 체크박스
9.
폼의 아랫부분으로 사용자가 입력을 제출하거나 취소하는 기능을 제공한다.
10.
Cancel 버튼
11.
Submit 버튼
12.
제출하지 않은 데이터가 이있는 상태에서 페이지를 나가려 하면 다이얼로그를 보여준다.
13.
키를 이용해 폼의 서브트리에 있는 모든 위젯에서 폼의 상태에 접근할 수 있다.

3. FormField 위젯

Form은 FormState 객체를 이용해 여러 입력필드를 처리하고 외부에서도 사용이 가능하다.
하지만, FormField위젯을 이용해 폼이 입력을 관리할 수 있다.
모든 입력 위젯은 폼 필드 위젯으로 감싸야 폼에서 이를 관리할 수 있다. 이러한 FormField 위젯은 세가지가 있는데 다음과 같다.
FormField: 모든 입력 위젯을 폼으로 바꾸는 표준 필드
TextFormField: 텍스트 필드를 감싸는 데 특화된 필드
DropdownButtonFormField: DropdownButton을 폼 필드로 감싸는 위젯
날씨 앱의 도시 추가 페이지에 나온 Form 위젯 내에는 총 4개의 폼 필드를 사용한다.
Form 예제

3.1 TextFormField 위젯

날씨 앱에서 사용하는 코드부분을 확인하여 세 가지 형식의 폼 필드를 살펴보자.
우선 TextFormField를 확인하자
Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: TextFormField( // --- (1) onSaved: (String val) => _newCity.name = val, // --- (2)(7) decoration: InputDecoration( // --- (3) border: OutlineInputBorder(), helperText: "Required", labelText: "City name", ), autofocus: true, // --- (4) autovalidate: true, // --- (5) validator: (String val) { // --- (6) if (val.isEmpty) return "Field cannot be left blank"; // --- (8) return null; }, ), ),
Dart
복사
1.
TextFormField는 TextField와 FormField를 합친 위젯으로 이 두 위젯과 비슷한 인수를 갖는다.
2.
폼 필드의 모든 자식에서 호출하는 특별한 FormField 메서드다.
3.
InputDecoration은 TextField 폼 요소에서만 제공하는 프로퍼티다.
4.
이 페이지로 이동했을 때 자동으로 포커스를 받도록 설정하는 TextField의 프로퍼티다.
5.
사용자가 텍스트를 입력할 때마다 입력된 내용을 검증할지 지시한다.
6.
사용자의 입력을 검증하는 데 사용 가능한 콜백을 받는 특별한 FormField 메서드다.
7.
포커스를 가진 상태에서 내용을 바꾸면 필드의 내용을 바로 검증한다.
8.
FormState.onSave를 호출하면 도시 인스턴스를 새 이름으로 갱신한다.

TextFormField의 중요 파라미터 3가지

validator : 콜백을 기다리는 모든 폼 필드의 파라미터로 TextFormField는 입력값을 String으로 전달한다. 이때 빈 값이나 null을 반환하면 오류가 발생하지 않는다. 해당 함수는 폼 필드의 모든 필드와 서브트리를 관리하는 FormState의 기능 중 하나다. FormState.validate()를 호출해 사용자의 입력을 검증하면 폼 필드의 validator 콜백이 호출된다. 또는 autoValidate위젯을 사용할 수 있다.
autoValidate : 폼 필드의 논리 플래그다. 이를 true로 설정하면 폼 필드가 바뀔 때마다 즉시 validate 콜백을 호출한다. 사용자에게 즉각 피드백이 가능해 자주 쓰인다.
onSaved : validator와 비슷한 기능을 제공하며 콜백으로 전달하는 인수다. FormState.save()를 호출하면 onSaved콜백이 호출된다.

3.2 DropdownFormButton 위젯

FormField를 확장한 또 다른 위젯.
TextFormField와 비슷하지만 DropdownFormButton은 사용자가 선택할 수 있는 여러 데이터를 표시한다.
DropdownButtonFormField 예제

날씨앱에서는 DropdownButtonFormField를 수정해서 사용한다.

: 날씨앱의 코드를 보면 DropdownButtonFormField 위젯 대신 DropDownExpanded 클래스를 사용한다. 이는 DropdownButtonFormField 위젯에서 isExpanded라는 논리 플래그를 추가한 것인데, 해당 플래그가 참일경우 드롭다운 위젯은 이 정보를 자식(DropDownButton)에 전달한다. 그리고 내장된 DropDownButton의 isExpanded도 참이라면 자식을 Expanded 위젯으로 감싼다.
이는 기본 DropdownButtonFormField 위젯은 isExpanded 인수를 받지 않는 버그가 있기에 예제에서 커스터마이징을 한 것인데, 이 버그 때문에 form field wrapper에서 isExpanded 프로퍼티에 접근할 수 없어 widget.isExpanded는 항상 거짓이 된다.
만약 실습을 하는시점에서 해당 문제가 해결이되었다면 원래대로의 DropdownButtonFormField 를 사용해도 된다.
다시 날씨앱으로 돌아와 해당 add city 페이지에서 Country 드랍박스 영역을 보면 독립적인 위젯(CountryDropdownField)을 추가해 가독성을 높인것을 볼 수 있다.
해당 위젯은 lib/widget/country_dropdown_field.dart에서 볼 수 있다.
class CountryDropdownField extends StatelessWidget { final Function onChanged; final Country country; const CountryDropdownField({ Key key, this.onChanged, this.country, }) : super(key: key); Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: DropDownExpanded<Country>( isExpanded: true, decoration: InputDecoration( border: OutlineInputBorder(), labelText: "Country", ), value: country ?? Country.AD, // --- (1) onChanged: (Country newSelection) => onChanged(newSelection),// --- (2) items: Country.ALL.map((Country country) { return DropdownMenuItem(value: country, child: Text(country.name));// --- (3) }).toList(), ), ); } }
Dart
복사
1.
드롭다운을 닫으면 선택된 값을 표시한다.
2.
항목을 선택하면 onChange가 호출되며 onChanged콜백으로 선택된 항목을 전달한다.
3.
items는 DropdownMenuItem<T>형식의 위젯 리스트를 기대한다. 예제에서는 List<DropdownMenuItem<Country>>를 반환하는 함수를 구현했지만 직접 리스트를 전달해도 된다.

3.3 일반 폼 필드

특정 위젯들을 제외한 나머지 대부분의 입력 형식의 위젯을 감싸서 폼에 사용할 수 있다. (ex: 체크박스, 날짜 선택, 슬라이더 등)
날씨 앱에선느 체크박스를 FormField로 감쌌다.
폼 필드의 체크박스
class _AddNewCityPageState extends State<AddNewCityPage> { City _newCity = City.fromUserInput(); bool _formChanged = false; bool _isDefaultFlag = false; // --- (1) final GlobalKey<FormState> _formKey = GlobalKey<FormState>(); FocusNode focusNode; Widget build(BuildContext context) { return Scaffold( appBar: AppBar(...), body: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: Form( key: _formKey, onChanged: _onFormChange, onWillPop: _onWillPop, child: ListView( shrinkWrap: true, children: <Widget>[ Padding(...), Padding(...), CountryDropdownField(...), FormField( // --- (2) onSaved: (val) => _newCity.active = _isDefaultFlag, // --- (3) builder: (context) { // --- (4) return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: <Widget>[ Text("Default city?"), Checkbox( value: _isDefaultFlag, // --- (5) onChanged: (val) { setState(() => _isDefaultFlag = val);// --- (6) }, ), ], ); }, ), ], ), ), ), ); }
Dart
복사
1.
체크박스의 상태를 관리하는 논리 플래그.
2.
onSaved같은 여러 프로퍼티는 기존 위젯과 동일하다.
3.
폼 필드는 현재 필드가 처리하는 데이터 형식을 모르기에 onSaved 콜백의 동작이 조금 다르다. 형식을 선언하지 않고 인수에 val을 사용한 이유도 동일하다.
4.
FormField는 자식이 아니라 빌더를 인수로 받는다. 아무 위젯이든 반환할 수 있기에 모든 위젯이 폼 필드가 될 수 있다.
5.
폼에서 사용하지 않은 일반 체크박스와 똑같이 동작한다. 체크박스 상태를 확인하는 논리 플래그를 사용한다.
6.
논리 플래그가 갱신되면 체킄박스 상태를 다시 그리도록 setState를 호출한다.

4. Form UI와 포커스 작업