[Flutter]AppStoreにあるような、タップすると拡大するカードを作ってみる

# タップすると拡大するカード
完成したアニメーションはこんな感じ
![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249334/9bd6c5bc-8666-36f1-7bdc-f66d804e5ec0.gif)

完成したコードは以下になります。
https://github.com/reiji012/flutter_card

##Widget
使用する主なWidgetは以下になります。
・Hero
・AnimatedPadding
・GestureDetector

カードの切り替えに**`PageController`、**そして画面遷移に**`PageRouteBuilder`**を使用します。

#作ってみよう

それでは実際に作っていきます。
今回はFlutterの環境構築は省きますので、まだ環境構築が済んでいない方はインストールしてください。

デフォルトでコードが生成されるのでそちらに手を加えていきます。

##一覧画面
まずはカードを並べる画面を作成していきたいと思います。
ではとりあえずの土台を作っていきます。

初期生成でいい感じにできていると思うので、邪魔な部分を取り払いましょう

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

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
        ),
      ),
    );
  }
}
```
とりあえずの土台ができました。
<img width="414" alt="IMG_6A9C36465741-1.jpeg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249334/49fb8ee5-2a6f-2241-8256-9e748ed4131d.jpeg">

ではここに色々と付け足していきましょう

カードを並べるためのPageViewと、実際に並べるカードを作っていきます。

###Card
(カードは、押した時に凹むアニメーションを付けたいのでStatefulWidgetで作っていきます。)

```dart:CustomCard

class CustomCard extends StatefulWidget {
  @override
  State<StatefulWidget> createState () {
    return CustomCardState();
  }
}

class CustomCardState extends State<CustomCard> {
  @override
  Widget build(BuildContext context) {
    return Card(
      clipBehavior: Clip.antiAlias,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(15.0),
      ),
      child: Align(
        alignment: Alignment.topCenter,
        child: Image.asset('image/image.jpg', fit: BoxFit.fill),
      ),
      elevation:10,
    );
  }
}
```

###PageView

```dart:_MyHomePageState
class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Container(
          child: cardPageView(),
        ),
      ),
    );
  }

  Widget cardPageView() {
    return Container(
      height: 315,
      child: PageView(
        controller: PageController(viewportFraction: 0.8),
        children: <Widget>[
          CustomCard(),
        ],
      ),
    );
  }
}
```

いい感じのカードが浮かび上がってきました！(分かりやすいように画像を表示させてみました)
<img width="414" alt="IMG_D77A81BB36E7-1.jpeg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249334/aa771d8e-24a3-5c5b-1227-cad9891ca04e.jpeg">
ちなみに画像の表示のさせ方は[こちら](https://qiita.com/yu124choco/items/a2710ec004d3425a2a0b)などを参考にしてみてください。


ではこのカードを複数出せるようにします
以下のようなデータを作って、それを元にカード複数表示させてみましょう。

`var itemList = ['one', 'two', 'three', 'for'];`

```dart
  Widget cardPageView() {
    var itemList = ['one', 'two', 'three', 'for'];

    return Container(
      height: 315,
      child: PageView(
        controller: PageController(viewportFraction: 0.8),
        children: <Widget>[
          for(var item in itemList ) Container(
            // 間隔が狭くなるので若干marginを付けてあげる
            margin: EdgeInsets.only(
              right: 10,
              bottom: 20
            ),
            child: CustomCard(),
          )
        ],
      ),
    );
  }
```

いい感じに並びました！
![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249334/3c32bd14-cf85-8f62-b018-65dc9741a217.gif)

ではカードをタップした時に画面を遷移するようにしていきます。

##詳細画面
拡大時のページを作ってみましょう

```dart:DetailPage
class DetailPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        backgroundColor: Colors.white,
        body: Column(
          children: <Widget>[
            Container(
              child: Image.asset('image/image.jpg'),
            ),
            Container(
              child: Text(
                'content'
              ),
            )
          ],
        )
    );
  }
}
```
<img width="414" alt="IMG_3781C2F44E85-1.jpeg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249334/eb69cb71-0d75-9689-f8e6-6a4367631352.jpeg">

ざっとこんな感じにしました。
###閉じるボタン
あとは縮小するためのボタンがないのでそれを付けてあげましょう

```dart:DetailPage

class DetailPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        backgroundColor: Colors.white,
        body: Column(
          children: <Widget>[
            Container(
              child: imageContents(context),
            ),
            Container(
              child: Text(
                'content'
              ),
            )
          ],
        )
    );
  }

  // 画像Widget
  Widget imageContents(BuildContext context) {
    double statusBarHeight = MediaQuery.of(context).padding.top;
    return Container(
        height: 277,
        color: Colors.white,
        child: Container(
            child: Stack(
              children: <Widget>[
                Image.asset('image/image.jpg', fit: BoxFit.cover,),
                Column(
                  verticalDirection: VerticalDirection.down,
                  children: <Widget>[
                    Container(
                      margin: EdgeInsets.only(
                        top: statusBarHeight
                      ),
                      child: Row(
                          mainAxisAlignment: MainAxisAlignment.spaceBetween, // これで両端に寄せる
                          children: <Widget>[
                            Container(),
                            Container(
                              child: RaisedButton(
                                child: Icon(Icons.close, color: Colors.white,),
                                color: Colors.blue,
                                shape: CircleBorder(),
                                onPressed: () {
                                  Navigator.pop(
                                    context,
                                  );
                                },
                              ),
                            )
                          ]
                      ),
                    )
                  ],
                )
              ],
            )
        )
    );
  }
}
```
<img width="414" alt="IMG_4808DC6009A1-1.jpeg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249334/cac38811-8cf6-97c9-7dc4-329da3e94da1.jpeg">

こんな感じになりました。
ちょっと長くなっちゃったので個別に解説します。
###ステータスバー
```dart
Widget imageContents(BuildContext context) {
    double statusBarHeight = MediaQuery.of(context).padding.top;
```

```dart
Container(
    margin: EdgeInsets.only(
        top: statusBarHeight
    ),
```
まずここで、ボタンがステータスバーにかぶらないように、ステータスバーの高さを取得してその分マージンを付けてあげてます。
###Stack
次に`Stack`について、

```dart
child: Stack(
    children: <Widget>[
    .....
    ]
)
```
ここですね。
この`Stack`を使用することによって、**Widget同士を重ねる**ことが可能になります。
これで画像の上にボタンを重ねて表示させています。
###ボタンの端寄せ
そしてボタンを右端に寄せている部分が以下になります。

```dart
child: Row(
    mainAxisAlignment: MainAxisAlignment.spaceBetween, // これで両端に寄せる
    children: <Widget>[
        Container(),
        Container(
            child: RaisedButton(
                 child: Icon(Icons.close, color: Colors.white,),
                 color: Colors.blue,
                 shape: CircleBorder(),
                 onPressed: () {
                   Navigator.pop(
                     context,
                   );
                 },
            ),
        )
    ]
),
```

`mainAxisAlignment: MainAxisAlignment.spaceBetween,`
Rowにこちらのオプションを付けてあげて、ダミーのWidgetを一つ置くことで右端に来るようにしています。

これで**カード一覧画面**と**拡大時の画面**ができました。

##アニメーション
それではこれからアニメーションを付けていきます。

###GestureDetectorとAnimatedPadding
Cardにジェスチャーアクションを付けていきます。

先ほど作ったCustomCardを**GestureDetector**で包みます。
そしてタップしたときの凹みを表現するためにさらにそれを**AnimatedPadding**で包みます。

```dart:CustomCardState

class CustomCardState extends State<CustomCard> {
  var _hasPadding = false;

  @override
  Widget build(BuildContext context) {
    return AnimatedPadding(
      duration: const Duration(milliseconds: 80),
      padding: EdgeInsets.all(_hasPadding ? 10 : 0),
      child: GestureDetector(
        onTapDown: (TapDownDetails downDetails) {
          setState(() {
            _hasPadding = true;
          });
        },
        onTap: () {
          print('Card tapped.');
          setState(() {
            _hasPadding = false;
          });
          Navigator.push(
              context,
              PageRouteBuilder(
                transitionDuration: Duration(milliseconds: 500),
                pageBuilder: (_, __, ___) => DetailPage(),
              ));
        },
        onTapCancel: () {
          setState(() {
            _hasPadding = false;
          });
        },
        child: Card(
          clipBehavior: Clip.antiAlias,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(15.0),
          ),
          child: Align(
            alignment: Alignment.topCenter,
            child: Image.asset('image/image.jpg', fit: BoxFit.fill),
          ),
          elevation:10,
        ),
      ),
    );
  }
}

}
```

こちらもそれぞれ解説していきます。

```dart:CustomCardState
duration: const Duration(milliseconds: 80),
padding: EdgeInsets.all(_hasPadding ? 10 : 0),
```

ここで凹むときのアニメーションの時間の長さと、どれくらい凹ませるかを指定しています。
`_hasPadding `
このstateを切り替えることで凹んだり元に戻したりしてます。

そして以下の部分が、各ジェスチャーに対応した処理をさせている部分です。

```dart:CustomCardState
onTapDown: (TapDownDetails downDetails) {
  setState(() {
    _hasPadding = true;
  });
},
onTap: () {
  print('Card tapped.');
  setState(() {
    _hasPadding = false;
  });
  Navigator.push(
      context,
      PageRouteBuilder(
        transitionDuration: Duration(milliseconds: 500),
        pageBuilder: (_, __, ___) => DetailPage(),
      ));
},
onTapCancel: () {
  setState(() {
    _hasPadding = false;
  });
},
```

####onTapDown
タップし続けているときの状態を指定しています。
`_hasPadding `を*true*に指定してあげることによってカード全体を凹ませています。
####onTap
カードをタップ(押して指を離したとき)の状態を指定しています。
`_hasPadding `を*false*に指定してあげることによってカード全体の凹みを元に戻しています。
そして**Navigator.push**で詳細画面に遷移する処理をさせています。ただ今の段階では詳細画面を表示する時に拡大するアニメーションがおきません。ここの処理については後ほど説明します。
####onTap
カードをタップをキャンセルしたの状態を指定しています。
`_hasPadding `を*false*に指定してあげることによってカード全体の凹みを元に戻しています。

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249334/7091c0ae-cfc5-9492-f3d6-6560999856ae.gif)

##Hero Animation
それではカードとタップして詳細を開くときの拡大アニメーションを実装していきます。
**HeroAnimation**を使用して実現します。

まずHero Animationとは、簡単に言うと`画面遷移する時に画面間で同じTagを持っているWiget同士を紐付けてアニメーションをつける`みたいな感じだと思います

```dart
Hero(
  tag: 'heroTag',
  child: Container(
    .........
  )
)
```
アニメーションさせたいWidgetを遷移前の画面遷移後の画面両方を**Hero**で囲って、同じtagを指定することでHeroAnimationが実現します。
[公式Reference](https://flutter.dev/docs/development/ui/animations/hero-animations)乗っけときます。

では実装していきます。

##Widget同士でTagを共有する処理を作る
先にHeroWidgetに指定するTagをCustomCardとDetailPageで共有する処理を作っていきます。
まずそれぞれのclassに*heroTag*変数を作ってあげます。

```dart:CustomCard
class CustomCard extends StatefulWidget {
  CustomCard(this.heroTag);

  String heroTag; //DetailPageと共有するTag

  @override
  State<StatefulWidget> createState () {
    return CustomCardState(heroTag);
  }
}

class CustomCardState extends State<CustomCard> {
  CustomCardState(this.heroTag);

  String heroTag;
  var _hasPadding = false;

```

```dart:DetailPage
class DetailPage extends StatelessWidget {
  DetailPage(this.heroTag);

  String heroTag; //CustomCardと共有するTag
```

そして少し前に遡って、**PageView**でカードを並べていたところを少しいじって、それぞれの配列の文字列をCustomCardに渡すようにしてあげましょう

```dart:MyHomePage
Widget cardPageView() {
    return Container(
      height: 315,
      child: PageView(
        // store this controller in a State to save the carousel scroll position
        controller: PageController(viewportFraction: 0.8),
        children: <Widget>[
          for(var item in itemList ) Container(
            // 間隔が狭くなるので若干marginを付けてあげる
            margin: EdgeInsets.only(
                right: 10,
                bottom: 20
            ),
            child: CustomCard(item), // ここでCustomCardに文字列を渡す
          )
        ],
      ),
    );
  }
```

それとカードをタップした時に詳細画面に遷移させる処理の中でCustomCardからDetailPageにTagを渡してあげます。

```dart:CustomCard
onTap: () {
  print('Card tapped.');
  setState(() {
    _hasPadding = false;
  });
  Navigator.push(
      context,
      PageRouteBuilder(
        transitionDuration: Duration(milliseconds: 500),
        pageBuilder: (_, __, ___) => DetailPage(heroTag), //tagを渡す
      )); 
},
```

###Animationの実装
CustomCardとDetailPageのWidgetを`Hero()`で囲っていきます。
そしてTagオプションに先ほど各画面で共有した`heroTag`を指定します。

```dart:CustomCard
class CustomCardState extends State<CustomCard> {
  var _hasPadding = false;

  @override
  Widget build(BuildContext context) {
    return Hero(  //ここでCustomCardと共通させるTagなどを指定していく
      tag: heroTag,
      child: content(),
    );
  }

  Widget content() {
    return AnimatedPadding(
      duration: const Duration(milliseconds: 80),
      padding: EdgeInsets.all(_hasPadding ? 10 : 0),
      child: GestureDetector(
        onTapDown: (TapDownDetails downDetails) {
          setState(() {
            _hasPadding = true;
          });
        },
        onTap: () {
          print('Card tapped.');
          setState(() {
            _hasPadding = false;
          });
          Navigator.push(
              context,
              PageRouteBuilder(
                transitionDuration: Duration(milliseconds: 500),
                pageBuilder: (_, __, ___) => DetailPage(),
              ));
        },
        onTapCancel: () {
          setState(() {
            _hasPadding = false;
          });
        },
        child: Card(
          clipBehavior: Clip.antiAlias,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(15.0),
          ),
          child: Align(
            alignment: Alignment.topCenter,
            child: Image.asset('image/image.jpg', fit: BoxFit.fill),
          ),
          elevation:10,
        ),
      ),
    );
  }
}
```

ちょっとごちゃごちゃしちゃったので中身を切り出してHero部分をすっきりさせました。
では次に詳細画面をいじります。

```dart:DetailPage

class DetailPage extends StatelessWidget {
  DetailPage(this.heroTag);

  String heroTag;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        backgroundColor: Colors.white,
        body: Column(
          children: <Widget>[
            Container(
              child: Hero(
                tag: heroTag,
                child: imageContents(context),
              ),
            ),
            Container(
              child: Text(
                'content'
              ),
            )
          ],
        )
    );
  }

  // 画像Widget
  Widget imageContents(BuildContext context) {
    double statusBarHeight = MediaQuery.of(context).padding.top;
    return Container(
        height: 277,
        color: Colors.white,
        child: Container(
            child: Stack(
              children: <Widget>[
                Image.asset('image/image.jpg', fit: BoxFit.cover,),
                Column(
                  verticalDirection: VerticalDirection.down,
                  children: <Widget>[
                    Container(
                      margin: EdgeInsets.only(
                        top: statusBarHeight
                      ),
                      child: Row(
                          mainAxisAlignment: MainAxisAlignment.spaceBetween, // これで両端に寄せる
                          children: <Widget>[
                            Container(),
                            Container(
                              child: RaisedButton(
                                child: Icon(Icons.close, color: Colors.white,),
                                color: Colors.blue,
                                shape: CircleBorder(),
                                onPressed: () {
                                  Navigator.pop(
                                    context,
                                  );
                                },
                              ),
                            )
                          ]
                      ),
                    )
                  ],
                )
              ],
            )
        )
    );
  }
}
```

ここまでの動きを見てみましょう
![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249334/f485ea24-000d-289a-8110-a583befd59fe.gif)

それっぽい動きになりましたね！
アニメーション中の背景や文字のエラーなど細かいところを直していきましょう。

##アニメーション中の背景
拡大時や縮小時に背景が真っ白になっているので直しましょう。
**DetailPage**の`Widget build(BuildContext context)`を以下のように修正してください。

```dart:DetailPage
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.transparent, //Widgetの全体の背景を透明にする
        body: Hero(
          tag: heroTag,
          child: Container(
            color: Colors.white, //HeroWidget以下のツリーの背景を白色にする
            child: Column(
              children: <Widget>[
                Container(
                  child: imageContents(context),
                ),
                Container(
                  child: Text(
                      'content'
                  ),
                )
              ],
            ),
          )
        )
    );
  }
```

Widget全体の背景を透明にして、HeroWidget以下のツリーに対して背景をつけることでアニメーション中に後ろの画面がちゃんと見えるようにします。

次にアニメーション中の文字のエラーを直していきます。
自分も正直なぜエラーが起こるのかと言う根本はわかっていないのですが、以下のissueを参考に修正しました。
https://github.com/flutter/flutter/issues/30647#issuecomment-509712719
アニメーション中のHero同士に親子関係があって、テキストスタイルが同じじゃなかったりするとダメっぽい…？

と言うことでコードを直していきます。
今CustomCardとDetailPageの両方にHeroWidgetを配置してると思うんですが、このHeroWidgetの下位ツリーを**Material**で包んでいきます。
そして**Material**のtypeオプションに`MaterialType.transparency`を付けてあげましょう

```dart:CustomCard
  @override
  Widget build(BuildContext context) {
    return Hero(
      tag: heroTag,
      child: Material(
        type: MaterialType.transparency,
        child: content(),
      ),
    );
  }
```

```dart:DetailPage
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.transparent, //Widgetの全体の背景を透明にする
        body: Hero(
          tag: heroTag,
          child: Material(
            type: MaterialType.transparency,
              child: Container(
                color: Colors.white, //HeroWidget以下のツリーの背景を白色にする,
                child: Column(
                  mainAxisSize: MainAxisSize.max,
                  children: <Widget>[
                    Container(
                      child: imageContents(context),
                    ),
                    Container(
                      child: Text('content'),
                    )
                  ],
                ),
              )
          )
        )
    );
  }
```

動かしてみましょう！

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249334/9bd6c5bc-8666-36f1-7bdc-f66d804e5ec0.gif)

うまくいきました！！！

#最後に
これでタップした時に拡大するカードを実装することができました。

自分もまだまだ勉強したてなのでもっと綺麗な実装方法などあると思います。
もしツッコミなどあればぜひコメントいただければと思います！

最後までみてくださってありがとうございました。

##参考にしたサイト
[Flutterのお手軽にアニメーションを扱えるAnimated系Widgetをすべて紹介](https://medium.com/flutter-jp/implicit-animation-b9d4b7358c28)