From 2f792c2f6b15d81dc67856168247b5ca2c5656c8 Mon Sep 17 00:00:00 2001 From: Michael Fransen Date: Thu, 12 Nov 2026 13:11:49 -0800 Subject: [PATCH] fix(mobile): isolate channel header scroll rebuilds (#9400) ## Summary Improve scrolling performance in the Messages channel list by avoiding unnecessary inventory rebuilds as the header moves. Keep scroll-driven header state in a notifier and retain the inventory while the header animates. Provider and parent updates still rebuild the inventory with current channel data. This complements the lazy-row optimization in #9401. In manual iPhone runs with 500 channels, adding lazy rows on top of header isolation reduced maximum observed Flutter UI work from **65.5 ms to 4.2 ms**. That measurement reflects the added lazy-row change; no standalone performance gain was established for header isolation. This is an independent production-only change. Test fixtures and diagnostic instrumentation are excluded; regression checks were run locally and are not included in this PR. ### Related issue Related: #5911 addresses scrolling behavior inside conversations. This change addresses header-triggered rebuilds in the Messages channel inventory. No matching open PR or issue was identified in the focused duplicate search. ### Testing Exercised Messages on a physical iPhone with 500 channels against an isolated local relay. Header collapse and channel updates worked. This change alone did not remove the large scroll stalls; lazy section rows are a separate change. The device run preceded the refresh onto current main. Local-only regression checks passed 10 tests on this independent branch. They verify stable inventory identity during header movement and visible channel updates. These checks are excluded from the commit. Remaining visible jumps are under investigation; this PR does not claim to resolve them. Generated with Codex Signed-off-by: Michael Fransen Co-authored-by: Karl <9e8471f7b792e46c9c100840959dc71c660786c79c2a3165dbcaf58f52c4266e@buzz.block.builderlab.xyz> --- .../lib/features/channels/channels_page.dart | 356 +++++++++--------- 1 file changed, 185 insertions(+), 171 deletions(-) diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index acfe94be4b..c3058e218b 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -217,8 +217,11 @@ class ChannelsPage extends HookConsumerWidget { final communityFlightActive = useState(false); final channelsScrollController = useScrollController(); final reducedMotion = MediaQuery.disableAnimationsOf(context); - final headerFrostProgress = useState(0.0); - final headerCollapse = useState(0.0); + // Scroll updates belong to the header, not the channel inventory below it. + final headerTreatment = useValueNotifier(( + collapse: 0.0, + frostProgress: 0.0, + )); useEffect( () { void updateHeaderTreatment() { @@ -230,15 +233,20 @@ class ChannelsPage extends HookConsumerWidget { : channelsScrollController.offset .clamp(0.0, filterContentHeight) .toDouble(); - if (headerCollapse.value != collapse) headerCollapse.value = collapse; final nextProgress = !channelsScrollController.hasClients ? 0.0 : (channelsScrollController.offset / _kHeaderFrostScrollDistance) .clamp(0.0, 1.0) .toDouble(); - if ((headerFrostProgress.value - nextProgress).abs() > 0.001) { - headerFrostProgress.value = nextProgress; - } + final previous = headerTreatment.value; + final frostProgress = + (previous.frostProgress - nextProgress).abs() > 0.001 + ? nextProgress + : previous.frostProgress; + headerTreatment.value = ( + collapse: collapse, + frostProgress: frostProgress, + ); } channelsScrollController.addListener(updateHeaderTreatment); @@ -640,171 +648,11 @@ class ChannelsPage extends HookConsumerWidget { return () => active = false; }, [newChannelAction, newDmAction, currentPubkey]); - return FrostedScaffold( - backgroundColor: usesPinnedGradient - ? Colors.transparent - : context.colors.surface, - backgroundGradient: topSectionGradient, - appBar: FrostedAppBar( - key: headerKey, - onNativeReadyChanged: (ready) => nativeHeaderReady.value = ready, - nativeTitle: nativeDuoTitle ? 'Messages' : '', - useNativeNavigation: messagesFilters == null, - nativeViewSuppressed: headerNativeSuppressed, - nativeLeading: messagesFilters != null - ? null - : IosNavigationAction( - label: 'Community settings', - onAvatarBoundsChanged: (bounds) { - final header = headerKey.currentContext?.findRenderObject(); - if (header is RenderBox) { - // Store native coordinates, then apply the current Home transform - // when measuring the destination immediately before departure. - nativeCommunityAvatarBounds.value = - header.globalToLocal(bounds.topLeft) & bounds.size; - } - }, - avatarHidden: communityFlightActive.value, - avatarIdentity: activeCommunity?.id, - symbol: 'building.2.crop.circle', - imageUrl: communityAvatar, - avatarInitial: communityName.isEmpty - ? '?' - : communityName.substring(0, 1).toUpperCase(), - onPressed: openCommunitySwitcher, - ), - nativeActions: messagesFilters != null - ? const [] - : [ - if (onSearch != null) - IosNavigationAction( - label: 'Search', - symbol: 'magnifyingglass', - sharesBackground: false, - icon: BuzzIcons.search300, - onPressed: onSearch, - ), - IosNavigationAction( - label: 'Create', - symbol: 'plus', - sharesBackground: false, - icon: BuzzIcons.plus, - children: [ - IosNavigationAction( - label: 'New channel', - symbol: 'number', - onPressed: () => - openCreateAction(_QuickAction.createChannel), - ), - IosNavigationAction( - label: 'New DM', - symbol: 'bubble.left', - onPressed: () => openCreateAction(_QuickAction.newDm), - ), - ], - ), - ], - horizontalInset: _kTopSectionInset, - // Let the full Buzz gradient show at rest. Once the list begins to - // move beneath this row, build up blur over the first 64dp of scroll - // without adding the usual white frosted wash. The Buzz list is - // transparent, so the blurred pixels remain a continuation of the - // pinned gradient instead of turning into a white header. - frosted: - messagesFilters != null || - !usesPinnedGradient || - headerFrostProgress.value > 0, - alwaysFrosted: messagesFilters != null, - leading: messagesFilters != null - ? null - : _CommunityIndicator( - onTap: openCommunitySwitcher, - avatarKey: communityAvatarKey, - hidden: communityFlightActive.value, - ), - centerTitle: false, - titleStyle: headerTitleStyle, - - automaticallyImplyLeading: messagesFilters == null, - title: messagesFilters == null - ? const SizedBox.shrink() - : ExcludeSemantics( - excluding: headerCollapse.value >= filterContentHeight, - child: ShaderMask( - blendMode: BlendMode.dstIn, - shaderCallback: (bounds) => LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - headerCollapse.value == 0 - ? Colors.white - : Colors.transparent, - Colors.white, - ], - stops: [ - 0, - (headerCollapse.value.clamp(0.0, Grid.sm) / bounds.height) - .clamp(0.0, 1.0), - ], - ).createShader(bounds), - // Keep the original upward motion, but fade the glyphs at - // status-area boundary instead of the text’s own tight bounds. - child: ClipRect( - child: Opacity( - opacity: 1 - headerCollapse.value / filterContentHeight, - child: SizedBox.expand( - child: Align( - alignment: Alignment.centerLeft, - child: Transform.translate( - offset: Offset(0, -headerCollapse.value), - child: Text('Messages', style: headerTitleStyle), - ), - ), - ), - ), - ), - ), - ), - actions: messagesFilters != null - ? messagesActionsBuilder?.call( - openCommunityGrid, - communityAvatarKey, - ) ?? - const [] - : [ - _ChannelHeaderActions( - onSearch: onSearch, - onCreate: openCreateMenu, - ), - ], - bottomHeight: messagesFilters == null - ? _kTopSectionBottomPadding - : filterContentHeight - headerCollapse.value, - bottomOverlap: messagesFilters == null ? 0 : headerCollapse.value, - bottom: messagesFilters == null - ? const SizedBox.expand() - : Padding( - padding: EdgeInsets.only( - // As soon as the rows overlap, keep the entire action - // cluster clear. Interpolating this inset blocks the bell - // during partial collapse even when the final layout fits. - right: headerCollapse.value > 0 - ? messagesActionsWidth + _kTopSectionInset - : 0, - ), - child: ClipRect( - // Native glass paints its shadows outside the viewport, but - // scrolled filters must not paint over the header actions. - clipBehavior: headerCollapse.value > 0 - ? Clip.hardEdge - : Clip.none, - child: messagesFilters!( - headerCollapse.value >= filterContentHeight, - ), - ), - ), - ), - body: _ChannelsBody( + return ValueListenableBuilder( + valueListenable: headerTreatment, + // Retain the inventory during header animation. Provider and parent + // updates still construct a fresh body with current channel data. + child: _ChannelsBody( soloSliver: soloSliver, showTeam: showTeam, showTeamHeading: showTeamHeading, @@ -825,6 +673,172 @@ class ChannelsPage extends HookConsumerWidget { }, onSelectChannel: openChannel, ), + builder: (context, treatment, child) => FrostedScaffold( + backgroundColor: usesPinnedGradient + ? Colors.transparent + : context.colors.surface, + backgroundGradient: topSectionGradient, + appBar: FrostedAppBar( + key: headerKey, + onNativeReadyChanged: (ready) => nativeHeaderReady.value = ready, + nativeTitle: nativeDuoTitle ? 'Messages' : '', + useNativeNavigation: messagesFilters == null, + nativeViewSuppressed: headerNativeSuppressed, + nativeLeading: messagesFilters != null + ? null + : IosNavigationAction( + label: 'Community settings', + onAvatarBoundsChanged: (bounds) { + final header = headerKey.currentContext?.findRenderObject(); + if (header is RenderBox) { + // Store native coordinates, then apply the current Home transform + // when measuring the destination immediately before departure. + nativeCommunityAvatarBounds.value = + header.globalToLocal(bounds.topLeft) & bounds.size; + } + }, + avatarHidden: communityFlightActive.value, + avatarIdentity: activeCommunity?.id, + symbol: 'building.2.crop.circle', + imageUrl: communityAvatar, + avatarInitial: communityName.isEmpty + ? '?' + : communityName.substring(0, 1).toUpperCase(), + onPressed: openCommunitySwitcher, + ), + nativeActions: messagesFilters != null + ? const [] + : [ + if (onSearch != null) + IosNavigationAction( + label: 'Search', + symbol: 'magnifyingglass', + sharesBackground: false, + icon: BuzzIcons.search300, + onPressed: onSearch, + ), + IosNavigationAction( + label: 'Create', + symbol: 'plus', + sharesBackground: false, + icon: BuzzIcons.plus, + children: [ + IosNavigationAction( + label: 'New channel', + symbol: 'number', + onPressed: () => + openCreateAction(_QuickAction.createChannel), + ), + IosNavigationAction( + label: 'New DM', + symbol: 'bubble.left', + onPressed: () => openCreateAction(_QuickAction.newDm), + ), + ], + ), + ], + horizontalInset: _kTopSectionInset, + // Let the full Buzz gradient show at rest. Once the list begins to + // move beneath this row, build up blur over the first 64dp of scroll + // without adding the usual white frosted wash. The Buzz list is + // transparent, so the blurred pixels remain a continuation of the + // pinned gradient instead of turning into a white header. + frosted: + messagesFilters != null || + !usesPinnedGradient || + treatment.frostProgress > 0, + alwaysFrosted: messagesFilters != null, + leading: messagesFilters != null + ? null + : _CommunityIndicator( + onTap: openCommunitySwitcher, + avatarKey: communityAvatarKey, + hidden: communityFlightActive.value, + ), + centerTitle: false, + titleStyle: headerTitleStyle, + + automaticallyImplyLeading: messagesFilters == null, + title: messagesFilters == null + ? const SizedBox.shrink() + : ExcludeSemantics( + excluding: treatment.collapse >= filterContentHeight, + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + treatment.collapse == 0 + ? Colors.white + : Colors.transparent, + Colors.white, + ], + stops: [ + 0, + (treatment.collapse.clamp(0.0, Grid.sm) / bounds.height) + .clamp(0.0, 1.0), + ], + ).createShader(bounds), + // Keep the original upward motion, but fade the glyphs at + // status-area boundary instead of the text’s own tight bounds. + child: ClipRect( + child: Opacity( + opacity: 1 - treatment.collapse / filterContentHeight, + child: SizedBox.expand( + child: Align( + alignment: Alignment.centerLeft, + child: Transform.translate( + offset: Offset(0, -treatment.collapse), + child: Text('Messages', style: headerTitleStyle), + ), + ), + ), + ), + ), + ), + ), + actions: messagesFilters != null + ? messagesActionsBuilder?.call( + openCommunityGrid, + communityAvatarKey, + ) ?? + const [] + : [ + _ChannelHeaderActions( + onSearch: onSearch, + onCreate: openCreateMenu, + ), + ], + bottomHeight: messagesFilters == null + ? _kTopSectionBottomPadding + : filterContentHeight - treatment.collapse, + bottomOverlap: messagesFilters == null ? 0 : treatment.collapse, + bottom: messagesFilters == null + ? const SizedBox.expand() + : Padding( + padding: EdgeInsets.only( + // As soon as the rows overlap, keep the entire action + // cluster clear. Interpolating this inset blocks the bell + // during partial collapse even when the final layout fits. + right: treatment.collapse > 0 + ? messagesActionsWidth + _kTopSectionInset + : 0, + ), + child: ClipRect( + // Native glass paints its shadows outside the viewport, but + // scrolled filters must not paint over the header actions. + clipBehavior: treatment.collapse > 0 + ? Clip.hardEdge + : Clip.none, + child: messagesFilters!( + treatment.collapse >= filterContentHeight, + ), + ), + ), + ), + body: child!, + ), ); } }