Skip to main content

frust_widgets/slivers/
grid.rs

1//! `sliver_grid`: a lazy grid of equal cells — Flutter's `SliverGrid`
2//! (`rendering/sliver_grid.dart`'s `RenderSliverGrid` with
3//! `SliverGridDelegateWithFixedCrossAxisCount` /
4//! `SliverGridDelegateWithMaxCrossAxisExtent`), materialised the way
5//! [`sliver_fixed_extent_list`](crate::sliver_fixed_extent_list) materialises
6//! its rows.
7//!
8//! # Geometry
9//!
10//! A [`SliverGridDelegate`] turns the cross-axis extent into the grid's
11//! layout: how many cells share a row, each cell's extent across
12//! (`(cross − cross_spacing × (count − 1)) / count`) and along
13//! (`across / child_aspect_ratio`) the axis, and the row stride along it
14//! (`along + main_spacing`). Item `i` sits in row `i / count`, column
15//! `i % count`, laid out tight to the cell. The scroll extent is
16//! `rows × stride − main_spacing` — the trailing spacing is not content —
17//! a function of the constraints and the item count alone, never of which
18//! cells are materialised.
19//!
20//! # Materialised at rebuild time
21//!
22//! Exactly the fixed-extent list's rule, by row: the rows whose stride touches
23//! the cache region the last layout asked for are materialised at the next
24//! rebuild — the cells `[first_row × count, end_row × count)` clamped to the
25//! item count — reconciled by item index ([`sliver_grid`]) or by a stable key
26//! ([`sliver_grid_keyed`]) through the reconcilers `ListView` shares, with the
27//! same one-frame catch-up. Paint clips when a row is cut and skips the rows
28//! wholly outside the paint extent.
29
30use std::collections::HashMap;
31use std::ops::Range;
32use std::rc::Rc;
33
34use frust_core::sliver::PRECISION_ERROR_TOLERANCE;
35use frust_core::{
36    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
37    LayoutCtx, PaintCtx, PaintScene, ScrollAxis, ScrollDirection, SemanticsCtx, SliverConstraints,
38    SliverGeometry, SliverWidget, View, Widget, any,
39};
40use kurbo::Size;
41
42use super::adapter::{axis_point, finite_extent, non_negative};
43use super::reconcile::{self, ItemViews, LiveRows};
44use super::{cache_offset, paint_offset};
45use crate::ChildKey;
46
47/// The debug-only tripwire message for two materialised cells claiming one
48/// key.
49const DUPLICATE_KEY_MSG: &str = "sliver_grid_keyed produced a duplicate key inside one window: \
50     cell identity is ambiguous (release: the first slot claiming a key keeps the live cell, \
51     later duplicates build fresh)";
52
53/// A view-held item builder, erased per cell.
54type ItemBuilder<State> = Rc<dyn Fn(usize) -> AnyView<State>>;
55
56/// A view-held stable-key function (`item index -> cell identity`).
57type KeyOf = Rc<dyn Fn(usize) -> ChildKey>;
58
59/// How a [`sliver_grid`] divides its cross axis into cells — Flutter's two
60/// stock `SliverGridDelegate`s. Spacings are logical px; `child_aspect_ratio`
61/// is a cell's cross-axis extent over its main-axis extent (width over height
62/// in a vertical grid).
63#[derive(Clone, Copy, Debug, PartialEq)]
64pub enum SliverGridDelegate {
65    /// Exactly `count` cells per row.
66    FixedCrossAxisCount {
67        count: usize,
68        main_spacing: f64,
69        cross_spacing: f64,
70        child_aspect_ratio: f64,
71    },
72    /// As few cells per row as keep each one at most `max_cross_extent`
73    /// across: `ceil(cross / (max_cross_extent + cross_spacing))`, at least
74    /// one.
75    MaxCrossAxisExtent {
76        max_cross_extent: f64,
77        main_spacing: f64,
78        cross_spacing: f64,
79        child_aspect_ratio: f64,
80    },
81}
82
83impl Default for SliverGridDelegate {
84    /// Two square cells per row, no spacing.
85    fn default() -> Self {
86        Self::FixedCrossAxisCount {
87            count: 2,
88            main_spacing: 0.0,
89            cross_spacing: 0.0,
90            child_aspect_ratio: 1.0,
91        }
92    }
93}
94
95/// One grid's resolved layout for a cross-axis extent.
96#[derive(Clone, Copy, Debug, PartialEq)]
97struct GridLayout {
98    count: usize,
99    cell_main: f64,
100    cell_cross: f64,
101    main_stride: f64,
102    cross_stride: f64,
103    main_spacing: f64,
104}
105
106impl SliverGridDelegate {
107    /// Panic unless every parameter is usable (the variant docs).
108    fn validate(&self) {
109        let (main_spacing, cross_spacing, ratio) = match *self {
110            Self::FixedCrossAxisCount {
111                count,
112                main_spacing,
113                cross_spacing,
114                child_aspect_ratio,
115            } => {
116                assert!(count > 0, "SliverGridDelegate count must be at least 1");
117                (main_spacing, cross_spacing, child_aspect_ratio)
118            }
119            Self::MaxCrossAxisExtent {
120                max_cross_extent,
121                main_spacing,
122                cross_spacing,
123                child_aspect_ratio,
124            } => {
125                assert!(
126                    max_cross_extent > 0.0 && max_cross_extent.is_finite(),
127                    "SliverGridDelegate max_cross_extent must be positive and finite"
128                );
129                (main_spacing, cross_spacing, child_aspect_ratio)
130            }
131        };
132        assert!(
133            main_spacing >= 0.0 && main_spacing.is_finite(),
134            "SliverGridDelegate main_spacing must be non-negative and finite"
135        );
136        assert!(
137            cross_spacing >= 0.0 && cross_spacing.is_finite(),
138            "SliverGridDelegate cross_spacing must be non-negative and finite"
139        );
140        assert!(
141            ratio > 0.0 && ratio.is_finite(),
142            "SliverGridDelegate child_aspect_ratio must be positive and finite"
143        );
144    }
145
146    /// The layout for `cross` px across — `getLayout` of Flutter's delegate.
147    fn layout(&self, cross: f64) -> GridLayout {
148        let (count, main_spacing, cross_spacing, ratio) = match *self {
149            Self::FixedCrossAxisCount {
150                count,
151                main_spacing,
152                cross_spacing,
153                child_aspect_ratio,
154            } => (count, main_spacing, cross_spacing, child_aspect_ratio),
155            Self::MaxCrossAxisExtent {
156                max_cross_extent,
157                main_spacing,
158                cross_spacing,
159                child_aspect_ratio,
160            } => {
161                let count = (cross / (max_cross_extent + cross_spacing)).ceil();
162                let count = if count.is_finite() {
163                    count.max(1.0)
164                } else {
165                    1.0
166                };
167                (
168                    count as usize,
169                    main_spacing,
170                    cross_spacing,
171                    child_aspect_ratio,
172                )
173            }
174        };
175        let count = count.max(1);
176        let usable = (cross - cross_spacing * (count - 1) as f64).max(0.0);
177        let cell_cross = usable / count as f64;
178        let cell_main = cell_cross / ratio;
179        GridLayout {
180            count,
181            cell_main,
182            cell_cross,
183            main_stride: cell_main + main_spacing,
184            cross_stride: cell_cross + cross_spacing,
185            main_spacing,
186        }
187    }
188}
189
190impl GridLayout {
191    /// How many rows `item_count` cells fill.
192    fn rows(&self, item_count: usize) -> usize {
193        item_count.div_ceil(self.count)
194    }
195
196    /// The scroll extent of `item_count` cells.
197    fn extent(&self, item_count: usize) -> f64 {
198        let rows = self.rows(item_count);
199        if rows == 0 {
200            0.0
201        } else {
202            (rows as f64 * self.main_stride - self.main_spacing).max(0.0)
203        }
204    }
205
206    /// The cells covering the cache region of `sc` — whole rows, clamped to
207    /// `item_count`; Flutter's `getMinChildIndexForScrollOffset` /
208    /// `getMaxChildIndexForScrollOffset` over the row stride.
209    fn cache_window(&self, sc: &SliverConstraints, item_count: usize) -> Range<usize> {
210        if item_count == 0 || sc.remaining_cache_extent <= 0.0 || self.main_stride <= 0.0 {
211            return 0..0;
212        }
213        let rows = self.rows(item_count) as f64;
214        let start = (sc.scroll_offset + sc.cache_origin).max(0.0);
215        let end = start + sc.remaining_cache_extent;
216        let first_row = row_at(start, self.main_stride, f64::floor).clamp(0.0, rows);
217        let end_row = if end.is_finite() {
218            row_at(end, self.main_stride, f64::ceil).clamp(0.0, rows)
219        } else {
220            rows
221        };
222        let first = (first_row as usize * self.count).min(item_count);
223        let end = (end_row as usize * self.count).min(item_count);
224        first.min(end)..end
225    }
226}
227
228/// `offset / stride` rounded by `round`, snapping to the nearest whole row
229/// within [`PRECISION_ERROR_TOLERANCE`] so float noise at a row boundary
230/// pulls in no neighbour.
231fn row_at(offset: f64, stride: f64, round: fn(f64) -> f64) -> f64 {
232    let actual = offset / stride;
233    let nearest = actual.round();
234    if (actual * stride - nearest * stride).abs() < PRECISION_ERROR_TOLERANCE {
235        nearest
236    } else {
237        round(actual)
238    }
239}
240
241/// A lazy grid of equal cells. See the [module docs](self).
242pub struct SliverGridView<State: 'static> {
243    item_count: usize,
244    delegate: SliverGridDelegate,
245    builder: ItemBuilder<State>,
246    /// `None` reconciles cells by item index.
247    key_of: Option<KeyOf>,
248}
249
250/// A lazy grid of `item_count` cells, the cell at `index` being
251/// `builder(index)`, laid out by [`SliverGridView::delegate`] (two square
252/// cells per row unless set) — reconciled by item index. See the
253/// [module docs](self).
254///
255/// ```
256/// use frust_widgets::{
257///     CustomScrollView, SliverGridDelegate, custom_scroll_view, sliver_grid, text,
258/// };
259/// let view: CustomScrollView<()> = custom_scroll_view(
260///     sliver_grid(1_000, |i| text(format!("cell {i}"))).delegate(
261///         SliverGridDelegate::FixedCrossAxisCount {
262///             count: 4,
263///             main_spacing: 8.0,
264///             cross_spacing: 8.0,
265///             child_aspect_ratio: 1.0,
266///         },
267///     ),
268/// );
269/// # let _ = view;
270/// ```
271pub fn sliver_grid<State: 'static, V: View<State>>(
272    item_count: usize,
273    builder: impl Fn(usize) -> V + 'static,
274) -> SliverGridView<State> {
275    SliverGridView::new(item_count, None, builder)
276}
277
278/// [`sliver_grid`] reconciled by the stable key `key_of(index)` instead of by
279/// item index, with
280/// [`sliver_fixed_extent_list_keyed`](crate::sliver_fixed_extent_list_keyed)'s
281/// contract.
282///
283/// ```
284/// use frust_widgets::{ChildKey, CustomScrollView, custom_scroll_view, sliver_grid_keyed, text};
285/// let view: CustomScrollView<()> = custom_scroll_view(sliver_grid_keyed(
286///     100,
287///     |i| ChildKey::new(i as u64),
288///     |i| text(format!("photo {i}")),
289/// ));
290/// # let _ = view;
291/// ```
292pub fn sliver_grid_keyed<State: 'static, V: View<State>>(
293    item_count: usize,
294    key_of: impl Fn(usize) -> ChildKey + 'static,
295    builder: impl Fn(usize) -> V + 'static,
296) -> SliverGridView<State> {
297    SliverGridView::new(item_count, Some(Rc::new(key_of)), builder)
298}
299
300impl<State: 'static> SliverGridView<State> {
301    fn new<V: View<State>>(
302        item_count: usize,
303        key_of: Option<KeyOf>,
304        builder: impl Fn(usize) -> V + 'static,
305    ) -> Self {
306        Self {
307            item_count,
308            delegate: SliverGridDelegate::default(),
309            builder: Rc::new(move |index| any(builder(index))),
310            key_of,
311        }
312    }
313
314    /// Lay the cells out with `delegate`.
315    ///
316    /// # Panics
317    ///
318    /// If a count is zero, a spacing negative or not finite, or an extent or
319    /// aspect ratio not positive and finite.
320    pub fn delegate(mut self, delegate: SliverGridDelegate) -> Self {
321        delegate.validate();
322        self.delegate = delegate;
323        self
324    }
325}
326
327/// The retained widget for a [`SliverGridView`]: the materialised cells, the
328/// constraints rebuild windows against, and the delegate.
329pub struct SliverGridWidget {
330    /// The materialised cells, parallel to [`Self::indices`].
331    children: Vec<ChildPod>,
332    /// The item index each cell renders — a contiguous ascending run.
333    indices: Vec<usize>,
334    /// Cell identity for a keyed grid; empty for a positional one.
335    key_index: HashMap<ChildKey, usize>,
336    item_count: usize,
337    delegate: SliverGridDelegate,
338    /// The constraints the last sliver layout ran with. `None` until the
339    /// first layout.
340    constraints: Option<SliverConstraints>,
341    /// The layout the last sliver layout resolved.
342    grid: Option<GridLayout>,
343    /// The paint extent of the last sliver layout.
344    paint_extent: f64,
345    /// Whether the last layout cut a row at the paint extent (so paint clips).
346    clip: bool,
347}
348
349impl SliverGridWidget {
350    fn new(item_count: usize, delegate: SliverGridDelegate) -> Self {
351        Self {
352            children: Vec::new(),
353            indices: Vec::new(),
354            key_index: HashMap::new(),
355            item_count,
356            delegate,
357            constraints: None,
358            grid: None,
359            paint_extent: 0.0,
360            clip: false,
361        }
362    }
363
364    /// The item indices currently materialised, ascending.
365    pub fn window(&self) -> &[usize] {
366        &self.indices
367    }
368
369    /// How many items the grid holds.
370    pub fn item_count(&self) -> usize {
371        self.item_count
372    }
373
374    /// The cells the last layout's cache region asks for — empty before the
375    /// first layout.
376    fn desired_window(&self) -> Range<usize> {
377        match &self.constraints {
378            Some(sc) => self
379                .delegate
380                .layout(sc.cross_axis_extent)
381                .cache_window(sc, self.item_count),
382            None => 0..0,
383        }
384    }
385
386    /// Whether the materialised cells cover `range`.
387    fn covers(&self, range: &Range<usize>) -> bool {
388        if range.is_empty() {
389            return true;
390        }
391        match (self.indices.first(), self.indices.last()) {
392            (Some(&first), Some(&last)) => first <= range.start && last + 1 >= range.end,
393            _ => false,
394        }
395    }
396}
397
398impl<State: 'static> View<State> for SliverGridView<State> {
399    type Element = SliverGridWidget;
400
401    /// No layout has run yet: nothing is materialised until the first rebuild
402    /// after it.
403    fn build(&self, _ctx: &mut BuildCtx<'_>) -> SliverGridWidget {
404        SliverGridWidget::new(self.item_count, self.delegate)
405    }
406
407    fn rebuild(
408        &self,
409        prev: &Self,
410        element: &mut SliverGridWidget,
411        ctx: &mut BuildCtx<'_>,
412    ) -> ChangeFlags {
413        let mut flags = ChangeFlags::NONE;
414        if element.item_count != self.item_count {
415            element.item_count = self.item_count;
416            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
417        }
418        if element.delegate != self.delegate {
419            element.delegate = self.delegate;
420            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
421        }
422        let range = element.desired_window();
423        let views = ItemViews {
424            prev: &*prev.builder,
425            next: &*self.builder,
426        };
427        let rows = LiveRows {
428            children: &mut element.children,
429            indices: &mut element.indices,
430        };
431        flags |= match self.key_of.as_ref() {
432            Some(key_of) => {
433                reconcile::reconcile_keyed(
434                    views,
435                    rows,
436                    &mut element.key_index,
437                    &**key_of,
438                    range,
439                    DUPLICATE_KEY_MSG,
440                    ctx,
441                )
442                .flags
443            }
444            None => {
445                // A positional frame owns no key identities.
446                element.key_index.clear();
447                reconcile::reconcile_positional(views, rows, range, ctx)
448            }
449        };
450        flags
451    }
452
453    fn teardown(&self, element: &mut SliverGridWidget, ctx: &mut BuildCtx<'_>) {
454        for (index, pod) in element.indices.iter().zip(element.children.iter_mut()) {
455            crate::authoring::teardown_child(&(self.builder)(*index), pod, ctx);
456        }
457    }
458}
459
460impl Widget for SliverGridWidget {
461    /// Outside a viewport the grid lays out as if it were the only sliver of a
462    /// viewport `bc.max()` long, scrolled to its start (the fixed-extent
463    /// list's rule).
464    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
465        let axis = self.constraints.map_or(ScrollAxis::Vertical, |sc| sc.axis);
466        let max = bc.max();
467        let main = non_negative(axis.main_extent(max));
468        let cross = finite_extent(axis.cross_extent(max));
469        let sc = SliverConstraints {
470            axis,
471            scroll_offset: 0.0,
472            preceding_scroll_extent: 0.0,
473            overlap: 0.0,
474            remaining_paint_extent: main,
475            cross_axis_extent: cross,
476            viewport_main_axis_extent: main,
477            cache_origin: 0.0,
478            remaining_cache_extent: main,
479            user_scroll_direction: ScrollDirection::Idle,
480        };
481        let geometry = self.layout_sliver(ctx, &sc);
482        bc.constrain(axis.size(geometry.paint_extent, cross))
483    }
484
485    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
486        if self.clip {
487            scene.push_clip(ctx.origin(), ctx.size());
488        }
489        let axis = self.constraints.map_or(ScrollAxis::Vertical, |sc| sc.axis);
490        let cell = self.grid.map_or(0.0, |grid| grid.cell_main);
491        let paint_extent = self.paint_extent;
492        for pod in &mut self.children {
493            // A row only cached is laid out but never painted.
494            let origin = pod.origin();
495            let start = axis.main_extent(Size::new(origin.x, origin.y));
496            if start + cell > 0.0 && start < paint_extent {
497                pod.paint_child(ctx, scene);
498            }
499        }
500        if self.clip {
501            scene.pop_clip();
502        }
503        // The next rebuild re-windows to what this frame's layout asked for.
504        if !self.covers(&self.desired_window()) {
505            ctx.request_frame();
506        }
507    }
508
509    /// The viewport only delivers a positional event inside this sliver's
510    /// paint extent, where only painted cells sit.
511    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
512        crate::authoring::route_event(&mut self.children, ctx, event)
513    }
514
515    fn semantics(&self, ctx: &mut SemanticsCtx) {
516        for pod in &self.children {
517            pod.semantics_child(ctx);
518        }
519    }
520
521    fn as_sliver(&mut self) -> Option<&mut dyn SliverWidget> {
522        Some(self)
523    }
524
525    crate::authoring::visit_children!(children);
526}
527
528impl SliverWidget for SliverGridWidget {
529    fn layout_sliver(&mut self, ctx: &mut LayoutCtx, sc: &SliverConstraints) -> SliverGeometry {
530        let axis = sc.axis;
531        let grid = self.delegate.layout(sc.cross_axis_extent);
532        let cell = BoxConstraints::tight(axis.size(grid.cell_main, grid.cell_cross));
533        for (index, pod) in self.indices.iter().zip(self.children.iter_mut()) {
534            let (row, column) = (index / grid.count, index % grid.count);
535            pod.layout_child(ctx, &cell);
536            pod.set_origin(axis_point(
537                axis,
538                row as f64 * grid.main_stride - sc.scroll_offset,
539                column as f64 * grid.cross_stride,
540            ));
541        }
542        let total = grid.extent(self.item_count);
543        let paint_extent = paint_offset(sc, 0.0, total);
544        let geometry = SliverGeometry {
545            cache_extent: cache_offset(sc, 0.0, total).max(paint_extent),
546            has_visual_overflow: total > sc.scroll_offset + sc.remaining_paint_extent
547                || sc.scroll_offset > 0.0,
548            ..SliverGeometry::new(total, paint_extent, total)
549        };
550        self.constraints = Some(*sc);
551        self.grid = Some(grid);
552        self.paint_extent = paint_extent;
553        self.clip = geometry.has_visual_overflow;
554        geometry
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    fn constraints(scroll_offset: f64, cross: f64) -> SliverConstraints {
563        SliverConstraints {
564            axis: ScrollAxis::Vertical,
565            scroll_offset,
566            preceding_scroll_extent: 0.0,
567            overlap: 0.0,
568            remaining_paint_extent: 600.0,
569            cross_axis_extent: cross,
570            viewport_main_axis_extent: 600.0,
571            cache_origin: 0.0,
572            remaining_cache_extent: 600.0,
573            user_scroll_direction: ScrollDirection::Idle,
574        }
575    }
576
577    #[test]
578    fn both_delegates_resolve_the_same_four_column_layout() {
579        let fixed = SliverGridDelegate::FixedCrossAxisCount {
580            count: 4,
581            main_spacing: 10.0,
582            cross_spacing: 8.0,
583            child_aspect_ratio: 2.0,
584        };
585        let max = SliverGridDelegate::MaxCrossAxisExtent {
586            max_cross_extent: 120.0,
587            main_spacing: 10.0,
588            cross_spacing: 8.0,
589            child_aspect_ratio: 2.0,
590        };
591        let expected = GridLayout {
592            count: 4,
593            cell_main: 50.0,
594            cell_cross: 100.0,
595            main_stride: 60.0,
596            cross_stride: 108.0,
597            main_spacing: 10.0,
598        };
599        assert_eq!(fixed.layout(424.0), expected);
600        assert_eq!(max.layout(424.0), expected);
601        // Ten cells fill three rows; the trailing spacing is not content.
602        assert_eq!(expected.extent(10), 170.0);
603        assert_eq!(expected.extent(0), 0.0);
604        // A narrow viewport still gets one column.
605        assert_eq!(max.layout(50.0).count, 1);
606    }
607
608    #[test]
609    fn the_window_is_the_whole_rows_the_cache_region_touches() {
610        let grid = SliverGridDelegate::FixedCrossAxisCount {
611            count: 4,
612            main_spacing: 10.0,
613            cross_spacing: 8.0,
614            child_aspect_ratio: 2.0,
615        }
616        .layout(424.0);
617        // [0, 600): rows 0..10 (row 10 starts exactly at 600).
618        assert_eq!(grid.cache_window(&constraints(0.0, 424.0), 1000), 0..40);
619        // [6000, 6600): rows 100..110.
620        assert_eq!(
621            grid.cache_window(&constraints(6000.0, 424.0), 1000),
622            400..440
623        );
624        // Mid-row edges round outward; the last row is partial.
625        assert_eq!(grid.cache_window(&constraints(30.0, 424.0), 1000), 0..44);
626        assert_eq!(grid.cache_window(&constraints(0.0, 424.0), 10), 0..10);
627        assert!(grid.cache_window(&constraints(0.0, 424.0), 0).is_empty());
628    }
629
630    #[test]
631    #[should_panic(expected = "count must be at least 1")]
632    fn a_zero_column_delegate_is_refused() {
633        let _ = sliver_grid::<(), _>(10, |_| crate::text("cell")).delegate(
634            SliverGridDelegate::FixedCrossAxisCount {
635                count: 0,
636                main_spacing: 0.0,
637                cross_spacing: 0.0,
638                child_aspect_ratio: 1.0,
639            },
640        );
641    }
642}