diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index d535ff3637..12e133f5aa 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -1,5 +1,7 @@ import 'package:duo_kit/duo_kit.dart'; + import 'duo_toolbar.dart'; + import 'dart:math' show max; import 'package:flutter/material.dart'; @@ -312,6 +314,41 @@ class FrostedAppBar extends StatelessWidget { ); } final offset = IosNavigationScrollScope.maybeOf(context); + // The bar reads the scroll offset itself, so scrolling rebuilds only + // the frame around it, not its native payload. + final nativeBar = IosNavigationBar( + centerTitleInPane: strip != null && !nativeLargeTitle, + titleInsets: nativeTitleInsets == EdgeInsets.zero + ? railInsets + : nativeTitleInsets, + alignCollapsedTitleToLeading: strip != null && nativeLargeTitle, + horizontalControls: + strip != null && + ((leadingAction?.keepInHeaderOnDuo ?? false) || + trailingActions.any((action) => action.keepInHeaderOnDuo)), + onReadyChanged: onNativeReadyChanged, + title: nativeTitle ?? (title is Text ? (title as Text).data ?? '' : ''), + subtitle: nativeSubtitle, + ephemeralLabel: nativeEphemeralLabel, + titleAvatar: nativeTitleAvatar, + titleAvatars: nativeTitleAvatars, + titleMaxWidth: nativeTitleMaxWidth, + titlePresenceColor: nativeTitlePresenceColor, + titleRefreshing: nativeTitleRefreshing, + onTitlePressed: onNativeTitlePressed, + largeTitle: nativeLargeTitle, + alwaysFrosted: alwaysFrosted || strip != null, + foregroundColor: iconColor, + leading: strip == null || (leadingAction?.keepInHeaderOnDuo ?? false) + ? leadingAction + : null, + onBack: strip == null ? onBack : null, + actions: strip == null + ? trailingActions + : trailingActions + .where((action) => action.keepInHeaderOnDuo) + .toList(), + ); Widget buildNative(double scrollOffset) { // Scrolling tracks the finger directly; only the search focus change // uses the coordinated layout transition. @@ -345,46 +382,7 @@ class FrostedAppBar extends StatelessWidget { left: 0, right: 0, height: barHeight, - child: IosNavigationBar( - centerTitleInPane: strip != null && !nativeLargeTitle, - titleInsets: nativeTitleInsets == EdgeInsets.zero - ? railInsets - : nativeTitleInsets, - alignCollapsedTitleToLeading: - strip != null && nativeLargeTitle, - horizontalControls: - strip != null && - ((leadingAction?.keepInHeaderOnDuo ?? false) || - trailingActions.any( - (action) => action.keepInHeaderOnDuo, - )), - onReadyChanged: onNativeReadyChanged, - title: - nativeTitle ?? - (title is Text ? (title as Text).data ?? '' : ''), - subtitle: nativeSubtitle, - ephemeralLabel: nativeEphemeralLabel, - titleAvatar: nativeTitleAvatar, - titleAvatars: nativeTitleAvatars, - titleMaxWidth: nativeTitleMaxWidth, - titlePresenceColor: nativeTitlePresenceColor, - titleRefreshing: nativeTitleRefreshing, - onTitlePressed: onNativeTitlePressed, - largeTitle: nativeLargeTitle, - alwaysFrosted: alwaysFrosted || strip != null, - foregroundColor: iconColor, - leading: - strip == null || - (leadingAction?.keepInHeaderOnDuo ?? false) - ? leadingAction - : null, - onBack: strip == null ? onBack : null, - actions: strip == null - ? trailingActions - : trailingActions - .where((action) => action.keepInHeaderOnDuo) - .toList(), - ), + child: nativeBar, ), if (bottom != null) AnimatedPositioned( diff --git a/mobile/test/shared/widgets/ios_navigation_bar_test.dart b/mobile/test/shared/widgets/ios_navigation_bar_test.dart index 2f487e1fd6..23c8110804 100644 --- a/mobile/test/shared/widgets/ios_navigation_bar_test.dart +++ b/mobile/test/shared/widgets/ios_navigation_bar_test.dart @@ -379,6 +379,41 @@ void main() { }); } + testWidgets('scrolling does not rebuild the native bar', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _testApp( + home: FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Conversation')), + body: ListView( + controller: controller, + children: [ + for (var i = 0; i < 40; i++) + SizedBox(height: 60, child: Text('Message $i')), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + final before = tester.widget( + find.byType(IosNavigationBar), + ); + controller.jumpTo(300); + await tester.pumpAndSettle(); + // The same widget instance is not rebuilt, so its native payload is not + // encoded again for every scrolled frame. + expect( + tester.widget(find.byType(IosNavigationBar)), + same(before), + ); + await tester.pumpWidget(const SizedBox()); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('native material follows live theme surface changes', ( tester, ) async {