| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -39,8 +39,8 @@ PythonNative is a cross-platform toolkit for building native Android and iOS app | |||
| 39 | 39 | - **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`. | |
| 40 | 40 | - **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. | |
| 41 | 41 | - **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). | ||
| 44 | 44 | - **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. | |
| 45 | 45 | - **Direct native bindings:** Python calls platform APIs directly through Chaquopy and rubicon-objc, with no JavaScript bridge. | |
| 46 | 46 | - **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. | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -13,6 +13,7 @@ project scaffolded with `pn init`. | |||
| 13 | 13 | | [Forms](examples/forms.md) | `TextInput`, controlled inputs, validation, submit. | | |
| 14 | 14 | | [Lists](examples/lists.md) | `FlatList`, keyed children, dynamic rendering. | | |
| 15 | 15 | | [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. | | ||
| 16 | 17 | ||
| 17 | 18 | ## Working from a project | |
| 18 | 19 | ||
| Original file line number | Diff line number | Diff 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`. | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -128,6 +128,130 @@ async def _intro(): | |||
| 128 | 128 | `Animated.sequence` runs animations one-after-another. Both are also | |
| 129 | 129 | awaitable. | |
| 130 | 130 | ||
| 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 | + | ||
| 131 | 255 | ## Easing | |
| 132 | 256 | ||
| 133 | 257 | `Animated.timing` accepts an `easing` argument: `"linear"`, | |
| Back | FazBrowse Home | New Git URL |
0 commit comments