1use tessera_ui::{
8 Callback, Color, Dp, FrameNanosControl, Modifier, State,
9 accesskit::{Role, Toggled},
10 current_frame_nanos, receive_frame_nanos, remember, tessera, use_context,
11};
12
13use crate::{
14 alignment::Alignment,
15 animation::Animatable,
16 boxed::boxed,
17 modifier::{InteractionState, InteractionVisualState, ModifierExt as _},
18 painter::Painter,
19 shape_def::{RoundedCorner, Shape},
20 surface::{RipplePresentation, SurfaceStyle, surface},
21 theme::{ContentColor, MaterialColorScheme, MaterialTheme, content_color_for},
22};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum IconButtonVariant {
27 Standard,
29 #[default]
31 Filled,
32 FilledTonal,
34 Outlined,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum IconButtonSize {
41 ExtraSmall,
43 #[default]
45 Small,
46 Medium,
48 Large,
50 ExtraLarge,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
56pub enum IconButtonWidth {
57 Narrow,
59 #[default]
61 Uniform,
62 Wide,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68pub enum IconButtonShape {
69 #[default]
71 Round,
72 Square,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
78pub enum IconButtonColorStyle {
79 #[default]
81 Vibrant,
82 Inherited,
84}
85
86impl IconButtonSize {
87 pub const fn height(self) -> Dp {
89 Dp(match self {
90 Self::ExtraSmall => 32.0,
91 Self::Small => 40.0,
92 Self::Medium => 56.0,
93 Self::Large => 96.0,
94 Self::ExtraLarge => 136.0,
95 })
96 }
97
98 pub const fn icon_size(self) -> Dp {
100 Dp(match self {
101 Self::ExtraSmall => 20.0,
102 Self::Small | Self::Medium => 24.0,
103 Self::Large => 32.0,
104 Self::ExtraLarge => 40.0,
105 })
106 }
107
108 pub const fn width(self, width: IconButtonWidth) -> Dp {
110 let (narrow, uniform, wide) = match self {
111 Self::ExtraSmall => (28.0, 32.0, 40.0),
112 Self::Small => (32.0, 40.0, 52.0),
113 Self::Medium => (48.0, 56.0, 72.0),
114 Self::Large => (64.0, 96.0, 128.0),
115 Self::ExtraLarge => (104.0, 136.0, 184.0),
116 };
117 Dp(match width {
118 IconButtonWidth::Narrow => narrow,
119 IconButtonWidth::Uniform => uniform,
120 IconButtonWidth::Wide => wide,
121 })
122 }
123
124 pub const fn outline_width(self) -> Dp {
126 Dp(match self {
127 Self::ExtraSmall | Self::Small | Self::Medium => 1.0,
128 Self::Large => 2.0,
129 Self::ExtraLarge => 3.0,
130 })
131 }
132
133 pub const fn square_radius(self) -> Dp {
135 Dp(match self {
136 Self::ExtraSmall | Self::Small => 12.0,
137 Self::Medium => 16.0,
138 Self::Large | Self::ExtraLarge => 28.0,
139 })
140 }
141
142 pub const fn pressed_radius(self) -> Dp {
144 Dp(match self {
145 Self::ExtraSmall | Self::Small => 8.0,
146 Self::Medium => 12.0,
147 Self::Large | Self::ExtraLarge => 16.0,
148 })
149 }
150}
151
152impl IconButtonBuilder {
153 pub fn round(&self) -> Self {
155 self.shape_style(IconButtonShape::Round)
156 }
157
158 pub fn square(&self) -> Self {
160 self.shape_style(IconButtonShape::Square)
161 }
162
163 pub fn standard(&self) -> Self {
165 self.variant(IconButtonVariant::Standard)
166 }
167
168 pub fn filled(&self) -> Self {
170 self.variant(IconButtonVariant::Filled)
171 }
172
173 pub fn filled_tonal(&self) -> Self {
175 self.variant(IconButtonVariant::FilledTonal)
176 }
177
178 pub fn outlined(&self) -> Self {
180 self.variant(IconButtonVariant::Outlined)
181 }
182}
183
184#[derive(Clone, Copy, Debug, PartialEq)]
185struct IconButtonCorner {
186 fraction: f32,
187 radius: f32,
188}
189
190impl IconButtonCorner {
191 fn new(size: IconButtonSize, shape: IconButtonShape, selected: bool, pressed: bool) -> Self {
192 let (fraction, radius) = if pressed {
193 (0.0, size.pressed_radius().0 as f32)
194 } else if (shape == IconButtonShape::Round) != selected {
195 (0.5, 0.0)
196 } else {
197 (0.0, size.square_radius().0 as f32)
198 };
199 Self { fraction, radius }
200 }
201}
202
203struct IconButtonMotion {
204 fraction: Animatable,
205 radius: Animatable,
206 target: IconButtonCorner,
207 frame_nanos: u64,
208}
209
210impl IconButtonMotion {
211 fn new(target: IconButtonCorner, frame_nanos: u64) -> Self {
212 Self {
213 fraction: Animatable::spring(target.fraction, 40.0, 1.0),
214 radius: Animatable::spring(target.radius, 40.0, 1.0),
215 target,
216 frame_nanos,
217 }
218 }
219
220 fn retarget(&mut self, target: IconButtonCorner, frame_nanos: u64) {
221 self.advance(frame_nanos);
222 self.fraction.set_target(target.fraction);
223 self.radius.set_target(target.radius);
224 self.target = target;
225 }
226
227 fn advance(&mut self, frame_nanos: u64) {
228 if frame_nanos > self.frame_nanos {
229 let dt = (frame_nanos - self.frame_nanos) as f32 / 1_000_000_000.0;
230 self.fraction.tick(dt);
231 self.radius.tick(dt);
232 self.frame_nanos = frame_nanos;
233 }
234 }
235
236 fn is_idle(&self) -> bool {
237 self.fraction.is_idle() && self.radius.is_idle()
238 }
239
240 fn shape(&self) -> Shape {
241 let fraction = self.fraction.value();
242 let corner = RoundedCorner::relative(
245 fraction,
246 Dp(self.radius.value() as f64),
247 3.0 - fraction * 2.0,
248 );
249 Shape::RoundedRectangle {
250 top_left: corner,
251 top_right: corner,
252 bottom_right: corner,
253 bottom_left: corner,
254 }
255 }
256}
257
258#[derive(Clone, Copy, Debug, PartialEq)]
259struct IconButtonColors {
260 container: Color,
261 content: Color,
262 border: Option<Color>,
263}
264
265fn resolve_colors(
266 scheme: &MaterialColorScheme,
267 variant: IconButtonVariant,
268 selected: Option<bool>,
269 enabled: bool,
270 inherited: Option<Color>,
271) -> IconButtonColors {
272 let checked = selected == Some(true);
273 let outlined = variant == IconButtonVariant::Outlined && !checked;
274 let container = if !enabled {
275 if matches!(
276 variant,
277 IconButtonVariant::Filled | IconButtonVariant::FilledTonal
278 ) || (variant == IconButtonVariant::Outlined && checked)
279 {
280 scheme.on_surface.with_alpha(0.1)
281 } else {
282 Color::TRANSPARENT
283 }
284 } else {
285 match (variant, selected) {
286 (IconButtonVariant::Filled, Some(false)) => scheme.surface_container,
287 (IconButtonVariant::Filled, _) => scheme.primary,
288 (IconButtonVariant::FilledTonal, Some(true)) => scheme.secondary,
289 (IconButtonVariant::FilledTonal, _) => scheme.secondary_container,
290 (IconButtonVariant::Outlined, Some(true)) => scheme.inverse_surface,
291 _ => Color::TRANSPARENT,
292 }
293 };
294 let content = if !enabled {
295 let base = if matches!(
296 variant,
297 IconButtonVariant::Standard | IconButtonVariant::Outlined
298 ) {
299 inherited.unwrap_or(scheme.on_surface)
300 } else {
301 scheme.on_surface
302 };
303 base.with_alpha(0.38)
304 } else {
305 match (variant, selected) {
306 (IconButtonVariant::Filled, Some(false)) => scheme.on_surface_variant,
307 (IconButtonVariant::Filled, _) => scheme.on_primary,
308 (IconButtonVariant::FilledTonal, Some(true)) => scheme.on_secondary,
309 (IconButtonVariant::FilledTonal, _) => scheme.on_secondary_container,
310 (IconButtonVariant::Outlined, Some(true)) => scheme.inverse_on_surface,
311 (IconButtonVariant::Standard, Some(true)) => scheme.primary,
312 _ => inherited.unwrap_or(scheme.on_surface_variant),
313 }
314 };
315 let border = outlined.then(|| {
316 let base = inherited.unwrap_or(scheme.outline_variant);
317 if enabled { base } else { base.with_alpha(0.38) }
318 });
319 IconButtonColors {
320 container,
321 content,
322 border,
323 }
324}
325
326#[tessera]
389pub fn icon_button(
390 variant: Option<IconButtonVariant>,
391 size: Option<IconButtonSize>,
392 width: Option<IconButtonWidth>,
393 shape_style: Option<IconButtonShape>,
394 selected: Option<bool>,
395 #[prop(into)] icon: Option<Painter>,
396 on_click: Option<Callback>,
397 enabled: Option<bool>,
398 modifier: Option<Modifier>,
399 shape: Option<Shape>,
400 color_style: Option<IconButtonColorStyle>,
401 color: Option<Color>,
402 content_color: Option<Color>,
403 interaction_state: Option<State<InteractionState>>,
404 presentation_state: Option<InteractionVisualState>,
405 presentation_ripple: Option<RipplePresentation>,
406 #[prop(into)] accessibility_label: Option<String>,
407 #[prop(into)] accessibility_description: Option<String>,
408) {
409 let scheme = use_context::<MaterialTheme>()
410 .expect("MaterialTheme must be provided")
411 .get()
412 .color_scheme;
413 let variant = variant.unwrap_or_default();
414 let size = size.unwrap_or_default();
415 let width = width.unwrap_or_default();
416 let enabled = enabled.unwrap_or(true);
417 let interactive = enabled && on_click.is_some();
418 let interaction_state = if let Some(state) = interaction_state {
419 state
420 } else {
421 remember(InteractionState::new)
422 };
423 if !interactive && interaction_state.with(|state| *state != InteractionState::new()) {
424 interaction_state.set(InteractionState::new());
425 }
426 let visual =
427 interaction_state.with(|state| state.with_presentation(presentation_state, enabled));
428 let target = if shape.is_some() {
429 IconButtonCorner {
430 fraction: 0.0,
431 radius: 0.0,
432 }
433 } else {
434 IconButtonCorner::new(
435 size,
436 shape_style.unwrap_or_default(),
437 selected.unwrap_or(false),
438 visual.is_pressed(),
439 )
440 };
441 let frame = current_frame_nanos();
442 let motion = remember(|| IconButtonMotion::new(target, frame));
443 if shape.is_some() {
444 if !motion.with(IconButtonMotion::is_idle) || motion.with(|motion| motion.target) != target
445 {
446 motion.set(IconButtonMotion::new(target, frame));
447 }
448 } else if motion.with(|motion| motion.target) != target {
449 motion.with_mut(|motion| motion.retarget(target, frame));
450 }
451 if !motion.with(IconButtonMotion::is_idle) {
452 receive_frame_nanos(move |frame| {
453 motion.with_mut(|motion| motion.advance(frame));
454 if motion.with(IconButtonMotion::is_idle) {
455 FrameNanosControl::Stop
456 } else {
457 FrameNanosControl::Continue
458 }
459 });
460 }
461 let inherited = if color_style.unwrap_or_default() == IconButtonColorStyle::Inherited {
462 Some(
463 use_context::<ContentColor>()
464 .map(|color| color.get().current)
465 .unwrap_or(scheme.on_surface),
466 )
467 } else {
468 None
469 };
470 let defaults = resolve_colors(&scheme, variant, selected, enabled, inherited);
471 let container = if enabled {
472 color.unwrap_or(defaults.container)
473 } else {
474 defaults.container
475 };
476 let content = if enabled {
477 content_color
478 .or_else(|| color.and_then(|color| content_color_for(color, &scheme)))
479 .unwrap_or(defaults.content)
480 } else {
481 defaults.content
482 };
483 let style = if let Some(border_color) = defaults.border {
484 SurfaceStyle::FilledOutlined {
485 fill_color: container,
486 border_color,
487 border_width: size.outline_width(),
488 }
489 } else {
490 SurfaceStyle::Filled { color: container }
491 };
492 surface()
493 .modifier(modifier.unwrap_or_default())
494 .shape(shape.unwrap_or_else(|| motion.with(IconButtonMotion::shape)))
495 .style(style)
496 .content_alignment(Alignment::Center)
497 .content_color(content)
498 .enabled(enabled)
499 .interaction_state(interaction_state)
500 .presentation_state_optional(presentation_state)
501 .presentation_ripple_optional(presentation_ripple)
502 .show_focus_indicator(true)
503 .ripple_color(content)
504 .accessibility_role(Role::Button)
505 .accessibility_toggled_optional(
506 selected.map(|value| if value { Toggled::True } else { Toggled::False }),
507 )
508 .accessibility_focusable(interactive)
509 .accessibility_label_optional(accessibility_label)
510 .accessibility_description_optional(accessibility_description)
511 .on_click_optional(on_click)
512 .child(move || {
513 let icon = icon.clone();
514 boxed()
515 .modifier(Modifier::new().size(size.width(width), size.height()))
516 .alignment(Alignment::Center)
517 .children(move || {
518 if let Some(icon) = &icon {
519 crate::icon::icon()
520 .painter(icon.clone())
521 .size(size.icon_size());
522 }
523 });
524 });
525}
526
527#[cfg(test)]
528mod tests;