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    pub fn of() -> ScrollBehavior {
232        AMBIENT.with(Cell::get)
233    }
234
235    /// Makes `behavior` the one in force until the guard is dropped: what the walks do for
236    /// each [`ScrollConfiguration`], and what a test does to lay a tree out under a
237    /// behaviour.
238    pub fn install(behavior: ScrollBehavior) -> ScrollBehaviorGuard {
239        ScrollBehaviorGuard(AMBIENT.with(|a| a.replace(behavior)))
240    }
241
242    /// Makes `behavior` the one in force at the **root** of the tree, under every
243    /// [`ScrollConfiguration`]: the application's, which the shell sets afresh each frame
244    /// from `Application::scroll_behavior`, as an application's root installs its own.
245    pub fn set_root(behavior: ScrollBehavior) {
246        AMBIENT.with(|a| a.set(behavior));
247    }
248}
249
250impl<Msg> ScrollConfiguration<Msg> {
251    /// `child`, and everything under it, scrolling as `behavior` says.
252    pub fn new(behavior: ScrollBehavior, child: impl Widget<Msg> + 'static) -> Self {
253        Self {
254            behavior,
255            inner: Box::new(child),
256        }
257    }
258
259    fn restyle(&self, base: frus_layout::Style) -> frus_layout::Style {
260        base
261    }
262}
263
264/// Puts the previous behaviour back when dropped.
265pub struct ScrollBehaviorGuard(ScrollBehavior);
266
267impl Drop for ScrollBehaviorGuard {
268    fn drop(&mut self) {
269        AMBIENT.with(|a| a.set(self.0));
270    }
271}
272
273crate::transparent::forward_transparent!(ScrollConfiguration {
274    fn key(&self) -> Option<u64> {
275        self.inner.key()
276    }
277    fn positioned(&self) -> Option<crate::positioned::Positioning> {
278        self.inner.positioned()
279    }
280    fn theme_override(&self, inherited: &crate::theme::Theme) -> Option<Box<crate::theme::Theme>> {
281        self.inner.theme_override(inherited)
282    }
283    fn media_override(&self, inherited: crate::MediaQuery) -> Option<crate::MediaQuery> {
284        self.inner.media_override(inherited)
285    }
286    fn scaffold_override(&self) -> Option<crate::ScaffoldInfo> {
287        self.inner.scaffold_override()
288    }
289    fn scroll_behavior_override(&self) -> Option<ScrollBehavior> {
290        Some(self.inner.scroll_behavior_override().unwrap_or(self.behavior))
291    }
292    fn autofill_group(&self) -> bool {
293        self.inner.autofill_group()
294    }
295    fn area_toolbar(
296        &self,
297        context: crate::ToolbarContext,
298    ) -> Option<Option<&dyn crate::widget::Widget<Msg>>> {
299        self.inner.area_toolbar(context)
300    }
301});
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use frus_core::TargetPlatform as P;
307
308    fn under(platform: P) -> Theme {
309        Theme::default().with_platform(platform)
310    }
311
312    /// **The reference's tables**, for every platform, under the application's behaviour.
313    #[test]
314    fn the_application_s_behaviour_follows_the_theme_s_platform() {
315        let behavior = ScrollBehavior::material();
316        for platform in P::ALL {
317            let theme = under(platform);
318            assert_eq!(behavior.platform(&theme), platform);
319            let physics = match platform {
320                P::Ios => ScrollPhysics::bouncing(ScrollDecelerationRate::Normal),
321                P::MacOs => ScrollPhysics::bouncing(ScrollDecelerationRate::Fast),
322                _ => ScrollPhysics::CLAMPING,
323            };
324            assert_eq!(behavior.scroll_physics(&theme), physics, "{platform}");
325            let bars = if platform.is_desktop() {
326                Scrollbars::Always
327            } else {
328                Scrollbars::Never
329            };
330            assert_eq!(behavior.scrollbars(&theme), bars, "{platform}");
331            let past = match platform {
332                P::Android => Some(OverscrollIndicator::Stretch),
333                P::Fuchsia => Some(OverscrollIndicator::Glow),
334                _ => None,
335            };
336            assert_eq!(behavior.overscroll_indicator(&theme), past, "{platform}");
337            let fling = match platform {
338                P::Ios => VelocityStrategy::RecentAverage(frus_core::BOUNCING_FLING_WEIGHTS),
339                P::MacOs => VelocityStrategy::RecentAverage(frus_core::DESKTOP_FLING_WEIGHTS),
340                _ => VelocityStrategy::Regression,
341            };
342            assert_eq!(
343                behavior.velocity_tracker(&theme).strategy(),
344                fling,
345                "{platform}"
346            );
347        }
348    }
349
350    /// **The base behaviour follows the system, not the theme**, and glows on Android
351    /// where the application's stretches.
352    #[test]
353    fn the_base_behaviour_follows_the_system() {
354        let base = ScrollBehavior::new();
355        let system = frus_core::default_target_platform();
356        for platform in P::ALL {
357            assert_eq!(base.platform(&under(platform)), system);
358        }
359        let android = base.with_platform(P::Android);
360        assert_eq!(
361            android.overscroll_indicator(&under(P::Ios)),
362            Some(OverscrollIndicator::Glow)
363        );
364        assert_eq!(
365            ScrollConfiguration::of(),
366            ScrollBehavior::new(),
367            "nothing installed"
368        );
369    }
370
371    /// **A change keeps the rest** — the reference's `copyWith`: scrollbars and overscroll
372    /// turned off everywhere, physics and platform pinned.
373    #[test]
374    fn a_changed_behaviour_keeps_what_it_did_not_change() {
375        let desktop = under(P::Windows);
376        let android = under(P::Android);
377        let quiet = ScrollBehavior::material()
378            .with_scrollbars(false)
379            .with_overscroll(false);
380        assert_eq!(quiet.scrollbars(&desktop), Scrollbars::Never);
381        assert_eq!(quiet.overscroll_indicator(&android), None);
382        assert_eq!(quiet.scroll_physics(&android), ScrollPhysics::CLAMPING);
383        let bouncy = ScrollBehavior::material().with_physics(ScrollPhysics::BOUNCING);
384        assert_eq!(bouncy.scroll_physics(&android), ScrollPhysics::BOUNCING);
385        assert_eq!(bouncy.scrollbars(&desktop), Scrollbars::Always);
386        let ios = ScrollBehavior::material().with_platform(P::Ios);
387        assert_eq!(ios.platform(&android), P::Ios);
388        assert_eq!(ios.overscroll_indicator(&android), None);
389    }
390
391    /// **Installed for a subtree, and put back**: the guard restores what was in force.
392    #[test]
393    fn an_installed_behaviour_is_put_back() {
394        let outer = ScrollBehavior::material();
395        let inner = outer.with_scrollbars(false);
396        let _a = ScrollConfiguration::install(outer);
397        {
398            let _b = ScrollConfiguration::install(inner);
399            assert_eq!(ScrollConfiguration::of(), inner);
400        }
401        assert_eq!(ScrollConfiguration::of(), outer);
402    }
403}