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}