1use frus_core::{Curve, TargetPlatform};
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
18pub enum PageTransitionsBuilder {
19 Zoom,
23 Cupertino,
26 FadeForwards,
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
36pub struct PageTransitionsTheme {
37 builders: [Option<PageTransitionsBuilder>; 6],
40}
41
42impl Default for PageTransitionsTheme {
43 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 #[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 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#[derive(Clone, Copy, Debug, PartialEq)]
89pub struct PagePose {
90 pub dx: f32,
93 pub scale: f32,
95 pub opacity: f32,
97}
98
99impl PagePose {
100 pub const REST: PagePose = PagePose {
102 dx: 0.0,
103 scale: 1.0,
104 opacity: 1.0,
105 };
106}
107
108#[derive(Clone, Copy, Debug, PartialEq)]
110pub struct TransitionFrame {
111 pub top: PagePose,
113 pub below: PagePose,
115 pub backdrop: f32,
119 pub scrim: f32,
122 pub barrier: f32,
125 pub edge_shadow: f32,
127 pub mirrored: bool,
130}
131
132pub const CUPERTINO_BARRIER_OPACITY: f32 = 0x18 as f32 / 255.0;
134
135impl PageTransitionsBuilder {
136 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 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
161fn 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
175fn 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 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
222fn cupertino(p: f32, forward: bool, gesture: bool) -> TransitionFrame {
227 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 barrier: CUPERTINO_BARRIER_OPACITY * Curve::ease().transform(a),
255 edge_shadow: Curve::linear_to_ease_out().transform(a),
256 mirrored: true,
257 }
258}
259
260fn 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 #[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 #[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 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 #[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 #[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 #[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 #[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 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}