티스토리 뷰
위처럼 할 일을 작성하는 페이지를 마저 완성해봅시다. 이 todo앱은 모든 항목을 반드시 입력받고 싶어서
사진에서 볼 수 있듯이, 비어있는 칸이 있으면 에러메세지를 출력해야합니다!
위의 화면까지 만들어놓고 글을 작성하기 시작했는데, 에러메세지를 출력하는 것이, Flutter가 여기서 진입장벽이
있다는 사실을 깨닫았습니다!.. 코드가 매우 복잡하니 중간중간 관련 topic 의 포스팅을 공부하시면서 보시면
좋을 것 같습니다.
바로 레이아웃을 만들면 좋겠지만, 데이터에 대해 먼저 생각해야합니다.
우리가 만드는 todo앱에서는 모든 내용(제목, 카테고리, 컨텐츠)가 모두 비어있지 않길 바랍니다.
그렇다면, Flutter에서 데이터를 어떻게 다루는지에 대한 고찰이 선행되어야 하겠습니다.
Android나 React-Native를 하는 저로서, Flutter를 할 때 가장 어려웠던 점은 Flutter는 프레임워크 자체에서
데이터 흐름을 강제한다는 것 이었습니다. 어떻게 강제하냐면은 자식 -> 부모로 흘러가도록 합니다.
https://stackoverflow.com/questions/56894935/how-to-access-data-of-child-widget-in-parent-in-flutter
https://stackoverflow.com/questions/51463906/emit-the-data-to-parent-widget-in-flutter
이에 관한 debate를 볼 수 있는 페이지들입니다.
React-Native에서는 컴포넌트의 reference를 이용해서 property나 method를 조작하고
Android에서도 findViewById 혹은 최근에는 DataBinding을 이용해서 자유자재로 컴포넌트에 접근하고
조작하는 방식으로 프로그래밍을 하죠. 사실 처음에 이런 Flutter 의 philisophy에 대해 제대로 알아보기전에는
프레임워크 자체의 설계실수인가 생각이 들 정도로, Android나 RN을 하던 저에게는 너무나 답답했습니다.
사실 지금도 답답합니다..
https://medium.com/flutter-community/flutter-communication-between-widgets-f5590230df1e
위의 링크에서 알 수 있듯이, 여러가지 방법이 존재합니다만
2번째로 나오는 GlobalKey의 접근법을 사용하면 앞서 말씀드렸던 Android나 RN에서 처럼 사용할 수 있지만
Flutter의 디자인 철학을 심각하게 위배하고, Widget들의 상태와 행동을 예측불가능하게 만드므로
이러한 경우에는 절대 사용하지 않도록 권하고 있습니다. 사실 이러한 문제는 Android,RN을 프로그래밍하면서
늘 느꼈던 문제이긴 합니다. Flutter의 그것처럼 이러한 strict한 룰에 익숙해지면 역설적으로 안전함속에서
무한의 자유를 느끼는 순간이 온다고 느끼는 때가 있습니다. 그때까지 묵묵히 삽질을 해야죠 뭐...
본론으로 돌아가서, 제가 이번 todo app에서 사용하고자 하는 방법론은 많이들 써보셨던 Observer pattern입니다.
요즘 Android에서도 MVVM 에서 LiveData 등을 Observe 하는 방식으로 많이 이용하죠. 저는 Android를 하면서
LiveData 와 Observer를 남용하면서 무한 디버깅모드로 들어간 경험이 있지만.. 그것을 양분삼아서 자제해가면서 써보겠습니다.
Dart에도 당연히 Observer pattern 구현체가 있습니다 바로 mobx 입니다.
일단, 프로젝트 폴더에서 pubspec.yaml 을 찾아서 열어보세요
그리고, 이 파일에서 위의 사진처럼
dependencies 에 mobx: ^0.4.0+1 을 추가해줍니다.
dev_dependencies에는 mobx_codegen: ^0.3.11 과 build_runner: ^1.3.1를 추가해줍니다.
(나머지는 본인의 것 그대로 사용하시면 됩니다!)
그리고 lib 폴더에(main.dart가 있는 폴더) TodoList.dart를 생성해주세요
import 'package:mobx/mobx.dart';
// Include generated file
part 'TodoList.g.dart';
// This is the class used by rest of your codebase
class TodoList = _TodoList with _$TodoList;
// The store-class
abstract class _TodoList with Store {
@observable
ObservableList<TodoData> list = ObservableList<TodoData>();
@action
void addTodo(TodoData data) {
list.add(data);
}
}
class TodoData {
String title;
String category;
String content;
TodoData(this.title, this.category, this.content);
}
TodoList todoList = TodoList();
syntax error가 사방팔방에서 뜨신다구요? 지극히 정상입니다. 따라오세요!
그리고 terminal/cmd에서 pubspec.yaml이 있는 폴더로 갑니다(그러니깐 프로젝트 root folder)
$ flutter packages pub run build_runner build
위의 명령어를 치세요.
영어가 막 뜹니다.
그러면
이렇게, 없었던 TodoList.g.dart가 생성되었습니다.
이제 우리가 만든 _TodoList 는 _$TodoList 와 mix-in 되어서 TodoList 라는 Store 클래스가 되었습니다.
@observable
ObservableList<TodoData> list = ObservableList<TodoData>(); 로 선언했기 때문에
이제, list는 observe 되는 상태의 객체가 됩니다.
이제 Todo를 등록할 수 있는 AddTodoPage Widget을 만들어보겠습니다.
lib 폴더에 AddTodoPage.dart 파일을 만들어줍니다.
전체코드는 아래와 같습니다.
import 'dart:async';
import 'dart:collection';
import 'dart:core';
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'TodoList.dart';
class AddTodoPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
// TODO: implement build
return TodoForm();
}
}
class TodoForm extends StatefulWidget {
@override
State<StatefulWidget> createState() {
// TODO: implement createState
return _TodoFormState();
}
}
class _TodoFormState extends State<TodoForm> {
final titleController = TextEditingController();
final categoryController = TextEditingController();
final contentController = TextEditingController();
final changeNotifier = StreamController.broadcast();
HashMap<String, String> todoDataList = new HashMap();
Set<TextEditingController> controllerSet;
TodoData todoData;
void pushTodoData(String field, String data) {
todoDataList[field] = data;
if(todoDataList.length > 2) {
this.todoData = TodoData(todoDataList['Title'], todoDataList["Category"], todoDataList["Content"]);
todoList.addTodo(this.todoData);
}
}
@override
void dispose() {
// TODO: implement dispose
changeNotifier.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
// TODO: implement build
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => changeNotifier.sink.add(null),
child: const Icon(Icons.add),
),
body: Column(children: [
Expanded(
flex: 2,
child: Align(
alignment: Alignment.center,
child: Container(
width: MediaQuery.of(context).size.width * 0.95,
height: MediaQuery.of(context).size.height,
padding: const EdgeInsets.only(top: 20.0),
child: NonNullableTextField("Title", changeNotifier.stream, pushTodoData)
))),
Expanded(
flex: 2,
child: Align(
alignment: Alignment.center,
child: Container(
width: MediaQuery.of(context).size.width * 0.95,
height: MediaQuery.of(context).size.height,
padding: const EdgeInsets.only(top: 20.0),
child: NonNullableTextField("Category", changeNotifier.stream, pushTodoData),
))),
Expanded(
flex: 6,
child: Align(
alignment: Alignment.center,
child: Container(
width: MediaQuery.of(context).size.width * 0.95,
height: MediaQuery.of(context).size.height,
padding: const EdgeInsets.only(top: 20.0),
child: NonNullableTextField("Content", changeNotifier.stream, pushTodoData,
10, 10)
))),
]));
}
}
class NonNullableTextField extends StatefulWidget {
final Stream registerValidation;
_TextFieldState textFieldState;
String _labelText;
int _minLines;
int _maxLines;
Function(String, String) registerTodoCallBack;
NonNullableTextField(this._labelText, this.registerValidation, this.registerTodoCallBack,
[this._minLines = 1, this._maxLines = 1]) : assert(registerValidation != null);
@override
State<StatefulWidget> createState() {
textFieldState = _TextFieldState(_labelText, _minLines, _maxLines);
return textFieldState;
}
}
class _TextFieldState extends State<NonNullableTextField> {
TextField textField;
TextEditingController textController = new TextEditingController();
bool _isValid = true;
String inputText = "";
StreamSubscription streamSubscription;
String _labelText= "";
int _min;
int _max;
void setInputText(String inputText) {
this.inputText = inputText;
}
_TextFieldState(String _labelText, [int _minLines = 1, int _maxLines = 1]) {
this._labelText = _labelText;
this._min = _minLines;
this._max = _maxLines;
}
bool validate() {
if (textController.text == "") {
setState(() {
this._isValid = false;
});
return false;
}
setState(() {
this._isValid = true;
});
widget.registerTodoCallBack(_labelText, textController.text);
return true;
}
@override
Widget build(BuildContext context) {
return TextField(
controller: textController,
minLines: _min,
maxLines: _max,
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: _labelText,
errorText: _isValid ? null : _labelText + " field is required"));
}
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
void initState() {
// TODO: implement initState
super.initState();
streamSubscription = widget.registerValidation.listen((_) => validate());
textController.addListener(() {
this.inputText = textController.text;
});
}
}
아래부터는 위의 코드를 옮겨다니며 설명하므로, 한 번 간략히 파악해보세요.
class AddTodoPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
// TODO: implement build
return TodoForm();
}
}
class TodoForm extends StatefulWidget {
@override
State<StatefulWidget> createState() {
// TODO: implement createState
return _TodoFormState();
}
}
TextField 들의 값에 따라 TextField 들이 보여지는 것이 달라져야하기 때문에(비어있을 경우 에러메세지 출력)
TodoForm을 StatefulWidget으로 만들어줍니다. 이제 TextField 를 만들것인데. 앞서 말씀드렸듯이
등록되기 전에 모든 항목이 비어있지 않은지 체크하고 비어있다면 실패하게 만들어야 합니다.
이러한 기능을 제공하기 위해 NonNullableTextfield 를 따로 만들겠습니다.
class NonNullableTextField extends StatefulWidget {
final Stream registerValidation;
_TextFieldState textFieldState;
String _labelText;
int _minLines;
int _maxLines;
Function(String, String) registerTodoCallBack;
NonNullableTextField(this._labelText, this.registerValidation, this.registerTodoCallBack,
[this._minLines = 1, this._maxLines = 1]) : assert(registerValidation != null);
@override
State<StatefulWidget> createState() {
textFieldState = _TextFieldState(_labelText, _minLines, _maxLines);
return textFieldState;
}
}
class _TextFieldState extends State<NonNullableTextField> {
TextField textField;
TextEditingController textController = new TextEditingController();
bool _isValid = true;
String inputText = "";
StreamSubscription streamSubscription;
String _labelText= "";
int _min;
int _max;
void setInputText(String inputText) {
this.inputText = inputText;
}
_TextFieldState(String _labelText, [int _minLines = 1, int _maxLines = 1]) {
this._labelText = _labelText;
this._min = _minLines;
this._max = _maxLines;
}
bool validate() {
if (textController.text == "") {
setState(() {
this._isValid = false;
});
return false;
}
setState(() {
this._isValid = true;
});
widget.registerTodoCallBack(_labelText, textController.text);
return true;
}
@override
Widget build(BuildContext context) {
return TextField(
controller: textController,
minLines: _min,
maxLines: _max,
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: _labelText,
errorText: _isValid ? null : _labelText + " field is required"));
}
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
streamSubscription = widget.registerValidation.listen((_) => validate());
textController.addListener(() {
this.inputText = textController.text;
});
}
}
내용이 들어가는 칸은 좀 더 길 것 이므로 내용 TextField 는 minimum line이 더 많아야할 것 입니다.
1편에서 레이아웃을 따로 파일로 관리하지 않아서 좋다고 했었는데, 이런 문제를 맞딱드리게 됩니다.
정리하자면, Custom Widget을 추상화해서 쓰기가 힘들다고 해야하는게 맞을 것 같습니다. 레이아웃
코드가 로직에 단단히 묶여있어서, 위의 코드에서 생성자에서 보듯이 인스턴스화하기가 힘들어집니다.
처음 생각했던 것은 C언어의 void 포인터와 같은 느낌의 타입인 dynamic을 이용하려고 했지만
TextField 를 extend 한다 하더라도, super를 호출할 때, FooWidget({ Key : key, Color : color})와 같은
생성자 파라미터를 보고서, 자바스크립트에서 하듯이 dynamic으로 넣는것이 불가능했습니다.
어떻게 하면 객체 생성을 간편하게 할지에 대한 방법을 찾아보아야할 것 같습니다.
State<T> 를 상속한 클래스에서는 widget 이라는 슈퍼클래스의 인스턴스 변수를 통해 슈퍼클래스의
참조를 얻을 수 있습니다.
여기서, Widget 간에 데이터를 주고받기 위해 사용한 코드들을 발췌해서 보겠습니다.
먼저 아셔야할 것은
NonNullableTextField의 Stream타입 인스턴스 변수인 registerValidation(이 변수는 NonNullableTextField가 생성될 때 생성자를 통해 받아온 것인데 이따 보겠습니다.) 을 가지고 있습니다.
1. widget.registerValidation.listen((_) => validate())
이 registerValidation Stream은 listen 메소드에 handler 로 쓸 validate 메소드를 넘겨줍니다.
그러니깐, registerValidation에 신호가 오면 validate가 호출되게 된다는 이야기입니다.
2. widget.registerTodoCallBack(_labelText, textController.text)
여기서 textController는 TextEditingController라는 객체입니다
https://api.flutter.dev/flutter/widgets/TextEditingController-class.html
이것을 사용하는 이유는, 이 TextEditingController라는 객체를 통해서야 TextField의 내용인 text 인스턴스 변수를
얻을 수 있기 때문입니다.
위의 번거로운 로직을 만들어준 이유는 , '플로팅 버튼이 눌리면 -> 각각의 TextField 가 validation을 검사해서
(비어있는지 안 비어있는지) 안 비어있다면 -> widget.registertodoCallback(_labelText,
state를 initiate 할 때, initState메소드 안에서 widget.registerValidation.listen((_) => validate())
validate 메소드에서 widget.registerTodoCallBack(_labelText, textController.text) 를 통해서
class NonNullableTextField extends StatefulWidget {
final Stream registerValidation;
_TextFieldState textFieldState;
String _labelText;
int _minLines;
int _maxLines;
Function(String, String) registerTodoCallBack;
NonNullableTextField(this._labelText, this.registerValidation, this.registerTodoCallBack,
[this._minLines = 1, this._maxLines = 1]) : assert(registerValidation != null);
.....
코드
....
NonNullableTextField가 생성될 때 받은 registerTodoCallBack을 호출합니다.
class _TodoFormState extends State<TodoForm> {
final titleController = TextEditingController();
final categoryController = TextEditingController();
final contentController = TextEditingController();
final changeNotifier = StreamController.broadcast();
HashMap<String, String> todoDataList = new HashMap();
Set<TextEditingController> controllerSet;
TodoData todoData;
void pushTodoData(String field, String data) {
todoDataList[field] = data;
if(todoDataList.length > 2) {
this.todoData = TodoData(todoDataList['Title'], todoDataList["Category"], todoDataList["Content"]);
todoList.addTodo(this.todoData);
}
}
@override
void dispose() {
// TODO: implement dispose
changeNotifier.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
// TODO: implement build
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => changeNotifier.sink.add(null),
child: const Icon(Icons.add),
),
body: Column(children: [
Expanded(
flex: 2,
child: Align(
.... 생략 .....
코드의 상단에서 final changeNotifier = StreamController.broadcast()로 할당하는 것을 볼 수 있습니다.
https://api.flutter.dev/flutter/dart-async/StreamController/StreamController.broadcast.html
이 StreamController에 관해 간략히 설명하자면
stream인데 한 번 이상 listen 될 수 있는 stream인 것 입니다.
children: [
Expanded(
flex: 2,
child: Align(
alignment: Alignment.center,
child: Container(
width: MediaQuery.of(context).size.width * 0.95,
height: MediaQuery.of(context).size.height,
padding: const EdgeInsets.only(top: 20.0),
child: NonNullableTextField("Title", changeNotifier.stream, pushTodoData)
))),
Expanded(
flex: 2,
child: Align(
alignment: Alignment.center,
child: Container(
width: MediaQuery.of(context).size.width * 0.95,
height: MediaQuery.of(context).size.height,
padding: const EdgeInsets.only(top: 20.0),
child: NonNullableTextField("Category", changeNotifier.stream, pushTodoData),
))),
Expanded(
flex: 6,
child: Align(
alignment: Alignment.center,
child: Container(
width: MediaQuery.of(context).size.width * 0.95,
height: MediaQuery.of(context).size.height,
padding: const EdgeInsets.only(top: 20.0),
child: NonNullableTextField("Content", changeNotifier.stream, pushTodoData,
10, 10)
)
보시면, 위의 NonNullableTextField 생성시마다 changeNotifer의 stream을 던져줍니다.
pushToData 도 던져주어서 이것을 callback으로 호출하게 됩니다.
void pushTodoData(String field, String data) {
todoDataList[field] = data;
if(todoDataList.length > 2) {
this.todoData = TodoData(todoDataList['Title'], todoDataList["Category"], todoDataList["Content"]);
todoList.addTodo(this.todoData);
}
}
HashMap 에 들어오는 족족 넣어주고, 2개 이상받으면 TodoData로 만들어서 todoList에 집어넣습니다.
이제 1번 포스팅에서 만들었던 main.dart에 코드를 마저 추가하겠습니다.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_a/TodoList.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'AddTodoPage.dart';
void main() {
runApp(
MaterialApp(
home : MyApp(),
routes : <String, WidgetBuilder> {
'/addTodo' : (BuildContext context) => AddTodoPage(),
},
));
}
class MyApp extends StatefulWidget {
@override
State<StatefulWidget> createState() {
// TODO: implement createState
return _MyAppState();
}
}
class _MyAppState extends State<MyApp> {
Widget _buildTodoList(List<TodoData> todoList) {
return ListView.builder(
padding: const EdgeInsets.all(16.0),
itemBuilder: (context, i) {
if(todoList.length == 0) {
return null;
}
if(i.isOdd) return Divider();
final index = i ~/ 2;
// ignore: missing_return
if(index >= todoList.length) {
return null;
}
return _buildRow(todoList[index]);
}
);
}
Widget _buildRow(TodoData data) {
return ListTile(
title: Text(
data.title,
)
);
}
@override
Widget build(BuildContext context) {
SystemChrome.setEnabledSystemUIOverlays([]);
// TODO: implement build
return Scaffold(
body: FractionallySizedBox(
widthFactor: 1,
heightFactor:1,
child: Observer(
builder : (_) =>_buildTodoList(todoList.list)),
),
floatingActionButton: AddTodoFloatingButton(),
);
}
}
class AddTodoFloatingButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FloatingActionButton(
onPressed: () => Navigator.of(context).pushNamed('/addTodo'),
child: const Icon(Icons.add),
);
}
}
_buildTodoList는 앞에서 만든 Observerble store인 TodoList의 list를 받아서 그 데이터를 바탕으로
List의 Row를 만들어냅니다.
@override
Widget build(BuildContext context) {
SystemChrome.setEnabledSystemUIOverlays([]);
// TODO: implement build
return Scaffold(
body: FractionallySizedBox(
widthFactor: 1,
heightFactor:1,
child: Observer(
builder : (_) =>_buildTodoList(todoList.list)),
),
floatingActionButton: AddTodoFloatingButton(),
);
}
이 코드에서 child : Observer (... 생략 ...) 부분은 observable 을 사용하는 핵심적인 부분입니다.
https://github.com/mobxjs/mobx.dart/tree/master/flutter_mobx
맨 아래를 보면 Observer contsructor에서 사용하는 parameter를 볼 수 있는데
Widget Function(BuilderContext context) builder) 를 받습니다.
따라서, 저도 코드에서 _builderTodoList(todoList.list))를 넣어서 ListView가 TodoList의 list가 바뀔때마다
함께 바뀌게 만들었습니다.
이렇게 되면 최종적으로 아래처럼 작동하게 됩니다.
'flutter' 카테고리의 다른 글
[KOR,ENG]Flutter todo - 1 (0) | 2020.01.01 |
---|---|
다트언어 - 1 (0) | 2020.01.01 |
- Total
- Today
- Yesterday
- CMake run protoc
- react-native
- CMake 강좌
- CMake probouf
- 14714 복습법
- 14714 공부법 어플리케이션
- 14714 플래너
- function pointer overflow
- 14714 어플
- review reminder
- CMake get file name
- buffer-over-flow
- 함수포인터 오버라이트
- 복습 계획어플
- CMAke 파일이름 추출
- CMake run proto compiler
- get_filename_component
- 복습 어플
- CMake 반복문
- 14714 공부법
- CMake for
- 토리파 공부법
- 14714 review
- aws 청구문의
- CMake get_filename_Component
- 14714 어플리케이션
- 14714 앱
- aws 프리티어 요금청구
- CMake for문
- CMake 기초
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |