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