Native views¶
The reconciler doesn't know what a Text or a Button is. It produces
a flat list of mutation ops (create, update, insert, remove,
destroy, set-frame) that reference views by integer tag, and hands
the whole list to the
NativeViewRegistry in
a single
apply_mutations
call per commit.
On a device that registry is the
BridgeBackend:
it serializes the batch and sends it across the
native bridge in one call, where a Swift or Kotlin
component manager per element type creates and updates the real
views. The browser preview is the same path with a WebSocket
transport: the page applies the transactions with DOM elements. In
tests the registry dispatches to an in-memory fake.
This page describes that boundary, walks through what a component
manager does, and covers the fake backend used by pytest.
The commit protocol¶
Every commit is one transaction: an ordered list of ops from
pythonnative.mutations, applied atomically from the perspective of
the render loop.
| Op | Meaning |
|---|---|
CreateOp(tag, type_name, props) |
Create a native view for tag. Props are already clean: callables have been routed to the event registry. |
UpdateOp(tag, changed_props) |
Apply only the props that changed (UNSET removes a prop; None is explicit null). |
InsertOp(parent_tag, child_tag, index) |
Place the child at index (move-aware: an attached child is repositioned, not duplicated). |
DestroyOp(tag) |
Release the native view (detaching it from its parent) and drop the tag record. |
SetFrameOp(tag, x, y, w, h) |
Apply a frame from headless layout or an explicit frame operation. Bridge renderers compute ordinary layout beside their widgets. |
Tags matter because the diff phase is pure: it runs before any native
view exists, so ops can't reference views directly. Tags also give the
native side a stable identity for event routing and animation
bookkeeping, and the flat op list is what makes a single crossing per
commit possible. On the wire each op is a short JSON array
(["c", tag, "Text", {...}]); see Transactions.
Component managers¶
On device every element type is implemented by a Swift
PNComponentManager (in PythonNativeKit) and a Kotlin
ComponentManager (in the pythonnative Gradle module). One manager
instance serves every view of its type; per-view state lives on the
view. The hooks mirror the op list:
| Hook | When it runs |
|---|---|
makeView / createView(tag, props) |
Once per c op. Builds the platform view and applies initial props. |
apply(props, initial) |
On create (full props) and on every u op (changed keys only; removed props arrive as null). |
insertChild(parent, child, index) |
On i. Move-aware and clamped. |
removeChild(parent, child) |
When a child is detached. |
destroy(view) |
On d. Unwires gestures and animations, then removes the view. |
setFrame(view, x, y, w, h) |
On f. Frames are points (iOS) or dp (Android). |
measure(view, maxW, maxH) |
Synchronously, when the layout engine needs a content-derived size. |
command(view, name, args) |
For imperative actions (focus, scroll_to_offset, ...). |
startAnimation / cancelAnimation |
For natively driven Animated values. |
Yoga interprets flex, margin, and padding props in the native renderer. Managers provide intrinsic measurements and apply computed frames.
Managers fire events by tag through PNEvents.emit(view, "on_change",
[value]) (Swift) or PNEvents.fire(view, "on_change", value)
(Kotlin). The bridge routes them to
dispatch_event.
Events never cross the bridge as callables¶
Callable props (on_press, on_change, ...) are stripped before a
CreateOp/UpdateOp is built and registered in the process-wide
EventRegistry keyed by
(tag, name). The native payload carries only _pn_events (the list
of event names present) so managers can wire expensive listeners
(scroll delegates, gesture recognizers) conditionally.
The payoff: a re-render that only changes a callback's identity (every lambda is a fresh object) costs zero native calls. The registry swaps the Python-side callback and the already-wired native listener picks it up on the next dispatch.
The registry¶
The NativeViewRegistry
protocol is what the reconciler talks to. The implementation is chosen
lazily by get_registry:
- On iOS and Android, a
BridgeBackendforwards every transaction, measurement, command, and animation request to native. - In the browser preview (
pn preview, withPN_PLATFORM=web), the sameBridgeBackendcommits through aWebTransportto the page, which applies them with DOM elements. See the Browser preview guide. - Under
pytest, the backend is replaced with a fake viaset_registry(or by constructing theReconcilerwith the fake directly).
The renderer validates an entire commit before applying it. A rejected commit fails the surface, and further incremental updates require a reset and remount. See Commits for the failure contract.
Layout and styling¶
Layout-related style keys are interpreted by the renderer's Yoga engine. The full
list (sizing, flex, position, margin, padding, spacing, ...) is
documented in Component properties.
The set of keys the layout engine consumes is exposed as
pythonnative.layout.LAYOUT_STYLE_KEYS.
Managers only deal with visual properties: colors, fonts, borders,
corner radii, image scaling, and text content. After each commit the
renderer computes layout and returns changed frames to Python.
Headless backends use pythonnative.layout and SetFrameOp instead.
On each platform that boils down to:
- iOS: every container is a
PNContainerView(a plainUIView) withtranslatesAutoresizingMaskIntoConstraintson;setFramesetsbounds.sizeandcenterso transforms and scroll offsets survive. Leaf managers implementmeasureviasizeThatFits. Shared visual props are applied byPNViewStyler. - Android: every container is a
PNFrameLayout;setFrameconverts dp to pixels and positions the view throughMarginLayoutParams. Leaf managers implementmeasurewithView.measure(...)andMeasureSpec. Shared visual props are applied byViewStyler.
Yoga provides shared layout rules. Native font metrics and control sizes can produce different intrinsic measurements on Android and iOS.
Children¶
Children of a container element become subviews of the corresponding
native view. The reconciler determines insertion order (and reorders
on key change) and expresses it as InsertOp / DestroyOp; the
manager performs the native mutation (insertSubview(_:at:) on iOS,
addView(child, index) on Android). InsertOp is move-aware, which
is how keyed reorders avoid recreating views.
Testing without a device¶
The test suite never loads Swift or Kotlin. It uses
FakeBackend, an in-memory
backend implementing the same mutation protocol while keeping a real
tree of FakeView objects.
render wires it up:
from pythonnative.testing import render
result = render(MyComponent())
assert result.get_by_text("Hello")
assert result.backend.ops_of("create") # every applied op is recorded
The fake raises on malformed transactions, including unknown tags and double destroys, so tests expose invalid mutation sequences. See the Testing guide.
The bridge itself is tested with
FakeTransport, which
decodes the JSON transactions the BridgeBackend produces and keeps a
view tree the way native would. Native decoders and managers have
their own XCTest and JUnit suites inside the native libraries.
Custom widgets¶
Adding a widget means a Swift manager, a Kotlin manager, and a Python
registration; the pythonnative.sdk module gives you
a type-checked entry point for the Python half:
- Define a frozen
Propsdataclass listing the widget's API surface. - Implement
PNComponentManager/ComponentManagersubclasses and register them under the element name from aPNPluginentry. - Call
register_component(or decorate a desktopViewHandlerwith@native_component) and hand callers anelement_factory.
After registration the reconciler treats the new element like any
other. pn build compiles the plugin's native sources into the app and
PyPI packages register automatically through entry points. See the
Custom native components guide
for the full walkthrough.
Next steps¶
- The wire protocol: The native bridge.
- Browse the API: Native views.
- Read the Layout engine concept page to understand how
SetFrameOps are produced. - See how the reconciler drives the backend: Reconciliation.
- Wrap a device API instead of a widget: Native modules guide.