【Flutter】heroineを使っておしゃれな画面遷移を実現する

![スクリーンショット 2025-01-10 16.53.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/672609/72d5788b-e8cf-070e-685d-9f0c2f5bc3e2.png)


# はじめに

「heroine」というPackagesが個人的にはすごく気に入り、すぐに実装をし開発者の方にお礼を伝えたところ、実装したPRを見えていただき、とてもいいフィードバックもいただけました。個人的にぜひつかってほしいと言う気持ちから記事にさせていただこうと思います。

Twitter（X）でご紹介しているので気になる方はこちらの方のツイートを見ていただけると。

https://x.com/imadetheseworks/status/1876913371980734861


# 簡単な導入方法

導入自体はとても簡単でわかりやすかったため、引用しながら簡単に書いていきます。

https://pub.dev/packages/heroine

:::note warn
警告
こちらのPackagesを使うためには、Flutterのバージョンが`3.27.1`でなければ使うことができませんでした。
:::

## 1. インストール

```shell
flutter pub add heroine
```

## 2. 下準備

```dart:
MaterialApp( // or CupertinoApp, or WidgetsApp
  home: MyHomePage(),
  // こちらの記載が必要になる
  navigatorObservers: [HeroineController()],
)
```

## 3. 使いたいWidgetに`heroine`をいれる

注意点として
- 画面遷移<b>元</b>と画面遷移<b>先</b>のどちらも`Heroine`が必要になります
- `Heroine`を使う上で`tag`が必須になり、`tag`はどちらも同じする必要があります

```dart
Heroine(
  tag: 'unique-tag',
  // この画面内に画面遷移の処理を記載する
  //  Navigator.pushなどで可能
  child: FirstScreen(),
)

```
```
Heroine(
  tag: 'unique-tag',
  child: SecondScreen(),
)
```

## 4. ちょっとしたカスタマイズをしてみる

### `flightShuttleBuilder`で調整する
- Heroは、ページ遷移の際にウィジェットをアニメーションで「飛ばす」ようなエフェクトを簡単に実現するためのFlutterの仕組みになる
- `flightShuttleBuilder`は、Heroウィジェットが飛行アニメーション（ページ遷移時のエフェクト）を行う際に、どのようなウィジェットを表示するかを決めるためのコールバックになる



`flightShuttleBuilder`には３種類あり、それぞれ違った挙動が起きる。

- `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だけをドラッグするだけで画面を閉じたりすることができます。

```dart
DragDismissable(
  onDismiss: () => Navigator.pop(context),
  child: Heroine(
    tag: 'unique-tag',
    child: SecondScreen(),
  ),
)
```


# 個人開発で使用した画面遷移

僕が個人開発で作成している「FoodGram」でも早速採用することにしました。

簡単なアプリ紹介としては、
- このアプリだけのフードマップを世界中の人と作りあげる
- 自分だけのフードアルバムを作成できる

iOSとAndroidのどちらもリリースしているのでぜひつかって投稿していただけると嬉しいです🙇

### iOS
https://apps.apple.com/gb/app/foodgram/id6474065183

### Android
https://play.google.com/store/apps/details?id=com.food_gram_app.com.com.com


ということで実装した一部をご紹介します。`heroine`を使用した実装は２種類やっていますが、今回はインスタグラムのストーリーふうな遷移を実装してみました。

### 画面遷移の独自で作成したアニメーション

```dart
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を使用した遷移
```dart
 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),
    );
  },
),
```


### 画面遷移元
```dart
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']),
                        ),
                      ),
                    ),
                  ),
                ),
              ),
            ),
          ),
        );
      },
    );
  }
}
```

### 画面遷移先

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

```

## スクリーンショット


https://x.com/isekiryu/status/1877630898713337881


詳しくはこちらのPRで実装しております。一部別の実装もしていますがお気になさらず🙇

https://github.com/iseruuuuu/food_gram_app/pull/480



# 最後に
まだまだ深ぼれそうな領域なので頑張って良いアプリにしていきたいものですね。自分はまだまだ作成者側には回れなさそうだなと実感しました(^_^;)
まあ、とりあえず個人開発生活を楽しみます。