はじめに
「heroine」というPackagesが個人的にはすごく気に入り、すぐに実装をし開発者の方にお礼を伝えたところ、実装したPRを見えていただき、とてもいいフィードバックもいただけました。個人的にぜひつかってほしいと言う気持ちから記事にさせていただこうと思います。
Twitter(X)でご紹介しているので気になる方はこちらの方のツイートを見ていただけると。
簡単な導入方法
導入自体はとても簡単でわかりやすかったため、引用しながら簡単に書いていきます。
警告
こちらのPackagesを使うためには、Flutterのバージョンが3.27.1
でなければ使うことができませんでした。
1. インストール
flutter pub add heroine
2. 下準備
MaterialApp( // or CupertinoApp, or WidgetsApp
home: MyHomePage(),
// こちらの記載が必要になる
navigatorObservers: [HeroineController()],
)
3. 使いたいWidgetにheroine
をいれる
注意点として
- 画面遷移元と画面遷移先のどちらも
Heroine
が必要になります -
Heroine
を使う上でtag
が必須になり、tag
はどちらも同じする必要があります
Heroine(
tag: 'unique-tag',
// この画面内に画面遷移の処理を記載する
// Navigator.pushなどで可能
child: FirstScreen(),
)
Heroine(
tag: 'unique-tag',
child: SecondScreen(),
)
4. ちょっとしたカスタマイズをしてみる
flightShuttleBuilder
で調整する
- Heroは、ページ遷移の際にウィジェットをアニメーションで「飛ばす」ようなエフェクトを簡単に実現するためのFlutterの仕組みになる
-
flightShuttleBuilder
は、Heroウィジェットが飛行アニメーション(ページ遷移時のエフェクト)を行う際に、どのようなウィジェットを表示するかを決めるためのコールバックになる
flightShuttleBuilder
には3種類あり、それぞれ違った挙動が起きる。
-
FadeShuttleBuilder
- ウィジェットがスムーズにフェードイン・フェードアウトするアニメーション
- 特に目立った演出を必要とせず、控えめで洗練された遷移効果を求める場合に適している
-
FlipShuttleBuilder
- ウィジェットが3D回転(フリップ)するようなアニメーション
- 遷移元のウィジェットが3D回転を始め、途中で消えながら、遷移先のウィジェットが裏面から現れて回転し、最終的に正面を向く
-
SingleShuttleBuilder
- 遷移元のウィジェットを飛ばさず、遷移先のウィジェットだけを表示するシンプルなアニメーション
- FlutterのデフォルトのHeroアニメーションに近い挙動
ShuttleBuilder | アニメーション効果 | おすすめ用途 |
---|---|---|
FadeShuttleBuilder | スムーズなフェード | シンプルで控えめな遷移が必要な場合 |
FlipShuttleBuilder | 3Dフリップアニメーション | ダイナミックで目を引く遷移が必要な場合 |
SingleShuttleBuilder | 遷移先ウィジェットのみを表示 | 最小限のアニメーションが必要な場合 |
SimpleSpringでアニメーションの「弾む感覚」や「スピード」をカスタマイズをする
- SimpleSpring.instant
- 瞬時にアニメーションを完了する設定
- SimpleSpring.defaultIOS
- iOSのデフォルトアニメーション設定
- SimpleSpring.bouncy
- 弾力性が強めのアニメーション設定
- SimpleSpring.snappy
- 弾みが少なく、素早いアニメーション設定
- SimpleSpring.interactive
- 短い応答時間(0.15秒)で、弾みの少ない設定
また、事前に用意するものもありますが、自前で設定することもできるそうです。
画面遷移先でドラックして閉じる実装
画面遷移をしたあとの戻りたいときに使える実装となります。指定したWidgetだけをドラッグするだけで画面を閉じたりすることができます。
DragDismissable(
onDismiss: () => Navigator.pop(context),
child: Heroine(
tag: 'unique-tag',
child: SecondScreen(),
),
)
個人開発で使用した画面遷移
僕が個人開発で作成している「FoodGram」でも早速採用することにしました。
簡単なアプリ紹介としては、
- このアプリだけのフードマップを世界中の人と作りあげる
- 自分だけのフードアルバムを作成できる
iOSとAndroidのどちらもリリースしているのでぜひつかって投稿していただけると嬉しいです🙇
iOS
Android
ということで実装した一部をご紹介します。heroine
を使用した実装は2種類やっていますが、今回はインスタグラムのストーリーふうな遷移を実装してみました。
画面遷移の独自で作成したアニメーション
CustomTransitionPage<Object?> zoomTransition(Widget screen) {
return CustomTransitionPage<Object?>(
child: screen,
transitionDuration: const Duration(milliseconds: 400),
reverseTransitionDuration: const Duration(milliseconds: 400),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final scaleAnimation = Tween<double>(
begin: 0.8,
end: 1,
).animate(
CurvedAnimation(
parent: animation,
curve: Curves.easeOutExpo,
),
);
final fadeAnimation = Tween<double>(
begin: 0,
end: 1,
).animate(
CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
),
);
return FadeTransition(
opacity: fadeAnimation,
child: Align(
alignment: Alignment.topCenter,
child: ScaleTransition(
scale: scaleAnimation,
child: child,
),
),
);
},
);
}
GoRouterを使用した遷移
return GoRouter(
observers: [HeroineController()],
// 以下省略
);
GoRoute(
path: '${RouterPath.timeLine}/${RouterPath.storyPage}',
name: RouterPath.storyPage,
pageBuilder: (context, state) {
final model = state.extra! as Model;
return zoomTransition(
StoryPage(posts: model.posts, users: model.users),
);
},
),
画面遷移元
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:food_gram_app/core/data/supabase/service/posts_service.dart';
import 'package:food_gram_app/main.dart';
import 'package:food_gram_app/router/router.dart';
import 'package:go_router/go_router.dart';
import 'package:heroine/heroine.dart';
class StoryWidget extends ConsumerWidget {
const StoryWidget({
required this.data,
super.key,
});
final List<Map<String, dynamic>> data;
@override
Widget build(BuildContext context, WidgetRef ref) {
final randomIndexes = List<int>.generate(data.length, (index) => index)
..shuffle();
return ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: data.length,
itemBuilder: (context, index) {
final randomIndex = randomIndexes[index];
return Heroine(
//tagをデータベースに格納しているidを使用
tag: data[randomIndex]['id'],
flightShuttleBuilder: SingleShuttleBuilder(),
child: Padding(
padding: const EdgeInsets.all(8),
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
Color(0xFFFF00A5),
Color(0xFFFE0141),
Color(0xFFFF9F00),
Color(0xFFFFC900),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Padding(
padding: EdgeInsets.all(3),
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
),
child: Padding(
padding: EdgeInsets.all(3),
child: GestureDetector(
onTap: () async {
final post = await ref
.read(postsServiceProvider)
.getPost(data, randomIndex);
await context.pushNamed(
RouterPath.storyPage,
extra: post,
);
},
child: CircleAvatar(
radius: 36,
backgroundColor: Colors.white,
foregroundImage: NetworkImage(
supabase.storage
.from('food')
.getPublicUrl(data[randomIndex]['food_image']),
),
),
),
),
),
),
),
),
);
},
);
}
}
画面遷移先
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:food_gram_app/core/data/admob/admob_banner.dart';
import 'package:food_gram_app/core/model/posts.dart';
import 'package:food_gram_app/core/model/users.dart';
import 'package:food_gram_app/main.dart';
import 'package:food_gram_app/ui/component/app_profile_image.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:heroine/heroine.dart';
import 'package:story/story_page_view.dart';
class StoryPage extends StatelessWidget {
const StoryPage({
required this.posts,
required this.users,
super.key,
});
final Posts posts;
final Users users;
@override
Widget build(BuildContext context) {
return SafeArea(
// 今回は、インスタ風に閉じたいため、GestureDetectorを使用
child: GestureDetector(
onVerticalDragEnd: (details) {
if (details.primaryVelocity != null &&
details.primaryVelocity! > 50) {
context.pop();
}
},
child: Heroine(
// tagのみの設定
tag: posts.id,
child: Scaffold(
backgroundColor: Colors.black,
body: Padding(
padding: const EdgeInsets.only(top: 10),
child: StoryPageView(
itemBuilder: (context, pageIndex, storyIndex) {
return Stack(
children: [
Positioned.fill(child: Container(color: Colors.black)),
FittedBox(
child: Padding(
padding: const EdgeInsets.only(top: 44, left: 8),
child: Row(
children: [
AppProfileImage(
imagePath: users.image,
radius: 28,
),
Gap(12),
FittedBox(
child: Text(
users.name,
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
Align(
child: CachedNetworkImage(
imageUrl: supabase.storage
.from('food')
.getPublicUrl(posts.foodImage),
fit: BoxFit.fitWidth,
width: MediaQuery.sizeOf(context).width,
height: MediaQuery.sizeOf(context).width,
),
),
Align(
alignment: Alignment.bottomCenter,
child: FittedBox(
child: SizedBox(
width: MediaQuery.sizeOf(context).width,
height: 150,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
posts.foodName,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
Text(
posts.restaurant,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
AdmobBanner(),
],
),
),
),
),
],
);
},
gestureItemBuilder: (context, pageIndex, storyIndex) {
return Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.only(top: 32),
child: IconButton(
padding: EdgeInsets.zero,
color: Colors.white,
icon: Icon(Icons.close),
onPressed: () {
Navigator.pop(context);
},
),
),
);
},
pageLength: 1,
storyLength: (pageIndex) {
return 1;
},
onPageLimitReached: () {
Navigator.pop(context);
},
),
),
),
),
),
);
}
}
スクリーンショット
詳しくはこちらのPRで実装しております。一部別の実装もしていますがお気になさらず🙇
最後に
まだまだ深ぼれそうな領域なので頑張って良いアプリにしていきたいものですね。自分はまだまだ作成者側には回れなさそうだなと実感しました(^_^;)
まあ、とりあえず個人開発生活を楽しみます。