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/// An elevated surface with the grouped-inset card look.
19#[composable]
20#[allow(non_snake_case)]
21pub fn LiquidCard(modifier: Modifier, content: impl FnMut() + 'static) {
22    let colors = liquid_colors();
23    let surface = colors.surface;
24    let shadow_alpha = if colors.is_dark { 0.35 } else { 0.07 };
25    let base = Modifier::empty()
26        .drop_shadow(
27            cranpose_ui_graphics::LayerShape::Rounded(
28                cranpose_ui_graphics::RoundedCornerShape::uniform(CARD_RADIUS),
29            ),
30            move |scope| {
31                scope.radius = 14.0;
32                scope.offset.y = 3.0;
33                scope.color = Color::BLACK.with_alpha(shadow_alpha);
34            },
35        )
36        .rounded_corners(CARD_RADIUS)
37        .draw_behind(move |scope| {
38            scope.draw_round_rect(Brush::solid(surface), CornerRadii::uniform(CARD_RADIUS));
39        });
40    Box(base.then(modifier), BoxSpec::default(), content);
41}
42
43/// A titled group of rows on one card (iOS grouped list section).
44#[composable]
45#[allow(non_snake_case)]
46pub fn LiquidListSection(
47    modifier: Modifier,
48    header: impl Into<String>,
49    content: impl FnMut() + 'static,
50) {
51    let colors = liquid_colors();
52    let typography = liquid_typography();
53    let header = header.into();
54    let content = Rc::new(RefCell::new(content));
55    Column(modifier, ColumnSpec::default(), move || {
56        if !header.is_empty() {
57            let style = TextStyle {
58                span_style: SpanStyle {
59                    color: Some(colors.secondary_label),
60                    ..typography.footnote.span_style.clone()
61                },
62                ..typography.footnote.clone()
63            };
64            Text(
65                header.to_uppercase(),
66                Modifier::empty().padding_each(20.0, 0.0, 20.0, 6.0),
67                style,
68            );
69        }
70        let content = Rc::clone(&content);
71        // Rows STACK: the card itself is a Box, so the section provides the
72        // Column (composing rows straight into the card overprinted them all
73        // at the card's origin).
74        LiquidCard(Modifier::empty().fill_max_width(), move || {
75            let content = Rc::clone(&content);
76            Column(
77                Modifier::empty().fill_max_width(),
78                ColumnSpec::default(),
79                move || {
80                    (content.borrow_mut())();
81                },
82            );
83        });
84    });
85}
86
87/// Configuration for [`LiquidListRow`].
88#[derive(Clone, Debug, Default, PartialEq)]
89pub struct LiquidListRowSpec {
90    /// Draw a hairline separator under the row.
91    pub separator: bool,
92}
93
94impl LiquidListRowSpec {
95    pub fn with_separator(mut self, separator: bool) -> Self {
96        self.separator = separator;
97        self
98    }
99}
100
101/// One tappable row inside a [`LiquidCard`] / [`LiquidListSection`]: a press
102/// wash and an optional hairline separator; `content` lays out the row.
103#[composable]
104#[allow(non_snake_case)]
105pub fn LiquidListRow(
106    modifier: Modifier,
107    spec: LiquidListRowSpec,
108    on_click: impl Fn() + 'static,
109    content: impl FnMut() + 'static,
110) {
111    let colors = liquid_colors();
112    let interaction = rememberMutableInteractionSource();
113    let pressed = interaction.collectIsPressedAsState();
114    let wash = animateColorAsState(
115        if pressed.get() {
116            colors.surface_pressed
117        } else {
118            colors.surface_pressed.with_alpha(0.0)
119        },
120        LiquidMotion::snappy(),
121        "row-wash",
122    );
123
124    let separator = spec.separator;
125    let separator_color = colors.separator;
126    let on_click = Rc::new(RefCell::new(on_click));
127    let base = Modifier::empty()
128        .fill_max_width()
129        .press_interaction_source(interaction)
130        .clickable(move |_point| {
131            (on_click.borrow_mut())();
132        })
133        .draw_behind(move |scope| {
134            let size = scope.size();
135            scope.draw_rect(Brush::solid(wash.get()));
136            if separator {
137                scope.draw_rect_at(
138                    cranpose_ui_graphics::Rect {
139                        x: 16.0,
140                        y: size.height - 0.5,
141                        width: (size.width - 16.0).max(0.0),
142                        height: 0.5,
143                    },
144                    Brush::solid(separator_color),
145                );
146            }
147        })
148        .padding_symmetric(16.0, 12.0);
149
150    Box(base.then(modifier), BoxSpec::default(), content);
151}