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!, + ), ); } }