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

feat: overhaul animations, gestures, and interaction props by owenthcarey · Pull Request #19 · pythonnative/pythonnative · GitHub

Repository navigation

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension .gradle  (1) .kt  (2) .md  (6) .plist  (1) .py  (23) .xml  (1) .yaml  (12) .yml  (1) All 8 file types selected
Viewed files
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Unified
Split
Hide whitespace
Diff view
Unified
Split
Hide whitespace
4 changes: 2 additions & 2 deletions README.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ PythonNative is a cross-platform toolkit for building native Android and iOS app
- **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`.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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).
- **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.
- **Direct native bindings:** Python calls platform APIs directly through Chaquopy and rubicon-objc, with no JavaScript bridge.
- **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.
Expand Down
1 change: 1 addition & 0 deletions docs/examples.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ project scaffolded with `pn init`.
| [Forms](examples/forms.md) | `TextInput`, controlled inputs, validation, submit. |
| [Lists](examples/lists.md) | `FlatList`, keyed children, dynamic rendering. |
| [Navigation](examples/navigation.md) | Stack, tab, and drawer navigators side-by-side. |
| [Collapsing header & bottom sheet](examples/collapsing-header.md) | Scroll-driven animation, `Animated.event`, gestures, and stacking. |

## Working from a project

Expand Down
205 changes: 205 additions & 0 deletions docs/examples/collapsing-header.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
# Collapsing header and bottom sheet

This example combines the interaction toolkit into the two patterns
that define "native feel": a collapsing header driven by the scroll
offset, and a draggable bottom sheet that snaps open or closed based
on release velocity. It exercises `Animated.event`,
`Animated.diff_clamp`, `interpolate`, animated operators, `z_index`,
`pointer_events`, and the `Pan` gesture with a spring release.

Paste it into `app/main.py` of a project scaffolded with `pn init`,
then run `pn preview` (or `pn run ios` / `pn run android`).

```python
import pythonnative as pn
from pythonnative import gestures

HEADER_HEIGHT = 64.0
SHEET_HEIGHT = 280.0


@pn.component
def CollapsingHeaderPage():
scroll_y = pn.use_animated_value(0.0)

# The header hides after 64 points of downward travel and comes
# back on any upward travel: diff_clamp tracks scroll deltas, and
# the interpolation maps the clamped total onto a negative shift.
clamped = pn.Animated.diff_clamp(scroll_y, 0.0, HEADER_HEIGHT)
header_shift = clamped.interpolate([0.0, HEADER_HEIGHT], [0.0, -HEADER_HEIGHT])
# Fade the title out during the first half of the travel; "clamp"
# pins the opacity at 0.0 for the rest.
title_opacity = clamped.interpolate(
[0.0, HEADER_HEIGHT / 2], [1.0, 0.0], extrapolate="clamp"
)

rows = [
pn.Text(
f"Row {i}",
style={"padding": 14, "background_color": "#F8FAFC"},
)
for i in range(1, 41)
]

return pn.View(
pn.ScrollView(
pn.Column(
pn.View(style={"height": HEADER_HEIGHT}), # header spacer
*rows,
style={"spacing": 2},
),
on_scroll=pn.Animated.event(y=scroll_y),
style={"flex": 1},
),
pn.Animated.View(
pn.Animated.Text(
"Inbox",
style={
"opacity": title_opacity,
"color": "#FFFFFF",
"font_size": 20,
"font_weight": "700",
},
),
style={
"position": "absolute",
"top": 0, "left": 0, "right": 0,
"height": HEADER_HEIGHT,
"z_index": 2,
"transform": [{"translate_y": header_shift}],
"background_color": "#4F46E5",
"justify_content": "center",
"padding_left": 16,
},
),
style={"flex": 1},
)


@pn.component
def BottomSheet(children=None, open: bool = False, on_close=None):
# 0.0 = fully open, SHEET_HEIGHT = fully hidden.
slide = pn.use_animated_value(0.0 if open else SHEET_HEIGHT)
drag_origin = pn.use_ref(0.0)

def _sync_open():
target = 0.0 if open else SHEET_HEIGHT
pn.Animated.spring(slide, to=target, stiffness=260, damping=24).start()

pn.use_effect(_sync_open, [open])

def on_pan_begin(event):
drag_origin.current = float(slide)

def on_pan_change(event):
# Follow the finger, but never above the open position.
slide.set_value(
max(0.0, min(SHEET_HEIGHT, drag_origin.current + event.translation_y))
)

def on_pan_end(event):
# Fast downward flick or past the midpoint: dismiss.
dismiss = event.velocity_y > 800 or float(slide) > SHEET_HEIGHT / 2
target = SHEET_HEIGHT if dismiss else 0.0
pn.Animated.spring(slide, to=target, stiffness=260, damping=24).start()
if dismiss and on_close is not None:
on_close()

# The scrim fades with the sheet position (derived, no listener),
# and stops intercepting touches entirely while hidden.
scrim_opacity = 1.0 - slide / SHEET_HEIGHT

return pn.View(
pn.Animated.View(
style=[pn.StyleSheet.absolute_fill(), {
"background_color": "#0F172A",
"opacity": scrim_opacity * 0.4,
"pointer_events": "none",
}],
),
pn.Animated.View(
pn.View( # grab handle
style={
"width": 40, "height": 4,
"border_radius": 2,
"background_color": "#CBD5E1",
"align_self": "center",
"margin": {"top": 8, "bottom": 12},
},
),
*(children or []),
gestures=[gestures.Pan(
on_begin=on_pan_begin,
on_change=on_pan_change,
on_end=on_pan_end,
)],
style={
"position": "absolute",
"left": 0, "right": 0, "bottom": 0,
"height": SHEET_HEIGHT,
"z_index": 3,
"transform": [{"translate_y": slide}],
"background_color": "#FFFFFF",
"border_top_left_radius": 16,
"border_top_right_radius": 16,
"padding": 16,
},
),
style=[pn.StyleSheet.absolute_fill(), {"pointer_events": "box_none"}],
)


@pn.component
def App():
sheet_open, set_sheet_open = pn.use_state(False)

return pn.View(
CollapsingHeaderPage(),
pn.View(
pn.Button("Open sheet", on_press=lambda: set_sheet_open(True)),
style={"position": "absolute", "bottom": 24, "right": 16, "z_index": 2},
),
BottomSheet(
children=[
pn.Text("Sheet content", style={"font_size": 17, "font_weight": "600"}),
pn.Text("Drag down to dismiss, or flick it away."),
],
open=sheet_open,
on_close=lambda: set_sheet_open(False),
),
style={"flex": 1},
)
```

## How it works

**Collapsing header.** `Animated.event(y=scroll_y)` writes the scroll
offset into `scroll_y` on every scroll event, with no Python listener
required for the animation itself. `Animated.diff_clamp` turns the
absolute offset into clamped travel, so the header reacts to scroll
*direction* rather than position, matching the behavior of toolbar
hiding in native mail and browser apps. The title opacity is derived
with plain arithmetic on the animated node; both derived nodes update
whenever the driver moves.

**Bottom sheet.** The `Pan` gesture writes the drag position into
`slide` directly, so the sheet tracks the finger with no
reconciliation per frame. On release, the velocity decides between
snapping open and dismissing, and `Animated.spring` handles the
settle. The scrim's opacity is `1.0 - slide / SHEET_HEIGHT`, another
derived node, and `pointer_events` keeps the scrim and the sheet's
wrapper from swallowing touches meant for the page underneath.

**Stacking.** The header, the floating button, and the sheet all use
`position: "absolute"` with `z_index` to define the stacking order
explicitly.

## Related reading

- The [Animations guide](../guides/animations.md) covers
interpolation, operators, `Animated.event`, and `diff_clamp` in
detail.
- The [Gestures guide](../guides/gestures.md) covers `Pan`, `Fling`,
and gesture composition.
- The [Styling guide](../guides/styling.md#interaction-surface)
documents `pointer_events`, `hit_slop`, and `on_layout`.
124 changes: 124 additions & 0 deletions docs/guides/animations.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,130 @@ async def _intro():
`Animated.sequence` runs animations one-after-another. Both are also
awaitable.

`Animated.stagger` is `parallel` with an offset: each animation starts
`delay` milliseconds after the previous one, which is the classic
"cards cascade in" effect:

```python
await pn.Animated.stagger(120, [
pn.Animated.timing(card_a, to=1.0, duration=200),
pn.Animated.timing(card_b, to=1.0, duration=200),
pn.Animated.timing(card_c, to=1.0, duration=200),
])
```

`Animated.loop` repeats any animation (including a `sequence` or
`parallel`). By default it loops forever until you `.stop()` it; pass
`iterations` for a fixed count:

```python
pulse = pn.Animated.loop(
pn.Animated.sequence([
pn.Animated.timing(scale, to=1.15, duration=300),
pn.Animated.timing(scale, to=1.0, duration=300),
]),
).start()
# Later, e.g. when loading finishes:
pulse.stop()
```

## Interpolation

Every animated node has an `interpolate` method that maps an input
range onto an output range. Drive one value and derive as many styled
properties from it as you like; the derived nodes update whenever the
driver moves:

```python
progress = pn.use_animated_value(0.0)

shift = progress.interpolate([0, 1], [0, 120])
color = progress.interpolate([0, 1], ["#6366F1", "#10B981"])
angle = progress.interpolate([0, 1], ["0deg", "90deg"])

pn.Animated.View(
style={
"background_color": color,
"transform": [{"translate_x": shift}, {"rotate": angle}],
},
)
```

Input ranges can have any number of monotonically increasing
breakpoints, and output ranges accept numbers, color strings, or
angle strings (`"deg"` or `"rad"`). The `extrapolate` argument
controls what happens outside the input range: `"extend"` (default)
continues the edge segment linearly, `"clamp"` pins to the edge
output, and `"identity"` passes the input through unchanged.
`extrapolate_left` and `extrapolate_right` set the two sides
independently. Interpolations chain: `node.interpolate(...)` returns
another node you can interpolate again.

## Derived values with operators

Animated nodes support Python arithmetic, so simple math doesn't need
an interpolation:

```python
opacity = progress * 0.5 + 0.5 # 0.5 .. 1.0
inverse = 1.0 - progress
centered = (progress - 0.5) * 2.0
```

`+`, `-`, `*`, `/`, `%`, and unary `-` all work, between nodes and
plain numbers or between two nodes. The result is a read-only node:
bind it into styles like any `AnimatedValue`, but drive the underlying
source value.

One thing to know: a value with derived dependents animates on the
Python ticker rather than the fully native driver, because the graph
has to be re-evaluated per frame to push the derived outputs. The
API and end state are identical; for most UI work the difference isn't
observable.

## Scroll-driven animation with `Animated.event`

`Animated.event` builds an event handler that writes fields from the
event payload straight into `AnimatedValue`s. The classic use is
binding a scroll offset:

```python
scroll_y = pn.use_animated_value(0.0)

pn.ScrollView(
content,
on_scroll=pn.Animated.event(y=scroll_y),
)
```

Each keyword names a payload field (`x` and `y` for scroll events) and
the value it feeds. Pass a positional callable as the first argument
if you also want a plain Python listener to run per event.

`Animated.diff_clamp` pairs naturally with scroll offsets: it tracks
the *change* in its input and clamps the running total, which is
exactly the collapsing-header behavior (hide after N points of
downward travel, reappear on any upward travel):

```python
clamped = pn.Animated.diff_clamp(scroll_y, 0, HEADER_HEIGHT)
header_shift = clamped.interpolate([0, HEADER_HEIGHT], [0, -HEADER_HEIGHT])

pn.Animated.View(
header_content,
style={
"position": "absolute",
"top": 0, "left": 0, "right": 0,
"height": HEADER_HEIGHT,
"z_index": 2,
"transform": [{"translate_y": header_shift}],
},
)
```

The e2e-suite app ships this exact pattern as the "Collapsing header"
demo under Animations.

## Easing

`Animated.timing` accepts an `easing` argument: `"linear"`,
Expand Down
Loading
Loading

Back | FazBrowse Home | New Git URL