Skip to main content

tessera_components/app_bar/
scroll.rs

1//! Nested scrolling for top app bars and their page content.
2//!
3//! ## Usage
4//!
5//! Coordinate a screen header with scrolling lists and feeds.
6
7use 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/// How a top app bar responds to vertical page scrolling.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
22pub enum AppBarScrollBehavior {
23    /// Keep the bar expanded and change its color when content scrolls under
24    /// it.
25    Pinned,
26    /// Collapse on upward movement and expand immediately on downward movement.
27    EnterAlways,
28    /// Collapse on upward movement and expand after the content reaches its
29    /// start.
30    #[default]
31    ExitUntilCollapsed,
32}
33
34/// Retained height, overlap, and settling motion for a scrolling top app bar.
35pub 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    /// Returns zero when expanded and one when fully collapsed.
69    pub fn collapsed_fraction(&self) -> f32 {
70        self.collapse.value().clamp(0.0, 1.0)
71    }
72
73    /// Returns the negative logical height offset applied to the bar.
74    pub fn height_offset(&self) -> Dp {
75        Dp(-self.range * self.collapsed_fraction() as f64)
76    }
77
78    /// Returns the accumulated logical content displacement, at most zero.
79    pub fn content_offset(&self) -> Dp {
80        Dp(self.content_offset)
81    }
82
83    /// Sets a normalized collapsed amount immediately, cancelling a pending
84    /// snap.
85    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    /// Restores an expanded, unscrolled bar and stops its motion.
96    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    /// Returns whether motion or a wheel-settling frame is still pending.
106    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/// # app_bar_scroll_provider
249///
250/// Coordinate a top app bar and scrollable page content for navigation and
251/// feeds.
252///
253/// ## Usage
254///
255/// Wrap both the app bar and its scrolling content, such as a complete
256/// scaffold.
257///
258/// ## Parameters
259///
260/// - `state` — optional external height and overlap state.
261/// - `behavior` — pinned, immediately expanding, or expand-at-start behavior.
262/// - `enabled` — whether scroll input updates the bar.
263/// - `snap` — settle an intermediate height after scrolling ends; defaults to
264///   true.
265/// - `scroll_controller` — optional content controller to synchronize overlap
266///   after programmatic scrolling.
267/// - `child` — app bar and scrolling page content in the shared scroll scope.
268///
269/// ## Examples
270///
271/// ```
272/// use tessera_components::{
273///     app_bar::{AppBarScrollState, TopAppBarVariant, app_bar_scroll_provider, top_app_bar},
274///     column::column,
275///     modifier::ModifierExt as _,
276///     scaffold::scaffold,
277///     scrollable::ScrollableController,
278///     text::text,
279/// };
280/// use tessera_ui::{Modifier, remember, tessera};
281/// #[tessera]
282/// fn screen() {
283///     let state = remember(AppBarScrollState::default);
284///     let scroll = remember(ScrollableController::new);
285///     app_bar_scroll_provider()
286///         .state(state)
287///         .scroll_controller(scroll)
288///         .child(move || {
289///             scaffold()
290///                 .top_bar(move || {
291///                     top_app_bar()
292///                         .scroll_state(state)
293///                         .variant(TopAppBarVariant::MediumFlexible)
294///                         .title("Inbox");
295///                 })
296///                 .content(move || {
297///                     column()
298///                         .modifier(Modifier::new().vertical_scroll(scroll))
299///                         .children(|| {
300///                             text("Messages");
301///                         });
302///                 });
303///         });
304///     assert_eq!(state.with(AppBarScrollState::collapsed_fraction), 0.0);
305/// }
306/// # #[tessera]
307/// # fn app() {
308/// #     tessera_components::theme::material_theme().child(|| screen());
309/// # }
310/// # tessera_ui::testing::layout_test(app).run();
311/// ```
312#[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}