やりたいこと
今まで若干敬遠していたFlutterのAnimationを触ってみたく、iOSの写真アプリの写真の画像を長押しした際に出てくるメニューを再現を思い立ちました。。。
※Flutter標準のCupertinoContextMenu
Widgetを使えばあっという間に再現できますが、今回はそれは使いません。
CupertinoContextMenu class - cupertino library - Dart API
完成品
左が今回作成したモノ。右がCupertinoContextMenu
を使用したモノ。
大体同じっぽく見えるのでヨシ!!🐱👉
ソースコード: https://github.com/popy1017/flutter_ios_long_tap_menu
再現する仕様の確認
iOS14.3の写真アプリを使って、大まかな動きを把握しました。
(字面で見てもわかりづらいと思うので実際に触ってみてください)
- 画像を長押しすると、その画像より少し大きい画像とメニュー(
DetailView
と呼びます)が前面に表示される。 - 長押しした画像がへこみ(一度小さくなり)、大きくなると共に
Hero
っぽいアニメーションでDetailView
が表示される。 - タップした位置に応じて
DetailView
の表示位置が変わる。- 上の方の画像をタップした場合、
DetailView
も上の方に表示される。 - 左の画像をタップした場合、
DetailView
のメニューも左に寄って表示される。(水平方向における画像の位置は中央)
- 上の方の画像をタップした場合、
-
DetailView
の背景は透過&ぼかし - メニューはだんだん大きくなるようなアニメーションで開閉する。
-
DetailView
を下方向にドラッグすると、メニューが閉じる。 -
DetailView
を下方向にある程度ドラッグすると、DetailView
が閉じる
Step1. 画像を長押しして、拡大画像を表示させる
詳細はソースコードのSample1
を参照してください。
タップ判定のために、Image
WidgetをInkWell
Widgetでラップし、onLongPress
を設定します。
また、Hero
アニメーションを適用させるために、Image
WidgetをHero
Widgetでラップします。
class Sample1 extends StatelessWidget {
final List<Widget> photos = List.generate(
imageUrls.length, (int index) => _HeroPhoto(url: imageUrls[index]));
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Sample1')),
body: GridView.count(
crossAxisCount: 2,
children: photos,
),
);
}
}
class _HeroPhoto extends StatelessWidget {
_HeroPhoto({@required this.url});
final String url;
@override
Widget build(BuildContext context) {
return InkWell(
child: Hero(
tag: url,
child: Image.network(
url,
fit: BoxFit.cover,
),
),
onLongPress: () async {
~~
onLongPress
の処理は以下のようになっています。
注意点としては、対応するHero
Widgetのchild
は同じである必要があることです。
(厳密に同じである必要があるかはわかりませんが、以下によると対応するHeroの
child
が異なる場合、挙動がおかしくなることがあるのは仕様であり、全く同じWidgetツリーとすることが推奨されています。)
Hero animation jumps when transitioning between Images with different fit · Issue #20510 · flutter/flutter
また、Hero
Widgetは異なる2つのRouteで動くので、片方のHero
Widgetは画面遷移先に配置する必要があります。(モーダルやダイアログは画面遷移しない(同じRouteに2つのHeroがある状態になる)ので、Heroアニメーションがうまく動きません。)
onLongPress: () async {
await Navigator.push(
context,
PageRouteBuilder(
opaque: false,
fullscreenDialog: true,
barrierDismissible: true, // 画面タップで閉じる
barrierColor: Colors.black.withOpacity(0.5), // 背景透過
pageBuilder: (BuildContext context, _, __) {
return Center(
child: Hero(
tag: url,
child: Image.network(
url,
fit: BoxFit.cover,
),
),
);
},
),
);
},
Step2 バウンスアニメーション(画像がへこんで、また大きくなるアニメーション)を再現する
ソースコードのSample4
とSample5
の部分が対応します。
以下の記事を参考にさせていただきました。
Flutterでアニメーションを付けたい場合は、簡単に使えるAnimatedXXX
というWidgetシリーズと、カスタマイズ性のあるXXXTransition
というWidgetシリーズが使えます。各Widgetの使い方などは以下の記事がわかりやすかったです。
今回は、Widgetの大きさを変えたいのでScaleTransition
Widgetを使用します。
(AnimatedSize
Widgetでもできるかもしれません)
- Flutterのお手軽にアニメーションを扱えるAnimated系Widgetをすべて紹介 | by mono | Flutter 🇯🇵 | Medium
- FlutterのTransition系アニメーションWidgetをすべて紹介 | by mono | Flutter 🇯🇵 | Medium
まずは、initState
にてAnimationController
を設定します。
_animationController.drive
で、アニメーションカーブとスケールを指定しています。このあたりの値は適当です。
Curves class - animation library - Dart API
class _Sample5State extends State<Sample5> with SingleTickerProviderStateMixin {
double width = 100;
double height = 100;
AnimationController _animationController;
Animation<double> _scale;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 200),
);
_scale = _animationController
.drive(
CurveTween(curve: Curves.bounceOut),
)
.drive(
Tween(begin: 1, end: 0.8),
);
}
~~
次に、LongPress発生時にアニメーションを開始させます。
アニメーションの開始は、_animationController.forward()
で行え、whenComplete(処理)
をつけると、そのアニメーションの終了を待ってから指定した処理を行うことができます。今回は、縮小処理が終わった後に元の大きさに戻したかったので、whenComplete(処理)
の処理に、アニメーションを逆再生する_animationController.reverse()
を書いています。
また、本家の動作では、このタイミングで端末が振動するようになっているので、flutter_vibrate
パッケージを使って微振動を起こすようにしています。
flutter_vibrate | Flutter Package
whenComplete()
の返り値はFuture<void>
型であるため、await
で処理全体の終了を待つことができます。アニメーションの終了を待たないと、縮小・拡大が行われる前に画像が前面に出てきてしまうので、Step1と同じような見た目になってしまいます。
Future<void> _forwardAnimation() async {
await _animationController.forward().whenComplete(() async {
Vibrate.feedback(FeedbackType.medium);
_animationController.reverse();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: GestureDetector(
onLongPress: () async {
await _forwardAnimation(); // 画面遷移前にアニメーションを開始し、終了を待つ
Navigator.push(
~~
最後に、アニメーションを適用させたいWidgetをScaleTransition
でラップします。
~~
child: Container(
width: 100,
height: 100,
child: ScaleTransition(
scale: _scale,
child: Hero(
tag: 'rect',
child: Image.network(
imageUrls.first,
fit: BoxFit.cover,
),
),
),
),
~~
Step3 タップした位置に応じて前面に出す画像の位置を変える
ソースコードのSample7と対応しています。
タップ位置の取得には、GestureDetector
WidgetのonTapDown(TapDownDetails)
を使います。
TapDownDetails.globalPosition.dx
, TapDownDetails.globalPosition.dy
でタップ座標を取ってこれます。
タップした位置がわかったら、次はタップした位置に応じて前面に出す画像の位置を変えます。
前面に出す画像をAlign
Widgetでラップし、位置を指定できるようにします。
// 前面に出す画像Widget
class _BigImage extends StatelessWidget {
const _BigImage({
@required this.alignment,
@required this.url,
});
final Alignment alignment;
final String url;
@override
Widget build(BuildContext context) {
return Align( // <= 追加
alignment: alignment,
child: Container(
// 画像の幅を画面の幅の80%に
width: MediaQuery.of(context).size.width * 0.8,
child: Hero(
tag: url,
child: ClipRRect(
borderRadius: BorderRadius.circular(8.0), // 画像の角を丸角に
child: Image.network(
url,
fit: BoxFit.cover,
),
),
),
),
);
}
}
Align
Widgetに指定するalignment
によく指定するのは、Alignment.center
やAlignment.topLeft
などですが、Alignment(x,y)
を指定することで細かい位置調整ができます。Alignment(x,y)
のx,y
の値の範囲は、-1〜1
なので、タップ位置と画面のサイズをもとに、タップ位置を-1〜1
に正規化します。
void _setBigImageAlignment(TapDownDetails details) {
final Size size = MediaQuery.of(context).size; // 画面サイズ
final double halfWidth = size.width / 2;
final double halfHeight = size.height / 2;
final double x = details.globalPosition.dx;
final double y = details.globalPosition.dy;
// Alignment(x,y)の(x,y)の値の範囲は、-1~1なので
// 画面の幅や高さからタップ座標を-1~1に正規化
// → Align(0,0)は中央
_alignment = Alignment(
(x - halfWidth) / halfWidth,
(y - halfHeight) / halfHeight,
);
}
Step4 背景ぼかし
ぼかしエフェクトをかけるためには、BackdropFilter
を使います。
ぼかし処理は結構重いので、多用は厳禁。
class _BigImage extends StatelessWidget {
const _BigImage({
@required this.alignment,
@required this.url,
});
final Alignment alignment;
final String url;
@override
Widget build(BuildContext context) {
return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
child: Align(
alignment: alignment,
child: Container(
width: MediaQuery.of(context).size.width * 0.8,
child: Hero(
~~
Step5 アクションメニューを追加し、拡大・縮小アニメーションを追加する
Sample11の内容になります。
メニューは、Card
Wideget、Column
Widgetを組み合わせて複数のアクションを縦に並べるようにしました。
また、タップした位置に応じてメニューの位置を変動させたいので、こちらもAlign
Widgetでラップしています。
class _ActionMenu extends StatelessWidget {
_ActionMenu({
this.alignment = Alignment.center,
});
final Alignment alignment;
@override
Widget build(BuildContext context) {
return Align(
alignment: alignment,
child: Container(
width: 200,
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text('コピー'),
trailing: Icon(Icons.copy),
onTap: () {},
),
~~
),
],
),
),
),
);
}
}
メニューに関しても大きさに関するアニメーションを使いたいので、ScaleTransition
で囲います。
class __DetailViewState extends State<_DetailView>
with SingleTickerProviderStateMixin {
~~
@override
Widget build(BuildContext context) {
return GestureDetector(
~~
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
child: Align(
alignment: widget.imageAlignment,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: Duration(milliseconds: 1),
transform: photoViewTransform,
child: _BigImage(
alignment: widget.imageAlignment, url: widget.imageUrl),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: ScaleTransition( // <= 追加
scale: _scale,
alignment: widget.menuAlignment,
child: _ActionMenu(alignment: widget.menuAlignment),
),
),
],
),
),
),
);
}
}
Step2と同様にinitState
でアニメーションの設定をし、initState
の最後にアニメーションを実行してメニューを開きます。Step2とは違い、最初の大きさを0、メニューが開いた時を1としたいので、Tween(begin: 0, end: 1)
としています。
class __DetailViewState extends State<_DetailView>
with SingleTickerProviderStateMixin {
AnimationController _animationController;
Animation<double> _scale;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 200),
);
_scale = _animationController
.drive(
CurveTween(curve: Curves.bounceOut),
)
.drive(
Tween(begin: 0, end: 1),
);
_openMenu();
}
TickerFuture _openMenu() {
return _animationController.forward();
}
Step6 下方向のドラッグでDetailView
を閉じられるようにする
こちらもソースコードのSample11に対応しています。
以下の記事が大変参考になりました。
Hero + PhotoView + GestureDetectorでドラッグアニメーション付き画像ビューア - Qiita
ドラッグを検知するためには、GestureDetector
WidgetのonVerticalDragStart
,onVerticalDragUpdate
,onVerticalDragEnd
を使います。
- onVerticalDragStart: ドラッグの開始時
- onVerticalDragUpdate: ドラッグの移動
- onVerticalDragEnd: ドラッグの終了
ドラッグ距離に応じてDetailView
を閉じるかどうかを決めたいので、ドラッグの開始地点を保持しておいて現在のドラッグ地点と比較してドラッグ距離を算出しています。また、ドラッグ開始時にメニューを閉じるようにしています。
ドラッグ距離に応じて画像を小さくしたりしていますが、上記記事で詳しく解説されているのでそちらを参照してください。
class __DetailViewState extends State<_DetailView>
with SingleTickerProviderStateMixin {
Offset currentDragPosition = Offset.zero;
Offset beginningDragPosition = Offset.zero;
~~
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _close,
onVerticalDragStart: (DragStartDetails dragStartDetails) {
beginningDragPosition = dragStartDetails.globalPosition;
_closeMenu();
},
onVerticalDragUpdate: (DragUpdateDetails dragUpdateDetails) {
setState(() {
currentDragPosition = Offset(
dragUpdateDetails.globalPosition.dx - beginningDragPosition.dx,
dragUpdateDetails.globalPosition.dy - beginningDragPosition.dy,
);
});
},
onVerticalDragEnd: (DragEndDetails dragEndDetails) {
if (currentDragPosition.distance < 150.0) {
setState(() {
currentDragPosition = Offset.zero;
});
_openMenu();
} else {
_close();
}
},
既知の課題
- 本家UI、
CupertinoContextMenu
と比べるとアニメーションのリッチさが少ない。本家UIとCupertinoContextMenu
ではDetailView
が開いたときにビヨヨンとなる感じがする。 - 本家UI、
CupertinoContextMenu
と比べると長押しの時間が微妙に長い(気がする) -
DetailView
の画像をドラッグしているときに、メニューのアニメーションが動かない。なのでドラッグ距離に応じてリアルタイムにメニューを開いたり閉じたりすることができていない。(いったんドラッグを止めるとアニメーションが動く。) - メニューの開閉を繰り返していると、メニューの文字とアイコンが表示されなくなることがある。