Skip to main content

frus_widgets/
scrollbehavior.rs

1//! [`ScrollBehavior`] and [`ScrollConfiguration`]: **how the scrollables of a subtree
2//! behave** — their physics, their scrollbars, what they show when pulled past an edge,
3//! how a fling is read — decided in one place, from one platform (milestone 616).
4//!
5//! The reference's shape (`scroll_configuration.dart`). A scrollable does not decide any
6//! of these on its own: it asks the behaviour in force where it stands, and the behaviour
7//! answers from the platform it follows. The base behaviour follows the platform the
8//! application runs on; the one an application installs by default follows the **theme's**
9//! platform, so a theme set to iOS bounces, hides its scrollbars and reads flings the iOS
10//! way on any device. A subtree that wants otherwise installs its own with a
11//! [`ScrollConfiguration`], usually the inherited one with a change:
12//!
13//! ```ignore
14//! ScrollConfiguration::new(
15//!     ScrollConfiguration::of().with_scrollbars(false),
16//!     ListView::new(...),
17//! )
18//! ```
19
20use std::cell::Cell;
21use std::hash::{Hash, Hasher};
22
23use frus_core::{TargetPlatform, VelocityStrategy, VelocityTracker};
24
25use crate::physics::{OverscrollIndicator, ScrollDecelerationRate, ScrollPhysics, Scrollbars};
26use crate::theme::Theme;
27use crate::widget::Widget;
28
29/// Which of the two behaviours a [`ScrollBehavior`] starts from.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
31enum Base {
32    /// The base behaviour: the platform the application runs on, a glow on Android.
33    Plain,
34    /// The application's: the theme's platform, the stretch on Android.
35    Material,
36}
37
38/// **How the scrollables under it behave**, from the platform it follows.
39///
40/// Two starting points, as in the reference: [`ScrollBehavior::new`], the base, which
41/// follows [`default_target_platform`](frus_core::default_target_platform), and
42/// [`ScrollBehavior::material`], the one an application installs, which follows
43/// [`Theme::platform`]. Each can be changed with the `with_` methods, the reference's
44/// `copyWith`: a behaviour with its scrollbars or its overscroll indicator turned off, with
45/// physics of its own, or following another platform.
46///
47/// The answers, platform by platform, are the reference's:
48///
49/// | | physics | scrollbars | overscroll | fling |
50/// |---|---|---|---|---|
51/// | Android | clamping | none | stretch (base: glow) | regression |
52/// | Fuchsia | clamping | none | glow | regression |
53/// | iOS | bouncing | none | none | recent average |
54/// | macOS | bouncing, fast | shown | none | recent average, desktop |
55/// | Linux, Windows | clamping | shown | none | regression |
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
57pub struct ScrollBehavior {
58    base: Base,
59    scrollbars: bool,
60    overscroll: bool,
61    physics: Option<ScrollPhysics>,
62    platform: Option<TargetPlatform>,
63}
64
65impl Default for ScrollBehavior {
66    /// The base behaviour, [`ScrollBehavior::new`].
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72impl ScrollBehavior {
73    /// The **base** behaviour: it follows the platform the application runs on
74    /// ([`default_target_platform`](frus_core::default_target_platform)), and shows a glow
75    /// past the edge on Android and Fuchsia. What [`ScrollConfiguration::of`] answers where
76    /// nothing was installed.
77    pub const fn new() -> Self {
78        Self {
79            base: Base::Plain,
80            scrollbars: true,
81            overscroll: true,
82            physics: None,
83            platform: None,
84        }
85    }
86
87    /// The **application's** behaviour: it follows the theme's platform
88    /// ([`Theme::platform`]), and stretches the content past the edge on Android. What an
89    /// application installs unless it says otherwise
90    /// (`Application::scroll_behavior`).
91    pub const fn material() -> Self {
92        Self {
93            base: Base::Material,
94            ..Self::new()
95        }
96    }
97
98    /// The same behaviour, with its scrollbars on or off. Off, none is drawn on any
99    /// platform; on, the platform decides.
100    #[must_use]
101    pub const fn with_scrollbars(mut self, scrollbars: bool) -> Self {
102        self.scrollbars = scrollbars;
103        self
104    }
105
106    /// The same behaviour, with its overscroll indicator on or off. Off, nothing is shown
107    /// past an edge on any platform; on, the platform decides.
108    #[must_use]
109    pub const fn with_overscroll(mut self, overscroll: bool) -> Self {
110        self.overscroll = overscroll;
111        self
112    }
113
114    /// The same behaviour, with these physics on every platform.
115    #[must_use]
116    pub const fn with_physics(mut self, physics: ScrollPhysics) -> Self {
117        self.physics = Some(physics);
118        self
119    }
120
121    /// The same behaviour, following `platform` instead of the one it would have followed.
122    #[must_use]
123    pub const fn with_platform(mut self, platform: TargetPlatform) -> Self {
124        self.platform = Some(platform);
125        self
126    }
127
128    /// The platform this behaviour follows under `theme`: the one it was given, else the
129    /// theme's for the application's behaviour, else the one the application runs on.
130    pub fn platform(&self, theme: &Theme) -> TargetPlatform {
131        self.platform.unwrap_or_else(|| match self.base {
132            Base::Material => theme.platform,
133            Base::Plain => frus_core::default_target_platform(),
134        })
135    }
136
137    /// The physics a scrollable under this behaviour starts from: bouncing on iOS, bouncing
138    /// with a faster stop on macOS, clamping elsewhere — unless the behaviour was given its
139    /// own. A scrollable's own physics take precedence over these.
140    pub fn scroll_physics(&self, theme: &Theme) -> ScrollPhysics {
141        self.physics.unwrap_or(match self.platform(theme) {
142            TargetPlatform::Ios => ScrollPhysics::bouncing(ScrollDecelerationRate::Normal),
143            TargetPlatform::MacOs => ScrollPhysics::bouncing(ScrollDecelerationRate::Fast),
144            TargetPlatform::Android
145            | TargetPlatform::Fuchsia
146            | TargetPlatform::Linux
147            | TargetPlatform::Windows => ScrollPhysics::CLAMPING,
148        })
149    }
150
151    /// Whether a vertical scrollable under this behaviour draws a scrollbar: on Linux,
152    /// macOS and Windows, where a pointer needs one to find its way; not on a touch screen,
153    /// where a finger already knows where it is on the page.
154    pub fn scrollbars(&self, theme: &Theme) -> Scrollbars {
155        if !self.scrollbars {
156            return Scrollbars::Never;
157        }
158        match self.platform(theme) {
159            TargetPlatform::Linux | TargetPlatform::MacOs | TargetPlatform::Windows => {
160                Scrollbars::Always
161            }
162            TargetPlatform::Android | TargetPlatform::Fuchsia | TargetPlatform::Ios => {
163                Scrollbars::Never
164            }
165        }
166    }
167
168    /// What a scrollable under this behaviour shows when it is pulled past an edge it will
169    /// not move beyond, or `None` for nothing: the stretch on Android under the
170    /// application's behaviour, a glow on Android under the base one and on Fuchsia under
171    /// both, nothing on iOS, Linux, macOS and Windows.
172    pub fn overscroll_indicator(&self, theme: &Theme) -> Option<OverscrollIndicator> {
173        if !self.overscroll {
174            return None;
175        }
176        match (self.platform(theme), self.base) {
177            (TargetPlatform::Android, Base::Material) => Some(OverscrollIndicator::Stretch),
178            (TargetPlatform::Android, Base::Plain) | (TargetPlatform::Fuchsia, _) => {
179                Some(OverscrollIndicator::Glow)
180            }
181            (
182                TargetPlatform::Ios
183                | TargetPlatform::Linux
184                | TargetPlatform::MacOs
185                | TargetPlatform::Windows,
186                _,
187            ) => None,
188        }
189    }
190
191    /// How a fling under this behaviour is read from the finger's last moves: a weighted
192    /// average of the most recent on iOS and macOS (each with its own weights), a fitted
193    /// curve elsewhere.
194    pub fn velocity_tracker(&self, theme: &Theme) -> VelocityTracker {
195        VelocityTracker::new(match self.platform(theme) {
196            TargetPlatform::Ios => {
197                VelocityStrategy::RecentAverage(frus_core::BOUNCING_FLING_WEIGHTS)
198            }
199            TargetPlatform::MacOs => {
200                VelocityStrategy::RecentAverage(frus_core::DESKTOP_FLING_WEIGHTS)
201            }
202            TargetPlatform::Android
203            | TargetPlatform::Fuchsia
204            | TargetPlatform::Linux
205            | TargetPlatform::Windows => VelocityStrategy::Regression,
206        })
207    }
208
209    /// Hashed into the relayout fingerprint: a subtree under another behaviour may lay out
210    /// another scrollbar.
211    pub(crate) fn shape_hash<H: Hasher>(&self, hasher: &mut H) {
212        self.hash(hasher);
213    }
214}
215
216thread_local! {
217    /// The behaviour in force where the walk is. The shell installs the application's at
218    /// the root of every walk; [`ScrollConfiguration`] replaces it for its subtree.
219    static AMBIENT: Cell<ScrollBehavior> = const { Cell::new(ScrollBehavior::new()) };
220}
221
222/// Puts a [`ScrollBehavior`] in force for a subtree.
223pub struct ScrollConfiguration<Msg = crate::callback::Callback> {
224    behavior: ScrollBehavior,
225    inner: Box<dyn Widget<Msg>>,
226}
227
228impl ScrollConfiguration {
229    /// The behaviour in force where this is asked — during a build or a layout — or the
230    /// base one, [`ScrollBehavior::new`], where nothing installed one.
231    #[inline(never)]
232    pub fn of() -> ScrollBehavior {
233        AMBIENT.with(Cell::get)
234    }
235
236    /// Makes `behavior` the one in force until the guard is dropped: what the walks do for
237    /// each [`ScrollConfiguration`], and what a test does to lay a tree out under a
238    /// behaviour.
239    #[inline(never)]
240    pub fn install(behavior: ScrollBehavior) -> ScrollBehaviorGuard {
241        ScrollBehaviorGuard(AMBIENT.with(|a| a.replace(behavior)))
242    }
243
244    /// Makes `behavior` the one in force at the **root** of the tree, under every
245    /// [`ScrollConfiguration`]: the application's, which the shell sets afresh each frame
246    /// from `Application::scroll_behavior`, as an application's root installs its own.
247    #[inline(never)]
248    pub fn set_root(behavior: ScrollBehavior) {
249        AMBIENT.with(|a| a.set(behavior));
250    }
251}
252
253impl<Msg> ScrollConfiguration<Msg> {
254    /// `child`, and everything under it, scrolling as `behavior` says.
255    pub fn new(behavior: ScrollBehavior, child: impl Widget<Msg> + 'static) -> Self {
256        Self {
257            behavior,
258            inner: Box::new(child),
259        }
260    }
261
262    fn restyle(&self, base: frus_layout::Style) -> frus_layout::Style {
263        base
264    }
265}
266
267/// Puts the previous behaviour back when dropped.
268pub struct ScrollBehaviorGuard(ScrollBehavior);
269
270impl Drop for ScrollBehaviorGuard {
271    #[inline(never)]
272    fn drop(&mut self) {
273        AMBIENT.with(|a| a.set(self.0));
274    }
275}
276
277crate::transparent::forward_transparent!(ScrollConfiguration {
278    fn key(&self) -> Option<u64> {
279        self.inner.key()
280    }
281    fn positioned(&self) -> Option<crate::positioned::Positioning> {
282        self.inner.positioned()
283    }
284    fn theme_override(&self, inherited: &crate::theme::Theme) -> Option<Box<crate::theme::Theme>> {
285        self.inner.theme_override(inherited)
286    }
287    fn media_override(&self, inherited: crate::MediaQuery) -> Option<crate::MediaQuery> {
288        self.inner.media_override(inherited)
289    }
290    fn scaffold_override(&self) -> Option<crate::ScaffoldInfo> {
291        self.inner.scaffold_override()
292    }
293    fn scroll_behavior_override(&self) -> Option<ScrollBehavior> {
294        Some(self.inner.scroll_behavior_override().unwrap_or(self.behavior))
295    }
296    fn autofill_group(&self) -> bool {
297        self.inner.autofill_group()
298    }
299    fn area_toolbar(
300        &self,
301        context: crate::ToolbarContext,
302    ) -> Option<Option<&dyn crate::widget::Widget<Msg>>> {
303        self.inner.area_toolbar(context)
304    }
305});
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use frus_core::TargetPlatform as P;
311
312    fn under(platform: P) -> Theme {
313        Theme::default().with_platform(platform)
314    }
315
316    /// **The reference's tables**, for every platform, under the application's behaviour.
317    #[test]
318    fn the_application_s_behaviour_follows_the_theme_s_platform() {
319        let behavior = ScrollBehavior::material();
320        for platform in P::ALL {
321            let theme = under(platform);
322            assert_eq!(behavior.platform(&theme), platform);
323            let physics = match platform {
324                P::Ios => ScrollPhysics::bouncing(ScrollDecelerationRate::Normal),
325                P::MacOs => ScrollPhysics::bouncing(ScrollDecelerationRate::Fast),
326                _ => ScrollPhysics::CLAMPING,
327            };
328            assert_eq!(behavior.scroll_physics(&theme), physics, "{platform}");
329            let bars = if platform.is_desktop() {
330                Scrollbars::Always
331            } else {
332                Scrollbars::Never
333            };
334            assert_eq!(behavior.scrollbars(&theme), bars, "{platform}");
335            let past = match platform {
336                P::Android => Some(OverscrollIndicator::Stretch),
337                P::Fuchsia => Some(OverscrollIndicator::Glow),
338                _ => None,
339            };
340            assert_eq!(behavior.overscroll_indicator(&theme), past, "{platform}");
341            let fling = match platform {
342                P::Ios => VelocityStrategy::RecentAverage(frus_core::BOUNCING_FLING_WEIGHTS),
343                P::MacOs => VelocityStrategy::RecentAverage(frus_core::DESKTOP_FLING_WEIGHTS),
344                _ => VelocityStrategy::Regression,
345            };
346            assert_eq!(
347                behavior.velocity_tracker(&theme).strategy(),
348                fling,
349                "{platform}"
350            );
351        }
352    }
353
354    /// **The base behaviour follows the system, not the theme**, and glows on Android
355    /// where the application's stretches.
356    #[test]
357    fn the_base_behaviour_follows_the_system() {
358        let base = ScrollBehavior::new();
359        let system = frus_core::default_target_platform();
360        for platform in P::ALL {
361            assert_eq!(base.platform(&under(platform)), system);
362        }
363        let android = base.with_platform(P::Android);
364        assert_eq!(
365            android.overscroll_indicator(&under(P::Ios)),
366            Some(OverscrollIndicator::Glow)
367        );
368        assert_eq!(
369            ScrollConfiguration::of(),
370            ScrollBehavior::new(),
371            "nothing installed"
372        );
373    }
374
375    /// **A change keeps the rest** — the reference's `copyWith`: scrollbars and overscroll
376    /// turned off everywhere, physics and platform pinned.
377    #[test]
378    fn a_changed_behaviour_keeps_what_it_did_not_change() {
379        let desktop = under(P::Windows);
380        let android = under(P::Android);
381        let quiet = ScrollBehavior::material()
382            .with_scrollbars(false)
383            .with_overscroll(false);
384        assert_eq!(quiet.scrollbars(&desktop), Scrollbars::Never);
385        assert_eq!(quiet.overscroll_indicator(&android), None);
386        assert_eq!(quiet.scroll_physics(&android), ScrollPhysics::CLAMPING);
387        let bouncy = ScrollBehavior::material().with_physics(ScrollPhysics::BOUNCING);
388        assert_eq!(bouncy.scroll_physics(&android), ScrollPhysics::BOUNCING);
389        assert_eq!(bouncy.scrollbars(&desktop), Scrollbars::Always);
390        let ios = ScrollBehavior::material().with_platform(P::Ios);
391        assert_eq!(ios.platform(&android), P::Ios);
392        assert_eq!(ios.overscroll_indicator(&android), None);
393    }
394
395    /// **Installed for a subtree, and put back**: the guard restores what was in force.
396    #[test]
397    fn an_installed_behaviour_is_put_back() {
398        let outer = ScrollBehavior::material();
399        let inner = outer.with_scrollbars(false);
400        let _a = ScrollConfiguration::install(outer);
401        {
402            let _b = ScrollConfiguration::install(inner);
403            assert_eq!(ScrollConfiguration::of(), inner);
404        }
405        assert_eq!(ScrollConfiguration::of(), outer);
406    }
407}