ux: bulk WIP — UxPlugin→XPlugin rename + new anim/core/navi/reactive packages
Catch-all commit for outstanding pre-existing local changes. Mixes several themes that would normally be split: - Rename: UxPlugin → XPlugin across iOS, macOS, Android registrants. - New top-level packages under lib/src/: anim/ (animated values, panes, sheets, dock, measured), core/ (Emitter, ReactiveBuilder scaffolding, presenter/widget/value/dispose primitives), navi/ (Screen/ScreenStack/Router/hero/transitions), reactive/. - Edits across existing plugins (clipboard, crash, file, gallery, keyboard, scanner, sensor, url) to align with the new core. - Test updates and CHANGELOG/README touches accompanying the above.
This commit is contained in:
156
lib/src/navi/hero.dart
Normal file
156
lib/src/navi/hero.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A shared-element widget that animates between two positions
|
||||
/// when the [ScreenStack] pushes or pops a screen.
|
||||
///
|
||||
/// Place [ScreenHero] widgets with the same [tag] on different screens.
|
||||
/// When a transition occurs, the hero "flies" from the old position
|
||||
/// to the new one inside an [Overlay].
|
||||
class ScreenHero extends StatefulWidget {
|
||||
const ScreenHero({
|
||||
super.key,
|
||||
required this.tag,
|
||||
required this.child,
|
||||
this.createRectTween,
|
||||
});
|
||||
|
||||
/// Identifier used to match heroes across pages.
|
||||
final Object tag;
|
||||
|
||||
/// The widget to display (and animate during flight).
|
||||
final Widget child;
|
||||
|
||||
/// Optional custom tween for the Rect interpolation.
|
||||
final CreateRectTween? createRectTween;
|
||||
|
||||
@override
|
||||
State<ScreenHero> createState() => ScreenHeroState();
|
||||
}
|
||||
|
||||
class ScreenHeroState extends State<ScreenHero> {
|
||||
Size? _placeholderSize;
|
||||
|
||||
void startFlight() {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
if (box == null || !box.hasSize) return;
|
||||
setState(() => _placeholderSize = box.size);
|
||||
}
|
||||
|
||||
void endFlight() {
|
||||
if (!mounted) return;
|
||||
setState(() => _placeholderSize = null);
|
||||
}
|
||||
|
||||
Rect? get globalRect {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
if (box == null || !box.hasSize) return null;
|
||||
return MatrixUtils.transformRect(
|
||||
box.getTransformTo(null),
|
||||
Offset.zero & box.size,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_placeholderSize != null) {
|
||||
return SizedBox.fromSize(size: _placeholderSize);
|
||||
}
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry + flight controller, owned by [ScreenStackState].
|
||||
class ScreenHeroController {
|
||||
final Map<Object, ScreenHeroState> _heroes = {};
|
||||
|
||||
void register(Object tag, ScreenHeroState state) => _heroes[tag] = state;
|
||||
void unregister(Object tag, ScreenHeroState state) {
|
||||
if (_heroes[tag] == state) _heroes.remove(tag);
|
||||
}
|
||||
|
||||
/// Snapshot current hero Rects. Call before the page list changes.
|
||||
Map<Object, Rect> snapshot() {
|
||||
final result = <Object, Rect>{};
|
||||
for (final entry in _heroes.entries) {
|
||||
final rect = entry.value.globalRect;
|
||||
if (rect != null) result[entry.key] = rect;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Start flights for any heroes whose position changed between
|
||||
/// [before] snapshot and the current state.
|
||||
void maybeStartFlights({
|
||||
required Map<Object, Rect> before,
|
||||
required Animation<double> animation,
|
||||
required OverlayState overlay,
|
||||
}) {
|
||||
for (final tag in before.keys) {
|
||||
final hero = _heroes[tag];
|
||||
if (hero == null) continue;
|
||||
final fromRect = before[tag]!;
|
||||
final toRect = hero.globalRect;
|
||||
if (toRect == null) continue;
|
||||
if (fromRect == toRect) continue;
|
||||
|
||||
_startFlight(
|
||||
tag: tag,
|
||||
hero: hero,
|
||||
fromRect: fromRect,
|
||||
toRect: toRect,
|
||||
animation: animation,
|
||||
overlay: overlay,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _startFlight({
|
||||
required Object tag,
|
||||
required ScreenHeroState hero,
|
||||
required Rect fromRect,
|
||||
required Rect toRect,
|
||||
required Animation<double> animation,
|
||||
required OverlayState overlay,
|
||||
}) {
|
||||
hero.startFlight();
|
||||
|
||||
final createTween = hero.widget.createRectTween;
|
||||
final rectTween = createTween != null
|
||||
? createTween(fromRect, toRect)
|
||||
: RectTween(begin: fromRect, end: toRect);
|
||||
|
||||
final child = hero.widget.child;
|
||||
OverlayEntry? entry;
|
||||
|
||||
void onEnd(AnimationStatus status) {
|
||||
if (status == AnimationStatus.completed ||
|
||||
status == AnimationStatus.dismissed) {
|
||||
entry?.remove();
|
||||
entry = null;
|
||||
hero.endFlight();
|
||||
animation.removeStatusListener(onEnd);
|
||||
}
|
||||
}
|
||||
|
||||
animation.addStatusListener(onEnd);
|
||||
|
||||
entry = OverlayEntry(
|
||||
builder: (context) => AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, _) {
|
||||
final rect = rectTween.evaluate(animation);
|
||||
if (rect == null) return const SizedBox.shrink();
|
||||
return Positioned(
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
child: IgnorePointer(child: child),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
overlay.insert(entry!);
|
||||
}
|
||||
}
|
||||
385
lib/src/navi/router.dart
Normal file
385
lib/src/navi/router.dart
Normal file
@@ -0,0 +1,385 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:ux/src/core/dispose.dart';
|
||||
import 'package:ux/src/core/emitter.dart';
|
||||
import 'package:ux/src/core/functional.dart';
|
||||
import 'package:ux/src/core/list_emitter.dart';
|
||||
import 'package:ux/src/core/store/store.dart';
|
||||
import 'package:ux/src/core/uri.dart';
|
||||
import 'package:ux/src/navi/screen_host.dart';
|
||||
import 'package:ux/src/navi/screen_stack.dart';
|
||||
import 'package:ux/src/navi/screen.dart';
|
||||
|
||||
/// Generic stack-based screen router.
|
||||
///
|
||||
/// Manages a [home] screen and a [stack] of pushed screens, rendering them
|
||||
/// via [ScreenStack]. Subclass and override [pages] to add guards
|
||||
/// (e.g. auth checks) before the default screen list.
|
||||
class XRouter extends BackButtonDispatcher
|
||||
with Dispose, ScreenShell
|
||||
implements ScreenHost {
|
||||
XRouter({required this.home, this.routeParser}) {
|
||||
home.parentHost = this;
|
||||
_updateActive();
|
||||
}
|
||||
|
||||
final Screen home;
|
||||
final RouteInformationParser<Object>? routeParser;
|
||||
late final stack = ListEmitter<Screen>()..disposeBy(this);
|
||||
late final delegate = XRouterDelegate(router: this);
|
||||
late final backDispatcher = XRouterBack(router: this);
|
||||
|
||||
/// The screen most recently fired `onActive`. Used to de-duplicate and
|
||||
/// pair active/inactive transitions when the topmost visible screen
|
||||
/// changes.
|
||||
Screen? _active;
|
||||
|
||||
/// Recomputes the topmost visible screen and fires onInactive/onActive
|
||||
/// when it has changed. Topmost is the last non-popped entry in [pages],
|
||||
/// which already expands ScreenShell hosting. Also re-applies the
|
||||
/// topmost screen's [Screen.supportedOrientations] to the OS, mirroring
|
||||
/// UIKit's per-VC `supportedInterfaceOrientations` unwind on pop.
|
||||
void _updateActive() {
|
||||
Screen? top;
|
||||
for (final page in pages.reversed) {
|
||||
if (!page.popped) {
|
||||
top = page;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (identical(top, _active)) return;
|
||||
_active?.onInactive();
|
||||
_active = top;
|
||||
top?.onActive();
|
||||
SystemChrome.setPreferredOrientations(
|
||||
(top?.supportedOrientations ?? DeviceOrientation.values).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Screen? get currentConfiguration => stack.lastOrNull ?? home;
|
||||
|
||||
Iterable<Screen> _expand(Screen page) sync* {
|
||||
yield page;
|
||||
if (page is ScreenShell) {
|
||||
for (final screen in (page as ScreenShell).pages) {
|
||||
if (!screen.popped) yield screen;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<Screen> get pages => [
|
||||
..._expand(home),
|
||||
for (final page in stack) ..._expand(page),
|
||||
];
|
||||
|
||||
late final _overlayEntry = OverlayEntry(builder: _buildContent);
|
||||
|
||||
bool get canPop {
|
||||
if (stack.isNotEmpty) return true;
|
||||
if (home is ScreenShell) {
|
||||
for (final screen in (home as ScreenShell).pages) {
|
||||
if (!screen.popped) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _updateSystemBack() {
|
||||
SystemNavigator.setFrameworkHandlesBack(canPop);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context) {
|
||||
// [pages] (not the [home]/[stack] fields) so subclasses can
|
||||
// override for gating — e.g. swap home to an auth screen.
|
||||
final p = pages;
|
||||
return MediaQuery.removeViewInsets(
|
||||
context: context,
|
||||
removeBottom: true,
|
||||
child: Material(
|
||||
child: NotificationListener<NavigationNotification>(
|
||||
onNotification: (_) => true, // absorb — router manages system back directly
|
||||
child: ScreenStack(
|
||||
home: p.first,
|
||||
stack: p.skip(1).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget build(BuildContext context) {
|
||||
_overlayEntry.markNeedsBuild();
|
||||
return Overlay(initialEntries: [_overlayEntry]);
|
||||
}
|
||||
|
||||
void popAll() {
|
||||
while (stack.isNotEmpty) {
|
||||
stack.last.pop();
|
||||
}
|
||||
if (home is ScreenShell) {
|
||||
for (final screen in (home as ScreenShell).pages.toList()) {
|
||||
screen.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Push [page] onto the router.
|
||||
///
|
||||
/// If an `==`-equal page is already mounted (shell-hosted by [home] or
|
||||
/// anywhere on [stack]), pops every stack entry above it and returns
|
||||
/// that existing page's future — the caller awaits until the mounted
|
||||
/// page is popped. No duplicate is created, no fresh instance is
|
||||
/// mounted. For identity-equal screens (no `==` override), this dedup
|
||||
/// path is unreachable; behavior matches the pre-dedup semantics.
|
||||
///
|
||||
/// The type parameter `T` on the awaiter may differ from the mounted
|
||||
/// page's original `T`; the returned future is cast. Pushing an equal
|
||||
/// page of a different `T` is undefined.
|
||||
Future<T?> push<T>(Screen<T> page) async {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
if (home == page) {
|
||||
popAll();
|
||||
return SynchronousFuture(null);
|
||||
}
|
||||
|
||||
final existing = _findMounted(page);
|
||||
if (existing != null) {
|
||||
while (stack.isNotEmpty && !identical(stack.last, existing)) {
|
||||
stack.last.pop();
|
||||
}
|
||||
delegate.notifyListeners();
|
||||
_updateActive();
|
||||
return existing.future as Future<T?>;
|
||||
}
|
||||
|
||||
final top = stack.lastOrNull ?? home;
|
||||
if (top case final ScreenShell host when host.accept(page)) {
|
||||
page.parentHost = this;
|
||||
page.detach = () {
|
||||
delegate.notifyListeners();
|
||||
_updateActive();
|
||||
};
|
||||
page.removed = () {
|
||||
host.remove(page);
|
||||
Dispose.object(page);
|
||||
};
|
||||
page.onPush();
|
||||
delegate.notifyListeners();
|
||||
_updateActive();
|
||||
return page.future;
|
||||
}
|
||||
|
||||
page.parentHost = this;
|
||||
page.detach = () {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
stack.removeWhere((p) => identical(p, page));
|
||||
delegate.notifyListeners();
|
||||
_updateActive();
|
||||
};
|
||||
page.removed = () => Dispose.object(page);
|
||||
page.onPush();
|
||||
stack.add(page);
|
||||
|
||||
delegate.notifyListeners();
|
||||
_updateActive();
|
||||
return page.future;
|
||||
}
|
||||
|
||||
/// Find an already-mounted screen equal to [page], skipping popped
|
||||
/// entries. Scans shell-hosted children of [home] first, then the
|
||||
/// router stack bottom→top.
|
||||
Screen? _findMounted(Screen page) {
|
||||
if (home is ScreenShell) {
|
||||
for (final hosted in (home as ScreenShell).pages) {
|
||||
if (hosted.popped) continue;
|
||||
if (identical(hosted, page) || hosted == page) return hosted;
|
||||
}
|
||||
}
|
||||
for (final entry in stack) {
|
||||
if (entry.popped) continue;
|
||||
if (identical(entry, page) || entry == page) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> setNewRoutePath(Screen? configuration) {
|
||||
if (configuration == null || configuration == home) {
|
||||
popAll();
|
||||
return SynchronousFuture(null);
|
||||
}
|
||||
return push(configuration);
|
||||
}
|
||||
}
|
||||
|
||||
class XRouterBack extends RootBackButtonDispatcher {
|
||||
XRouterBack({required this.router});
|
||||
|
||||
final XRouter router;
|
||||
|
||||
@override
|
||||
Future<bool> didPopRoute() async {
|
||||
for (final page in router.stack.reversed) {
|
||||
if (page.handleBack()) return true;
|
||||
}
|
||||
if (router.home.handleBack()) return true;
|
||||
final page = router.stack.lastOrNull;
|
||||
if (page != null) {
|
||||
page.pop();
|
||||
return true;
|
||||
}
|
||||
return super.didPopRoute();
|
||||
}
|
||||
}
|
||||
|
||||
class XRouterDelegate extends RouterDelegate<Screen> with Emitter {
|
||||
XRouterDelegate({required this.router});
|
||||
|
||||
final XRouter router;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => router.build(context);
|
||||
|
||||
@override
|
||||
Future<bool> popRoute() => router.backDispatcher.didPopRoute();
|
||||
|
||||
@override
|
||||
Future<void> setNewRoutePath(Screen? configuration) async =>
|
||||
router.setNewRoutePath(configuration);
|
||||
|
||||
@override
|
||||
void notifyListeners() {
|
||||
super.notifyListeners();
|
||||
router._updateSystemBack();
|
||||
}
|
||||
|
||||
@override
|
||||
Screen? get currentConfiguration => router.currentConfiguration;
|
||||
}
|
||||
|
||||
/// Mixin for screens that can host other screens (e.g. a tab host showing
|
||||
/// a detail screen alongside the tab bar).
|
||||
///
|
||||
/// When a screen is pushed, the router asks the current top screen's shell
|
||||
/// whether to [accept] it. If accepted, the screen is managed by the shell
|
||||
/// instead of the router's main stack.
|
||||
mixin ScreenShell {
|
||||
/// Whether this shell wants to own [screen]. Return `true` to intercept
|
||||
/// the push — the screen will not go onto the router stack.
|
||||
bool accept(Screen screen) => false;
|
||||
|
||||
/// Called after a hosted screen's exit animation completes.
|
||||
void remove(Screen screen) {}
|
||||
|
||||
/// The screens currently hosted by this shell. The router flattens
|
||||
/// these into the [ScreenStack] alongside the main stack.
|
||||
Iterable<Screen> get pages => const [];
|
||||
}
|
||||
|
||||
/// Mixin for screens that support deep-linking via a URL.
|
||||
mixin Deeplink<T> on Screen<T> {
|
||||
String get restoreUrl;
|
||||
}
|
||||
|
||||
/// A [RouteInformationParser] that converts URIs into [Screen]s
|
||||
/// using a [UriParser], and restores URLs from [Deeplink] screens.
|
||||
class XRouteParser extends RouteInformationParser<Object> {
|
||||
XRouteParser({required this.parser, this.normalize});
|
||||
|
||||
final UriParser<Screen, dynamic> parser;
|
||||
final Uri Function(Uri)? normalize;
|
||||
|
||||
Screen? parse(Uri? uri) {
|
||||
if (uri == null) return null;
|
||||
if (normalize != null) uri = normalize!(uri);
|
||||
return parser.parse(uri, null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Object> parseRouteInformation(RouteInformation routeInformation) async {
|
||||
return parse(routeInformation.uri) as Object;
|
||||
}
|
||||
|
||||
@override
|
||||
RouteInformation? restoreRouteInformation(Object configuration) {
|
||||
return (configuration is Deeplink ? configuration.restoreUrl : null)
|
||||
?.pipe(Uri.tryParse)
|
||||
?.pipe((uri) => RouteInformation(uri: uri));
|
||||
}
|
||||
}
|
||||
|
||||
/// The root widget for a ux app.
|
||||
///
|
||||
/// Resolves a [XRouter] from the [Store] and sets up theming,
|
||||
/// localization, scroll behavior, and back-button dispatching —
|
||||
/// without pulling in Flutter's [Navigator] or [MaterialApp].
|
||||
class XApp extends StatelessWidget {
|
||||
const XApp({
|
||||
super.key,
|
||||
required this.router,
|
||||
this.title = '',
|
||||
this.theme,
|
||||
this.debugShowCheckedModeBanner = true,
|
||||
this.localizationsDelegates,
|
||||
this.supportedLocales = const [Locale('en')],
|
||||
this.scrollBehavior,
|
||||
this.overlayKey,
|
||||
});
|
||||
|
||||
final XRouter router;
|
||||
final String title;
|
||||
final ThemeData? theme;
|
||||
final bool debugShowCheckedModeBanner;
|
||||
final Iterable<LocalizationsDelegate<dynamic>>? localizationsDelegates;
|
||||
final Iterable<Locale> supportedLocales;
|
||||
final ScrollBehavior? scrollBehavior;
|
||||
final GlobalKey<OverlayState>? overlayKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final data = theme ?? ThemeData();
|
||||
|
||||
return Title(
|
||||
title: title,
|
||||
color: data.primaryColor,
|
||||
child: MediaQuery.fromView(
|
||||
view: View.of(context),
|
||||
child: Localizations(
|
||||
locale: supportedLocales.first,
|
||||
delegates: [
|
||||
...?localizationsDelegates,
|
||||
DefaultMaterialLocalizations.delegate,
|
||||
DefaultWidgetsLocalizations.delegate,
|
||||
],
|
||||
child: AnimatedTheme(
|
||||
data: data,
|
||||
child: ScrollConfiguration(
|
||||
behavior: scrollBehavior ?? const MaterialScrollBehavior(),
|
||||
child: Overlay(
|
||||
key: overlayKey,
|
||||
initialEntries: [
|
||||
OverlayEntry(
|
||||
builder: (_) => Shortcuts(
|
||||
shortcuts: WidgetsApp.defaultShortcuts,
|
||||
child: Actions(
|
||||
actions: WidgetsApp.defaultActions,
|
||||
child: DefaultTextEditingShortcuts(
|
||||
child: Router(
|
||||
routeInformationParser: router.routeParser,
|
||||
routerDelegate: router.delegate,
|
||||
backButtonDispatcher: router.backDispatcher,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
136
lib/src/navi/screen.dart
Normal file
136
lib/src/navi/screen.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:ux/src/navi/screen_host.dart';
|
||||
import 'package:ux/src/navi/transitions.dart';
|
||||
|
||||
/// A full-screen destination that can be pushed onto a [ScreenStack]
|
||||
/// and managed by a [XRouter].
|
||||
///
|
||||
/// Provides its own content via [buildPresenter] and controls
|
||||
/// how it transitions in/out via [buildTransition].
|
||||
mixin Screen<T> {
|
||||
// === rendering ===
|
||||
|
||||
/// Identity tag used by [ScreenStack] to reconcile a `home` slot:
|
||||
/// same [runtimeType] + same [key] → same surface, slot State
|
||||
/// preserved. Has no effect on stack entries (diffed by `==`).
|
||||
Key? get key => null;
|
||||
|
||||
/// Build the page content.
|
||||
Widget buildPresenter(BuildContext context);
|
||||
|
||||
/// Wrap [child] (the output of [buildPresenter]) in a transition
|
||||
/// driven by [animation] (0→1 on push, 1→0 on pop).
|
||||
///
|
||||
/// Override to customise. The default is a platform slide.
|
||||
Widget buildTransition(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Widget child,
|
||||
) => ScreenTransitions.platformSlide(context, animation, child);
|
||||
|
||||
/// Duration of the enter/exit animation.
|
||||
Duration get transitionDuration => const Duration(milliseconds: 300);
|
||||
|
||||
/// Whether this screen is a modal (dialog, bottom sheet, etc.).
|
||||
bool get isModal => false;
|
||||
|
||||
/// Whether the iOS edge-swipe gesture should be enabled for this page.
|
||||
/// Set to `false` for bottom sheets and overlays that dismiss differently.
|
||||
bool get canSwipeBack => true;
|
||||
|
||||
/// Device orientations this screen supports while it is the topmost
|
||||
/// visible page. The router applies the topmost screen's value via
|
||||
/// [SystemChrome.setPreferredOrientations] on each active-screen
|
||||
/// change, mirroring UIKit's per-`UIViewController.supportedInterfaceOrientations`.
|
||||
/// Override to force portrait/landscape on a specific page (e.g. a camera).
|
||||
Iterable<DeviceOrientation> get supportedOrientations =>
|
||||
DeviceOrientation.values;
|
||||
|
||||
// === navigation ===
|
||||
|
||||
/// The host that pushed this screen — set by the host on push.
|
||||
/// Mirrors UIKit's `UIViewController.navigationController`.
|
||||
/// `null` until pushed.
|
||||
///
|
||||
/// Pages typically don't read this directly; use [host] instead,
|
||||
/// which is the right target for "push another page from here".
|
||||
ScreenHost? parentHost;
|
||||
|
||||
/// The host that pushes from inside this screen's body should
|
||||
/// target. Defaults to [parentHost] — regular screens push
|
||||
/// siblings onto their own host. `SheetScreen` overrides this to
|
||||
/// route into its own nested stack so pages pushed from inside a
|
||||
/// sheet body land within the sheet rather than escaping it.
|
||||
ScreenHost? get host => parentHost;
|
||||
|
||||
/// Whether the back button / swipe should pop this page.
|
||||
bool get canPop => true;
|
||||
|
||||
/// Custom back-button handling. Return `true` to consume the event.
|
||||
bool handleBack() => false;
|
||||
|
||||
/// Called after the page is pushed onto the router.
|
||||
void onPush() {}
|
||||
|
||||
/// Called when this screen becomes the topmost visible screen in the
|
||||
/// router. Fires once after push, and again whenever it becomes topmost
|
||||
/// after a screen above it pops. Always paired with a later [onInactive].
|
||||
void onActive() {}
|
||||
|
||||
/// Called when another screen is pushed on top, or right before this one
|
||||
/// is popped. Mirror of [onActive].
|
||||
void onInactive() {}
|
||||
|
||||
// === pop lifecycle ===
|
||||
|
||||
/// Whether this page has been popped (explicitly removed).
|
||||
bool popped = false;
|
||||
|
||||
/// Set by the router when pushing. Called during [pop] to remove the page
|
||||
/// from the router stack and notify the delegate.
|
||||
VoidCallback? detach;
|
||||
|
||||
/// Set by the router when pushing. Called from [onRemoved] after the exit
|
||||
/// animation completes for final cleanup (disposal).
|
||||
VoidCallback? removed;
|
||||
|
||||
Completer<T?>? _completer;
|
||||
|
||||
/// A future that completes with the result when the page is popped.
|
||||
Future<T?> get future {
|
||||
_completer ??= Completer<T?>();
|
||||
return _completer!.future;
|
||||
}
|
||||
|
||||
/// Remove this page. Marks as popped, detaches from the router,
|
||||
/// and completes the [future] with [result].
|
||||
void pop([T? result]) {
|
||||
popped = true;
|
||||
onDetach();
|
||||
if (_completer?.isCompleted != true) {
|
||||
_completer?.complete(result);
|
||||
}
|
||||
}
|
||||
|
||||
/// Called during [pop] before the future is completed.
|
||||
/// Override for cleanup that must happen before the exit animation.
|
||||
@mustCallSuper
|
||||
void onDetach() {
|
||||
detach?.call();
|
||||
detach = null;
|
||||
}
|
||||
|
||||
/// Called after the exit animation completes and the page is fully removed.
|
||||
/// Override for cleanup that should happen after the page is off screen.
|
||||
@mustCallSuper
|
||||
void onRemoved() {
|
||||
if (popped) {
|
||||
removed?.call();
|
||||
removed = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
lib/src/navi/screen_host.dart
Normal file
15
lib/src/navi/screen_host.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:ux/src/navi/screen.dart';
|
||||
|
||||
/// A target for [Screen]-level pushes. Implementations include
|
||||
/// [XRouter] (the global navigator) and `_SheetNestedHost` (the
|
||||
/// inner stack of a `SheetScreen`).
|
||||
///
|
||||
/// Mirrors UIKit's pattern where every `UIViewController` knows its
|
||||
/// `navigationController`: pages route via the host they were pushed
|
||||
/// onto rather than walking ancestor stacks or looking up global
|
||||
/// state.
|
||||
abstract class ScreenHost {
|
||||
/// Push [page] onto this host's stack. Future completes when
|
||||
/// [page] is popped (with the popped result, or `null` on dismiss).
|
||||
Future<R?> push<R>(Screen<R> page);
|
||||
}
|
||||
427
lib/src/navi/screen_stack.dart
Normal file
427
lib/src/navi/screen_stack.dart
Normal file
@@ -0,0 +1,427 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show PredictiveBackEvent;
|
||||
|
||||
import 'package:ux/src/core/functional.dart';
|
||||
import 'package:ux/src/navi/hero.dart';
|
||||
import 'package:ux/src/navi/screen.dart';
|
||||
|
||||
/// A declarative screen stack with animated transitions.
|
||||
///
|
||||
/// Two slots with different reconciliation contracts:
|
||||
///
|
||||
/// - [home] — `Widget.canUpdate`-style match (`runtimeType` +
|
||||
/// [Screen.key]). A fresh instance of the same type/key updates the
|
||||
/// home slot in place; State below is preserved. Different type or
|
||||
/// key tears it down. Home never animates in/out, never pops.
|
||||
/// - [stack] — `==`-based diff. Equal Screens reorder, distinct
|
||||
/// Screens get new slots with enter transitions and pop animations.
|
||||
class ScreenStack extends StatefulWidget {
|
||||
const ScreenStack({
|
||||
super.key,
|
||||
required this.home,
|
||||
this.stack = const [],
|
||||
this.onRemoved,
|
||||
});
|
||||
|
||||
final Screen home;
|
||||
final List<Screen> stack;
|
||||
final ValueChanged<Screen>? onRemoved;
|
||||
|
||||
@override
|
||||
State<ScreenStack> createState() => ScreenStackState();
|
||||
|
||||
static ScreenStackState of(BuildContext context) {
|
||||
return context.findAncestorStateOfType<ScreenStackState>()!;
|
||||
}
|
||||
|
||||
static ScreenStackState? maybeOf(BuildContext context) {
|
||||
return context.findAncestorStateOfType<ScreenStackState>();
|
||||
}
|
||||
}
|
||||
|
||||
class ScreenStackState extends State<ScreenStack> with TickerProviderStateMixin {
|
||||
late ScreenSlot _homeSlot;
|
||||
final List<ScreenSlot> _entries = [];
|
||||
final ScreenHeroController heroController = ScreenHeroController();
|
||||
final FocusScopeNode _outerScope = FocusScopeNode(debugLabel: 'ScreenStack');
|
||||
|
||||
bool _canPop = false;
|
||||
ScreenSlot? _focusedSlot;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_homeSlot = _createHomeSlot(widget.home);
|
||||
for (final page in widget.stack) {
|
||||
_entries.add(_createEntry(page, animated: false));
|
||||
}
|
||||
_updateCanPop();
|
||||
_scheduleTopFocusHandoff();
|
||||
}
|
||||
|
||||
/// Hand focus to the current top slot's scope on the next frame.
|
||||
/// Mirrors what [Navigator] does via `setFirstFocus` on push: without
|
||||
/// it, a freshly-pushed page's `autofocus: true` TextField ends up
|
||||
/// focused but never becomes the active scope, so the keyboard token
|
||||
/// is never consumed and the system keyboard doesn't open.
|
||||
void _scheduleTopFocusHandoff() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final top = _topSlot;
|
||||
if (top == _focusedSlot) return;
|
||||
_focusedSlot = top;
|
||||
_outerScope.setFirstFocus(top.focusScope);
|
||||
});
|
||||
}
|
||||
|
||||
void _updateCanPop() {
|
||||
final canPop = _entries.any((e) => !e.removing);
|
||||
if (canPop == _canPop) return;
|
||||
_canPop = canPop;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) NavigationNotification(canHandlePop: canPop).dispatch(context);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ScreenStack oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_updateHome(oldWidget.home, widget.home);
|
||||
_diffStack(oldWidget.stack, widget.stack);
|
||||
}
|
||||
|
||||
void _updateHome(Screen oldHome, Screen newHome) {
|
||||
if (identical(oldHome, newHome)) return;
|
||||
if (oldHome.runtimeType == newHome.runtimeType &&
|
||||
oldHome.key == newHome.key) {
|
||||
_homeSlot.page = newHome;
|
||||
return;
|
||||
}
|
||||
final old = _homeSlot;
|
||||
old.controller.dispose();
|
||||
old.focusScope.dispose();
|
||||
if (identical(_focusedSlot, old)) _focusedSlot = null;
|
||||
_homeSlot = _createHomeSlot(newHome);
|
||||
_scheduleTopFocusHandoff();
|
||||
}
|
||||
|
||||
void _diffStack(List<Screen> oldStack, List<Screen> newStack) {
|
||||
final oldSet = <Screen>{...oldStack};
|
||||
final newSet = <Screen>{...newStack};
|
||||
|
||||
final hasChanges = oldStack.length != newStack.length ||
|
||||
oldSet.any((p) => !newSet.contains(p)) ||
|
||||
newSet.any((p) => !oldSet.contains(p));
|
||||
|
||||
// Snapshot only on actual mutation; otherwise every host-driven
|
||||
// rebuild would schedule a post-frame flight check.
|
||||
final heroSnapshot = hasChanges ? heroController.snapshot() : null;
|
||||
|
||||
for (final entry in _entries.toList()) {
|
||||
if (entry.removing) continue;
|
||||
if (!newSet.contains(entry.page)) {
|
||||
_removeEntry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
for (final page in newStack) {
|
||||
if (!oldSet.contains(page)) {
|
||||
_entries.add(_createEntry(page, animated: true));
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder to match newStack, keeping removing entries in their
|
||||
// original relative position so an exiting child stays above its
|
||||
// parent, not behind it.
|
||||
final ordered = <ScreenSlot>[];
|
||||
for (final page in newStack) {
|
||||
ordered.add(_entries.firstWhere((e) => e.page == page && !e.removing));
|
||||
}
|
||||
final result = <ScreenSlot>[];
|
||||
int oi = 0;
|
||||
for (final entry in _entries) {
|
||||
if (entry.removing) {
|
||||
result.add(entry);
|
||||
} else if (oi < ordered.length) {
|
||||
result.add(ordered[oi++]);
|
||||
}
|
||||
}
|
||||
while (oi < ordered.length) {
|
||||
result.add(ordered[oi++]);
|
||||
}
|
||||
_entries
|
||||
..clear()
|
||||
..addAll(result);
|
||||
|
||||
if (heroSnapshot != null && heroSnapshot.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final topEntry = _entries.lastWhereOrNull((e) => !e.removing);
|
||||
if (topEntry == null) return;
|
||||
final overlay = Overlay.maybeOf(context);
|
||||
if (overlay == null) return;
|
||||
heroController.maybeStartFlights(
|
||||
before: heroSnapshot,
|
||||
animation: topEntry.controller,
|
||||
overlay: overlay,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
_updateCanPop();
|
||||
_scheduleTopFocusHandoff();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
ScreenSlot _createHomeSlot(Screen home) {
|
||||
final controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: Duration.zero,
|
||||
value: 1.0,
|
||||
);
|
||||
return ScreenSlot(page: home, controller: controller);
|
||||
}
|
||||
|
||||
ScreenSlot _createEntry(Screen page, {required bool animated}) {
|
||||
final controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: page.transitionDuration,
|
||||
value: animated ? 0.0 : 1.0,
|
||||
);
|
||||
final entry = ScreenSlot(page: page, controller: controller);
|
||||
if (animated) controller.forward();
|
||||
return entry;
|
||||
}
|
||||
|
||||
void _removeEntry(ScreenSlot entry) {
|
||||
entry.removing = true;
|
||||
entry.controller.reverse().then((_) {
|
||||
if (!mounted) return;
|
||||
_entries.remove(entry);
|
||||
entry.controller.dispose();
|
||||
entry.focusScope.dispose();
|
||||
if (_focusedSlot == entry) _focusedSlot = null;
|
||||
entry.page.onRemoved();
|
||||
widget.onRemoved?.call(entry.page);
|
||||
_scheduleTopFocusHandoff();
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_homeSlot.controller.dispose();
|
||||
_homeSlot.focusScope.dispose();
|
||||
for (final entry in _entries) {
|
||||
entry.controller.dispose();
|
||||
entry.focusScope.dispose();
|
||||
}
|
||||
_outerScope.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Topmost live slot — the last non-removing pushed entry if any,
|
||||
/// else home. Used for focus handoff.
|
||||
ScreenSlot get _topSlot =>
|
||||
_entries.lastWhereOrNull((e) => !e.removing) ?? _homeSlot;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final top = _entries.lastWhereOrNull((e) => !e.removing);
|
||||
return Actions(
|
||||
actions: {
|
||||
DismissIntent: CallbackAction<DismissIntent>(
|
||||
onInvoke: (_) {
|
||||
final focus = FocusManager.instance.primaryFocus;
|
||||
if (focus != null && focus.context != null &&
|
||||
focus.context!.findAncestorWidgetOfExactType<EditableText>() != null) {
|
||||
focus.unfocus();
|
||||
return null;
|
||||
}
|
||||
if (top != null && top.page.isModal) {
|
||||
top.page.pop();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: FocusScope(
|
||||
node: _outerScope,
|
||||
child: Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: [
|
||||
// Anchored on the slot's GlobalKey so a slot replacement
|
||||
// unmounts the old subtree — without it, Flutter would
|
||||
// reconcile inner widgets by type and preserve their State.
|
||||
KeyedSubtree(key: _homeSlot.key, child: _homeSlot.build(context)),
|
||||
for (final entry in _entries)
|
||||
ScreenBackHandler(
|
||||
key: entry.key,
|
||||
entry: entry,
|
||||
enabled: entry == top && _canPop,
|
||||
onPop: () => entry.page.pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A slot in the [ScreenStack] that holds a [Screen] and manages its
|
||||
/// transition animation.
|
||||
class ScreenSlot {
|
||||
ScreenSlot({required this.page, required this.controller});
|
||||
|
||||
Screen page;
|
||||
final AnimationController controller;
|
||||
final key = GlobalKey();
|
||||
final contentKey = GlobalKey();
|
||||
// Each slot owns its own FocusScope so pushed pages start with a clean
|
||||
// focus state — the outer ScreenStack scope calls `setFirstFocus` on this
|
||||
// node when the slot becomes top, matching Navigator's per-route scope
|
||||
// handoff. Without per-slot scopes, a new page's `autofocus: true`
|
||||
// TextField silently no-ops (the outgoing page's focus still occupies the
|
||||
// shared scope's `_focusedChildren`), and without `setFirstFocus` the
|
||||
// keyboard token is never consumed so no keyboard appears.
|
||||
final focusScope = FocusScopeNode();
|
||||
bool removing = false;
|
||||
|
||||
Widget build(BuildContext context) {
|
||||
final child = page is Listenable
|
||||
? ListenableBuilder(
|
||||
listenable: page as Listenable,
|
||||
builder: (context, _) => page.buildPresenter(context),
|
||||
)
|
||||
: page.buildPresenter(context);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
child: FocusScope(node: focusScope, child: child),
|
||||
builder: (context, child) =>
|
||||
page.buildTransition(context, controller, child!),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps the top screen entry and handles Android predictive back gestures.
|
||||
/// Drives the entry's [AnimationController] in response to gesture progress.
|
||||
class ScreenBackHandler extends StatefulWidget {
|
||||
const ScreenBackHandler({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.enabled,
|
||||
required this.onPop,
|
||||
});
|
||||
|
||||
final ScreenSlot entry;
|
||||
final bool enabled;
|
||||
final VoidCallback onPop;
|
||||
|
||||
@override
|
||||
State<ScreenBackHandler> createState() => ScreenBackHandlerState();
|
||||
}
|
||||
|
||||
class ScreenBackHandlerState extends State<ScreenBackHandler>
|
||||
with WidgetsBindingObserver {
|
||||
bool _gestureInProgress = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
bool handleStartBackGesture(PredictiveBackEvent backEvent) {
|
||||
if (!widget.enabled || backEvent.isButtonEvent) return false;
|
||||
_gestureInProgress = true;
|
||||
widget.entry.controller.value = 1.0 - backEvent.progress;
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
void handleUpdateBackGestureProgress(PredictiveBackEvent backEvent) {
|
||||
if (!_gestureInProgress) return;
|
||||
widget.entry.controller.value = 1.0 - backEvent.progress;
|
||||
}
|
||||
|
||||
@override
|
||||
void handleCommitBackGesture() {
|
||||
if (!_gestureInProgress) return;
|
||||
_gestureInProgress = false;
|
||||
widget.onPop();
|
||||
}
|
||||
|
||||
@override
|
||||
void handleCancelBackGesture() {
|
||||
if (!_gestureInProgress) return;
|
||||
_gestureInProgress = false;
|
||||
widget.entry.controller.animateTo(1.0,
|
||||
duration: const Duration(milliseconds: 150), curve: Curves.easeOut);
|
||||
}
|
||||
|
||||
// ── iOS edge swipe ──
|
||||
|
||||
bool _swiping = false;
|
||||
double _swipeStart = 0;
|
||||
|
||||
static const _edgeWidth = 40.0;
|
||||
|
||||
void _onHorizontalDragUpdate(DragUpdateDetails d) {
|
||||
if (!widget.enabled) return;
|
||||
if (!_swiping) {
|
||||
_swiping = true;
|
||||
_swipeStart = 0;
|
||||
}
|
||||
final width = context.size?.width ?? 400;
|
||||
final progress = ((d.localPosition.dx - _swipeStart) / width).clamp(0.0, 1.0);
|
||||
widget.entry.controller.value = 1.0 - progress;
|
||||
}
|
||||
|
||||
void _onHorizontalDragEnd(DragEndDetails d) {
|
||||
if (!_swiping) return;
|
||||
_swiping = false;
|
||||
if (widget.entry.controller.value < 0.5 || d.velocity.pixelsPerSecond.dx > 300) {
|
||||
widget.onPop();
|
||||
} else {
|
||||
widget.entry.controller.animateTo(1.0,
|
||||
duration: const Duration(milliseconds: 150), curve: Curves.easeOut);
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isIOSLike {
|
||||
final platform = Theme.of(context).platform;
|
||||
return platform == TargetPlatform.iOS || platform == TargetPlatform.macOS;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: [
|
||||
widget.entry.build(context),
|
||||
// Left edge swipe strip — sits on top to catch horizontal drags
|
||||
// without competing with the page's scroll views.
|
||||
if (_isIOSLike && widget.enabled && widget.entry.page.canSwipeBack)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: _edgeWidth,
|
||||
child: GestureDetector(
|
||||
onHorizontalDragUpdate: _onHorizontalDragUpdate,
|
||||
onHorizontalDragEnd: _onHorizontalDragEnd,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
182
lib/src/navi/sheet_screen.dart
Normal file
182
lib/src/navi/sheet_screen.dart
Normal file
@@ -0,0 +1,182 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:ux/src/core/dispose.dart';
|
||||
import 'package:ux/src/core/list_emitter.dart';
|
||||
import 'package:ux/src/navi/screen.dart';
|
||||
import 'package:ux/src/navi/screen_host.dart';
|
||||
import 'package:ux/src/navi/screen_stack.dart';
|
||||
import 'package:ux/src/navi/transitions.dart';
|
||||
import 'package:ux/src/anim/sheet.dart';
|
||||
|
||||
/// Screen mixin that presents its content as a half-sheet with full
|
||||
/// telegram-style physics (drag-to-expand, drag-to-dismiss with spring
|
||||
/// snap, scroll coordination via [SheetController]) and a nested
|
||||
/// navigation stack inside the modal — same pattern as telegram-iOS's
|
||||
/// `AttachmentController` hosting a `NavigationContainer`.
|
||||
///
|
||||
/// The screen-stack's transition animation is set to instant
|
||||
/// (`ScreenTransitions.none`) — the [Sheet] widget self-manages its
|
||||
/// own slide-up entry / exit using its internal animation controller.
|
||||
/// This keeps the route layer thin while preserving the full physics
|
||||
/// model from `~/projects/ciao/app/lib/widgets/sheet.dart`.
|
||||
///
|
||||
/// Inside the sheet, [host] resolves to this sheet's own
|
||||
/// [nestedHost], so `host?.push(SomePage())` from the body lands in
|
||||
/// a nested `ScreenStack` rendered inside the sheet's bounds (slides
|
||||
/// in from the right). The original parent host that pushed this
|
||||
/// sheet is reachable as [parentHost] for rare escape cases.
|
||||
///
|
||||
/// Typical use:
|
||||
///
|
||||
/// ```dart
|
||||
/// class MyPage with Screen<Result>, SheetScreen<Result>, Emitter {
|
||||
/// @override
|
||||
/// double get sheetCollapsedExtent => 0.55;
|
||||
///
|
||||
/// @override
|
||||
/// Widget buildSheetBody(BuildContext context) => /* page content */;
|
||||
/// }
|
||||
///
|
||||
/// // Pushing within the sheet:
|
||||
/// final picked = await host?.push(SubPage());
|
||||
/// ```
|
||||
mixin SheetScreen<T> on Screen<T> {
|
||||
/// Initial height as a fraction of the viewport (0.0–1.0).
|
||||
/// Defaults to 0.55 — telegram's standard half-sheet.
|
||||
double get sheetCollapsedExtent => 0.55;
|
||||
|
||||
/// Whether the backdrop tap and drag-down past threshold dismiss
|
||||
/// the sheet. When false, only programmatic [pop] closes it.
|
||||
bool get sheetIsDismissible => true;
|
||||
|
||||
/// When true, the sheet enters at full screen and never collapses.
|
||||
/// Drag-to-dismiss is gated to a higher distance threshold.
|
||||
bool get sheetIsFullSize => false;
|
||||
|
||||
/// Backdrop scrim color. Sheet fades alpha from 0 (offscreen) to
|
||||
/// `barrierColor.a` (fully collapsed).
|
||||
Color get sheetBarrierColor => const Color(0x8C000000);
|
||||
|
||||
/// Sheet background. Override for theme-driven colors.
|
||||
Color? get sheetBackgroundColor => null;
|
||||
|
||||
/// Top-corner radius. Bottom corners shrink toward 0 as the sheet
|
||||
/// expands.
|
||||
double get sheetBorderRadius => 16.0;
|
||||
|
||||
/// Scroll-coordination controller. Inner scroll views attach via
|
||||
/// `SheetController.attach(scrollController)` and use
|
||||
/// `controller.physics` so the sheet drag and the list scroll
|
||||
/// hand off cleanly at scroll-top.
|
||||
late final SheetController sheetController = SheetController();
|
||||
|
||||
/// Override to provide the sheet's content. Renders below the
|
||||
/// drag-handle pill (`SheetThumb`) as the home of the sheet's
|
||||
/// nested navigation stack.
|
||||
Widget buildSheetBody(BuildContext context);
|
||||
|
||||
/// Inner navigation host for this sheet's nested stack. Pushes
|
||||
/// from inside the sheet body land here.
|
||||
late final _SheetNestedHost _nestedHost = _SheetNestedHost();
|
||||
|
||||
/// Public-typed view of the sheet's nested host.
|
||||
ScreenHost get nestedHost => _nestedHost;
|
||||
|
||||
// ── Screen<T> overrides ────────────────────────────────────────────
|
||||
|
||||
/// Pushes from inside this sheet body target the nested stack,
|
||||
/// not the parent host that pushed the sheet. Use [parentHost]
|
||||
/// (inherited from `Screen`) to escape the modal — rare.
|
||||
@override
|
||||
ScreenHost? get host => _nestedHost;
|
||||
|
||||
@override
|
||||
Widget buildTransition(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Widget child,
|
||||
) =>
|
||||
ScreenTransitions.none(context, animation, child);
|
||||
|
||||
/// Zero-duration route transition — the [Sheet] widget self-manages
|
||||
/// its slide-up entry via its own internal animation controller.
|
||||
@override
|
||||
Duration get transitionDuration => Duration.zero;
|
||||
|
||||
@override
|
||||
Widget buildPresenter(BuildContext context) {
|
||||
return Sheet(
|
||||
controller: sheetController,
|
||||
collapsedExtent: sheetCollapsedExtent,
|
||||
isFullSize: sheetIsFullSize,
|
||||
isDismissible: sheetIsDismissible,
|
||||
barrierColor: sheetBarrierColor,
|
||||
backgroundColor: sheetBackgroundColor,
|
||||
borderRadius: sheetBorderRadius,
|
||||
onDismiss: pop,
|
||||
// Fresh `_SheetBody` per rebuild is fine — `ScreenStack`
|
||||
// reconciles `home` by type+key.
|
||||
child: ListenableBuilder(
|
||||
listenable: _nestedHost.pages,
|
||||
builder: (context, _) => ScreenStack(
|
||||
home: _SheetBody(this),
|
||||
stack: [..._nestedHost.pages],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// System back / iOS edge swipe pops the innermost nested page
|
||||
/// first. With the nested stack empty, falls through to the
|
||||
/// default `handleBack` (which pops the sheet itself).
|
||||
@override
|
||||
bool handleBack() {
|
||||
if (_nestedHost.pages.isNotEmpty) {
|
||||
_nestedHost.pages.last.pop();
|
||||
return true;
|
||||
}
|
||||
return super.handleBack();
|
||||
}
|
||||
|
||||
@override
|
||||
void onRemoved() {
|
||||
super.onRemoved();
|
||||
// Tear down any still-mounted nested entries when the sheet is
|
||||
// dismissed (drag, backdrop tap, programmatic pop).
|
||||
for (final page in _nestedHost.pages.toList()) {
|
||||
page.pop();
|
||||
}
|
||||
_nestedHost.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _SheetNestedHost with Dispose implements ScreenHost {
|
||||
final pages = ListEmitter<Screen>();
|
||||
|
||||
@override
|
||||
Future<R?> push<R>(Screen<R> page) {
|
||||
page.parentHost = this;
|
||||
page.detach = () => pages.removeWhere((p) => identical(p, page));
|
||||
page.removed = () => Dispose.object(page);
|
||||
page.onPush();
|
||||
pages.add(page);
|
||||
return page.future;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
pages.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapter that exposes [SheetScreen.buildSheetBody] as the nested
|
||||
/// `ScreenStack`'s `home`.
|
||||
class _SheetBody with Screen<void> {
|
||||
_SheetBody(this.sheet);
|
||||
final SheetScreen sheet;
|
||||
|
||||
@override
|
||||
Widget buildPresenter(BuildContext context) =>
|
||||
sheet.buildSheetBody(context);
|
||||
}
|
||||
70
lib/src/navi/transitions.dart
Normal file
70
lib/src/navi/transitions.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Built-in transition builders for use in [Screen.buildTransition].
|
||||
abstract final class ScreenTransitions {
|
||||
/// Slide from right (push) / slide to right (pop).
|
||||
static Widget platformSlide(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Widget child,
|
||||
) {
|
||||
final position = Tween<Offset>(
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut));
|
||||
return SlideTransition(position: position, child: child);
|
||||
}
|
||||
|
||||
/// Simple opacity fade.
|
||||
static Widget fade(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Widget child,
|
||||
) {
|
||||
return FadeTransition(
|
||||
opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
/// Slide up from the bottom edge.
|
||||
static Widget slideUp(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Widget child,
|
||||
) {
|
||||
final position = Tween<Offset>(
|
||||
begin: const Offset(0, 1),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut));
|
||||
return SlideTransition(position: position, child: child);
|
||||
}
|
||||
|
||||
/// Fade on wide screens, slide on narrow. Stable widget tree — no
|
||||
/// unmount/remount when the screen width crosses the threshold.
|
||||
static Widget responsive(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Widget child, {
|
||||
double breakpoint = 600,
|
||||
}) {
|
||||
final wide = MediaQuery.sizeOf(context).width >= breakpoint;
|
||||
final curved = CurvedAnimation(parent: animation, curve: Curves.easeOut);
|
||||
final isExiting = animation.status == AnimationStatus.reverse;
|
||||
final position = Tween<Offset>(
|
||||
begin: wide ? Offset.zero : const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(curved);
|
||||
return FadeTransition(
|
||||
opacity: wide && !isExiting ? curved : const AlwaysStoppedAnimation(1.0),
|
||||
child: SlideTransition(position: position, child: child),
|
||||
);
|
||||
}
|
||||
|
||||
/// No animation — instant swap.
|
||||
static Widget none(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Widget child,
|
||||
) => child;
|
||||
}
|
||||
Reference in New Issue
Block a user