tessera_ui_basic_components/
radio_button.rs1use std::{
5 sync::Arc,
6 time::{Duration, Instant},
7};
8
9use closure::closure;
10use derive_builder::Builder;
11use parking_lot::RwLock;
12use tessera_ui::{
13 Color, DimensionValue, Dp, Px,
14 accesskit::{Action, Role, Toggled},
15 tessera,
16};
17
18use crate::{
19 RippleState,
20 alignment::Alignment,
21 animation,
22 boxed::{BoxedArgsBuilder, boxed},
23 material_color,
24 shape_def::Shape,
25 surface::{SurfaceArgsBuilder, SurfaceStyle, surface},
26};
27
28const RADIO_ANIMATION_DURATION: Duration = Duration::from_millis(200);
29const HOVER_STATE_LAYER_OPACITY: f32 = 0.08;
30const RIPPLE_OPACITY: f32 = 0.1;
31
32#[derive(Clone)]
34pub struct RadioButtonState {
35 ripple: RippleState,
36 selection: Arc<RwLock<RadioSelectionState>>,
37}
38
39impl Default for RadioButtonState {
40 fn default() -> Self {
41 Self::new(false)
42 }
43}
44
45impl RadioButtonState {
46 pub fn new(selected: bool) -> Self {
48 Self {
49 ripple: RippleState::new(),
50 selection: Arc::new(RwLock::new(RadioSelectionState::new(selected))),
51 }
52 }
53
54 pub fn is_selected(&self) -> bool {
56 self.selection.read().selected
57 }
58
59 pub fn set_selected(&self, selected: bool) {
61 let mut selection = self.selection.write();
62 if selection.selected != selected {
63 selection.selected = selected;
64 selection.start_progress = selection.progress;
65 selection.last_change_time = Some(Instant::now());
66 }
67 }
68
69 pub fn select(&self) -> bool {
71 let mut selection = self.selection.write();
72 if selection.selected {
73 return false;
74 }
75 selection.selected = true;
76 selection.start_progress = selection.progress;
77 selection.last_change_time = Some(Instant::now());
78 true
79 }
80
81 fn update_animation(&self) {
82 let mut selection = self.selection.write();
83 if let Some(start) = selection.last_change_time {
84 let elapsed = start.elapsed();
85 let fraction =
86 (elapsed.as_secs_f32() / RADIO_ANIMATION_DURATION.as_secs_f32()).min(1.0);
87 let target = if selection.selected { 1.0 } else { 0.0 };
88 selection.progress =
89 selection.start_progress + (target - selection.start_progress) * fraction;
90 if fraction >= 1.0 {
91 selection.last_change_time = None;
92 selection.progress = target;
93 selection.start_progress = target;
94 }
95 }
96 }
97
98 fn animation_progress(&self) -> f32 {
99 self.selection.read().progress
100 }
101
102 fn ripple_state(&self) -> RippleState {
103 self.ripple.clone()
104 }
105}
106
107struct RadioSelectionState {
108 selected: bool,
109 progress: f32,
110 start_progress: f32,
111 last_change_time: Option<Instant>,
112}
113
114impl RadioSelectionState {
115 fn new(selected: bool) -> Self {
116 let progress = if selected { 1.0 } else { 0.0 };
117 Self {
118 selected,
119 progress,
120 start_progress: progress,
121 last_change_time: None,
122 }
123 }
124}
125
126#[derive(Builder, Clone)]
128#[builder(pattern = "owned")]
129pub struct RadioButtonArgs {
130 #[builder(default = "Arc::new(|_| {})")]
132 pub on_select: Arc<dyn Fn(bool) + Send + Sync>,
133 #[builder(default = "Dp(20.0)")]
135 pub size: Dp,
136 #[builder(default = "Dp(48.0)")]
138 pub touch_target_size: Dp,
139 #[builder(default = "Dp(2.0)")]
141 pub stroke_width: Dp,
142 #[builder(default = "Dp(10.0)")]
144 pub dot_size: Dp,
145 #[builder(default = "material_color::global_material_scheme().primary")]
147 pub selected_color: Color,
148 #[builder(default = "material_color::global_material_scheme().on_surface_variant")]
150 pub unselected_color: Color,
151 #[builder(default = "material_color::global_material_scheme().on_surface.with_alpha(0.38)")]
153 pub disabled_selected_color: Color,
154 #[builder(default = "material_color::global_material_scheme().on_surface.with_alpha(0.38)")]
156 pub disabled_unselected_color: Color,
157 #[builder(default = "true")]
159 pub enabled: bool,
160 #[builder(default, setter(strip_option, into))]
162 pub accessibility_label: Option<String>,
163 #[builder(default, setter(strip_option, into))]
165 pub accessibility_description: Option<String>,
166}
167
168impl Default for RadioButtonArgs {
169 fn default() -> Self {
170 RadioButtonArgsBuilder::default()
171 .build()
172 .expect("RadioButtonArgsBuilder default build should succeed")
173 }
174}
175
176fn interpolate_color(a: Color, b: Color, t: f32) -> Color {
177 let factor = t.clamp(0.0, 1.0);
178 Color {
179 r: a.r + (b.r - a.r) * factor,
180 g: a.g + (b.g - a.g) * factor,
181 b: a.b + (b.b - a.b) * factor,
182 a: a.a + (b.a - a.a) * factor,
183 }
184}
185
186#[tessera]
236pub fn radio_button(args: impl Into<RadioButtonArgs>, state: RadioButtonState) {
237 let args: RadioButtonArgs = args.into();
238
239 let state_for_accessibility = state.clone();
240 let state_for_animation = state.clone();
241 let accessibility_label = args.accessibility_label.clone();
242 let accessibility_description = args.accessibility_description.clone();
243 let on_select_for_accessibility = args.on_select.clone();
244 let enabled_for_accessibility = args.enabled;
245 input_handler(Box::new(move |input| {
246 state_for_animation.update_animation();
247 let selected = state_for_animation.is_selected();
248
249 let mut builder = input.accessibility().role(Role::RadioButton);
250
251 if let Some(label) = accessibility_label.as_ref() {
252 builder = builder.label(label.clone());
253 }
254 if let Some(description) = accessibility_description.as_ref() {
255 builder = builder.description(description.clone());
256 }
257
258 builder = builder.toggled(if selected {
259 Toggled::True
260 } else {
261 Toggled::False
262 });
263
264 if enabled_for_accessibility {
265 builder = builder.focusable().action(Action::Click);
266 } else {
267 builder = builder.disabled();
268 }
269
270 builder.commit();
271
272 if enabled_for_accessibility {
273 let state = state_for_accessibility.clone();
274 let on_select = on_select_for_accessibility.clone();
275 input.set_accessibility_action_handler(move |action| {
276 if action == Action::Click && state.select() {
277 on_select(true);
278 }
279 });
280 }
281 }));
282
283 state.update_animation();
284 let progress = state.animation_progress();
285 let eased_progress = animation::easing(progress);
286 let is_selected = state.is_selected();
287
288 let target_size = Dp(args.touch_target_size.0.max(args.size.0));
289 let padding_dp = Dp(((target_size.0 - args.size.0) / 2.0).max(0.0));
290
291 let ring_color = if args.enabled {
292 interpolate_color(args.unselected_color, args.selected_color, progress)
293 } else if is_selected {
294 args.disabled_selected_color
295 } else {
296 args.disabled_unselected_color
297 };
298
299 let base_state_layer_color = if args.enabled {
300 ring_color
301 } else if is_selected {
302 args.disabled_selected_color
303 } else {
304 args.disabled_unselected_color
305 };
306
307 let hover_style = args.enabled.then_some(SurfaceStyle::Filled {
308 color: base_state_layer_color.with_alpha(HOVER_STATE_LAYER_OPACITY),
309 });
310
311 let ripple_color = if args.enabled {
312 base_state_layer_color.with_alpha(RIPPLE_OPACITY)
313 } else {
314 Color::TRANSPARENT
315 };
316
317 let target_dot_color = if args.enabled {
318 args.selected_color
319 } else {
320 args.disabled_selected_color
321 };
322 let active_dot_color = interpolate_color(Color::TRANSPARENT, target_dot_color, eased_progress);
323
324 let ring_style = SurfaceStyle::Outlined {
325 color: ring_color,
326 width: args.stroke_width,
327 };
328
329 let on_click = if args.enabled {
330 Some(Arc::new(closure!(clone args.on_select, clone state, || {
331 if state.select() {
332 on_select(true);
333 }
334 })) as Arc<dyn Fn() + Send + Sync>)
335 } else {
336 None
337 };
338
339 let mut root_builder = SurfaceArgsBuilder::default()
340 .width(DimensionValue::Fixed(target_size.to_px()))
341 .height(DimensionValue::Fixed(target_size.to_px()))
342 .padding(padding_dp)
343 .shape(Shape::Ellipse)
344 .style(SurfaceStyle::Filled {
345 color: Color::TRANSPARENT,
346 })
347 .hover_style(hover_style)
348 .ripple_color(ripple_color)
349 .accessibility_role(Role::RadioButton);
350
351 if let Some(on_click) = on_click.clone() {
352 root_builder = root_builder.on_click(on_click);
353 }
354
355 surface(
356 root_builder.build().expect("builder construction failed"),
357 args.enabled.then(|| state.ripple_state()),
358 {
359 let args = args.clone();
360 move || {
361 surface(
362 SurfaceArgsBuilder::default()
363 .width(DimensionValue::Fixed(args.size.to_px()))
364 .height(DimensionValue::Fixed(args.size.to_px()))
365 .shape(Shape::Ellipse)
366 .style(ring_style)
367 .build()
368 .expect("builder construction failed"),
369 None,
370 {
371 let dot_size_px = args.dot_size.to_px();
372 move || {
373 let animated_size =
374 (dot_size_px.0 as f32 * eased_progress).round() as i32;
375 if animated_size > 0 {
376 boxed(
377 BoxedArgsBuilder::default()
378 .alignment(Alignment::Center)
379 .width(DimensionValue::Fixed(args.size.to_px()))
380 .height(DimensionValue::Fixed(args.size.to_px()))
381 .build()
382 .expect("builder construction failed"),
383 |scope| {
384 scope.child({
385 let dot_color = active_dot_color;
386 move || {
387 surface(
388 SurfaceArgsBuilder::default()
389 .width(DimensionValue::Fixed(Px(
390 animated_size,
391 )))
392 .height(DimensionValue::Fixed(Px(
393 animated_size,
394 )))
395 .shape(Shape::Ellipse)
396 .style(SurfaceStyle::Filled {
397 color: dot_color,
398 })
399 .build()
400 .expect("builder construction failed"),
401 None,
402 || {},
403 );
404 }
405 });
406 },
407 );
408 }
409 }
410 },
411 );
412 }
413 },
414 );
415}