From 517289f3a5c1686801200337e8d762168c697d0b Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 22 Feb 2026 03:36:31 -0800 Subject: [PATCH] mici scroller: add move animation (#37319) * already 90% of the way there and not 144 lines * nice * lift properly * lift, wait, move, wait, drop! * some clean up * epic, he ran a simulation to turn opacity filter into pixels * scroll independant move animation without layout! * move into function * clean up * rm * overlay behind moving item * Revert "overlay behind moving item" This reverts commit 598e22363eb66af6496fe5f1eea8e643d4c2adbb. * simpler overlay under lifted item * support multiple animations at once * Revert "support multiple animations at once" This reverts commit 3ce6c8281053ee4831ceb88cacf66c343fc7d7ff. * clean up * cmt * clean up * kinda works * Revert "kinda works" This reverts commit ff050c6afc058788b3189a0acc202ada17353504. * clean up clean up * clear overlay * diff report * don't break more --- system/ui/widgets/scroller.py | 126 ++++++++++++++++++++++++++++++---- 1 file changed, 112 insertions(+), 14 deletions(-) diff --git a/system/ui/widgets/scroller.py b/system/ui/widgets/scroller.py index 8de86fca9..8630b4703 100644 --- a/system/ui/widgets/scroller.py +++ b/system/ui/widgets/scroller.py @@ -3,6 +3,7 @@ import numpy as np from collections.abc import Callable from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter +from openpilot.common.swaglog import cloudlog from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState from openpilot.system.ui.widgets import Widget @@ -12,6 +13,9 @@ LINE_COLOR = rl.GRAY LINE_PADDING = 40 ANIMATION_SCALE = 0.6 +MOVE_LIFT = 20 +MOVE_OVERLAY_ALPHA = 0.65 + EDGE_SHADOW_WIDTH = 20 MIN_ZOOM_ANIMATION_TIME = 0.075 # seconds @@ -95,6 +99,15 @@ class Scroller(Widget): self._scroll_indicator = ScrollIndicator() self._edge_shadows = edge_shadows and self._horizontal + # move animation state + # on move; lift src widget -> wait -> move all -> wait -> drop src widget + self._overlay_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps) + self._move_animations: dict[Widget, FirstOrderFilter] = {} + self._move_lift: dict[Widget, FirstOrderFilter] = {} + # these are used to wait before moving/dropping, also to move onto next part of the animation earlier for timing + self._pending_lift: set[Widget] = set() + self._pending_move: set[Widget] = set() + for item in items: self.add_widget(item) @@ -123,7 +136,8 @@ class Scroller(Widget): def add_widget(self, item: Widget) -> None: self._items.append(item) - item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled) + item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled and self._scrolling_to is None + and not self.moving_items) def set_scrolling_enabled(self, enabled: bool | Callable[[], bool]) -> None: """Set whether scrolling is enabled (does not affect widget enabled state).""" @@ -197,6 +211,69 @@ class Scroller(Widget): return self.scroll_panel.get_offset() + @property + def moving_items(self) -> bool: + return len(self._move_animations) > 0 or len(self._move_lift) > 0 + + def move_item(self, from_idx: int, to_idx: int): + assert self._horizontal + if from_idx == to_idx: + return + + if self.moving_items: + cloudlog.warning(f"Already moving items, cannot move from {from_idx} to {to_idx}") + return + + item = self._items.pop(from_idx) + self._items.insert(to_idx, item) + + # store original position in content space of all affected widgets to animate from + for idx in range(min(from_idx, to_idx), max(from_idx, to_idx) + 1): + affected_item = self._items[idx] + self._move_animations[affected_item] = FirstOrderFilter(affected_item.rect.x - self._scroll_offset, 0.15, 1 / gui_app.target_fps) + self._pending_move.add(affected_item) + + # lift only src widget to make it more clear which one is moving + self._move_lift[item] = FirstOrderFilter(0.0, 0.15, 1 / gui_app.target_fps) + self._pending_lift.add(item) + + def _do_move_animation(self, item: Widget, target_x: float, target_y: float) -> tuple[float, float]: + if item in self._move_lift: + lift_filter = self._move_lift[item] + + # Animate lift + if len(self._pending_move) > 0: + lift_filter.update(MOVE_LIFT) + # start moving when almost lifted + if abs(lift_filter.x - MOVE_LIFT) < 2: + self._pending_lift.discard(item) + else: + # if done moving, animate down + lift_filter.update(0) + if abs(lift_filter.x) < 1: + del self._move_lift[item] + target_y -= lift_filter.x + + # Animate move + if item in self._move_animations: + move_filter = self._move_animations[item] + + # compare/update in content space to match filter + content_x = target_x - self._scroll_offset + if len(self._pending_lift) == 0: + move_filter.update(content_x) + + # drop when close to target + if abs(move_filter.x - content_x) < 10: + self._pending_move.discard(item) + + # finished moving + if abs(move_filter.x - content_x) < 1: + del self._move_animations[item] + target_x = move_filter.x + self._scroll_offset + + return target_x, target_y + def _layout(self): self._visible_items = [item for item in self._items if item.is_visible] @@ -242,30 +319,46 @@ class Scroller(Widget): [self._item_pos_filter.x, self._scroll_offset, self._item_pos_filter.x]) y -= np.clip(jello_offset, -20, 20) + # Animate moves if needed + x, y = self._do_move_animation(item, x, y) + # Update item state item.set_position(round(x), round(y)) # round to prevent jumping when settling item.set_parent_rect(self._rect) + def _render_item(self, item: Widget): + # Skip rendering if not in viewport + if not rl.check_collision_recs(item.rect, self._rect): + return + + # Scale each element around its own origin when scrolling + scale = self._zoom_filter.x + if scale != 1.0: + rl.rl_push_matrix() + rl.rl_scalef(scale, scale, 1.0) + rl.rl_translatef((1 - scale) * (item.rect.x + item.rect.width / 2) / scale, + (1 - scale) * (item.rect.y + item.rect.height / 2) / scale, 0) + item.render() + rl.rl_pop_matrix() + else: + item.render() + def _render(self, _): rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y), int(self._rect.width), int(self._rect.height)) for item in reversed(self._visible_items): - # Skip rendering if not in viewport - if not rl.check_collision_recs(item.rect, self._rect): + if item in self._move_lift: continue + self._render_item(item) - # Scale each element around its own origin when scrolling - scale = self._zoom_filter.x - if scale != 1.0: - rl.rl_push_matrix() - rl.rl_scalef(scale, scale, 1.0) - rl.rl_translatef((1 - scale) * (item.rect.x + item.rect.width / 2) / scale, - (1 - scale) * (item.rect.y + item.rect.height / 2) / scale, 0) - item.render() - rl.rl_pop_matrix() - else: - item.render() + # Dim background if moving items, lifted items are above + self._overlay_filter.update(MOVE_OVERLAY_ALPHA if self.moving_items else 0.0) + if self._overlay_filter.x > 0.01: + rl.draw_rectangle_rec(self._rect, rl.Color(0, 0, 0, int(255 * self._overlay_filter.x))) + + for item in self._move_lift: + self._render_item(item) rl.end_scissor_mode() @@ -295,5 +388,10 @@ class Scroller(Widget): def hide_event(self): super().hide_event() + self._overlay_filter.x = 0.0 + self._move_animations.clear() + self._move_lift.clear() + self._pending_lift.clear() + self._pending_move.clear() for item in self._items: item.hide_event()