Skip to content

Commit 517289f

Browse files
authored
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 598e223. * simpler overlay under lifted item * support multiple animations at once * Revert "support multiple animations at once" This reverts commit 3ce6c82. * clean up * cmt * clean up * kinda works * Revert "kinda works" This reverts commit ff050c6. * clean up clean up * clear overlay * diff report * don't break more
1 parent 6fcd218 commit 517289f

1 file changed

Lines changed: 112 additions & 14 deletions

File tree

system/ui/widgets/scroller.py

Lines changed: 112 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from collections.abc import Callable
44

55
from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter
6+
from openpilot.common.swaglog import cloudlog
67
from openpilot.system.ui.lib.application import gui_app
78
from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState
89
from openpilot.system.ui.widgets import Widget
@@ -12,6 +13,9 @@
1213
LINE_PADDING = 40
1314
ANIMATION_SCALE = 0.6
1415

16+
MOVE_LIFT = 20
17+
MOVE_OVERLAY_ALPHA = 0.65
18+
1519
EDGE_SHADOW_WIDTH = 20
1620

1721
MIN_ZOOM_ANIMATION_TIME = 0.075 # seconds
@@ -95,6 +99,15 @@ def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: boo
9599
self._scroll_indicator = ScrollIndicator()
96100
self._edge_shadows = edge_shadows and self._horizontal
97101

102+
# move animation state
103+
# on move; lift src widget -> wait -> move all -> wait -> drop src widget
104+
self._overlay_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
105+
self._move_animations: dict[Widget, FirstOrderFilter] = {}
106+
self._move_lift: dict[Widget, FirstOrderFilter] = {}
107+
# these are used to wait before moving/dropping, also to move onto next part of the animation earlier for timing
108+
self._pending_lift: set[Widget] = set()
109+
self._pending_move: set[Widget] = set()
110+
98111
for item in items:
99112
self.add_widget(item)
100113

@@ -123,7 +136,8 @@ def items(self) -> list[Widget]:
123136

124137
def add_widget(self, item: Widget) -> None:
125138
self._items.append(item)
126-
item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled)
139+
item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled and self._scrolling_to is None
140+
and not self.moving_items)
127141

128142
def set_scrolling_enabled(self, enabled: bool | Callable[[], bool]) -> None:
129143
"""Set whether scrolling is enabled (does not affect widget enabled state)."""
@@ -197,6 +211,69 @@ def _get_scroll(self, visible_items: list[Widget], content_size: float) -> float
197211

198212
return self.scroll_panel.get_offset()
199213

214+
@property
215+
def moving_items(self) -> bool:
216+
return len(self._move_animations) > 0 or len(self._move_lift) > 0
217+
218+
def move_item(self, from_idx: int, to_idx: int):
219+
assert self._horizontal
220+
if from_idx == to_idx:
221+
return
222+
223+
if self.moving_items:
224+
cloudlog.warning(f"Already moving items, cannot move from {from_idx} to {to_idx}")
225+
return
226+
227+
item = self._items.pop(from_idx)
228+
self._items.insert(to_idx, item)
229+
230+
# store original position in content space of all affected widgets to animate from
231+
for idx in range(min(from_idx, to_idx), max(from_idx, to_idx) + 1):
232+
affected_item = self._items[idx]
233+
self._move_animations[affected_item] = FirstOrderFilter(affected_item.rect.x - self._scroll_offset, 0.15, 1 / gui_app.target_fps)
234+
self._pending_move.add(affected_item)
235+
236+
# lift only src widget to make it more clear which one is moving
237+
self._move_lift[item] = FirstOrderFilter(0.0, 0.15, 1 / gui_app.target_fps)
238+
self._pending_lift.add(item)
239+
240+
def _do_move_animation(self, item: Widget, target_x: float, target_y: float) -> tuple[float, float]:
241+
if item in self._move_lift:
242+
lift_filter = self._move_lift[item]
243+
244+
# Animate lift
245+
if len(self._pending_move) > 0:
246+
lift_filter.update(MOVE_LIFT)
247+
# start moving when almost lifted
248+
if abs(lift_filter.x - MOVE_LIFT) < 2:
249+
self._pending_lift.discard(item)
250+
else:
251+
# if done moving, animate down
252+
lift_filter.update(0)
253+
if abs(lift_filter.x) < 1:
254+
del self._move_lift[item]
255+
target_y -= lift_filter.x
256+
257+
# Animate move
258+
if item in self._move_animations:
259+
move_filter = self._move_animations[item]
260+
261+
# compare/update in content space to match filter
262+
content_x = target_x - self._scroll_offset
263+
if len(self._pending_lift) == 0:
264+
move_filter.update(content_x)
265+
266+
# drop when close to target
267+
if abs(move_filter.x - content_x) < 10:
268+
self._pending_move.discard(item)
269+
270+
# finished moving
271+
if abs(move_filter.x - content_x) < 1:
272+
del self._move_animations[item]
273+
target_x = move_filter.x + self._scroll_offset
274+
275+
return target_x, target_y
276+
200277
def _layout(self):
201278
self._visible_items = [item for item in self._items if item.is_visible]
202279

@@ -242,30 +319,46 @@ def _layout(self):
242319
[self._item_pos_filter.x, self._scroll_offset, self._item_pos_filter.x])
243320
y -= np.clip(jello_offset, -20, 20)
244321

322+
# Animate moves if needed
323+
x, y = self._do_move_animation(item, x, y)
324+
245325
# Update item state
246326
item.set_position(round(x), round(y)) # round to prevent jumping when settling
247327
item.set_parent_rect(self._rect)
248328

329+
def _render_item(self, item: Widget):
330+
# Skip rendering if not in viewport
331+
if not rl.check_collision_recs(item.rect, self._rect):
332+
return
333+
334+
# Scale each element around its own origin when scrolling
335+
scale = self._zoom_filter.x
336+
if scale != 1.0:
337+
rl.rl_push_matrix()
338+
rl.rl_scalef(scale, scale, 1.0)
339+
rl.rl_translatef((1 - scale) * (item.rect.x + item.rect.width / 2) / scale,
340+
(1 - scale) * (item.rect.y + item.rect.height / 2) / scale, 0)
341+
item.render()
342+
rl.rl_pop_matrix()
343+
else:
344+
item.render()
345+
249346
def _render(self, _):
250347
rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y),
251348
int(self._rect.width), int(self._rect.height))
252349

253350
for item in reversed(self._visible_items):
254-
# Skip rendering if not in viewport
255-
if not rl.check_collision_recs(item.rect, self._rect):
351+
if item in self._move_lift:
256352
continue
353+
self._render_item(item)
257354

258-
# Scale each element around its own origin when scrolling
259-
scale = self._zoom_filter.x
260-
if scale != 1.0:
261-
rl.rl_push_matrix()
262-
rl.rl_scalef(scale, scale, 1.0)
263-
rl.rl_translatef((1 - scale) * (item.rect.x + item.rect.width / 2) / scale,
264-
(1 - scale) * (item.rect.y + item.rect.height / 2) / scale, 0)
265-
item.render()
266-
rl.rl_pop_matrix()
267-
else:
268-
item.render()
355+
# Dim background if moving items, lifted items are above
356+
self._overlay_filter.update(MOVE_OVERLAY_ALPHA if self.moving_items else 0.0)
357+
if self._overlay_filter.x > 0.01:
358+
rl.draw_rectangle_rec(self._rect, rl.Color(0, 0, 0, int(255 * self._overlay_filter.x)))
359+
360+
for item in self._move_lift:
361+
self._render_item(item)
269362

270363
rl.end_scissor_mode()
271364

@@ -295,5 +388,10 @@ def show_event(self):
295388

296389
def hide_event(self):
297390
super().hide_event()
391+
self._overlay_filter.x = 0.0
392+
self._move_animations.clear()
393+
self._move_lift.clear()
394+
self._pending_lift.clear()
395+
self._pending_move.clear()
298396
for item in self._items:
299397
item.hide_event()

0 commit comments

Comments
 (0)