29
31

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-02-20

タップすると拡大するカード

完成したアニメーションはこんな感じ
output.gif

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

Widget

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

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

作ってみよう

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

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

一覧画面

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

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

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_6A9C36465741-1.jpeg

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

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

Card

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

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

_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_D77A81BB36E7-1.jpeg
ちなみに画像の表示のさせ方はこちらなどを参考にしてみてください。

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

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

  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

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

詳細画面

拡大時のページを作ってみましょう

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_3781C2F44E85-1.jpeg

ざっとこんな感じにしました。

閉じるボタン

あとは縮小するためのボタンがないのでそれを付けてあげましょう

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_4808DC6009A1-1.jpeg

こんな感じになりました。
ちょっと長くなっちゃったので個別に解説します。

ステータスバー

Widget imageContents(BuildContext context) {
    double statusBarHeight = MediaQuery.of(context).padding.top;
Container(
    margin: EdgeInsets.only(
        top: statusBarHeight
    ),

まずここで、ボタンがステータスバーにかぶらないように、ステータスバーの高さを取得してその分マージンを付けてあげてます。

Stack

次にStackについて、

child: Stack(
    children: <Widget>[
    .....
    ]
)

ここですね。
このStackを使用することによって、Widget同士を重ねることが可能になります。
これで画像の上にボタンを重ねて表示させています。

ボタンの端寄せ

そしてボタンを右端に寄せている部分が以下になります。

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で包みます。

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,
        ),
      ),
    );
  }
}

}

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

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

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

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

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

タップし続けているときの状態を指定しています。
_hasPaddingtrueに指定してあげることによってカード全体を凹ませています。

onTap

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

onTap

カードをタップをキャンセルしたの状態を指定しています。
_hasPaddingfalseに指定してあげることによってカード全体の凹みを元に戻しています。

output.gif

Hero Animation

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

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

Hero(
  tag: 'heroTag',
  child: Container(
    .........
  )
)

アニメーションさせたいWidgetを遷移前の画面遷移後の画面両方をHeroで囲って、同じtagを指定することでHeroAnimationが実現します。
公式Reference乗っけときます。

では実装していきます。

Widget同士でTagを共有する処理を作る

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

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;

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

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

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

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を渡してあげます。

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を指定します。

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部分をすっきりさせました。
では次に詳細画面をいじります。

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

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

アニメーション中の背景

拡大時や縮小時に背景が真っ白になっているので直しましょう。
DetailPageWidget build(BuildContext context)を以下のように修正してください。

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を付けてあげましょう

CustomCard
  @override
  Widget build(BuildContext context) {
    return Hero(
      tag: heroTag,
      child: Material(
        type: MaterialType.transparency,
        child: content(),
      ),
    );
  }
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

うまくいきました!!!

最後に

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

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

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

参考にしたサイト

Flutterのお手軽にアニメーションを扱えるAnimated系Widgetをすべて紹介

29
31
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
29
31