1use iced::advanced::Shell;
2use iced::advanced::layout::{self, Layout};
3use iced::advanced::renderer;
4use iced::advanced::widget::{self, Tree, Widget};
5use iced::mouse;
6use iced::{Border, Color, Element, Event, Length, Point, Rectangle, Size};
7
8pub struct VerticalScrollbar<'a, Message> {
9 content_height: f32,
10 value: f32,
11 on_change: Box<dyn Fn(f32) -> Message + 'a>,
12 width: Length,
13 height: Length,
14 min_handle_height: f32,
15}
16
17impl<'a, Message> VerticalScrollbar<'a, Message> {
18 pub fn new<F>(content_height: f32, value: f32, on_change: F) -> Self
19 where
20 F: Fn(f32) -> Message + 'a,
21 {
22 Self {
23 content_height: content_height.max(1.0),
24 value,
25 on_change: Box::new(on_change),
26 width: Length::Fixed(16.0),
27 height: Length::Fill,
28 min_handle_height: 12.0,
29 }
30 }
31
32 pub fn width(mut self, width: Length) -> Self {
33 self.width = width;
34 self
35 }
36
37 pub fn height(mut self, height: Length) -> Self {
38 self.height = height;
39 self
40 }
41
42 fn normalized_value(&self) -> f32 {
43 self.value.clamp(0.0, 1.0)
44 }
45
46 fn handle_height(&self, bounds: Rectangle) -> f32 {
47 if self.content_height <= bounds.height {
48 bounds.height
49 } else {
50 (bounds.height * (bounds.height / self.content_height))
51 .clamp(self.min_handle_height, bounds.height)
52 }
53 }
54
55 fn handle_bounds(&self, bounds: Rectangle) -> Rectangle {
56 let handle_height = self.handle_height(bounds);
57 let travel = (bounds.height - handle_height).max(0.0);
58 let handle_y = bounds.y + travel * self.normalized_value();
59 Rectangle {
60 x: bounds.x,
61 y: handle_y,
62 width: bounds.width,
63 height: handle_height,
64 }
65 }
66
67 fn drag_value(&self, cursor_position: Point, bounds: Rectangle, drag_offset_y: f32) -> f32 {
68 let handle_height = self.handle_height(bounds);
69 let travel = (bounds.height - handle_height).max(0.0);
70 if travel <= f32::EPSILON {
71 return 0.0;
72 }
73 let handle_top = (cursor_position.y - bounds.y - drag_offset_y).clamp(0.0, travel);
74 (handle_top / travel).clamp(0.0, 1.0)
75 }
76
77 fn page_step(&self, bounds: Rectangle) -> f32 {
78 let max_scroll = (self.content_height - bounds.height).max(0.0);
79 if max_scroll <= f32::EPSILON {
80 1.0
81 } else {
82 (bounds.height / max_scroll).clamp(0.0, 1.0)
83 }
84 }
85
86 fn page_click_value(&self, cursor_position: Point, bounds: Rectangle) -> f32 {
87 let handle_bounds = self.handle_bounds(bounds);
88 let page_step = self.page_step(bounds);
89 let current = self.normalized_value();
90 if cursor_position.y < handle_bounds.y {
91 (current - page_step).clamp(0.0, 1.0)
92 } else {
93 (current + page_step).clamp(0.0, 1.0)
94 }
95 }
96}
97
98#[derive(Default)]
99struct State {
100 is_dragging: bool,
101 drag_offset_y: f32,
102}
103
104impl<'a, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
105 for VerticalScrollbar<'a, Message>
106where
107 Renderer: renderer::Renderer,
108{
109 fn size(&self) -> Size<Length> {
110 Size {
111 width: self.width,
112 height: self.height,
113 }
114 }
115
116 fn layout(
117 &mut self,
118 _tree: &mut Tree,
119 _renderer: &Renderer,
120 limits: &layout::Limits,
121 ) -> layout::Node {
122 let size = limits.width(self.width).height(self.height).resolve(
123 self.width,
124 self.height,
125 Size::ZERO,
126 );
127
128 layout::Node::new(size)
129 }
130
131 fn draw(
132 &self,
133 tree: &Tree,
134 renderer: &mut Renderer,
135 _theme: &Theme,
136 _style: &renderer::Style,
137 layout: Layout<'_>,
138 cursor: mouse::Cursor,
139 _viewport: &Rectangle,
140 ) {
141 let bounds = layout.bounds();
142 let state = tree.state.downcast_ref::<State>();
143 let handle_bounds = self.handle_bounds(bounds);
144 let handle_hovered = cursor.is_over(handle_bounds);
145 let border_width = 1.0;
146 let back_color = Color::from_rgb(
147 0x42 as f32 / 255.0,
148 0x46 as f32 / 255.0,
149 0x4D as f32 / 255.0,
150 );
151 let border_color = Color::from_rgb(
152 0x30 as f32 / 255.0,
153 0x33 as f32 / 255.0,
154 0x3C as f32 / 255.0,
155 );
156 let handle_color = if state.is_dragging || handle_hovered {
157 Color::from_rgb(
158 0x75 as f32 / 255.0,
159 0xC2 as f32 / 255.0,
160 0xFF as f32 / 255.0,
161 )
162 } else {
163 Color::from_rgb(
164 0x8B as f32 / 255.0,
165 0x90 as f32 / 255.0,
166 0x97 as f32 / 255.0,
167 )
168 };
169 let border_radius = 2.0;
170
171 renderer.fill_quad(
172 renderer::Quad {
173 bounds,
174 border: Border {
175 radius: border_radius.into(),
176 width: border_width,
177 color: border_color,
178 },
179 ..Default::default()
180 },
181 back_color,
182 );
183
184 renderer.fill_quad(
185 renderer::Quad {
186 bounds: handle_bounds,
187 border: Border {
188 radius: border_radius.into(),
189 width: border_width,
190 color: Color::TRANSPARENT,
191 },
192 ..Default::default()
193 },
194 handle_color,
195 );
196 }
197
198 fn tag(&self) -> widget::tree::Tag {
199 widget::tree::Tag::of::<State>()
200 }
201
202 fn state(&self) -> widget::tree::State {
203 widget::tree::State::new(State::default())
204 }
205
206 fn update(
207 &mut self,
208 tree: &mut Tree,
209 event: &Event,
210 layout: Layout<'_>,
211 cursor: mouse::Cursor,
212 _renderer: &Renderer,
213 _clipboard: &mut dyn iced::advanced::Clipboard,
214 shell: &mut Shell<'_, Message>,
215 _viewport: &Rectangle,
216 ) {
217 let state = tree.state.downcast_mut::<State>();
218 let bounds = layout.bounds();
219
220 match event {
221 Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
222 if cursor.is_over(bounds)
223 && let Some(cursor_position) = cursor.position()
224 {
225 let handle_bounds = self.handle_bounds(bounds);
226 if cursor_position.y >= handle_bounds.y
227 && cursor_position.y <= handle_bounds.y + handle_bounds.height
228 {
229 state.is_dragging = true;
230 state.drag_offset_y =
231 (cursor_position.y - handle_bounds.y).clamp(0.0, handle_bounds.height);
232 } else {
233 shell.publish((self.on_change)(
234 self.page_click_value(cursor_position, bounds),
235 ));
236 }
237 }
238 }
239 Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
240 state.is_dragging = false;
241 }
242 Event::Mouse(mouse::Event::CursorMoved { .. }) => {
243 if state.is_dragging
244 && let Some(cursor_position) = cursor.position()
245 {
246 shell.publish((self.on_change)(self.drag_value(
247 cursor_position,
248 bounds,
249 state.drag_offset_y,
250 )));
251 }
252 }
253 _ => {}
254 }
255 }
256}
257
258impl<'a, Message, Theme, Renderer> From<VerticalScrollbar<'a, Message>>
259 for Element<'a, Message, Theme, Renderer>
260where
261 Message: 'a,
262 Theme: 'a,
263 Renderer: renderer::Renderer + 'a,
264{
265 fn from(scrollbar: VerticalScrollbar<'a, Message>) -> Self {
266 Self::new(scrollbar)
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use iced::Event;
274 use iced::advanced::{
275 Layout, Shell, clipboard, layout,
276 widget::{self, Tree, Widget},
277 };
278
279 fn test_layout(width: f32, height: f32) -> Layout<'static> {
280 let node = Box::leak(Box::new(layout::Node::new(Size::new(width, height))));
281 Layout::new(node)
282 }
283
284 #[cfg(debug_assertions)]
285 #[test]
286 fn click_below_handle_pages_down_by_one_viewport() {
287 let mut scrollbar =
288 VerticalScrollbar::new(300.0, 0.25, |value| value).height(Length::Fixed(100.0));
289 let mut tree = Tree {
290 tag: widget::tree::Tag::of::<State>(),
291 state: widget::tree::State::new(State::default()),
292 children: Vec::new(),
293 };
294 let layout = test_layout(16.0, 100.0);
295 let mut messages = Vec::new();
296 let mut shell = Shell::new(&mut messages);
297 let renderer = ();
298 let mut clipboard = clipboard::Null;
299 let viewport = Rectangle::new(Point::ORIGIN, Size::new(16.0, 100.0));
300
301 <VerticalScrollbar<'_, f32> as Widget<f32, iced::Theme, ()>>::update(
302 &mut scrollbar,
303 &mut tree,
304 &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
305 layout,
306 mouse::Cursor::Available(Point::new(8.0, 90.0)),
307 &renderer,
308 &mut clipboard,
309 &mut shell,
310 &viewport,
311 );
312
313 assert_eq!(messages.len(), 1);
314 assert!((messages[0] - 0.75).abs() < 0.01);
315 }
316
317 #[cfg(debug_assertions)]
318 #[test]
319 fn click_above_handle_pages_up_by_one_viewport() {
320 let mut scrollbar =
321 VerticalScrollbar::new(300.0, 0.75, |value| value).height(Length::Fixed(100.0));
322 let mut tree = Tree {
323 tag: widget::tree::Tag::of::<State>(),
324 state: widget::tree::State::new(State::default()),
325 children: Vec::new(),
326 };
327 let layout = test_layout(16.0, 100.0);
328 let mut messages = Vec::new();
329 let mut shell = Shell::new(&mut messages);
330 let renderer = ();
331 let mut clipboard = clipboard::Null;
332 let viewport = Rectangle::new(Point::ORIGIN, Size::new(16.0, 100.0));
333
334 <VerticalScrollbar<'_, f32> as Widget<f32, iced::Theme, ()>>::update(
335 &mut scrollbar,
336 &mut tree,
337 &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
338 layout,
339 mouse::Cursor::Available(Point::new(8.0, 10.0)),
340 &renderer,
341 &mut clipboard,
342 &mut shell,
343 &viewport,
344 );
345
346 assert_eq!(messages.len(), 1);
347 assert!((messages[0] - 0.25).abs() < 0.01);
348 }
349
350 #[cfg(debug_assertions)]
351 #[test]
352 fn dragging_handle_uses_grab_offset_instead_of_jumping() {
353 let mut scrollbar =
354 VerticalScrollbar::new(400.0, 0.5, |value| value).height(Length::Fixed(100.0));
355 let mut tree = Tree {
356 tag: widget::tree::Tag::of::<State>(),
357 state: widget::tree::State::new(State::default()),
358 children: Vec::new(),
359 };
360 let layout = test_layout(16.0, 100.0);
361 let renderer = ();
362 let mut clipboard = clipboard::Null;
363 let viewport = Rectangle::new(Point::ORIGIN, Size::new(16.0, 100.0));
364 let mut messages = Vec::new();
365
366 {
367 let mut shell = Shell::new(&mut messages);
368 <VerticalScrollbar<'_, f32> as Widget<f32, iced::Theme, ()>>::update(
369 &mut scrollbar,
370 &mut tree,
371 &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
372 layout,
373 mouse::Cursor::Available(Point::new(8.0, 42.0)),
374 &renderer,
375 &mut clipboard,
376 &mut shell,
377 &viewport,
378 );
379 }
380
381 assert!(messages.is_empty());
382
383 {
384 let mut shell = Shell::new(&mut messages);
385 <VerticalScrollbar<'_, f32> as Widget<f32, iced::Theme, ()>>::update(
386 &mut scrollbar,
387 &mut tree,
388 &Event::Mouse(mouse::Event::CursorMoved {
389 position: Point::new(8.0, 52.0),
390 }),
391 layout,
392 mouse::Cursor::Available(Point::new(8.0, 52.0)),
393 &renderer,
394 &mut clipboard,
395 &mut shell,
396 &viewport,
397 );
398 }
399
400 assert_eq!(messages.len(), 1);
401 assert!((messages[0] - 0.6333).abs() < 0.02);
402 }
403}