9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

スクリーンショット 2025-01-10 16.53.59.png

はじめに

「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で実装しております。一部別の実装もしていますがお気になさらず🙇

最後に

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

9
4
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
9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?