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]
22pub fn Surface(modifier: Modifier, content: impl FnMut() + 'static) {
23    let color = liquid_colors().surface;
24    Box(
25        Modifier::empty()
26            .draw_behind(move |scope| {
27                scope.draw_round_rect(Brush::solid(color), CornerRadii::uniform(12.0));
28            })
29            .then(modifier),
30        BoxSpec::default(),
31        content,
32    );
33}
34
35/// Theme-aware elevated card container.
36#[composable]
37pub fn Card(modifier: Modifier, content: impl FnMut() + 'static) {
38    LiquidCard(modifier, content);
39}
40
41/// An elevated glass pane with the grouped-inset card look: a resting pane
42/// that transmits its backdrop through the translucent surface wash.
43#[composable]
44pub fn LiquidCard(modifier: Modifier, content: impl FnMut() + 'static) {
45    let colors = liquid_colors();
46    let surface_glass = colors.surface_glass;
47    let base = Modifier::empty().glass_effect_with(
48        Glass::regular().shape(LiquidShape::RoundedRect(CARD_RADIUS)),
49        move || GlassDynamics {
50            activity: Some(0.0),
51            resting_tint: Some(surface_glass),
52            ..Default::default()
53        },
54    );
55    Box(base.then(modifier), BoxSpec::default(), content);
56}
57
58/// A titled group of rows on one card (iOS grouped list section).
59#[composable]
60pub fn LiquidListSection(
61    modifier: Modifier,
62    header: impl Into<String>,
63    content: impl FnMut() + 'static,
64) {
65    let colors = liquid_colors();
66    let typography = liquid_typography();
67    let header = header.into();
68    let content = Rc::new(RefCell::new(content));
69    Column(modifier, ColumnSpec::default(), move || {
70        if !header.is_empty() {
71            let style = TextStyle {
72                span_style: SpanStyle {
73                    color: Some(colors.secondary_label),
74                    ..typography.footnote.span_style.clone()
75                },
76                ..typography.footnote.clone()
77            };
78            Text(
79                header.to_uppercase(),
80                Modifier::empty().padding_each(20.0, 0.0, 20.0, 6.0),
81                style,
82            );
83        }
84        let content = Rc::clone(&content);
85        LiquidCard(Modifier::empty().fill_max_width(), move || {
86            let content = Rc::clone(&content);
87            Column(
88                Modifier::empty().fill_max_width(),
89                ColumnSpec::default(),
90                move || {
91                    (content.borrow_mut())();
92                },
93            );
94        });
95    });
96}
97
98/// Configuration for [`LiquidListRow`].
99#[derive(Clone, Debug, Default, PartialEq)]
100pub struct LiquidListRowSpec {
101    /// Draw a hairline separator under the row.
102    pub separator: bool,
103}
104
105impl LiquidListRowSpec {
106    pub fn with_separator(mut self, separator: bool) -> Self {
107        self.separator = separator;
108        self
109    }
110}
111
112/// One tappable row inside a [`LiquidCard`] / [`LiquidListSection`]: a press
113/// wash and an optional hairline separator; `content` lays out the row.
114#[composable]
115pub fn LiquidListRow(
116    modifier: Modifier,
117    spec: LiquidListRowSpec,
118    on_click: impl Fn() + 'static,
119    content: impl FnMut() + 'static,
120) {
121    let colors = liquid_colors();
122    let interaction = rememberMutableInteractionSource();
123    let pressed = interaction.collectIsPressedAsState();
124    let wash = animateColorAsState(
125        if pressed.get() {
126            colors.surface_pressed
127        } else {
128            colors.surface_pressed.with_alpha(0.0)
129        },
130        LiquidMotion::snappy(),
131        "row-wash",
132    );
133
134    let separator = spec.separator;
135    let separator_color = colors.separator;
136    let on_click = Rc::new(RefCell::new(on_click));
137    let base = Modifier::empty()
138        .fill_max_width()
139        .press_interaction_source(interaction)
140        .clickable(move |_point| {
141            (on_click.borrow_mut())();
142        })
143        .draw_behind(move |scope| {
144            let size = scope.size();
145            scope.draw_rect(Brush::solid(wash.get()));
146            if separator {
147                scope.draw_rect_at(
148                    cranpose_ui_graphics::Rect {
149                        x: 16.0,
150                        y: size.height - 0.5,
151                        width: (size.width - 16.0).max(0.0),
152                        height: 0.5,
153                    },
154                    Brush::solid(separator_color),
155                );
156            }
157        })
158        .padding_symmetric(16.0, 12.0);
159
160    Box(base.then(modifier), BoxSpec::default(), content);
161}
162
163#[cfg(test)]
164#[path = "tests/card_tests.rs"]
165mod tests;