Skip to main content

repose_material/
ripple.rs

1//! Material 3 Ripple indication.
2//!
3//! Animation specs matching Compose `RippleAnimation`:
4//!   - Fade in:  alpha 0->1, 75ms, LinearEasing
5//!   - Expand:   radius 0.3·maxDim -> targetRadius, 225ms, FastOutSlowInEasing
6//!   - Center:   pressPos -> layoutCenter, 225ms, LinearEasing (bounded only)
7//!   - Fade out: alpha 1->0, 150ms, LinearEasing (after fade-in completes)
8//!
9//! Compose's RippleAnimation.always completes fade-in to 1.0 before fading out,
10//! even if the press was released before fade-in finished.
11//! See RippleAnimation.kt `if (finishRequested && !finishedFadingIn) alpha = 1f`.
12
13use std::cell::RefCell;
14use std::rc::Rc;
15use std::time::Duration;
16
17use repose_core::animation::{AnimatedValue, AnimationSpec, Easing};
18use repose_core::animation_driver;
19use repose_core::{
20    Color, Indication, IndicationDrawNode, IndicationNodeFactory, InteractionSource, PressId, Rect,
21    Scene, SceneNode, Vec2, remember_state_with_key, request_frame,
22};
23
24const FADE_IN_MS: u64 = 75;
25const RADIUS_MS: u64 = 225;
26const FADE_OUT_MS: u64 = 150;
27
28/// StateTokens.PressedStateLayerOpacity -> fixed press opacity multiplier matching Compose.
29const PRESS_ALPHA: f32 = 0.10;
30/// StateTokens.FocusStateLayerOpacity -> Material 3 focus state layer opacity.
31const FOCUS_ALPHA: f32 = 0.12;
32/// StateTokens.HoverStateLayerOpacity -> Material 3 hover state layer opacity.
33const HOVER_ALPHA: f32 = 0.08;
34
35#[derive(Clone, Debug)]
36pub struct RippleConfig {
37    pub bounded: bool,
38    pub radius: Option<f32>,
39    pub color: Option<Color>,
40    pub enable_press: bool,
41    pub enable_focus: bool,
42    pub enable_hover: bool,
43}
44
45impl Default for RippleConfig {
46    fn default() -> Self {
47        Self {
48            bounded: true,
49            radius: None,
50            color: None,
51            enable_press: true,
52            enable_focus: true,
53            enable_hover: true,
54        }
55    }
56}
57
58pub fn ripple(config: RippleConfig) -> Rc<dyn IndicationNodeFactory> {
59    Rc::new(RippleNodeFactory { config })
60}
61
62#[derive(Clone, Debug)]
63pub struct RippleNodeFactory {
64    pub config: RippleConfig,
65}
66
67impl Indication for RippleNodeFactory {}
68
69impl IndicationNodeFactory for RippleNodeFactory {
70    fn create(&self, interaction_source: &InteractionSource) -> Box<dyn IndicationDrawNode> {
71        Box::new(RippleDrawNode::new(
72            interaction_source.clone(),
73            self.config.clone(),
74        ))
75    }
76}
77
78struct RippleDrawNode {
79    interaction_source: InteractionSource,
80    config: RippleConfig,
81}
82
83impl RippleDrawNode {
84    fn new(interaction_source: InteractionSource, config: RippleConfig) -> Self {
85        Self {
86            interaction_source,
87            config,
88        }
89    }
90
91    fn anim_base(&self) -> String {
92        format!("rp:{:p}", self.interaction_source.stable_id())
93    }
94
95    fn register_driver(key: &str, anim: Rc<RefCell<AnimatedValue<f32>>>) {
96        animation_driver::register(
97            key.to_string(),
98            Rc::new(RefCell::new(move || anim.borrow_mut().update())),
99        );
100        request_frame();
101    }
102}
103
104impl IndicationDrawNode for RippleDrawNode {
105    fn draw(&self, scene: &mut Scene, rect: Rect, radius: [f32; 4], alpha: f32) {
106        let base_color = self.config.color.unwrap_or(Color(0, 0, 0, 255));
107
108        // M3 state layers (focus, hover) rendered as shape-matched overlays.
109        // Drawn before the press ripple so the ripple fades in on top.
110        let is_pressed = self.interaction_source.collect_is_pressed();
111        let is_hovered = self.interaction_source.collect_is_hovered();
112        let is_focused = self.interaction_source.collect_is_focused();
113
114        if self.config.enable_focus && is_focused && !is_pressed {
115            scene.nodes.push(SceneNode::Rect {
116                rect,
117                brush: base_color.with_alpha_f32(FOCUS_ALPHA * alpha).into(),
118                radius,
119            });
120        }
121        if self.config.enable_hover && is_hovered && !is_pressed {
122            scene.nodes.push(SceneNode::Rect {
123                rect,
124                brush: base_color.with_alpha_f32(HOVER_ALPHA * alpha).into(),
125                radius,
126            });
127        }
128
129        if !self.config.enable_press {
130            return;
131        }
132
133        let base = self.anim_base();
134        let bounded = self.config.bounded;
135        let center_scene = Vec2 {
136            x: rect.x + rect.w * 0.5,
137            y: rect.y + rect.h * 0.5,
138        };
139
140        let target_radius = self.config.radius.unwrap_or_else(|| {
141            let diag = (rect.w * rect.w + rect.h * rect.h).sqrt();
142            if bounded {
143                diag * 0.5 + 10.0
144            } else {
145                diag * 0.5
146            }
147        });
148        let start_radius = rect.w.max(rect.h) * 0.3;
149
150        let current_pid = self.interaction_source.collect_last_press_id();
151        let press_pos = self.interaction_source.collect_last_press_position();
152
153        let k_alpha = format!("{}:a", base);
154        let k_rad = format!("{}:r", base);
155        let k_ctr = format!("{}:c", base);
156
157        let alpha_anim = remember_state_with_key(&k_alpha, || {
158            AnimatedValue::new(
159                0.0f32,
160                AnimationSpec::tween(Duration::from_millis(FADE_IN_MS), Easing::Linear),
161            )
162        });
163        let rad_anim = remember_state_with_key(&k_rad, || {
164            AnimatedValue::new(
165                0.0f32,
166                AnimationSpec::tween(Duration::from_millis(RADIUS_MS), Easing::FastOutSlowIn),
167            )
168        });
169        let ctr_anim = remember_state_with_key(&k_ctr, || {
170            AnimatedValue::new(
171                0.0f32,
172                AnimationSpec::tween(Duration::from_millis(RADIUS_MS), Easing::Linear),
173            )
174        });
175
176        // Phase: 0=idle, 1=rising(fade-in), 2=visible, 3=fading-out
177        let k_phase = format!("{}:ph", base);
178        let phase = remember_state_with_key(&k_phase, || 0u8);
179
180        // Track last processed press ID to detect new presses
181        let k_last_pid = format!("{}:lpid", base);
182        let last_pid = remember_state_with_key(&k_last_pid, || None::<PressId>);
183
184        // Pending release flag -> set when release occurs before fade-in completes
185        let k_release_pending = format!("{}:rpend", base);
186        let release_pending = remember_state_with_key(&k_release_pending, || false);
187
188        let prev_pid = *last_pid.borrow();
189
190        animation_driver::touch(&format!("{}:drv:a", base));
191        animation_driver::touch(&format!("{}:drv:r", base));
192        animation_driver::touch(&format!("{}:drv:c", base));
193
194        // Use last_press_id which persists after release (unlike is_pressed which is transient
195        // because press+release can both happen before the next frame renders).
196        let new_press = current_pid.is_some() && current_pid != prev_pid;
197
198        if new_press {
199            *last_pid.borrow_mut() = current_pid;
200            *phase.borrow_mut() = 1;
201            *release_pending.borrow_mut() = false;
202
203            let spec_in = AnimationSpec::tween(Duration::from_millis(FADE_IN_MS), Easing::Linear);
204            let spec_rad =
205                AnimationSpec::tween(Duration::from_millis(RADIUS_MS), Easing::FastOutSlowIn);
206            let spec_ctr = AnimationSpec::tween(Duration::from_millis(RADIUS_MS), Easing::Linear);
207
208            {
209                let mut a = alpha_anim.borrow_mut();
210                a.snap_to(0.0);
211                a.set_spec(spec_in);
212                a.set_target(1.0);
213            }
214            Self::register_driver(&format!("{}:drv:a", base), alpha_anim.clone());
215
216            {
217                let mut r = rad_anim.borrow_mut();
218                r.snap_to(0.0);
219                r.set_spec(spec_rad);
220                r.set_target(1.0);
221            }
222            Self::register_driver(&format!("{}:drv:r", base), rad_anim.clone());
223
224            {
225                let mut c = ctr_anim.borrow_mut();
226                c.snap_to(0.0);
227                c.set_spec(spec_ctr);
228                c.set_target(1.0);
229            }
230            Self::register_driver(&format!("{}:drv:c", base), ctr_anim.clone());
231        }
232
233        // Compare against *last_pid.borrow(), not prev_pid, because prev_pid was
234        // captured before the new-press block and would be None on first detection.
235        if *phase.borrow() != 0
236            && !is_pressed
237            && current_pid.is_some()
238            && current_pid == *last_pid.borrow()
239        {
240            *release_pending.borrow_mut() = true;
241        }
242
243        if *phase.borrow() != 0 && !animation_driver::is_registered(&format!("{}:drv:a", base)) {
244            *phase.borrow_mut() = 0;
245            *last_pid.borrow_mut() = current_pid;
246            *release_pending.borrow_mut() = false;
247            alpha_anim.borrow_mut().snap_to(0.0);
248            rad_anim.borrow_mut().snap_to(0.0);
249            ctr_anim.borrow_mut().snap_to(0.0);
250            return;
251        }
252
253        let fade_pct = *alpha_anim.borrow().get();
254
255        if *phase.borrow() == 1 && fade_pct >= 1.0 {
256            // Fade-in complete -> move to visible or start fade-out
257            if *release_pending.borrow() {
258                *phase.borrow_mut() = 3;
259                let spec_out =
260                    AnimationSpec::tween(Duration::from_millis(FADE_OUT_MS), Easing::Linear);
261                alpha_anim.borrow_mut().set_target(0.0);
262                alpha_anim.borrow_mut().set_spec(spec_out);
263                Self::register_driver(&format!("{}:drv:a", base), alpha_anim.clone());
264            } else {
265                *phase.borrow_mut() = 2;
266            }
267        }
268
269        if *phase.borrow() == 2 && *release_pending.borrow() {
270            // Release happened while visible -> start fade-out
271            *phase.borrow_mut() = 3;
272            let spec_out = AnimationSpec::tween(Duration::from_millis(FADE_OUT_MS), Easing::Linear);
273            alpha_anim.borrow_mut().set_target(0.0);
274            alpha_anim.borrow_mut().set_spec(spec_out);
275            Self::register_driver(&format!("{}:drv:a", base), alpha_anim.clone());
276        }
277
278        if *phase.borrow() == 3 && fade_pct <= 0.01 {
279            // Fade-out complete -> reset
280            *phase.borrow_mut() = 0;
281            // Keep last_pid = current_pid so the stale press ID doesn't
282            // retrigger new_press as a false positive on the next frame.
283            *last_pid.borrow_mut() = current_pid;
284            alpha_anim.borrow_mut().snap_to(0.0);
285            rad_anim.borrow_mut().snap_to(0.0);
286            ctr_anim.borrow_mut().snap_to(0.0);
287            return;
288        }
289
290        if *phase.borrow() == 0 {
291            return;
292        }
293        let snap_finish = *release_pending.borrow() && *phase.borrow() == 1;
294        if fade_pct <= 0.01 && !snap_finish {
295            return;
296        }
297
298        // Compose RippleAnimation.draw() snaps alpha to 1.0 when finish() is called
299        // during fade-in: `if (finishRequested && !finishedFadingIn) alpha = 1f`.
300        let draw_alpha = if snap_finish { 1.0f32 } else { fade_pct };
301
302        let rad_pct = *rad_anim.borrow().get();
303        let ctr_pct = *ctr_anim.borrow().get();
304        let current_radius = start_radius + (target_radius - start_radius) * rad_pct;
305
306        let origin_scene = match press_pos {
307            Some(pos) => {
308                let ox = rect.x + pos.x;
309                let oy = rect.y + pos.y;
310                if bounded {
311                    Vec2 {
312                        x: ox + (center_scene.x - ox) * ctr_pct,
313                        y: oy + (center_scene.y - oy) * ctr_pct,
314                    }
315                } else {
316                    center_scene
317                }
318            }
319            None => center_scene,
320        };
321
322        // Compose: color.copy(alpha = PressAlpha * animatedAlpha)
323        let ripple_alpha = PRESS_ALPHA * draw_alpha * alpha;
324        if ripple_alpha <= 0.001 {
325            return;
326        }
327
328        let draw_color = base_color.with_alpha_f32(ripple_alpha);
329
330        if bounded {
331            scene.nodes.push(SceneNode::PushClip {
332                rect,
333                radius,
334                op: repose_core::ClipOp::Intersect,
335            });
336            scene.nodes.push(SceneNode::Ellipse {
337                rect: Rect {
338                    x: origin_scene.x - current_radius,
339                    y: origin_scene.y - current_radius,
340                    w: current_radius * 2.0,
341                    h: current_radius * 2.0,
342                },
343                brush: draw_color.into(),
344            });
345            scene.nodes.push(SceneNode::PopClip);
346        } else {
347            scene.nodes.push(SceneNode::Ellipse {
348                rect: Rect {
349                    x: origin_scene.x - current_radius,
350                    y: origin_scene.y - current_radius,
351                    w: current_radius * 2.0,
352                    h: current_radius * 2.0,
353                },
354                brush: draw_color.into(),
355            });
356        }
357    }
358}