タップすると拡大するカード
完成したコードは以下になります。
https://github.com/reiji012/flutter_card
Widget
使用する主なWidgetは以下になります。
・Hero
・AnimatedPadding
・GestureDetector
カードの切り替えに**PageController
、そして画面遷移にPageRouteBuilder
**を使用します。
作ってみよう
それでは実際に作っていきます。
今回はFlutterの環境構築は省きますので、まだ環境構築が済んでいない方はインストールしてください。
デフォルトでコードが生成されるのでそちらに手を加えていきます。
一覧画面
まずはカードを並べる画面を作成していきたいと思います。
ではとりあえずの土台を作っていきます。
初期生成でいい感じにできていると思うので、邪魔な部分を取り払いましょう
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(
),
),
);
}
}
ではここに色々と付け足していきましょう
カードを並べるためのPageViewと、実際に並べるカードを作っていきます。
Card
(カードは、押した時に凹むアニメーションを付けたいのでStatefulWidgetで作っていきます。)
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
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(),
],
),
);
}
}
いい感じのカードが浮かび上がってきました!(分かりやすいように画像を表示させてみました)
ちなみに画像の表示のさせ方はこちらなどを参考にしてみてください。
ではこのカードを複数出せるようにします
以下のようなデータを作って、それを元にカード複数表示させてみましょう。
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(),
)
],
),
);
}
ではカードをタップした時に画面を遷移するようにしていきます。
詳細画面
拡大時のページを作ってみましょう
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'
),
)
],
)
);
}
}

ざっとこんな感じにしました。
閉じるボタン
あとは縮小するためのボタンがないのでそれを付けてあげましょう
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,
);
},
),
)
]
),
)
],
)
],
)
)
);
}
}

こんな感じになりました。
ちょっと長くなっちゃったので個別に解説します。
ステータスバー
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で包みます。
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,
),
),
);
}
}
}
こちらもそれぞれ解説していきます。
duration: const Duration(milliseconds: 80),
padding: EdgeInsets.all(_hasPadding ? 10 : 0),
ここで凹むときのアニメーションの時間の長さと、どれくらい凹ませるかを指定しています。
_hasPadding
このstateを切り替えることで凹んだり元に戻したりしてます。
そして以下の部分が、各ジェスチャーに対応した処理をさせている部分です。
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に指定してあげることによってカード全体の凹みを元に戻しています。
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変数を作ってあげます。
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;
class DetailPage extends StatelessWidget {
DetailPage(this.heroTag);
String heroTag; //CustomCardと共有するTag
そして少し前に遡って、PageViewでカードを並べていたところを少しいじって、それぞれの配列の文字列をCustomCardに渡すようにしてあげましょう
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を渡してあげます。
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
を指定します。
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部分をすっきりさせました。
では次に詳細画面をいじります。
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,
);
},
),
)
]
),
)
],
)
],
)
)
);
}
}
それっぽい動きになりましたね!
アニメーション中の背景や文字のエラーなど細かいところを直していきましょう。
アニメーション中の背景
拡大時や縮小時に背景が真っ白になっているので直しましょう。
DetailPageのWidget build(BuildContext context)
を以下のように修正してください。
@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
を付けてあげましょう
@override
Widget build(BuildContext context) {
return Hero(
tag: heroTag,
child: Material(
type: MaterialType.transparency,
child: content(),
),
);
}
@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'),
)
],
),
)
)
)
);
}
動かしてみましょう!
うまくいきました!!!
最後に
これでタップした時に拡大するカードを実装することができました。
自分もまだまだ勉強したてなのでもっと綺麗な実装方法などあると思います。
もしツッコミなどあればぜひコメントいただければと思います!
最後までみてくださってありがとうございました。