Skip to main content

cranpose_liquid/widgets/
card.rs

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