Skip to main content

repose_material/material3/
app_bar.rs

1#![allow(non_snake_case)]
2
3use std::cell::Cell;
4use std::rc::Rc;
5
6use repose_core::NestedScrollConnection;
7use repose_core::*;
8use repose_ui::{
9    Box, Column, Row, ZStack,
10    ViewExt,
11};
12
13use super::*;
14
15use super::util::lerp_color;
16/// Color slots for [`TopAppBar`].
17#[derive(Clone, Copy, Debug)]
18pub struct TopAppBarColors {
19    pub container_color: Color,
20    pub scrolled_container_color: Color,
21    pub navigation_icon_content_color: Color,
22    pub title_content_color: Color,
23    pub subtitle_content_color: Color,
24    pub action_icon_content_color: Color,
25}
26
27impl TopAppBarColors {
28    pub fn container_color(&self, scroll_fraction: f32) -> Color {
29        lerp_color(
30            self.container_color,
31            self.scrolled_container_color,
32            scroll_fraction.clamp(0.0, 1.0),
33        )
34    }
35}
36
37impl Default for TopAppBarColors {
38    fn default() -> Self {
39        Self {
40            container_color: TopAppBarDefaults::container_color(),
41            scrolled_container_color: TopAppBarDefaults::scrolled_container_color(),
42            navigation_icon_content_color: TopAppBarDefaults::navigation_icon_content_color(),
43            title_content_color: TopAppBarDefaults::title_content_color(),
44            subtitle_content_color: TopAppBarDefaults::subtitle_content_color(),
45            action_icon_content_color: TopAppBarDefaults::action_icon_content_color(),
46        }
47    }
48}
49
50/// Scroll response mode for [`TopAppBarScrollBehavior`].
51#[derive(Clone, Copy, Debug, PartialEq)]
52pub enum TopAppBarScrollMode {
53    /// Always visible, no scroll response.
54    Pinned,
55    /// Collapses upward when scrolling down, expands as soon as scrolling up.
56    EnterAlways,
57    /// Collapses when scrolling down, but only expands once the nested content
58    /// has been scrolled back to the very top. Used by medium/large bars.
59    ExitUntilCollapsed,
60}
61
62/// Drives scroll-based collapsing/expanding of a TopAppBar.
63///
64/// Create one, pass its [`nested_scroll_connection`](TopAppBarScrollBehavior::nested_scroll_connection)
65/// to a lazy list's [`set_nested_scroll_parent`] method, and either set the
66/// resulting [`collapsed_offset`](TopAppBarScrollBehavior::collapsed_offset)
67/// on the TopAppBar via [`TopAppBarConfig::scroll_offset`], or attach it via
68/// [`TopAppBarConfig::scroll_behavior`] so the bar wires offset + color itself.
69#[derive(Clone)]
70pub struct TopAppBarScrollBehavior {
71    pub collapsed_offset: Signal<f32>,
72    pub height: f32,
73    pub collapsed_height: f32,
74    pub mode: TopAppBarScrollMode,
75    _pending: Rc<Cell<f32>>,
76}
77
78impl std::fmt::Debug for TopAppBarScrollBehavior {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.debug_struct("TopAppBarScrollBehavior")
81            .field("offset", &self.offset())
82            .field("height", &self.height)
83            .field("collapsed_height", &self.collapsed_height)
84            .field("mode", &self.mode)
85            .finish()
86    }
87}
88
89impl TopAppBarScrollBehavior {
90    pub fn new(height: f32, collapsed_height: f32, mode: TopAppBarScrollMode) -> Self {
91        Self {
92            collapsed_offset: signal(0.0),
93            height,
94            collapsed_height,
95            mode,
96            _pending: Rc::new(Cell::new(0.0)),
97        }
98    }
99
100    /// Returns a [`NestedScrollConnection`] that collapses the bar on
101    /// downward scroll and expands on upward scroll. `ExitUntilCollapsed`
102    /// only expands once the nested content is back at the top.
103    pub fn nested_scroll_connection(&self) -> NestedScrollConnection {
104        let off = self.collapsed_offset.clone();
105        let max_collapse = -(self.height - self.collapsed_height);
106
107        match self.mode {
108            TopAppBarScrollMode::Pinned => NestedScrollConnection::new(),
109            TopAppBarScrollMode::EnterAlways => {
110                NestedScrollConnection::new().on_pre_scroll(move |d: Vec2, _source| -> Vec2 {
111                    let mut consumed = Vec2::ZERO;
112                    let current = off.get();
113                    if d.y > 0.0 {
114                        // Scrolling down -> collapse bar
115                        if current > max_collapse {
116                            let consume = d.y.min(current - max_collapse);
117                            off.set(current - consume);
118                            consumed.y = consume;
119                        }
120                    } else if current < 0.0 {
121                        // Scrolling up -> expand bar
122                        let consume = (-d.y).min(-current);
123                        off.set(current + consume);
124                        consumed.y = consume;
125                    }
126                    if consumed.y != 0.0 {
127                        repose_core::request_frame();
128                    }
129                    consumed
130                })
131            }
132            TopAppBarScrollMode::ExitUntilCollapsed => NestedScrollConnection::new()
133                .on_pre_scroll({
134                    let off = off.clone();
135                    move |d: Vec2, _source| -> Vec2 {
136                        let mut consumed = Vec2::ZERO;
137                        if d.y > 0.0 {
138                            // Scrolling down -> collapse bar
139                            let current = off.get();
140                            if current > max_collapse {
141                                let consume = d.y.min(current - max_collapse);
142                                off.set(current - consume);
143                                consumed.y = consume;
144                                repose_core::request_frame();
145                            }
146                        }
147                        consumed
148                    }
149                })
150                .on_post_scroll(
151                    move |_consumed: Vec2, available: Vec2, _source| -> Vec2 {
152                        // Upward scroll leftover means the content is at the top,
153                        // so the bar may expand.
154                        let mut expanded = Vec2::ZERO;
155                        if available.y < 0.0 {
156                            let current = off.get();
157                            if current < 0.0 {
158                                let consume = (-available.y).min(-current);
159                                off.set(current + consume);
160                                expanded.y = consume;
161                                repose_core::request_frame();
162                            }
163                        }
164                        expanded
165                    },
166                ),
167        }
168    }
169
170    /// Returns the current collapsed offset (0 = fully expanded, negative = collapsed).
171    pub fn offset(&self) -> f32 {
172        self.collapsed_offset.get()
173    }
174
175    /// Collapse progress in `0.0..=1.0` (`0` = expanded, `1` = fully collapsed).
176    /// Drives the container color lerp so the scrolled color tracks the offset.
177    pub fn collapsed_fraction(&self) -> f32 {
178        let range = (self.height - self.collapsed_height).max(f32::EPSILON);
179        ((-self.collapsed_offset.get()) / range).clamp(0.0, 1.0)
180    }
181}
182
183/// Configuration for [`TopAppBar`].
184#[derive(Clone, Debug)]
185pub struct TopAppBarConfig {
186    pub modifier: Modifier,
187    pub colors: TopAppBarColors,
188    pub height: f32,
189    /// Collapse progress in `0.0..=1.0` driving the container color lerp.
190    /// Ignored when [`scroll_behavior`](TopAppBarConfig::scroll_behavior) is set.
191    pub scroll_fraction: f32,
192    /// Vertical translate offset (negative = collapsed upward).
193    /// Ignored when [`scroll_behavior`](TopAppBarConfig::scroll_behavior) is set.
194    pub scroll_offset: f32,
195    /// Optional shared scroll behavior. When set, the bar reads
196    /// [`TopAppBarScrollBehavior::offset`] and
197    /// [`TopAppBarScrollBehavior::collapsed_fraction`] reactively itself,
198    /// so translate and container color stay in sync without manual wiring.
199    pub scroll_behavior: Option<Rc<TopAppBarScrollBehavior>>,
200    pub window_insets: WindowInsets,
201    pub content_padding: PaddingValues,
202}
203
204/// System window insets for top app bar padding.
205#[derive(Clone, Copy, Debug)]
206pub struct WindowInsets {
207    pub top: f32,
208    pub bottom: f32,
209    pub left: f32,
210    pub right: f32,
211}
212
213impl Default for WindowInsets {
214    fn default() -> Self {
215        Self {
216            top: 0.0,
217            bottom: 0.0,
218            left: 0.0,
219            right: 0.0,
220        }
221    }
222}
223
224impl Default for TopAppBarConfig {
225    fn default() -> Self {
226        Self {
227            modifier: Modifier::new(),
228            colors: TopAppBarColors::default(),
229            height: TopAppBarDefaults::HEIGHT,
230            scroll_fraction: 0.0,
231            scroll_offset: 0.0,
232            scroll_behavior: None,
233            window_insets: WindowInsets::default(),
234            content_padding: PaddingValues {
235                left: 4.0,
236                right: 4.0,
237                top: 0.0,
238                bottom: 0.0,
239            },
240        }
241    }
242}
243
244fn top_app_bar_layout(
245    title: View,
246    subtitle: Option<View>,
247    navigation_icon: Option<View>,
248    actions: Vec<View>,
249    config: TopAppBarConfig,
250    centered: bool,
251) -> View {
252    let insets = config.window_insets;
253    // When a behavior is attached, read its offset/fraction reactively so the
254    // bar's translate and container color track collapse automatically.
255    let (scroll_offset, scroll_fraction) = if let Some(ref sb) = config.scroll_behavior {
256        (sb.offset(), sb.collapsed_fraction())
257    } else {
258        (config.scroll_offset, config.scroll_fraction)
259    };
260    let bg = config.colors.container_color(scroll_fraction);
261    let colors = config.colors;
262
263    let root_m = Modifier::new()
264        .min_width(200.0)
265        .height(config.height + insets.top)
266        .background(bg)
267        .translate(0.0, scroll_offset)
268        .semantics(Semantics::new(Role::Container));
269
270    let nav = navigation_icon
271        .map(|icon| with_content_color(colors.navigation_icon_content_color, move || icon))
272        .unwrap_or(Box(Modifier::new().width(16.0).fill_max_height()));
273
274    let actions_row = Row(Modifier::new()
275        .align_items(AlignItems::CENTER)
276        .flex_shrink(0.0))
277    .child(
278        actions
279            .into_iter()
280            .map(|a| {
281                with_content_color(colors.action_icon_content_color, move || a.clone())
282            })
283            .collect::<Vec<_>>(),
284    );
285
286    let title_column = Column(Modifier::new().justify_content(JustifyContent::CENTER)).child((
287        Box(Modifier::new()).child(with_content_color(
288            colors.title_content_color,
289            || title,
290        )),
291        subtitle
292            .map(|s| {
293                Box(Modifier::new()).child(with_content_color(
294                    colors.subtitle_content_color,
295                    || s,
296                ))
297            })
298            .unwrap_or(Box(Modifier::new())),
299    ));
300
301    let content_padding = PaddingValues {
302        left: config.content_padding.left + insets.left,
303        right: config.content_padding.right + insets.right,
304        top: config.content_padding.top + insets.top,
305        bottom: config.content_padding.bottom + insets.bottom,
306    };
307
308    if centered {
309        // True center alignment: nav/actions sit at the edges while the title
310        // overlays the bar, centered across the FULL width (not the leftover
311        // space between nav and actions), matching Compose's optical centering.
312        ZStack(root_m.then(config.modifier)).child((
313            Row(Modifier::new()
314                .fill_max_width()
315                .align_items(AlignItems::CENTER)
316                .padding_values(content_padding))
317            .child((
318                nav,
319                Box(Modifier::new().flex_grow(1.0)),
320                actions_row,
321            )),
322            Box(Modifier::new()
323                .absolute()
324                .offset(Some(0.0), Some(0.0), Some(0.0), None)
325                .fill_max_width()
326                .justify_content(JustifyContent::CENTER)
327                .align_items(AlignItems::CENTER))
328            .child(title_column),
329        ))
330    } else {
331        Row(root_m
332            .padding_values(content_padding)
333            .then(config.modifier))
334        .child((
335            nav,
336            Box(Modifier::new()
337                .padding_values(PaddingValues {
338                    left: 16.0,
339                    right: 0.0,
340                    top: 0.0,
341                    bottom: 0.0,
342                })
343                .flex_grow(1.0))
344            .child(title_column),
345            actions_row,
346        ))
347    }
348}
349
350/// M3 Top App Bar (small). Displays a title with optional navigation icon,
351/// subtitle, and trailing action buttons.
352pub fn TopAppBar(
353    title: View,
354    subtitle: Option<View>,
355    navigation_icon: Option<View>,
356    actions: Vec<View>,
357    config: TopAppBarConfig,
358) -> View {
359    top_app_bar_layout(title, subtitle, navigation_icon, actions, config, false)
360}
361
362/// M3 Center-Aligned Top App Bar - same as TopAppBar but the title is truly
363/// centered across the full bar width (nav/actions sit at the edges).
364pub fn CenterAlignedTopAppBar(
365    title: View,
366    subtitle: Option<View>,
367    navigation_icon: Option<View>,
368    actions: Vec<View>,
369    config: TopAppBarConfig,
370) -> View {
371    top_app_bar_layout(title, subtitle, navigation_icon, actions, config, true)
372}