💙

GoRouter + Animations

2024/09/27に公開

画面遷移にアニメーションを追加

画面遷移時にインパクトのあるアニメーションが欲しい。リッチな動きをUIにつけてみたいと思った。

https://x.com/JBOY83062526/status/1839320095451185347

やってみたこと

pageBuilderを使って画面遷移にアニメーションを追加してみた。transitionとは遷移という意味です。
今回はCustomTransitionPageを使ってリッチなアニメーションに仕立てています。

final GoRouter _router = GoRouter(
    routes: [
      GoRoute(
        path: '/',
        pageBuilder: (context, state) => CustomTransitionPage<void>(
          key: state.pageKey,
          child: const HomeScreen(),
          transitionsBuilder: (context, animation, secondaryAnimation, child) {
            return FadeThroughTransition(
              animation: animation,
              secondaryAnimation: secondaryAnimation,
              child: child,
            );
          },
        ),
      ),
      GoRoute(
        path: '/details',
        pageBuilder: (context, state) => CustomTransitionPage<void>(
          key: state.pageKey,
          child: const DetailsScreen(),
          transitionsBuilder: (context, animation, secondaryAnimation, child) {
            return FadeThroughTransition(
              animation: animation,
              secondaryAnimation: secondaryAnimation,
              child: child,
            );
          },
        ),
      ),
    ],
  );

https://pub.dev/documentation/go_router/latest/topics/Transition animations-topic.html

Transition animations topic
GoRouter allows you to customize the transition animation for each GoRoute. To configure a custom transition animation, provide a pageBuilder parameter to the GoRoute constructor:

トランジションアニメーションのトピック
GoRouterでは、各GoRouteの遷移アニメーションをカスタマイズすることができます。カスタム トランジション アニメーションを設定するには、GoRoute コンストラクタに pageBuilder パラメータを指定します:
https://pub.dev/documentation/go_router/latest/topics/Transition animations-topic.html

AIの解説によると

state.pageKeyはアニメーションの文脈で重要な役割を果たします。このkeyを指定する主な理由は以下の通りです:

ページの一意性の保証:
state.pageKeyは各ページに一意のキーを提供します。これにより、Flutter のウィジェットツリーが各ページを一意に識別できるようになります。
アニメーションの適切な処理:
画面遷移時のアニメーションを正しく処理するために重要です。キーがないと、Flutter は新しいページと古いページを区別できず、アニメーションが期待通りに動作しない可能性があります。
ページの状態管理:
キーは、ページの状態を適切に管理するのに役立ちます。特に、同じ型の複数のページがナビゲーションスタック上に存在する場合に重要です。
再構築の最適化:
Flutter のフレームワークがウィジェットツリーを効率的に再構築するのに役立ちます。

CustomTransitionPageを使用する場合、特にアニメーションを伴う遷移では、このkeyの指定が重要になります。state.pageKeyを使用することで、go_router が提供する状態管理の恩恵を受けつつ、カスタムアニメーションを実装できます。
ただし、アニメーションを使用しない単純なページ遷移の場合、keyを省略しても大きな問題は生じない可能性があります。しかし、一般的にはベストプラクティスとして、CustomTransitionPageを使用する際にはkeyを指定することをお勧めします。
アプリの規模や複雑さによっては、この細かい最適化が全体のパフォーマンスとユーザー体験に影響を与える可能性があります。特に、複雑なナビゲーション構造や頻繁なページ遷移を行うアプリでは、keyの適切な使用が重要になってきます。

Example

全体のソースコードはこちら。青い箱をタップすると立体的な画面遷移ができます。

使用したパッケージ

https://pub.dev/packages/animations
https://pub.dev/packages/go_router

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:animations/animations.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  MyApp({super.key});

  final GoRouter _router = GoRouter(
    routes: [
      GoRoute(
        path: '/',
        pageBuilder: (context, state) => CustomTransitionPage<void>(
          key: state.pageKey,
          child: const HomeScreen(),
          transitionsBuilder: (context, animation, secondaryAnimation, child) {
            return FadeThroughTransition(
              animation: animation,
              secondaryAnimation: secondaryAnimation,
              child: child,
            );
          },
        ),
      ),
      GoRoute(
        path: '/details',
        pageBuilder: (context, state) => CustomTransitionPage<void>(
          key: state.pageKey,
          child: const DetailsScreen(),
          transitionsBuilder: (context, animation, secondaryAnimation, child) {
            return FadeThroughTransition(
              animation: animation,
              secondaryAnimation: secondaryAnimation,
              child: child,
            );
          },
        ),
      ),
    ],
  );

  
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      routerConfig: _router,
    );
  }
}

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Home')),
      body: Center(
        child: OpenContainer(
          transitionType: ContainerTransitionType.fade,
          openBuilder: (BuildContext context, VoidCallback _) {
            return const DetailsScreen();
          },
          closedBuilder: (BuildContext context, VoidCallback openContainer) {
            return Container(
              width: 200,
              height: 200,
              color: Colors.blue,
              child: const Center(
                child: Text(
                  'Tap to open details',
                  style: TextStyle(color: Colors.white),
                ),
              ),
            );
          },
        ),
      ),
    );
  }
}

class DetailsScreen extends StatelessWidget {
  const DetailsScreen({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Details')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('Details Screen'),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                context.pop();
              },
              child: const Text('Go back'),
            ),
          ],
        ),
      ),
    );
  }
}

最後に

今回は、go_routerを使用したアニメーションありの画面遷移を実装してみました。しかしこちらのパッケージ8ヶ月前から更新が止まっている💦

自作するしかないか💦
https://pub.dev/packages/animations/example

なんか違うものでもよくないか。
フェードインを使ってグラデーションが変化するのはどうだろうか...
https://x.com/JBOY83062526/status/1839326513029861886

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  MyApp({super.key});

  final GoRouter _router = GoRouter(
    routes: [
      GoRoute(
        path: '/',
        pageBuilder: (context, state) => CustomFadeTransitionPage(
          key: state.pageKey,
          child: const MainScreen(initialIndex: 0),
        ),
      ),
      GoRoute(
        path: '/details',
        pageBuilder: (context, state) => CustomFadeTransitionPage(
          key: state.pageKey,
          child: const MainScreen(initialIndex: 1),
        ),
      ),
    ],
  );

  
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Custom Fade Transition Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      routerConfig: _router,
    );
  }
}

class CustomFadeTransitionPage extends CustomTransitionPage<void> {
  CustomFadeTransitionPage({
    required super.child,
    super.key,
  }) : super(
          transitionsBuilder: (context, animation, secondaryAnimation, child) {
            return FadeTransition(
              opacity: animation,
              child: child,
            );
          },
          transitionDuration: const Duration(milliseconds: 1000),
        );
}

class MainScreen extends StatefulWidget {
  final int initialIndex;

  const MainScreen({super.key, required this.initialIndex});

  
  _MainScreenState createState() => _MainScreenState();
}

class _MainScreenState extends State<MainScreen> with SingleTickerProviderStateMixin {
  late int _currentIndex;
  late AnimationController _controller;
  late Animation<Color?> _colorAnimation1;
  late Animation<Color?> _colorAnimation2;

  final List<List<Color>> _gradients = [
    [Colors.blue.shade200, Colors.purple.shade200],
    [Colors.purple.shade200, Colors.red.shade200],
  ];

  
  void initState() {
    super.initState();
    _currentIndex = widget.initialIndex;
    _controller = AnimationController(
      duration: const Duration(milliseconds: 1000),
      vsync: this,
    );

    _setColorAnimation();
  }

  void _setColorAnimation() {
    _colorAnimation1 = ColorTween(
      begin: _gradients[_currentIndex][0],
      end: _gradients[1 - _currentIndex][0],
    ).animate(_controller);

    _colorAnimation2 = ColorTween(
      begin: _gradients[_currentIndex][1],
      end: _gradients[1 - _currentIndex][1],
    ).animate(_controller);
  }

  void _changePage() {
    setState(() {
      _currentIndex = 1 - _currentIndex;
    });
    _setColorAnimation();
    _controller.forward(from: 0.0);

    Future.delayed(const Duration(milliseconds: 500), () {
      if (_currentIndex == 0) {
        context.go('/');
      } else {
        context.go('/details');
      }
    });
  }

  
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Container(
          decoration: BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topLeft,
              end: Alignment.bottomRight,
              colors: [
                _colorAnimation1.value!,
                _colorAnimation2.value!,
              ],
            ),
          ),
          child: Scaffold(
            backgroundColor: Colors.transparent,
            appBar: AppBar(
              title: Text(_currentIndex == 0 ? 'Home' : 'Details'),
              backgroundColor: Colors.transparent,
              elevation: 0,
            ),
            body: Center(
              child: ElevatedButton(
                onPressed: _changePage,
                child: Text(_currentIndex == 0 ? 'Go to Details' : 'Go back to Home'),
              ),
            ),
          ),
        );
      },
    );
  }
}

おまけのロジック

アニメーションの持続時間の延長:

CustomFadeTransitionPage の transitionDuration を1秒(1000ミリ秒)に設定しました。

グラデーションアニメーションの追加:

AnimationController と ColorTween を使用して、グラデーション色のアニメーションを実装しました。

ページ遷移ロジックの修正:

_changePage メソッドでアニメーションを開始し、500ミリ秒後にページ遷移を行うようにしました。これにより、グラデーションの変化が始まってからページ遷移が行われるため、よりスムーズな視覚効果が得られます。

AnimatedBuilder の使用:

グラデーションの変化をリアルタイムで反映するために AnimatedBuilder を使用しています。

この実装により、以下の効果が得られます:

グラデーションの変化が1秒かけてスムーズに行われます。
ページ遷移のフェードアニメーションも1秒間続きます。
グラデーションの変化が始まってから少し遅れてページ遷移が始まるため、視覚的な連続性が向上します。

この方法では、グラデーションの変化とページ遷移のタイミングを細かく制御できるようになりました。ユーザーにとって、よりスムーズで魅力的な画面遷移体験を提供できるはずです。
さらに改善や調整が必要な点があれば、お知らせください。

Discussion