first commit
This commit is contained in:
278
lib/views/helpers/widgets/page_transitions.dart
Normal file
278
lib/views/helpers/widgets/page_transitions.dart
Normal file
@@ -0,0 +1,278 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
|
||||
/// ───────────────────────────────────────────────────────────────────────────
|
||||
/// PAGE MOTION — one vocabulary for every screen you can navigate into
|
||||
///
|
||||
/// Before this file the app had three different answers to "what happens when
|
||||
/// I tap a row": `Get.to` with GetX's default transition, a bare
|
||||
/// `MaterialPageRoute` (which on Android is the fade-through zoom, and reads as
|
||||
/// a hard cut on mid-range hardware), and a `pushReplacement` with no
|
||||
/// transition intent at all. Tapping *Edit Profile* looked nothing like tapping
|
||||
/// *Open map*, and neither looked like anything.
|
||||
///
|
||||
/// Two motions, and only two, from here on:
|
||||
///
|
||||
/// • [MilerSlideRoute] — right → left. For the *work*: a stop's map, the
|
||||
/// multi-stop route, the next step of onboarding. These are places the
|
||||
/// rider goes on his way somewhere else, so they behave like pages — the
|
||||
/// one below parallaxes, and a left-edge swipe goes back.
|
||||
///
|
||||
/// • [MilerSheetRoute] — bottom → top, and back down the same way. For
|
||||
/// anything laid *over* what the rider was doing: every screen under
|
||||
/// Account, plus stop verification and payment collection. It rises, it
|
||||
/// covers, it drops away.
|
||||
///
|
||||
/// ── Why Account is all sheets ──
|
||||
///
|
||||
/// These screens are leaves. You open Notifications, Saved Address, FAQ or
|
||||
/// Terms to do one thing, and then you are done with it — there is nowhere
|
||||
/// onward to go, which is why they all close with an × rather than a back
|
||||
/// arrow. A cross that dismissed a page sideways contradicted itself: the icon
|
||||
/// promised a layer dropping away and the motion showed a page being left
|
||||
/// behind. Now the icon and the direction say the same thing, which is what
|
||||
/// Uber's account screens do.
|
||||
///
|
||||
/// Both routes share the same expo-out entrance — fast off the mark, long soft
|
||||
/// landing, motion that arrives rather than stops. They differ on the way out,
|
||||
/// and deliberately: see [kMilerExitCurve] and [kMilerSheetExitCurve].
|
||||
/// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Fast start, long settle. The thing that makes a push feel "smooth" rather
|
||||
/// than "fast" — most of the distance is covered early, so the eye reads it as
|
||||
/// responsive, and the tail is slow enough that it never appears to snap.
|
||||
const Curve kMilerEnterCurve = Cubic(0.16, 1.0, 0.30, 1.0);
|
||||
|
||||
/// Leaving a *screen* — a map, a stop, something you were working inside.
|
||||
/// The rider's decision is already made, so this gets out of the way.
|
||||
const Curve kMilerExitCurve = Cubic(0.4, 0.0, 0.7, 0.2);
|
||||
|
||||
/// Leaving a *sheet*, which is a different gesture entirely.
|
||||
///
|
||||
/// A task screen dropping back down is the app putting something away, and the
|
||||
/// eye follows it the whole way — so it is eased at both ends rather than
|
||||
/// snatched off the screen. [kMilerExitCurve] was written for a page sliding
|
||||
/// sideways out of view, where nobody is watching the tail; used on a
|
||||
/// full-height sheet it read as the page being deleted rather than closed.
|
||||
const Curve kMilerSheetExitCurve = Cubic(0.33, 0.0, 0.15, 1.0);
|
||||
|
||||
// ── The 300ms ceiling ──
|
||||
//
|
||||
// 360/400/380 were within a hair of each other and all above the ceiling the
|
||||
// motion scale sets for anything on the rider's critical path. He crosses these
|
||||
// transitions 40 times a shift; 60–100ms each way is time spent watching the
|
||||
// app instead of working. The curves are unchanged — they are what make the
|
||||
// motion read as considered — only the duration comes down.
|
||||
const Duration kMilerPushIn = DesignConstants.motionPage;
|
||||
const Duration kMilerPushOut = Duration(milliseconds: 240);
|
||||
const Duration kMilerSheetIn = DesignConstants.motionPage;
|
||||
|
||||
/// Deliberately close to the entrance, and much slower than the 290ms it was.
|
||||
///
|
||||
/// Dismissal had been treated as dead time to be minimised — the standard
|
||||
/// instinct, and wrong here. Tapping × sent a full-height page down the screen
|
||||
/// in under a third of a second, which does not read as "closing", it reads as
|
||||
/// the screen vanishing. Going and coming should take about the same time,
|
||||
/// because they are the same distance.
|
||||
const Duration kMilerSheetOut = Duration(milliseconds: 240);
|
||||
|
||||
/// Right → left drill-down, with the iOS parallax on the page underneath and
|
||||
/// an interactive left-edge back-swipe.
|
||||
///
|
||||
/// Cupertino's own 500ms is a beat too slow for a screen a rider opens forty
|
||||
/// times a shift, so the duration is pulled in; the curve stays Cupertino's
|
||||
/// because it is what the back-gesture physics are written against.
|
||||
class MilerSlideRoute<T> extends CupertinoPageRoute<T> {
|
||||
/// Off for screens whose own content wants horizontal drags — a full-bleed
|
||||
/// Google Map, most of all. There, an edge swipe meant to nudge the map would
|
||||
/// throw the rider out of live navigation, so the back arrow is the only way
|
||||
/// out and that is the correct trade.
|
||||
final bool swipeToGoBack;
|
||||
|
||||
MilerSlideRoute({
|
||||
required super.builder,
|
||||
this.swipeToGoBack = true,
|
||||
super.settings,
|
||||
super.maintainState,
|
||||
});
|
||||
|
||||
@override
|
||||
Duration get transitionDuration => kMilerPushIn;
|
||||
|
||||
@override
|
||||
Duration get reverseTransitionDuration => kMilerPushOut;
|
||||
|
||||
@override
|
||||
bool get popGestureEnabled => swipeToGoBack && super.popGestureEnabled;
|
||||
}
|
||||
|
||||
/// Bottom → top task screen, Uber-style.
|
||||
///
|
||||
/// The incoming page rises the full height of the screen on the expo-out curve
|
||||
/// while the page it covers eases back a few percent — the small counter-motion
|
||||
/// is what stops a full-screen slab from reading as a jump-cut. When a further
|
||||
/// screen is pushed on top of *this* one, it slides up and out of the way by
|
||||
/// the same small amount, so a two-deep task flow still parallaxes.
|
||||
class MilerSheetRoute<T> extends PageRouteBuilder<T> {
|
||||
MilerSheetRoute({
|
||||
required WidgetBuilder builder,
|
||||
super.settings,
|
||||
super.maintainState,
|
||||
}) : super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
builder(context),
|
||||
transitionDuration: kMilerSheetIn,
|
||||
reverseTransitionDuration: kMilerSheetOut,
|
||||
// A task screen is a modal presentation: Flutter uses this to keep
|
||||
// the page below from running its own outgoing slide, which would
|
||||
// fight the upward motion.
|
||||
fullscreenDialog: true,
|
||||
transitionsBuilder: _buildSheetTransition,
|
||||
);
|
||||
|
||||
static Widget _buildSheetTransition(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
final Animation<Offset> enter =
|
||||
Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero).animate(
|
||||
CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: kMilerEnterCurve,
|
||||
reverseCurve: kMilerSheetExitCurve,
|
||||
),
|
||||
);
|
||||
|
||||
// Pushed over by yet another screen: retreat slightly upward instead of
|
||||
// sitting frozen under the new page.
|
||||
final Animation<Offset> recede =
|
||||
Tween<Offset>(begin: Offset.zero, end: const Offset(0, -0.06)).animate(
|
||||
CurvedAnimation(
|
||||
parent: secondaryAnimation,
|
||||
curve: kMilerEnterCurve,
|
||||
reverseCurve: kMilerSheetExitCurve,
|
||||
),
|
||||
);
|
||||
|
||||
return SlideTransition(
|
||||
position: recede,
|
||||
child: SlideTransition(position: enter, child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ── Call sites ──────────────────────────────────────────────────────────────
|
||||
///
|
||||
/// `openScreen(context, const FaqPage())` reads at the tap site as "go deeper",
|
||||
/// `openSheet(context, const Profile())` as "do a thing and come back". Both
|
||||
/// return the popped result, so they drop straight into the places that were
|
||||
/// awaiting a `Navigator.push`.
|
||||
|
||||
/// Right → left. The default for anything you navigate *into*.
|
||||
///
|
||||
/// Pass `swipeToGoBack: false` when the destination is a map or anything else
|
||||
/// that handles its own horizontal drags.
|
||||
Future<T?> openScreen<T>(
|
||||
BuildContext context,
|
||||
Widget page, {
|
||||
bool swipeToGoBack = true,
|
||||
}) {
|
||||
return Navigator.of(context).push<T>(
|
||||
MilerSlideRoute<T>(builder: (_) => page, swipeToGoBack: swipeToGoBack),
|
||||
);
|
||||
}
|
||||
|
||||
/// Bottom → top. For task screens you complete or abandon.
|
||||
Future<T?> openSheet<T>(BuildContext context, Widget page) {
|
||||
return Navigator.of(
|
||||
context,
|
||||
).push<T>(MilerSheetRoute<T>(builder: (_) => page));
|
||||
}
|
||||
|
||||
/// Bottom → top, replacing the current screen — for the end of a flow, where
|
||||
/// going "back" to the screen you just finished would be wrong.
|
||||
Future<T?> replaceWithSheet<T, TO>(BuildContext context, Widget page) {
|
||||
return Navigator.of(
|
||||
context,
|
||||
).pushReplacement<T, TO>(MilerSheetRoute<T>(builder: (_) => page));
|
||||
}
|
||||
|
||||
/// ───────────────────────────────────────────────────────────────────────────
|
||||
/// THE SMALLER SURFACES — dialogs and bottom sheets
|
||||
///
|
||||
/// A page is not the only thing that arrives. The app asks for confirmation in
|
||||
/// dialogs and offers choices in sheets far more often than it pushes a screen,
|
||||
/// and both were using framework defaults that predate everything above.
|
||||
/// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Bottom sheets: Flutter's default is 250ms in / 200ms out on
|
||||
/// `decelerate` — quick enough to register as a pop rather than a rise, which
|
||||
/// is especially wrong for the sheets a rider reads (skip reasons, stop
|
||||
/// details, payment) before deciding something. Same curve as the pages, so a
|
||||
/// sheet and a pushed screen feel like the same hand moved them.
|
||||
///
|
||||
/// Pass as `sheetAnimationStyle:` to `showModalBottomSheet`.
|
||||
const AnimationStyle kMilerSheetStyle = AnimationStyle(
|
||||
duration: Duration(milliseconds: 340),
|
||||
curve: kMilerEnterCurve,
|
||||
reverseDuration: Duration(milliseconds: 300),
|
||||
reverseCurve: kMilerSheetExitCurve,
|
||||
);
|
||||
|
||||
/// For the tall ones — a sheet that covers three-quarters of the screen is
|
||||
/// closer to a page than to a picker, and travelling that far in 340ms reads as
|
||||
/// a slam. Distance wants time: the same curve, given room to decelerate.
|
||||
const AnimationStyle kMilerLargeSheetStyle = AnimationStyle(
|
||||
duration: Duration(milliseconds: 430),
|
||||
curve: kMilerEnterCurve,
|
||||
reverseDuration: Duration(milliseconds: 360),
|
||||
reverseCurve: kMilerSheetExitCurve,
|
||||
);
|
||||
|
||||
/// Confirmation dialogs that grow into place instead of blinking on.
|
||||
///
|
||||
/// Material's `showDialog` is a 150ms straight fade with no movement at all —
|
||||
/// the dialog is simply there, at full size, before the eye has found it. This
|
||||
/// keeps the fade but adds the small scale-up that tells you *where* it came
|
||||
/// from, and gives the barrier the same clock so the screen dims with it rather
|
||||
/// than a beat ahead.
|
||||
///
|
||||
/// Drop-in for `showDialog`: same `context`/`builder`/`barrierDismissible`.
|
||||
Future<T?> showAppDialog<T>({
|
||||
required BuildContext context,
|
||||
required WidgetBuilder builder,
|
||||
bool barrierDismissible = true,
|
||||
Color barrierColor = const Color(0x8A000000),
|
||||
bool useRootNavigator = true,
|
||||
}) {
|
||||
return showGeneralDialog<T>(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
barrierLabel: barrierDismissible
|
||||
? MaterialLocalizations.of(context).modalBarrierDismissLabel
|
||||
: null,
|
||||
barrierColor: barrierColor,
|
||||
useRootNavigator: useRootNavigator,
|
||||
transitionDuration: const Duration(milliseconds: 260),
|
||||
pageBuilder: (context, _, _) => Builder(builder: builder),
|
||||
transitionBuilder: (context, animation, _, child) {
|
||||
// `drive` rather than a `CurvedAnimation`: this builder runs every frame
|
||||
// of the transition, and a CurvedAnimation built here would be a new
|
||||
// listener-holding object each time. A CurveTween is a pure function and
|
||||
// reads the same in reverse, so dismissal is the arrival played backwards.
|
||||
final Animation<double> curved = animation.drive(
|
||||
CurveTween(curve: Curves.easeOutCubic),
|
||||
);
|
||||
return FadeTransition(
|
||||
opacity: curved,
|
||||
child: ScaleTransition(
|
||||
scale: curved.drive(Tween<double>(begin: 0.94, end: 1.0)),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user