cranpose_liquid/widgets/
card.rs1use 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#[composable]
22#[allow(non_snake_case)]
23pub fn Surface(modifier: Modifier, content: impl FnMut() + 'static) {
24 let color = liquid_colors().surface;
25 Box(
26 Modifier::empty()
27 .draw_behind(move |scope| {
28 scope.draw_round_rect(Brush::solid(color), CornerRadii::uniform(12.0));
29 })
30 .then(modifier),
31 BoxSpec::default(),
32 content,
33 );
34}
35
36#[composable]
38#[allow(non_snake_case)]
39pub fn Card(modifier: Modifier, content: impl FnMut() + 'static) {
40 LiquidCard(modifier, content);
41}
42
43#[composable]
46#[allow(non_snake_case)]
47pub fn LiquidCard(modifier: Modifier, content: impl FnMut() + 'static) {
48 let colors = liquid_colors();
49 let surface_glass = colors.surface_glass;
50 let base = Modifier::empty().glass_effect_with(
51 Glass::regular().shape(LiquidShape::RoundedRect(CARD_RADIUS)),
52 move || GlassDynamics {
53 activity: Some(0.0),
54 resting_tint: Some(surface_glass),
55 ..Default::default()
56 },
57 );
58 Box(base.then(modifier), BoxSpec::default(), content);
59}
60
61#[composable]
63#[allow(non_snake_case)]
64pub fn LiquidListSection(
65 modifier: Modifier,
66 header: impl Into<String>,
67 content: impl FnMut() + 'static,
68) {
69 let colors = liquid_colors();
70 let typography = liquid_typography();
71 let header = header.into();
72 let content = Rc::new(RefCell::new(content));
73 Column(modifier, ColumnSpec::default(), move || {
74 if !header.is_empty() {
75 let style = TextStyle {
76 span_style: SpanStyle {
77 color: Some(colors.secondary_label),
78 ..typography.footnote.span_style.clone()
79 },
80 ..typography.footnote.clone()
81 };
82 Text(
83 header.to_uppercase(),
84 Modifier::empty().padding_each(20.0, 0.0, 20.0, 6.0),
85 style,
86 );
87 }
88 let content = Rc::clone(&content);
89 LiquidCard(Modifier::empty().fill_max_width(), move || {
90 let content = Rc::clone(&content);
91 Column(
92 Modifier::empty().fill_max_width(),
93 ColumnSpec::default(),
94 move || {
95 (content.borrow_mut())();
96 },
97 );
98 });
99 });
100}
101
102#[derive(Clone, Debug, Default, PartialEq)]
104pub struct LiquidListRowSpec {
105 pub separator: bool,
107}
108
109impl LiquidListRowSpec {
110 pub fn with_separator(mut self, separator: bool) -> Self {
111 self.separator = separator;
112 self
113 }
114}
115
116#[composable]
119#[allow(non_snake_case)]
120pub fn LiquidListRow(
121 modifier: Modifier,
122 spec: LiquidListRowSpec,
123 on_click: impl Fn() + 'static,
124 content: impl FnMut() + 'static,
125) {
126 let colors = liquid_colors();
127 let interaction = rememberMutableInteractionSource();
128 let pressed = interaction.collectIsPressedAsState();
129 let wash = animateColorAsState(
130 if pressed.get() {
131 colors.surface_pressed
132 } else {
133 colors.surface_pressed.with_alpha(0.0)
134 },
135 LiquidMotion::snappy(),
136 "row-wash",
137 );
138
139 let separator = spec.separator;
140 let separator_color = colors.separator;
141 let on_click = Rc::new(RefCell::new(on_click));
142 let base = Modifier::empty()
143 .fill_max_width()
144 .press_interaction_source(interaction)
145 .clickable(move |_point| {
146 (on_click.borrow_mut())();
147 })
148 .draw_behind(move |scope| {
149 let size = scope.size();
150 scope.draw_rect(Brush::solid(wash.get()));
151 if separator {
152 scope.draw_rect_at(
153 cranpose_ui_graphics::Rect {
154 x: 16.0,
155 y: size.height - 0.5,
156 width: (size.width - 16.0).max(0.0),
157 height: 0.5,
158 },
159 Brush::solid(separator_color),
160 );
161 }
162 })
163 .padding_symmetric(16.0, 12.0);
164
165 Box(base.then(modifier), BoxSpec::default(), content);
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn a_row_draws_no_separator_until_one_is_asked_for() {
174 assert!(
175 !LiquidListRowSpec::default().separator,
176 "the last row of a section must not draw a trailing hairline"
177 );
178 assert!(LiquidListRowSpec::default().with_separator(true).separator);
179 assert!(
180 !LiquidListRowSpec::default()
181 .with_separator(true)
182 .with_separator(false)
183 .separator,
184 "the latest answer is the one that holds"
185 );
186 }
187}