FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

feat: overhaul animations, gestures, and interaction props (#19) · pythonnative/pythonnative@75cd0ca · GitHub

Repository navigation

Commit 75cd0ca

Browse files
authored
feat: overhaul animations, gestures, and interaction props (#19)
1 parent 6d24a93 commit 75cd0ca

47 files changed

Lines changed: 5342 additions & 223 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ PythonNative is a cross-platform toolkit for building native Android and iOS app
3939
- **Typed `style` prop:** Pass all visual and layout properties through a single `style` dict, fully described by the `pn.Style` `TypedDict` and the ergonomic `pn.style(...)` helper for IDE autocomplete and static checking. Compose reusable styles with `StyleSheet`.
4040
- **Cross-platform flexbox engine:** A pure-Python, Yoga-style layout engine computes frames once and applies them to native views, so `flex`, `padding`, `aspect_ratio`, and `position: "absolute"` produce the same geometry on Android and iOS.
4141
- **Virtual view tree + reconciler:** Element trees are diffed and patched with minimal native mutations, similar to React's reconciliation. Each commit lands as **one batched transaction** of mutation ops, and event callbacks are routed through a tag-based registry so re-renders that only change closures cost zero native calls. State updates re-render **locally**: only the component whose state changed (and its subtree) re-runs, and unchanged leaves reuse cached intrinsic measurements, so deep UIs stay responsive instead of re-rendering the whole app from the root on every tap.
42-
- **Native-driven animations:** The `Animated` API (timing / spring / decay, awaitable or fire-and-forget) hands animations to Core Animation and `ViewPropertyAnimator` whenever possible, so no Python code runs per frame; a pure-Python ticker covers the rest.
43-
- **Native gesture system:** Attach `Tap`, `LongPress`, `Pan`, `Swipe`, `Pinch`, and `Rotation` recognizers to any view via the `gestures=` prop, backed by `UIGestureRecognizer` on iOS and a unit-testable pure-Python arbiter on Android and desktop.
42+
- **Native-driven animations:** The `Animated` API (timing / spring / decay / loop / stagger, awaitable or fire-and-forget) hands animations to Core Animation and `ViewPropertyAnimator` whenever possible, so no Python code runs per frame; a pure-Python ticker covers the rest. `interpolate`, arithmetic operators on animated nodes, `Animated.event` scroll binding, and `diff_clamp` cover the scroll-driven patterns (collapsing headers, parallax) that define native feel.
43+
- **Native gesture system:** Attach `Tap`, `LongPress`, `Pan`, `Swipe`, `Fling`, `Pinch`, and `Rotation` recognizers to any view via the `gestures=` prop, backed by `UIGestureRecognizer` on iOS and a unit-testable pure-Python arbiter on Android and desktop. Compose them with `Race`, `Exclusive`, and `Simultaneous` for cross-gesture arbitration (single vs. double tap, drag vs. long press).
4444
- **Virtualized lists:** `FlatList` / `SectionList` window their rows in Python over the platform scroll view: uniform, exact, or measured variable heights, grids, headers/footers, infinite scroll, and an imperative scroll controller, identical on every platform.
4545
- **Direct native bindings:** Python calls platform APIs directly through Chaquopy and rubicon-objc, with no JavaScript bridge.
4646
- **Custom-component SDK:** Wrap any platform widget as a first-class element with type-checked props via `pythonnative.sdk` (`Props`, `@native_component`, `element_factory`). Plugins distributed on PyPI auto-register through the `pythonnative.handlers` entry-point group.

‎docs/examples.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ project scaffolded with `pn init`.
1313
| [Forms](examples/forms.md) | `TextInput`, controlled inputs, validation, submit. |
1414
| [Lists](examples/lists.md) | `FlatList`, keyed children, dynamic rendering. |
1515
| [Navigation](examples/navigation.md) | Stack, tab, and drawer navigators side-by-side. |
16+
| [Collapsing header & bottom sheet](examples/collapsing-header.md) | Scroll-driven animation, `Animated.event`, gestures, and stacking. |
1617

1718
## Working from a project
1819

‎docs/examples/collapsing-header.md‎

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
# Collapsing header and bottom sheet
2+
3+
This example combines the interaction toolkit into the two patterns
4+
that define "native feel": a collapsing header driven by the scroll
5+
offset, and a draggable bottom sheet that snaps open or closed based
6+
on release velocity. It exercises `Animated.event`,
7+
`Animated.diff_clamp`, `interpolate`, animated operators, `z_index`,
8+
`pointer_events`, and the `Pan` gesture with a spring release.
9+
10+
Paste it into `app/main.py` of a project scaffolded with `pn init`,
11+
then run `pn preview` (or `pn run ios` / `pn run android`).
12+
13+
```python
14+
import pythonnative as pn
15+
from pythonnative import gestures
16+
17+
HEADER_HEIGHT = 64.0
18+
SHEET_HEIGHT = 280.0
19+
20+
21+
@pn.component
22+
def CollapsingHeaderPage():
23+
scroll_y = pn.use_animated_value(0.0)
24+
25+
# The header hides after 64 points of downward travel and comes
26+
# back on any upward travel: diff_clamp tracks scroll deltas, and
27+
# the interpolation maps the clamped total onto a negative shift.
28+
clamped = pn.Animated.diff_clamp(scroll_y, 0.0, HEADER_HEIGHT)
29+
header_shift = clamped.interpolate([0.0, HEADER_HEIGHT], [0.0, -HEADER_HEIGHT])
30+
# Fade the title out during the first half of the travel; "clamp"
31+
# pins the opacity at 0.0 for the rest.
32+
title_opacity = clamped.interpolate(
33+
[0.0, HEADER_HEIGHT / 2], [1.0, 0.0], extrapolate="clamp"
34+
)
35+
36+
rows = [
37+
pn.Text(
38+
f"Row {i}",
39+
style={"padding": 14, "background_color": "#F8FAFC"},
40+
)
41+
for i in range(1, 41)
42+
]
43+
44+
return pn.View(
45+
pn.ScrollView(
46+
pn.Column(
47+
pn.View(style={"height": HEADER_HEIGHT}), # header spacer
48+
*rows,
49+
style={"spacing": 2},
50+
),
51+
on_scroll=pn.Animated.event(y=scroll_y),
52+
style={"flex": 1},
53+
),
54+
pn.Animated.View(
55+
pn.Animated.Text(
56+
"Inbox",
57+
style={
58+
"opacity": title_opacity,
59+
"color": "#FFFFFF",
60+
"font_size": 20,
61+
"font_weight": "700",
62+
},
63+
),
64+
style={
65+
"position": "absolute",
66+
"top": 0, "left": 0, "right": 0,
67+
"height": HEADER_HEIGHT,
68+
"z_index": 2,
69+
"transform": [{"translate_y": header_shift}],
70+
"background_color": "#4F46E5",
71+
"justify_content": "center",
72+
"padding_left": 16,
73+
},
74+
),
75+
style={"flex": 1},
76+
)
77+
78+
79+
@pn.component
80+
def BottomSheet(children=None, open: bool = False, on_close=None):
81+
# 0.0 = fully open, SHEET_HEIGHT = fully hidden.
82+
slide = pn.use_animated_value(0.0 if open else SHEET_HEIGHT)
83+
drag_origin = pn.use_ref(0.0)
84+
85+
def _sync_open():
86+
target = 0.0 if open else SHEET_HEIGHT
87+
pn.Animated.spring(slide, to=target, stiffness=260, damping=24).start()
88+
89+
pn.use_effect(_sync_open, [open])
90+
91+
def on_pan_begin(event):
92+
drag_origin.current = float(slide)
93+
94+
def on_pan_change(event):
95+
# Follow the finger, but never above the open position.
96+
slide.set_value(
97+
max(0.0, min(SHEET_HEIGHT, drag_origin.current + event.translation_y))
98+
)
99+
100+
def on_pan_end(event):
101+
# Fast downward flick or past the midpoint: dismiss.
102+
dismiss = event.velocity_y > 800 or float(slide) > SHEET_HEIGHT / 2
103+
target = SHEET_HEIGHT if dismiss else 0.0
104+
pn.Animated.spring(slide, to=target, stiffness=260, damping=24).start()
105+
if dismiss and on_close is not None:
106+
on_close()
107+
108+
# The scrim fades with the sheet position (derived, no listener),
109+
# and stops intercepting touches entirely while hidden.
110+
scrim_opacity = 1.0 - slide / SHEET_HEIGHT
111+
112+
return pn.View(
113+
pn.Animated.View(
114+
style=[pn.StyleSheet.absolute_fill(), {
115+
"background_color": "#0F172A",
116+
"opacity": scrim_opacity * 0.4,
117+
"pointer_events": "none",
118+
}],
119+
),
120+
pn.Animated.View(
121+
pn.View( # grab handle
122+
style={
123+
"width": 40, "height": 4,
124+
"border_radius": 2,
125+
"background_color": "#CBD5E1",
126+
"align_self": "center",
127+
"margin": {"top": 8, "bottom": 12},
128+
},
129+
),
130+
*(children or []),
131+
gestures=[gestures.Pan(
132+
on_begin=on_pan_begin,
133+
on_change=on_pan_change,
134+
on_end=on_pan_end,
135+
)],
136+
style={
137+
"position": "absolute",
138+
"left": 0, "right": 0, "bottom": 0,
139+
"height": SHEET_HEIGHT,
140+
"z_index": 3,
141+
"transform": [{"translate_y": slide}],
142+
"background_color": "#FFFFFF",
143+
"border_top_left_radius": 16,
144+
"border_top_right_radius": 16,
145+
"padding": 16,
146+
},
147+
),
148+
style=[pn.StyleSheet.absolute_fill(), {"pointer_events": "box_none"}],
149+
)
150+
151+
152+
@pn.component
153+
def App():
154+
sheet_open, set_sheet_open = pn.use_state(False)
155+
156+
return pn.View(
157+
CollapsingHeaderPage(),
158+
pn.View(
159+
pn.Button("Open sheet", on_press=lambda: set_sheet_open(True)),
160+
style={"position": "absolute", "bottom": 24, "right": 16, "z_index": 2},
161+
),
162+
BottomSheet(
163+
children=[
164+
pn.Text("Sheet content", style={"font_size": 17, "font_weight": "600"}),
165+
pn.Text("Drag down to dismiss, or flick it away."),
166+
],
167+
open=sheet_open,
168+
on_close=lambda: set_sheet_open(False),
169+
),
170+
style={"flex": 1},
171+
)
172+
```
173+
174+
## How it works
175+
176+
**Collapsing header.** `Animated.event(y=scroll_y)` writes the scroll
177+
offset into `scroll_y` on every scroll event, with no Python listener
178+
required for the animation itself. `Animated.diff_clamp` turns the
179+
absolute offset into clamped travel, so the header reacts to scroll
180+
*direction* rather than position, matching the behavior of toolbar
181+
hiding in native mail and browser apps. The title opacity is derived
182+
with plain arithmetic on the animated node; both derived nodes update
183+
whenever the driver moves.
184+
185+
**Bottom sheet.** The `Pan` gesture writes the drag position into
186+
`slide` directly, so the sheet tracks the finger with no
187+
reconciliation per frame. On release, the velocity decides between
188+
snapping open and dismissing, and `Animated.spring` handles the
189+
settle. The scrim's opacity is `1.0 - slide / SHEET_HEIGHT`, another
190+
derived node, and `pointer_events` keeps the scrim and the sheet's
191+
wrapper from swallowing touches meant for the page underneath.
192+
193+
**Stacking.** The header, the floating button, and the sheet all use
194+
`position: "absolute"` with `z_index` to define the stacking order
195+
explicitly.
196+
197+
## Related reading
198+
199+
- The [Animations guide](../guides/animations.md) covers
200+
interpolation, operators, `Animated.event`, and `diff_clamp` in
201+
detail.
202+
- The [Gestures guide](../guides/gestures.md) covers `Pan`, `Fling`,
203+
and gesture composition.
204+
- The [Styling guide](../guides/styling.md#interaction-surface)
205+
documents `pointer_events`, `hit_slop`, and `on_layout`.

‎docs/guides/animations.md‎

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,130 @@ async def _intro():
128128
`Animated.sequence` runs animations one-after-another. Both are also
129129
awaitable.
130130

131+
`Animated.stagger` is `parallel` with an offset: each animation starts
132+
`delay` milliseconds after the previous one, which is the classic
133+
"cards cascade in" effect:
134+
135+
```python
136+
await pn.Animated.stagger(120, [
137+
pn.Animated.timing(card_a, to=1.0, duration=200),
138+
pn.Animated.timing(card_b, to=1.0, duration=200),
139+
pn.Animated.timing(card_c, to=1.0, duration=200),
140+
])
141+
```
142+
143+
`Animated.loop` repeats any animation (including a `sequence` or
144+
`parallel`). By default it loops forever until you `.stop()` it; pass
145+
`iterations` for a fixed count:
146+
147+
```python
148+
pulse = pn.Animated.loop(
149+
pn.Animated.sequence([
150+
pn.Animated.timing(scale, to=1.15, duration=300),
151+
pn.Animated.timing(scale, to=1.0, duration=300),
152+
]),
153+
).start()
154+
# Later, e.g. when loading finishes:
155+
pulse.stop()
156+
```
157+
158+
## Interpolation
159+
160+
Every animated node has an `interpolate` method that maps an input
161+
range onto an output range. Drive one value and derive as many styled
162+
properties from it as you like; the derived nodes update whenever the
163+
driver moves:
164+
165+
```python
166+
progress = pn.use_animated_value(0.0)
167+
168+
shift = progress.interpolate([0, 1], [0, 120])
169+
color = progress.interpolate([0, 1], ["#6366F1", "#10B981"])
170+
angle = progress.interpolate([0, 1], ["0deg", "90deg"])
171+
172+
pn.Animated.View(
173+
style={
174+
"background_color": color,
175+
"transform": [{"translate_x": shift}, {"rotate": angle}],
176+
},
177+
)
178+
```
179+
180+
Input ranges can have any number of monotonically increasing
181+
breakpoints, and output ranges accept numbers, color strings, or
182+
angle strings (`"deg"` or `"rad"`). The `extrapolate` argument
183+
controls what happens outside the input range: `"extend"` (default)
184+
continues the edge segment linearly, `"clamp"` pins to the edge
185+
output, and `"identity"` passes the input through unchanged.
186+
`extrapolate_left` and `extrapolate_right` set the two sides
187+
independently. Interpolations chain: `node.interpolate(...)` returns
188+
another node you can interpolate again.
189+
190+
## Derived values with operators
191+
192+
Animated nodes support Python arithmetic, so simple math doesn't need
193+
an interpolation:
194+
195+
```python
196+
opacity = progress * 0.5 + 0.5 # 0.5 .. 1.0
197+
inverse = 1.0 - progress
198+
centered = (progress - 0.5) * 2.0
199+
```
200+
201+
`+`, `-`, `*`, `/`, `%`, and unary `-` all work, between nodes and
202+
plain numbers or between two nodes. The result is a read-only node:
203+
bind it into styles like any `AnimatedValue`, but drive the underlying
204+
source value.
205+
206+
One thing to know: a value with derived dependents animates on the
207+
Python ticker rather than the fully native driver, because the graph
208+
has to be re-evaluated per frame to push the derived outputs. The
209+
API and end state are identical; for most UI work the difference isn't
210+
observable.
211+
212+
## Scroll-driven animation with `Animated.event`
213+
214+
`Animated.event` builds an event handler that writes fields from the
215+
event payload straight into `AnimatedValue`s. The classic use is
216+
binding a scroll offset:
217+
218+
```python
219+
scroll_y = pn.use_animated_value(0.0)
220+
221+
pn.ScrollView(
222+
content,
223+
on_scroll=pn.Animated.event(y=scroll_y),
224+
)
225+
```
226+
227+
Each keyword names a payload field (`x` and `y` for scroll events) and
228+
the value it feeds. Pass a positional callable as the first argument
229+
if you also want a plain Python listener to run per event.
230+
231+
`Animated.diff_clamp` pairs naturally with scroll offsets: it tracks
232+
the *change* in its input and clamps the running total, which is
233+
exactly the collapsing-header behavior (hide after N points of
234+
downward travel, reappear on any upward travel):
235+
236+
```python
237+
clamped = pn.Animated.diff_clamp(scroll_y, 0, HEADER_HEIGHT)
238+
header_shift = clamped.interpolate([0, HEADER_HEIGHT], [0, -HEADER_HEIGHT])
239+
240+
pn.Animated.View(
241+
header_content,
242+
style={
243+
"position": "absolute",
244+
"top": 0, "left": 0, "right": 0,
245+
"height": HEADER_HEIGHT,
246+
"z_index": 2,
247+
"transform": [{"translate_y": header_shift}],
248+
},
249+
)
250+
```
251+
252+
The e2e-suite app ships this exact pattern as the "Collapsing header"
253+
demo under Animations.
254+
131255
## Easing
132256

133257
`Animated.timing` accepts an `easing` argument: `"linear"`,

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL