Skip to main content

inset_rendering/
viewport_offset.rs

1//! Flutter counterpart: `rendering/viewport_offset.dart`.
2
3use std::fmt::{self, Debug};
4use std::hash::{Hash, Hasher};
5use std::rc::Rc;
6use std::time::Duration;
7
8use inset_animation::{Curve, Curves};
9use inset_foundation::{
10    App, ChangeNotifier, ChangeNotifierData, CompleterFuture, Handle, HandleId, Listenable,
11    ListenableObject, Listener,
12};
13
14/// The direction of a scroll, relative to the positive scroll offset.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum ScrollDirection {
17    /// No scrolling is underway.
18    Idle,
19
20    /// Scrolling is happening in the negative scroll offset direction.
21    Forward,
22
23    /// Scrolling is happening in the positive scroll offset direction.
24    Reverse,
25}
26
27/// Returns the opposite of the given [`ScrollDirection`].
28///
29/// [`ScrollDirection::Idle`] is unchanged.
30pub fn flip_scroll_direction(direction: ScrollDirection) -> ScrollDirection {
31    match direction {
32        ScrollDirection::Idle => ScrollDirection::Idle,
33        ScrollDirection::Forward => ScrollDirection::Reverse,
34        ScrollDirection::Reverse => ScrollDirection::Forward,
35    }
36}
37
38/// Which part of the content inside the viewport should be visible.
39///
40/// The [`pixels`](Self::pixels) value determines the scroll offset that the viewport uses to
41/// select which part of its content to display. As the user scrolls the viewport, this value
42/// changes, which changes the content that is displayed.
43///
44/// This object is a [`Listenable`] that notifies its listeners when
45/// [`pixels`](Self::pixels) changes.
46///
47/// See also:
48///
49///  * [`crate::RenderViewportBase`], which is a render object that uses viewport offsets.
50pub trait ViewportOffset: ChangeNotifier + Sized {
51    /// The number of pixels to offset the children in the opposite of the axis direction.
52    ///
53    /// For example, if the axis direction is down, then the pixel value represents the number
54    /// of logical pixels to move the children _up_ the screen. Similarly, if the axis
55    /// direction is left, then the pixels value represents the number of logical pixels to
56    /// move the children to _right_.
57    ///
58    /// This object notifies its listeners when this value changes (except when the value
59    /// changes due to [`correct_by`](Self::correct_by)).
60    fn pixels(self: Handle<Self>, app: &App) -> f64;
61
62    /// Whether the [`pixels`](Self::pixels) property is available.
63    fn has_pixels(self: Handle<Self>, app: &App) -> bool;
64
65    /// Called when the viewport's extents are established.
66    ///
67    /// The argument is the dimension of the [`crate::RenderViewport`] in the main axis (e.g.
68    /// the height, for a vertical viewport).
69    ///
70    /// This may be called redundantly, with the same value, each frame. This is called during
71    /// layout for the viewport. If the viewport is configured to shrink-wrap its contents, it
72    /// may be called several times, since the layout is repeated each time the scroll offset
73    /// is corrected.
74    ///
75    /// If this is called, it is called before
76    /// [`apply_content_dimensions`](Self::apply_content_dimensions), which will be called soon
77    /// afterwards in the same layout phase.
78    ///
79    /// If applying the viewport dimensions changes the scroll offset, return `false`.
80    /// Otherwise, return `true`. If you return `false`, the viewport will be laid out again
81    /// with the new scroll offset. This is expensive.
82    fn apply_viewport_dimension(self: Handle<Self>, app: &mut App, viewport_dimension: f64)
83    -> bool;
84
85    /// Called when the viewport's content extents are established.
86    ///
87    /// The arguments are the minimum and maximum scroll extents respectively. The minimum will
88    /// be equal to or less than the maximum. In the case of slivers, the minimum will be equal
89    /// to or less than zero, the maximum will be equal to or greater than zero.
90    ///
91    /// The maximum scroll extent has the viewport dimension subtracted from it. For instance,
92    /// if there is 100.0 pixels of scrollable content, and the viewport is 80.0 pixels high,
93    /// then the minimum scroll extent will typically be 0.0 and the maximum scroll extent will
94    /// typically be 20.0, because there's only 20.0 pixels of actual scroll slack.
95    ///
96    /// If applying the content dimensions changes the scroll offset, return `false`.
97    /// Otherwise, return `true`. If you return `false`, the viewport will be laid out again
98    /// with the new scroll offset. This is expensive.
99    ///
100    /// This is called at least once each time the viewport is laid out, even if the values
101    /// have not changed. It may be called many times if the scroll offset is corrected (if
102    /// this returns `false`). This is always called after
103    /// [`apply_viewport_dimension`](Self::apply_viewport_dimension), if that method is called.
104    fn apply_content_dimensions(
105        self: Handle<Self>,
106        app: &mut App,
107        min_scroll_extent: f64,
108        max_scroll_extent: f64,
109    ) -> bool;
110
111    /// Apply a layout-time correction to the scroll offset.
112    ///
113    /// This method should change the [`pixels`](Self::pixels) value by `correction`, but
114    /// without notifying listeners. It is called during layout by the viewport, before
115    /// [`apply_content_dimensions`](Self::apply_content_dimensions). After this method is
116    /// called, the layout will be recomputed and that may result in this method being called
117    /// again, though this should be very rare.
118    ///
119    /// See also:
120    ///
121    ///  * [`jump_to`](Self::jump_to), for also changing the scroll position when not in
122    ///    layout. It applies the change immediately and notifies its listeners.
123    fn correct_by(self: Handle<Self>, app: &mut App, correction: f64);
124
125    /// Jumps [`pixels`](Self::pixels) from its current value to the given value, without
126    /// animation, and without checking if the new value is in range.
127    ///
128    /// See also:
129    ///
130    ///  * [`correct_by`](Self::correct_by), for changing the current offset in the middle of
131    ///    layout and that defers the notification of its listeners until after layout.
132    fn jump_to(self: Handle<Self>, app: &mut App, pixels: f64);
133
134    /// Animates [`pixels`](Self::pixels) from its current value to the given value.
135    ///
136    /// The returned future will complete when the animation ends, whether it completed
137    /// successfully or whether it was interrupted prematurely.
138    ///
139    /// The duration must not be zero. To jump to a particular value without an animation, use
140    /// [`jump_to`](Self::jump_to).
141    fn animate_to(
142        self: Handle<Self>,
143        app: &mut App,
144        to: f64,
145        duration: Duration,
146        curve: Rc<dyn Curve>,
147    ) -> CompleterFuture<()>;
148
149    /// Calls [`jump_to`](Self::jump_to) if duration is `None` or zero, otherwise
150    /// [`animate_to`](Self::animate_to) is called.
151    ///
152    /// If [`animate_to`](Self::animate_to) is called then `curve` defaults to
153    /// [`Curves::ease`]. The `clamp` parameter is ignored by this stub implementation but
154    /// implementors like `ScrollPosition` handle it by adjusting `to` to prevent over or
155    /// underscroll.
156    fn move_to(
157        self: Handle<Self>,
158        app: &mut App,
159        to: f64,
160        duration: Option<Duration>,
161        curve: Option<Rc<dyn Curve>>,
162        clamp: Option<bool>,
163    ) -> CompleterFuture<()> {
164        ViewportOffsetBase::move_to(self, app, to, duration, curve, clamp)
165    }
166
167    /// The direction in which the user is trying to change [`pixels`](Self::pixels), relative
168    /// to the viewport's `axis_direction`.
169    ///
170    /// If the _user_ is not scrolling, this will return [`ScrollDirection::Idle`] even if
171    /// there is (for example) a scroll activity currently animating the position.
172    ///
173    /// This is exposed in [`crate::SliverConstraints::user_scroll_direction`], which is used
174    /// by some slivers to determine how to react to a change in scroll offset. For example,
175    /// [`crate::RenderSliverFloatingPersistentHeader`] will only expand a floating app bar
176    /// when the user scroll direction is in the positive scroll offset direction.
177    fn user_scroll_direction(self: Handle<Self>, app: &App) -> ScrollDirection;
178
179    /// Whether a viewport is allowed to change [`pixels`](Self::pixels) implicitly to respond
180    /// to a call to [`crate::RenderObject::show_on_screen`].
181    ///
182    /// `show_on_screen` is, for example, used to bring a text field fully on screen after it
183    /// has received focus. This property controls whether the viewport associated with this
184    /// offset is allowed to change the offset's [`pixels`](Self::pixels) value to fulfill such
185    /// a request.
186    fn allow_implicit_scrolling(self: Handle<Self>, app: &App) -> bool;
187
188    /// Add additional information to the given `description` for use by Dart's `toString`.
189    ///
190    /// An implementor that adds its own lines calls this one where Dart writes
191    /// `super.debugFillDescription(description)`.
192    fn debug_fill_description(self: Handle<Self>, app: &App, description: &mut Vec<String>) {
193        if self.has_pixels(app) {
194            description.push(format!("offset: {:.1}", self.pixels(app)));
195        }
196    }
197
198    /// The type-erased `ViewportOffset` handle. Free: the vtable is a `const`, and the id is
199    /// copied.
200    fn as_viewport_offset(self: Handle<Self>) -> AnyViewportOffset {
201        AnyViewportOffset {
202            id: self.id(),
203            vtable: const { &ViewportOffsetVTable::of::<Self>() },
204        }
205    }
206}
207
208/// The shared bodies of `ViewportOffset`: call one where Dart writes `super.…`.
209pub struct ViewportOffsetBase;
210
211impl ViewportOffsetBase {
212    /// See [`ViewportOffset::move_to`].
213    pub fn move_to<T: ViewportOffset>(
214        this: Handle<T>,
215        app: &mut App,
216        to: f64,
217        duration: Option<Duration>,
218        curve: Option<Rc<dyn Curve>>,
219        clamp: Option<bool>,
220    ) -> CompleterFuture<()> {
221        let _ = clamp;
222        let Some(duration) = duration.filter(|duration| !duration.is_zero()) else {
223            this.jump_to(app, to);
224            return CompleterFuture::ready(());
225        };
226        this.animate_to(app, to, duration, curve.unwrap_or(Curves::ease()))
227    }
228}
229
230/// The dispatch signature of [`ViewportOffset::animate_to`].
231type AnimateToFn = fn(&mut App, HandleId, f64, Duration, Rc<dyn Curve>) -> CompleterFuture<()>;
232
233/// The dispatch signature of [`ViewportOffset::move_to`].
234type MoveToFn = fn(
235    &mut App,
236    HandleId,
237    f64,
238    Option<Duration>,
239    Option<Rc<dyn Curve>>,
240    Option<bool>,
241) -> CompleterFuture<()>;
242
243/// The vtable of an erased [`AnyViewportOffset`]: one `&'static` table per concrete
244/// [`ViewportOffset`] type.
245struct ViewportOffsetVTable {
246    add_listener: fn(&mut App, HandleId, Listener),
247    remove_listener: fn(&mut App, HandleId, &Listener),
248    pixels: fn(&App, HandleId) -> f64,
249    has_pixels: fn(&App, HandleId) -> bool,
250    apply_viewport_dimension: fn(&mut App, HandleId, f64) -> bool,
251    apply_content_dimensions: fn(&mut App, HandleId, f64, f64) -> bool,
252    correct_by: fn(&mut App, HandleId, f64),
253    jump_to: fn(&mut App, HandleId, f64),
254    animate_to: AnimateToFn,
255    move_to: MoveToFn,
256    user_scroll_direction: fn(&App, HandleId) -> ScrollDirection,
257    allow_implicit_scrolling: fn(&App, HandleId) -> bool,
258}
259
260/// The typed handle for an erased id. Free: nothing is looked up; `get` checks the slot.
261fn resolve<T: 'static>(id: HandleId) -> Handle<T> {
262    Handle::from_id(id)
263}
264
265impl ViewportOffsetVTable {
266    const fn of<T: ViewportOffset>() -> ViewportOffsetVTable {
267        ViewportOffsetVTable {
268            add_listener: |app, id, listener| {
269                ListenableObject::add_listener(resolve::<T>(id), app, listener)
270            },
271            remove_listener: |app, id, listener| {
272                ListenableObject::remove_listener(resolve::<T>(id), app, listener)
273            },
274            pixels: |app, id| T::pixels(resolve(id), app),
275            has_pixels: |app, id| T::has_pixels(resolve(id), app),
276            apply_viewport_dimension: |app, id, dimension| {
277                T::apply_viewport_dimension(resolve(id), app, dimension)
278            },
279            apply_content_dimensions: |app, id, min, max| {
280                T::apply_content_dimensions(resolve(id), app, min, max)
281            },
282            correct_by: |app, id, correction| T::correct_by(resolve(id), app, correction),
283            jump_to: |app, id, pixels| T::jump_to(resolve(id), app, pixels),
284            animate_to: |app, id, to, duration, curve| {
285                T::animate_to(resolve(id), app, to, duration, curve)
286            },
287            move_to: |app, id, to, duration, curve, clamp| {
288                T::move_to(resolve(id), app, to, duration, curve, clamp)
289            },
290            user_scroll_direction: |app, id| T::user_scroll_direction(resolve(id), app),
291            allow_implicit_scrolling: |app, id| T::allow_implicit_scrolling(resolve(id), app),
292        }
293    }
294}
295
296/// Erased [`ViewportOffset`]: what a field or parameter Dart types as `ViewportOffset` becomes.
297///
298/// Equality is Dart's `==` on an object reference.
299#[derive(Clone, Copy)]
300pub struct AnyViewportOffset {
301    id: HandleId,
302    vtable: &'static ViewportOffsetVTable,
303}
304
305impl AnyViewportOffset {
306    /// See [`ViewportOffset::pixels`].
307    pub fn pixels(self, app: &App) -> f64 {
308        (self.vtable.pixels)(app, self.id)
309    }
310
311    /// See [`ViewportOffset::has_pixels`].
312    pub fn has_pixels(self, app: &App) -> bool {
313        (self.vtable.has_pixels)(app, self.id)
314    }
315
316    /// See [`ViewportOffset::apply_viewport_dimension`].
317    pub fn apply_viewport_dimension(self, app: &mut App, viewport_dimension: f64) -> bool {
318        (self.vtable.apply_viewport_dimension)(app, self.id, viewport_dimension)
319    }
320
321    /// See [`ViewportOffset::apply_content_dimensions`].
322    pub fn apply_content_dimensions(
323        self,
324        app: &mut App,
325        min_scroll_extent: f64,
326        max_scroll_extent: f64,
327    ) -> bool {
328        (self.vtable.apply_content_dimensions)(app, self.id, min_scroll_extent, max_scroll_extent)
329    }
330
331    /// See [`ViewportOffset::correct_by`].
332    pub fn correct_by(self, app: &mut App, correction: f64) {
333        (self.vtable.correct_by)(app, self.id, correction)
334    }
335
336    /// See [`ViewportOffset::jump_to`].
337    pub fn jump_to(self, app: &mut App, pixels: f64) {
338        (self.vtable.jump_to)(app, self.id, pixels)
339    }
340
341    /// See [`ViewportOffset::animate_to`].
342    pub fn animate_to(
343        self,
344        app: &mut App,
345        to: f64,
346        duration: Duration,
347        curve: Rc<dyn Curve>,
348    ) -> CompleterFuture<()> {
349        (self.vtable.animate_to)(app, self.id, to, duration, curve)
350    }
351
352    /// See [`ViewportOffset::move_to`].
353    pub fn move_to(
354        self,
355        app: &mut App,
356        to: f64,
357        duration: Option<Duration>,
358        curve: Option<Rc<dyn Curve>>,
359        clamp: Option<bool>,
360    ) -> CompleterFuture<()> {
361        (self.vtable.move_to)(app, self.id, to, duration, curve, clamp)
362    }
363
364    /// See [`ViewportOffset::user_scroll_direction`].
365    pub fn user_scroll_direction(self, app: &App) -> ScrollDirection {
366        (self.vtable.user_scroll_direction)(app, self.id)
367    }
368
369    /// See [`ViewportOffset::allow_implicit_scrolling`].
370    pub fn allow_implicit_scrolling(self, app: &App) -> bool {
371        (self.vtable.allow_implicit_scrolling)(app, self.id)
372    }
373}
374
375impl PartialEq for AnyViewportOffset {
376    fn eq(&self, other: &AnyViewportOffset) -> bool {
377        self.id == other.id
378    }
379}
380
381impl Eq for AnyViewportOffset {}
382
383impl Hash for AnyViewportOffset {
384    fn hash<H: Hasher>(&self, state: &mut H) {
385        self.id.hash(state);
386    }
387}
388
389impl Debug for AnyViewportOffset {
390    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
391        write!(f, "AnyViewportOffset({:?})", self.id)
392    }
393}
394
395impl Listenable for AnyViewportOffset {
396    fn add_listener(&self, app: &mut App, listener: Listener) {
397        (self.vtable.add_listener)(app, self.id, listener)
398    }
399
400    fn remove_listener(&self, app: &mut App, listener: &Listener) {
401        (self.vtable.remove_listener)(app, self.id, listener)
402    }
403}
404
405/// A [`ViewportOffset`] whose [`pixels`](ViewportOffset::pixels) value does not change unless
406/// the viewport issues a correction.
407///
408/// Flutter's private `_FixedViewportOffset`, reached through the `ViewportOffset.fixed` and
409/// `ViewportOffset.zero` factories.
410#[derive(Debug, Default)]
411pub struct FixedViewportOffset {
412    change_notifier: ChangeNotifierData,
413    pixels: f64,
414}
415
416impl FixedViewportOffset {
417    /// Creates a viewport offset with the given [`pixels`](ViewportOffset::pixels) value.
418    ///
419    /// Dart's `ViewportOffset.fixed`.
420    pub fn new(app: &mut App, value: f64) -> Handle<FixedViewportOffset> {
421        app.create(FixedViewportOffset {
422            change_notifier: ChangeNotifierData::new(),
423            pixels: value,
424        })
425    }
426
427    /// Creates a viewport offset with a [`pixels`](ViewportOffset::pixels) value of 0.0.
428    ///
429    /// Dart's `ViewportOffset.zero`.
430    pub fn zero(app: &mut App) -> Handle<FixedViewportOffset> {
431        FixedViewportOffset::new(app, 0.0)
432    }
433}
434
435impl ChangeNotifier for FixedViewportOffset {
436    fn change_notifier_data(&self) -> &ChangeNotifierData {
437        &self.change_notifier
438    }
439
440    fn change_notifier_data_mut(&mut self) -> &mut ChangeNotifierData {
441        &mut self.change_notifier
442    }
443}
444
445impl ViewportOffset for FixedViewportOffset {
446    fn pixels(self: Handle<Self>, app: &App) -> f64 {
447        app.get(self).pixels
448    }
449
450    fn has_pixels(self: Handle<Self>, _app: &App) -> bool {
451        true
452    }
453
454    fn apply_viewport_dimension(
455        self: Handle<Self>,
456        _app: &mut App,
457        _viewport_dimension: f64,
458    ) -> bool {
459        true
460    }
461
462    fn apply_content_dimensions(
463        self: Handle<Self>,
464        _app: &mut App,
465        _min_scroll_extent: f64,
466        _max_scroll_extent: f64,
467    ) -> bool {
468        true
469    }
470
471    fn correct_by(self: Handle<Self>, app: &mut App, correction: f64) {
472        app.get_mut(self).pixels += correction;
473    }
474
475    fn jump_to(self: Handle<Self>, _app: &mut App, _pixels: f64) {
476        // Do nothing, viewport is fixed.
477    }
478
479    fn animate_to(
480        self: Handle<Self>,
481        _app: &mut App,
482        _to: f64,
483        _duration: Duration,
484        _curve: Rc<dyn Curve>,
485    ) -> CompleterFuture<()> {
486        CompleterFuture::ready(())
487    }
488
489    fn user_scroll_direction(self: Handle<Self>, _app: &App) -> ScrollDirection {
490        ScrollDirection::Idle
491    }
492
493    fn allow_implicit_scrolling(self: Handle<Self>, _app: &App) -> bool {
494        false
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use inset_foundation::AppCell;
502
503    #[test]
504    fn a_fixed_offset_only_moves_by_correction() {
505        let cell = AppCell::new();
506        let mut app = cell.borrow_mut();
507        let offset = FixedViewportOffset::new(&mut app, 12.0);
508        assert_eq!(offset.pixels(&app), 12.0);
509        assert!(offset.has_pixels(&app));
510        offset.jump_to(&mut app, 40.0);
511        assert_eq!(offset.pixels(&app), 12.0);
512        offset.correct_by(&mut app, -5.0);
513        assert_eq!(offset.pixels(&app), 7.0);
514        assert_eq!(offset.user_scroll_direction(&app), ScrollDirection::Idle);
515        assert!(!offset.allow_implicit_scrolling(&app));
516    }
517
518    #[test]
519    fn the_erased_edge_dispatches_to_the_concrete_offset() {
520        let cell = AppCell::new();
521        let mut app = cell.borrow_mut();
522        let offset = FixedViewportOffset::zero(&mut app).as_viewport_offset();
523        assert_eq!(offset.pixels(&app), 0.0);
524        assert!(offset.apply_viewport_dimension(&mut app, 100.0));
525        assert!(offset.apply_content_dimensions(&mut app, 0.0, 200.0));
526        let moved = offset.move_to(&mut app, 30.0, None, None, None);
527        assert_eq!(offset.pixels(&app), 0.0);
528        assert!(
529            moved.is_completed(),
530            "a jump hands back an already completed future"
531        );
532        let animated = offset.animate_to(&mut app, 30.0, Duration::from_millis(10), Curves::ease());
533        assert!(
534            animated.is_completed(),
535            "a fixed offset's animation is over before it starts"
536        );
537    }
538}