tessera_ui_basic_components/
button_groups.rs

1//! Material 3-style segmented buttons with single or multiple selection.
2//!
3//! ## Usage
4//!
5//! Used for grouping related actions.
6
7use 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/// According to the [`ButtonGroups-Types`](https://m3.material.io/components/button-groups/specs#3b51d175-cc02-4701-b3f8-c9ffa229123a)
33/// spec, the [`button_groups`] component supports two styles: `Standard` and `Connected`.
34///
35/// ## Standard
36///
37/// Buttons have spacing between them and do not need to be the same width.
38///
39/// ## Connected
40///
41/// Buttons are adjacent with no spacing, and each button must be the same width.
42#[derive(Debug, Clone, Copy, Default)]
43pub enum ButtonGroupsStyle {
44    /// Buttons have spacing between them and do not need to be the same width.
45    #[default]
46    Standard,
47    /// Buttons are adjacent with no spacing, and each button must be the same width.
48    Connected,
49}
50
51/// According to the [`ButtonGroups-Configurations`](https://m3.material.io/components/button-groups/specs#0d2cf762-275c-4693-9484-fe011501439e)
52/// spec, the [`button_groups`] component supports two selection modes: `Single` and `Multiple`.
53///
54/// ## Single
55///
56/// Only one button can be selected at a time.
57///
58/// ## Multiple
59///
60/// Multiple buttons can be selected at the same time.
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
62pub enum ButtonGroupsSelectionMode {
63    /// Only one button can be selected at a time.
64    #[default]
65    Single,
66    /// Multiple buttons can be selected at the same time.
67    Multiple,
68}
69
70/// According to the [`ButtonGroups-Configurations`](https://m3.material.io/components/button-groups/specs#0d2cf762-275c-4693-9484-fe011501439e)
71/// spec, the [`button_groups`] component supports a series of sizes.
72#[derive(Debug, Clone, Copy, Default)]
73pub enum ButtonGroupsSize {
74    /// Extra small size.
75    ExtraSmall,
76    /// Small size.
77    Small,
78    /// Medium size.
79    #[default]
80    Medium,
81    /// Large size.
82    Large,
83    /// Extra large size.
84    ExtraLarge,
85}
86
87/// A scope for declaratively adding children to a [`button_groups`] component.
88pub 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    /// Add a child component to the button group, which will be wrapped by a [`button`] component.
95    ///
96    /// # Arguments
97    ///
98    /// - `child_closure` - A closure that takes a [`Color`] and returns a [`button`] component. The
99    ///   `Color` argument should be used for the content of the child component.
100    /// - `on_click_closure` - A closure that will be called when the button is clicked. The closure
101    ///   takes a `bool` argument indicating whether the button is now active (selected) or not.
102    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/// Arguments for the [`button_groups`] component.
113#[derive(Builder, Default)]
114pub struct ButtonGroupsArgs {
115    /// Size of the button group.
116    #[builder(default)]
117    pub size: ButtonGroupsSize,
118    /// Style of the button group.
119    #[builder(default)]
120    pub style: ButtonGroupsStyle,
121    /// Selection mode of the button group.
122    #[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        // See https://m3.material.io/components/button-groups/specs#f41a7d35-b9c2-4340-b3bb-47b34acaaf45
139        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/// State of a button group.
199#[derive(Clone, Default)]
200pub struct ButtonGroupsState {
201    item_states: Arc<RwLock<HashMap<usize, ButtonItemState>>>,
202}
203
204/// # button_groups
205///
206/// Button groups organize buttons and add interactions between them.
207///
208/// ## Usage
209///
210/// Used for grouping related actions.
211///
212/// ## Arguments
213///
214/// - `args` - Arguments for configuring the button group.
215/// - `state` - State of the button group.
216/// - `scope_config` - A closure that configures the children of the button group using
217///   a [`ButtonGroupsScope`].
218///
219/// # Example
220///
221/// ```
222/// use tessera_ui_basic_components::{
223///    button_groups::{ButtonGroupsArgs, ButtonGroupsState, button_groups},
224///    text::{TextArgs, text},
225/// };
226///
227/// let button_groups_state = ButtonGroupsState::default();
228/// button_groups(
229///     ButtonGroupsArgs::default(),
230///     button_groups_state.clone(),
231///     |scope| {
232///         scope.child(
233///             |color| {
234///                 text(TextArgs {
235///                     text: "Button 1".to_string(),
236///                     color,
237///                     ..Default::default()
238///                 })
239///             },
240///             |_| {
241///                 println!("Button 1 clicked");
242///             },
243///         );
244///
245///         scope.child(
246///             |color| {
247///                 text(TextArgs {
248///                     text: "Button 2".to_string(),
249///                     color,
250///                     ..Default::default()
251///                 })
252///             },
253///             |actived| {
254///                 println!("Button 2 clicked");
255///             },
256///         );
257///
258///         scope.child(
259///             |color| {
260///                 text(TextArgs {
261///                     text: "Button 3".to_string(),
262///                     color,
263///                     ..Default::default()
264///                 })
265///             },
266///             |actived| {
267///                 println!("Button 3 clicked");
268///             },
269///         );
270///     },
271/// );
272/// ```
273#[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                                            // Deactivate all other buttons if in single selection mode
328                                            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}