From c9f3cd5ad2ce223f3ff9bc623453aafc0327c8cd Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Thu, 22 May 2025 11:31:50 +0800 Subject: [PATCH] system/ui: enhance scroll panel with iPhone-like physics and behavior (#35312) * improve scroll panel for iphone-like experience * add comments * increase demo run time for easier testing --- system/ui/lib/scroll_panel.py | 166 ++++++++++++++++++++++++++++------ system/ui/text.py | 2 +- 2 files changed, 138 insertions(+), 30 deletions(-) diff --git a/system/ui/lib/scroll_panel.py b/system/ui/lib/scroll_panel.py index 7dc2fdb917..43111504bb 100644 --- a/system/ui/lib/scroll_panel.py +++ b/system/ui/lib/scroll_panel.py @@ -1,16 +1,23 @@ import pyray as rl from enum import IntEnum +# Scroll constants for smooth scrolling behavior MOUSE_WHEEL_SCROLL_SPEED = 30 -INERTIA_FRICTION = 0.95 # The rate at which the inertia slows down -MIN_VELOCITY = 0.1 # Minimum velocity before stopping the inertia -DRAG_THRESHOLD = 5 # Pixels of movement to consider it a drag, not a click +INERTIA_FRICTION = 0.92 # The rate at which the inertia slows down +MIN_VELOCITY = 0.5 # Minimum velocity before stopping the inertia +DRAG_THRESHOLD = 5 # Pixels of movement to consider it a drag, not a click +BOUNCE_FACTOR = 0.2 # Elastic bounce when scrolling past boundaries +BOUNCE_RETURN_SPEED = 0.15 # How quickly it returns from the bounce +MAX_BOUNCE_DISTANCE = 150 # Maximum distance for bounce effect +FLICK_MULTIPLIER = 1.8 # Multiplier for flick gestures +VELOCITY_HISTORY_SIZE = 5 # Track velocity over multiple frames for smoother motion class ScrollState(IntEnum): IDLE = 0 DRAGGING_CONTENT = 1 DRAGGING_SCROLLBAR = 2 + BOUNCING = 3 class GuiScrollPanel: @@ -22,14 +29,33 @@ class GuiScrollPanel: self._view = rl.Rectangle(0, 0, 0, 0) self._show_vertical_scroll_bar: bool = show_vertical_scroll_bar self._velocity_y = 0.0 # Velocity for inertia - self._is_dragging = False + self._is_dragging: bool = False + self._bounce_offset: float = 0.0 + self._last_frame_time = rl.get_time() + self._velocity_history: list[float] = [] + self._last_drag_time: float = 0.0 + self._content_rect: rl.Rectangle | None = None + self._bounds_rect: rl.Rectangle | None = None def handle_scroll(self, bounds: rl.Rectangle, content: rl.Rectangle) -> rl.Vector2: + # Store rectangles for reference + self._content_rect = content + self._bounds_rect = bounds + + # Calculate time delta + current_time = rl.get_time() + delta_time = current_time - self._last_frame_time + self._last_frame_time = current_time + + # Prevent large jumps + delta_time = min(delta_time, 0.05) + mouse_pos = rl.get_mouse_position() + max_scroll_y = max(content.height - bounds.height, 0) - # Handle dragging logic + # Start dragging on mouse press if rl.check_collision_point_rec(mouse_pos, bounds) and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT): - if self._scroll_state == ScrollState.IDLE: + if self._scroll_state == ScrollState.IDLE or self._scroll_state == ScrollState.BOUNCING: self._scroll_state = ScrollState.DRAGGING_CONTENT if self._show_vertical_scroll_bar: scrollbar_width = rl.gui_get_style(rl.GuiControl.LISTVIEW, rl.GuiListViewProperty.SCROLLBAR_WIDTH) @@ -38,51 +64,133 @@ class GuiScrollPanel: self._scroll_state = ScrollState.DRAGGING_SCROLLBAR self._last_mouse_y = mouse_pos.y - self._start_mouse_y = mouse_pos.y # Record starting position - self._velocity_y = 0.0 # Reset velocity when drag starts - self._is_dragging = False # Reset dragging flag + self._start_mouse_y = mouse_pos.y + self._last_drag_time = current_time + self._velocity_history = [] + self._velocity_y = 0.0 + self._bounce_offset = 0.0 + self._is_dragging = False - if self._scroll_state != ScrollState.IDLE: + # Handle active dragging + if self._scroll_state == ScrollState.DRAGGING_CONTENT or self._scroll_state == ScrollState.DRAGGING_SCROLLBAR: if rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT): delta_y = mouse_pos.y - self._last_mouse_y - # Check if movement exceeds the drag threshold + # Track velocity for inertia + time_since_last_drag = current_time - self._last_drag_time + if time_since_last_drag > 0: + drag_velocity = delta_y / time_since_last_drag / 60.0 + self._velocity_history.append(drag_velocity) + + if len(self._velocity_history) > VELOCITY_HISTORY_SIZE: + self._velocity_history.pop(0) + + self._last_drag_time = current_time + + # Detect actual dragging total_drag = abs(mouse_pos.y - self._start_mouse_y) if total_drag > DRAG_THRESHOLD: self._is_dragging = True if self._scroll_state == ScrollState.DRAGGING_CONTENT: + # Add resistance at boundaries + if (self._offset.y > 0 and delta_y > 0) or (self._offset.y < -max_scroll_y and delta_y < 0): + delta_y *= BOUNCE_FACTOR + self._offset.y += delta_y elif self._scroll_state == ScrollState.DRAGGING_SCROLLBAR: - delta_y = -delta_y + scroll_ratio = content.height / bounds.height + self._offset.y -= delta_y * scroll_ratio self._last_mouse_y = mouse_pos.y - self._velocity_y = delta_y # Update velocity during drag - elif rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT): - self._scroll_state = ScrollState.IDLE - # Handle mouse wheel scrolling + elif rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT): + # Calculate flick velocity + if self._velocity_history: + total_weight = 0 + weighted_velocity = 0.0 + + for i, v in enumerate(self._velocity_history): + weight = i + 1 + weighted_velocity += v * weight + total_weight += weight + + if total_weight > 0: + avg_velocity = weighted_velocity / total_weight + self._velocity_y = avg_velocity * FLICK_MULTIPLIER + + # Check bounds + if self._offset.y > 0 or self._offset.y < -max_scroll_y: + self._scroll_state = ScrollState.BOUNCING + else: + self._scroll_state = ScrollState.IDLE + + # Handle mouse wheel wheel_move = rl.get_mouse_wheel_move() - if self._show_vertical_scroll_bar: - self._offset.y += wheel_move * (MOUSE_WHEEL_SCROLL_SPEED - 20) - rl.gui_scroll_panel(bounds, rl.ffi.NULL, content, self._offset, self._view) - else: - self._offset.y += wheel_move * MOUSE_WHEEL_SCROLL_SPEED + if wheel_move != 0: + self._velocity_y = 0.0 + + if self._show_vertical_scroll_bar: + self._offset.y += wheel_move * (MOUSE_WHEEL_SCROLL_SPEED - 20) + rl.gui_scroll_panel(bounds, rl.ffi.NULL, content, self._offset, self._view) + else: + self._offset.y += wheel_move * MOUSE_WHEEL_SCROLL_SPEED + + if self._offset.y > 0 or self._offset.y < -max_scroll_y: + self._scroll_state = ScrollState.BOUNCING # Apply inertia (continue scrolling after mouse release) if self._scroll_state == ScrollState.IDLE: - self._offset.y += self._velocity_y - self._velocity_y *= INERTIA_FRICTION # Slow down velocity over time + if abs(self._velocity_y) > MIN_VELOCITY: + self._offset.y += self._velocity_y + self._velocity_y *= INERTIA_FRICTION - # Stop scrolling when velocity is low - if abs(self._velocity_y) < MIN_VELOCITY: + if self._offset.y > 0 or self._offset.y < -max_scroll_y: + self._scroll_state = ScrollState.BOUNCING + else: self._velocity_y = 0.0 - # Ensure scrolling doesn't go beyond bounds - max_scroll_y = max(content.height - bounds.height, 0) - self._offset.y = max(min(self._offset.y, 0), -max_scroll_y) + # Handle bouncing effect + elif self._scroll_state == ScrollState.BOUNCING: + target_y = 0.0 + if self._offset.y < -max_scroll_y: + target_y = -max_scroll_y + + distance = target_y - self._offset.y + bounce_step = distance * BOUNCE_RETURN_SPEED + self._offset.y += bounce_step + self._velocity_y *= INERTIA_FRICTION * 0.8 + + if abs(distance) < 0.5 and abs(self._velocity_y) < MIN_VELOCITY: + self._offset.y = target_y + self._velocity_y = 0.0 + self._scroll_state = ScrollState.IDLE + + # Limit bounce distance + if self._scroll_state != ScrollState.DRAGGING_CONTENT: + if self._offset.y > MAX_BOUNCE_DISTANCE: + self._offset.y = MAX_BOUNCE_DISTANCE + elif self._offset.y < -(max_scroll_y + MAX_BOUNCE_DISTANCE): + self._offset.y = -(max_scroll_y + MAX_BOUNCE_DISTANCE) return self._offset def is_click_valid(self) -> bool: - return self._scroll_state == ScrollState.IDLE and not self._is_dragging and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) + # Check if this is a click rather than a drag + return ( + self._scroll_state == ScrollState.IDLE + and not self._is_dragging + and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) + ) + + def get_normalized_scroll_position(self) -> float: + """Returns the current scroll position as a value from 0.0 to 1.0""" + if not self._content_rect or not self._bounds_rect: + return 0.0 + + max_scroll_y = max(self._content_rect.height - self._bounds_rect.height, 0) + if max_scroll_y == 0: + return 0.0 + + normalized = -self._offset.y / max_scroll_y + return max(0.0, min(1.0, normalized)) diff --git a/system/ui/text.py b/system/ui/text.py index 33e8167c64..82e64d836f 100755 --- a/system/ui/text.py +++ b/system/ui/text.py @@ -88,4 +88,4 @@ class TextWindow(BaseWindow[TextWindowRenderer]): if __name__ == "__main__": with TextWindow(DEMO_TEXT): - time.sleep(5) + time.sleep(30)