Chapter 11

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

すぎっと@フルペラットエンジニア
すぎっと@フルペラットエンジニア
2022.02.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),
  ),
),

おしまい

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

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

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

すぎっと。