Skip to main content

vizia_core/views/
scrollview.rs

1use std::sync::Arc;
2
3use crate::prelude::*;
4
5pub(crate) const SCROLL_SENSITIVITY: f32 = 20.0;
6
7/// Events for setting the properties of a scroll view.
8pub enum ScrollEvent {
9    /// Sets the progress of scroll position between 0 and 1 for the x axis
10    SetX(f32),
11    /// Sets the progress of scroll position between 0 and 1 for the y axis
12    SetY(f32),
13    /// Adds given progress to scroll position for the x axis and clamps between 0 and 1
14    ScrollX(f32),
15    /// Adds given progress to scroll position for the y axis and clamps between 0 and 1
16    ScrollY(f32),
17    /// Sets the size for the inner scroll-content view which holds the content
18    ChildGeo(f32, f32),
19
20    ScrollToView(Entity),
21}
22
23/// A container a view which allows the user to scroll any overflowed content.
24pub struct ScrollView {
25    /// Progress of scroll position between 0 and 1 for the x axis
26    pub scroll_x: Signal<f32>,
27    /// Progress of scroll position between 0 and 1 for the y axis
28    pub scroll_y: Signal<f32>,
29    /// Callback called when the scrollview is scrolled.
30    pub on_scroll: Option<Arc<dyn Fn(&mut EventContext, f32, f32) + Send + Sync>>,
31    /// Width of the inner VStack which holds the content (typically bigger than container_width)
32    pub inner_width: Signal<f32>,
33    /// Height of the inner VStack which holds the content (typically bigger than container_height)
34    pub inner_height: Signal<f32>,
35    /// Width of the outer `ScrollView` which wraps the inner (typically smaller than inner_width)
36    pub container_width: Signal<f32>,
37    /// Height of the outer `ScrollView` which wraps the inner (typically smaller than inner_height)
38    pub container_height: Signal<f32>,
39    /// Whether the scrollbar should move to the cursor when pressed.
40    pub scroll_to_cursor: Signal<bool>,
41    /// Whether the horizontal scrollbar should be visible.
42    pub show_horizontal_scrollbar: Signal<bool>,
43    /// Whether the vertical scrollbar should be visible.
44    pub show_vertical_scrollbar: Signal<bool>,
45}
46
47impl ScrollView {
48    fn map_scroll_x_to_physical(scroll_x: f32, direction: Direction) -> f32 {
49        if direction == Direction::RightToLeft { 1.0 - scroll_x } else { scroll_x }
50    }
51
52    fn map_scroll_x_from_physical(scroll_x: f32, direction: Direction) -> f32 {
53        if direction == Direction::RightToLeft { 1.0 - scroll_x } else { scroll_x }
54    }
55
56    /// Creates a new [ScrollView].
57    pub fn new<F>(cx: &mut Context, content: F) -> Handle<Self>
58    where
59        F: 'static + FnOnce(&mut Context),
60    {
61        let scroll_to_cursor = Signal::new(false);
62        let scroll_x = Signal::new(0.0_f32);
63        let scroll_y = Signal::new(0.0_f32);
64        let inner_width = Signal::new(0.0_f32);
65        let inner_height = Signal::new(0.0_f32);
66        let container_width = Signal::new(0.0_f32);
67        let container_height = Signal::new(0.0_f32);
68        let show_horizontal_scrollbar = Signal::new(true);
69        let show_vertical_scrollbar = Signal::new(true);
70        let direction = cx.environment().direction;
71        let vertical_ratio: Memo<f32> = Memo::new(move |_| {
72            let inner = inner_height.get();
73            if inner == 0.0_f32 {
74                0.0_f32
75            } else {
76                (container_height.get() / inner).clamp(0.0_f32, 1.0_f32)
77            }
78        });
79
80        let horizontal_ratio: Memo<f32> = Memo::new(move |_| {
81            let inner = inner_width.get();
82            if inner == 0.0_f32 {
83                0.0_f32
84            } else {
85                (container_width.get() / inner).clamp(0.0_f32, 1.0_f32)
86            }
87        });
88
89        let has_h_scroll = Memo::new(move |_| container_width.get() < inner_width.get());
90        let has_v_scroll = Memo::new(move |_| container_height.get() < inner_height.get());
91
92        let horizontal_scrollbar_value: Memo<f32> = Memo::new(move |_| scroll_x.get());
93
94        let scroll_state = Memo::new(move |_| {
95            (
96                scroll_x.get(),
97                scroll_y.get(),
98                inner_width.get(),
99                inner_height.get(),
100                container_width.get(),
101                container_height.get(),
102                direction.get(),
103            )
104        });
105        let scroll_state_signal = scroll_state;
106
107        Self {
108            scroll_to_cursor,
109            scroll_x,
110            scroll_y,
111            on_scroll: None,
112            inner_width,
113            inner_height,
114            container_width,
115            container_height,
116            show_horizontal_scrollbar,
117            show_vertical_scrollbar,
118        }
119        .build(cx, move |cx| {
120            ScrollContent::new(cx, content);
121
122            Binding::new(cx, show_vertical_scrollbar, move |cx| {
123                if show_vertical_scrollbar.get() {
124                    Scrollbar::new(
125                        cx,
126                        scroll_y,
127                        vertical_ratio,
128                        Orientation::Vertical,
129                        |cx, value| {
130                            cx.emit(ScrollEvent::SetY(value));
131                        },
132                    )
133                    .position_type(PositionType::Absolute)
134                    .scroll_to_cursor(scroll_to_cursor);
135                }
136            });
137
138            Binding::new(cx, show_horizontal_scrollbar, move |cx| {
139                if show_horizontal_scrollbar.get() {
140                    Scrollbar::new(
141                        cx,
142                        horizontal_scrollbar_value,
143                        horizontal_ratio,
144                        Orientation::Horizontal,
145                        |cx, value| {
146                            cx.emit(ScrollEvent::SetX(value));
147                        },
148                    )
149                    .position_type(PositionType::Absolute)
150                    .scroll_to_cursor(scroll_to_cursor);
151                }
152            });
153        })
154        .bind(scroll_state, move |mut handle| {
155            let (
156                scroll_x,
157                scroll_y,
158                inner_width,
159                inner_height,
160                container_width,
161                container_height,
162                direction,
163            ) = scroll_state_signal.get();
164            let scale_factor = handle.context().scale_factor();
165            let top = ((inner_height - container_height) * scroll_y).round() / scale_factor;
166            let physical_scroll_x = ScrollView::map_scroll_x_to_physical(scroll_x, direction);
167            let left = ((inner_width - container_width) * physical_scroll_x).round() / scale_factor;
168            handle.horizontal_scroll(-left.abs()).vertical_scroll(-top.abs());
169        })
170        .toggle_class("h-scroll", has_h_scroll)
171        .toggle_class("v-scroll", has_v_scroll)
172    }
173
174    fn reset(&mut self) {
175        if self.inner_width.get() == self.container_width.get() {
176            self.scroll_x.set(0.0);
177        }
178
179        if self.inner_height.get() == self.container_height.get() {
180            self.scroll_y.set(0.0);
181        }
182    }
183}
184
185impl View for ScrollView {
186    fn element(&self) -> Option<&'static str> {
187        Some("scrollview")
188    }
189
190    fn event(&mut self, cx: &mut EventContext, event: &mut Event) {
191        event.map(|scroll_update, meta| {
192            match scroll_update {
193                ScrollEvent::ScrollX(f) => {
194                    let delta = if cx.environment().direction.get() == Direction::RightToLeft {
195                        -*f
196                    } else {
197                        *f
198                    };
199                    self.scroll_x.set((self.scroll_x.get() + delta).clamp(0.0, 1.0));
200
201                    if let Some(callback) = &self.on_scroll {
202                        (callback)(cx, self.scroll_x.get(), self.scroll_y.get());
203                    }
204                }
205
206                ScrollEvent::ScrollY(f) => {
207                    self.scroll_y.set((self.scroll_y.get() + *f).clamp(0.0, 1.0));
208                    if let Some(callback) = &self.on_scroll {
209                        (callback)(cx, self.scroll_x.get(), self.scroll_y.get());
210                    }
211                }
212
213                ScrollEvent::SetX(f) => {
214                    self.scroll_x.set(*f);
215                    if let Some(callback) = &self.on_scroll {
216                        (callback)(cx, self.scroll_x.get(), self.scroll_y.get());
217                    }
218                }
219
220                ScrollEvent::SetY(f) => {
221                    self.scroll_y.set(*f);
222                    if let Some(callback) = &self.on_scroll {
223                        (callback)(cx, self.scroll_x.get(), self.scroll_y.get());
224                    }
225                }
226
227                ScrollEvent::ChildGeo(w, h) => {
228                    let bounds = cx.bounds();
229                    let scale_factor = cx.scale_factor();
230
231                    let mut scroll_x = self.scroll_x.get();
232                    let mut scroll_y = self.scroll_y.get();
233                    let mut inner_width = self.inner_width.get();
234                    let mut inner_height = self.inner_height.get();
235                    let mut container_width = self.container_width.get();
236                    let mut container_height = self.container_height.get();
237
238                    if inner_width != 0.0 && inner_height != 0.0 {
239                        let top =
240                            ((inner_height - container_height) * scroll_y).round() / scale_factor;
241                        let physical_scroll_x = ScrollView::map_scroll_x_to_physical(
242                            scroll_x,
243                            cx.environment().direction.get(),
244                        );
245                        let left = ((inner_width - container_width) * physical_scroll_x).round()
246                            / scale_factor;
247
248                        container_width = bounds.width();
249                        container_height = bounds.height();
250                        inner_width = *w;
251                        inner_height = *h;
252
253                        if inner_width != container_width {
254                            let physical_scroll_x = ((left * scale_factor)
255                                / (inner_width - container_width))
256                                .clamp(0.0, 1.0);
257                            scroll_x = ScrollView::map_scroll_x_from_physical(
258                                physical_scroll_x,
259                                cx.environment().direction.get(),
260                            );
261                        } else {
262                            scroll_x = 0.0;
263                        }
264
265                        if inner_height != container_height {
266                            scroll_y = ((top * scale_factor) / (inner_height - container_height))
267                                .clamp(0.0, 1.0);
268                        } else {
269                            scroll_y = 0.0;
270                        }
271
272                        self.scroll_x.set(scroll_x);
273                        self.scroll_y.set(scroll_y);
274                        self.inner_width.set(inner_width);
275                        self.inner_height.set(inner_height);
276                        self.container_width.set(container_width);
277                        self.container_height.set(container_height);
278
279                        if let Some(callback) = &self.on_scroll {
280                            (callback)(cx, self.scroll_x.get(), self.scroll_y.get());
281                        }
282
283                        self.reset();
284                    }
285
286                    self.inner_width.set(*w);
287                    self.inner_height.set(*h);
288                    self.reset();
289                }
290
291                ScrollEvent::ScrollToView(entity) => {
292                    let view_bounds = cx.cache.get_bounds(*entity);
293
294                    let content_bounds = cx.bounds();
295
296                    let direction = cx.environment().direction.get();
297                    let mut physical_scroll_x =
298                        ScrollView::map_scroll_x_to_physical(self.scroll_x.get(), direction);
299
300                    let dx = content_bounds.right() - view_bounds.right();
301                    let dy = content_bounds.bottom() - view_bounds.bottom();
302
303                    // Calculate the scroll position to bring the child into view.
304                    if dx < 0.0 {
305                        let sx = (-dx / (self.inner_width.get() - self.container_width.get()))
306                            .clamp(0.0, 1.0);
307                        physical_scroll_x = (physical_scroll_x + sx).clamp(0.0, 1.0);
308                    }
309
310                    if dy < 0.0 {
311                        let sy = (-dy / (self.inner_height.get() - self.container_height.get()))
312                            .clamp(0.0, 1.0);
313                        self.scroll_y.set((self.scroll_y.get() + sy).clamp(0.0, 1.0));
314                    }
315
316                    let dx = view_bounds.left() - content_bounds.left();
317                    let dy = view_bounds.top() - content_bounds.top();
318
319                    if dx < 0.0 {
320                        let sx = (-dx / (self.inner_width.get() - self.container_width.get()))
321                            .clamp(0.0, 1.0);
322                        physical_scroll_x = (physical_scroll_x - sx).clamp(0.0, 1.0);
323                    }
324
325                    self.scroll_x
326                        .set(ScrollView::map_scroll_x_from_physical(physical_scroll_x, direction));
327
328                    if dy < 0.0 {
329                        let sy = (-dy / (self.inner_height.get() - self.container_height.get()))
330                            .clamp(0.0, 1.0);
331                        self.scroll_y.set((self.scroll_y.get() - sy).clamp(0.0, 1.0));
332                    }
333
334                    if let Some(callback) = &self.on_scroll {
335                        (callback)(cx, self.scroll_x.get(), self.scroll_y.get());
336                    }
337                }
338            }
339
340            // Prevent scroll events propagating to any parent scrollviews.
341            // TODO: This might be desired behavior when the scrollview is scrolled all the way.
342            meta.consume();
343        });
344
345        event.map(|window_event, meta| match window_event {
346            WindowEvent::GeometryChanged(geo) => {
347                if geo.contains(GeoChanged::WIDTH_CHANGED)
348                    || geo.contains(GeoChanged::HEIGHT_CHANGED)
349                {
350                    let bounds = cx.bounds();
351                    let scale_factor = cx.scale_factor();
352
353                    let mut scroll_x = self.scroll_x.get();
354                    let mut scroll_y = self.scroll_y.get();
355                    let inner_width = self.inner_width.get();
356                    let inner_height = self.inner_height.get();
357                    let mut container_width = self.container_width.get();
358                    let mut container_height = self.container_height.get();
359
360                    if inner_width != 0.0 && inner_height != 0.0 {
361                        let top =
362                            ((inner_height - container_height) * scroll_y).round() / scale_factor;
363                        let physical_scroll_x = ScrollView::map_scroll_x_to_physical(
364                            scroll_x,
365                            cx.environment().direction.get(),
366                        );
367                        let left = ((inner_width - container_width) * physical_scroll_x).round()
368                            / scale_factor;
369
370                        container_width = bounds.width();
371                        container_height = bounds.height();
372
373                        if inner_width != container_width {
374                            let physical_scroll_x = ((left * scale_factor)
375                                / (inner_width - container_width))
376                                .clamp(0.0, 1.0);
377                            scroll_x = ScrollView::map_scroll_x_from_physical(
378                                physical_scroll_x,
379                                cx.environment().direction.get(),
380                            );
381                        } else {
382                            scroll_x = 0.0;
383                        }
384
385                        if inner_height != container_height {
386                            scroll_y = ((top * scale_factor) / (inner_height - container_height))
387                                .clamp(0.0, 1.0);
388                        } else {
389                            scroll_y = 0.0;
390                        }
391
392                        self.scroll_x.set(scroll_x);
393                        self.scroll_y.set(scroll_y);
394                        self.container_width.set(container_width);
395                        self.container_height.set(container_height);
396
397                        if let Some(callback) = &self.on_scroll {
398                            (callback)(cx, self.scroll_x.get(), self.scroll_y.get());
399                        }
400
401                        self.reset();
402                    }
403
404                    self.container_width.set(bounds.width());
405                    self.container_height.set(bounds.height());
406                }
407            }
408
409            WindowEvent::MouseScroll(x, y) => {
410                cx.set_active(true);
411                let (x, y) = if cx.modifiers.shift() { (-*y, -*x) } else { (-*x, -*y) };
412
413                // What percentage of the negative space does this cross?
414                if x != 0.0 && self.inner_width.get() > self.container_width.get() {
415                    let negative_space = self.inner_width.get() - self.container_width.get();
416                    if negative_space != 0.0 {
417                        let logical_delta = x * SCROLL_SENSITIVITY / negative_space;
418                        cx.emit(ScrollEvent::ScrollX(logical_delta));
419                    }
420                    // Prevent event propagating to ancestor scrollviews.
421                    meta.consume();
422                }
423                if y != 0.0 && self.inner_height.get() > self.container_height.get() {
424                    let negative_space = self.inner_height.get() - self.container_height.get();
425                    if negative_space != 0.0 {
426                        let logical_delta = y * SCROLL_SENSITIVITY / negative_space;
427                        cx.emit(ScrollEvent::ScrollY(logical_delta));
428                    }
429                    // Prevent event propagating to ancestor scrollviews.
430                    meta.consume();
431                }
432            }
433
434            WindowEvent::MouseOut => {
435                cx.set_active(false);
436            }
437
438            _ => {}
439        });
440    }
441}
442
443impl Handle<'_, ScrollView> {
444    /// Sets a callback which will be called when a scrollview is scrolled, either with the mouse wheel, touchpad, or using the scroll bars.
445    pub fn on_scroll(
446        self,
447        callback: impl Fn(&mut EventContext, f32, f32) + 'static + Send + Sync,
448    ) -> Self {
449        self.modify(|scrollview| scrollview.on_scroll = Some(Arc::new(callback)))
450    }
451
452    /// Sets whether the scrollbar should move to the cursor when pressed.
453    pub fn scroll_to_cursor(self, scroll_to_cursor: impl Res<bool> + 'static) -> Self {
454        let scroll_to_cursor = scroll_to_cursor.to_signal(self.cx);
455        self.bind(scroll_to_cursor, move |handle| {
456            handle.modify(|scrollview| scrollview.scroll_to_cursor.set(scroll_to_cursor.get()));
457        })
458    }
459
460    /// Set the horizontal scroll position of the [ScrollView]. Accepts a value or signal of type `f32` between 0 and 1.
461    pub fn scroll_x(self, scrollx: impl Res<f32> + 'static) -> Self {
462        let scrollx = scrollx.to_signal(self.cx);
463        self.bind(scrollx, move |handle| {
464            handle.modify(|scrollview| scrollview.scroll_x.set(scrollx.get()));
465        })
466    }
467
468    /// Set the vertical scroll position of the [ScrollView]. Accepts a value or signal of type `f32` between 0 and 1.
469    pub fn scroll_y(self, scrolly: impl Res<f32> + 'static) -> Self {
470        let scrolly = scrolly.to_signal(self.cx);
471        self.bind(scrolly, move |handle| {
472            handle.modify(|scrollview| scrollview.scroll_y.set(scrolly.get()));
473        })
474    }
475
476    /// Sets whether the horizontal scrollbar should be visible.
477    pub fn show_horizontal_scrollbar(self, flag: impl Res<bool> + 'static) -> Self {
478        let flag = flag.to_signal(self.cx);
479        self.bind(flag, move |handle| {
480            handle.modify(|scrollview| scrollview.show_horizontal_scrollbar.set(flag.get()));
481        })
482    }
483
484    /// Sets whether the vertical scrollbar should be visible.
485    pub fn show_vertical_scrollbar(self, flag: impl Res<bool> + 'static) -> Self {
486        let flag = flag.to_signal(self.cx);
487        self.bind(flag, move |handle| {
488            handle.modify(|scrollview| scrollview.show_vertical_scrollbar.set(flag.get()));
489        })
490    }
491}
492
493struct ScrollContent {}
494
495impl ScrollContent {
496    pub fn new(cx: &mut Context, content: impl FnOnce(&mut Context)) -> Handle<Self> {
497        Self {}.build(cx, content)
498    }
499}
500
501impl View for ScrollContent {
502    fn element(&self) -> Option<&'static str> {
503        Some("scroll-content")
504    }
505
506    fn event(&mut self, cx: &mut EventContext, event: &mut Event) {
507        event.map(|window_event, _| match window_event {
508            WindowEvent::GeometryChanged(geo) => {
509                if geo.contains(GeoChanged::WIDTH_CHANGED)
510                    || geo.contains(GeoChanged::HEIGHT_CHANGED)
511                {
512                    let bounds = cx.bounds();
513                    // If the width or height have changed then send this back up to the ScrollData.
514                    cx.emit(ScrollEvent::ChildGeo(bounds.w, bounds.h));
515                }
516            }
517
518            _ => {}
519        });
520    }
521}