❤️

【Flutter】付箋型のTodoアプリを作ってみた - Animation / InheritedNotifier

2021/08/14に公開

付箋型のTodo(メモ)アプリを作ってみました。

完成イメージ
完成イメージ

👇 完成コード(実行可)
https://dartpad.dev/?null_safety=true&id=ae1e5e566e9e557fdca929d542a17ec3

実現したい機能を考える

  • Todoアプリでよくあるリスト型ではなく、付箋型にして自由に動かせるようにしたい
    (GestureDetectorやPositionedなど位置情報を駆使してWidgetを動かす)
  • Todoの作成時や削除時にアニメーションさせたい
    (Implicit, Explicit両方のアニメーションを使用する)
  • テキスト編集時に別画面を開かずインラインで編集したい
  • 付箋の背景画像として外部の画像素材を使ってみたい

使用する画像素材

こちらの「いらすとや」の画像素材を使わせてもらいました。
吹き出し

モデルを考える

TodoModel
enum Sex { girl, boy }


class TodoModel {
  final Key id;
  final Offset position;
  final String description;
  final Sex sex;
  final bool done;

  const TodoModel({
    required this.id,
    required this.position,
    this.description = '',
    this.sex = Sex.girl,
    this.done = false,
  });

  TodoModel copyWith({
    Key? id,
    Offset? position,
    String? description,
    Sex? sex,
    bool? done,
  }) {
    return TodoModel(
      id: id ?? this.id,
      position: position ?? this.position,
      description: description ?? this.description,
      sex: sex ?? this.sex,
      done: done ?? this.done,
    );
  }
}

Todoになぜ性別があるのかはさておき、、(付箋の色くらいに思っていただけると)

  • Todo詳細のテキスト -> description
  • 画面中の位置情報 -> position
  • 完了したか否か -> done

idはTodoを識別するのにユニークな値を振ってあげる必要があります。今回はUniqueKey()を使用。

immutableなクラスなので、copyWithメソッドを準備。
Dart Data Class Generator
https://marketplace.visualstudio.com/items?itemName=BendixMa.dart-data-class-generator
この部分はこちらのVSCode拡張を利用して生成しました。dataクラスに関わるコードをチェリーピックして使えるので便利です。

状態管理

上記TodoModelをリストにしたものを状態として管理し、アプリ全体に情報を伝えるため ValueNotofier + InheritedNotifier を使用します。

TodosNotifier
class TodosNotifier extends ValueNotifier<List<TodoModel>> {
  TodosNotifier() : super(<TodoModel>[]) {
    _init();
  }

  void _init() {
    // Todoリストの初期値
    super.value = [
      TodoModel(
        id: UniqueKey(),
        position: const Offset(30, 30),
        description: '画面ダブルクリックでメモを追加してね。左上ボタンで性別が変えられるよ。',
        sex: Sex.boy,
      ),
      TodoModel(
        id: UniqueKey(),
        position: const Offset(300, 300),
        description: '右上ボタンをクリックかテキストエリアのダブルクリックでメモが編集できるよ。',
        sex: Sex.girl,
      ),
    ];
  }

  void addTodo(Offset position) {
    final todo = TodoModel(
      id: UniqueKey(),
      // positionは左上隅のOffsetなので画像の大きさの半分を縦横それぞれ引くことで中央寄せ
      position: position -
          Offset(
            TodoWidget.imageSize.width / 2,
            TodoWidget.imageSize.height / 2,
          ),
      description: 'メモ',
    );
    super.value.add(todo);
    // super.valueのリスト自体を入れ替えない場合はnotifyListeners()が必要
    notifyListeners();
  }

  void move(Offset delta, Key? id) {
    final list = value.map<TodoModel>((e) {
      if (e.id == id) {
        return e.copyWith(position: e.position + delta);
      }
      return e;
    }).toList();
    super.value = list;
  }

  void changeDescription(String description, Key? id) {
    final list = value.map<TodoModel>((e) {
      if (e.id == id) {
        return e.copyWith(description: description);
      }
      return e;
    }).toList();
    super.value = list;
  }

  void toggleDone(Key? id) {
    final list = value.map<TodoModel>((e) {
      if (e.id == id) {
        return e.copyWith(done: !e.done);
      }
      return e;
    }).toList();
    super.value = list;
  }

  void toggleSex(Key? id) {
    final list = value.map<TodoModel>((e) {
      if (e.id == id) {
        return e.copyWith(sex: e.sex == Sex.boy ? Sex.girl : Sex.boy);
      }
      return e;
    }).toList();
    super.value = list;
  }

  void delete(Key? id) {
    super.value.removeWhere((element) => element.id == id);
    notifyListeners();
  }
}

ValueNotifierはChangeNotifierと異なり、状態に変更を加えた後に必ずしもnotifyListeners()する必要はありませんが、

// valueはList
super.value.add(todo);
notifyListeners();

のようにList自体が入れ替わらない形での変更の場合は必要になります。

基本の付箋Widget

TodoWidget
class TodoWidget extends StatefulWidget {
  final TextEditingController controller;
  final TodoModel todo;
  final bool editMode;
  final VoidCallback? onSubmitted;
  final VoidCallback? onEditMode;

  const TodoWidget({
    Key? key,
    required this.controller,
    required this.todo,
    required this.editMode,
    required this.onEditMode,
    this.onSubmitted,
  }) : super(key: key);

  static const image = <Sex, String>{
   // 画像素材のURL(省略)
  };

  static const imageSize = Size(260, 280);

  
  _TodoWidgetState createState() => _TodoWidgetState();
}

class _TodoWidgetState extends State<TodoWidget> {
  
  void initState() {
    super.initState();
    widget.controller.text = widget.todo.description;
  }

  
  Widget build(BuildContext context) {
    // 編集モード時にTextField以外をタップすると編集モードが解除
    return GestureDetector(
      onTap: widget.editMode ? widget.onSubmitted?.call : null,
      child: Container(
        width: TodoWidget.imageSize.width,
        height: TodoWidget.imageSize.height,
        padding: const EdgeInsets.fromLTRB(45, 50, 60, 145),
        decoration: BoxDecoration(
          image: DecorationImage(
            alignment: Alignment.topLeft,
            fit: BoxFit.fitWidth,
            image: NetworkImage(TodoWidget.image[widget.todo.sex]!),
          ),
        ),
        // 編集モード時はTextField、それ以外はテキスト表示
        // テキストをダブルクリックすると編集モードになる
        child: !widget.editMode
            ? GestureDetector(
                onDoubleTap: widget.onEditMode,
                child: Text(
                  widget.todo.description,
                  maxLines: 4,
                  overflow: TextOverflow.fade,
                  style: const TextStyle(fontSize: 14),
                ),
              )
            : TextField(
                controller: widget.controller,
                maxLines: null,
                maxLength: 44,
                autofocus: true,
                style: const TextStyle(fontSize: 14),
                decoration: const InputDecoration(
                  border: InputBorder.none,
                  contentPadding: EdgeInsets.zero,
                ),
              ),
      ),
    );
  }
}

このWidgetの核は、画像素材をBoxDecorationとして背景にしたContainerです。Containerの上にTodoの詳細テキストをTextとしてStackしています。

「テキスト編集モード」の時に、このTextの部分をTextFieldに入れ替えることによってインライン入力風を実現しています。

GestureDetectorが2つあるかと思いますが、1つ目は「テキスト編集モード」の時に作動するonTapで、このWidgetのTextField以外の部分をタップすると編集モードが解除されます。

2つ目は、編集モードではない時に作動します。表示されているテキストをダブルタップすると編集モードに入れるものです。

TodoWidget > TextField
                decoration: const InputDecoration(
                  border: InputBorder.none,
                  contentPadding: EdgeInsets.zero,
                ),

TextFieldのdecorationはpaddingをゼロに設定しないと、デフォルトでpaddingがかなり入っているため、表示領域が狭まって元のテキストから位置がかなりずれてしまいます。

ボタン付きの付箋Widget

上記Widgetにボタンを付け足したWidgetです。
ここでAnimationの設定を施しています。またここからTodoNotifierのメソッドにアクセスしてTodoの詳細を変更するため、TodoWidgetで仕込んだTextEditingControllerもここから渡しています。

TodoWidgetWithButtons
class TodoWidgetWithButtons extends StatefulWidget {
  final TodoModel todo;

  const TodoWidgetWithButtons({Key? key, required this.todo}) : super(key: key);

  
  _TodoWidgetWithButtonsState createState() => _TodoWidgetWithButtonsState();
}

class _TodoWidgetWithButtonsState extends State<TodoWidgetWithButtons>
    with SingleTickerProviderStateMixin {
  static const _duration = Duration(milliseconds: 800);

  // forward().then()のsetStateはAnimationControllerのisCompletedパラメーターをWidgetに読み込むため
  // lateで宣言しつつ代入すると、initState内で代入するのと同じ効果がある
  late final _animationController = AnimationController(
    vsync: this,
    duration: _duration,
  )..forward().then((value) => setState(() {}));

  late final _textController = TextEditingController();

  bool _editMode = false;

  void _changeDescription() {
    // テキスト内容が変わっている時のみ実行
    if (_textController.text != widget.todo.description) {
      TodosNotifierProvider.of(context)
          .changeDescription(_textController.text, widget.todo.id);
    }
    setState(() => _editMode = false);
  }

  void _toggleEditMode() {
    if (_editMode) {
      _changeDescription();
    } else {
      setState(() => _editMode = true);
    }
  }

  void _toggleDone() {
    TodosNotifierProvider.of(context).toggleDone(widget.todo.id);
  }

  void _toggleSex() {
    TodosNotifierProvider.of(context).toggleSex(widget.todo.id);
  }

  void _delete() {
    // then()の後にdeleteしないと即時削除されてアニメーションが見れない
    _animationController.reverse().then((value) {
      TodosNotifierProvider.of(context).delete(widget.todo.id);
    });
  }

  
  Widget build(BuildContext context) {
    // Explicit Animation
    return ScaleTransition(
      // アニメーションの状態によりCurveを変化
      scale: _animationController.drive(
        CurveTween(
          curve: _animationController.isCompleted
              ? Curves.bounceIn
              : Curves.bounceOut,
        ),
      ),
      child: SizedBox(
        width: TodoWidget.imageSize.width,
        height: TodoWidget.imageSize.height,
        child: Stack(
          children: [
            // Todoメモの本体
            _buildAnimatedTodo(),
            // 周りのボタン類
            _buildEditButton(),
            _buildSexButton(),
            _buildDeleteButton(),
            _buildCheckButton(),
          ],
        ),
      ),
    );
  }

  Widget _buildAnimatedTodo() {
    // Implicit Animation
    return AnimatedOpacity(
      opacity: widget.todo.done ? 0.25 : 1.0,
      curve: Curves.easeOutQuint,
      duration: _duration,
      child: TodoWidget(
        controller: _textController,
        todo: widget.todo,
        editMode: _editMode,
        onSubmitted: _changeDescription,
        onEditMode: _toggleEditMode,
      ),
    );
  }
  // (以降省略)
}

Animationは AnimatedOpacity(Implicit Animation)と ScaleTransition(Explicit Animation)との2種類を使っています。

AnimatedOpacityはこのWidgetのボタン類を除いた部分(TodoWidget)に適用され、タスク完了のボタンを押したタイミングで発動。透明度が0.25と1.0の間で切り替わります。

ScaleTransitionはWidget全体がAnimationControllerの進行に伴って大きさの比率が0から1に変化します。このアニメーションはこのTodoWidgetWithButtonsクラスのインスタンスが生成されたタイミングで表示されます。またTodoを削除する時にも逆再生する形でアニメーションします。

AnimationController
  late final _animationController = AnimationController(
    vsync: this,
    duration: _duration,
  )..forward().then((value) => setState(() {}));

forward()により、アニメーションが発動。その後に続くthen()はアニメーションが終わった後に実行するコールバックです。ここでは空のsetState()をすることでAnimationControllerのisCompletedというプロパティの状態をWidgetに伝えています。

アニメーションが終わると進行具合を表すvalueプロパティが0.0から1.0になり、isCompletedがtrueになるのですが、最初にWidgetをbuild()した時にはまだ0.0の段階なので、isCompletedを何かの描画の条件にしている場合はこのようにする必要があります。

今回はScaleTransitionのCurveの種類を変更するための処理でした。

ScaleTransition > scale
      scale: _animationController.drive(
        CurveTween(
          curve: _animationController.isCompleted
              ? Curves.bounceIn
              : Curves.bounceOut,
        ),
      ),

なぜこのようなことをするのかというと、、生成時と同じCurveを削除時にも使用すると以下のように違和感があり、曲線が逆のCurveを利用する必要があったためです。

then(()=>setState())追加前
then(()=>setState())追加前
Curves.bounceIn → Curves.bounceIn

then(()=>setState())追加後
then(()=>setState())追加後
Curves.bounceIn → Curves.bounceOut

削除時のアニメーション
  void _delete() {
    // then()の後にdeleteしないと即時削除されてアニメーションが見れない
    _animationController.reverse().then((value) {
      TodosNotifierProvider.of(context).delete(widget.todo.id);
    });
  }

削除にはanimationをreverse()することで動きを付けていますが、ここでもthen()が必要になり、アニメーションが終わった後に実際の削除をするようにしています。

悪い例
    _animationController.reverse();
    TodosNotifierProvider.of(context).delete(widget.todo.id);

とした場合はアニメーションが発動した直後に削除されて画面が更新されてしまうため、動きが見えず、突如消えたような表示になります。

アニメーションを利用するときはこのように状態の変化による画面更新と、アニメーション自体による画面更新のタイミングを考慮に入れてひと工夫する必要がある場合もあります。

ドラッグでWidgetの位置変更 / ダブルクリックで新規作成

に関する処理はMyHomePageで行っています。基本的にはGestureDetectorとStackを組み合わせる形で実現。

MyHomePage
class MyHomePage extends StatefulWidget {
  
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  late Offset _mousePosition;

  void _addTodo() {
    TodosNotifierProvider.of(context).addTodo(_mousePosition);
  }

  
  Widget build(BuildContext context) {
    final notifier = TodosNotifierProvider.of(context);

    return Scaffold(
      backgroundColor: Colors.amber[50],
      body: GestureDetector(
        // TapDown時に位置取得し、Tap時(指を離した)に_addTodoを実行
        onDoubleTapDown: (details) => _mousePosition = details.localPosition,
        onDoubleTap: _addTodo,
        child: Container(
          width: double.infinity,
          height: double.infinity,
          // カラーを指定しないと上のGestureDetectorの範囲がchildの範囲に収まってしまう
          color: Colors.transparent,
          child: Stack(
            children: <Widget>[
              for (final todo in notifier.value)
                Positioned(
                  left: todo.position.dx,
                  top: todo.position.dy,
                  child: GestureDetector(
                    // downにすることで移動開始が少し早まる
                    dragStartBehavior: DragStartBehavior.down,
                    onPanUpdate: (details) {
                      notifier.move(details.delta, todo.id);
                    },
                    child: TodoWidgetWithButtons(
                      todo: todo,
                    ),
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

ここでもGestureDetectorが2つあります。1つ目のはTodoの新規作成を担当。onDoubleTapDown(ダブルタップして2回目のタップで指を置いたタイミング)で位置情報を取得し、onDoubleTap(2回目のタップで指を離したタイミング)でその位置情報を元にTodoの新規作成を行っています。

2つ目のGestureDetectorではTodoの位置変更を担当。onPanUpdate(ドラッグ中)でポインタ位置の初期値からの差分(delta)を取得し、それを元に随時TodoModelのpositionプロパティを変更しています。変更があるとnotifierからお知らせが来て、Widgetが再描画されることで動かしているように見える仕組みです。

GestureDetectorのdragStartBehaviorというプロパティにはDragStartBehavior.downかDragStartBehavior.startのenumを指定するのですが、これらはドラッグ時の位置情報取得開始のタイミングが異なるようです。

downがポインタを置いたタイミングから、startがドラッグを開始したタイミングからみたいで、downの方がドラッグの反応がいい一方、startの方が安定しているかもしれないとのこと。今回はdownを選んでいます。

ちなみにGestureDetector > Containerの組み合わせで使う場合、いくらContainerのサイズが大きくてもContainerのchildのサイズ以上にはGesture検知の範囲が広がらないようです。ただし、Containerにcolor(透明でも可)を指定してやることで、検知範囲がContainerのサイズまで広がってくれます。

GestureDetector > Container
GestureDetector(
        child: Container(
          width: double.infinity,
          height: double.infinity,
          color: Colors.transparent,
	  child: Stack(

最後に

以上です、ありがとうございました。
【過去の同様の記事】
https://zenn.dev/inari_sushio/articles/9643f20ebff29d
https://zenn.dev/inari_sushio/articles/620b436122cd03

Discussion