Skip to content

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 BridgeBackend forwards every transaction, measurement, command, and animation request to native.
  • In the browser preview (pn preview, with PN_PLATFORM=web), the same BridgeBackend commits through a WebTransport to the page, which applies them with DOM elements. See the Browser preview guide.
  • Under pytest, the backend is replaced with a fake via set_registry (or by constructing the Reconciler with 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 plain UIView) with translatesAutoresizingMaskIntoConstraints on; setFrame sets bounds.size and center so transforms and scroll offsets survive. Leaf managers implement measure via sizeThatFits. Shared visual props are applied by PNViewStyler.
  • Android: every container is a PNFrameLayout; setFrame converts dp to pixels and positions the view through MarginLayoutParams. Leaf managers implement measure with View.measure(...) and MeasureSpec. Shared visual props are applied by ViewStyler.

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:

  1. Define a frozen Props dataclass listing the widget's API surface.
  2. Implement PNComponentManager / ComponentManager subclasses and register them under the element name from a PNPlugin entry.
  3. Call register_component (or decorate a desktop ViewHandler with @native_component) and hand callers an element_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