cranpose_liquid/widgets/
card.rs1use 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#[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#[composable]
36#[allow(non_snake_case)]
37pub fn Card(modifier: Modifier, content: impl FnMut() + 'static) {
38 LiquidCard(modifier, content);
39}
40
41#[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#[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 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#[derive(Clone, Debug, Default, PartialEq)]
112pub struct LiquidListRowSpec {
113 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#[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}