import 'package:flutter/material.dart'; import 'common.dart'; /// Builds header content for a given collapse progress: 0 fully open, 1 fully /// collapsed. typedef CollapsingHeaderBuilder = Widget Function(BuildContext context, double t); /// The pinned brand header used by the main tabs. /// /// It stays on screen while the content scrolls beneath it, and shrinks from /// [expandedHeight] to [collapsedHeight] on the way. Pinning a tall header at /// full size would cost a quarter of a phone screen for the whole session; /// collapsing keeps the context and gives the space back. class KrowSliverHeader extends StatelessWidget { const KrowSliverHeader({ super.key, required this.expandedHeight, required this.collapsedHeight, required this.builder, }); /// Heights exclude the status bar, which is added for you. final double expandedHeight; final double collapsedHeight; final CollapsingHeaderBuilder builder; @override Widget build(BuildContext context) { final top = MediaQuery.paddingOf(context).top; return SliverPersistentHeader( pinned: true, delegate: _HeaderDelegate( minHeight: collapsedHeight + top, maxHeight: expandedHeight + top, topInset: top, builder: builder, ), ); } } class _HeaderDelegate extends SliverPersistentHeaderDelegate { _HeaderDelegate({ required this.minHeight, required this.maxHeight, required this.topInset, required this.builder, }); final double minHeight; final double maxHeight; final double topInset; final CollapsingHeaderBuilder builder; @override double get minExtent => minHeight; @override double get maxExtent => maxHeight; @override Widget build(BuildContext context, double shrinkOffset, bool overlaps) { final range = maxHeight - minHeight; final t = range <= 0 ? 1.0 : (shrinkOffset / range).clamp(0.0, 1.0); return BrandSurface( child: Padding( padding: EdgeInsets.only(top: topInset), child: builder(context, t), ), ); } @override bool shouldRebuild(_HeaderDelegate old) => old.minHeight != minHeight || old.maxHeight != maxHeight || old.topInset != topInset || old.builder != builder; } /// Cross-fades a header between its open and collapsed states. /// /// The open content keeps its natural size and is clipped as the header /// shrinks — letting it compress instead would squash the type on the way /// down, and would overflow before it finished fading. class HeaderCrossFade extends StatelessWidget { const HeaderCrossFade({ super.key, required this.t, required this.expandedHeight, required this.expanded, required this.collapsed, }); final double t; final double expandedHeight; final Widget expanded; final Widget collapsed; @override Widget build(BuildContext context) { // The open state clears out over the first two thirds; the collapsed bar // arrives over the last half. They overlap briefly, which reads as one // state becoming the other rather than a swap. final openOpacity = (1 - t * 1.5).clamp(0.0, 1.0); final shutOpacity = ((t - 0.5) * 2).clamp(0.0, 1.0); return Stack( fit: StackFit.expand, children: [ if (openOpacity > 0) ClipRect( child: OverflowBox( alignment: Alignment.topCenter, minHeight: 0, // Unbounded, so content taller than the header lays out at its // natural size and is clipped. Capping it here instead would // throw a layout overflow — and one only visible in a debug // build, so it reaches a device before it reaches you. maxHeight: double.infinity, child: Opacity( opacity: openOpacity, child: IgnorePointer(ignoring: t > 0.5, child: expanded), ), ), ), if (shutOpacity > 0) Opacity( opacity: shutOpacity, child: IgnorePointer(ignoring: t < 0.5, child: collapsed), ), ], ); } }