Skip to main content

ferroui_themes_material/
loading_indicator.rs

1//! Android Material3 Expressive loading indicators, including the seven-shape
2//! indeterminate sequence, spring morphs, and progress-controlled circle/burst.
3//! Ported from AndroidX LoadingIndicator.kt and MaterialShapes.kt at
4//! e4d933c98df7baa7ea2b83eea6e086915961e0ff (Apache-2.0).
5//! Copyright 2024 The Android Open Source Project.
6
7use crate::motion::{animations_enabled, MaterialSpringSpec};
8use crate::progress_indicator::rounded_geometry::{
9    loading_polygons, path_bounds, Morph, Polygon, V,
10};
11use ferroui_base::animation::{IGlobalClock, TimeSpan};
12use ferroui_base::input::InputElementImpl;
13use ferroui_base::interactivity::InteractiveImpl;
14use ferroui_base::layout::LayoutableImpl;
15use ferroui_base::media::{BoxShadows, DrawingContext, IBrush, StreamGeometry};
16use ferroui_base::platform::IGeometryContext;
17use ferroui_base::reactive::{AnonymousObserver, IDisposable};
18use ferroui_base::{
19    ferro_class, ferro_class_info, ferro_impl_classes, ferro_properties, instantiate,
20    DirectProperty, FerroLocator, FerroObjectImpl, FerroObjectImplExt, FerroProperty,
21    FerroPropertyChangedEventArgs, LocatorExtensions, Point, Rect, Ref, Size, StyledElementImpl,
22    StyledProperty, Visual, VisualImpl, VisualImplExt, VisualTreeAttachmentEventArgs,
23};
24use ferroui_controls::automation::peers::{AutomationPeer, ProgressBarAutomationPeer};
25use ferroui_controls::primitives::{RangeBase, TemplatedControlImpl};
26use ferroui_controls::ControlImpl;
27use std::cell::{Cell, RefCell};
28use std::f64::consts::PI;
29use std::rc::Rc;
30use std::sync::OnceLock;
31
32/// A Material loading indicator with native 48dp container and 38dp active size.
33/// `Value`, `Minimum`, `Maximum`, and range accessibility come from RangeBase;
34/// `Percentage` provides a convenient 0–100 alias independent of that range.
35#[repr(C)]
36pub struct MaterialLoadingIndicator {
37    base: RangeBase,
38    percentage: Cell<f64>,
39    clock_subscription: RefCell<Option<Rc<dyn IDisposable>>>,
40    visibility_subscription: RefCell<Option<Rc<dyn IDisposable>>>,
41    elapsed: Cell<f64>,
42    epoch: Cell<Option<f64>>,
43}
44ferro_class!(MaterialLoadingIndicator: RangeBase);
45ferro_class_info!(MaterialLoadingIndicator {
46    new: MaterialLoadingIndicator::new
47});
48ferro_impl_classes!(MaterialLoadingIndicator: StyledElementImpl, InteractiveImpl, InputElementImpl, TemplatedControlImpl);
49impl ControlImpl for MaterialLoadingIndicator {
50    fn on_create_automation_peer(this: &Self) -> Ref<AutomationPeer> {
51        ProgressBarAutomationPeer::new(this).upcast()
52    }
53}
54impl FerroObjectImpl for MaterialLoadingIndicator {
55    fn on_property_changed(this: &Self, change: &FerroPropertyChangedEventArgs<'_>) {
56        Self::parent_on_property_changed(this, change);
57        this.invalidate_visual();
58        let property = change.property();
59        if property == RangeBase::value_property().as_property()
60            || property == RangeBase::minimum_property().as_property()
61            || property == RangeBase::maximum_property().as_property()
62        {
63            let range = this.maximum() - this.minimum();
64            let percentage = if range > 0.0 {
65                ((this.value() - this.minimum()) / range * 100.0).clamp(0.0, 100.0)
66            } else {
67                0.0
68            };
69            this.set_and_raise_cell(Self::percentage_property(), &this.percentage, percentage);
70        }
71        if property == Self::is_indeterminate_property().as_property()
72            || property == Self::reduced_motion_property().as_property()
73        {
74            this.update_clock();
75        }
76    }
77}
78impl LayoutableImpl for MaterialLoadingIndicator {
79    fn measure_override(_this: &Self, available: Size) -> Size {
80        Size::new(
81            48.0_f64.min(available.width),
82            48.0_f64.min(available.height),
83        )
84    }
85}
86impl VisualImpl for MaterialLoadingIndicator {
87    fn bypass_flow_direction_policies(_this: &Self) -> bool {
88        true
89    }
90    fn on_attached_to_visual_tree(this: &Self, e: &VisualTreeAttachmentEventArgs) {
91        Self::parent_on_attached_to_visual_tree(this, e);
92        let weak = this.to_ref().downgrade();
93        *this.visibility_subscription.borrow_mut() =
94            Some(this.is_effectively_visible_changed(move || {
95                if let Some(this) = weak.upgrade() {
96                    this.update_clock();
97                }
98            }));
99        this.update_clock();
100    }
101    fn on_detached_from_visual_tree(this: &Self, e: &VisualTreeAttachmentEventArgs) {
102        this.stop_clock();
103        if let Some(subscription) = this.visibility_subscription.borrow_mut().take() {
104            subscription.dispose();
105        }
106        Self::parent_on_detached_from_visual_tree(this, e);
107    }
108    fn render(this: &Self, dc: &mut DrawingContext) {
109        let size = this.bounds().size();
110        let side = size.width.min(size.height);
111        if !side.is_finite() || side <= 0.0 {
112            return;
113        }
114        let container = this.container_brush();
115        dc.draw_rectangle(
116            container.as_ref(),
117            None,
118            Rect::new(0.0, 0.0, size.width, size.height),
119            side / 2.0,
120            side / 2.0,
121            &BoxShadows::default(),
122        );
123        let active = this.active_brush();
124        if active.is_none() {
125            return;
126        }
127        let shapes = loading_shapes();
128        let (curves, scale, rotation) = if this.is_indeterminate() {
129            let time = if !animations_enabled(this.reduced_motion()) {
130                0.0
131            } else {
132                this.elapsed.get()
133            };
134            let frame = loading_indeterminate_frame(time);
135            (
136                shapes.sequence[frame.0].cubics(frame.1),
137                shapes.indeterminate_scale,
138                frame.2,
139            )
140        } else {
141            let progress = (this.percentage() / 100.0).clamp(0.0, 1.0);
142            (
143                shapes.determinate.cubics(progress),
144                shapes.determinate_scale,
145                -progress * PI,
146            )
147        };
148        let scale = side * scale * 38.0 / 48.0;
149        let scaled: Vec<_> = curves
150            .into_iter()
151            .map(|c| c.transformed(|p| p * scale))
152            .collect();
153        let bounds = path_bounds(&scaled);
154        let path_center = V::new((bounds[0] + bounds[2]) / 2.0, (bounds[1] + bounds[3]) / 2.0);
155        let point = |p: V| {
156            let p =
157                (p - path_center).rotate(rotation) + V::new(size.width / 2.0, size.height / 2.0);
158            Point::new(p.x, p.y)
159        };
160        let geometry = StreamGeometry::new();
161        let mut path = geometry.open();
162        path.begin_figure(point(scaled[0].0[0]), true);
163        for cubic in &scaled {
164            path.cubic_bezier_to(
165                point(cubic.0[1]),
166                point(cubic.0[2]),
167                point(cubic.0[3]),
168                true,
169            );
170        }
171        path.end_figure(true);
172        path.dispose();
173        dc.draw_geometry(active.as_ref(), None, &geometry.upcast());
174    }
175}
176ferro_properties! {impl MaterialLoadingIndicator {
177    pub fn percentage_property()->DirectProperty<MaterialLoadingIndicator,f64>{
178        FerroProperty::register_direct::<MaterialLoadingIndicator,_>("Percentage",|o|o.percentage(),Some(|o,value|o.set_percentage(value)),0.0)
179    }
180    pub fn is_indeterminate_property()->StyledProperty<bool>{FerroProperty::register::<MaterialLoadingIndicator,_>("IsIndeterminate",true)}
181    pub fn reduced_motion_property()->StyledProperty<bool>{FerroProperty::register::<MaterialLoadingIndicator,_>("ReducedMotion",false)}
182    pub fn active_brush_property()->StyledProperty<Option<Rc<dyn IBrush>>>{FerroProperty::register::<MaterialLoadingIndicator,_>("ActiveBrush",None)}
183    pub fn container_brush_property()->StyledProperty<Option<Rc<dyn IBrush>>>{FerroProperty::register::<MaterialLoadingIndicator,_>("ContainerBrush",None)}
184}}
185impl MaterialLoadingIndicator {
186    fn static_constructor() {
187        Visual::affects_render::<MaterialLoadingIndicator>(&[
188            Self::active_brush_property().as_property(),
189            Self::container_brush_property().as_property(),
190        ]);
191    }
192    pub fn construct() -> Self {
193        Self {
194            base: RangeBase::construct(),
195            percentage: Cell::new(0.0),
196            clock_subscription: RefCell::new(None),
197            visibility_subscription: RefCell::new(None),
198            elapsed: Cell::new(0.0),
199            epoch: Cell::new(None),
200        }
201    }
202    pub fn new() -> Ref<Self> {
203        let this = instantiate(Self::construct());
204        this.set_is_hit_test_visible(false);
205        this
206    }
207    pub fn percentage(&self) -> f64 {
208        self.percentage.get()
209    }
210    pub fn set_percentage(&self, value: f64) {
211        let fraction = if value.is_nan() {
212            0.0
213        } else {
214            (value / 100.0).clamp(0.0, 1.0)
215        };
216        self.set_range_value(self.minimum() + (self.maximum() - self.minimum()) * fraction);
217    }
218    pub fn is_indeterminate(&self) -> bool {
219        self.get_value(Self::is_indeterminate_property())
220    }
221    pub fn set_is_indeterminate(&self, value: bool) {
222        self.set_value(Self::is_indeterminate_property(), value);
223    }
224    pub fn reduced_motion(&self) -> bool {
225        self.get_value(Self::reduced_motion_property())
226    }
227    pub fn set_reduced_motion(&self, value: bool) {
228        self.set_value(Self::reduced_motion_property(), value);
229    }
230    pub fn active_brush(&self) -> Option<Rc<dyn IBrush>> {
231        self.get_value(Self::active_brush_property())
232    }
233    pub fn set_active_brush(&self, value: Option<Rc<dyn IBrush>>) {
234        self.set_value(Self::active_brush_property(), value);
235    }
236    pub fn container_brush(&self) -> Option<Rc<dyn IBrush>> {
237        self.get_value(Self::container_brush_property())
238    }
239    pub fn set_container_brush(&self, value: Option<Rc<dyn IBrush>>) {
240        self.set_value(Self::container_brush_property(), value);
241    }
242    fn update_clock(&self) {
243        if !self.is_attached_to_visual_tree()
244            || !self.is_effectively_visible()
245            || !self.is_indeterminate()
246            || !animations_enabled(self.reduced_motion())
247        {
248            self.stop_clock();
249            return;
250        }
251        if self.clock_subscription.borrow().is_some() {
252            return;
253        }
254        let Some(clock) = FerroLocator::current().get_service::<dyn IGlobalClock>() else {
255            return;
256        };
257        let weak = self.to_ref().downgrade();
258        self.epoch.set(None);
259        *self.clock_subscription.borrow_mut() = Some(clock.subscribe(Rc::new(
260            AnonymousObserver::new(move |time: TimeSpan| {
261                if let Some(this) = weak.upgrade() {
262                    if !animations_enabled(this.reduced_motion()) {
263                        this.stop_clock();
264                        this.invalidate_visual();
265                        return;
266                    }
267                    let time = time.total_seconds();
268                    if !time.is_finite() {
269                        return;
270                    }
271                    let epoch = this.epoch.get().unwrap_or_else(|| {
272                        let epoch = time - this.elapsed.get();
273                        this.epoch.set(Some(epoch));
274                        epoch
275                    });
276                    this.elapsed.set((time - epoch).max(0.0));
277                    this.invalidate_visual();
278                }
279            }),
280        )));
281    }
282    fn stop_clock(&self) {
283        if let Some(subscription) = self.clock_subscription.borrow_mut().take() {
284            subscription.dispose();
285        }
286        self.epoch.set(None);
287    }
288}
289impl Drop for MaterialLoadingIndicator {
290    fn drop(&mut self) {
291        if let Some(subscription) = self.clock_subscription.get_mut().take() {
292            subscription.dispose();
293        }
294        if let Some(subscription) = self.visibility_subscription.get_mut().take() {
295            subscription.dispose();
296        }
297    }
298}
299struct LoadingShapes {
300    sequence: Vec<Morph>,
301    determinate: Morph,
302    indeterminate_scale: f64,
303    determinate_scale: f64,
304}
305fn loading_shapes() -> &'static LoadingShapes {
306    static SHAPES: OnceLock<LoadingShapes> = OnceLock::new();
307    SHAPES.get_or_init(|| {
308        let polygons = loading_polygons();
309        let indeterminate_scale = polygons
310            .iter()
311            .fold(1.0_f64, |scale, p| scale.min(p.rotation_safe_scale()));
312        let circle = Polygon::circle(10)
313            .normalized()
314            .transformed(|p| p.rotate(PI / 10.0));
315        let determinate_scale = 1.0_f64
316            .min(circle.rotation_safe_scale())
317            .min(polygons[0].rotation_safe_scale());
318        let sequence = (0..polygons.len())
319            .map(|i| {
320                Morph::new(
321                    &polygons[i].normalized(),
322                    &polygons[(i + 1) % polygons.len()].normalized(),
323                )
324            })
325            .collect();
326        LoadingShapes {
327            sequence,
328            determinate: Morph::new(&circle.normalized(), &polygons[0].normalized()),
329            indeterminate_scale,
330            determinate_scale,
331        }
332    })
333}
334/// Shape index, spring progress, and clockwise rotation in radians. New morphs
335/// start every 650ms; continuous global rotation takes 4666ms per revolution.
336/// Compose computes a 297ms settling duration for this spring at visibility
337/// threshold 0.1, then snaps to its target and holds until the next interval.
338pub fn loading_indeterminate_frame(seconds: f64) -> (usize, f64, f64) {
339    let time = if seconds.is_finite() {
340        seconds.max(0.0)
341    } else {
342        0.0
343    };
344    let iteration = (time / 0.65).floor();
345    let local = time.rem_euclid(0.65);
346    let progress = if local >= 0.297 {
347        1.0
348    } else {
349        MaterialSpringSpec::new(0.6, 200.0)
350            .sample(local, 0.0, 1.0, 0.0)
351            .value
352    };
353    let rotation =
354        (90.0 * (iteration + 1.0) + progress * 90.0 + time.rem_euclid(4.666) / 4.666 * 360.0)
355            .to_radians();
356    ((iteration as usize) % 7, progress, rotation)
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    #[test]
363    fn loading_sequence_uses_native_shapes_and_timing() {
364        assert_eq!(7, loading_shapes().sequence.len());
365        assert_eq!((0, 0.0, PI / 2.0), loading_indeterminate_frame(0.0));
366        assert_eq!(1, loading_indeterminate_frame(0.65).0);
367        assert_eq!(6, loading_indeterminate_frame(4.0).0);
368        assert!(
369            loading_indeterminate_frame(0.28).1 > 1.0,
370            "native spring must preserve expressive overshoot"
371        );
372        assert_eq!(1.0, loading_indeterminate_frame(0.6).1);
373    }
374}