1mod circular;
8mod geometry;
9mod shapes;
10
11#[cfg(test)]
12mod tests;
13
14use tessera_ui::{
15 Color, ComputedData, Dp, FrameNanosControl, LayoutResult, MeasurementError, Modifier,
16 accesskit::Role,
17 current_frame_nanos,
18 layout::{MeasureScope, layout},
19 receive_frame_nanos, remember, tessera, use_context,
20};
21
22use crate::{
23 modifier::{ModifierExt as _, Padding, SemanticsArgs},
24 progress::{ProgressStrokeCap, cubic_bezier_easing},
25 theme::MaterialTheme,
26};
27
28use self::{
29 circular::{Cache, CircularWaveDraw, vertices},
30 geometry::LinearWaveDraw,
31};
32
33const AMPLITUDE_DURATION: u64 = 500_000_000;
34const INDETERMINATE_DURATION: u64 = 1_750_000_000;
35
36pub struct WavyProgressIndicatorDefaults;
38
39impl WavyProgressIndicatorDefaults {
40 pub const LINEAR_WIDTH: Dp = Dp(240.0);
42 pub const LINEAR_HEIGHT: Dp = Dp(10.0);
44 pub const STROKE_WIDTH: Dp = Dp(4.0);
46 pub const LINEAR_DETERMINATE_WAVELENGTH: Dp = Dp(40.0);
48 pub const LINEAR_INDETERMINATE_WAVELENGTH: Dp = Dp(20.0);
50 pub const TRACK_GAP: Dp = Dp(4.0);
52 pub const STOP_SIZE: Dp = Dp(4.0);
54 pub const CIRCULAR_SIZE: Dp = Dp(48.0);
56 pub const CIRCULAR_WAVELENGTH: Dp = Dp(15.0);
58
59 pub fn indicator_amplitude(progress: f32) -> f32 {
62 let progress = normalized(progress);
63 if progress <= 0.1 || progress >= 0.95 {
64 0.0
65 } else {
66 1.0
67 }
68 }
69}
70
71#[derive(Clone, Copy, PartialEq)]
72struct CircularWaveLayout;
73
74impl tessera_ui::LayoutModifierNode for CircularWaveLayout {
75 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
76 Ok(LayoutResult::new(ComputedData {
77 width: input
78 .parent_constraint()
79 .width()
80 .clamp(WavyProgressIndicatorDefaults::CIRCULAR_SIZE.to_px()),
81 height: input
82 .parent_constraint()
83 .height()
84 .clamp(WavyProgressIndicatorDefaults::CIRCULAR_SIZE.to_px()),
85 }))
86 }
87}
88
89fn circular_indeterminate(elapsed: u64) -> (f32, f32) {
90 let millis = (elapsed % 6_000_000_000) as f32 / 1_000_000.0;
91 let progress = crate::progress::circular_indeterminate_progress(millis);
92 let rotation = millis / 6000.0 * 1080.0
93 + crate::progress::circular_additional_rotation_degrees(millis)
94 + 90.0;
95 (progress, rotation.to_radians())
96}
97
98#[tessera]
147pub fn circular_wavy_progress_indicator(
148 progress: Option<f32>,
149 modifier: Option<Modifier>,
150 color: Option<Color>,
151 track_color: Option<Color>,
152 stroke_width: Option<Dp>,
153 track_stroke_width: Option<Dp>,
154 stroke_cap: Option<ProgressStrokeCap>,
155 track_stroke_cap: Option<ProgressStrokeCap>,
156 amplitude: Option<f32>,
157 wavelength: Option<Dp>,
158 wave_speed: Option<Dp>,
159 gap_size: Option<Dp>,
160 paused: Option<bool>,
161 reverse_direction: Option<bool>,
162 #[prop(into)] accessibility_label: Option<String>,
163 #[prop(into)] accessibility_description: Option<String>,
164) {
165 use tessera_ui::FocusModifierExt as _;
166
167 let progress = progress.map(normalized);
168 let wavelength =
169 nonnegative(wavelength.unwrap_or(WavyProgressIndicatorDefaults::CIRCULAR_WAVELENGTH));
170 let speed = nonnegative(wave_speed.unwrap_or(wavelength));
171 let stroke = nonnegative(stroke_width.unwrap_or(WavyProgressIndicatorDefaults::STROKE_WIDTH));
172 let amplitude = if wavelength.0 == 0.0 {
173 0.0
174 } else {
175 amplitude.map(normalized).unwrap_or_else(|| {
176 progress
177 .map(WavyProgressIndicatorDefaults::indicator_amplitude)
178 .unwrap_or(1.0)
179 })
180 };
181 let measured_size = remember(|| None::<[f32; 2]>);
182 let size = measured_size
183 .get()
184 .unwrap_or([WavyProgressIndicatorDefaults::CIRCULAR_SIZE.to_pixels_f32(); 2]);
185 let vertex_count = vertices(
186 size[0],
187 size[1],
188 stroke.to_pixels_f32(),
189 wavelength.to_pixels_f32(),
190 );
191 let period_nanos = (wavelength.0 > 0.0 && speed.0 > 0.0).then(|| {
192 ((wavelength.0 / speed.0 * 1000.0 * vertex_count as f64)
193 .round()
194 .max(50.0)
195 * 1_000_000.0)
196 .min(u64::MAX as f64) as u64
197 });
198 let motion = Motion {
199 amplitude,
200 period_nanos,
201 indeterminate: progress.is_none(),
202 paused: paused.unwrap_or(false),
203 circular: true,
204 };
205 let now = current_frame_nanos();
206 let clock = remember(|| Clock::new(motion, now));
207 if clock.with(|clock| clock.motion != motion) {
208 clock.with_mut(|clock| clock.configure(motion, now));
209 }
210 if clock.with(Clock::needs_frames) {
211 receive_frame_nanos(move |frame| {
212 clock.with_mut(|clock| clock.advance(frame));
213 if clock.with(Clock::needs_frames) {
214 FrameNanosControl::Continue
215 } else {
216 FrameNanosControl::Stop
217 }
218 });
219 }
220 let cache = remember(Cache::default).get();
221 let clock = clock.get();
222 let (fraction, rotation) = progress
223 .map(|progress| (progress, 0.0))
224 .unwrap_or_else(|| circular_indeterminate(clock.elapsed));
225 let scheme = use_context::<MaterialTheme>()
226 .expect("MaterialTheme must be provided")
227 .get()
228 .color_scheme;
229 layout()
230 .modifier(
231 modifier
232 .unwrap_or_default()
233 .clip_to_bounds()
234 .semantics(SemanticsArgs {
235 role: Some(Role::ProgressIndicator),
236 label: accessibility_label,
237 description: accessibility_description,
238 numeric_value: progress.map(f64::from),
239 numeric_range: progress.map(|_| (0.0, 1.0)),
240 ..Default::default()
241 })
242 .on_layout_coordinates(move |coordinates: tessera_ui::LayoutCoordinates| {
243 let size = [
244 coordinates.local_size.width.to_f32(),
245 coordinates.local_size.height.to_f32(),
246 ];
247 if measured_size.get() != Some(size) {
248 measured_size.set(Some(size));
249 }
250 }),
251 )
252 .policy(CircularWaveLayout)
253 .draw(CircularWaveDraw {
254 cache,
255 wavelength,
256 progress: fraction,
257 amplitude: clock.amplitude(),
258 phase: if clock.amplitude() > 0.0 && period_nanos.is_some() {
259 clock.phase.fract() as f32
260 } else {
261 0.0
262 },
263 rotation,
264 stroke_width: stroke,
265 track_stroke_width: nonnegative(
266 track_stroke_width.unwrap_or(WavyProgressIndicatorDefaults::STROKE_WIDTH),
267 ),
268 gap: nonnegative(gap_size.unwrap_or(WavyProgressIndicatorDefaults::TRACK_GAP)),
269 stroke_cap: stroke_cap.unwrap_or_default(),
270 track_stroke_cap: track_stroke_cap.unwrap_or_default(),
271 color: color.unwrap_or(scheme.primary),
272 track_color: track_color.unwrap_or(scheme.secondary_container),
273 reverse: reverse_direction.unwrap_or(false),
274 });
275}
276
277fn normalized(value: f32) -> f32 {
278 if value.is_nan() {
279 0.0
280 } else {
281 value.clamp(0.0, 1.0)
282 }
283}
284
285fn nonnegative(value: Dp) -> Dp {
286 if value.0.is_finite() {
287 Dp(value.0.max(0.0))
288 } else {
289 Dp::ZERO
290 }
291}
292
293#[derive(Clone, Copy, PartialEq)]
294struct Motion {
295 amplitude: f32,
296 period_nanos: Option<u64>,
297 indeterminate: bool,
298 paused: bool,
299 circular: bool,
300}
301
302#[derive(Clone, Copy)]
303struct Clock {
304 motion: Motion,
305 elapsed: u64,
306 frame: u64,
307 phase: f64,
308 amplitude_origin: f32,
309 amplitude_start: u64,
310}
311
312impl Clock {
313 fn new(motion: Motion, frame: u64) -> Self {
314 Self {
315 motion,
316 elapsed: 0,
317 frame,
318 phase: 0.0,
319 amplitude_origin: motion.amplitude,
320 amplitude_start: 0,
321 }
322 }
323
324 fn amplitude(&self) -> f32 {
325 let t =
326 self.elapsed.saturating_sub(self.amplitude_start) as f64 / AMPLITUDE_DURATION as f64;
327 if t >= 1.0 || self.amplitude_origin == self.motion.amplitude {
328 return self.motion.amplitude;
329 }
330 let easing = if self.amplitude_origin < self.motion.amplitude {
331 cubic_bezier_easing(t as f32, 0.2, 0.0, 0.0, 1.0)
332 } else {
333 cubic_bezier_easing(t as f32, 0.3, 0.0, 0.8, 0.15)
334 };
335 self.amplitude_origin + (self.motion.amplitude - self.amplitude_origin) * easing
336 }
337
338 fn needs_frames(&self) -> bool {
339 !self.motion.paused
340 && (self.motion.indeterminate
341 || (self.amplitude_origin != self.motion.amplitude
342 && self.elapsed.saturating_sub(self.amplitude_start) < AMPLITUDE_DURATION)
343 || (self.amplitude() > 0.0 && self.motion.period_nanos.is_some()))
344 }
345
346 fn advance(&mut self, frame: u64) {
347 let delta = frame.saturating_sub(self.frame);
348 if !self.motion.paused
349 && (!self.motion.circular || self.amplitude() > 0.0)
350 && let Some(period) = self.motion.period_nanos
351 {
352 self.phase += delta as f64 / period as f64;
353 if !self.motion.circular {
354 self.phase = self.phase.fract();
355 }
356 }
357 if self.needs_frames() {
358 self.elapsed = self.elapsed.saturating_add(delta);
359 }
360 self.frame = frame;
361 }
362
363 fn configure(&mut self, motion: Motion, frame: u64) {
364 self.advance(frame);
365 if motion.amplitude != self.motion.amplitude {
366 self.amplitude_origin = if motion.paused {
367 motion.amplitude
368 } else {
369 self.amplitude()
370 };
371 self.amplitude_start = self.elapsed;
372 }
373 if motion.period_nanos.is_none() {
374 self.phase = 0.0;
375 }
376 self.motion = motion;
377 }
378}
379
380fn indeterminate_fractions(elapsed: u64) -> [[f32; 2]; 2] {
381 let millis = (elapsed % INDETERMINATE_DURATION) as f64 / 1_000_000.0;
382 let keyframe = |delay: f64, duration: f64| {
383 let t = ((millis - delay) / duration).clamp(0.0, 1.0) as f32;
384 if t == 0.0 || t == 1.0 {
385 t
386 } else {
387 cubic_bezier_easing(t, 0.3, 0.0, 0.8, 0.15)
388 }
389 };
390 [
391 [keyframe(250.0, 1000.0), keyframe(0.0, 1000.0)],
392 [keyframe(900.0, 850.0), keyframe(650.0, 850.0)],
393 ]
394}
395
396#[derive(Clone, Copy, PartialEq)]
397struct LinearWaveLayout;
398
399impl tessera_ui::LayoutModifierNode for LinearWaveLayout {
400 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
401 Ok(LayoutResult::new(ComputedData {
402 width: input
403 .parent_constraint()
404 .width()
405 .clamp(WavyProgressIndicatorDefaults::LINEAR_WIDTH.to_px()),
406 height: input
407 .parent_constraint()
408 .height()
409 .clamp(WavyProgressIndicatorDefaults::LINEAR_HEIGHT.to_px()),
410 }))
411 }
412}
413
414#[tessera]
471pub fn linear_wavy_progress_indicator(
472 progress: Option<f32>,
473 modifier: Option<Modifier>,
474 color: Option<Color>,
475 track_color: Option<Color>,
476 stroke_width: Option<Dp>,
477 track_stroke_width: Option<Dp>,
478 stroke_cap: Option<ProgressStrokeCap>,
479 track_stroke_cap: Option<ProgressStrokeCap>,
480 amplitude: Option<f32>,
481 wavelength: Option<Dp>,
482 wave_speed: Option<Dp>,
483 gap_size: Option<Dp>,
484 stop_size: Option<Dp>,
485 paused: Option<bool>,
486 reverse_direction: Option<bool>,
487 #[prop(into)] accessibility_label: Option<String>,
488 #[prop(into)] accessibility_description: Option<String>,
489) {
490 let progress = progress.map(normalized);
491 let amplitude = amplitude.map(normalized).unwrap_or_else(|| {
492 progress
493 .map(WavyProgressIndicatorDefaults::indicator_amplitude)
494 .unwrap_or(1.0)
495 });
496 let wavelength = nonnegative(wavelength.unwrap_or(if progress.is_some() {
497 WavyProgressIndicatorDefaults::LINEAR_DETERMINATE_WAVELENGTH
498 } else {
499 WavyProgressIndicatorDefaults::LINEAR_INDETERMINATE_WAVELENGTH
500 }));
501 let speed = nonnegative(wave_speed.unwrap_or(wavelength));
502 let period_nanos = (wavelength.0 > 0.0 && speed.0 > 0.0).then(|| {
503 ((wavelength.0 / speed.0 * 1000.0).round().max(50.0) * 1_000_000.0).min(u64::MAX as f64)
504 as u64
505 });
506 let motion = Motion {
507 amplitude,
508 period_nanos,
509 indeterminate: progress.is_none(),
510 paused: paused.unwrap_or(false),
511 circular: false,
512 };
513 let now = current_frame_nanos();
514 let clock = remember(|| Clock::new(motion, now));
515 if clock.with(|clock| clock.motion != motion) {
516 clock.with_mut(|clock| clock.configure(motion, now));
517 }
518 if clock.with(Clock::needs_frames) {
519 receive_frame_nanos(move |frame| {
520 clock.with_mut(|clock| clock.advance(frame));
521 if clock.with(Clock::needs_frames) {
522 FrameNanosControl::Continue
523 } else {
524 FrameNanosControl::Stop
525 }
526 });
527 }
528 let clock = clock.get();
529 let fractions = progress
530 .map(|progress| [[0.0, progress], [0.0, 0.0]])
531 .unwrap_or_else(|| indeterminate_fractions(clock.elapsed));
532 let scheme = use_context::<MaterialTheme>()
533 .expect("MaterialTheme must be provided")
534 .get()
535 .color_scheme;
536 let semantics = SemanticsArgs {
537 role: Some(Role::ProgressIndicator),
538 label: accessibility_label,
539 description: accessibility_description,
540 numeric_value: progress.map(f64::from),
541 numeric_range: progress.map(|_| (0.0, 1.0)),
542 bounds_padding: Some(Padding::vertical(Dp(10.0))),
543 ..Default::default()
544 };
545 layout()
546 .modifier(
547 modifier
548 .unwrap_or_default()
549 .clip_to_bounds()
550 .semantics(semantics),
551 )
552 .policy(LinearWaveLayout)
553 .draw(LinearWaveDraw {
554 fractions,
555 count: if progress.is_some() { 1 } else { 2 },
556 amplitude: clock.amplitude(),
557 phase: clock.phase as f32,
558 wavelength,
559 stroke_width: nonnegative(
560 stroke_width.unwrap_or(WavyProgressIndicatorDefaults::STROKE_WIDTH),
561 ),
562 track_stroke_width: nonnegative(
563 track_stroke_width.unwrap_or(WavyProgressIndicatorDefaults::STROKE_WIDTH),
564 ),
565 gap: nonnegative(gap_size.unwrap_or(WavyProgressIndicatorDefaults::TRACK_GAP)),
566 stop: if progress.is_some() {
567 nonnegative(stop_size.unwrap_or(WavyProgressIndicatorDefaults::STOP_SIZE))
568 } else {
569 Dp::ZERO
570 },
571 stroke_cap: stroke_cap.unwrap_or_default(),
572 track_stroke_cap: track_stroke_cap.unwrap_or_default(),
573 color: color.unwrap_or(scheme.primary),
574 track_color: track_color.unwrap_or(scheme.secondary_container),
575 reverse: reverse_direction.unwrap_or(false),
576 });
577}