どうやってiOSのModal遷移をAutoRouteで再現する?
前提
元々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,
),
),
],
),
),
);
},
),
);
}
}
いきなり見せられても意味がわからないと思うので、一つずつ話をします。
- まず、以下のようにCustomRouteでRouteを定義します。この時のcustomRouteBuilderの引数に対して、自作した関数を入れてあげることで、カスタマイズした画面遷移が実現可能になります。
()
class AppRouter extends $AppRouter {
List<AutoRoute> get routes => [
CustomRoute(page: HogeRoute.page, customRouteBuilder: appModalCustomRouteBuilder),
];
}
- それではカスタマイズしたrouteBuilder関数を見てみましょう。以下のように返り値はPageRouteBuilderクラスとなります。それぞれの引数の説明を行います。
-
fullScreenDialog:
- そもそもこの値をtrueにしないと、通常のpush遷移になってしまうため、trueにする
-
settings:
- ここには、画面遷移をしたいページを入れてあげてください。
-
barrierColor:
- iOSのようなモーダル遷移をする場合、画面全体を包み込んで遷移するのではなく、以下、画像のように後ろに画面遷移元のページがちょっとだけ見えると思います。そこに対して、何色を付与するかをここで設定できます。
- 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,
);
});
}
- 最後に実際に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,
),
),
],
),
),
);
},
),
);
}
}
- 最後に
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