Files
krow-worker-app/lib/widgets/collapsing_header.dart
2026-09-17 22:26:11 +05:30

138 lines
4.1 KiB
Dart

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