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