zng_wgt_scroll/
lazy_prop.rs

1use std::{fmt, mem};
2
3use crate::ScrollMode;
4use zng_wgt::prelude::*;
5
6/// Lazy loading mode of a widget.
7///
8/// See [`lazy`] property for more details.
9///
10/// [`lazy`]: fn@lazy
11#[derive(Clone, PartialEq)]
12pub enum LazyMode {
13    /// Node always loaded.
14    Disabled,
15    /// Node lazy loaded.
16    Enabled {
17        /// Function that instantiates the node that replaces the widget when it is not in the init viewport.
18        ///
19        /// All node methods are called on the placeholder, except the render methods, it should efficiently estimate
20        /// the size of the inited widget.
21        placeholder: WidgetFn<()>,
22        /// If the node is deinited when is moved out of the viewport.
23        ///
24        /// If `false` the node stays loaded after the first lazy load.
25        ///
26        /// If `true` the placeholder size is always used, this is to avoid the app entering a "flickering" loop
27        /// when the actual bounds are different causing an immediate deinit. An error is logged if the placeholder
28        /// size does not match.
29        deinit: bool,
30
31        /// The scroll directions that are considered for intersection with the viewport.
32        ///
33        /// If set to [`ScrollMode::VERTICAL`] the widget is inited if it intersects on the vertical dimension only,
34        /// even if it is not actually in the viewport due to horizontal offset, and if `deinit` is flagged only the placeholder
35        /// height is enforced, the width can be different from the actual.
36        ///
37        /// If set to [`ScrollMode::NONE`] this value is ignored, behaving like [`ScrollMode::PAN`].
38        intersect: ScrollMode,
39    },
40}
41impl LazyMode {
42    /// Enable lazy mode with a node that estimates the widget size.
43    ///
44    /// The widget function must produce a node that is used as the layout placeholder for the actual widget content.
45    ///
46    /// The widget will init when the placeholder stops being culled by render, and deinit when it starts being culled.
47    /// Note that in this mode the placeholder size is always used as the widget size, see the `deinit` docs in [`LazyMode::Enabled`]
48    /// for more details.
49    ///
50    /// See [`FrameBuilder::auto_hide_rect`] for more details about render culling.
51    ///
52    /// [`FrameBuilder::auto_hide_rect`]: zng_wgt::prelude::FrameBuilder::auto_hide_rect
53    pub fn lazy(placeholder: WidgetFn<()>) -> Self {
54        Self::Enabled {
55            placeholder,
56            deinit: true,
57            intersect: ScrollMode::PAN,
58        }
59    }
60
61    /// Like [`lazy`], but only considers the height and vertical offset to init and deinit. Like [`lazy`]
62    /// the placeholder height is enforced, but the width is allowed to change between placeholder and actual.
63    ///
64    /// Note that if the widget is inlined the full size of the widget placeholder is enforced like [`lazy`],
65    /// the widget will still init and deinit considering only the vertical intersection.
66    ///
67    /// [`lazy`]: Self::lazy
68    pub fn lazy_vertical(placeholder: WidgetFn<()>) -> Self {
69        Self::Enabled {
70            placeholder,
71            deinit: true,
72            intersect: ScrollMode::VERTICAL,
73        }
74    }
75
76    /// Like [`lazy`], but only considers the width and horizontal offset to init and deinit. Like [`lazy`]
77    /// the placeholder width is enforced, but the height is allowed to change between placeholder and actual.
78    ///
79    /// Note that if the widget is inlined the full size of the widget placeholder is enforced like [`lazy`],
80    /// the widget will still init and deinit considering only the horizontal intersection.
81    ///
82    /// [`lazy`]: Self::lazy
83    pub fn lazy_horizontal(placeholder: WidgetFn<()>) -> Self {
84        Self::Enabled {
85            placeholder,
86            deinit: true,
87            intersect: ScrollMode::HORIZONTAL,
88        }
89    }
90
91    /// Like [`lazy`] but the widget stays inited after the first, even if it is culled by render it will be present in the UI tree.
92    ///
93    /// Note that this mode allows the actual size to be different from the placeholder size, so it can be used for items that
94    /// can't estimate their own size exactly.
95    ///
96    /// This mode is only recommended for items that are "heavy" to init, but light after, otherwise the app will show degraded
97    /// performance after many items are inited.
98    ///
99    /// [`lazy`]: Self::lazy
100    pub fn once(placeholder: WidgetFn<()>) -> Self {
101        Self::Enabled {
102            placeholder,
103            deinit: false,
104            intersect: ScrollMode::PAN,
105        }
106    }
107
108    /// Like [`once`], but only considers the height and vertical offset to init.
109    ///
110    /// [`once`]: Self::once
111    pub fn once_vertical(placeholder: WidgetFn<()>) -> Self {
112        Self::Enabled {
113            placeholder,
114            deinit: false,
115            intersect: ScrollMode::VERTICAL,
116        }
117    }
118
119    /// Like [`once`], but only considers the width and horizontal offset to init.
120    ///
121    /// [`once`]: Self::once
122    pub fn once_horizontal(placeholder: WidgetFn<()>) -> Self {
123        Self::Enabled {
124            placeholder,
125            deinit: false,
126            intersect: ScrollMode::HORIZONTAL,
127        }
128    }
129
130    /// If lazy init is mode.
131    pub fn is_enabled(&self) -> bool {
132        matches!(self, Self::Enabled { .. })
133    }
134
135    /// If lazy init is disabled.
136    pub fn is_disabled(&self) -> bool {
137        matches!(self, Self::Disabled)
138    }
139
140    /// Unwrap and correct the intersect mode.
141    fn unwrap_intersect(&self) -> ScrollMode {
142        match self {
143            LazyMode::Disabled => panic!("expected `LazyMode::Enabled`"),
144            LazyMode::Enabled { intersect, .. } => {
145                let m = *intersect;
146                if m.is_empty() { ScrollMode::PAN } else { m }
147            }
148        }
149    }
150}
151impl fmt::Debug for LazyMode {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        match self {
154            Self::Disabled => write!(f, "Disabled"),
155            Self::Enabled { deinit, .. } => f.debug_struct("Enabled").field("deinit", deinit).finish_non_exhaustive(),
156        }
157    }
158}
159
160/// Enables lazy init for the widget.
161///
162/// See [`LazyMode`] for details.
163#[property(WIDGET, default(LazyMode::Disabled))]
164pub fn lazy(child: impl UiNode, mode: impl IntoVar<LazyMode>) -> impl UiNode {
165    let mode = mode.into_var();
166    // max two nodes:
167    // * in `deinit` mode can be two [0]: placeholder, [1]: actual.
168    // * or can be only placeholder or only actual.
169    let children = vec![];
170    // actual child, not inited
171    let mut not_inited = Some(child.boxed());
172    let mut in_viewport = false;
173
174    match_node_list(children, move |c, op| match op {
175        UiNodeOp::Init => {
176            WIDGET.sub_var(&mode);
177
178            if let LazyMode::Enabled { placeholder, deinit, .. } = mode.get() {
179                if mem::take(&mut in_viewport) {
180                    // init
181
182                    if deinit {
183                        // Keep placeholder, layout will still use it to avoid glitches when the actual layout causes a deinit,
184                        // and the placeholder another init on a loop.
185                        //
186                        // This time we have the actual widget content, so the placeholder is upgraded to a full widget to
187                        // have a place to store the layout info.
188
189                        let placeholder = placeholder(()).into_widget();
190                        c.children().push(placeholder);
191                    }
192                    c.children().push(not_inited.take().unwrap());
193                } else {
194                    // only placeholder
195
196                    let placeholder = placeholder(());
197                    let placeholder = zng_app::widget::base::node::widget_inner(placeholder).boxed();
198
199                    // just placeholder, and as the `widget_inner`, first render may init
200                    c.children().push(placeholder);
201                }
202            } else {
203                // not enabled, just init actual
204                c.children().push(not_inited.take().unwrap());
205            }
206        }
207        UiNodeOp::Deinit => {
208            c.deinit_all();
209
210            if not_inited.is_none() {
211                not_inited = c.children().pop(); // pop actual
212            }
213            c.children().clear(); // drop placeholder, if any
214        }
215        UiNodeOp::Update { .. } => {
216            if mode.is_new() {
217                WIDGET.reinit();
218            }
219        }
220        UiNodeOp::Measure { wm, desired_size } => {
221            let mut size = c.with_node(0, |n| n.measure(wm));
222
223            if not_inited.is_none() && c.len() == 2 {
224                // is inited and can deinit, measure the actual child and validate
225
226                let lazy_size = size;
227                let actual_size = c.with_node(1, |n| n.measure(wm));
228
229                let mut intersect_mode = ScrollMode::empty();
230
231                let lazy_inline = c.children()[0]
232                    .with_context(WidgetUpdateMode::Ignore, || WIDGET.bounds().measure_inline())
233                    .flatten();
234                if let Some(actual_inline) = wm.inline() {
235                    if let Some(lazy_inline) = lazy_inline {
236                        fn validate<T: PartialEq + fmt::Debug>(actual: T, lazy: T, name: &'static str) {
237                            if actual != lazy {
238                                tracing::debug!(
239                                    target: "lazy",
240                                    "widget `{}` measure inline {name} `{actual:?}` not equal to lazy `{lazy:?}`",
241                                    WIDGET.id()
242                                );
243                            }
244                        }
245                        validate(actual_inline.first, lazy_inline.first, "first");
246                        validate(actual_inline.first_wrapped, lazy_inline.first_wrapped, "first_wrapped");
247                        validate(actual_inline.last, lazy_inline.last, "last");
248                        validate(actual_inline.last_wrapped, lazy_inline.last_wrapped, "last_wrapped");
249
250                        actual_inline.first = lazy_inline.first;
251                        actual_inline.first_wrapped = lazy_inline.first_wrapped;
252                        actual_inline.last = lazy_inline.last;
253                        actual_inline.last_wrapped = lazy_inline.last_wrapped;
254
255                        intersect_mode = ScrollMode::PAN;
256                    } else {
257                        tracing::debug!(target: "lazy", "widget `{}` measure inlined, but lazy did not inline", WIDGET.id());
258                    }
259                } else {
260                    if lazy_inline.is_some() {
261                        tracing::debug!(target: "lazy", "widget `{}` measure did not inline, but lazy did", WIDGET.id());
262                    }
263
264                    intersect_mode = mode.with(|s| s.unwrap_intersect());
265                }
266
267                if intersect_mode == ScrollMode::PAN {
268                    if lazy_size != actual_size {
269                        tracing::debug!(
270                            target: "lazy",
271                            "widget `{}` measure size `{actual_size:?}` not equal to lazy size `{lazy_size:?}`",
272                            WIDGET.id()
273                        );
274                    }
275                } else if intersect_mode == ScrollMode::VERTICAL {
276                    if lazy_size.height != actual_size.height {
277                        tracing::debug!(
278                            target: "lazy",
279                            "widget `{}` measure height `{:?}` not equal to lazy height `{:?}`",
280                            WIDGET.id(),
281                            actual_size.height,
282                            lazy_size.height,
283                        );
284                    }
285
286                    size.width = actual_size.width;
287                } else if intersect_mode == ScrollMode::HORIZONTAL {
288                    if lazy_size.width != actual_size.width {
289                        tracing::debug!(
290                            target: "lazy",
291                            "widget `{}` measure width `{:?}` not equal to lazy width `{:?}`",
292                            WIDGET.id(),
293                            actual_size.width,
294                            lazy_size.width,
295                        );
296                    }
297
298                    size.height = actual_size.height;
299                }
300            }
301
302            *desired_size = size;
303        }
304        UiNodeOp::Layout { wl, final_size } => {
305            let mut size = c.with_node(0, |n| n.layout(wl));
306
307            if not_inited.is_none() && c.len() == 2 {
308                // is inited and can deinit, layout the actual child and validate
309
310                let lazy_size = size;
311                let actual_size = c.with_node(1, |n| n.layout(wl));
312
313                let mut intersect_mode = ScrollMode::empty();
314
315                let lazy_inlined = c.children()[0]
316                    .with_context(WidgetUpdateMode::Ignore, || WIDGET.bounds().inline().is_some())
317                    .unwrap();
318                if wl.inline().is_some() {
319                    if !lazy_inlined {
320                        tracing::debug!(target: "lazy", "widget `{}` inlined, but lazy did not inline", WIDGET.id());
321                    } else {
322                        intersect_mode = ScrollMode::PAN;
323                    }
324                } else {
325                    if lazy_inlined {
326                        tracing::debug!(target: "lazy", "widget `{}` layout did not inline, but lazy did", WIDGET.id());
327                    }
328
329                    intersect_mode = mode.with(|s| s.unwrap_intersect());
330                }
331
332                if intersect_mode == ScrollMode::PAN {
333                    if lazy_size != actual_size {
334                        tracing::debug!(
335                            target: "lazy",
336                            "widget `{}` layout size `{actual_size:?}` not equal to lazy size `{lazy_size:?}`",
337                            WIDGET.id()
338                        );
339                    }
340                } else if intersect_mode == ScrollMode::VERTICAL {
341                    if lazy_size.height != actual_size.height {
342                        tracing::debug!(
343                            target: "lazy",
344                            "widget `{}` layout height `{:?}` not equal to lazy height `{:?}`",
345                            WIDGET.id(),
346                            actual_size.height,
347                            lazy_size.height,
348                        );
349                    }
350
351                    size.width = actual_size.width;
352                } else if intersect_mode == ScrollMode::HORIZONTAL {
353                    if lazy_size.width != actual_size.width {
354                        tracing::debug!(
355                            target: "lazy",
356                            "widget `{}` layout width `{:?}` not equal to lazy width `{:?}`",
357                            WIDGET.id(),
358                            actual_size.width,
359                            lazy_size.width,
360                        );
361                    }
362
363                    size.height = actual_size.height;
364                }
365            }
366
367            *final_size = size;
368        }
369        UiNodeOp::Render { frame } => {
370            c.delegated();
371
372            if not_inited.is_some() {
373                // not inited, verify
374
375                c.children()[0].render(frame); // update bounds
376
377                let intersect_mode = mode.with(|s| s.unwrap_intersect());
378                let outer_bounds = WIDGET.bounds().outer_bounds();
379                let viewport = frame.auto_hide_rect();
380
381                in_viewport = if intersect_mode == ScrollMode::VERTICAL {
382                    outer_bounds.min_y() < viewport.max_y() && outer_bounds.max_y() > viewport.min_y()
383                } else if intersect_mode == ScrollMode::HORIZONTAL {
384                    outer_bounds.min_x() < viewport.max_x() && outer_bounds.max_x() > viewport.min_x()
385                } else {
386                    outer_bounds.intersects(&viewport)
387                };
388                if in_viewport {
389                    // request init
390                    WIDGET.reinit();
391                }
392            } else if c.len() == 2 {
393                // is inited and can deinit, check viewport on placeholder
394
395                c.children()[1].render(frame); // render + update bounds
396
397                frame.hide(|f| {
398                    f.with_hit_tests_disabled(|f| {
399                        // update bounds (not used but can be inspected)
400                        c.children()[0].render(f);
401                    });
402                });
403
404                let intersect_mode = mode.with(|s| s.unwrap_intersect());
405                let viewport = frame.auto_hide_rect();
406                let outer_bounds = WIDGET.bounds().outer_bounds();
407
408                in_viewport = if intersect_mode == ScrollMode::VERTICAL {
409                    outer_bounds.min_y() < viewport.max_y() && outer_bounds.max_y() > viewport.min_y()
410                } else if intersect_mode == ScrollMode::HORIZONTAL {
411                    outer_bounds.min_x() < viewport.max_x() && outer_bounds.max_x() > viewport.min_x()
412                } else {
413                    outer_bounds.intersects(&viewport)
414                };
415                if !in_viewport {
416                    // request deinit
417                    WIDGET.reinit();
418                }
419            } else {
420                // is inited and cannot deinit
421                c.children()[0].render(frame);
422            }
423        }
424        UiNodeOp::RenderUpdate { update } => {
425            c.delegated();
426            if not_inited.is_none() {
427                // child is actual child
428                let last = c.children().len() - 1;
429
430                c.children()[last].render_update(update);
431
432                if last == 1 {
433                    update.hidden(|u| {
434                        // update bounds (not used but can be inspected)
435                        c.children()[0].render_update(u);
436                    });
437                }
438            } else {
439                // update bounds
440                c.children()[0].render_update(update);
441            }
442        }
443        _ => {}
444    })
445}