Skip to main content

freya_components/scrollviews/
use_scroll_controller.rs

1use freya_core::prelude::*;
2use torin::prelude::Direction;
3
4/// Where along an axis a scroll should land, the beginning or the end.
5#[derive(Default, PartialEq, Eq)]
6pub enum ScrollPosition {
7    #[default]
8    Start,
9    End,
10}
11
12/// Initial configuration for a [`ScrollController`] created with [`use_scroll_controller`].
13#[derive(Default)]
14pub struct ScrollConfig {
15    /// Where the vertical axis starts scrolled to when first laid out.
16    pub default_vertical_position: ScrollPosition,
17    /// Where the horizontal axis starts scrolled to when first laid out.
18    pub default_horizontal_position: ScrollPosition,
19}
20
21/// A pending request to scroll an axis to a given [`ScrollPosition`], consumed on the next layout.
22pub struct ScrollRequest {
23    pub(crate) position: ScrollPosition,
24    pub(crate) direction: Direction,
25    pub(crate) init: bool,
26}
27
28impl ScrollRequest {
29    /// Creates a request to scroll `direction` to `position`.
30    pub fn new(position: ScrollPosition, direction: Direction) -> ScrollRequest {
31        ScrollRequest {
32            position,
33            direction,
34            init: false,
35        }
36    }
37}
38
39/// An absolute scroll movement along one axis, in pixels.
40pub enum ScrollEvent {
41    X(i32),
42    Y(i32),
43}
44
45/// Handle to drive and read a scrollable area programmatically.
46///
47/// By default a scrollable owns its scroll position and only the user can move it, through the
48/// wheel, the scrollbar, arrow keys or dragging. A [`ScrollController`] lets your own code read and
49/// change that position instead. Create one with [`use_scroll_controller`] and hand it to a
50/// scrollable through its `new_controlled` constructor.
51///
52/// Some cases where a controller is needed:
53///
54/// - Jumping to the top or bottom in response to an action, for example scrolling a chat to the
55///   newest message after sending one.
56/// - Keeping several scrollables in sync, like a diff view with two panes that move together.
57/// - Reading the current scroll position to drive something else, such as a "scroll to top" button
58///   that only appears once the user has scrolled down.
59///
60/// # Scrolling from code
61///
62/// [`scroll_to`](ScrollController::scroll_to) queues a jump to the start or end of an axis, applied
63/// on the next layout. This is the common way to snap a list to its top or bottom.
64///
65/// ```rust
66/// # use freya::prelude::*;
67/// fn app() -> impl IntoElement {
68///     let mut scroll_controller = use_scroll_controller(ScrollConfig::default);
69///
70///     rect()
71///         .child(
72///             Button::new()
73///                 .on_press(move |_| {
74///                     scroll_controller.scroll_to(ScrollPosition::End, Direction::Vertical);
75///                 })
76///                 .child("Scroll to bottom"),
77///         )
78///         .child(
79///             ScrollView::new_controlled(scroll_controller)
80///                 .children((0..100).map(|i| label().key(i).text(format!("Item {i}")).into())),
81///         )
82/// }
83/// ```
84///
85/// For an exact pixel offset use [`scroll_to_y`](ScrollController::scroll_to_y) or
86/// [`scroll_to_x`](ScrollController::scroll_to_x). The current position is available by converting
87/// the controller into a `(i32, i32)` tuple of `(x, y)` pixels.
88///
89/// # Keeping scrollables in sync
90///
91/// Because a [`ScrollController`] is a cheap [`Copy`] handle, pass the same one to several
92/// scrollables and they share a single scroll position: moving any of them moves the rest.
93///
94/// ```rust
95/// # use freya::prelude::*;
96/// fn app() -> impl IntoElement {
97///     let scroll_controller = use_scroll_controller(ScrollConfig::default);
98///
99///     rect()
100///         .horizontal()
101///         .spacing(6.)
102///         .child(
103///             ScrollView::new_controlled(scroll_controller)
104///                 .width(Size::flex(1.))
105///                 .children((0..30).map(|i| label().key(i).text(format!("Left {i}")).into())),
106///         )
107///         .child(
108///             ScrollView::new_controlled(scroll_controller)
109///                 .width(Size::flex(1.))
110///                 .children((0..30).map(|i| label().key(i).text(format!("Right {i}")).into())),
111///         )
112/// }
113/// ```
114///
115/// # Starting position
116///
117/// The [`ScrollConfig`] passed to [`use_scroll_controller`] also decides where each axis starts.
118/// Set [`default_vertical_position`](ScrollConfig::default_vertical_position) to
119/// [`ScrollPosition::End`] to open a list already scrolled to the bottom.
120///
121/// ```rust
122/// # use freya::prelude::*;
123/// fn app() -> impl IntoElement {
124///     let scroll_controller = use_scroll_controller(|| ScrollConfig {
125///         default_vertical_position: ScrollPosition::End,
126///         ..Default::default()
127///     });
128///
129///     ScrollView::new_controlled(scroll_controller)
130///         .children((0..100).map(|i| label().key(i).text(format!("Item {i}")).into()))
131/// }
132/// ```
133#[derive(PartialEq, Clone, Copy)]
134pub struct ScrollController {
135    notifier: State<()>,
136    requests: State<Vec<ScrollRequest>>,
137    on_scroll: State<Callback<ScrollEvent, bool>>,
138    get_scroll: State<Callback<(), (i32, i32)>>,
139}
140
141impl From<ScrollController> for (i32, i32) {
142    /// Reads the current `(x, y)` scroll position in pixels.
143    fn from(val: ScrollController) -> Self {
144        val.get_scroll.read().call(())
145    }
146}
147
148impl ScrollController {
149    /// Creates a controller starting at scroll position `(x, y)` with a list of requests to apply.
150    pub fn new(x: i32, y: i32, initial_requests: Vec<ScrollRequest>) -> Self {
151        let mut scroll = State::create((x, y));
152        Self {
153            notifier: State::create(()),
154            requests: State::create(initial_requests),
155            on_scroll: State::create(Callback::new(move |ev| {
156                let current = *scroll.read();
157                match ev {
158                    ScrollEvent::X(x) => {
159                        scroll.write().0 = x;
160                    }
161                    ScrollEvent::Y(y) => {
162                        scroll.write().1 = y;
163                    }
164                }
165                current != *scroll.read()
166            })),
167            get_scroll: State::create(Callback::new(move |_| *scroll.read())),
168        }
169    }
170    /// Builds a controller from externally owned state, letting the caller manage its storage.
171    pub fn managed(
172        notifier: State<()>,
173        requests: State<Vec<ScrollRequest>>,
174        on_scroll: State<Callback<ScrollEvent, bool>>,
175        get_scroll: State<Callback<(), (i32, i32)>>,
176    ) -> Self {
177        Self {
178            notifier,
179            requests,
180            on_scroll,
181            get_scroll,
182        }
183    }
184
185    /// Applies any pending requests against the given content size. Called by the scrollable on every layout.
186    pub fn use_apply(&mut self, width: f32, height: f32) {
187        let _ = self.notifier.read();
188        for request in self.requests.write().drain(..) {
189            match request {
190                ScrollRequest {
191                    position: ScrollPosition::Start,
192                    direction: Direction::Vertical,
193                    ..
194                } => {
195                    self.on_scroll.write().call(ScrollEvent::Y(0));
196                }
197                ScrollRequest {
198                    position: ScrollPosition::Start,
199                    direction: Direction::Horizontal,
200                    ..
201                } => {
202                    self.on_scroll.write().call(ScrollEvent::X(0));
203                }
204                ScrollRequest {
205                    position: ScrollPosition::End,
206                    direction: Direction::Vertical,
207                    init,
208                    ..
209                } => {
210                    if init && height == 0. {
211                        continue;
212                    }
213                    let (_x, y) = self.get_scroll.read().call(());
214                    self.on_scroll
215                        .write()
216                        .call(ScrollEvent::Y(y - height as i32));
217                }
218                ScrollRequest {
219                    position: ScrollPosition::End,
220                    direction: Direction::Horizontal,
221                    init,
222                    ..
223                } => {
224                    if init && width == 0. {
225                        continue;
226                    }
227
228                    let (x, _y) = self.get_scroll.read().call(());
229                    self.on_scroll
230                        .write()
231                        .call(ScrollEvent::X(x - width as i32));
232                }
233            }
234        }
235    }
236
237    /// Scrolls the horizontal axis to `to` pixels. Returns whether the position actually changed.
238    pub fn scroll_to_x(&mut self, to: i32) -> bool {
239        self.on_scroll.write().call(ScrollEvent::X(to))
240    }
241
242    /// Scrolls the vertical axis to `to` pixels. Returns whether the position actually changed.
243    pub fn scroll_to_y(&mut self, to: i32) -> bool {
244        self.on_scroll.write().call(ScrollEvent::Y(to))
245    }
246
247    /// Queues a scroll of `scroll_direction` to `scroll_position`, applied on the next layout.
248    pub fn scroll_to(&mut self, scroll_position: ScrollPosition, scroll_direction: Direction) {
249        self.requests
250            .write()
251            .push(ScrollRequest::new(scroll_position, scroll_direction));
252        self.notifier.write();
253    }
254}
255
256/// Creates a [`ScrollController`] tied to the component, configured by the returned [`ScrollConfig`].
257pub fn use_scroll_controller(init: impl FnOnce() -> ScrollConfig) -> ScrollController {
258    use_hook(|| {
259        let config = init();
260
261        ScrollController::new(
262            0,
263            0,
264            vec![
265                ScrollRequest {
266                    position: config.default_vertical_position,
267                    direction: Direction::Vertical,
268                    init: true,
269                },
270                ScrollRequest {
271                    position: config.default_horizontal_position,
272                    direction: Direction::Horizontal,
273                    init: true,
274                },
275            ],
276        )
277    })
278}