Skip to main content

cranpose_ui/widgets/wear/
switch_button.rs

1//! A Wear switch row: a 52dp capsule with a label and a 32x22dp switch.
2//!
3//! Three details here are measured, not guessed, and each is a place a
4//! reasonable implementation goes wrong:
5//!
6//! - **The switch graphic sits 2dp above the row's centre.** Its slot is
7//!   32x24dp but the switch draws at 32x22dp, and the slot aligns width only —
8//!   vertical placement falls back to the top. Centring the graphic in the row,
9//!   which is the obvious thing to do, is wrong by 2 pixels at density 2.
10//! - **A checked switch has no border.** The track and the border both resolve
11//!   to `primary`, and the drawing suppresses a border equal to its track. Only
12//!   the unchecked state shows the outline ring.
13//! - **A checked thumb is often invisible, and still matters.** In a scheme
14//!   where the thumb and the container are the same colour it shows only where
15//!   it overlaps the light track — but it still occludes the track, so it
16//!   cannot be optimised away.
17
18#![allow(non_snake_case)]
19
20use crate::composable;
21use crate::modifier::{Brush, Color, CornerRadii, Modifier, Point, Rect};
22use crate::widgets::wear::density::WearDensity;
23use crate::widgets::wear::theme::{WearColors, WearTextStyle};
24use crate::widgets::{Layout, Text};
25use cranpose_core::NodeId;
26use cranpose_foundation::SemanticsWidgetRole;
27use cranpose_ui_graphics::{DrawScope, Size, VectorPath};
28use cranpose_ui_layout::{Constraints, Measurable, MeasurePolicy, MeasureResult, Placement};
29
30/// `SwitchButtonDefaults` plus `ToggleButton`'s layout constants.
31#[derive(Clone, Copy, Debug, PartialEq)]
32pub struct SwitchButtonSpec {
33    pub colors: WearColors,
34    pub label_style: WearTextStyle,
35    pub secondary_style: WearTextStyle,
36    pub min_height: f32,
37    pub corner_radius: f32,
38    pub padding_horizontal: f32,
39    pub padding_vertical: f32,
40    /// `SWITCH_WIDTH`.
41    pub switch_width: f32,
42    /// `SWITCH_OUTER_HEIGHT` — the slot the graphic is placed in.
43    pub switch_slot_height: f32,
44    /// `SWITCH_INNER_HEIGHT` — what the graphic actually draws.
45    pub switch_height: f32,
46    /// `SWITCH_TRACK_WIDTH` — the unchecked state's border stroke.
47    pub track_border_width: f32,
48    pub thumb_radius_unchecked: f32,
49    pub thumb_radius_checked: f32,
50    /// `TOGGLE_CONTROL_SPACING`.
51    pub control_spacing: f32,
52    /// `LabelSpacerSize`.
53    pub label_spacing: f32,
54    /// How far the thumb has travelled, `0.0` unchecked and `1.0` checked.
55    ///
56    /// A caller animating the transition drives this; a caller that does not
57    /// passes the checked state itself. Wear animates it with
58    /// `motionScheme.fastEffectsSpec()`, a critically damped spring of
59    /// stiffness 1400.
60    pub progress: f32,
61}
62
63impl Default for SwitchButtonSpec {
64    fn default() -> Self {
65        Self {
66            colors: WearColors::default(),
67            label_style: WearTextStyle::LABEL_MEDIUM,
68            secondary_style: WearTextStyle::LABEL_SMALL,
69            min_height: 52.0,
70            corner_radius: 26.0,
71            padding_horizontal: 14.0,
72            padding_vertical: 8.0,
73            switch_width: 32.0,
74            switch_slot_height: 24.0,
75            switch_height: 22.0,
76            track_border_width: 2.0,
77            thumb_radius_unchecked: 6.0,
78            thumb_radius_checked: 9.0,
79            control_spacing: 6.0,
80            label_spacing: 1.0,
81            progress: 0.0,
82        }
83    }
84}
85
86impl SwitchButtonSpec {
87    pub fn colors(mut self, colors: WearColors) -> Self {
88        self.colors = colors;
89        self
90    }
91
92    /// The thumb travel, which a caller animates or snaps.
93    pub fn progress(mut self, progress: f32) -> Self {
94        self.progress = progress.clamp(0.0, 1.0);
95        self
96    }
97}
98
99/// The stiffness and damping of the two springs a Wear switch animates on.
100///
101/// `MotionScheme.standard()`: `fastEffectsSpec` moves the thumb, its radius and
102/// the tick's scale; `slowEffectsSpec` moves every colour. Both are critically
103/// damped — a switch does not bounce.
104pub const SWITCH_THUMB_STIFFNESS: f32 = 1400.0;
105pub const SWITCH_COLOR_STIFFNESS: f32 = 260.0;
106pub const SWITCH_DAMPING_RATIO: f32 = 1.0;
107
108/// The colours one state of the switch draws with.
109#[derive(Clone, Copy, Debug, PartialEq)]
110pub struct SwitchColors {
111    pub container: Color,
112    pub label: Color,
113    pub secondary_label: Color,
114    pub track: Color,
115    /// `Color::rgba(_, _, _, 0.0)` when the border is suppressed.
116    pub track_border: Color,
117    pub thumb: Color,
118    pub tick: Color,
119}
120
121impl SwitchColors {
122    /// The slots a switch resolves to at a given checked state.
123    ///
124    /// The border suppression lives here: `if currentTrackColor ==
125    /// currentTrackBorderColor { borderColor = Color.Transparent }`, which in
126    /// the checked state is always true because both are `primary`.
127    pub fn of(colors: WearColors, checked: bool) -> Self {
128        let (container, label, secondary_label, track, border, thumb, tick) = if checked {
129            (
130                colors.primary_container,
131                colors.on_primary_container,
132                fade(colors.on_primary_container, 0.9),
133                colors.primary,
134                colors.primary,
135                colors.primary_container,
136                colors.primary,
137            )
138        } else {
139            (
140                colors.surface_container,
141                colors.on_surface,
142                colors.on_surface_variant,
143                colors.surface_container,
144                colors.outline,
145                colors.outline,
146                colors.primary,
147            )
148        };
149        let track_border = if border == track {
150            Color::rgba(border.0, border.1, border.2, 0.0)
151        } else {
152            border
153        };
154        Self {
155            container,
156            label,
157            secondary_label,
158            track,
159            track_border,
160            thumb,
161            tick,
162        }
163    }
164}
165
166fn fade(color: Color, alpha: f32) -> Color {
167    Color::rgba(color.0, color.1, color.2, color.3 * alpha)
168}
169
170/// Where the thumb is and how big, at a given progress.
171///
172/// `thumbPadding` is `height/2 - radius` on each side, so the thumb grows into
173/// its own margin as it travels: 5dp of clearance unchecked and 2dp checked.
174pub fn switch_thumb(spec: SwitchButtonSpec, progress: f32) -> (f32, f32) {
175    let progress = progress.clamp(0.0, 1.0);
176    let radius = spec.thumb_radius_unchecked
177        + (spec.thumb_radius_checked - spec.thumb_radius_unchecked) * progress;
178    let half = spec.switch_height * 0.5;
179    let start = radius + (half - spec.thumb_radius_unchecked);
180    let end = spec.switch_width - radius - (half - spec.thumb_radius_checked);
181    (start + (end - start) * progress, radius)
182}
183
184/// The tick's scale factor: a cubic ease-out on the thumb progress.
185///
186/// The stroke scales with the canvas, so the tick's own line thins as it grows
187/// in rather than being drawn thin and widening.
188pub fn switch_tick_scale(progress: f32) -> f32 {
189    let remaining = 1.0 - progress.clamp(0.0, 1.0);
190    1.0 - remaining * remaining * remaining
191}
192
193/// Draws the 32x22dp switch graphic into a scope of exactly that size.
194pub fn draw_switch(scope: &mut dyn DrawScope, spec: SwitchButtonSpec, colors: SwitchColors) {
195    let size = scope.size();
196    if size.width <= 0.0 || size.height <= 0.0 {
197        return;
198    }
199    let radius = size.height * 0.5;
200    scope.draw_round_rect(Brush::Solid(colors.track), CornerRadii::uniform(radius));
201
202    if colors.track_border.3 > 0.0 {
203        // A centred stroke, so the ring is inset by half its own width and the
204        // rounded corner shrinks with it.
205        let inset = spec.track_border_width * 0.5;
206        scope.draw_round_rect_at_stroked(
207            Rect {
208                x: inset,
209                y: inset,
210                width: size.width - spec.track_border_width,
211                height: size.height - spec.track_border_width,
212            },
213            Brush::Solid(colors.track_border),
214            CornerRadii::uniform(radius - inset),
215            cranpose_ui_graphics::Stroke::new(spec.track_border_width),
216        );
217    }
218
219    let (centre_x, thumb_radius) = switch_thumb(spec, spec.progress);
220    scope.draw_circle(
221        Brush::Solid(colors.thumb),
222        Point {
223            x: centre_x,
224            y: radius,
225        },
226        thumb_radius,
227    );
228
229    draw_tick(
230        scope,
231        Point {
232            x: centre_x,
233            y: radius,
234        },
235        switch_tick_scale(spec.progress),
236        colors.tick,
237    );
238}
239
240/// Wear's tick, centred on a point.
241///
242/// The path lives in a 24x24dp design box and is **two disjoint subpaths**, not
243/// one polyline: the joint is two overlapping round caps rather than a stroke
244/// join, which is a visibly different corner.
245fn draw_tick(scope: &mut dyn DrawScope, centre: Point, scale: f32, color: Color) {
246    if scale <= 0.0 || color.3 <= 0.0 {
247        return;
248    }
249    // (start, end) in the 24x24 design box, relative to its (12, 12) centre.
250    const SEGMENTS: [((f32, f32), (f32, f32)); 2] = [
251        ((7.4 - 12.0, 13.0 - 12.0), (9.9 - 12.0, 15.5 - 12.0)),
252        ((10.5 - 12.0, 15.1 - 12.0), (16.5 - 12.0, 9.1 - 12.0)),
253    ];
254    const STROKE: f32 = 2.0;
255    let half = STROKE * scale * 0.5;
256    for (start, end) in SEGMENTS {
257        let a = Point {
258            x: centre.x + start.0 * scale,
259            y: centre.y + start.1 * scale,
260        };
261        let b = Point {
262            x: centre.x + end.0 * scale,
263            y: centre.y + end.1 * scale,
264        };
265        stroke_round_capped(scope, a, b, half, color);
266    }
267}
268
269/// A round-capped straight stroke, as its exact constituent shapes.
270///
271/// A round-capped stroke is the sum of the segment and a disc, so it is a
272/// parallelogram body between two circles. `DrawScope` has no line primitive
273/// and `draw_vector_path` fills rather than strokes, so this composes the three
274/// shapes it is made of rather than approximating it.
275fn stroke_round_capped(scope: &mut dyn DrawScope, a: Point, b: Point, half: f32, color: Color) {
276    if half <= 0.0 {
277        return;
278    }
279    let (dx, dy) = (b.x - a.x, b.y - a.y);
280    let length = (dx * dx + dy * dy).sqrt();
281    if length > f32::EPSILON {
282        let (nx, ny) = (-dy / length * half, dx / length * half);
283        let body = format!(
284            "M {} {} L {} {} L {} {} L {} {} Z",
285            a.x + nx,
286            a.y + ny,
287            b.x + nx,
288            b.y + ny,
289            b.x - nx,
290            b.y - ny,
291            a.x - nx,
292            a.y - ny
293        );
294        if let Ok(path) = VectorPath::parse(&body) {
295            scope.draw_vector_path(&path, Brush::Solid(color));
296        }
297    }
298    scope.draw_circle(Brush::Solid(color), a, half);
299    scope.draw_circle(Brush::Solid(color), b, half);
300}
301
302/// A switch row: a label, an optional secondary label, and a switch.
303///
304/// `on_checked_change` is handed the **new** value, matching
305/// `Modifier.toggleable`. It is unwrapped into a zero-argument callback for the
306/// node below because a composable's callback parameters take no arguments —
307/// the macro re-installs them through an `Fn()`.
308pub fn SwitchButton<F>(
309    modifier: Modifier,
310    spec: SwitchButtonSpec,
311    checked: bool,
312    label: String,
313    secondary_label: Option<String>,
314    on_checked_change: F,
315) -> NodeId
316where
317    F: Fn(bool) + 'static,
318{
319    SwitchButtonNode(modifier, spec, checked, label, secondary_label, move || {
320        on_checked_change(!checked)
321    })
322}
323
324/// The node half of [`SwitchButton`].
325#[composable]
326pub fn SwitchButtonNode<F>(
327    modifier: Modifier,
328    spec: SwitchButtonSpec,
329    checked: bool,
330    label: String,
331    secondary_label: Option<String>,
332    on_toggle: F,
333) -> NodeId
334where
335    F: FnMut() + 'static,
336{
337    let density = WearDensity::current();
338    let colors = SwitchColors::of(spec.colors, checked);
339    let radius = density.dp(spec.corner_radius);
340    let container = colors.container;
341    let chrome = modifier
342        .draw_behind(move |scope: &mut dyn DrawScope| {
343            scope.draw_round_rect(Brush::Solid(container), CornerRadii::uniform(radius));
344        })
345        // `Role.Switch`, which is what tells a screen reader the row is a
346        // toggle before it is acted on rather than after. Wear puts it on the
347        // `Switch` control inside the row and lets the merge carry it up; this
348        // names it on the row, which is the same announcement and does not
349        // depend on the merge.
350        .toggleable(
351            checked,
352            Some(format!("{label}, {}", if checked { "on" } else { "off" })),
353            Some(SemanticsWidgetRole::Switch),
354            move |_next| on_toggle(),
355        );
356
357    let label_style = spec.label_style.resolve(colors.label);
358    let secondary_style = spec.secondary_style.resolve(colors.secondary_label);
359    let label_text = label;
360    Layout(
361        chrome,
362        SwitchButtonMeasurePolicy {
363            spec,
364            density: density.density(),
365            has_secondary: secondary_label.is_some(),
366        },
367        move || {
368            Text(label_text.clone(), Modifier::empty(), label_style.clone());
369            if let Some(secondary) = secondary_label.clone() {
370                Text(secondary, Modifier::empty(), secondary_style.clone());
371            }
372            SwitchGraphic(Modifier::empty(), spec, colors);
373        },
374    )
375}
376
377/// The switch itself, as its own node so it has its own hit rect and its own
378/// place in the tree.
379#[composable]
380pub fn SwitchGraphic(modifier: Modifier, spec: SwitchButtonSpec, colors: SwitchColors) -> NodeId {
381    crate::widgets::Canvas(
382        modifier.size_points(spec.switch_width, spec.switch_height),
383        move |scope: &mut dyn DrawScope| draw_switch(scope, spec, colors),
384    )
385}
386
387#[derive(Clone, Debug, PartialEq)]
388struct SwitchButtonMeasurePolicy {
389    spec: SwitchButtonSpec,
390    density: f32,
391    has_secondary: bool,
392}
393
394impl MeasurePolicy for SwitchButtonMeasurePolicy {
395    fn measure(
396        &self,
397        measurables: &[Box<dyn Measurable>],
398        constraints: Constraints,
399    ) -> MeasureResult {
400        let mut placements = Vec::new();
401        let size = self.measure_into(measurables, constraints, &mut placements);
402        MeasureResult::new(size, placements)
403    }
404
405    fn measure_into(
406        &self,
407        measurables: &[Box<dyn Measurable>],
408        constraints: Constraints,
409        placements: &mut Vec<Placement>,
410    ) -> Size {
411        placements.clear();
412        let density = WearDensity::new(self.density, 1.0);
413        let horizontal = density.dp(self.spec.padding_horizontal) * 2.0;
414        let vertical = density.dp(self.spec.padding_vertical) * 2.0;
415        let width = if constraints.max_width.is_finite() {
416            constraints.max_width
417        } else {
418            constraints.min_width
419        };
420
421        // The switch is measured first because the label column gets whatever
422        // is left: `Labels` has `weight(1f)` and absorbs all the slack, so its
423        // width is a subtraction rather than an intrinsic.
424        let switch_width = density.dp(self.spec.switch_width);
425        let spacing = density.dp(self.spec.control_spacing);
426        let label_width = (width - horizontal - switch_width - spacing).max(0.0);
427        let label_constraints = Constraints {
428            min_width: 0.0,
429            max_width: label_width,
430            min_height: 0.0,
431            max_height: f32::INFINITY,
432        };
433
434        let label_count = if self.has_secondary { 2 } else { 1 };
435        let mut labels = Vec::with_capacity(label_count);
436        for measurable in measurables.iter().take(label_count) {
437            labels.push(measurable.measure(label_constraints));
438        }
439        let switch = measurables.get(label_count).map(|measurable| {
440            measurable.measure(Constraints {
441                min_width: 0.0,
442                max_width: switch_width,
443                min_height: 0.0,
444                max_height: f32::INFINITY,
445            })
446        });
447
448        let label_spacing = density.dp(self.spec.label_spacing);
449        let column: f32 = labels
450            .iter()
451            .map(|placeable| density.ceil(placeable.height()))
452            .sum::<f32>()
453            + label_spacing * labels.len().saturating_sub(1) as f32;
454        let slot = density.dp(self.spec.switch_slot_height);
455        let content_demand = column.max(slot);
456        let height = density
457            .dp(self.spec.min_height)
458            .max(density.ceil(content_demand) + vertical)
459            .clamp(constraints.min_height, constraints.max_height);
460
461        let content = height - vertical;
462        let padding_top = density.dp(self.spec.padding_vertical);
463        let mut y = padding_top + density.centre(content, column);
464        let x = density.dp(self.spec.padding_horizontal);
465        for placeable in &labels {
466            placements.push(Placement::new(placeable.node_id(), x, y, 0));
467            y += density.ceil(placeable.height()) + label_spacing;
468        }
469
470        if let Some(switch) = switch {
471            // The 24dp slot is centred in the content area, and the 22dp
472            // graphic is TOP-aligned inside that slot — so the graphic ends up
473            // one slot-slack above the row's centre, not on it.
474            let slot_top = padding_top + density.centre(content, slot);
475            let switch_x = width - density.dp(self.spec.padding_horizontal) - switch.width();
476            placements.push(Placement::new(switch.node_id(), switch_x, slot_top, 0));
477        }
478
479        Size::new(width, height)
480    }
481
482    fn min_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
483        let density = WearDensity::new(self.density, 1.0);
484        measurables
485            .iter()
486            .map(|m| m.min_intrinsic_width(height))
487            .fold(0.0, f32::max)
488            + density.dp(self.spec.padding_horizontal) * 2.0
489            + density.dp(self.spec.switch_width)
490            + density.dp(self.spec.control_spacing)
491    }
492
493    fn max_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
494        self.min_intrinsic_width(measurables, height)
495    }
496
497    fn min_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
498        let density = WearDensity::new(self.density, 1.0);
499        let label_count = if self.has_secondary { 2 } else { 1 };
500        let column: f32 = measurables
501            .iter()
502            .take(label_count)
503            .map(|m| m.min_intrinsic_height(width))
504            .sum::<f32>();
505        density.dp(self.spec.min_height).max(
506            density.ceil(column.max(density.dp(self.spec.switch_slot_height)))
507                + density.dp(self.spec.padding_vertical) * 2.0,
508        )
509    }
510
511    fn max_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
512        self.min_intrinsic_height(measurables, width)
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use cranpose_ui_graphics::{DrawScopeDefault, Size as GraphicsSize};
520
521    fn colors() -> WearColors {
522        WearColors {
523            primary: Color::from_rgb_u8(0xB9, 0xF2, 0xFF),
524            primary_container: Color::from_rgb_u8(0x0F, 0x36, 0x4E),
525            surface_container: Color::from_rgb_u8(0x0A, 0x16, 0x22),
526            outline: Color::from_rgb_u8(0x1D, 0x4D, 0x69),
527            ..WearColors::default()
528        }
529    }
530
531    #[test]
532    fn the_defaults_are_the_ones_the_tokens_declare() {
533        let spec = SwitchButtonSpec::default();
534        assert_eq!(spec.min_height, 52.0);
535        assert_eq!(spec.corner_radius, 26.0);
536        assert_eq!(spec.padding_horizontal, 14.0);
537        assert_eq!(spec.padding_vertical, 8.0);
538        assert_eq!(spec.switch_width, 32.0);
539        assert_eq!(spec.switch_slot_height, 24.0);
540        assert_eq!(spec.switch_height, 22.0);
541        assert_eq!(spec.track_border_width, 2.0);
542        assert_eq!(spec.thumb_radius_unchecked, 6.0);
543        assert_eq!(spec.thumb_radius_checked, 9.0);
544        assert_eq!(spec.control_spacing, 6.0);
545    }
546
547    #[test]
548    fn the_thumb_travels_between_the_two_measured_positions() {
549        let spec = SwitchButtonSpec::default();
550        let (unchecked, radius_off) = switch_thumb(spec, 0.0);
551        assert_eq!(unchecked, 11.0, "22px at density 2");
552        assert_eq!(radius_off, 6.0);
553        let (checked, radius_on) = switch_thumb(spec, 1.0);
554        assert_eq!(checked, 21.0, "42px at density 2");
555        assert_eq!(radius_on, 9.0);
556    }
557
558    #[test]
559    fn a_checked_switch_draws_no_border_and_an_unchecked_one_does() {
560        let checked = SwitchColors::of(colors(), true);
561        assert_eq!(
562            checked.track_border.3, 0.0,
563            "track and border are both primary, so the border is suppressed"
564        );
565        assert_eq!(checked.track, colors().primary);
566        assert_eq!(checked.thumb, colors().primary_container);
567
568        let unchecked = SwitchColors::of(colors(), false);
569        assert!(unchecked.track_border.3 > 0.0);
570        assert_eq!(unchecked.track_border, colors().outline);
571    }
572
573    #[test]
574    fn a_checked_thumb_is_the_container_colour_and_still_gets_drawn() {
575        let scheme = colors();
576        let checked = SwitchColors::of(scheme, true);
577        assert_eq!(
578            checked.thumb, checked.container,
579            "invisible against the row, and still occluding the track"
580        );
581        let mut scope = DrawScopeDefault::new(GraphicsSize::new(32.0, 22.0));
582        draw_switch(
583            &mut scope,
584            SwitchButtonSpec::default().colors(scheme).progress(1.0),
585            checked,
586        );
587        let primitives = scope.into_primitives();
588        // Track, thumb, and the tick's two segments as body + two caps each.
589        assert_eq!(primitives.len(), 8);
590    }
591
592    #[test]
593    fn an_unchecked_switch_draws_its_ring_and_no_tick() {
594        let mut scope = DrawScopeDefault::new(GraphicsSize::new(32.0, 22.0));
595        draw_switch(
596            &mut scope,
597            SwitchButtonSpec::default().colors(colors()).progress(0.0),
598            SwitchColors::of(colors(), false),
599        );
600        // Track, border ring, thumb — and nothing else, because the tick's
601        // scale is zero at progress zero.
602        assert_eq!(scope.into_primitives().len(), 3);
603    }
604
605    #[test]
606    fn the_tick_eases_out_rather_than_growing_linearly() {
607        assert_eq!(switch_tick_scale(0.0), 0.0);
608        assert_eq!(switch_tick_scale(1.0), 1.0);
609        // A cubic ease-out is past halfway before the thumb is.
610        assert!(switch_tick_scale(0.5) > 0.5);
611        assert!((switch_tick_scale(0.5) - 0.875).abs() < 1e-6);
612    }
613
614    #[test]
615    fn the_springs_are_the_ones_the_standard_motion_scheme_declares() {
616        assert_eq!(SWITCH_THUMB_STIFFNESS, 1400.0);
617        assert_eq!(SWITCH_COLOR_STIFFNESS, 260.0);
618        assert_eq!(SWITCH_DAMPING_RATIO, 1.0);
619    }
620}