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