Motion¶
Motion is how PythonNative animates. Interaction logic that has to keep up with a finger runs on the platform's UI thread, inside the renderer core, as a worklet: an ordinary, type-checked Python function written in a subset the core compiles, verifies, and interprets. Python never sees the per-frame work, so a sheet follows the finger and settles on a detent while the Python thread is busy rendering, decoding JSON, or importing a module.
import pythonnative as pn
from pythonnative import gestures
CARD = {"padding": 24, "border_radius": 12, "background_color": "#D1FAE5"}
@pn.component
def DraggableCard() -> pn.Node:
x = pn.use_shared_value(0.0)
y = pn.use_shared_value(0.0)
@pn.worklet
def drag(event: gestures.GestureEvent) -> None:
x.value = event.translation_x
y.value = event.translation_y
@pn.worklet
def release(event: gestures.GestureEvent) -> None:
x.value = pn.with_spring(0, velocity=event.velocity_x)
y.value = pn.with_spring(0, velocity=event.velocity_y)
moved = pn.use_animated_style(
lambda: {"transform": [{"translate_x": x.value}, {"translate_y": y.value}]}
)
return pn.View(
pn.Text("Drag me"),
style=[CARD, moved],
gestures=[gestures.Pan(on_change=drag, on_end=release)],
)
drag, release, and the lambda never run on the Python thread. The
core runs drag each time the pan reports a change, which writes x
and y. The animated style read them, so the core runs it again, and
the platform applies the new transform before the frame is drawn. On
release, assigning with_spring(...) starts a spring that the core
advances every display frame.
Motion is PythonNative's counterpart of React Native's Reanimated and
Gesture Handler. It replaced the Animated API; if you're upgrading,
see Migrating from Animated. The design is
recorded in RFC 0009 (rfcs/0009-motion.md in the repository).
The mental model¶
Two threads take part in every animation:
| Thread | Runs |
|---|---|
| Python application thread | Components and hooks; compiling and installing worklets; reading and writing shared values; awaiting animations; run_on_python callbacks |
| Platform UI thread, inside the core | Event and gesture worklets, animation drivers, derived values, reactions, frame callbacks, animated styles, and layout animations |
Python describes the motion: which shared values exist, which worklets read and write them, and which styles they drive. It sends that description to the core behind the commit that renders it, the same way it sends view commands. From then on the core does the work. The UI thread never waits on Python, and it never takes the core's commit lock (see The native bridge).
On every display frame, the core runs, in order:
- animation drivers (
with_timing,with_spring, and the rest), - derived values,
- reactions,
- frame callbacks, and then
- animated styles.
A shared value that changes partway through a frame is seen by everything after it in the same frame, and each binding runs at most once per frame. Events and gestures run their worklets as soon as the platform reports them, and the bindings that depend on what they wrote run before the next frame is drawn.
The core is the same C++ library on iOS, Android, the browser preview, and the host build that pytest loads, so a worklet computes the same result, frame for frame, everywhere it runs.
Worklets¶
A worklet is a function the core can run on the UI thread. There are two ways to write one:
- A function decorated with
@pn.worklet, at module level or inside a component. - An inline
lambdapassed directly to an API that only accepts worklets:use_animated_style,use_derived_value,use_animated_reaction,use_frame_callback, an animation'son_finish=, and a custom layout animation passed toentering=,exiting=, orlayout=.
Event props and gesture callbacks accept either kind of callback. They
run a decorated worklet on the UI thread and anything else, including a
plain lambda, on the Python thread. The decorator is the visible
marker for code that leaves Python's thread, so an existing handler
never moves threads silently.
A worklet is still a Python function. Calling it from Python runs it as Python, so you can unit test its logic directly, and your type checker sees ordinary annotations:
@pn.worklet
def rubber_band(offset: float, limit: float) -> float:
if offset <= limit:
return offset
return limit + (offset - limit) * 0.35
assert rubber_band(150, 100) == 117.5
A worklet compiles the first time it's installed, not when its module is imported. Programs are cached by code object, so a worklet defined inside a component compiles once, however many times the component renders, and each render only sends the captured values that changed.
The language¶
Worklets are written in a subset of Python small enough for the core to verify, bound, and interpret without an interpreter:
| Construct | Supported |
|---|---|
| Parameters | Positional parameters without defaults. Annotations are for your type checker; the core reads event fields by name |
| Literals | int, float, bool, None, str, tuples, lists, and dicts with string keys |
| Names | Parameters, locals, captured closure variables, module globals, and builtins |
| Operators | + - * / // % **; unary -, +, and not; comparisons, including chains, in, and is; and, or, and conditional expressions |
| Statements | Assignment, augmented assignment, tuple unpacking, if/elif/else, for over range(...) or a sequence, break, continue, return, pass, and expression statements |
| Comprehensions | List and tuple comprehensions with one for clause over range(...) or a sequence, with if filters |
| Calls | Other worklets; abs, min, max, round, int, float, bool, and len; math functions and constants; the motion helpers below; run_on_python |
| Attributes | .value on shared values (read and write), .cancel() on a shared value, and fields of event parameters (read) |
| Subscripts | Indexing tuples, lists, strings, and dicts (read) |
Semantics follow Python wherever the subset reaches. // and % floor
toward negative infinity, round rounds half to even, / is true
division, dividing by zero raises ZeroDivisionError, and and or
return an operand, truthiness follows Python's rules, and comparing a
number with a string raises TypeError. Numbers are IEEE doubles that
carry an integer flag, so 7 // 2 is 3.
Everything else is a compile error that names the construct and
suggests an alternative: while, try, with, import, match,
nested def or class, a lambda inside a worklet, global,
nonlocal, raise, assert, yield, await, f-strings, sets,
slices, :=, star arguments, del, item assignment, method calls, and
attribute writes other than .value.
Captures¶
A worklet's free variables and globals are resolved when it's installed, after the component renders:
- Shared values become references to the core's value. Reads and writes go to the UI thread's copy.
- Other worklets are linked into the program, so a worklet can call a helper worklet.
- Supported builtins and helpers (
min,math.sqrt,pn.with_spring,pn.interpolate) become intrinsics the core implements natively. None, numbers, strings, and tuples, lists, and dicts of them become constants, as doEasingcurves andSpringparameters.- Attribute and subscript chains on any other object are evaluated
once, at install, and become constants.
theme.colors.primaryorSNAP_POINTS[2]reads the value that was current when the component rendered.
Anything else, such as a plain Python function or an arbitrary object, can't cross to the UI thread. Captured values are re-read on every render, so a worklet defined in a component always sees that render's props and state.
Errors¶
A construct outside the subset raises
WorkletError with the file,
line, and column when the worklet is installed. Installing happens in
the render or commit that uses the worklet, so the error reaches the
nearest error boundary like any render error:
@pn.worklet
def settle(event: gestures.GestureEvent) -> None:
while offset.value > 0:
offset.value -= 1
@pn.worklet
def label(event: gestures.GestureEvent) -> str:
return event.state.upper()
WorkletError: app/screen.py:9:5: `while` loops can't run on the UI thread; use `for i in range(n)`
WorkletError: app/screen.py:15:12: method calls like `.upper()` aren't supported in worklets
pn lint reports the same
compile errors statically, as PN201, so you see them before you run
the app.
A few errors can only be found at install, when the worklet's captures are resolved:
def log(y: float) -> None:
print(y)
@pn.worklet
def track(event: pn.ScrollEvent) -> None:
log(event.y)
WorkletError: worklet track can't call log on the UI thread; mark it @pn.worklet, or call it with run_on_python()
Mark the helper @pn.worklet if it only computes values, or call it
with run_on_python if it needs
Python.
A worklet that fails while it runs on the UI thread (ZeroDivisionError,
TypeError, a style key it can't animate) disables that one binding;
the rest of the app keeps animating. The failure reaches Python as a
WorkletError whose error_type names the Python exception it raised.
It goes to pn.set_error_handler with
the phase "worklet", and without a handler it's logged once. Every run
also has a budget of 50,000 instructions, so a runaway loop can't stall
a frame; exceeding it is a runtime error too.
Shared values¶
A SharedValue holds an int, a float, a
bool, or a str (a color) that both threads can read and write.
use_shared_value creates one whose
identity is stable for the component's lifetime, like use_ref, and
the core releases it when the component unmounts:
offset = pn.use_shared_value(0.0) # SharedValue[float]
expanded = pn.use_shared_value(False) # SharedValue[bool]
tint = pn.use_shared_value("#2563EB") # colors animate too
From a worklet¶
In a worklet, offset.value reads and writes the UI thread's copy
directly. Writing an animation starts it, and writing a plain value
cancels any animation that's running:
@pn.worklet
def release(event: gestures.GestureEvent) -> None:
if event.velocity_y > 800:
offset.value = pn.with_timing(CLOSED, duration=180)
else:
offset.value = pn.with_spring(OPEN, velocity=event.velocity_y)
From Python¶
Python works with the same value from event handlers, effects, and tasks:
- Reading
offset.valuereturns the latest value the UI thread published. It publishes after each frame and each event, without ever waiting for a reader. A read that follows a write from Python returns the written value until the UI thread has applied it. - Writing
offset.value = 0.0queues the write behind pending mounts, like a view command. Writing an animation (offset.value = pn.with_spring(1)) starts it. - Awaiting
await offset.animate(animation)starts an animation and returnsTrueonce it ran to completion, orFalsewhen something interrupted it. Canceling the awaiting task cancels the animation. - Canceling
offset.cancel()stops a running animation where it is.
@pn.component
def Toast(message: str) -> pn.Node:
opacity = pn.use_shared_value(0.0)
async def show() -> None:
await opacity.animate(pn.with_timing(1, duration=200))
await asyncio.sleep(2)
if await opacity.animate(pn.with_timing(0, duration=300)):
dismiss()
pn.use_effect(show, [message])
faded = pn.use_animated_style(lambda: {"opacity": opacity.value})
return pn.View(pn.Text(message), style=[styles.toast, faded])
To run animations in parallel and wait for all of them, gather the awaits:
await asyncio.gather(
x.animate(pn.with_spring(0)),
scale.animate(pn.with_timing(1, duration=250)),
)
Reading .value during render is allowed, but it isn't reactive: a
render that reads it doesn't run again when the value changes. Read
shared values in worklets, such as an animated style, instead. pn lint
reports a read during render as
PN203.
Animations¶
Six functions build animations. Each returns a frozen descriptor that
you assign to a shared value, in a worklet or in Python, or pass to
await shared.animate(...). Inside a worklet the same calls build the
same descriptors in the core.
pn.with_timing(1.0, duration=300, easing=pn.Easing.ease_out)
pn.with_spring(0.0, velocity=event.velocity_y, damping=20, stiffness=200)
pn.with_decay(velocity=event.velocity_x, clamp=(-200, 0))
pn.with_delay(150, pn.with_timing(1.0))
pn.with_sequence(pn.with_timing(1.2, duration=120), pn.with_spring(1.0))
pn.with_repeat(pn.with_timing(360, duration=1000, easing=pn.Easing.linear), times=None)
| Function | What it does |
|---|---|
with_timing(to, *, duration=300, easing=None) |
Moves to to over duration milliseconds along easing (ease-in-out by default) |
with_spring(to, *, ...) |
Springs to to, starting at velocity units per second |
with_decay(*, velocity, deceleration=0.998, clamp=None, rubber_band_factor=0.6) |
Coasts from the current value at velocity until it stops; with clamp=(lower, upper), it rubber-bands back from a bound it crosses, or stops at it when rubber_band_factor is 0 |
with_delay(delay_ms, animation, *, reduce_motion="system") |
Starts animation after a delay |
with_sequence(*animations) |
Runs animations one after another, each from where the last ended |
with_repeat(animation, *, times=None, reverse=False, reduce_motion="system") |
Repeats times times (at least 1), or forever when times is None; with reverse, every other run heads back |
with_timing and with_spring animate numbers and colors; the other
drivers animate numbers. Velocities are in units per second, so a
gesture's velocity_x and velocity_y carry over directly. A spring
that's still moving when another animation replaces it hands over its
velocity, so retargeting a fling feels continuous.
Every driver takes on_finish, a worklet that receives
finished: bool on the UI thread (False when the animation was
interrupted). To get back to Python from there, call
run_on_python.
Springs¶
with_spring follows React Native's spring
model. Tune it in one of two ways:
- By feel:
duration(milliseconds) withdamping_ratio. The spring settles in about that time;damping_ratio=1doesn't overshoot, and lower values bounce. - By physics:
damping,stiffness, andmass(10, 100, and 1 by default).
overshoot_clamping=True stops the spring at its target instead of
letting it bounce past, and rest_displacement and rest_speed set
when it counts as settled. pn.Spring holds the
same parameters as a value, for the layout animations and shared-element
transitions that accept spring=:
Easing¶
pn.Easing holds the curves with_timing
accepts. They're data, so they cross to the core and evaluate the same
way there and in Python:
| Curve | Shape |
|---|---|
Easing.linear |
t |
Easing.ease, Easing.ease_in |
bezier(0.42, 0, 1, 1) |
Easing.ease_out |
bezier(0, 0, 0.58, 1) |
Easing.ease_in_out |
bezier(0.42, 0, 0.58, 1), the default |
Easing.quad |
t * t |
Easing.cubic |
t * t * t |
Easing.bounce |
Penner's bounce-out |
Easing.bezier(x1, y1, x2, y2) |
A CSS cubic Bézier; y values may overshoot |
Easing.custom(fn) |
Any progress -> eased Python function, sampled at 64 points that the core interpolates |
A name such as "ease_out" works wherever a curve does, and a
misspelled name raises ValueError. Where UIKit runs the animation
itself (a list item's insert and remove transitions and a shared-element
flight on iOS), bounce isn't a Bézier it can take, so it plays as
ease_out.
Reduce motion¶
Every animation except with_sequence, and every layout animation,
takes reduce_motion: "system" (the default) follows the platform's
accessibility setting, "always" always reduces, and "never" ignores
the setting. On Android, turning system animations off counts as reduce
motion. A reduced animation jumps to its final value and still reports
completion, so code that awaits it, or waits for its on_finish, keeps
working. A reduced with_delay skips its delay, and a reduced
with_repeat lands where its last run would end (an endless one stays
put). A sequence and a delay leave their animations to their own
settings, while a repeat that isn't reduced runs every iteration in
full. To branch on
the setting in a render, read
use_reduce_motion.
Helpers¶
Four helpers are plain functions in Python and intrinsics in the core, so a worklet computes the same result in both places:
interpolate(x, input_range, output_range, extrapolate="extend")mapsxpiecewise linearly. Outside the input range,"extend"continues the nearest segment,"clamp"holds the end value, and"identity"returnsx; pass a(left, right)pair to choose each side separately.interpolate_color(x, input_range, colors)blends between colors, clamped at the ends.clamp(x, lower, upper)limitsxto a range.snap_point(value, velocity, points)returns the point a release lands nearest, projected a fifth of a second ahead with its velocity, so a flick carries past the nearest point the way a sheet or carousel settles.
Hooks¶
Five hooks connect a component to the core. None of them takes a dependency list: their worklets' captures are compared on every render, and only the ones that changed are sent.
pn.use_shared_value(initial) # SharedValue[T]
pn.use_derived_value(worklet) # read-only SharedValue[T]
pn.use_animated_style(worklet) # AnimatedStyle
pn.use_animated_reaction(prepare, react) # None
pn.use_frame_callback(worklet, active=True) # None
use_derived_value re-runs its worklet
whenever a shared value it read changes, and publishes the result as a
read-only shared value that other worklets, and Python, can read:
use_animated_style returns an
AnimatedStyle that goes in any built-in
component's style list, after the static styles it overrides. The core
runs the worklet once when the view mounts, before its first frame is
drawn, and again whenever a shared value it read changes:
header = pn.use_animated_style(lambda: {
"opacity": pn.interpolate(progress.value, (0, 1), (1, 0.6)),
"transform": [{"scale": pn.interpolate(progress.value, (0, 1), (1, 0.9))}],
})
return pn.View(title, style=[styles.header, header])
One animated style can drive several views.
use_animated_reaction runs
react(current, previous) on the UI thread whenever the result of
prepare changes. It's the way to respond to a crossing, typically by
calling back into Python:
pn.use_animated_reaction(
lambda: offset.value > DISMISS_DISTANCE,
lambda past, _before: pn.run_on_python(set_armed, past),
)
use_frame_callback runs a worklet
every display frame while active is true. It receives a
FrameInfo with timestamp_ms and
delta_ms:
@pn.worklet
def spin(frame: pn.FrameInfo) -> None:
angle.value = (angle.value + frame.delta_ms * 0.36) % 360
pn.use_frame_callback(spin, active=playing)
Animatable properties¶
An animated style can write two kinds of style keys:
- Paint properties are applied by the platform on the UI thread, in
the same frame:
opacity,transform,background_color,color,border_colorand its sides,border_radiusand its corners,shadow_color,shadow_offset,shadow_opacity,shadow_radius,elevation,tint_color,placeholder_color,text_shadow_color,text_shadow_offset,text_shadow_radius, andz_index. - Layout properties are every key the layout engine reads except
display,position,direction, andoverflow:width,height, themin_*andmax_*sizes,margin*,padding*,top,left,right,bottom,flex_grow,flex_shrink,flex_basis,gap,border_width,aspect_ratio, and the rest. The core posts them to its layout worker, which updates Yoga and sends the changed frames through the normal mount queue.
Any other key, such as font_size or font_family, is a runtime error
that disables the binding.
Prefer transforms. A layout property lands a frame behind the
worklet that wrote it and costs a layout pass every frame, and every
sibling it pushes moves too. A paint property lands in the same frame
and costs nothing beyond drawing. For anything that tracks a finger or
runs every frame, animate transform (translate_x, translate_y,
scale, rotate) and opacity. Keep layout properties for changes
that should reflow their neighbors, like an accordion opening, and
consider a layout= animation for those, which
animates the frame instead of the style.
Precedence is the same on every platform. A key written by an
animated style replaces the same key from the static style, including
the whole transform list, so an animated transform must include any
static entries you want to keep. When a re-render removes the animated
style, or its worklet stops returning a key, the platform restores the
static value:
lifted = pn.use_animated_style(lambda: {"transform": [{"scale": 1.05}]} if pressed.value else {})
pn.View(style=[{"transform": [{"rotate": 10}]}, lifted]) # rotate 10, or scale 1.05 while pressed
Views with an animated style, entering, exiting, layout,
shared_tag, gestures, or worklet event props are never flattened away
(see View flattening).
Event worklets¶
Any on_* event prop takes a worklet. The core runs it synchronously
when the platform reports the event, and the event isn't sent to
Python. That's most useful for continuous events: on_scroll,
on_layout, on_content_size_change, and a Pressable's press-state
events. A prop holds either a worklet or a Python callback, not both.
scroll_y = pn.use_shared_value(0.0)
@pn.worklet
def track(event: pn.ScrollEvent) -> None:
scroll_y.value = event.y
header = pn.use_animated_style(lambda: {
"transform": [{"translate_y": -pn.clamp(scroll_y.value, 0, 120)}],
"opacity": pn.interpolate(scroll_y.value, (0, 120), (1, 0), extrapolate="clamp"),
})
return pn.Column(
pn.View(title, style=[styles.header, header]),
pn.ScrollView(*rows, on_scroll=track),
)
The event parameter has the same fields as the typed payload a Python
handler receives (ScrollEvent,
LayoutEvent, and so on). The
collapsing header example builds a
complete scroll-driven header this way.
Gesture worklets¶
Gesture callbacks (on_begin, on_change, on_end, on_tap, and the
rest) take worklets the same way. A recognizer can mix them, for
example a worklet on_change that moves a view with a Python on_end
that saves where it landed:
The worklet receives a
GestureEvent with the same field
names a Python callback sees. Gesture worklets run on every platform: on
Android, in the browser preview, and in tests the core's own gesture
engine calls them, and on iOS UIKit's recognizers report through the
core, which runs them before anything reaches Python. The
Gestures guide has more patterns.
Calling Python from a worklet¶
run_on_python(callback, *args) queues
callback(*args) on the Python thread:
@pn.worklet
def release(event: gestures.GestureEvent) -> None:
if event.translation_x < -DISMISS_DISTANCE:
x.value = pn.with_timing(-WIDTH, duration=180)
pn.run_on_python(on_dismiss, item_id)
else:
x.value = pn.with_spring(0, velocity=event.velocity_x)
The callback must be a name the worklet captures, such as a prop, a
state setter, or a function defined in the component; the core holds a
reference to it, not the function itself. Arguments must be values a
worklet can produce: numbers, strings, booleans, None, and tuples,
lists, and dicts of them. Calls made in one frame are delivered in
order. Called from Python (in a test, say), run_on_python calls the
callback directly.
Calls aren't coalesced, so a worklet that calls Python every frame floods the Python thread. Call it when something happens, such as a release or a threshold crossing, not on every change.
Layout animations¶
Every built-in view takes three layout animation props, declared once
in MotionProps:
from pythonnative.motion import FadeIn, FadeOut, LinearTransition
pn.View(
pn.Text(entry.title),
entering=FadeIn(duration=200),
exiting=FadeOut(duration=150),
layout=LinearTransition(spring=pn.Spring(damping_ratio=0.8, duration=350)),
).with_key(entry.id)
enteringruns when the view is created. Inside a subtree created in the same commit, only the outermost entering animation runs.exitingruns when the view is removed. Only the outermost removed view's exit animation runs, and its whole subtree goes with it.layoutanimates the view from its old frame to its new one when a commit moves or resizes it, never on its first layout. Its children take their new frames immediately. A new frame during the animation retargets from where the view is.
The presets live in pythonnative.motion. Each
is a frozen dataclass with duration (300 milliseconds by default),
delay, easing or spring, and reduce_motion:
| Use | Presets |
|---|---|
entering |
FadeIn, SlideIn(edge=...), ZoomIn |
exiting |
FadeOut, SlideOut(edge=...), ZoomOut |
layout |
LinearTransition (move and resize), FadingTransition (fade out at the old frame, then in at the new one) |
SlideIn and SlideOut move past the window's "left", "right"
(the default), "top", or "bottom" edge.
Keyframes¶
Keyframes animates through styles at
points of progress from 0 to 1. Numbers and colors blend, and
transform lists blend entry by entry when their shapes match. Keys go
back to the view's own style when it finishes. easings sets the curve
leading into a keyframe:
from pythonnative.motion import Keyframes
POP = Keyframes(
{
0.0: {"opacity": 0, "transform": [{"scale": 0.8}]},
0.6: {"opacity": 1, "transform": [{"scale": 1.05}]},
1.0: {"transform": [{"scale": 1.0}]},
},
duration=400,
easings={1.0: pn.Easing.ease_out},
)
pn.View(badge, entering=POP)
Custom layout animations¶
For anything else, pass a worklet (a decorated function or an inline
lambda). It receives LayoutValues
(where the view ends, where it is, and the window's size) and returns
{"initial": {...}, "animate": {...}}: a starting style, and an
animation or value per key. Frame keys are x, y, width, and
height. Transform channels (translate_x, translate_y, scale,
rotate, and the rest) are keys of their own, composed after the
view's own transform:
from pythonnative.motion import LayoutValues
@pn.worklet
def drop_in(values: LayoutValues) -> dict:
return {
"initial": {"opacity": 0, "translate_y": -values.target_height},
"animate": {
"opacity": pn.with_timing(1, duration=200),
"translate_y": pn.with_spring(0, damping_ratio=0.7, duration=400),
},
}
pn.View(card, entering=drop_in)
Turning them off for a subtree¶
LayoutAnimationConfig
wraps children in a View that turns layout animations off inside it.
skip_entering=True skips the entering animations of views mounted
along with it, such as a screen's first rows, so a screen doesn't
animate every row in when it opens. skip_exiting=True removes views
inside it at once:
from pythonnative.motion import LayoutAnimationConfig
LayoutAnimationConfig(*rows, skip_entering=True, style={"flex": 1})
How exits work¶
The component that renders an exiting view unmounts at once, as in
React Native: its effects are cleaned up, its refs are cleared, and its
events stop. Only the native view stays behind. When a commit removes
views, the core finds the outermost removed view that has exiting and
sends the platform an exit operation for it instead of a delete.
The platform leaves the view where it is, stops routing touches to it,
and leaves it out of the child indices later insertions use. The core
runs the exit animation and, when it finishes, tells the platform to
destroy the subtree.
So an exiting row keeps its last frame while it fades, and a new row
inserted beside it lands in the right slot. Layout no longer counts the
exiting view, so its neighbors take their new places right away,
animating there if they have a layout animation. View tags are never
reused, so a view that's still animating out can't be confused with a
new one.
Leaving stack screens and the content of a closing Modal use the same
mechanism, owned by the platform instead of an animation: the platform
keeps the screen through its native transition and then releases it.
That's why a popped screen keeps its content while it slides away.
List items¶
Rows of a FlatList or
SectionList belong to the native list,
which recycles them as you scroll, so list-level props animate them
instead of the row's own entering and exiting:
from pythonnative.motion import FadeIn, FadeOut, LinearTransition
pn.FlatList(
data=entries,
render_item=lambda entry, index: EntryRow(entry),
key_extractor=lambda entry, index: entry.id,
item_entering=FadeIn(duration=200),
item_exiting=FadeOut(duration=150),
item_layout=LinearTransition(),
)
item_entering, item_exiting, and item_layout take one of the
presets (not Keyframes or a custom worklet, which raise TypeError).
They run only when the data changes: rows that appear because you
scrolled, or that the list recycled, never animate. Each platform runs
them with its list's own machinery: UIKit's collection view layout
attributes and batch updates on iOS, a RecyclerView.ItemAnimator on
Android, and FLIP animations on row elements in the browser preview.
Views created along with a list row don't run their own entering
animations, so scrolling a recycled cell into view never triggers one.
Shared-element transitions¶
Give a view on the screen you push from and a view on the screen you
push to the same shared_tag. During the push, and the pop back, the
element flies between them while the screens slide:
from pythonnative.motion import SharedTransition
# The journal row
pn.Image(photo, shared_tag=f"photo-{entry.id}", style=styles.thumbnail)
# The entry screen
pn.Image(
photo,
shared_tag=f"photo-{entry.id}",
style=styles.hero,
shared_transition=SharedTransition(spring=pn.Spring(damping_ratio=0.9, duration=450)),
)
When a stack starts a push or a pop, the platform asks the core for the
views the two screens share. For each pair it hides the source, lifts
the destination view into an overlay at the source's place on screen,
and moves and resizes it to where it belongs, blending border_radius,
background_color, and opacity from the source's style to its own.
At the end the destination returns to its screen and the source shows
again. The destination is the real view, so a loaded image or live
text is what the flight lands on, with no cross-fade.
SharedTransition customizes
the flight: duration and easing for a timed flight, or spring, and
props for which paint properties blend. Set it on the destination (or
the source). Without one, the flight matches the platform's push.
- iOS recreates the stack's slide in a custom transition, and the back swipe from the screen's edge drives the flight interactively; a canceled swipe flies the element back. On iOS 26, a back swipe that starts mid-screen doesn't pop a screen with shared elements.
- Android times the fragment transition to match and flies elements in the stack container's overlay. Transitions aren't interactive.
- The browser preview aligns its screen transition with the same timing and flies elements in the overlay layer.
Shared elements cover pushes and pops within one stack, including
card-style groups. Tabs, Modal, modal presentations, and the drawer
aren't covered.
Components built on Motion¶
These components are written in Python with worklets and the public
Motion API alone, so their source in components/bottom_sheet.py,
components/swipeable.py, and navigation/navigators.py is a good
reference for your own.
BottomSheet¶
BottomSheet rises from the bottom of the
window and snaps to detents. It presents in a
Portal above the screen, with a grab handle and
an optional dimmed backdrop:
@pn.component
def Inbox() -> pn.Node:
sheet = pn.use_ref()
detent, set_detent = pn.use_state(0)
return pn.View(
MessageList(),
pn.Button("Filters", on_press=lambda: sheet.current.expand()),
pn.BottomSheet(
FilterPanel(),
snap_points=(240, "50%", "90%"),
index=0, # -1 starts closed
on_change=set_detent,
on_close=lambda: set_detent(-1),
ref=sheet,
),
style={"flex": 1},
)
Dragging the handle follows the finger. On release, the sheet settles
on the detent that snap_point projects from
the release velocity, rubber-banding past the highest detent and
closing below the lowest one when close_on_drag_down is set (the
default). Snap points are heights in points or percentages of the
window, ordered by the height they come to on the current window, with
indexes counting from the lowest. Whenever the sheet settles, after a
drag, a change to index, a call on the
BottomSheetHandle in ref
(snap_to(index), expand(), collapse(), and close()), or a tap on
the backdrop, on_change receives the detent's index, or on_close runs
if the sheet closed. Tapping the backdrop closes the sheet, or collapses
it when close_on_drag_down is off.
With handle=False, the whole sheet is the drag target. Content that
scrolls should leave the handle on, since dragging the sheet from inside
a scrolled ScrollView isn't supported.
Swipeable¶
Swipeable slides a row aside to reveal
actions behind it:
pn.Swipeable(
EntryRow(entry),
right_actions=pn.Pressable(pn.Icon("trash-2"), on_press=lambda: delete(entry.id)),
right_width=88,
on_open=lambda side: pn.Haptics.selection(),
)
Its pan activates only on horizontal movement and fails on vertical
movement, so a Swipeable row works inside a vertical list. On
release, the row opens when it's past threshold (half the actions'
width by default), counting where the release velocity would carry it,
and springs closed otherwise. friction above 1 makes the row lag
behind the finger, and overshoot lets it travel past the actions while
dragging. Once the row settles somewhere new, on_open receives "left"
or "right", or on_close runs; a drag that springs back where it
started reports nothing. The
SwipeableHandle in ref has
open_left(), open_right(), and close().
The drawer¶
DrawerNavigator keeps its menu
mounted and follows the finger: swipe from the left edge to pull it
open, and drag it, or tap the dimmed screen, to close it. A spring
settles it either way.
Drawer = pn.DrawerNavigator(
pn.Screen(FeedScreen, title="Feed"),
pn.Screen(SettingsScreen, title="Settings"),
drawer_width=280,
drawer_type="slide", # "front" (the default) slides over the screen
swipe_edge_width=32, # how far from the edge a swipe can start
swipe_enabled=True,
)
See Navigation for the drawer's navigation handle.
Testing¶
Tests render into the host build of the renderer core, so worklets run
in the same VM they run in on a device, and the
virtual clock drives the core's frame clock.
Animations, layout animations, and exits advance with
clock.advance(...) and finish without sleeping. Three helpers on
RenderResult read what the
platform would show:
drag(target, dx=0, dy=0, *, duration=0.25, steps=10, release=True)sends a pointer stream through the core's gesture engine: down at the view's center,stepseven moves overdurationseconds (which sets the release velocity), and up unlessreleaseisFalse. Worklet callbacks run in the core and Python callbacks run in the test. UIKit's recognizers aren't simulated.presented_style(target)returns the view's style as the platform shows it: committed props with motion's writes on top.presented_frame(target)returns its frame, including layout animations.
fire(target, "on_scroll", {...}) delivers an event to a worklet
handler through the core, just as it delivers one to a Python handler.
import pythonnative as pn
from pythonnative.testing import FakeClock, render
def test_sheet_snaps_open(pn_clock: FakeClock) -> None:
changes: list[int] = []
tree = render(pn.View(pn.BottomSheet(pn.Text("filters"), snap_points=(200, 400), on_change=changes.append)))
handle = tree.get_by_role("adjustable")
surface = handle.parent
tree.drag(handle, dy=-150, duration=0.3)
pn_clock.advance(2.0)
tree.settle()
assert changes == [1]
assert tree.presented_style(surface)["transform"] == [{"translate_y": 0}]
@pn.component
def Feed() -> pn.Node:
scroll_y = pn.use_shared_value(0.0)
@pn.worklet
def track(event: pn.ScrollEvent) -> None:
scroll_y.value = event.y
fade = pn.use_animated_style(
lambda: {"opacity": pn.interpolate(scroll_y.value, (0, 120), (1, 0), "clamp")}
)
return pn.Column(
pn.View(style=[{"height": 40}, fade], test_id="header"),
pn.ScrollView(pn.View(style={"height": 1000}), on_scroll=track),
)
def test_header_fades_as_the_list_scrolls(pn_clock: FakeClock) -> None:
tree = render(Feed())
scroll = next(view for view in tree.views() if view.type_name == "ScrollView")
tree.fire(scroll, "on_scroll", {"x": 0, "y": 60})
assert tree.presented_style(tree.get_by_test_id("header"))["opacity"] == 0.5
An exiting view leaves the component tree at once, so a query no longer
finds it, while its native view stays in tree.backend.native.views
until the exit animation finishes. Pure worklet logic needs no render at
all: call the worklet as a function.
Release builds¶
Release builds ship sourceless bytecode, so a worklet can't be compiled
from its source on the device. Before staging drops the sources,
pn build (and pn run --release) compiles every @pn.worklet function, and every
inline lambda passed to a worklet-only API or to on_finish=,
entering=, exiting=, or layout=, into a table named
pn_worklets.json beside the app. At run time each worklet is found in
the table by its file, line, and name, and a lambda by where its body
starts, so several lambdas on one line are told apart. A worklet that
doesn't compile fails the build with every error listed:
Some worklets can't run on the UI thread:
app/screens/journal.py:42:9: `while` loops can't run on the UI thread; use `for i in range(n)`
That's why the worklet-only APIs require the decorator or an inline
lambda. A plain function would compile from its source in development
but wouldn't be in the release table, so they raise TypeError for one
in every build, and pn lint reports it as
PN202.
Migrating from Animated¶
Animated was removed, with no compatibility layer: pn.Animated
raises AttributeError. Each old pattern has a direct replacement:
Animated |
Motion |
|---|---|
v = pn.use_animated_value(0.0) |
v = pn.use_shared_value(0.0) |
pn.Animated.View(style={"opacity": v}) |
pn.View(style=[base, pn.use_animated_style(lambda: {"opacity": v.value})]) |
pn.Animated.Text, pn.Animated.Image |
pn.Text, pn.Image, or any built-in component, with an animated style |
v.set_value(1.0), float(v), v.value |
v.value = 1.0, v.value |
pn.Animated.timing(v, to=1, duration=300).start() |
v.value = pn.with_timing(1, duration=300) |
result = await pn.Animated.timing(...), result.finished |
finished = await v.animate(pn.with_timing(...)) |
pn.Animated.spring(v, to=0, initial_velocity=vy) |
pn.with_spring(0, velocity=vy) |
pn.Animated.decay(v, velocity=event.velocity_x / 1000) |
pn.with_decay(velocity=event.velocity_x) (units per second, not per millisecond) |
pn.Animated.sequence([a, b]) |
pn.with_sequence(a, b) |
pn.Animated.parallel([a, b]) |
await asyncio.gather(x.animate(a), y.animate(b)), or assign each value |
pn.Animated.stagger(50, [...]) |
pn.with_delay(i * 50, animation) for each value |
pn.Animated.delay(200) in a sequence |
pn.with_delay(200, animation) |
pn.Animated.loop(animation, iterations=3) |
pn.with_repeat(animation, times=3) (times=None repeats forever) |
handle.stop(), v.stop_animation() |
v.cancel() |
v.interpolate([0, 1], [0, 100]) |
pn.interpolate(v.value, (0, 1), (0, 100)) in a worklet |
a + b, 1.0 - v / height |
Plain arithmetic in use_animated_style or use_derived_value |
pn.Animated.diff_clamp(v, 0, 64) |
A scroll worklet that keeps its own clamped total (see the collapsing header) |
on_scroll=pn.Animated.event(y=scroll_y) |
on_scroll=track, where @pn.worklet def track(event): scroll_y.value = event.y |
v.add_listener(...) |
pn.use_animated_reaction(...) with pn.run_on_python(...) |
easing=lambda t: ... |
easing=pn.Easing.custom(lambda t: ...) |
Gesture callbacks that call set_value |
@pn.worklet gesture callbacks that write .value |
ANIMATABLE_PROPS (paint-only) |
Paint and layout properties; see Animatable properties |
pn.Easing and its curves are unchanged. EasingSpec, the type of a
curve, is no longer exported from pythonnative; import it from
pythonnative.motion.
Next steps¶
- Scroll-driven motion end to end: Collapsing header and bottom sheet.
- Drags, flings, and composition: Gestures.
- API reference: Motion.