use std::{collections::HashMap, sync::Arc, time::Instant};
use derive_setters::Setters;
use tessera_ui::{
Color, ComputedData, Dp, LayoutInput, LayoutOutput, LayoutSpec, MeasurementError, Modifier, Px,
PxPosition, remember, tessera, use_context,
};
use crate::{
alignment::MainAxisAlignment,
animation,
button::{ButtonArgs, button},
modifier::ModifierExt,
row::{RowArgs, row},
shape_def::{RoundedCorner, Shape},
spacer::spacer,
theme::MaterialTheme,
};
#[derive(Debug, Clone, Copy, Default)]
pub enum ButtonGroupsStyle {
#[default]
Standard,
Connected,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ButtonGroupsSelectionMode {
#[default]
Single,
Multiple,
}
#[derive(Debug, Clone, Copy, Default)]
pub enum ButtonGroupsSize {
ExtraSmall,
Small,
#[default]
Medium,
Large,
ExtraLarge,
}
pub struct ButtonGroupsScope<'a> {
child_closures: &'a mut Vec<Box<dyn FnOnce(Color) + Send + Sync>>,
on_click_closures: &'a mut Vec<Arc<dyn Fn(bool) + Send + Sync>>,
}
impl ButtonGroupsScope<'_> {
pub fn child<F, C>(&mut self, child: F, on_click: C)
where
F: FnOnce(Color) + Send + Sync + 'static,
C: Fn(bool) + Send + Sync + 'static,
{
self.child_closures.push(Box::new(child));
self.on_click_closures.push(Arc::new(on_click));
}
}
#[derive(Default, Setters)]
pub struct ButtonGroupsArgs {
pub size: ButtonGroupsSize,
pub style: ButtonGroupsStyle,
pub selection_mode: ButtonGroupsSelectionMode,
}
#[derive(Clone)]
struct ButtonGroupsLayout {
container_height: Dp,
between_space: Dp,
active_button_shape: Shape,
inactive_button_shape: Shape,
inactive_button_shape_start: Shape,
inactive_button_shape_end: Shape,
}
impl ButtonGroupsLayout {
fn new(size: ButtonGroupsSize, style: ButtonGroupsStyle) -> Self {
let container_height = match size {
ButtonGroupsSize::ExtraSmall => Dp(32.0),
ButtonGroupsSize::Small => Dp(40.0),
ButtonGroupsSize::Medium => Dp(56.0),
ButtonGroupsSize::Large => Dp(96.0),
ButtonGroupsSize::ExtraLarge => Dp(136.0),
};
let between_space = match style {
ButtonGroupsStyle::Standard => match size {
ButtonGroupsSize::ExtraSmall => Dp(18.0),
ButtonGroupsSize::Small => Dp(12.0),
_ => Dp(8.0),
},
ButtonGroupsStyle::Connected => Dp(2.0),
};
let active_button_shape = match style {
ButtonGroupsStyle::Standard => Shape::rounded_rectangle(Dp(16.0)),
ButtonGroupsStyle::Connected => Shape::capsule(),
};
let inactive_button_shape = match style {
ButtonGroupsStyle::Standard => Shape::capsule(),
ButtonGroupsStyle::Connected => Shape::rounded_rectangle(Dp(16.0)),
};
let inactive_button_shape_start = match style {
ButtonGroupsStyle::Standard => active_button_shape,
ButtonGroupsStyle::Connected => Shape::RoundedRectangle {
top_left: RoundedCorner::Capsule,
top_right: RoundedCorner::manual(Dp(16.0), 3.0),
bottom_right: RoundedCorner::manual(Dp(16.0), 3.0),
bottom_left: RoundedCorner::Capsule,
},
};
let inactive_button_shape_end = match style {
ButtonGroupsStyle::Standard => active_button_shape,
ButtonGroupsStyle::Connected => Shape::RoundedRectangle {
top_left: RoundedCorner::manual(Dp(16.0), 3.0),
top_right: RoundedCorner::Capsule,
bottom_right: RoundedCorner::Capsule,
bottom_left: RoundedCorner::manual(Dp(16.0), 3.0),
},
};
Self {
container_height,
between_space,
active_button_shape,
inactive_button_shape,
inactive_button_shape_start,
inactive_button_shape_end,
}
}
}
#[derive(Default)]
struct ButtonItemState {
actived: bool,
elastic_state: ElasticState,
}
#[derive(Default)]
struct ButtonGroupsState {
item_states: HashMap<usize, ButtonItemState>,
}
impl ButtonGroupsState {
fn item_state_mut(&mut self, index: usize) -> &mut ButtonItemState {
self.item_states.entry(index).or_default()
}
}
#[tessera]
pub fn button_groups<F>(args: impl Into<ButtonGroupsArgs>, scope_config: F)
where
F: FnOnce(&mut ButtonGroupsScope),
{
let state = remember(ButtonGroupsState::default);
let args = args.into();
let mut child_closures = Vec::new();
let mut on_click_closures = Vec::new();
{
let mut scope = ButtonGroupsScope {
child_closures: &mut child_closures,
on_click_closures: &mut on_click_closures,
};
scope_config(&mut scope);
}
let layout = ButtonGroupsLayout::new(args.size, args.style);
let child_len = child_closures.len();
let selection_mode = args.selection_mode;
row(
RowArgs {
modifier: Modifier::new().height(layout.container_height),
main_axis_alignment: MainAxisAlignment::SpaceBetween,
..Default::default()
},
move |scope| {
for (index, child_closure) in child_closures.into_iter().enumerate() {
let on_click_closure = on_click_closures[index].clone();
scope.child(move || {
let actived =
state.with(|s| s.item_states.get(&index).is_some_and(|item| item.actived));
if actived {
let mut button_args = ButtonArgs::filled(move || {
on_click_closure(false);
state.with_mut(|s| {
let item = s.item_state_mut(index);
item.actived = false;
item.elastic_state.toggle();
});
});
button_args.shape = layout.active_button_shape;
let scheme = use_context::<MaterialTheme>()
.expect("MaterialTheme must be provided")
.get()
.color_scheme;
let label_color = scheme.on_primary;
button(button_args, move || {
elastic_container(state, index, move || child_closure(label_color))
});
} else {
let mut button_args = ButtonArgs::filled(move || {
on_click_closure(true);
state.with_mut(|s| {
if selection_mode == ButtonGroupsSelectionMode::Single {
for (other_index, item) in s.item_states.iter_mut() {
if *other_index != index && item.actived {
item.actived = false;
item.elastic_state.toggle();
}
}
}
let item = s.item_state_mut(index);
item.actived = true;
item.elastic_state.toggle();
});
});
let scheme = use_context::<MaterialTheme>()
.expect("MaterialTheme must be provided")
.get()
.color_scheme;
button_args.color = scheme.secondary_container;
if index == 0 {
button_args.shape = layout.inactive_button_shape_start;
} else if index == child_len - 1 {
button_args.shape = layout.inactive_button_shape_end;
} else {
button_args.shape = layout.inactive_button_shape;
}
let scheme = use_context::<MaterialTheme>()
.expect("MaterialTheme must be provided")
.get()
.color_scheme;
let label_color = scheme.on_secondary_container;
button(button_args, move || {
elastic_container(state, index, move || child_closure(label_color))
});
}
});
if index != child_len - 1 {
scope.child(move || {
spacer(Modifier::new().width(layout.between_space));
})
}
}
},
)
}
struct ElasticState {
expended: bool,
last_toggle: Option<Instant>,
start_progress: f32,
}
impl Default for ElasticState {
fn default() -> Self {
Self {
expended: false,
last_toggle: None,
start_progress: 0.0,
}
}
}
impl ElasticState {
fn toggle(&mut self) {
let current_visual_progress = self.calculate_current_progress();
self.expended = !self.expended;
self.last_toggle = Some(Instant::now());
self.start_progress = current_visual_progress;
}
fn update(&mut self) -> f32 {
let current_progress = self.calculate_current_progress();
if self.expended {
animation::spring(current_progress, 15.0, 0.35)
} else {
animation::easing(current_progress)
}
}
fn calculate_current_progress(&self) -> f32 {
let Some(last_toggle) = self.last_toggle else {
return if self.expended { 1.0 } else { 0.0 };
};
let elapsed = last_toggle.elapsed().as_secs_f32();
let duration = 0.25;
let t = (elapsed / duration).clamp(0.0, 1.0);
let start = self.start_progress;
let target = if self.expended { 1.0 } else { 0.0 };
start + (target - start) * t
}
}
#[tessera]
fn elastic_container(
state: tessera_ui::State<ButtonGroupsState>,
index: usize,
child: impl FnOnce(),
) {
child();
let progress = state.with_mut(|s| s.item_state_mut(index).elastic_state.update());
layout(ElasticContainerLayout { progress })
}
#[derive(Clone, Copy, PartialEq)]
struct ElasticContainerLayout {
progress: f32,
}
impl LayoutSpec for ElasticContainerLayout {
fn measure(
&self,
input: &LayoutInput<'_>,
output: &mut LayoutOutput<'_>,
) -> Result<ComputedData, MeasurementError> {
let child_id = input.children_ids()[0];
let child_size = input.measure_child_in_parent_constraint(child_id)?;
let additional_width = child_size.width.mul_f32(0.15 * self.progress);
output.place_child(child_id, PxPosition::new(additional_width / 2, Px::ZERO));
Ok(ComputedData {
width: child_size.width + additional_width,
height: child_size.height,
})
}
}