tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! Showcase de scroll avanzado (Tier 5): **app-bar colapsable** (sliver) +
2//! lista scrolleable + **inercia (fling)**. Un único `offset` en el Model
3//! maneja el colapso del header y el scroll del cuerpo; los botones "Fling"
4//! sueltan una velocidad que decae con [`fling_step`] vía un ticker periódico.
5//!
6//! Corre con:
7//! ```text
8//! cargo run -p llimphi-widget-scroll --example scroll_avanzado --release
9//! ```
10
11use std::time::Duration;
12
13use llimphi_theme::Theme;
14use llimphi_ui::llimphi_layout::taffy::{
15 prelude::{length, percent, FlexDirection, Size, Style},
16 AlignItems, JustifyContent, Rect,
17};
18use llimphi_ui::{App, Handle, View};
19use llimphi_widget_scroll::{
20 clamp_offset, fling_settled, fling_step, sliver_app_bar, sliver_max_offset, ScrollPalette,
21 FLING_FRICTION,
22};
23
24const HEADER_MAX: f32 = 200.0;
25const HEADER_MIN: f32 = 56.0;
26const VIEWPORT: f32 = 560.0;
27const ROW_H: f32 = 46.0;
28const N_ROWS: usize = 40;
29const CONTENT_LEN: f32 = N_ROWS as f32 * ROW_H;
30const DT: f32 = 1.0 / 60.0;
31
32#[derive(Clone)]
33enum Msg {
34 /// Delta de scroll en px (rueda / arrastre de barra) a sumar al offset.
35 ScrollBy(f32),
36 /// Soltar una inercia con esta velocidad inicial (px/s).
37 Fling(f32),
38 /// Tick del ticker: avanza la inercia si hay.
39 Tick,
40}
41
42struct Model {
43 offset: f32,
44 velocity: f32,
45 theme: Theme,
46}
47
48fn max_off() -> f32 {
49 sliver_max_offset(CONTENT_LEN, VIEWPORT, HEADER_MAX, HEADER_MIN)
50}
51
52struct Demo;
53
54impl App for Demo {
55 type Model = Model;
56 type Msg = Msg;
57
58 fn title() -> &'static str {
59 "llimphi · scroll avanzado (sliver + fling)"
60 }
61
62 fn initial_size() -> (u32, u32) {
63 (720, VIEWPORT as u32)
64 }
65
66 fn init(handle: &Handle<Self::Msg>) -> Self::Model {
67 // Ticker de inercia ~60 fps (mismo patrón que `approach`).
68 handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
69 Model { offset: 0.0, velocity: 0.0, theme: Theme::dark() }
70 }
71
72 fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
73 match msg {
74 Msg::ScrollBy(d) => {
75 model.velocity = 0.0; // un scroll manual corta la inercia
76 model.offset = clamp_offset(model.offset + d, max_off() + VIEWPORT, VIEWPORT);
77 // (clamp_offset usa content/viewport; aquí el "content" efectivo
78 // es max_off + viewport, así max_offset(...) == max_off.)
79 }
80 Msg::Fling(v) => model.velocity = v,
81 Msg::Tick => {
82 if model.velocity != 0.0 {
83 let (v, delta) = fling_step(model.velocity, DT, FLING_FRICTION);
84 model.offset =
85 clamp_offset(model.offset + delta, max_off() + VIEWPORT, VIEWPORT);
86 // Frenar en los topes o al asentarse.
87 if fling_settled(v) || model.offset <= 0.0 || model.offset >= max_off() {
88 model.velocity = 0.0;
89 } else {
90 model.velocity = v;
91 }
92 }
93 }
94 }
95 model
96 }
97
98 fn view(model: &Self::Model) -> View<Self::Msg> {
99 let t = &model.theme;
100 let pal = ScrollPalette::from_theme(t);
101
102 // Lista (cuerpo del sliver): filas alternadas.
103 let rows: Vec<View<Msg>> = (0..N_ROWS)
104 .map(|i| {
105 let bg = if i % 2 == 0 { t.bg_panel } else { t.bg_panel_alt };
106 View::new(Style {
107 size: Size { width: percent(1.0), height: length(ROW_H) },
108 align_items: Some(AlignItems::CENTER),
109 padding: Rect { left: length(20.0), right: length(20.0), top: length(0.0), bottom: length(0.0) },
110 ..Default::default()
111 })
112 .fill(bg)
113 .text(format!("Fila {:02}", i + 1), 18.0, t.fg_text)
114 })
115 .collect();
116 let list = View::new(Style {
117 flex_direction: FlexDirection::Column,
118 size: Size { width: percent(1.0), height: length(CONTENT_LEN) },
119 ..Default::default()
120 })
121 .children(rows);
122
123 let theme = t.clone();
124 let sliver = sliver_app_bar(
125 model.offset,
126 HEADER_MAX,
127 HEADER_MIN,
128 move |frac| header(&theme, frac),
129 list,
130 CONTENT_LEN,
131 VIEWPORT,
132 Msg::ScrollBy,
133 &pal,
134 );
135
136 View::new(Style {
137 size: Size { width: percent(1.0), height: percent(1.0) },
138 ..Default::default()
139 })
140 .fill(t.bg_app)
141 .children(vec![sliver])
142 }
143}
144
145/// Header colapsable: el título encoge con `frac` y el subtítulo + botones de
146/// fling se desvanecen al colapsar (el `clip` del header los recorta).
147fn header(t: &Theme, frac: f32) -> View<Msg> {
148 let title_size = 34.0 - 14.0 * frac; // 34 → 20
149 // Fondo que se aclara al colapsar (de accent a panel).
150 let title_row = View::new(Style {
151 flex_direction: FlexDirection::Row,
152 size: Size { width: percent(1.0), height: length(HEADER_MIN) },
153 align_items: Some(AlignItems::CENTER),
154 justify_content: Some(JustifyContent::SPACE_BETWEEN),
155 padding: Rect { left: length(20.0), right: length(16.0), top: length(0.0), bottom: length(0.0) },
156 ..Default::default()
157 })
158 .children(vec![
159 View::new(Style { ..Default::default() })
160 .text("Scroll avanzado", title_size, t.fg_text),
161 fling_buttons(t),
162 ]);
163
164 let subtitle = View::new(Style {
165 size: Size { width: percent(1.0), height: length(28.0) },
166 align_items: Some(AlignItems::CENTER),
167 padding: Rect { left: length(20.0), right: length(20.0), top: length(0.0), bottom: length(0.0) },
168 ..Default::default()
169 })
170 .alpha(1.0 - frac) // se desvanece al colapsar
171 .text("Tier 5 · app-bar colapsable + inercia · rueda para scrollear", 15.0, t.fg_muted);
172
173 View::new(Style {
174 flex_direction: FlexDirection::Column,
175 size: Size { width: percent(1.0), height: length(HEADER_MAX) },
176 ..Default::default()
177 })
178 .fill(t.bg_panel_alt)
179 .children(vec![title_row, subtitle])
180}
181
182fn fling_buttons(t: &Theme) -> View<Msg> {
183 let btn = |label: &str, v: f32| {
184 View::new(Style {
185 size: Size { width: length(96.0), height: length(34.0) },
186 align_items: Some(AlignItems::CENTER),
187 justify_content: Some(JustifyContent::CENTER),
188 ..Default::default()
189 })
190 .fill(t.bg_button)
191 .hover_fill(t.bg_button_hover)
192 .radius(8.0)
193 .text(label.to_string(), 15.0, t.fg_text)
194 .on_click(Msg::Fling(v))
195 };
196 View::new(Style {
197 flex_direction: FlexDirection::Row,
198 gap: Size { width: length(8.0), height: length(0.0) },
199 ..Default::default()
200 })
201 .children(vec![btn("Fling ▲", -2600.0), btn("Fling ▼", 2600.0)])
202}
203
204fn main() {
205 llimphi_ui::run::<Demo>();
206}