티스토리 뷰

flutter

[KOR,ENG]Flutter todo 만들기 - 2

4whomtbts 2020. 1. 2. 20:34

위처럼 할 일을 작성하는 페이지를 마저 완성해봅시다. 이 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

 

How to access data of child widget in parent in flutter?

I want to know the value of a certain field in a child (or child of child) widget in the parent widget, only when I want to know it. Using a Callback or InheritedWidget/Provider doesn't work in thi...

stackoverflow.com

https://stackoverflow.com/questions/51463906/emit-the-data-to-parent-widget-in-flutter

 

Emit the data to parent Widget in Flutter

I'm trying to set the text from child widget to parent widget. But the text is not reflecting in parent widget. Tried to use setState() also but still unable to get expected result. Following is...

stackoverflow.com

https://stackoverflow.com/questions/54535286/flutter-how-to-access-child-data-on-parent-event-or-action

 

Flutter - How to access child data on parent event or action

I have a longer input form where the user can add a number of identical subforms. For e.g. think about a form where parents can enter their data (parentform) and afterwards add their childs. Sinc...

stackoverflow.com

이에 관한 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 class - widgets library - Dart API

A controller for an editable text field. Whenever the user modifies a text field with an associated TextEditingController, the text field updates value and the controller notifies its listeners. Listeners can then read the text and selection properties to

api.flutter.dev

이것을 사용하는 이유는, 이 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.broadcast constructor - StreamController class - dart:async library - Dart API

StreamController .broadcast constructor StreamController .broadcast({void onListen(), void onCancel(), bool sync: false }) A controller where stream can be listened to more than once. The Stream returned by stream is a broadcast stream. It can be listened

api.flutter.dev

이 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

 

mobxjs/mobx.dart

MobX for the Dart language. Hassle-free, reactive state-management for your Dart and Flutter apps. - mobxjs/mobx.dart

github.com

 

맨 아래를 보면 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
댓글