Skip to main content

cranpose_liquid/widgets/
card.rs

1use std::{cell::RefCell, rc::Rc};
2
3use cranpose_animation::animateColorAsState;
4use cranpose_macros::composable;
5use cranpose_ui::{
6    Modifier, rememberMutableInteractionSource,
7    text::{SpanStyle, TextStyle},
8    widgets::{Box, BoxSpec, Column, ColumnSpec, Text},
9};
10use cranpose_ui_graphics::{Brush, CornerRadii};
11
12use crate::{
13    material::{Glass, GlassDynamics, LiquidModifierExt, LiquidShape},
14    motion::LiquidMotion,
15    theme::{liquid_colors, liquid_typography},
16};
17
18const CARD_RADIUS: f32 = 20.0;
19
20/// Theme-aware surface container using the current Liquid color roles.
21#[composable]
22#[allow(non_snake_case)]
23pub fn Surface(modifier: Modifier, content: impl FnMut() + 'static) {
24    let color = liquid_colors().surface;
25    Box(
26        Modifier::empty()
27            .draw_behind(move |scope| {
28                scope.draw_round_rect(Brush::solid(color), CornerRadii::uniform(12.0));
29            })
30            .then(modifier),
31        BoxSpec::default(),
32        content,
33    );
34}
35
36/// Theme-aware elevated card container.
37#[composable]
38#[allow(non_snake_case)]
39pub fn Card(modifier: Modifier, content: impl FnMut() + 'static) {
40    LiquidCard(modifier, content);
41}
42
43/// An elevated glass pane with the grouped-inset card look: a resting pane
44/// that transmits its backdrop through the translucent surface wash.
45#[composable]
46#[allow(non_snake_case)]
47pub fn LiquidCard(modifier: Modifier, content: impl FnMut() + 'static) {
48    let colors = liquid_colors();
49    let surface_glass = colors.surface_glass;
50    let base = Modifier::empty().glass_effect_with(
51        Glass::regular().shape(LiquidShape::RoundedRect(CARD_RADIUS)),
52        move || GlassDynamics {
53            activity: Some(0.0),
54            resting_tint: Some(surface_glass),
55            ..Default::default()
56        },
57    );
58    Box(base.then(modifier), BoxSpec::default(), content);
59}
60
61/// A titled group of rows on one card (iOS grouped list section).
62#[composable]
63#[allow(non_snake_case)]
64pub fn LiquidListSection(
65    modifier: Modifier,
66    header: impl Into<String>,
67    content: impl FnMut() + 'static,
68) {
69    let colors = liquid_colors();
70    let typography = liquid_typography();
71    let header = header.into();
72    let content = Rc::new(RefCell::new(content));
73    Column(modifier, ColumnSpec::default(), move || {
74        if !header.is_empty() {
75            let style = TextStyle {
76                span_style: SpanStyle {
77                    color: Some(colors.secondary_label),
78                    ..typography.footnote.span_style.clone()
79                },
80                ..typography.footnote.clone()
81            };
82            Text(
83                header.to_uppercase(),
84                Modifier::empty().padding_each(20.0, 0.0, 20.0, 6.0),
85                style,
86            );
87        }
88        let content = Rc::clone(&content);
89        LiquidCard(Modifier::empty().fill_max_width(), move || {
90            let content = Rc::clone(&content);
91            Column(
92                Modifier::empty().fill_max_width(),
93                ColumnSpec::default(),
94                move || {
95                    (content.borrow_mut())();
96                },
97            );
98        });
99    });
100}
101
102/// Configuration for [`LiquidListRow`].
103#[derive(Clone, Debug, Default, PartialEq)]
104pub struct LiquidListRowSpec {
105    /// Draw a hairline separator under the row.
106    pub separator: bool,
107}
108
109impl LiquidListRowSpec {
110    pub fn with_separator(mut self, separator: bool) -> Self {
111        self.separator = separator;
112        self
113    }
114}
115
116/// One tappable row inside a [`LiquidCard`] / [`LiquidListSection`]: a press
117/// wash and an optional hairline separator; `content` lays out the row.
118#[composable]
119#[allow(non_snake_case)]
120pub fn LiquidListRow(
121    modifier: Modifier,
122    spec: LiquidListRowSpec,
123    on_click: impl Fn() + 'static,
124    content: impl FnMut() + 'static,
125) {
126    let colors = liquid_colors();
127    let interaction = rememberMutableInteractionSource();
128    let pressed = interaction.collectIsPressedAsState();
129    let wash = animateColorAsState(
130        if pressed.get() {
131            colors.surface_pressed
132        } else {
133            colors.surface_pressed.with_alpha(0.0)
134        },
135        LiquidMotion::snappy(),
136        "row-wash",
137    );
138
139    let separator = spec.separator;
140    let separator_color = colors.separator;
141    let on_click = Rc::new(RefCell::new(on_click));
142    let base = Modifier::empty()
143        .fill_max_width()
144        .press_interaction_source(interaction)
145        .clickable(move |_point| {
146            (on_click.borrow_mut())();
147        })
148        .draw_behind(move |scope| {
149            let size = scope.size();
150            scope.draw_rect(Brush::solid(wash.get()));
151            if separator {
152                scope.draw_rect_at(
153                    cranpose_ui_graphics::Rect {
154                        x: 16.0,
155                        y: size.height - 0.5,
156                        width: (size.width - 16.0).max(0.0),
157                        height: 0.5,
158                    },
159                    Brush::solid(separator_color),
160                );
161            }
162        })
163        .padding_symmetric(16.0, 12.0);
164
165    Box(base.then(modifier), BoxSpec::default(), content);
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn a_row_draws_no_separator_until_one_is_asked_for() {
174        assert!(
175            !LiquidListRowSpec::default().separator,
176            "the last row of a section must not draw a trailing hairline"
177        );
178        assert!(LiquidListRowSpec::default().with_separator(true).separator);
179        assert!(
180            !LiquidListRowSpec::default()
181                .with_separator(true)
182                .with_separator(false)
183                .separator,
184            "the latest answer is the one that holds"
185        );
186    }
187}