Skip to main content

frus_widgets/
pagetransitions.rs

1//! **How a page arrives and leaves**, platform by platform (milestone 620).
2//!
3//! The reference's shape (`page_transitions_theme.dart`): a [`PageTransitionsTheme`] on the
4//! theme maps each platform to a [`PageTransitionsBuilder`], and the builder decides how the
5//! page being pushed or popped, and the page under it, look at each moment of the
6//! transition. By default the desktops zoom, Apple's platforms slide, and Android fades
7//! forwards — the reference's own defaults, read from the **theme's** platform so a theme
8//! set to iOS slides on any device.
9//!
10//! Everything here is a pure function of the transition's progress: the navigator asks
11//! [`PageTransitionsBuilder::frame`] what to paint, and the router runs the progress over
12//! [`PageTransitionsBuilder::transition_duration`].
13
14use frus_core::{Curve, TargetPlatform};
15
16/// One of the reference's page transitions.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
18pub enum PageTransitionsBuilder {
19    /// The page zooms in from 85 % over a scrim, the one under it grows a little; popping,
20    /// the page shrinks away and fades (`ZoomPageTransitionsBuilder`). What the desktops
21    /// use by default, and what a platform with no builder falls back to.
22    Zoom,
23    /// The page slides in from the end edge while the one under it drifts a third of the
24    /// way towards the start, as iOS pages do (`CupertinoPageTransitionsBuilder`).
25    Cupertino,
26    /// The page slides a quarter of the way in as it fades in, and the one under it a
27    /// quarter of the way out as it fades out, as Android's own pages do
28    /// (`FadeForwardsPageTransitionsBuilder`). What Android uses when no back gesture is
29    /// under way.
30    FadeForwards,
31}
32
33/// How the theme's platform chooses a [`PageTransitionsBuilder`]: the reference's
34/// `PageTransitionsTheme`.
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
36pub struct PageTransitionsTheme {
37    /// One per platform, in [`TargetPlatform::ALL`]'s order; `None` falls back to
38    /// [`PageTransitionsBuilder::Zoom`].
39    builders: [Option<PageTransitionsBuilder>; 6],
40}
41
42impl Default for PageTransitionsTheme {
43    /// The reference's defaults (`page_transitions_theme.dart:764`): Android fades
44    /// forwards, iOS and macOS slide, Linux and Windows zoom, and Fuchsia, which has none,
45    /// zooms.
46    fn default() -> Self {
47        use PageTransitionsBuilder as B;
48        let mut builders = [None; 6];
49        for (platform, builder) in [
50            (TargetPlatform::Android, B::FadeForwards),
51            (TargetPlatform::Ios, B::Cupertino),
52            (TargetPlatform::MacOs, B::Cupertino),
53            (TargetPlatform::Windows, B::Zoom),
54            (TargetPlatform::Linux, B::Zoom),
55        ] {
56            builders[index(platform)] = Some(builder);
57        }
58        Self { builders }
59    }
60}
61
62fn index(platform: TargetPlatform) -> usize {
63    TargetPlatform::ALL
64        .iter()
65        .position(|p| *p == platform)
66        .unwrap_or(0)
67}
68
69impl PageTransitionsTheme {
70    /// The same theme, with `builder` for `platform`.
71    #[must_use]
72    pub fn with(mut self, platform: TargetPlatform, builder: PageTransitionsBuilder) -> Self {
73        self.builders[index(platform)] = Some(builder);
74        self
75    }
76
77    /// The builder for `platform`: its own, else the zoom, as the reference falls back
78    /// (`page_transitions_theme.dart:881`) — except iOS, which slides whatever.
79    pub fn builder_for(&self, platform: TargetPlatform) -> PageTransitionsBuilder {
80        self.builders[index(platform)].unwrap_or(match platform {
81            TargetPlatform::Ios => PageTransitionsBuilder::Cupertino,
82            _ => PageTransitionsBuilder::Zoom,
83        })
84    }
85}
86
87/// How one page looks at a moment of a transition.
88#[derive(Clone, Copy, Debug, PartialEq)]
89pub struct PagePose {
90    /// How far it is moved along the reading direction, in fractions of the page's
91    /// width: positive towards the end edge.
92    pub dx: f32,
93    /// Its scale about its centre.
94    pub scale: f32,
95    /// Its opacity.
96    pub opacity: f32,
97}
98
99impl PagePose {
100    /// Where a page rests.
101    pub const REST: PagePose = PagePose {
102        dx: 0.0,
103        scale: 1.0,
104        opacity: 1.0,
105    };
106}
107
108/// What a transition paints at one moment: the two pages, and what lies between them.
109#[derive(Clone, Copy, Debug, PartialEq)]
110pub struct TransitionFrame {
111    /// The page pushed, or being popped.
112    pub top: PagePose,
113    /// The page under it.
114    pub below: PagePose,
115    /// A fill of the scheme's surface **behind both pages**, at this opacity: the fade's
116    /// background, what shows where the pages have faded (`page_transitions_theme.dart:541`).
117    /// `0` for none.
118    pub backdrop: f32,
119    /// A fill of the scheme's surface **between the two pages**, at this opacity: the zoom's
120    /// scrim, which hides the page under the one arriving (`:1014`). `0` for none.
121    pub scrim: f32,
122    /// A dimming over the page under the top one, black at this opacity: the iOS route's
123    /// barrier. `0` for none.
124    pub barrier: f32,
125    /// The iOS page's shadow along its start edge, `0..=1` of its strength. `0` for none.
126    pub edge_shadow: f32,
127    /// Whether `dx` turns round under a right-to-left layout: the iOS slide does, the fade
128    /// does not (the reference's `SlideTransition` there is given no direction).
129    pub mirrored: bool,
130}
131
132/// The iOS route's barrier colour, black at `0x18` (`route.dart:48`).
133pub const CUPERTINO_BARRIER_OPACITY: f32 = 0x18 as f32 / 255.0;
134
135impl PageTransitionsBuilder {
136    /// How long a push or a pop takes, in seconds: 300 ms for the zoom, 500 for the iOS
137    /// slide, 450 for the fade (`page_transitions_builder.dart:66`, `route.dart:155`,
138    /// `page_transitions_theme.dart:470`).
139    pub fn transition_duration(self) -> f32 {
140        match self {
141            PageTransitionsBuilder::Zoom => 0.3,
142            PageTransitionsBuilder::Cupertino => 0.5,
143            PageTransitionsBuilder::FadeForwards => 0.45,
144        }
145    }
146
147    /// What is painted `progress` of the way through a transition, `0` being the old page
148    /// at rest and `1` the new one. `forward` is a push; otherwise a pop, where the top
149    /// page is the one leaving. `gesture` is a finger dragging a pop: the motion follows it
150    /// linearly, as the reference's does while a pop gesture is under way.
151    pub fn frame(self, progress: f32, forward: bool, gesture: bool) -> TransitionFrame {
152        let p = progress.clamp(0.0, 1.0);
153        match self {
154            PageTransitionsBuilder::Zoom => zoom(p, forward),
155            PageTransitionsBuilder::Cupertino => cupertino(p, forward, gesture),
156            PageTransitionsBuilder::FadeForwards => fade_forwards(p, forward),
157        }
158    }
159}
160
161/// `inner` over `[begin, end]` of `t`, linear inside.
162fn interval(begin: f32, end: f32, t: f32) -> f32 {
163    Curve::Interval {
164        begin,
165        end,
166        inner: Box::new(Curve::Linear),
167    }
168    .transform(t)
169}
170
171fn lerp(a: f32, b: f32, t: f32) -> f32 {
172    a + (b - a) * t
173}
174
175/// The zoom (`page_transitions_theme.dart:116-133`, `:175-298`). Its scales move on the
176/// emphasized curve; its fades and scrim on linear intervals.
177fn zoom(p: f32, forward: bool) -> TransitionFrame {
178    let scale =
179        |from: f32, to: f32| lerp(from, to, Curve::ease_in_out_cubic_emphasized().transform(p));
180    if forward {
181        TransitionFrame {
182            top: PagePose {
183                dx: 0.0,
184                scale: scale(0.85, 1.0),
185                opacity: interval(0.125, 0.25, p),
186            },
187            below: PagePose {
188                scale: scale(1.0, 1.05),
189                ..PagePose::REST
190            },
191            // Drawn behind the page arriving, until it has arrived (`:1014`).
192            backdrop: 0.0,
193            scrim: if p < 1.0 {
194                0.6 * interval(0.2075, 0.4175, p)
195            } else {
196                0.0
197            },
198            barrier: 0.0,
199            edge_shadow: 0.0,
200            mirrored: false,
201        }
202    } else {
203        TransitionFrame {
204            top: PagePose {
205                dx: 0.0,
206                scale: scale(1.0, 0.9),
207                opacity: 1.0 - interval(0.0825, 0.2075, p),
208            },
209            below: PagePose {
210                scale: scale(1.1, 1.0),
211                ..PagePose::REST
212            },
213            backdrop: 0.0,
214            scrim: 0.0,
215            barrier: 0.0,
216            edge_shadow: 0.0,
217            mirrored: false,
218        }
219    }
220}
221
222/// The iOS slide (`route.dart:531-572`): the top page from the end edge on
223/// `fastEaseInToSlowEaseOut` (its mirror on the way back), the one under it a third of the
224/// way towards the start on `linearToEaseOut` (`easeInToLinear` on the way back); linear
225/// under a finger.
226fn cupertino(p: f32, forward: bool, gesture: bool) -> TransitionFrame {
227    // The route's own animation, `a`: 0 when the top page is off screen, 1 at rest.
228    let a = if forward { p } else { 1.0 - p };
229    let (top, below) = if gesture {
230        (a, a)
231    } else if forward {
232        (
233            Curve::fast_ease_in_to_slow_ease_out().transform(a),
234            Curve::linear_to_ease_out().transform(a),
235        )
236    } else {
237        (
238            Curve::Flipped(Box::new(Curve::fast_ease_in_to_slow_ease_out())).transform(a),
239            Curve::ease_in_to_linear().transform(a),
240        )
241    };
242    TransitionFrame {
243        top: PagePose {
244            dx: 1.0 - top,
245            ..PagePose::REST
246        },
247        below: PagePose {
248            dx: -below / 3.0,
249            ..PagePose::REST
250        },
251        backdrop: 0.0,
252        scrim: 0.0,
253        // The barrier eases in with the route (`Curves.ease`, the barrier's default).
254        barrier: CUPERTINO_BARRIER_OPACITY * Curve::ease().transform(a),
255        edge_shadow: Curve::linear_to_ease_out().transform(a),
256        mirrored: true,
257    }
258}
259
260/// The fade forwards (`page_transitions_theme.dart:396-552`): a quarter of a page's width
261/// on the emphasized curve, with the fades on linear intervals, and the surface behind the
262/// page leaving.
263fn fade_forwards(p: f32, forward: bool) -> TransitionFrame {
264    let slide = Curve::ease_in_out_cubic_emphasized().transform(p);
265    let (top, below) = if forward {
266        (
267            PagePose {
268                dx: 0.25 * (1.0 - slide),
269                scale: 1.0,
270                opacity: interval(0.0, 0.75, p),
271            },
272            PagePose {
273                dx: -0.25 * slide,
274                scale: 1.0,
275                opacity: 1.0 - interval(0.0, 0.25, p),
276            },
277        )
278    } else {
279        (
280            PagePose {
281                dx: 0.25 * slide,
282                scale: 1.0,
283                opacity: 1.0 - interval(0.0, 0.25, p),
284            },
285            PagePose {
286                dx: -0.25 * (1.0 - slide),
287                scale: 1.0,
288                opacity: interval(0.0, 0.75, p),
289            },
290        )
291    };
292    TransitionFrame {
293        top,
294        below,
295        backdrop: if p < 1.0 { 1.0 } else { 0.0 },
296        scrim: 0.0,
297        barrier: 0.0,
298        edge_shadow: 0.0,
299        mirrored: false,
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use PageTransitionsBuilder as B;
307
308    /// **The reference's defaults**: Android fades forwards, Apple's platforms slide, the
309    /// desktops and Fuchsia zoom; a theme can say otherwise for any one of them.
310    #[test]
311    fn each_platform_has_the_reference_s_transition() {
312        let theme = PageTransitionsTheme::default();
313        for platform in TargetPlatform::ALL {
314            let want = match platform {
315                TargetPlatform::Android => B::FadeForwards,
316                TargetPlatform::Ios | TargetPlatform::MacOs => B::Cupertino,
317                _ => B::Zoom,
318            };
319            assert_eq!(theme.builder_for(platform), want, "{platform}");
320        }
321        let custom = theme.with(TargetPlatform::Linux, B::Cupertino);
322        assert_eq!(custom.builder_for(TargetPlatform::Linux), B::Cupertino);
323        assert_eq!(custom.builder_for(TargetPlatform::Windows), B::Zoom);
324        assert_eq!(B::Zoom.transition_duration(), 0.3);
325        assert_eq!(B::Cupertino.transition_duration(), 0.5);
326        assert_eq!(B::FadeForwards.transition_duration(), 0.45);
327    }
328
329    /// **Every transition starts with the old page at rest and ends with the new one at
330    /// rest**, push or pop, with nothing left between them.
331    #[test]
332    fn every_transition_begins_and_ends_at_rest() {
333        for builder in [B::Zoom, B::Cupertino, B::FadeForwards] {
334            for forward in [true, false] {
335                let start = builder.frame(0.0, forward, false);
336                let end = builder.frame(1.0, forward, false);
337                // At 0 the page that was showing shows; at 1 the page that will.
338                let (was, will) = if forward {
339                    (start.below, end.top)
340                } else {
341                    (start.top, end.below)
342                };
343                for (pose, what) in [(was, "start"), (will, "end")] {
344                    assert!(
345                        pose.dx.abs() < 1e-4
346                            && (pose.scale - 1.0).abs() < 1e-4
347                            && (pose.opacity - 1.0).abs() < 1e-4,
348                        "{builder:?} forward={forward} {what}: {pose:?}"
349                    );
350                }
351                assert_eq!(
352                    (end.backdrop, end.scrim),
353                    (0.0, 0.0),
354                    "{builder:?} leaves nothing behind"
355                );
356            }
357        }
358    }
359
360    /// **The zoom, push**: the page grows from 85 % and fades in over 12.5–25 % of the
361    /// way, over a scrim that rises to 60 % between 20.75 and 41.75 %; the page under it
362    /// grows to 105 %.
363    #[test]
364    fn the_zoom_pushes_from_85_percent_over_a_scrim() {
365        let start = B::Zoom.frame(0.0, true, false);
366        assert_eq!((start.top.scale, start.top.opacity), (0.85, 0.0));
367        let early = B::Zoom.frame(0.1, true, false);
368        assert_eq!(early.top.opacity, 0.0, "not yet faded in");
369        let quarter = B::Zoom.frame(0.25, true, false);
370        assert_eq!(quarter.top.opacity, 1.0, "faded in by a quarter");
371        let mid = B::Zoom.frame(0.5, true, false);
372        assert!((mid.scrim - 0.6).abs() < 1e-4, "the scrim at 60 %");
373        let near = B::Zoom.frame(0.999, true, false);
374        assert!((near.below.scale - 1.05).abs() < 1e-2);
375    }
376
377    /// **The zoom, pop**: the page shrinks to 90 % and is gone by 20.75 %; the one under it
378    /// settles from 110 %.
379    #[test]
380    fn the_zoom_pops_by_shrinking_away() {
381        let gone = B::Zoom.frame(0.21, false, false);
382        assert_eq!(gone.top.opacity, 0.0);
383        let start = B::Zoom.frame(0.0, false, false);
384        assert!((start.below.scale - 1.1).abs() < 1e-4);
385        assert_eq!(start.scrim, 0.0, "no scrim on the way back");
386    }
387
388    /// **The iOS slide**: in from the end edge, the page under it a third of the way out,
389    /// its barrier at 0x18 and its shadow at full strength at rest; under a finger, linear.
390    #[test]
391    fn the_cupertino_slide_parallaxes_a_third() {
392        let start = B::Cupertino.frame(0.0, true, false);
393        assert_eq!(start.top.dx, 1.0);
394        let end = B::Cupertino.frame(1.0, true, false);
395        assert!((end.below.dx + 1.0 / 3.0).abs() < 1e-4);
396        assert!((end.barrier - CUPERTINO_BARRIER_OPACITY).abs() < 1e-4);
397        assert!(end.mirrored);
398        let half = B::Cupertino.frame(0.5, false, true);
399        assert!((half.top.dx - 0.5).abs() < 1e-4, "linear under a finger");
400        assert!((half.below.dx + 1.0 / 6.0).abs() < 1e-4);
401        let eased = B::Cupertino.frame(0.5, false, false);
402        assert!(eased.top.dx != 0.5, "eased when no finger drives it");
403    }
404
405    /// **The fade forwards**: a quarter of the width each way, the new page faded in over
406    /// three quarters of the way, the old one out over the first quarter; not mirrored.
407    #[test]
408    fn the_fade_forwards_moves_a_quarter() {
409        let start = B::FadeForwards.frame(0.0, true, false);
410        assert_eq!((start.top.dx, start.top.opacity), (0.25, 0.0));
411        let quarter = B::FadeForwards.frame(0.25, true, false);
412        assert_eq!(quarter.below.opacity, 0.0);
413        // Halfway, the new page is two thirds in: its fade runs over the first 75 %.
414        let half = B::FadeForwards.frame(0.5, true, false);
415        assert!(
416            (half.top.opacity - 2.0 / 3.0).abs() < 1e-4,
417            "{}",
418            half.top.opacity
419        );
420        let back = B::FadeForwards.frame(0.5, false, false);
421        assert!(
422            (back.below.opacity - 2.0 / 3.0).abs() < 1e-4,
423            "and on the way back"
424        );
425        let end = B::FadeForwards.frame(1.0, true, false);
426        assert!((end.below.dx + 0.25).abs() < 1e-4);
427        assert!(!end.mirrored);
428        let pop = B::FadeForwards.frame(0.0, false, false);
429        assert_eq!(pop.below.dx, -0.25);
430    }
431}