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    pub press_offset: Option<Vec2>,
44}
45
46impl Default for RippleConfig {
47    fn default() -> Self {
48        Self {
49            bounded: true,
50            radius: None,
51            color: None,
52            enable_press: true,
53            enable_focus: true,
54            enable_hover: true,
55            press_offset: None,
56        }
57    }
58}
59
60pub fn ripple(config: RippleConfig) -> Rc<dyn IndicationNodeFactory> {
61    Rc::new(RippleNodeFactory { config })
62}
63
64/// Default factory for `LocalIndication` (color resolved from `content_color()` at draw time).
65pub fn default_ripple() -> Rc<dyn IndicationNodeFactory> {
66    ripple(RippleConfig::default())
67}
68
69#[derive(Clone, Debug)]
70pub struct RippleNodeFactory {
71    pub config: RippleConfig,
72}
73
74impl Indication for RippleNodeFactory {}
75
76impl IndicationNodeFactory for RippleNodeFactory {
77    fn create(&self, interaction_source: &InteractionSource) -> Box<dyn IndicationDrawNode> {
78        Box::new(RippleDrawNode::new(
79            interaction_source.clone(),
80            self.config.clone(),
81        ))
82    }
83}
84
85struct RippleDrawNode {
86    interaction_source: InteractionSource,
87    config: RippleConfig,
88}
89
90impl RippleDrawNode {
91    fn new(interaction_source: InteractionSource, config: RippleConfig) -> Self {
92        Self {
93            interaction_source,
94            config,
95        }
96    }
97
98    fn anim_base(&self) -> String {
99        format!("rp:{:p}", self.interaction_source.stable_id())
100    }
101
102    fn register_driver(key: &str, anim: Rc<RefCell<AnimatedValue<f32>>>) {
103        animation_driver::register(
104            key.to_string(),
105            Rc::new(RefCell::new(move || anim.borrow_mut().update())),
106        );
107        request_frame();
108    }
109}
110
111impl IndicationDrawNode for RippleDrawNode {
112    fn draw(&self, scene: &mut Scene, rect: Rect, radius: [f32; 4], alpha: f32) {
113        let base_color = self
114            .config
115            .color
116            .unwrap_or_else(|| repose_core::locals::content_color());
117
118        // M3 state layers (focus, hover) rendered as circles. Drawn even while
119        // pressed (Compose draws stateLayer then drawRipples together);
120        // priority: focus > hover.
121        let is_pressed = self.interaction_source.collect_is_pressed();
122        let is_hovered = self.interaction_source.collect_is_hovered();
123        let is_focused = self.interaction_source.collect_is_focus_visible();
124        let bounded = self.config.bounded;
125
126        let center_scene = Vec2 {
127            x: rect.x + rect.w * 0.5,
128            y: rect.y + rect.h * 0.5,
129        };
130        let target_radius = self.config.radius.unwrap_or_else(|| {
131            let diag = (rect.w * rect.w + rect.h * rect.h).sqrt();
132            if bounded {
133                diag * 0.5 + 10.0
134            } else {
135                diag * 0.5
136            }
137        });
138
139        let layer_alpha = if self.config.enable_focus && is_focused {
140            Some(FOCUS_ALPHA)
141        } else if self.config.enable_hover && is_hovered {
142            Some(HOVER_ALPHA)
143        } else {
144            None
145        };
146
147        if let Some(layer_a) = layer_alpha {
148            let layer_a = layer_a * alpha;
149            if layer_a > 0.001 {
150                let layer_rect = Rect {
151                    x: center_scene.x - target_radius,
152                    y: center_scene.y - target_radius,
153                    w: target_radius * 2.0,
154                    h: target_radius * 2.0,
155                };
156                let brush = base_color.with_alpha_f32(layer_a).into();
157                if bounded {
158                    scene.nodes.push(SceneNode::PushClip {
159                        rect,
160                        radius,
161                        op: repose_core::ClipOp::Intersect,
162                    });
163                }
164                scene.nodes.push(SceneNode::Ellipse {
165                    rect: layer_rect,
166                    brush,
167                });
168                if bounded {
169                    scene.nodes.push(SceneNode::PopClip);
170                }
171            }
172        }
173
174        if !self.config.enable_press {
175            return;
176        }
177
178        let base = self.anim_base();
179        let start_radius = rect.w.max(rect.h) * 0.3;
180
181        let current_pid = self.interaction_source.collect_last_press_id();
182        let press_pos = self.interaction_source.collect_last_press_position();
183
184        let k_alpha = format!("{}:a", base);
185        let k_rad = format!("{}:r", base);
186        let k_ctr = format!("{}:c", base);
187
188        let alpha_anim = remember_state_with_key(&k_alpha, || {
189            AnimatedValue::new(
190                0.0f32,
191                AnimationSpec::tween(Duration::from_millis(FADE_IN_MS), Easing::Linear),
192            )
193        });
194        let rad_anim = remember_state_with_key(&k_rad, || {
195            AnimatedValue::new(
196                0.0f32,
197                AnimationSpec::tween(Duration::from_millis(RADIUS_MS), Easing::FastOutSlowIn),
198            )
199        });
200        let ctr_anim = remember_state_with_key(&k_ctr, || {
201            AnimatedValue::new(
202                0.0f32,
203                AnimationSpec::tween(Duration::from_millis(RADIUS_MS), Easing::Linear),
204            )
205        });
206
207        // Phase: 0=idle, 1=rising(fade-in), 2=visible, 3=fading-out
208        let k_phase = format!("{}:ph", base);
209        let phase = remember_state_with_key(&k_phase, || 0u8);
210
211        // Track last processed press ID to detect new presses
212        let k_last_pid = format!("{}:lpid", base);
213        let last_pid = remember_state_with_key(&k_last_pid, || None::<PressId>);
214
215        // Pending release flag -> set when release occurs before fade-in completes
216        let k_release_pending = format!("{}:rpend", base);
217        let release_pending = remember_state_with_key(&k_release_pending, || false);
218
219        let prev_pid = *last_pid.borrow();
220
221        animation_driver::touch(&format!("{}:drv:a", base));
222        animation_driver::touch(&format!("{}:drv:r", base));
223        animation_driver::touch(&format!("{}:drv:c", base));
224
225        // Use last_press_id which persists after release (unlike is_pressed which is transient
226        // because press+release can both happen before the next frame renders).
227        let new_press = current_pid.is_some() && current_pid != prev_pid;
228
229        if new_press {
230            *last_pid.borrow_mut() = current_pid;
231            *phase.borrow_mut() = 1;
232            *release_pending.borrow_mut() = false;
233
234            let spec_in = AnimationSpec::tween(Duration::from_millis(FADE_IN_MS), Easing::Linear);
235            let spec_rad =
236                AnimationSpec::tween(Duration::from_millis(RADIUS_MS), Easing::FastOutSlowIn);
237            let spec_ctr = AnimationSpec::tween(Duration::from_millis(RADIUS_MS), Easing::Linear);
238
239            {
240                let mut a = alpha_anim.borrow_mut();
241                a.snap_to(0.0);
242                a.set_spec(spec_in);
243                a.set_target(1.0);
244            }
245            Self::register_driver(&format!("{}:drv:a", base), alpha_anim.clone());
246
247            {
248                let mut r = rad_anim.borrow_mut();
249                r.snap_to(0.0);
250                r.set_spec(spec_rad);
251                r.set_target(1.0);
252            }
253            Self::register_driver(&format!("{}:drv:r", base), rad_anim.clone());
254
255            {
256                let mut c = ctr_anim.borrow_mut();
257                c.snap_to(0.0);
258                c.set_spec(spec_ctr);
259                c.set_target(1.0);
260            }
261            Self::register_driver(&format!("{}:drv:c", base), ctr_anim.clone());
262        }
263
264        // Compare against *last_pid.borrow(), not prev_pid, because prev_pid was
265        // captured before the new-press block and would be None on first detection.
266        if *phase.borrow() != 0
267            && !is_pressed
268            && current_pid.is_some()
269            && current_pid == *last_pid.borrow()
270        {
271            *release_pending.borrow_mut() = true;
272        }
273
274        if *phase.borrow() != 0 && !animation_driver::is_registered(&format!("{}:drv:a", base)) {
275            *phase.borrow_mut() = 0;
276            *last_pid.borrow_mut() = current_pid;
277            *release_pending.borrow_mut() = false;
278            alpha_anim.borrow_mut().snap_to(0.0);
279            rad_anim.borrow_mut().snap_to(0.0);
280            ctr_anim.borrow_mut().snap_to(0.0);
281            return;
282        }
283
284        let fade_pct = *alpha_anim.borrow().get();
285
286        if *phase.borrow() == 1 && fade_pct >= 1.0 {
287            // Fade-in complete -> move to visible or start fade-out
288            if *release_pending.borrow() {
289                *phase.borrow_mut() = 3;
290                let spec_out =
291                    AnimationSpec::tween(Duration::from_millis(FADE_OUT_MS), Easing::Linear);
292                alpha_anim.borrow_mut().set_target(0.0);
293                alpha_anim.borrow_mut().set_spec(spec_out);
294                Self::register_driver(&format!("{}:drv:a", base), alpha_anim.clone());
295            } else {
296                *phase.borrow_mut() = 2;
297            }
298        }
299
300        if *phase.borrow() == 2 && *release_pending.borrow() {
301            // Release happened while visible -> start fade-out
302            *phase.borrow_mut() = 3;
303            let spec_out = AnimationSpec::tween(Duration::from_millis(FADE_OUT_MS), Easing::Linear);
304            alpha_anim.borrow_mut().set_target(0.0);
305            alpha_anim.borrow_mut().set_spec(spec_out);
306            Self::register_driver(&format!("{}:drv:a", base), alpha_anim.clone());
307        }
308
309        if *phase.borrow() == 3 && fade_pct <= 0.01 {
310            // Fade-out complete -> reset
311            *phase.borrow_mut() = 0;
312            // Keep last_pid = current_pid so the stale press ID doesn't
313            // retrigger new_press as a false positive on the next frame.
314            *last_pid.borrow_mut() = current_pid;
315            alpha_anim.borrow_mut().snap_to(0.0);
316            rad_anim.borrow_mut().snap_to(0.0);
317            ctr_anim.borrow_mut().snap_to(0.0);
318            return;
319        }
320
321        if *phase.borrow() == 0 {
322            return;
323        }
324        let snap_finish = *release_pending.borrow() && *phase.borrow() == 1;
325        if fade_pct <= 0.01 && !snap_finish {
326            return;
327        }
328
329        // Compose RippleAnimation.draw() snaps alpha to 1.0 when finish() is called
330        // during fade-in: `if (finishRequested && !finishedFadingIn) alpha = 1f`.
331        let draw_alpha = if snap_finish { 1.0f32 } else { fade_pct };
332
333        let rad_pct = *rad_anim.borrow().get();
334        let ctr_pct = *ctr_anim.borrow().get();
335        let current_radius = start_radius + (target_radius - start_radius) * rad_pct;
336
337        let origin_scene = match press_pos {
338            Some(pos) => {
339                let effective = if let Some(off) = self.config.press_offset {
340                    Vec2 {
341                        x: pos.x - off.x,
342                        y: pos.y - off.y,
343                    }
344                } else {
345                    pos
346                };
347                let ox = rect.x + effective.x;
348                let oy = rect.y + effective.y;
349                if bounded {
350                    Vec2 {
351                        x: ox + (center_scene.x - ox) * ctr_pct,
352                        y: oy + (center_scene.y - oy) * ctr_pct,
353                    }
354                } else {
355                    center_scene
356                }
357            }
358            None => center_scene,
359        };
360
361        // Compose: color.copy(alpha = PressAlpha * animatedAlpha)
362        let ripple_alpha = PRESS_ALPHA * draw_alpha * alpha;
363        if ripple_alpha <= 0.001 {
364            return;
365        }
366
367        let draw_color = base_color.with_alpha_f32(ripple_alpha);
368
369        if bounded {
370            scene.nodes.push(SceneNode::PushClip {
371                rect,
372                radius,
373                op: repose_core::ClipOp::Intersect,
374            });
375            scene.nodes.push(SceneNode::Ellipse {
376                rect: Rect {
377                    x: origin_scene.x - current_radius,
378                    y: origin_scene.y - current_radius,
379                    w: current_radius * 2.0,
380                    h: current_radius * 2.0,
381                },
382                brush: draw_color.into(),
383            });
384            scene.nodes.push(SceneNode::PopClip);
385        } else {
386            scene.nodes.push(SceneNode::Ellipse {
387                rect: Rect {
388                    x: origin_scene.x - current_radius,
389                    y: origin_scene.y - current_radius,
390                    w: current_radius * 2.0,
391                    h: current_radius * 2.0,
392                },
393                brush: draw_color.into(),
394            });
395        }
396    }
397}