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