waterui_layout/collections/scroll.rs
1//! Scroll containers that defer behaviour to the active renderer backend.
2
3use nami::{Binding, Computed};
4use waterui_core::{AnyView, View, raw_view};
5
6use crate::{Point, StretchAxis};
7
8/// Explicit programmatic control for a scrollable view.
9///
10/// A controller owns a reactive target and a monotonically increasing request
11/// generation. The generation makes repeated requests to the same target
12/// observable after the user has scrolled elsewhere.
13#[derive(Clone, Debug)]
14pub struct ScrollController<T: Clone + 'static> {
15 target: Binding<T>,
16 generation: Binding<i32>,
17}
18
19impl<T: Clone + 'static> ScrollController<T> {
20 /// Creates a controller whose first target is `initial_target`.
21 #[must_use]
22 pub fn new(initial_target: T) -> Self {
23 Self {
24 target: Binding::container(initial_target),
25 generation: Binding::container(0),
26 }
27 }
28
29 /// Requests an immediate jump to `target`.
30 ///
31 /// # Panics
32 ///
33 /// Panics if the request generation exceeds [`i32::MAX`].
34 pub fn scroll_to(&self, target: T) {
35 self.target.set(target);
36 self.generation.set(
37 self.generation
38 .get()
39 .checked_add(1)
40 .expect("scroll request generation overflow"),
41 );
42 }
43
44 /// Returns the current requested target as a read-only signal.
45 #[must_use]
46 pub fn target(&self) -> Computed<T> {
47 self.target.clone().into()
48 }
49
50 /// Returns the request generation as a read-only signal.
51 #[must_use]
52 pub fn generation(&self) -> Computed<i32> {
53 self.generation.clone().into()
54 }
55}
56
57impl<T> Default for ScrollController<T>
58where
59 T: Clone + Default + 'static,
60{
61 fn default() -> Self {
62 Self::new(T::default())
63 }
64}
65
66/// A scrollable view that displays content larger than its frame.
67///
68/// Use a `ScrollView` when you have content that might not fit in the available space.
69/// The view automatically enables scrolling in the specified direction.
70///
71/// ```rust
72/// # use waterui::prelude::*;
73/// # fn feed() -> impl View {
74/// scroll(
75/// vstack((
76/// text("Item 1"),
77/// text("Item 2"),
78/// text("Item 3"),
79/// // ... many more items
80/// ))
81/// )
82/// # }
83/// ```
84///
85/// By default, `ScrollView` scrolls vertically. For horizontal scrolling:
86///
87/// ```rust
88/// # use waterui::layout::scroll::scroll_horizontal;
89/// # use waterui::prelude::*;
90/// # fn carousel(long_content: impl View) -> impl View {
91/// scroll_horizontal(long_content)
92/// # }
93/// ```
94///
95/// Or both directions:
96///
97/// ```rust
98/// # use waterui::layout::scroll::scroll_both;
99/// # use waterui::prelude::*;
100/// # fn canvas(large_image: impl View) -> impl View {
101/// scroll_both(large_image)
102/// # }
103/// ```
104#[derive(Debug)]
105pub struct ScrollView {
106 axis: Axis,
107 content: AnyView,
108 controller: Option<ScrollController<Point>>,
109}
110
111/// Defines the scrolling directions supported by `ScrollView`.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
113#[non_exhaustive]
114pub enum Axis {
115 /// Allow horizontal scrolling only.
116 Horizontal,
117 /// Allow vertical scrolling only (default).
118 #[default]
119 Vertical,
120 /// Allow scrolling in both directions.
121 All,
122}
123
124impl ScrollView {
125 /// Creates a new `ScrollView` with the specified scroll axis and content.
126 #[must_use]
127 pub const fn new(axis: Axis, content: AnyView) -> Self {
128 Self {
129 axis,
130 content,
131 controller: None,
132 }
133 }
134
135 /// Decomposes the `ScrollView` into its axis, content, and controller.
136 pub fn into_inner(self) -> (Axis, AnyView, Option<ScrollController<Point>>) {
137 (self.axis, self.content, self.controller)
138 }
139
140 /// Returns borrowed access to axis, content, and controller.
141 #[must_use = "this borrows the scroll view's parts without consuming it"]
142 pub const fn as_parts(&self) -> (Axis, &AnyView, Option<&ScrollController<Point>>) {
143 (self.axis, &self.content, self.controller.as_ref())
144 }
145
146 /// Connects a programmatic scroll controller.
147 #[must_use]
148 pub fn scroll_controller(mut self, controller: &ScrollController<Point>) -> Self {
149 self.controller = Some(controller.clone());
150 self
151 }
152
153 /// Creates a `ScrollView` with horizontal scrolling.
154 pub fn horizontal(content: impl View) -> Self {
155 Self::new(Axis::Horizontal, AnyView::new(content))
156 }
157
158 /// Creates a `ScrollView` with vertical scrolling.
159 pub fn vertical(content: impl View) -> Self {
160 Self::new(Axis::Vertical, AnyView::new(content))
161 }
162
163 /// Creates a `ScrollView` with scrolling in both directions.
164 pub fn both(content: impl View) -> Self {
165 Self::new(Axis::All, AnyView::new(content))
166 }
167}
168
169raw_view!(ScrollView, StretchAxis::Both);
170
171/// Creates a vertical `ScrollView` with the given content.
172///
173/// This is the most common scroll direction for lists and long content.
174/// The actual scrolling behavior is implemented by the renderer backend.
175pub fn scroll(content: impl View) -> ScrollView {
176 ScrollView::vertical(content)
177}
178
179/// Creates a horizontal `ScrollView` with the given content.
180///
181/// Useful for wide content that needs to scroll left-right.
182/// The actual scrolling behavior is implemented by the renderer backend.
183pub fn scroll_horizontal(content: impl View) -> ScrollView {
184 ScrollView::horizontal(content)
185}
186
187/// Creates a `ScrollView` that can scroll in both directions.
188///
189/// Useful for large content like images or tables that may need both horizontal and vertical scrolling.
190/// The actual scrolling behavior is implemented by the renderer backend.
191pub fn scroll_both(content: impl View) -> ScrollView {
192 ScrollView::both(content)
193}
194
195#[cfg(test)]
196mod tests {
197 use super::ScrollController;
198 use crate::Point;
199 use nami::Signal;
200
201 #[test]
202 fn repeated_target_requests_advance_generation() {
203 let controller = ScrollController::new(Point::zero());
204
205 controller.scroll_to(Point::new(0.0, 240.0));
206 assert_eq!(controller.target().get(), Point::new(0.0, 240.0));
207 assert_eq!(controller.generation().get(), 1);
208
209 controller.scroll_to(Point::new(0.0, 240.0));
210 assert_eq!(controller.target().get(), Point::new(0.0, 240.0));
211 assert_eq!(controller.generation().get(), 2);
212 }
213}