tessera_ui_basic_components/
button_groups.rs1use std::{
8 collections::HashMap,
9 sync::{
10 Arc,
11 atomic::{AtomicBool, Ordering},
12 },
13 time::Instant,
14};
15
16use closure::closure;
17use derive_builder::Builder;
18use parking_lot::RwLock;
19use tessera_ui::{Color, ComputedData, Dp, Px, PxPosition, tessera};
20
21use crate::{
22 RippleState,
23 alignment::MainAxisAlignment,
24 animation,
25 button::{ButtonArgs, button},
26 material_color::global_material_scheme,
27 row::{RowArgs, row},
28 shape_def::{RoundedCorner, Shape},
29 spacer::{SpacerArgs, spacer},
30};
31
32#[derive(Debug, Clone, Copy, Default)]
43pub enum ButtonGroupsStyle {
44 #[default]
46 Standard,
47 Connected,
49}
50
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
62pub enum ButtonGroupsSelectionMode {
63 #[default]
65 Single,
66 Multiple,
68}
69
70#[derive(Debug, Clone, Copy, Default)]
73pub enum ButtonGroupsSize {
74 ExtraSmall,
76 Small,
78 #[default]
80 Medium,
81 Large,
83 ExtraLarge,
85}
86
87pub struct ButtonGroupsScope<'a> {
89 child_closures: &'a mut Vec<Box<dyn FnOnce(Color) + Send + Sync>>,
90 on_click_closures: &'a mut Vec<Arc<dyn Fn(bool) + Send + Sync>>,
91}
92
93impl ButtonGroupsScope<'_> {
94 pub fn child<F, C>(&mut self, child: F, on_click: C)
103 where
104 F: FnOnce(Color) + Send + Sync + 'static,
105 C: Fn(bool) + Send + Sync + 'static,
106 {
107 self.child_closures.push(Box::new(child));
108 self.on_click_closures.push(Arc::new(on_click));
109 }
110}
111
112#[derive(Builder, Default)]
114pub struct ButtonGroupsArgs {
115 #[builder(default)]
117 pub size: ButtonGroupsSize,
118 #[builder(default)]
120 pub style: ButtonGroupsStyle,
121 #[builder(default)]
123 pub selection_mode: ButtonGroupsSelectionMode,
124}
125
126#[derive(Clone)]
127struct ButtonGroupsLayout {
128 container_height: Dp,
129 between_space: Dp,
130 active_button_shape: Shape,
131 inactive_button_shape: Shape,
132 inactive_button_shape_start: Shape,
133 inactive_button_shape_end: Shape,
134}
135
136impl ButtonGroupsLayout {
137 fn new(size: ButtonGroupsSize, style: ButtonGroupsStyle) -> Self {
138 let container_height = match size {
140 ButtonGroupsSize::ExtraSmall => Dp(32.0),
141 ButtonGroupsSize::Small => Dp(40.0),
142 ButtonGroupsSize::Medium => Dp(56.0),
143 ButtonGroupsSize::Large => Dp(96.0),
144 ButtonGroupsSize::ExtraLarge => Dp(136.0),
145 };
146 let between_space = match style {
147 ButtonGroupsStyle::Standard => match size {
148 ButtonGroupsSize::ExtraSmall => Dp(18.0),
149 ButtonGroupsSize::Small => Dp(12.0),
150 _ => Dp(8.0),
151 },
152 ButtonGroupsStyle::Connected => Dp(2.0),
153 };
154 let active_button_shape = match style {
155 ButtonGroupsStyle::Standard => Shape::rounded_rectangle(Dp(16.0)),
156 ButtonGroupsStyle::Connected => Shape::capsule(),
157 };
158 let inactive_button_shape = match style {
159 ButtonGroupsStyle::Standard => Shape::capsule(),
160 ButtonGroupsStyle::Connected => Shape::rounded_rectangle(Dp(16.0)),
161 };
162 let inactive_button_shape_start = match style {
163 ButtonGroupsStyle::Standard => active_button_shape,
164 ButtonGroupsStyle::Connected => Shape::RoundedRectangle {
165 top_left: RoundedCorner::Capsule,
166 top_right: RoundedCorner::manual(Dp(16.0), 3.0),
167 bottom_right: RoundedCorner::manual(Dp(16.0), 3.0),
168 bottom_left: RoundedCorner::Capsule,
169 },
170 };
171 let inactive_button_shape_end = match style {
172 ButtonGroupsStyle::Standard => active_button_shape,
173 ButtonGroupsStyle::Connected => Shape::RoundedRectangle {
174 top_left: RoundedCorner::manual(Dp(16.0), 3.0),
175 top_right: RoundedCorner::Capsule,
176 bottom_right: RoundedCorner::Capsule,
177 bottom_left: RoundedCorner::manual(Dp(16.0), 3.0),
178 },
179 };
180 Self {
181 container_height,
182 between_space,
183 active_button_shape,
184 inactive_button_shape,
185 inactive_button_shape_start,
186 inactive_button_shape_end,
187 }
188 }
189}
190
191#[derive(Default, Clone)]
192struct ButtonItemState {
193 ripple_state: RippleState,
194 actived: Arc<AtomicBool>,
195 elastic_state: Arc<RwLock<ElasticState>>,
196}
197
198#[derive(Clone, Default)]
200pub struct ButtonGroupsState {
201 item_states: Arc<RwLock<HashMap<usize, ButtonItemState>>>,
202}
203
204#[tessera]
274pub fn button_groups<F>(
275 args: impl Into<ButtonGroupsArgs>,
276 state: ButtonGroupsState,
277 scope_config: F,
278) where
279 F: FnOnce(&mut ButtonGroupsScope),
280{
281 let args = args.into();
282 let mut child_closures = Vec::new();
283 let mut on_click_closures = Vec::new();
284 {
285 let mut scope = ButtonGroupsScope {
286 child_closures: &mut child_closures,
287 on_click_closures: &mut on_click_closures,
288 };
289 scope_config(&mut scope);
290 }
291 let layout = ButtonGroupsLayout::new(args.size, args.style);
292 let child_len = child_closures.len();
293 let selection_mode = args.selection_mode;
294 row(
295 RowArgs {
296 height: layout.container_height.into(),
297 main_axis_alignment: MainAxisAlignment::SpaceBetween,
298 ..Default::default()
299 },
300 closure!(
301 clone state,
302 |scope| {
303 for (index, child_closure) in child_closures.into_iter().enumerate() {
304 let on_click_closure = on_click_closures[index].clone();
305 let item_state = state.item_states.write().entry(index).or_default().clone();
306
307 scope.child(
308 closure!(clone state, clone layout, || {
309 let ripple_state = item_state.ripple_state.clone();
310 let actived = item_state.actived.load(Ordering::Acquire);
311 let elastic_state = item_state.elastic_state.clone();
312 if actived {
313 let mut button_args = ButtonArgs::filled(
314 Arc::new(move || {
315 on_click_closure(false);
316 item_state.actived.store(false, Ordering::Release);
317 item_state.elastic_state.write().toggle();
318 })
319 );
320 button_args.shape = layout.active_button_shape;
321 button(button_args, ripple_state, || elastic_container(elastic_state, move || child_closure(global_material_scheme().on_primary)));
322 } else {
323 let mut button_args = ButtonArgs::filled(
324 Arc::new(move || {
325 on_click_closure(true);
326 if selection_mode == ButtonGroupsSelectionMode::Single {
327 for item in state.item_states.read().values() {
329 if item.actived.load(Ordering::Acquire) {
330 item.actived.store(false, Ordering::Release);
331 item.elastic_state.write().toggle();
332 }
333 }
334 }
335 item_state.actived.store(true, Ordering::Release);
336 item_state.elastic_state.write().toggle();
337 })
338 );
339 button_args.color = global_material_scheme().secondary_container;
340 if index == 0 {
341 button_args.shape = layout.inactive_button_shape_start;
342 } else if index == child_len - 1 {
343 button_args.shape = layout.inactive_button_shape_end;
344 } else {
345 button_args.shape = layout.inactive_button_shape;
346 }
347
348 button(button_args, ripple_state, move || elastic_container(
349 elastic_state,
350 move || child_closure(global_material_scheme().on_secondary_container))
351 );
352 }
353 })
354 );
355 if index != child_len - 1 {
356 scope.child(move || {
357 spacer(SpacerArgs {
358 width: layout.between_space.into(),
359 ..Default::default()
360 });
361 })
362 }
363 }
364 }
365 ),
366 )
367}
368
369struct ElasticState {
370 expended: bool,
371 last_toggle: Option<Instant>,
372 start_progress: f32,
373}
374
375impl Default for ElasticState {
376 fn default() -> Self {
377 Self {
378 expended: false,
379 last_toggle: None,
380 start_progress: 0.0,
381 }
382 }
383}
384
385impl ElasticState {
386 fn toggle(&mut self) {
387 let current_visual_progress = self.calculate_current_progress();
388 self.expended = !self.expended;
389 self.last_toggle = Some(Instant::now());
390 self.start_progress = current_visual_progress;
391 }
392
393 fn update(&mut self) -> f32 {
394 let current_progress = self.calculate_current_progress();
395 if self.expended {
396 animation::spring(current_progress, 15.0, 0.35)
397 } else {
398 animation::easing(current_progress)
399 }
400 }
401
402 fn calculate_current_progress(&self) -> f32 {
403 let Some(last_toggle) = self.last_toggle else {
404 return if self.expended { 1.0 } else { 0.0 };
405 };
406
407 let elapsed = last_toggle.elapsed().as_secs_f32();
408 let duration = 0.25;
409 let t = (elapsed / duration).clamp(0.0, 1.0);
410 let start = self.start_progress;
411 let target = if self.expended { 1.0 } else { 0.0 };
412
413 start + (target - start) * t
414 }
415}
416
417#[tessera]
418fn elastic_container(state: Arc<RwLock<ElasticState>>, child: impl FnOnce()) {
419 child();
420 let progress = state.write().update();
421 measure(Box::new(move |input| {
422 let child_id = input.children_ids[0];
423 let child_size = input.measure_child(child_id, input.parent_constraint)?;
424 let additional_width = child_size.width.mul_f32(0.15 * progress);
425 input.place_child(child_id, PxPosition::new(additional_width / 2, Px::ZERO));
426
427 Ok(ComputedData {
428 width: child_size.width + additional_width,
429 height: child_size.height,
430 })
431 }))
432}