【Flutter】iOSアプリのマイページ画面とかでよくあるスクロールしたら画像がズームされるUIを実装してみた

# はじめに
下記のキャプチャ動画のような、iOSアプリのマイページ画面とかでよくあるスクロールしたら画像がズームされるUIを実装してみました。(このUIなんて呼んだらいいんだ？)

ソースコードは[こちらのリポジトリ](https://github.com/TsurumotoKentarou/flutter-scroll-zoom-header-image)にあげています。

![preview1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/263659/0bb7d123-4782-8e6f-d268-4d16c3b21cc6.gif)

## 動作環境
Flutter: 1.22 
Dart: 2.10

## 実装
実装の手順は下記の通りです。

* **CustomScrollView**と**SliverAppBar**で画像ヘッダー付きのリストを表示する
* ヘッダーの幅と高さを画像の比率に合わせる

## **CustomScrollView**と**SliverAppBar**で画像ヘッダー付きのリストを表示する
まずはヘッダー付きリストを作成します。ソースコードはこちらです。

``` dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class ScrollZoomHeaderScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // ヘッダー画像
    final Image headerImage = Image.asset(
      'assets/view.jpg',
      fit: BoxFit.cover,
    );
    return Scaffold(
        body: SafeArea(
      child: CustomScrollView(
        slivers: <Widget>[
          SliverAppBar(
              backgroundColor: Colors.white,
              pinned: true,
              stretch: true,
              expandedHeight: 200,
              flexibleSpace: FlexibleSpaceBar(
                  collapseMode: CollapseMode.parallax,
                  stretchModes: [
                    StretchMode.zoomBackground,
                  ],
                  background: headerImage)),
          SliverPadding(
            padding: const EdgeInsets.all(10),
            sliver: SliverList(
                delegate: SliverChildBuilderDelegate((context, index) {
              return Card(
                child: ListTile(
                  title: Text("List：$index"),
                  leading: Icon(Icons.person),
                ),
              );
            }, childCount: 100)),
          ),
        ],
      ),
    ));
  }
}
```

こちらを実行するとヘッダー画像付きのリストが表示され、スクロール位置が一番上の状態で下に引っ張るようにスクロールすると画像が拡大されながら表示されます。
拡大されながら表示するためには、SliverAppBarの**stretch**プロパティをtrueに設定する必要があります。

## ヘッダーの幅と高さを画像の比率に合わせる
ここまでの実装でほぼ要件は満たせたのですが、上記の実装だと下記の動画のように、下に引っ張ってから画像が拡大されるまでの間にラグがあることがわかります。(伝わりますかね？)

![ezgif.com-gif-maker (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/263659/e3b5315b-6177-6ab0-62cd-b0c5c71ed5ea.gif)

これは画像の高さと、ヘッダーの高さを設定するSliverAppBarの**expandedHeight**が合っていないためです。(先程の実装だとexpandedHeightは200で固定していました)

なのでヘッダーの高さを画像の高さと合わせてあげる必要があるのですが、そのまま設定するとでかい画像だった場合にヘッダーが画面に収まりきらないので、ヘッダーの高さを画像の比率で計算します。

``` dart 
// ヘッダーの幅
// 画面幅いっぱいにする
final headerWidth = MediaQuery.of(context).size.width;

// ヘッダーの高さ
// 画像の比率から計算する
final headerHeight = headerWidth * (画像の高さ / 画像の幅)

```

※ **(上記のようにヘッダーの高さを動的にする以外では、ヘッダーの高さを固定値にして画像をその高さに合わせてクロップしてあげる方針もあります。ヘッダーの高さは固定にすることが多いのでそっちの方が良いかもしれません)**

## コード全体

ソースコード全体はこちらです。
画像はImageStreamListenerで読み込み、Completerで読み込み完了を発火させ、FutureBuilder内でサイズを参照しています。

``` dart
import 'dart:async';

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'dart:ui' as ui show Image;

class ScrollZoomHeaderScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // ヘッダー画像
    final Image headerImage = Image.asset(
      'assets/view.jpg',
      fit: BoxFit.cover,
    );

    // ヘッダー画像のCompleter
    Completer<ui.Image> completer = new Completer<ui.Image>();
    headerImage.image
        .resolve(ImageConfiguration())
        .addListener(ImageStreamListener((ImageInfo info, bool _) {
      completer.complete(info.image);
    }));

    // ヘッダー画像の幅
    final headerWidth = MediaQuery.of(context).size.width;

    return Scaffold(
        body: SafeArea(
      child: CustomScrollView(
        slivers: <Widget>[
          FutureBuilder<ui.Image>(
              future: completer.future,
              builder: (context, snapshot) {
                return SliverAppBar(
                    backgroundColor: Colors.white,
                    pinned: true,
                    stretch: true,
                    expandedHeight: snapshot.hasData
                        ? headerWidth *
                            snapshot.data.height /
                            snapshot.data.width
                        : 0,
                    flexibleSpace: FlexibleSpaceBar(
                        collapseMode: CollapseMode.parallax,
                        stretchModes: [
                          StretchMode.zoomBackground,
                        ],
                        background: headerImage));
              }),
          SliverPadding(
            padding: const EdgeInsets.all(10),
            sliver: SliverList(
                delegate: SliverChildBuilderDelegate((context, index) {
              return Card(
                child: ListTile(
                  title: Text("List：$index"),
                  leading: Icon(Icons.person),
                ),
              );
            }, childCount: 100)),
          ),
        ],
      ),
    ));
  }
}

```

以上です。

## 参考にした記事
* [SliverAppBarのドキュメント](https://api.flutter.dev/flutter/material/SliverAppBar-class.html)
* [ImageStreamListenerについて](https://www.366service.com/jp/qa/c2ad2d7b1b320202133592aa6c638eb9)