🤔

どうやってiOSのModal遷移をAutoRouteで再現する?

2024/01/01に公開

前提

元々iOSエンジニア出身の方はModal Transitionにすごく馴染みがあると思います。

Modal Transitionとは、ページが画面下から表示されるような遷移方法のことです。
以下の動画をご覧ください!

コードでモーダル遷移を行う場合は、基本的に以下のように記述しますよね!

final vc = HogeViewController()
self.present(vc)

それでは!
FlutterのAutoRouteでこれを実現しようとしてみましょう!!
みなさんならどうしますか??

できなくない?

そうなのです。簡単には同じようなモーダル遷移の再現をすることが難しいのです。。

というのも、AutoRouteでモーダル遷移をしたい場合は、以下のような形で試行錯誤すると思います。ちなみに、コード例では、auto_route v7.8.4を使っています。

import 'package:app/ui/route/app_route.gr.dart';
import 'package:auto_route/auto_route.dart';

import 'login/login_route.dart';

()
class AppRouter extends $AppRouter {
  
  List<AutoRoute> get routes => [
        AutoRoute(page: ConnectedHogeRoute.page,  fullscreenDialog: true),
      ];
}

しかし、これで行えるモーダル遷移は以下動画のように、画面全体をページで包むような遷移しかできません。

該当コード

()
class HogePage extends StatelessWidget {
  const HogePage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return const Scaffold(
      backgroundColor: Colors.purple,
      body: Center(child: Text('Hoge')),
    );
  }
}

()
class TestPage extends HookConsumerWidget {
  const TestPage({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final router = useRouter();

    return Scaffold(
      body: TestPage(
        onPressed: () => router.push(const HogeRoute()),
      )
    );
  }
}

()
class AppRouter extends $AppRouter {
  
  List<AutoRoute> get routes => [
        AutoRoute(page: HogeRoute.page, fullscreenDialog: true),
      ];
}

じゃあ、どうする?

このままでは、iOSのようなモーダル遷移ができないので、自作するしかありません!
自作してみましょう!

まず、auto_routeでは、元から用意されているAutoRoute以外に、CustomRouteというクラスを使うことができます。CustomRouteは名前の通り、画面遷移の方法をカスタマイズするためのRouteクラスです。

まずは、以下のコードをご覧ください。

()
class AppRouter extends $AppRouter {
  
  List<AutoRoute> get routes => [
        CustomRoute(page: HogeRoute.page, customRouteBuilder: appModalCustomRouteBuilder),
      ];
}

Route<T> appModalCustomRouteBuilder<T>(
  BuildContext context,
  Widget child,
  AutoRoutePage<T> page,
) {
  return PageRouteBuilder(
      fullscreenDialog: true,
      settings: page,
      barrierColor: Colors.black.withOpacity(0.5),
      opaque: false,
      pageBuilder: (_, __, ___) {
        return _AppModalPage(
          child: child,
        );
      });
}

class _AppModalPage extends HookConsumerWidget {
  const _AppModalPage({
    required this.child,
  });

  final Widget child;

  
  Widget build(BuildContext context, WidgetRef ref) {
    final draggableScrollController = DraggableScrollableController();
    final animation = useAnimationController(
      duration: const Duration(milliseconds: 200),
    );
    final router = useRouter();
    const begin = Offset(0, 1);
    const end = Offset.zero;

    useEffect(() {
      animation.forward();
      return null;
    }, const []);

    draggableScrollController.addListener(() {
      if (draggableScrollController.pixels <= 10) {
        router.pop();
      }
    });

    return SlideTransition(
      position: Tween<Offset>(
        begin: begin,
        end: end,
      ).animate(animation),
      child: DraggableScrollableSheet(
        snap: true,
        snapAnimationDuration: const Duration(milliseconds: 150),
        controller: draggableScrollController,
        minChildSize: 0.0,
        initialChildSize: 0.88,
        maxChildSize: 0.9,
        shouldCloseOnMinExtent: true,
        builder: (_, scrollController) {
          return ClipRRect(
            borderRadius: const BorderRadius.only(
              topLeft: Radius.circular(16),
              topRight: Radius.circular(16),
            ),
            child: ColoredBox(
              color: Colors.white,
              child: ListView(
                padding: EdgeInsets.zero,
                controller: scrollController,
                children: [
                  SizedBox(
                    height: MediaQuery.of(context).size.height,
                    child: ClipRRect(
                      borderRadius: const BorderRadius.only(
                        topLeft: Radius.circular(16),
                        topRight: Radius.circular(16),
                      ),
                      child: child,
                    ),
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }
}

いきなり見せられても意味がわからないと思うので、一つずつ話をします。

  1. まず、以下のようにCustomRouteでRouteを定義します。この時のcustomRouteBuilderの引数に対して、自作した関数を入れてあげることで、カスタマイズした画面遷移が実現可能になります。
()
class AppRouter extends $AppRouter {
  
  List<AutoRoute> get routes => [
        CustomRoute(page: HogeRoute.page, customRouteBuilder: appModalCustomRouteBuilder),
      ];
}
  1. それではカスタマイズしたrouteBuilder関数を見てみましょう。以下のように返り値はPageRouteBuilderクラスとなります。それぞれの引数の説明を行います。
  • fullScreenDialog:

    • そもそもこの値をtrueにしないと、通常のpush遷移になってしまうため、trueにする
  • settings:

    • ここには、画面遷移をしたいページを入れてあげてください。
  • barrierColor:

    • iOSのようなモーダル遷移をする場合、画面全体を包み込んで遷移するのではなく、以下、画像のように後ろに画面遷移元のページがちょっとだけ見えると思います。そこに対して、何色を付与するかをここで設定できます。
  • opaque:

    • opaqueをtrueにした場合、画面遷移元のページがopaque(不透明)なってしまい、真っ黒になります。明示的にfalseを入れてあげましょう。
  • pageBuilder:

    • 実際にカスタマイズした画面遷移はここで行います!
Route<T> appModalCustomRouteBuilder<T>(
  BuildContext context,
  Widget child,
  AutoRoutePage<T> page,
) {
  return PageRouteBuilder(
      fullscreenDialog: true,
      settings: page,
      barrierColor: Colors.black.withOpacity(0.5),
      opaque: false,
      pageBuilder: (_, __, ___) {
        return _AppModalPage(
          child: child,
        );
      });
}

  1. 最後に実際にpageBuilderに対して、入れるPageをみてみましょう。
  • まず、animationを使っていることがわかると思います。なぜ使うかというと、自作で画面下から上に遷移するようなアニメーションを自作する必要があるからです。
  • 画面を開いた時点で、animationをforwardしているだけです。
class _AppModalPage extends HookConsumerWidget {
  const _AppModalPage({
    required this.child,
  });

  final Widget child;

  
  Widget build(BuildContext context, WidgetRef ref) {
    final animation = useAnimationController(
      duration: const Duration(milliseconds: 200),
    );
    const begin = Offset(0, 1);
    const end = Offset.zero;

    useEffect(() {
      animation.forward();
      return null;
    }, const []);

    return SlideTransition(
      position: Tween<Offset>(
        begin: begin,
        end: end,
      ).animate(animation),
      child: ...,
          );
        },
      ),
    );
  }
}
  • 1番のポイントはDraggableScrollableSheetWidgetを使っていることです。

  • DraggableScrollableSheetとはFlutterが提供してくれているWidgetで、ラップされたページをドラッグ可能にするWidgetです。

  • 使い方

    • DraggableScrollableSheetを親Widgetとして配置して、子供に対して、ListViewなどのスクロール可能なWidgetを追加する
    • DraggableScrollableControllerをDraggableScrollableSheetのcontrollerプロパティに入れてあげる。
    • minChildSize: 0.0,initialChildSize: 0.88,maxChildSize: 0.9,の引数は英語が表すように、表示されるサイズを細かく指定できる引数となります。
    • snap: true,snapAnimationDuration: const Duration(milliseconds: 150),のことを言葉で説明するのが難しいのですが、snapがtrueであった方がよりiOSのモーダル遷移を再現しやすいです。
class _AppModalPage extends HookConsumerWidget {
  const _AppModalPage({
    required this.child,
  });

  final Widget child;

  
  Widget build(BuildContext context, WidgetRef ref) {
    final draggableScrollController = DraggableScrollableController();
    final router = useRouter();

    draggableScrollController.addListener(() {
      if (draggableScrollController.pixels <= 10) {
        router.pop();
      }
    });

    return SlideTransition(
      position: Tween<Offset>(
        begin: begin,
        end: end,
      ).animate(animation),
      child: DraggableScrollableSheet(
        snap: true,
        snapAnimationDuration: const Duration(milliseconds: 150),
        controller: draggableScrollController,
        minChildSize: 0.0,
        initialChildSize: 0.88,
        maxChildSize: 0.9,
        shouldCloseOnMinExtent: true,
        builder: (_, scrollController) {
          return ClipRRect(
            borderRadius: const BorderRadius.only(
              topLeft: Radius.circular(16),
              topRight: Radius.circular(16),
            ),
            child: ColoredBox(
              color: Colors.white,
              child: ListView(
                padding: EdgeInsets.zero,
                controller: scrollController,
                children: [
                  SizedBox(
                    height: MediaQuery.of(context).size.height,
                    child: ClipRRect(
                      borderRadius: const BorderRadius.only(
                        topLeft: Radius.circular(16),
                        topRight: Radius.circular(16),
                      ),
                      child: child,
                    ),
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }
}
  1. 最後にClipRRectをしてしてあげることで、ページを丸角にすることができます。2つClipRRectを利用しています。1個でよくないか?と思った方は実際に試してみてください!2つが良さそうって思うと思います!

最後に

  • 結構大変でしたが、以下の動画のようにiOSのようなモーダル遷移を実現することが可能です!
  • もっと良さそうな方法があれば、ぜひ教えてください〜!

P.S.

  • 使われているuseRouterって何?って人がもしかするといるかもなので、一応書いておきます。すごい簡単です。ただ、StackRouterの取得を簡単にしているだけです。
import 'package:auto_route/auto_route.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

StackRouter useRouter() {
  final context = useContext();
  return context.router;
}

Discussion