Chapter 11

Step10: デザインをカイゼンしてみよう

sugit
sugit
2023.05.07に更新

Step10 の概要

Step10 では、ここまでに作成したアプリケーションのデザインに関していろいろな変更を加えます。いろいろなパターンの UI 作成をすることを目的としています。

本章で学べること

  • ListView / GridView
  • IndexedStack
  • CachedNetworkImage
  • Hero Animation
  • ...など

Step10-1. リストとグリッドを切り替えられるようにしてみよう

トップページは List 形式で、ポケモンの画像・タイトル・タイプなどが並んでいます。もっとシンプルに画像だけ並べたスタイルでも良いですよね。
では、作っていきましょう。
PokeList.dart にはお気に入りリストへの切り替え機能も実装されているので、PokeList.dart を拡張してしまいます。ファイル名が PokeList なのがちょっと気になるのですが、まぁ気にせずいきましょう。

bool isGridMode = true;

とりあえずモードを追加して、分岐させます。

if (isGridMode) {
    return GridView.builder(
      gridDelegate:
          const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 3,
      ),
      itemCount: itemCount(favs.favs.length, _currentPage) + 1,
      itemBuilder: (context, index) {
        if (index ==
            itemCount(favs.favs.length, _currentPage)) {
          return Padding(
            padding: const EdgeInsets.all(16),
            child: OutlinedButton(
              child: const Text('more'),
              style: OutlinedButton.styleFrom(
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(20),
                ),
              ),
              onPressed:
                  isLastPage(favs.favs.length, _currentPage)
                      ? null
                      : () => {
                            setState(() => _currentPage++),
                          },
            ),
          );
        } else {
          return PokeGridItem(
            poke: pokes.byId(itemId(favs.favs, index)),
          );
        }
      },
    );
  } else {
    return ListView.builder(
    // ...

PokeGridItem は以下のようにグリッドビュー用に別途実装しました。

class PokeGridItem extends StatelessWidget {
  const PokeGridItem({Key? key, required this.poke}) : super(key: key);
  final Pokemon? poke;
  
  Widget build(BuildContext context) {
    if (poke != null) {
      return Column(
        children: [
          InkWell(
            onTap: () => {
              Navigator.of(context).push(
                MaterialPageRoute(
                  builder: (BuildContext context) => PokeDetail(poke: poke!),
                ),
              ),
            },
            child: Container(
              height: 100,
              width: 100,
              decoration: BoxDecoration(
                color: (pokeTypeColors[poke!.types.first] ?? Colors.grey[100])
                    ?.withOpacity(.3),
                borderRadius: BorderRadius.circular(10),
                image: DecorationImage(
                  fit: BoxFit.fitWidth,
                  image: NetworkImage(
                    poke!.imageUrl,
                  ),
                ),
              ),
            ),
          ),
          Text(
            poke!.name,
            style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
          ),
        ],
      );
    } else {
      return const SizedBox(
        height: 100,
        width: 100,
        child: Center(
          child: Text('...'),
        ),
      );
    }
  }
}

表示の切り替えメニューを追加します。

これでできました。

Step10-2. IndexedStack

Step5 のときに、ボトムナビゲーションを使ったページ切りかえで「がっつり切り替えているので情報が失われます」という記述をしました。

ここではその問題を解決しようと思います。

IndexedStack class - widgets library - Dart API

Step5 では以下のように Widget を切り替えていました。

body: SafeArea(
        child: currentbnb == 0 ? const PokeList() : const Settings(),
      ),

これだと何がまずいのでしょうか。

お気に入り表示モードを例に見てみましょう。

この時点の実装では、表示モードを「お気に入り表示」にしたのち、ボトムナビゲーションで設定画面に切り替え、戻ってくると、「お気に入り表示」が消えてしまいます。

これは ボトムナビゲーションによる切り替えによって、Widget ツリーから完全に破棄されているからです。

Scaffold → SafeArea → PokeList / Settings と、完全に切り替わっており、設定画面が出ているときには PokeList が完全に失われています。

これによってどういう問題があるかというと、PokeList に紐づく state も一緒に失われることです。

ナビゲーションを切り替えて戻ってくると、以下の通り、_PokeListState のハッシュ値が変わってい流ことがわかります。つまり、完全に別の状態が再度生成されているということになります。

これではメインの画面の状態が破棄されてしまい、使い勝手がとても悪いアプリケーションとなります。そこで、状態が破棄されないようにしたいです。

この場合、処置は 2 種類の方法があります。

  1. 状態を StatefulWidget に紐づく State に持たせるのをやめ、Provider などで上位に保持する
  2. Widget を破棄しないようにする

(1)の手法はシンプルにすべてを StatelessWidget で構成することです。しかし、この方法では本来 Provider などの状態管理に載せるほどでもない情報を載せることになる場合があります。これは好みが分かれるところですが、私はあまり良いものではないと思います。Widget の寿命と State の寿命はセットにしておきたいような要素は少なくないからです。

(2) IndexedStack がこれを解決します。予想がつくかもしれませんが、IndexedStack は Widget を切り替えても、他の Widget を破棄せず保持してくれます。

変更方法はシンプルです。

body: SafeArea(
        child: IndexedStack(
          children: const [PokeList(), Settings()],
          index: currentbnb,
        ),
      ),

これだけです。

これまでの説明で想像がつくかもしれませんが、以下のようにどちらの Widget も同時に保持した状態で、見せるものだけ変更しています。

Step10-3. CachedNetworkImage

Flutter アプリでネットワーク上にある画像データを大量に扱う場合、必ずといっていいほど使用した方が良いパッケージを紹介し、導入します。

cached_network_image | Flutter Package

これは Flutter 公式も公認の方法です。

Work with cached images

ネットワーク上の画像(https:// ~~~~ / .png など) を表示する場合、標準では

NetworkImage("URL")

で対応すると思います。

このポケモンリストについても、各所で NetworkImage() Widget を使用しています。

NetworkImage でも仕組みは確認していませんが、ある程度は破棄せず保持しておいてくれているのでそれなりに画像をみることはできます。しかし、さすがにアプリを終了したりすると、キャッシュは消えてしまいます。

CachedNetworkImage はデバイスにキャッシュを保持してくれます。iOS, Android なら sqflite の形式で保存されますので、アプリを終了してもキャッシュが残ります。ただ、保存先は各 OS が保持するアプリの保存領域ですので、OS 都合で消えるかもしれません。あくまでキャッシュという扱いだということをご理解ください。

CachedNetworkImage の使い方は至ってシンプルです。

NetworkImage() => CachedNetworkImageProvider()
Image.network() => CachedNetworkImage()

と置き換えるだけです。

もちろん CachedNetworkImage にはいろいろなオプションがあります。

placeholder や errorWidget は定番で、読み込み中の状態を表示する Widget と読み込みエラーの時の Widget を指定できます。リリース予定のアプリであれば、ぜひ使用してください。

今回はあくまでキャッシュを導入するための説明でしたので、煩わしいのでカットします。

Step10-4. Hero アニメーション

アニメーションの導入をおこないます。

アニメーションにはいろいろな種類がありますが、基本的には Before と After の状態を定義し、その間の変化に対してプログラムを設計するようなものです。

左から右に移動するのであれば、位置情報をプログラムしますし、ズームアップするのであれば、高さ・幅をプログラムします。

もっともシンプルでわかりやすいアニメーションとして Hero があります。

Hero animations

今回はこちらを導入してみます。

Hero の導入はとても簡単で、共通の tag を指定した Hero Widget で Before と After それぞれをラップするだけです。

child: Hero(
        tag: poke.name,
        child: CachedNetworkImage(
          imageUrl: poke.imageUrl,
          height: 200,
          width: 200,
        ),
      ),
child: Hero(
        tag: poke!.name,
        child: Container(
          height: 100,
          width: 100,
          decoration: BoxDecoration(
            color: (pokeTypeColors[poke!.types.first] ?? Colors.grey[100])
                ?.withOpacity(.3),
            borderRadius: BorderRadius.circular(10),
            image: DecorationImage(
              fit: BoxFit.fitWidth,
              image: CachedNetworkImageProvider(
                poke!.imageUrl,
              ),
            ),
          ),
        ),
      ),

これだけで、以下のようなアニメーションが完成します。

HeroAnimation.gif

アニメーションは要所で使うと良いアクセントになりますが、正直くどい物も多いです。
今回導入した Hero アニメーションもサンプルとして大胆でわかりやすかったので導入しましたが、もしこのアプリをリリースするとなったら、私ならこのアニメーションは外します。ちょっとくどいので。アニメーションに凝りすぎるとアプリケーションの動作がもっさりしたりするので、結構使いどこが難しかったりします。明確な指針はないので、実際に実装して見て、アプリを触って見て、可能なら触ってもらって、導入を検討するのが良さそうです。

Step10-5. UI のカイゼン

詳細画面をもう少しリッチな見た目にしようと思います。

まず背景色をポケモンのタイプ色で塗りましょう。Opacity はあどで微調整しますのでとりあえず 0.5 にします。Scaffold の background を使わなかったのは、ダークモードとライトモードの背景に合わせて少し濃淡を変えたかったので、Opacity + 各モードの背景で色を変えてみた、という感じです。

body: Container(
          color: (pokeTypeColors[poke.types.first] ?? Colors.grey[100])
              ?.withOpacity(.5),
					child: SafeArea(
				    // ....

次に少しアクセントを追加してみます。

ポケモンの背景に丸いイメージを重ねてみます。重ねるときは Stack でしたね。

Stack(
  alignment: Alignment.bottomCenter,
  children: [
    Padding(
      padding: const EdgeInsets.all(16),
      child: Container(
        height: 280,
        width: 280,
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(180),
          color: Colors.white.withOpacity(.5),
        ),
      ),
    ),
    SizedBox(
      child: Hero(
        tag: poke.name,
        child: CachedNetworkImage(
          imageUrl: poke.imageUrl,
          height: 250,
          width: 250,
        ),
      ),
    ),
  ],
),

ポケモンの番号も "No.1" という見た目から "#001" に変更します。

このゼロ埋め処理は Dart なら簡単にできます。

Container(
  padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
  decoration: BoxDecoration(
    borderRadius: BorderRadius.circular(90),
    color: Colors.white.withOpacity(.5),
  ),
  child: Text(
    '#${poke.id.toString().padLeft(3, "0")}',
    style: const TextStyle(
      fontSize: 16,
      fontWeight: FontWeight.bold,
    ),
  ),
),

名前のところにもう少し余白が欲しいので追加します。
あと、小文字はなんとなくカッコ悪いので 1 文字目だけ大文字にします。

Padding(
  padding: const EdgeInsets.symmetric(vertical: 16),
  child: Text(
    '${poke.name.substring(0, 1).toUpperCase()}${poke.name.substring(1)}',
    style: const TextStyle(
        fontSize: 36, fontWeight: FontWeight.bold),
  ),
),

おしまい

とりあえず、本書は初版の時点ではここまでです。
新しいネタが思いつき次第順次追加しようと思います。

詳細解説用の別冊を用意する予定をしているので、今回実装したアプリケーションについてもっと詳しく知りたい場合はぜひ別冊の方を見てみてください。

長文にお付き合いいただき、ありがとうございました。

すぎっと。