tessera_components/app_bar/
scroll.rs1use tessera_ui::{
8 CallbackWith, Dp, FocusModifierExt, Modifier, RenderSlot, ScrollEventSource, State,
9 current_frame_nanos, layout::layout, receive_frame_nanos, remember, tessera,
10};
11
12use crate::{
13 animation::Animatable,
14 nested_scroll::{
15 NestedScrollConnection, PostScrollInput, PreScrollInput, ScrollDelta, ScrollVelocity,
16 },
17 scrollable::ScrollableController,
18};
19
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
22pub enum AppBarScrollBehavior {
23 Pinned,
26 EnterAlways,
28 #[default]
31 ExitUntilCollapsed,
32}
33
34pub struct AppBarScrollState {
36 behavior: AppBarScrollBehavior,
37 enabled: bool,
38 snap: bool,
39 range: f64,
40 collapse: Animatable,
41 color: Animatable,
42 content_offset: f64,
43 last_frame: Option<u64>,
44 last_input: Option<u64>,
45 touch_active: bool,
46 pending_snap: bool,
47}
48
49impl Default for AppBarScrollState {
50 fn default() -> Self {
51 Self {
52 behavior: AppBarScrollBehavior::default(),
53 enabled: true,
54 snap: true,
55 range: 0.0,
56 collapse: Animatable::spring(0.0, 380.0_f32.sqrt(), 0.8),
57 color: Animatable::spring(0.0, 40.0, 1.0),
58 content_offset: 0.0,
59 last_frame: None,
60 last_input: None,
61 touch_active: false,
62 pending_snap: false,
63 }
64 }
65}
66
67impl AppBarScrollState {
68 pub fn collapsed_fraction(&self) -> f32 {
70 self.collapse.value().clamp(0.0, 1.0)
71 }
72
73 pub fn height_offset(&self) -> Dp {
75 Dp(-self.range * self.collapsed_fraction() as f64)
76 }
77
78 pub fn content_offset(&self) -> Dp {
80 Dp(self.content_offset)
81 }
82
83 pub fn set_collapsed_fraction(&mut self, fraction: f32) {
86 let fraction = if fraction.is_finite() {
87 fraction.clamp(0.0, 1.0)
88 } else {
89 0.0
90 };
91 self.collapse = Animatable::spring(fraction, 380.0_f32.sqrt(), 0.8);
92 self.pending_snap = false;
93 }
94
95 pub fn reset(&mut self) {
97 self.set_collapsed_fraction(0.0);
98 self.content_offset = 0.0;
99 self.color = Animatable::spring(0.0, 40.0, 1.0);
100 self.touch_active = false;
101 self.last_input = None;
102 self.last_frame = None;
103 }
104
105 pub fn is_animating(&self) -> bool {
107 !self.collapse.is_idle()
108 || !self.color.is_idle()
109 || (self.pending_snap && !self.touch_active)
110 }
111
112 pub(super) fn configure_height(&mut self, expanded: Dp, compact: Dp) {
113 self.range = if self.behavior == AppBarScrollBehavior::Pinned {
114 0.0
115 } else {
116 (expanded.0 - compact.0).max(0.0)
117 };
118 if self.range > 0.0 {
119 self.color = Animatable::spring(0.0, 40.0, 1.0);
120 } else {
121 self.color
122 .set_target(if self.content_offset < 0.0 { 1.0 } else { 0.0 });
123 }
124 }
125
126 pub(super) fn behavior(&self) -> AppBarScrollBehavior {
127 self.behavior
128 }
129 pub(super) fn height_range(&self) -> Dp {
130 Dp(self.range)
131 }
132 pub(super) fn drag_enabled(&self) -> bool {
133 self.enabled && self.behavior != AppBarScrollBehavior::Pinned
134 }
135 pub(super) fn drag_by(&mut self, pixels: f32) {
136 if self.drag_enabled() {
137 self.input(ScrollEventSource::Touch);
138 self.consume(pixels);
139 }
140 }
141 pub(super) fn end_drag(&mut self) {
142 self.finish(current_frame_nanos());
143 }
144
145 pub(super) fn color_fraction(&self) -> f32 {
146 if self.range > 0.0 && self.behavior != AppBarScrollBehavior::Pinned {
147 crate::progress::cubic_bezier_easing(self.collapsed_fraction(), 0.4, 0.0, 1.0, 1.0)
148 } else {
149 self.color.value().clamp(0.0, 1.0)
150 }
151 }
152
153 fn input(&mut self, source: ScrollEventSource) {
154 let frame = current_frame_nanos();
155 if !self.is_animating() {
156 self.last_frame = Some(frame);
157 }
158 self.last_input = Some(frame);
159 self.touch_active = source == ScrollEventSource::Touch;
160 self.pending_snap = self.snap
161 && self.range > 0.0
162 && self.collapsed_fraction() > 0.0
163 && self.collapsed_fraction() < 1.0;
164 }
165
166 fn consume(&mut self, pixels: f32) -> f32 {
167 if self.range <= 0.0 || !pixels.is_finite() {
168 return 0.0;
169 }
170 let density = Dp(1.0).to_pixels_f32().max(f32::EPSILON) as f64;
171 let old = self.height_offset().0;
172 let next = (old + pixels as f64 / density).clamp(-self.range, 0.0);
173 self.set_collapsed_fraction((-next / self.range) as f32);
174 self.pending_snap =
175 self.snap && self.collapsed_fraction() > 0.0 && self.collapsed_fraction() < 1.0;
176 ((next - old) * density) as f32
177 }
178
179 fn pre_scroll(&mut self, input: PreScrollInput) -> ScrollDelta {
180 if !self.enabled || input.available.y == 0.0 {
181 return ScrollDelta::ZERO;
182 }
183 self.input(input.source);
184 if self.behavior == AppBarScrollBehavior::Pinned
185 || (self.behavior == AppBarScrollBehavior::ExitUntilCollapsed
186 && input.available.y > 0.0)
187 {
188 return ScrollDelta::ZERO;
189 }
190 ScrollDelta::new(0.0, self.consume(input.available.y))
191 }
192
193 fn post_scroll(&mut self, input: PostScrollInput) -> ScrollDelta {
194 if !self.enabled || (input.consumed_by_child.y == 0.0 && input.available.y == 0.0) {
195 return ScrollDelta::ZERO;
196 }
197 self.input(input.source);
198 self.content_offset = (self.content_offset
199 + input.consumed_by_child.y as f64 / Dp(1.0).to_pixels_f32() as f64)
200 .min(0.0);
201 if input.available.y > 0.0 {
202 self.content_offset = 0.0;
203 }
204 if self.range <= 0.0 {
205 self.color
206 .set_target(if self.content_offset < 0.0 { 1.0 } else { 0.0 });
207 }
208 let consumed = if self.behavior == AppBarScrollBehavior::ExitUntilCollapsed
209 && input.available.y > 0.0
210 {
211 self.consume(input.available.y)
212 } else {
213 0.0
214 };
215 ScrollDelta::new(0.0, consumed)
216 }
217
218 fn finish(&mut self, frame: u64) {
219 self.touch_active = false;
220 self.last_frame = Some(frame);
221 if self.pending_snap && self.snap {
222 self.collapse
223 .set_target(if self.collapsed_fraction() < 0.5 {
224 0.0
225 } else {
226 1.0
227 });
228 }
229 self.pending_snap = false;
230 }
231
232 fn tick(&mut self, frame: u64) {
233 let previous = self.last_frame.replace(frame).unwrap_or(frame);
234 if self.pending_snap
235 && !self.touch_active
236 && self
237 .last_input
238 .is_some_and(|last| frame.saturating_sub(last) >= 150_000_000)
239 {
240 self.finish(frame);
241 }
242 let dt = frame.saturating_sub(previous) as f32 / 1_000_000_000.0;
243 self.collapse.tick(dt);
244 self.color.tick(dt);
245 }
246}
247
248#[tessera]
313pub fn app_bar_scroll_provider(
314 state: Option<State<AppBarScrollState>>,
315 behavior: Option<AppBarScrollBehavior>,
316 enabled: Option<bool>,
317 snap: Option<bool>,
318 scroll_controller: Option<State<ScrollableController>>,
319 child: Option<RenderSlot>,
320) {
321 let state = if let Some(state) = state {
322 state
323 } else {
324 remember(AppBarScrollState::default)
325 };
326 let active = remember(|| state);
327 if active.get() != state {
328 active.set(state);
329 }
330 let behavior = behavior.unwrap_or_default();
331 let enabled = enabled.unwrap_or(true);
332 let snap = snap.unwrap_or(true);
333 if state.with(|value| (value.behavior, value.enabled, value.snap)) != (behavior, enabled, snap)
334 {
335 state.with_mut(|value| {
336 value.behavior = behavior;
337 value.enabled = enabled;
338 value.snap = snap;
339 value.pending_snap = false;
340 if !enabled || !snap {
341 let fraction = value.collapsed_fraction();
342 value.set_collapsed_fraction(fraction);
343 value.touch_active = false;
344 }
345 if behavior == AppBarScrollBehavior::Pinned {
346 value.set_collapsed_fraction(0.0);
347 }
348 });
349 }
350 if enabled && let Some(controller) = scroll_controller {
351 let offset = controller.with(|controller| controller.target_position().y as f64)
352 / Dp(1.0).to_pixels_f32().max(f32::EPSILON) as f64;
353 if state.with(|value| value.content_offset) != offset {
354 let frame = current_frame_nanos();
355 state.with_mut(|value| {
356 value.content_offset = offset.min(0.0);
357 if value.range <= 0.0 {
358 if value.color.is_idle() {
359 value.last_frame = Some(frame);
360 }
361 value
362 .color
363 .set_target(if offset >= 0.0 { 0.0 } else { 1.0 });
364 }
365 });
366 }
367 }
368 if state.with(AppBarScrollState::is_animating) {
369 receive_frame_nanos(move |frame| {
370 let state = active.get();
371 state.with_mut(|value| value.tick(frame));
372 if state.with(AppBarScrollState::is_animating) {
373 tessera_ui::FrameNanosControl::Continue
374 } else {
375 tessera_ui::FrameNanosControl::Stop
376 }
377 });
378 }
379 let parent = NestedScrollConnection::nearest();
380 let coordinate_scale = remember(|| 1.0_f32);
381 let connection = NestedScrollConnection::new()
382 .with_coordinate_scale(coordinate_scale)
383 .with_parent(parent)
384 .with_pre_scroll_handler(CallbackWith::new(move |input| {
385 active.get().with_mut(|value| value.pre_scroll(input))
386 }))
387 .with_post_scroll_handler(CallbackWith::new(move |input| {
388 active.get().with_mut(|value| value.post_scroll(input))
389 }))
390 .with_post_fling_handler(CallbackWith::new(move |_| {
391 let frame = current_frame_nanos();
392 active.get().with_mut(|value| value.finish(frame));
393 ScrollVelocity::ZERO
394 }));
395 let child = child.unwrap_or_else(RenderSlot::empty);
396 layout()
397 .modifier(Modifier::new().on_local_to_window(
398 move |transform: tessera_ui::LocalToWindow| {
399 if coordinate_scale.get() != transform.scale() {
400 coordinate_scale.set(transform.scale());
401 }
402 },
403 ))
404 .modifier(Modifier::new().nested_scroll(connection))
405 .child(move || child.render());
406}