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