Skip to main content

frust_widgets/slivers/
persistent_header.rs

1//! `sliver_persistent_header`: a header that shrinks from a maximum to a
2//! minimum extent as it scrolls, and optionally stays pinned to the leading
3//! edge or floats back in on a reverse scroll — Flutter's
4//! `SliverPersistentHeader` (`rendering/sliver_persistent_header.dart`).
5//!
6//! The header is described by a [`SliverPersistentHeaderDelegate`]: a minimum
7//! and a maximum extent, and a `build(shrink_offset, overlaps_content)` that
8//! returns the header's content for how far it has shrunk. The header scrolls
9//! `max_extent` px of content; its child is laid out tight at
10//! `max(min_extent, max_extent − shrink_offset)` along the axis (tight, not
11//! Flutter's loose `asBoxConstraints(maxExtent: ..)`, so the child's extent and
12//! the geometry always agree) and at the viewport's cross extent across it.
13//!
14//! # The four behaviours
15//!
16//! * **Scrolling** (the default) — `RenderSliverScrollingPersistentHeader`:
17//!   `shrink_offset = min(scroll_offset, max_extent)`, the paint extent is
18//!   `max_extent − scroll_offset` and the child is pushed back by however much
19//!   of it no longer fits, so it shrinks to `min_extent` and then scrolls away
20//!   like a box.
21//! * **Pinned** ([`SliverPersistentHeaderView::pinned`]) —
22//!   `RenderSliverPinnedPersistentHeader`: the header never paints less than
23//!   `min_extent` (space permitting) and stays at the leading edge. Its layout
24//!   extent shrinks to `max_extent − scroll_offset` (down to zero) while its
25//!   paint extent does not, so the viewport reports the difference to the next
26//!   sliver as [`SliverConstraints::overlap`] and that sliver starts at the
27//!   header's layout extent, under the header (an earlier sliver paints over a
28//!   later one). The paint origin is the incoming `overlap`: zero for a first
29//!   header, below an earlier pinned header otherwise, so pinned headers stack.
30//!   `overlaps_content` is `overlap > 0`.
31//! * **Floating** ([`SliverPersistentHeaderView::floating`]) —
32//!   `RenderSliverFloatingPersistentHeader`: the header keeps an *effective*
33//!   scroll offset of its own. Scrolling toward the content's end moves it with
34//!   the content; scrolling back toward the start while the constraints'
35//!   [`user_scroll_direction`](SliverConstraints::user_scroll_direction) is
36//!   [`ScrollDirection::Forward`] reveals it at once, wherever the content is.
37//!   `shrink_offset = min(effective, max_extent)` and `overlaps_content` is
38//!   `effective < scroll_offset`. There is no snap: a half-revealed header stays
39//!   half revealed when the scroll stops.
40//! * **Floating and pinned** — `RenderSliverFloatingPinnedPersistentHeader`:
41//!   floating, but never painting less than `min_extent`.
42//!
43//! The viewport reports a direction only while user input drives the motion (a
44//! drag or its release fling); a wheel step, a controller jump or tween scrolls
45//! with [`ScrollDirection::Idle`], so they never reveal a floating header — they
46//! only move or hide it.
47//!
48//! # The shrink offset reaches the child at the next rebuild
49//!
50//! The child is a view, and views are built only in [`View::rebuild`], never
51//! during layout. Layout therefore *records* the shrink offset and
52//! `overlaps_content` it computed; paint requests a frame when they differ from
53//! what the live child was built with; and the next rebuild calls the
54//! delegate's `build` with the recorded values. Until then the child keeps its
55//! previous content but is already laid out at the new extent — so the content
56//! lags the geometry by one frame while the header resizes. A shrink-offset
57//! change below [`SHRINK_REBUILD_TOLERANCE`] does not rebuild at all (the child
58//! keeps the content built under the previous shrink offset), except a change
59//! onto either end of the range, `0` or `max_extent`, so a header resting fully
60//! expanded or fully shrunk is always built at exactly that end.
61//!
62//! `build` must be a pure function of its two arguments and captured state:
63//! rebuild reconstructs the previous child from the previous view's `build`
64//! with the values the live child was built with (the reconciliation
65//! [`ListView`](crate::ListView) uses for its rows), so it can run twice per
66//! rebuild.
67//!
68//! Laid out as a box (outside a viewport) the header is unscrolled: its child is
69//! laid out at `max_extent` along the axis of its last sliver layout (vertical
70//! before one), clamped into the incoming constraints.
71
72use std::rc::Rc;
73
74use frust_core::{
75    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
76    LayoutCtx, PaintCtx, PaintScene, ScrollAxis, ScrollDirection, SemanticsCtx, SliverConstraints,
77    SliverGeometry, SliverWidget, View, Widget, any,
78};
79use kurbo::{Point, Size};
80
81use super::adapter::{axis_point, finite_extent};
82
83/// How far, in logical px, the shrink offset may move away from the one the
84/// live child was built with before the header asks for a rebuild (the
85/// [module docs](self)' *The shrink offset reaches the child at the next
86/// rebuild*).
87pub const SHRINK_REBUILD_TOLERANCE: f64 = 0.5;
88
89/// A view-held header builder (`shrink_offset, overlaps_content -> content`),
90/// erased per build.
91type HeaderBuilder<State> = Rc<dyn Fn(f64, bool) -> AnyView<State>>;
92
93/// What a [`sliver_persistent_header`] shows: its extent range and how to
94/// build its content for a shrink offset — Flutter's
95/// `SliverPersistentHeaderDelegate`.
96///
97/// `build(shrink_offset, overlaps_content)` receives how far the header has
98/// shrunk from `max_extent` (`0` fully expanded, up to `max_extent`) and
99/// whether content is scrolling under it. An extent that is NaN, infinite or
100/// negative is treated as `0.0`, and a `max_extent` below `min_extent` as
101/// `min_extent`.
102pub struct SliverPersistentHeaderDelegate<F> {
103    /// The smallest extent the header shrinks to along the scroll axis.
104    pub min_extent: f64,
105    /// The extent of the fully expanded header along the scroll axis — also
106    /// how much the header scrolls.
107    pub max_extent: f64,
108    /// Builds the header's content for a shrink offset; see the type docs.
109    pub build: F,
110}
111
112impl<F> SliverPersistentHeaderDelegate<F> {
113    /// A delegate from its three parts.
114    pub fn new(min_extent: f64, max_extent: f64, build: F) -> Self {
115        Self {
116            min_extent,
117            max_extent,
118            build,
119        }
120    }
121}
122
123/// A sliver header with a min/max extent. See the [module docs](self).
124pub struct SliverPersistentHeaderView<State: 'static> {
125    min_extent: f64,
126    max_extent: f64,
127    builder: HeaderBuilder<State>,
128    pinned: bool,
129    floating: bool,
130}
131
132/// A header sliver that shrinks from `delegate.max_extent` to
133/// `delegate.min_extent` as it scrolls, its content rebuilt by
134/// `delegate.build` for the current shrink offset. It scrolls away like a box
135/// unless made [`pinned`](SliverPersistentHeaderView::pinned) or
136/// [`floating`](SliverPersistentHeaderView::floating). See the
137/// [module docs](self).
138///
139/// ```
140/// use frust_widgets::{
141///     CustomScrollView, SliverPersistentHeaderDelegate, custom_scroll_view,
142///     sliver_fixed_extent_list, sliver_persistent_header, text,
143/// };
144/// let header = sliver_persistent_header(SliverPersistentHeaderDelegate::new(
145///     56.0,
146///     200.0,
147///     |shrink: f64, _overlaps: bool| text(format!("shrunk by {shrink:.0}")),
148/// ))
149/// .pinned(true);
150/// let view: CustomScrollView<()> = custom_scroll_view((
151///     header,
152///     sliver_fixed_extent_list(100, 48.0, |i| text(format!("row {i}"))),
153/// ));
154/// # let _ = view;
155/// ```
156pub fn sliver_persistent_header<State, V, F>(
157    delegate: SliverPersistentHeaderDelegate<F>,
158) -> SliverPersistentHeaderView<State>
159where
160    State: 'static,
161    V: View<State>,
162    F: Fn(f64, bool) -> V + 'static,
163{
164    let min_extent = finite_extent(delegate.min_extent);
165    let max_extent = finite_extent(delegate.max_extent).max(min_extent);
166    let build = delegate.build;
167    SliverPersistentHeaderView {
168        min_extent,
169        max_extent,
170        builder: Rc::new(move |shrink, overlaps| any(build(shrink, overlaps))),
171        pinned: false,
172        floating: false,
173    }
174}
175
176impl<State: 'static> SliverPersistentHeaderView<State> {
177    /// Keep the header at the viewport's leading edge, never painting less
178    /// than `min_extent` (`false` by default). See the [module docs](self)'
179    /// *The four behaviours*.
180    pub fn pinned(mut self, pinned: bool) -> Self {
181        self.pinned = pinned;
182        self
183    }
184
185    /// Reveal the header as soon as the user scrolls back toward the content's
186    /// start, wherever the content is (`false` by default; no snap). See the
187    /// [module docs](self)' *The four behaviours*.
188    pub fn floating(mut self, floating: bool) -> Self {
189        self.floating = floating;
190        self
191    }
192
193    fn child_view(&self, inputs: BuildInputs) -> AnyView<State> {
194        (self.builder)(inputs.shrink_offset, inputs.overlaps_content)
195    }
196}
197
198/// The two values a header's content is built from.
199#[derive(Clone, Copy, Debug, PartialEq)]
200struct BuildInputs {
201    shrink_offset: f64,
202    overlaps_content: bool,
203}
204
205impl BuildInputs {
206    const EXPANDED: BuildInputs = BuildInputs {
207        shrink_offset: 0.0,
208        overlaps_content: false,
209    };
210}
211
212/// The retained widget for a [`SliverPersistentHeaderView`]: the content's
213/// pod, the build inputs it holds and the ones the last layout asked for, and
214/// a floating header's effective scroll offset.
215pub struct SliverPersistentHeaderWidget {
216    child: ChildPod,
217    min_extent: f64,
218    max_extent: f64,
219    pinned: bool,
220    floating: bool,
221    /// The axis of the last sliver layout (vertical before the first).
222    axis: ScrollAxis,
223    /// What the live child was built with.
224    built: BuildInputs,
225    /// What the last layout computed — what the next rebuild builds with when
226    /// it differs enough from `built` ([`Self::needs_rebuild`]).
227    wanted: BuildInputs,
228    /// A floating header's own scroll offset (Flutter's
229    /// `_effectiveScrollOffset`): how far it is scrolled out, which a reverse
230    /// scroll shrinks independently of the content.
231    effective_scroll_offset: f64,
232    /// The constraints' scroll offset at a floating header's last layout
233    /// (Flutter's `_lastActualScrollOffset`); `None` before the first.
234    last_scroll_offset: Option<f64>,
235}
236
237impl SliverPersistentHeaderWidget {
238    fn new<State: 'static>(view: &SliverPersistentHeaderView<State>, child: ChildPod) -> Self {
239        Self {
240            child,
241            min_extent: view.min_extent,
242            max_extent: view.max_extent,
243            pinned: view.pinned,
244            floating: view.floating,
245            axis: ScrollAxis::Vertical,
246            built: BuildInputs::EXPANDED,
247            wanted: BuildInputs::EXPANDED,
248            effective_scroll_offset: 0.0,
249            last_scroll_offset: None,
250        }
251    }
252
253    /// The shrink offset the last layout computed: how far the header has
254    /// shrunk from its maximum extent.
255    pub fn shrink_offset(&self) -> f64 {
256        self.wanted.shrink_offset
257    }
258
259    /// Whether the last layout found content scrolling under the header — the
260    /// `overlaps_content` the delegate's `build` receives.
261    pub fn overlaps_content(&self) -> bool {
262        self.wanted.overlaps_content
263    }
264
265    /// Whether the live child was built with inputs too far from the ones the
266    /// last layout computed (the [module docs](self)' *The shrink offset
267    /// reaches the child at the next rebuild*).
268    fn needs_rebuild(&self) -> bool {
269        let (built, wanted) = (self.built, self.wanted);
270        if built.overlaps_content != wanted.overlaps_content {
271            return true;
272        }
273        if built.shrink_offset == wanted.shrink_offset {
274            return false;
275        }
276        (built.shrink_offset - wanted.shrink_offset).abs() >= SHRINK_REBUILD_TOLERANCE
277            || wanted.shrink_offset <= 0.0
278            || wanted.shrink_offset >= self.max_extent
279    }
280
281    /// Lay the child out for `shrink_offset` — tight at
282    /// `max(min_extent, max_extent − shrink_offset)` along the axis — record
283    /// the build inputs, and return the child's main-axis extent.
284    fn layout_child_at(
285        &mut self,
286        ctx: &mut LayoutCtx,
287        sc: &SliverConstraints,
288        shrink_offset: f64,
289        overlaps_content: bool,
290    ) -> f64 {
291        self.wanted = BuildInputs {
292            shrink_offset,
293            overlaps_content,
294        };
295        let extent = self.min_extent.max(self.max_extent - shrink_offset);
296        let tight = BoxConstraints::tight(sc.axis.size(extent, sc.cross_axis_extent));
297        sc.axis.main_extent(self.child.layout_child(ctx, &tight))
298    }
299
300    /// Advance a floating header's effective scroll offset to `sc` —
301    /// `RenderSliverFloatingPersistentHeader.performLayout` without the snap.
302    ///
303    /// Once the header is scrolled wholly out (effective offset at or past
304    /// `max_extent`) the offset is kept at the constraints' scroll offset, the
305    /// value Flutter's own non-floating branch settles on: every value past
306    /// `max_extent` lays the header out identically, and pinning one keeps a
307    /// second layout under the same constraints a no-op, as the layout-skip
308    /// contract requires.
309    fn advance_floating(&mut self, sc: &SliverConstraints) {
310        let max = self.max_extent;
311        let scroll_offset = sc.scroll_offset;
312        match self.last_scroll_offset {
313            Some(last) if scroll_offset < last || self.effective_scroll_offset < max => {
314                let mut delta = last - scroll_offset;
315                if sc.user_scroll_direction == ScrollDirection::Forward {
316                    // Revealing: a header scrolled far out comes back from its
317                    // own edge, not from wherever the content is.
318                    self.effective_scroll_offset = self.effective_scroll_offset.min(max);
319                } else if delta > 0.0 {
320                    // Not a user scroll toward the start: shrinking is allowed,
321                    // expanding is not.
322                    delta = 0.0;
323                }
324                self.effective_scroll_offset =
325                    (self.effective_scroll_offset - delta).clamp(0.0, scroll_offset);
326            }
327            _ => self.effective_scroll_offset = scroll_offset,
328        }
329        if self.effective_scroll_offset >= max {
330            self.effective_scroll_offset = scroll_offset;
331        }
332        self.last_scroll_offset = Some(scroll_offset);
333    }
334
335    /// `RenderSliverScrollingPersistentHeader`'s layout.
336    fn layout_scrolling(&mut self, ctx: &mut LayoutCtx, sc: &SliverConstraints) -> SliverGeometry {
337        let max = self.max_extent;
338        let child_extent = self.layout_child_at(ctx, sc, sc.scroll_offset.min(max), false);
339        let paint = max - sc.scroll_offset;
340        self.place_child((paint.max(0.0) - child_extent).min(0.0), sc.axis);
341        SliverGeometry {
342            paint_origin: sc.overlap.min(0.0),
343            has_visual_overflow: true,
344            ..SliverGeometry::new(max, paint.clamp(0.0, sc.remaining_paint_extent), max)
345        }
346    }
347
348    /// `RenderSliverPinnedPersistentHeader`'s layout.
349    fn layout_pinned(&mut self, ctx: &mut LayoutCtx, sc: &SliverConstraints) -> SliverGeometry {
350        let max = self.max_extent;
351        let child_extent =
352            self.layout_child_at(ctx, sc, sc.scroll_offset.min(max), sc.overlap > 0.0);
353        self.place_child(0.0, sc.axis);
354        // The paint space left below the overlap — more than the remaining
355        // paint extent under a negative (overscroll) overlap, which the clamp
356        // to it keeps within the protocol.
357        let remaining = (sc.remaining_paint_extent - sc.overlap).max(0.0);
358        let paint = child_extent.min(remaining).min(sc.remaining_paint_extent);
359        let layout = (max - sc.scroll_offset).clamp(0.0, remaining).min(paint);
360        SliverGeometry {
361            paint_origin: sc.overlap,
362            layout_extent: layout,
363            has_visual_overflow: false,
364            cache_extent: if layout > 0.0 {
365                layout - sc.cache_origin
366            } else {
367                layout
368            },
369            ..SliverGeometry::new(max, paint, max)
370        }
371    }
372
373    /// `RenderSliverFloatingPersistentHeader`'s layout, and with `pinned`
374    /// `RenderSliverFloatingPinnedPersistentHeader`'s.
375    fn layout_floating(&mut self, ctx: &mut LayoutCtx, sc: &SliverConstraints) -> SliverGeometry {
376        self.advance_floating(sc);
377        let max = self.max_extent;
378        let effective = self.effective_scroll_offset;
379        let child_extent =
380            self.layout_child_at(ctx, sc, effective.min(max), effective < sc.scroll_offset);
381        let paint_unclamped = max - effective;
382        let layout_unclamped = max - sc.scroll_offset;
383        let (paint, layout, child_position) = if self.pinned {
384            let min_allowed = self.min_extent.min(sc.remaining_paint_extent);
385            let paint = paint_unclamped.clamp(min_allowed, sc.remaining_paint_extent);
386            (paint, layout_unclamped.clamp(0.0, paint), 0.0)
387        } else {
388            (
389                paint_unclamped.clamp(0.0, sc.remaining_paint_extent),
390                layout_unclamped.clamp(0.0, sc.remaining_paint_extent),
391                (paint_unclamped.max(0.0) - child_extent).min(0.0),
392            )
393        };
394        self.place_child(child_position, sc.axis);
395        SliverGeometry {
396            paint_origin: sc.overlap.min(0.0),
397            layout_extent: layout,
398            has_visual_overflow: true,
399            cache_extent: layout,
400            ..SliverGeometry::new(max, paint, max)
401        }
402    }
403
404    /// Place the child `main` px along `axis` from the header's paint start.
405    fn place_child(&mut self, main: f64, axis: ScrollAxis) {
406        self.child.set_origin(axis_point(axis, main, 0.0));
407    }
408}
409
410impl<State: 'static> View<State> for SliverPersistentHeaderView<State> {
411    type Element = SliverPersistentHeaderWidget;
412
413    /// No layout has run yet: the content is built fully expanded.
414    fn build(&self, ctx: &mut BuildCtx<'_>) -> SliverPersistentHeaderWidget {
415        let child = crate::authoring::build_child(&self.child_view(BuildInputs::EXPANDED), ctx);
416        SliverPersistentHeaderWidget::new(self, child)
417    }
418
419    fn rebuild(
420        &self,
421        prev: &Self,
422        element: &mut SliverPersistentHeaderWidget,
423        ctx: &mut BuildCtx<'_>,
424    ) -> ChangeFlags {
425        let mut flags = ChangeFlags::NONE;
426        if element.floating != self.floating {
427            // A header that starts or stops floating starts from the content's
428            // position.
429            element.last_scroll_offset = None;
430        }
431        if element.min_extent != self.min_extent
432            || element.max_extent != self.max_extent
433            || element.pinned != self.pinned
434            || element.floating != self.floating
435        {
436            element.min_extent = self.min_extent;
437            element.max_extent = self.max_extent;
438            element.pinned = self.pinned;
439            element.floating = self.floating;
440            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
441        }
442        let built = element.built;
443        let next = if element.needs_rebuild() {
444            element.wanted
445        } else {
446            built
447        };
448        let prev_child = prev.child_view(built);
449        let next_child = self.child_view(next);
450        flags |= crate::authoring::rebuild_child(&prev_child, &next_child, &mut element.child, ctx);
451        element.built = next;
452        flags
453    }
454
455    fn teardown(&self, element: &mut SliverPersistentHeaderWidget, ctx: &mut BuildCtx<'_>) {
456        crate::authoring::teardown_child(&self.child_view(element.built), &mut element.child, ctx);
457    }
458}
459
460impl Widget for SliverPersistentHeaderWidget {
461    /// Outside a viewport the header is unscrolled: the child is laid out at
462    /// `max_extent` along the axis of the last sliver layout (vertical before
463    /// one), clamped into `bc`, and at `bc`'s largest finite cross extent.
464    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
465        let axis = self.axis;
466        let cross = finite_extent(axis.cross_extent(bc.max())).max(axis.cross_extent(bc.min()));
467        let tight = bc.tighten(axis.size(self.max_extent, cross));
468        let size = self.child.layout_child(ctx, &tight);
469        self.child.set_origin(Point::ZERO);
470        self.wanted = BuildInputs::EXPANDED;
471        size
472    }
473
474    /// Paints the child, and requests the frame whose rebuild brings the
475    /// content up to the shrink offset this frame's layout computed.
476    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
477        self.child.paint_child(ctx, scene);
478        if self.needs_rebuild() {
479            ctx.request_frame();
480        }
481    }
482
483    /// The viewport delivers a positional event only inside the header's
484    /// hit-test extent (or to a captured child), so the ordinary single-child
485    /// routing applies.
486    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
487        crate::authoring::route_event_single(&mut self.child, ctx, event)
488    }
489
490    fn semantics(&self, ctx: &mut SemanticsCtx) {
491        self.child.semantics_child(ctx);
492    }
493
494    fn as_sliver(&mut self) -> Option<&mut dyn SliverWidget> {
495        Some(self)
496    }
497
498    crate::authoring::visit_children!(child);
499}
500
501impl SliverWidget for SliverPersistentHeaderWidget {
502    fn layout_sliver(&mut self, ctx: &mut LayoutCtx, sc: &SliverConstraints) -> SliverGeometry {
503        self.axis = sc.axis;
504        if self.floating {
505            self.layout_floating(ctx, sc)
506        } else if self.pinned {
507            self.layout_pinned(ctx, sc)
508        } else {
509            self.layout_scrolling(ctx, sc)
510        }
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use crate::test_support::leaf;
518
519    fn constraints(scroll_offset: f64, overlap: f64) -> SliverConstraints {
520        SliverConstraints {
521            axis: ScrollAxis::Vertical,
522            scroll_offset,
523            preceding_scroll_extent: 0.0,
524            overlap,
525            remaining_paint_extent: 500.0,
526            cross_axis_extent: 400.0,
527            viewport_main_axis_extent: 500.0,
528            cache_origin: -scroll_offset.min(250.0),
529            remaining_cache_extent: 750.0 + scroll_offset.min(250.0),
530            user_scroll_direction: ScrollDirection::Idle,
531        }
532    }
533
534    fn header(pinned: bool, floating: bool) -> SliverPersistentHeaderWidget {
535        let view: SliverPersistentHeaderView<()> = sliver_persistent_header(
536            SliverPersistentHeaderDelegate::new(60.0, 200.0, |_: f64, _: bool| leaf(10.0, 10.0)),
537        )
538        .pinned(pinned)
539        .floating(floating);
540        let mut counter = 0;
541        view.build(&mut BuildCtx::new(&mut counter))
542    }
543
544    fn layout(widget: &mut SliverPersistentHeaderWidget, sc: &SliverConstraints) -> SliverGeometry {
545        let mut ctx = LayoutCtx::new();
546        let geometry = widget.layout_sliver(&mut ctx, sc);
547        geometry.debug_assert_valid(sc);
548        geometry
549    }
550
551    #[test]
552    fn extents_are_sanitised() {
553        let view: SliverPersistentHeaderView<()> = sliver_persistent_header(
554            SliverPersistentHeaderDelegate::new(f64::NAN, -5.0, |_: f64, _: bool| leaf(1.0, 1.0)),
555        );
556        assert_eq!((view.min_extent, view.max_extent), (0.0, 0.0));
557        let view: SliverPersistentHeaderView<()> = sliver_persistent_header(
558            SliverPersistentHeaderDelegate::new(80.0, 40.0, |_: f64, _: bool| leaf(1.0, 1.0)),
559        );
560        assert_eq!((view.min_extent, view.max_extent), (80.0, 80.0));
561    }
562
563    #[test]
564    fn a_scrolling_header_shrinks_then_scrolls_away() {
565        let mut w = header(false, false);
566        let g = layout(&mut w, &constraints(100.0, 0.0));
567        assert_eq!(
568            (g.scroll_extent, g.paint_extent, g.layout_extent),
569            (200.0, 100.0, 100.0)
570        );
571        assert_eq!(w.shrink_offset(), 100.0);
572        assert_eq!(w.child.origin(), Point::ZERO);
573        let g = layout(&mut w, &constraints(170.0, 0.0));
574        assert_eq!(g.paint_extent, 30.0);
575        assert_eq!(w.child.origin(), Point::new(0.0, -30.0));
576        let g = layout(&mut w, &constraints(400.0, 0.0));
577        assert!(!g.visible);
578        assert_eq!(w.shrink_offset(), 200.0);
579    }
580
581    #[test]
582    fn a_pinned_header_keeps_its_minimum_and_reports_the_rest_as_overlap() {
583        let mut w = header(true, false);
584        for scroll_offset in [0.0, 50.0, 140.0, 199.0, 200.0, 1000.0] {
585            let g = layout(&mut w, &constraints(scroll_offset, 0.0));
586            assert!(g.paint_extent >= 60.0, "at {scroll_offset}");
587            assert_eq!(g.paint_origin, 0.0);
588            assert_eq!(g.layout_extent, (200.0 - scroll_offset).max(0.0));
589        }
590        // Below an earlier pinned header it paints after that header's overlap.
591        let g = layout(&mut w, &constraints(300.0, 50.0));
592        assert_eq!(
593            (g.paint_origin, g.paint_extent, g.layout_extent),
594            (50.0, 60.0, 0.0)
595        );
596        assert!(w.overlaps_content());
597    }
598
599    #[test]
600    fn a_floating_header_only_expands_under_a_forward_user_scroll() {
601        let mut w = header(false, true);
602        assert_eq!(layout(&mut w, &constraints(1000.0, 0.0)).paint_extent, 0.0);
603        // Back 40 px without a user direction: still hidden.
604        assert_eq!(layout(&mut w, &constraints(960.0, 0.0)).paint_extent, 0.0);
605        // Back 40 px more under a forward user scroll: 40 px revealed.
606        let forward = SliverConstraints {
607            user_scroll_direction: ScrollDirection::Forward,
608            ..constraints(920.0, 0.0)
609        };
610        let g = layout(&mut w, &forward);
611        assert_eq!((g.paint_extent, g.layout_extent), (40.0, 0.0));
612        assert_eq!(w.shrink_offset(), 160.0);
613        assert!(w.overlaps_content());
614        // The same constraints again change nothing.
615        assert_eq!(layout(&mut w, &forward), g);
616        // Forward toward the end hides it again with the content.
617        let g = layout(&mut w, &constraints(940.0, 0.0));
618        assert_eq!(g.paint_extent, 20.0);
619    }
620
621    #[test]
622    fn a_floating_pinned_header_never_paints_below_its_minimum() {
623        let mut w = header(true, true);
624        let g = layout(&mut w, &constraints(1000.0, 0.0));
625        assert_eq!((g.paint_extent, g.layout_extent), (60.0, 0.0));
626        let g = layout(
627            &mut w,
628            &SliverConstraints {
629                user_scroll_direction: ScrollDirection::Forward,
630                ..constraints(900.0, 0.0)
631            },
632        );
633        assert_eq!(g.paint_extent, 100.0);
634    }
635
636    #[test]
637    fn a_sub_tolerance_shrink_change_keeps_the_previous_build() {
638        let mut w = header(true, false);
639        w.built = BuildInputs {
640            shrink_offset: 100.0,
641            overlaps_content: false,
642        };
643        layout(&mut w, &constraints(100.4, 0.0));
644        assert!(!w.needs_rebuild());
645        layout(&mut w, &constraints(100.5, 0.0));
646        assert!(w.needs_rebuild());
647        // Reaching either end always rebuilds.
648        w.built.shrink_offset = 199.8;
649        layout(&mut w, &constraints(400.0, 0.0));
650        assert!(w.needs_rebuild());
651        w.built.shrink_offset = 0.2;
652        layout(&mut w, &constraints(0.0, 0.0));
653        assert!(w.needs_rebuild());
654    }
655}