5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FlutterでTodoアプリを作ってみた(Windows)

Last updated at Posted at 2024-10-20

この記事を書いた目的

勉強としてFlutterでTodoアプリを作ってみたのですが、
割ととっつきやすく結構面白かったので、どういう処理が行われているのかを
備忘録として整理してみました。

Todoの記事は割とあるのですが、コードがベッと貼ってあるだけだったり
構造的なアプローチを元に解説している記事はあまり見かけなかったので、
実況中継な感じも交えて作ってみました。

対象の読者

・Macを持っていない方
・オブジェクト指向が分かる方(推奨)
・JavaScript、Java系の言語が使える方は、理解しやすいかと思います
 →上記2つにあてはまらない方は「最初はこうやって使うんだな~」という
  感覚を押さえていくといいと思います

Macを持っている方でも環境構築以外は実質同じなので
こちらの記事を一部でも参考にしてもらえたら嬉しいです。

アピールポイント

・初心者の方にも読みやすいように、解説は多めにしています。
 「暗記」より「理解」が進むように、
 環境を作った後はコピペではなくコードを実際に書いてみると良いかもしれません

・色々記事を見るけど、仕事終わって家帰ってきた後に
 コード書くのは億劫だなぁ...という方でも
 「ちょっとやってみようかな」と思える記事になるように書いてみました

範囲

(今回の記事は、以下をまとめています)

・Flutterの環境構築方法(Windows10)
・シミュレータープラグインのインストール方法
・ファイル構成と反映確認
・Flutterの仕組みとmain.dartの中身
・ToDoアプリの仕様と設計
・ToDoアプリの実装

Flutterの環境構築方法(Windows10)

・Flutter SDKのダウンロード
 以下の公式サイトから、Flutterをダウンロードします

 https://docs.flutter.dev/get-started/install

今回開発に使用しているマシンはWindowsなので、「Windows」をクリック。
flutter_download.png

 
推奨となっている「Android」を選択し、遷移したページの下にある
「flutter_windows_(バージョン)-stable.zip」をクリックしてzipファイルを
ダウンロードします

flutter_select.png

flutter_zip.png

ダウンロードができた後は7-zip等で、zipファイルをCドライブ直下に展開します。
(C:\flutter_windows_(バージョン)-stableとなるように設定します)

・システム環境変数の設定

Windowsの設定から「システム環境変数の編集」を検索し、ウィンドウを開きます。

windows_setting.png

ウィンドウを開いた後、「環境変数」を選択します。

system_pref.png

環境変数のウィンドウが開いた後は、下部の「システム環境変数」内にある
項目のPathをダブルクリックするか、選択して「編集」をクリックすることで
実際に環境変数の編集が可能になります。

windows.env_edit.png

設定値は「C:\flutter_windows_(バージョン名)-stable\flutter\bin」のように、
binフォルダが含まれるように設定し、「OK」をクリックしてください。
(下の図は私の環境で設定した値になります)

env_set.png

環境変数の設定後に、新規で開いたコマンドプロンプトにて

flutter --version

を実行してバージョンが表示されていれば正しく読み込めています。

cmd.png

読み込めていることが確認できた後は、Flutter doctorコマンドを実行して
関連ツールのインストール状況を確認します。

初回実行時は、
「Android ToolChain」「Visual Studio」
「Android Studio」「Vs Code」辺りが
不足しているかと思います(各個人の環境によるため、異なる場合があります)

今回は、手っ取り早くVscodeのインストールだけを行います。

Vscodeは以下のURLからWindows用のインストーラーをダウンロードして頂き、
インストーラーの指示に従ってインストールを完了して頂ければ問題ないかと思います。

・FlutterとDartプラグインのインストール(Vscode)

拡張機能(左側にある4つのブロックのマーク)を選択し、
「Flutter」と「Dart」をインストールします。デバッグモードや、補完機能のプラグインになります。

flutter_vscode.png

flutter_dart.png

上記の拡張機能をインストールした後は、一度Vscodeを再起動して読み込み直し
作成したいディレクトリ内で

flutter create プロジェクト名

を実行し、プロジェクトを作成します。(今回はtest_appという名前にしました)

ここで、作成したプロジェクトのディレクトリに移動して、
runコマンドで一回立ち上げてみます。

cd test_app
flutter run

すると、Connected devicesという中に以下の選択肢が出るので
ここは「2」を選択します。
(※1に関しては、Visual StudioのToolChainが入っていないため、エラーになります
  追加することによってWindowsアプリケーションでの起動が可能になります。)

Connected devices:
Windows (desktop) • windows • windows-x64    • Microsoft Windows [Version 10.0.19045.5011]
Chrome (web)      • chrome  • web-javascript • Google Chrome 129.0.6668.100
Edge (web)        • edge    • web-javascript • Microsoft Edge 129.0.2792.89
[1]: Windows (windows)
[2]: Chrome (chrome)
[3]: Edge (edge)

すると、実際にGoogle Chromeにて最初のアプリが立ち上がりました。
Webブラウザでスマートフォンのアプリの画面が出てくるのは新鮮な感じがします。

flutter_count.gif

しかしこれだけだとAndroid, iPhoneなどのシミュレーターがなく
ちょっと物寂しい感じがするので、先に「device_preview」というプラグインの追加を行います。

シミュレータープラグインのインストール方法

以下のサイトから手順に沿ってプラグインを追加します。

iOSに関してはWindowsではシミュレーターが提供されていないよ、みたいな記事が多くて
「そっか、Mac買わないといけないのかー」なんて思っていたのですが(汗)、
このプラグインが提供されていたので特に問題ありませんでした。とてもありがたいです。

上記のリンクから「Installing」タブに記載されている以下のコマンドを実行します。
flutter pubのpubは、パッケージ管理ツールのことを指します(yarnとかnpmと類似)

flutter pub add device_preview

上記でdevice_previewパッケージを追加した後、
実際に以下でインストールを行います。

flutter pub get

その後、Vscodeに戻り、プロジェクト内のpubspec.yamlに

dependencies:
  device_preview: (バージョン)

が追加されていることを確認できれば、正常にインストールされています。

ファイル構成

プロジェクトの細かい構成に関しては割愛しますが、
基本的にはlib以下にフォルダやdartファイルを追加していき
定数やプログラムなどを書いていく、というような流れになるかと思います。

(lib以外のフォルダに関しては 大まかに言うとビルド用で
デバイスやOSの名前が書かれている通り各プラットフォームで動かせるように
dartからkotlin, swiftなどの他言語に変換されたアプリケーションが配置されますよ、
という感じです)

プロジェクト内のlib/main.dartを開きます。

vscode_file_view.png

プラグインの反映を確認するために、このmain.dartの実装を変更します。
void型のmain()とStatelessWidgetを継承しているMyAppクラスに対して、
以下の実装に変更します。

import 'package:device_preview/device_preview.dart';

void main() => runApp(
  DevicePreview(
    enabled: !kReleaseMode,
    builder: (context) => MyApp(), // Wrap your app
  ),
);

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      locale: DevicePreview.locale(context),
      builder: DevicePreview.appBuilder,
      theme: ThemeData.light(),
      darkTheme: ThemeData.dark(),
      home: const MyHomePage(title: 'ToDo App'),
    );
  }
}

もし赤い波線が表示されてしまった場合は、使用したいライブラリのインポートが必要になるかと
思うので、波線個所にカーソルを合わせて「クイックフィックス」から

import library 'package:device_preview/device_preview.dart'

を選んでクリックすると解消されるかと思います。

(testフォルダにあるwidget_test.dartが赤くなっている場合は、constを消去してあげることで一旦解決するかと思います)

この状態で再度

flutter run 

を実行して起動します。

flutter_iphone.gif

おおー、すごい!
XcodeやAndroidで見たあのシミュレーター画面が出現しました!
Windowsでも見れるのはちょっと感動です。。。

このDevice Previewですが、右側の設定から、端末の種類
(iPhone/Android/iPad/MacBookPro)などが多数選択できます。
またロケーション、キーボード表示、ダークモードなどの切り替えにも対応しています。

flutter_iphone_setting.gif

また画面を横向きにしてスタイル崩れがないかなどのチェックもできるので、非常に万能だと感じました。

一方で、公式にもある通り(シミュレータの)完全な挙動を保証している訳ではないため
リリース前提にアプリのテストを行う際は、実機による検証も必要になるかと思います。

個人的には、この画面を横向きにしたときのレイアウトのチェックは、クロスデバイスだと
「不具合報告」→「修正報告」→「複数台端末による検証」のようなフローが
結構手間になっていたので、
確認しながら実装できるのはとても助かると感じました。

Flutterの仕組みとmain.dartの中身

先ほどのシミュレーターの画面をもう一度見てみましょう。
画面に描画される領域は「appBar」「body」「その他」という、とてもシンプルな3点構成になります。
領域に対して、スタイルのプロパティやテキスト、ボタンといったパーツを配置していく
という流れが基本になります。
これだけ見ると、シンプルな感じしますよね。

iphone_dart.png

次に、プログラム側の実装についてです。
シミュレーターを入れる前の
サンプルのlib/main.dartの中身の一部を抜粋して一緒に確認してみましょう。


void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {

  @override
  Widget build(BuildContext context) {

    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: Column(
          children: <Widget>[
            
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(),
    );
  }
}


なんかコードになったとたん、結構複雑そうに見えますよね(笑)

基本的には、1ファイルに3つのクラスが入っていて、
それらが互いに相互作用することによって処理が行われています。
(ちなみにmainはエントリーポイントです)

1つ目の解説としては、
・「Widget」 = 道具とか、仕掛けみたいな意味
・「StatelessWidget」クラス = 状態を持たないクラス
・「StatefulWidget」 クラス  =  状態を持つクラス
・「State」クラス      = 状態(そのもの)のクラス

上記がクラス名の意図で、そのクラスの役割はというと

・StatelessWidgetは、画面というよりはもっと外側にある
 アプリケーションの構成に関しての制御が中心

・StatefulWidgetは、アプリケーションのうちの1画面で
 どういう中身(State)を持ったコンポーネント呼び出すかという管理が中心

・Stateは画面内にあるすべてのUIとスタイル、データを保持したりするような
 具体的なプログラムの制御が中心(重たい処理や、ビジネスロジックなどを除く)

という感じになります。

なのでイメージ的には、画面を中心に
StatelessWidget > StatefulWidget > State
という感じで内向き(または外向き)の関係性があると言えます。

画面をいじりたければState見て、呼び出すStateクラスを変えたいなって思ったら
StatefulWidget見て、みたいな感じですね。

コードを書いている時に「あれなんか足りないな?」と思った時はこの図を思い出してもらうと良いかもしれないです。
(lib以下は最初はmain.dartの1ファイルしかないので、
フォルダ構成は勿論のこと、ここからflutterのアーキテクチャを自分好みにカスタムしてみるのも面白そうですね)

Onion.png

上記の解説の流れに従うと、
処理はStateに書いていくという感じなので、以下のように連動していることもお分かりいただけるかと思います。

relation.png

最後に、StatelessWidgetクラスとStateが返している値に関してもチェックしてみます。

先ほど解説した各クラスの役割と関係性を理解してから次のコードを見ると

explanation.png

結局

・StatelessWidgetが返したいのは
 より大きいもの = アプリケーションクラスのインスタンス
           (○○Appクラス)
・Stateが返したいのは
 より小さいもの = パーツの集合体クラスのインスタンス
           (Scaffold(土台、足場)クラス)

建築のように
Scaffoldを作って、渡して、出来上がったAppを納品して
という流れになるので、意味合いとしても自然に納得がいくかなと思います。

Widgetツリーについて

最後に重要な概念であるWidgetツリーについて解説します。

Stateクラスが返却するScaffoldの中身ですが、結論として
この中に置けるものは決まっています。

例えば、body > child > children のように
大きいものから徐々に細かくなっていく感じで、
さらに各Widgetが持てるプロパティがあらかじめ決まっています。(DOMツリーと近い概念です)

下図は一例ですが、もっと深く知りたいという方は
Flutterを生み出したGoogleが提唱している「Everything is Widget」の概念を読んでみるといいかもしれません。
https://docs.flutter.dev/ui/layout

widget_tree.png

ということで、
実はStatelessWidgetやStatefulWidget、Scaffoldなどもこれに含まれています。

特に画面の内部を構成する、Stateクラスが管理しているScaffoldの中身に関しては
親要素(AppBar, body, その他)毎に設定できる子要素やプロパティなどを
外側から内側に向かって記述していくという流れになります。


親要素: ***(

     子要素1: ***(
      プロパティ: ***,

            子要素2: ***(
            プロパティ: ***,
            ...
          )
     )
)

実際に初期のmain.dartにあるMyHomePageStateの
buildが返却しているScaffoldは上と同じような書き方をしていることが
お分かりいただけるかと思います。


return Scaffold(
  appBar: AppBar(
    backgroundColor: Theme.of(context).colorScheme.inversePrimary,
    title: Text(widget.title),
  ),
  body: Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        const Text(
          'You have pushed the button this many times:',
        ),
        Text(
          '$_counter',
          style: Theme.of(context).textTheme.headlineMedium,
        ),
      ],
    ),
  ),
  floatingActionButton: FloatingActionButton(
    onPressed: _incrementCounter,
    tooltip: 'Increment',
    child: const Icon(Icons.add),
  ), 
);

公式ドキュメントを参照しながら
流れに沿ってコードの記述ができるという意味では、段取りさえ理解できていれば
かなり使い勝手が良さそうな印象があります。

それでは大まかにFlutterのプログラムの構成が分かったかと思うので、Todoアプリの作成を
一緒に行っていきましょう!

ToDoアプリの仕様と設計

まずは最低限な仕様で要件を定義します。

①Todoの取得ができること
②Todoの追加ができること
③Todoの削除ができること 
④Todoの更新ができること
⑤Todoの保存ができること

まずメソッドですが、こちらはシンプルに
①get   : Todoを取得
②add   : Todoを追加
③delete :Todoを削除
④update :Todoを更新
⑤save  :Todoを保存
という感じで命名・定義してみます。

今回は、Webブラウザでの検証になるので
データのストア先はlocalStorageを利用します。

flutterの場合
localStorageを利用するにあたって、shared_preferencesの追加が必要になるので
先に追加しておきましょう。

flutter pub add shared_preferences

上記でshared_preferencesパッケージを追加した後、
実際に以下でインストールを行います。

flutter pub get

その後、Vscodeに戻り、プロジェクト内のpubspec.yamlに

dependencies:
  shared_preferences: (バージョン)

が追加されていることを確認できれば、正常にインストールされています。

それでは早速実装、、、といきたいですが、
ここはせっかくなので、ちょっと寄り道して
必要なクラス、データ、メソッドを設計してみましょう。

先ほどのmain.dartの中身についての説明に従うと
StatelessWidget, StatefulWdiget, Stateの3種類のクラスが存在し

drawio_class_base.png

この親クラスを元に、実際に使いたいクラスを作成するので
継承(extends)したそれぞれの子クラスが出来上がります。

drawio_class_extends.png

次にクラス名の名前を決めていくのですが、
ここは先述した

・StatelessWidgetは、画面というよりはもっと外側にある
 アプリケーションの構成に関しての制御が中心

・StatefulWidgetは、アプリケーションのうちの1画面で
 どういう中身(State)を持ったコンポーネント呼び出すかという管理が中心

・Stateは画面内にあるすべてのUIとスタイル、データを保持したりするような
 具体的なプログラムの制御が中心(ビジネスロジックなどを除く)

の流れに沿って

「TodoApp」「TodoForm」「TodoFormState」という感じで決めていきます。
つまり、こうなりますね。

drawio_class_extends_named.png

これでクラスに関しては一旦確定したので、次に各クラスが持つべきメソッドを決めていきましょう。

建築のように
Scaffoldを作って、渡して、出来上がったAppを納品して

TodoAppとTodoFormStateは、次のクラスに利用してもらうために組み上げるので、
親クラスからオーバーライドしたbuildメソッドが必要になります。

なので、こうなりますね。

drawio_class_extends_build.png

次に、TodoFormです。これは

・StatefulWidgetは、アプリケーションのうちの1画面で
どういう中身(State)を持ったコンポーネント呼び出すかという管理が中心

ということだったので、
TodoAppに渡せるように親クラスからオーバーライドしたcreateStateメソッドを用意します。

こんな感じですね。

0350_fix.png

またWidgetは有形なもので、Stateは無形(これが独り歩きすることはない)なので
StatelessWidgetと、StatefulWidgetの2クラスにはコンストラクタを用意します。
これらのクラスは画面毎に用意するので、一意となるキーが設定できるようになっています。

あとはStateクラスだけアンダーバーを付けることで、上の2つのクラスと区別しておきます。

画面を中心に
StatelessWidget > StatefulWidget > State
という感じで内向き(または外向き)の関係性があると言えます。

ということで
Stateクラス→TodoFormクラス→TodoAppクラスに依存関係を持たせましょう。

0350_02_fix.png

なんとまあ、先ほどのコードが立派なクラス設計図になってしまいました(笑)
というわけで先ほどのmain.dartファイルの本質的な構造は、上図のようになります。

次に、TodoFormクラスが持つ状態(State)の
TodoFormStateクラスに持たせるデータやメソッドは何が必要でしょうか?

ここでは一旦
入力値をtitle、
送信処理をsendとしてみます。

図を更新するとこうなりますね。

0350_03_fix.png

ところがどっこい、既にお気付きの方もいるかと思いますが
これではまだ設計が不完全になってしまいます。

何が足りないかというと勿論

①get   : Todoアプリを取得(取得できないときは、空の配列を返す)
②add   : Todoを追加
③delete :Todoを削除
④update :Todoを更新
⑤save  :Todoを保存

これらのメソッドもそうなんですが
依存関係(矢印の向き先にあるクラスの何のメソッドを呼ぶとか)が不明であることと、
Todoをモデル(オブジェクト)として保持するクラス

Todoリストをデータとして保存するためのクラス、そして
Todoリストを画面に表示させるためのコンポーネントとしてのクラス、
このコンポーネントの状態管理のクラスも足りていません。

ということで引き続きクラスやメソッドを追加して、データの流れと依存関係を整理しつつ
設計を変更していきましょう。

具体的には
・表示用のモデルクラスTodoを追加
・ストアクラスTodoListStoreを追加
・初期状態の制御が必要なので、
 オーバーライドしたinitStateメソッドをStateクラスに追加
・TodoListクラスとTodoListStateクラスの追加

・TodoFormは常時表示ではなく
 ボタンが押された時に表示されるようにしたいので
 TodoListクラスに_showTodoFormメソッドを追加
を合わせた5つの変更になります。

上記の変更を反映すると、以下のようになるかと思います。
本質的な部分だけ抜き出していくと、こんな感じでしょうか。

image.png

最後に各クラスの依存関係を整理すると、こんな感じになると思います。

複数のクラスがTodoListStoreに対してアクセスする構造になっているので、
このままだとデータ更新時の不具合が発生するリスクがあります。
このクラスのインスタンスは1つしか生成されないように、シングルトンパターンを適用します。

todo_diagram_fixed.png

このアプリができる処理としては、

・TodoApp起動時にTodoListが表示
・画面にある追加ボタンを押すと、TodoListState内にあるshowTodoForm()が実行されて
 TodoFormが表示される(登録画面)
・TodoForm内でタイトルを入力して追加ボタンを押すと、TodoFormState内で
 TodoListStoreクラスのadd()が実行されて、save()でTodoのインスタンスを保存する
・すでに登録されているリスト選択すると、
 TodoListState内にあるshowTodoForm()が実行されて、TodoFormが表示される
 (更新画面)
・TodoForm内でタイトルを入力して更新ボタンを押すと、TodoFormState内で
 TodoListStoreクラスのupdate()が実行されて、
 save()でTodoのインスタンスを保存する(上書きする)
・チェックがついた(done)Todoは、削除ボタンを押すと、TodoFormState内で
 TodoListStoreクラスのremove()が実行されて、
 save()でTodoのインスタンスを保存する(上書きする)

といった具合になります。

最低限のデータと機能ですが、これできちんと設計ができたかと思うので、
次に実装の方を進めていきましょう。

libフォルダ以下に用意するファイルは
①main.dart    : アプリケーション全体を呼ぶ処理(main(), TodoAppクラス)
②todo_list.dart   : TodoList, TodoListStateクラスの処理
③todo_form.dart  : TodoForm, TodoFormStateクラスの処理
④todo.dart     : Todoモデルクラスの処理
⑤todo_list_store.dart : TodoListStoreクラスの処理

になります。

今までの説明を見た後に、以下のコードを見て頂くと理解しやすいかと思います。

①main.dart


import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

import 'package:device_preview/device_preview.dart';
import 'package:test_app/todo_list.dart';


void main() => runApp(
  DevicePreview(
    enabled: !kReleaseMode,
    builder: (context) => TodoApp(),
  ),
);

class TodoApp extends StatelessWidget {
  
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      locale: DevicePreview.locale(context),
      builder: DevicePreview.appBuilder,
      theme: ThemeData.light(),
      darkTheme: ThemeData.dark(),
      home: TodoList(),
    );
  }
}

main.dartに関しては、先ほど説明した通り、アプリケーション全体のクラスを配置します。

②todo_list.dart


import 'package:flutter/material.dart';
import 'package:test_app/todo_form.dart';

class TodoList extends StatefulWidget {

  @override
  State<TodoList> createState() => _TodoFormState();
}

class _TodoFormState extends State<TodoList> {

  void _showTodoForm() async {

    await Navigator.of(context).push(
      MaterialPageRoute(
        builder:(context) {
          return TodoForm();
        },
      )
    );
  }


  @override
  Widget build(BuildContext context) {

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text("TodoList"),
      ),
      body: ListView.builder(
          //Todoリストの表示処理を書いていく
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _showTodoForm,
        child: const Icon(Icons.add),
      ), 
    );
  }
}

②の実装の要点としては、Navigator.of(context).push()によって、
画面遷移の処理を行っています。
ここでは遷移先にTodoFormクラスを指定してあげることで、
floatingActionButton(画面右下のボタン)が押された時にTodoの入力フォームを表示させる
という処理が可能になります。

また、保存先に登録されたTodoの表示をするために
bodyに対してはListViewを使っています。

それでは次に、フォームの処理についてです。

③todo_form.dart


import 'package:flutter/material.dart';

class TodoForm extends StatefulWidget {

  @override
  State<TodoForm> createState() => _TodoFormState();
}

class _TodoFormState extends State<TodoForm> {


  @override
  Widget build(BuildContext context) {

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text("TodoForm"),
      ),
      body: Container(
        padding: EdgeInsets.all(30),

        child: Column(
          children: <Widget>[

            //入力フォーム
            TextField(
              autofocus: true,
              decoration: const InputDecoration(
                labelText: "todoを入力",
              ),
            ),
            //ボタン
            Container(
              margin: EdgeInsets.only(top: 20),

              child: SizedBox(
                height: 30,
                width: double.infinity,
                child: ElevatedButton(
                  onPressed: () {
                    Navigator.of(context).pop();
                  },
                  child: Text(
                    "追加",
                  ),
                ),
              ),
            )
            
          ],
        ),
      )
    );
  }
}

特に見てほしい箇所が、bodyの中身についてです。

階層構造でクラスやパラメータを設定していくという流れについて説明したかと思いますが
ここではまさにその書き方を適用しています。
基本的にbodyの中には、外枠のpadding, marginが調整できるように
Containerを使用していきます。

またColumnを使用するとその中に複数のWidgetを縦に並べることが可能になります。

この時点でまだTodoのモデルとCRUD処理を持つクラスはまだ作成していませんが、
画面の制御としては以下のように動けているかと思います。
なんかそれっぽくなってきましたね!

flutter_iphone_form.gif

④todo.dart

こちらはモデルクラスなので
今回保存するデータの変数を記述しています。
またSharedPreferenceを使ってデータを保存・取り出す際に型の変換が必要になるため、
JsonからMapおよびJsonに変換するメソッドを追加しておきます。


class Todo {


  late int id;

  late String title;

  late bool done;

  Todo(
    this.id,
    this.title,
    this.done,
  );

  Todo.fromJson(Map json) {
    id    = json['id'];
    title = json['title'];
    done  = json['done'];
  }

  toJson() {
    return {
      'id': id,
      'title': title,
      'done': done,
    };
  }

}


ここから
実際にデータの保存をして表示させる所まで実装を行ってみます。

⑤todo_list_store.dart



import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:test_app/todo.dart';


class TodoListStore {

  static final TodoListStore _instance = TodoListStore._internal();

  TodoListStore._internal();

  factory TodoListStore() {
    return _instance;
  }


  List<Todo> _list = [];


  int count() {
    return _list.length; 
  }

  Todo findByIndex(int index) {
    return _list[index];
  }

  void get() async {

    var prefs = await SharedPreferences.getInstance();

    var target = prefs.getStringList('todo') ?? [];

    //StringList→Json→Map→TodoListに変換
    _list = target.map((m) => Todo.fromJson(json.decode(m))).toList();

  }

  void add(bool done, String title) {

    int id;

    if (count() == 0) {
      id = 1;
    }
    else {
      id = _list.last.id + 1;
    }

    var todo = Todo(id, title, done);

    _list.add(todo);
    save();
  }

  void delete(Todo todo) {

    _list.remove(todo);
    save();
  }

  void update(Todo todo, bool done, [String? title]) {

    todo.done = done;

    if (title != null) {
      todo.title = title;
    }

    save();
  }

  void save() async {

    var prefs = await SharedPreferences.getInstance();

    //TodoList→Map→Json→StringListに変換
    var target = _list.map((m) => json.encode(m)).toList();
    prefs.setStringList('todo', target);
  }


}

このクラスでやっていることの中で特に重要な部分については以下になります。

①シングルトンパターンに関して
static final修飾子を追加することで上書きできないクラス変数として
プログラム上で共有される、唯一のインスタンスとなります。(データの永続化)

_internal() は、コンストラクタをプライベートアクセスにしています。

この結果、自動でインスタンスが生成されなくなるため、クラス内部の
ファクトリーメソッドを介してインスタンスを返却するようにします。

こうすることで、外部からTodoListStoreを利用できるようになります。

②ローカルにデータの送受信を行うために必要なSharedPreferenceに関して
こちらは保存をする際にList型に変換する必要があるため、
TodoList → Map → Json → StringList の流れで変換をします。

一方でデータを取り出したい時は、逆方向の
StringList → Json → Map → TodoList の流れで変換をします。

③実際にtodoを保存する際は、事前に配列の要素が入っているかを
配列自体の長さでチェックして、
・ある場合はidの最後尾に+1して、他のデータを上書きしないようにsaveする
・ない場合は1を代入して、最初の1番目のデータとしてsaveする

という感じになります。

一通りTodoListStoreクラスに
必要なメソッドが実装されたので、表示側の処理に戻りたいと思います。

TodoFormの修正


import 'package:flutter/material.dart';
import 'package:test_app/todo.dart';
import 'package:test_app/todo_list_store.dart';

class TodoForm extends StatefulWidget {

  final Todo? todo;

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

  @override
  State<TodoForm> createState() => _TodoFormState();
}

class _TodoFormState extends State<TodoForm> {


  final TodoListStore _store = TodoListStore();

  late bool _isCreated;   //新規追加かどうか

  late String _title;

  late bool _done;

  late Todo todo;

  @override
  void initState() {

    super.initState();

    var todo = widget.todo;
    
    _title = todo?.title ?? "";

    _done = todo?.done ?? false;

    if (todo == null) {
      _isCreated = false;
    }
    else {
      _isCreated = true;
      this.todo = todo;
    }
  }

  @override
  Widget build(BuildContext context) {

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(_isCreated ? '更新画面' : '登録画面'),
      ),
      body: Container(
        padding: EdgeInsets.all(30),

        child: Column(
          children: <Widget>[

            TextField(
              autofocus: true,
              decoration: const InputDecoration(
                labelText: "todoを入力",
              ),
              controller: TextEditingController(text: _title),
              onChanged: (String value) {
                _title = value;
              },
            ),
            
            Container(
              margin: EdgeInsets.only(top: 20),

              child: Column(
                children: [

                  SizedBox(
                    height: 30,
                    width: double.infinity,
                    child: ElevatedButton(
                      onPressed: () {

                        _isCreated ? 
                        _store.update(widget.todo!, _done, _title) :
                        _store.add(_done, _title); 

                        Navigator.of(context).pop();
                      },
                      child: Text(
                        _isCreated ? "更新する" : "追加する"
                      ),
                    ),
                  ),

                  Visibility(
                    visible: (_isCreated && _done) ? true : false,
                    child: Container(
                      margin: EdgeInsets.only(top: 20),
                      child:  SizedBox(
                      height: 30,
                      width: double.infinity,
                      child: ElevatedButton(
                          onPressed: () {

                            if (_isCreated) {
                              setState(() => _store.delete(todo));
                            }
                            Navigator.of(context).pop();
                          },
                          child: Text(
                            "削除する"
                          ),
                        ),
                      ),
                    )
                  ) 

                ],
              ),
            )
          ],
        ),
      )
    );
  }
}

まずTodoList側でTodoのデータを取得し、_showTodoFormの実行時にTodoFormに対して値の送信を行うため
TodoFormクラスにTodoの変数を追加しています。
また、新規で追加する際はTodo自体がnullになる可能性があるため、nullセーフになるように?が付いています。
コンストラクタの箇所に関してもtodoを渡すかどうかで値を分けています。

次に_TodoFormStateですが、こちらでは

TodoListStoreを呼んだ後に各値の初期化を行っています。
通常nullセーフな値はその場で初期化しないと怒られてしまうのですが
lateを使うことによって、以降の好きな位置で初期化をすることができるようになります。
(実際にinitState内で初期化していることが分かるかと思います)

bodyの中身に関して重要な箇所は、
TextFieldの中に
TextEditingControllerを設定することにより、テキストフィールドの
入力の変更を検知して値の更新ができるようになります。
これは、既に登録したTodoの中身を確認する時に使われます。

ElevatedButtonに関しては、_isCreatedの値の内容に応じて
ボタンの表示と処理の内容が切り替わるように実装しています。

TodoListの修正


import 'package:flutter/material.dart';
import 'package:test_app/todo.dart';
import 'package:test_app/todo_form.dart';
import 'package:test_app/todo_list_store.dart';

class TodoList extends StatefulWidget {

  @override
  State<TodoList> createState() => _TodoListState();
}

class _TodoListState extends State<TodoList> {

  final TodoListStore _store = TodoListStore();

  void _showTodoForm([Todo? todo]) async {

    await Navigator.of(context).push(
      MaterialPageRoute(
        builder:(context) {
          return TodoForm(todo: todo);
        },
      )
    );

    setState(() {});
  }

  @override
  void initState() {

    super.initState();

    Future (
      () async {
        () => _store.get();
      }
    );
    
  }

  @override
  Widget build(BuildContext context) {

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text("Todoリスト"),
      ),
      body: ListView.builder(
        itemCount: _store.count(),
        itemBuilder: (context, index) {

        var item = _store.findByIndex(index);

          return GestureDetector(

              onTap: () {
                _showTodoForm(item);
              },

              child: Container(
              decoration: const BoxDecoration(
                border: Border(
                  bottom: BorderSide(color: Colors.grey),
                ),
              ),

              child: ListTile(

                leading: Text(item.id.toString()),
                title: Text(item.title),
                trailing: Checkbox(
                  value: item.done,
                  onChanged: (bool? value) {
                    setState(() => _store.update(item, value!));
                  },
                ),
              ),
              
            ),
          );
        },
      ),
      
      floatingActionButton: FloatingActionButton(
        onPressed: _showTodoForm,
        child: const Icon(Icons.add),
      ), 
    );
  }
}

TodoListの方に関しては、値の取得と表示、ページの更新を行うように処理を実装しています。

TodoFormの方で言及した通り、Todoのインスタンスを_showTodoFormに渡す必要があり、
引数を追加しました。この引数に値が渡らないことがあるので、?が付いています。

またTodoの追加及び更新を行うので、setStateを追加して都度ページを更新するようにしています。

次にinitState内のFutureですが、非同期処理をその場で実行して
TodoListの中身を取得しています。

あとはbodyの中身について、リスト化して画面に表示するためのListviewを使用して
各列に対しての表示内容を設定しています。

今回は各リストをタップしたら更新画面に遷移するように、GestureDetectorを使用しました。

以上のコードをflutter runで実際に動かしてみると、、、、、

flutter_iphone_finished.gif

バッチリ動いていますね!

という感じで、FlutterのTodoアプリが完成しました。

おわりに

皆様いかかでしたでしょうか?

Flutterのコードを書く時のコツは、構造上UI部分と混ざってしまう事が多く
初めから完璧なものを作ろうとすると結構ごちゃごちゃしてくるので
最低限のUIを作成 → 機能の実装 → 詳細を詰める、みたいなスタンスでいくと良いのかなと個人的に思いました。

特にVscodeだとクイックフィックスが使えて割と良い感じに不具合を直してくれることもありますし、
紹介したシミュレーターもとても使いやすいので、「慣れること」を意識しながら学習してみると定着しやすいかと思います:)

ソースコード全体は私のこちらのリポジトリに載せていますので、何かあればご参照ください
https://github.com/JWRicky/flutter-todo

最後に、参考にさせて頂いた記事の一覧になります

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?