mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
fix(mobile): prevent iOS status bar scroll to top during transitions (#30717)
This commit is contained in:
parent
db9e7c20d7
commit
b82d480552
4 changed files with 40 additions and 5 deletions
|
|
@ -277,7 +277,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
||||||
),
|
),
|
||||||
routerConfig: router.config(
|
routerConfig: router.config(
|
||||||
deepLinkBuilder: _deepLinkBuilder,
|
deepLinkBuilder: _deepLinkBuilder,
|
||||||
navigatorObservers: () => [AppNavigationObserver(ref: ref)],
|
navigatorObservers: () => [AppNavigationObserver(ref: ref), TransitioningRouteObserver()],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
|
||||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
|
import 'package:immich_mobile/routing/app_navigation_observer.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
||||||
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
|
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
|
||||||
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
||||||
|
|
@ -190,7 +191,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
||||||
// may be in a background tab. In either case, `handleStatusBarTap()` still fires
|
// may be in a background tab. In either case, `handleStatusBarTap()` still fires
|
||||||
// Make sure the timeline is the primary route before scrolling to the top
|
// Make sure the timeline is the primary route before scrolling to the top
|
||||||
final routeData = context.findAncestorWidgetOfExactType<RouteDataScope>()?.routeData;
|
final routeData = context.findAncestorWidgetOfExactType<RouteDataScope>()?.routeData;
|
||||||
if (ModalRoute.of(context)?.isCurrent == true && routeData?.isActive == true) {
|
// The tap is generated async, so it can arrive after a route pop has started (due to a back button or similar)
|
||||||
|
// Check if route is alive and not exiting before taking action
|
||||||
|
final observers = Navigator.maybeOf(context)?.widget.observers ?? const <NavigatorObserver>[];
|
||||||
|
final isRouteTransitioning = observers.whereType<TransitioningRouteObserver>().any(
|
||||||
|
(observer) => observer.hasTransitioningRoute,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ModalRoute.of(context)?.isCurrent == true && routeData?.isActive == true && !isRouteTransitioning) {
|
||||||
_scrollToTop();
|
_scrollToTop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,3 +30,22 @@ class AppNavigationObserver extends AutoRouterObserver {
|
||||||
ref.invalidate(isAssetViewerOpenProvider);
|
ref.invalidate(isAssetViewerOpenProvider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tracks routes that are undergoing a pop transition
|
||||||
|
class TransitioningRouteObserver extends NavigatorObserver {
|
||||||
|
int _transitioningRoutes = 0;
|
||||||
|
|
||||||
|
/// Whether a "popping" route is still on screen
|
||||||
|
bool get hasTransitioningRoute => _transitioningRoutes > 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didPop(Route route, Route? previousRoute) {
|
||||||
|
if (route is! TransitionRoute) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_transitioningRoutes += 1;
|
||||||
|
// Transition completed and route disposed
|
||||||
|
unawaited(route.completed.whenComplete(() => _transitioningRoutes -= 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
|
import 'package:immich_mobile/routing/app_navigation_observer.dart';
|
||||||
|
|
||||||
import '../../../fixtures/asset.stub.dart';
|
import '../../../fixtures/asset.stub.dart';
|
||||||
|
|
||||||
|
|
@ -62,7 +63,9 @@ void main() {
|
||||||
timelineServiceProvider.overrideWithValue(service),
|
timelineServiceProvider.overrideWithValue(service),
|
||||||
appConfigProvider.overrideWithValue(const AppConfig()),
|
appConfigProvider.overrideWithValue(const AppConfig()),
|
||||||
],
|
],
|
||||||
child: MaterialApp.router(routerConfig: router.config()),
|
child: MaterialApp.router(
|
||||||
|
routerConfig: router.config(navigatorObservers: () => [TransitioningRouteObserver()]),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Segment stream resolves
|
// Segment stream resolves
|
||||||
|
|
@ -96,9 +99,14 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(position.pixels, initialScrolledPosition, reason: 'ignored while behind a pushed route');
|
expect(position.pixels, initialScrolledPosition, reason: 'ignored while behind a pushed route');
|
||||||
|
|
||||||
// Once the timeline is visible, taps on the status bar should scroll to the top
|
// The tap is async, so it can arrive while a pushed route is transitioning to popped
|
||||||
await router.maybePop();
|
unawaited(router.maybePop());
|
||||||
|
await tester.pump();
|
||||||
|
await tapStatusBar();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
expect(position.pixels, initialScrolledPosition, reason: 'ignored while the pushed route pops');
|
||||||
|
|
||||||
|
// Once the timeline is visible, taps on the status bar should scroll to the top
|
||||||
await tapStatusBar();
|
await tapStatusBar();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(position.pixels, 0, reason: 'scrolls the foreground timeline');
|
expect(position.pixels, 0, reason: 'scrolls the foreground timeline');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue