Custom native components¶
PythonNative renders through native component managers: a Swift
PNComponentManager in PythonNativeKit and a Kotlin ComponentManager
in the pythonnative Gradle module own every UIView and
android.view.View. Python owns the element tree and reconciliation and ships styles in each
validated commit (see
The native bridge).
Adding your own component means writing one manager per platform, registering both under an element name, and giving Python a typed factory for it. Custom components then participate in reconciliation, flex layout, gestures, animations, and Fast Refresh exactly like the built-ins.
This guide builds a small Badge widget end to end and shows how to
ship it as an installable PyPI plugin.
The pieces¶
| Piece | Where | Role |
|---|---|---|
Props dataclass |
Python | Declares the props your component accepts, with types and defaults. |
PNComponentManager subclass |
Swift (ios/) |
Creates the UIView, applies props, measures, handles commands. |
ComponentManager subclass |
Kotlin (android/) |
Same for android.view.View. |
PNPlugin entry |
Swift and Kotlin | Registers the managers (and any native modules) by name. |
pn_plugin.json |
Plugin root | Tells pn build which entry to call on each platform. |
register_component + element_factory |
Python | Declares the element name and exposes the typed factory. |
ViewHandler (optional) |
Python | Off-device stand-in so unit tests can render the component without a device. |
The native runtime runs Yoga over the committed styles. Managers receive
absolute frames through setFrame and implement measure for content-sized
leaves. See Generated native contracts for schema
generation, typed adapters, and plugin contract registration.
Project layout¶
my_badge/
pyproject.toml
my_badge/
__init__.py # Props, register_component, Badge factory
desktop.py # optional ViewHandler for pn preview
native/
__init__.py # empty; makes the directory importable
pn_plugin.json
ios/
BadgeManager.swift
MyBadgePlugin.swift
android/
com/example/badge/
BadgeManager.kt
MyBadgePlugin.kt
1. Typed props¶
my_badge/__init__.py:
from dataclasses import dataclass
from typing import Optional
import pythonnative as pn
from pythonnative.sdk import Props, element_factory, register_component
@dataclass(frozen=True)
class BadgeProps(Props):
"""Visible state of a Badge.
Every field defaults so callers pass only what they care about.
``style`` is the standard ``StyleProp`` accepted by every built-in.
"""
text: str = ""
color: str = "#FF3B30"
text_color: str = "#FFFFFF"
style: Optional[pn.StyleProp] = None
register_component(name="Badge", props=BadgeProps)
Badge = element_factory("Badge")
Props is a frozen dataclass, so the reconciler's equality diff stays
cheap. register_component declares the element name; the factory
validates kwargs against BadgeProps, resolves style through
resolve_style, and returns a
regular Element.
Props cross the bridge as JSON. Stick to strings, numbers, booleans, lists, and dicts; callables become events (see below) and anything else is dropped with a warning.
2. The Swift manager¶
native/ios/BadgeManager.swift:
import PythonNativeKit
import UIKit
final class BadgeView: UIView {
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 12
clipsToBounds = true
label.textAlignment = .center
label.font = .systemFont(ofSize: 13, weight: .semibold)
addSubview(label)
}
required init?(coder: NSCoder) { fatalError() }
override func layoutSubviews() {
super.layoutSubviews()
label.frame = bounds
}
}
public final class BadgeManager: PNComponentManager {
public override func makeView(props: [String: Any]) -> UIView {
BadgeView(frame: .zero)
}
public override func apply(view: UIView, props: [String: Any], initial: Bool) {
super.apply(view: view, props: props, initial: initial) // background, border, opacity, ...
guard let badge = view as? BadgeView else { return }
if let text = PNProps.string(props["text"]) { badge.label.text = text }
if let color = PNProps.string(props["color"]) { badge.backgroundColor = PNColor.parse(color) }
if let textColor = PNProps.string(props["text_color"]) { badge.label.textColor = PNColor.parse(textColor) }
}
public override func measure(view: UIView, maxW: CGFloat, maxH: CGFloat) -> CGSize {
guard let badge = view as? BadgeView else { return .zero }
let fit = badge.label.sizeThatFits(CGSize(width: max(0, maxW - 24), height: maxH))
return CGSize(width: fit.width + 24, height: fit.height + 8)
}
}
apply receives the full props on create (initial == true) and only
the changed keys on update; a removed prop arrives as NSNull. Call
mergedProps(view) when you need the complete current set.
3. The Kotlin manager¶
native/android/com/example/badge/BadgeManager.kt:
package com.example.badge
import android.content.Context
import android.graphics.drawable.GradientDrawable
import android.view.Gravity
import android.view.View
import android.widget.TextView
import com.pythonnative.runtime.components.ComponentManager
import com.pythonnative.runtime.components.PNColor
import org.json.JSONObject
class BadgeManager : ComponentManager() {
override fun createView(context: Context, tag: Long, props: JSONObject): View =
TextView(context).apply {
gravity = Gravity.CENTER
background = GradientDrawable().apply { cornerRadius = 12 * resources.displayMetrics.density }
}
override fun applyProps(view: View, props: JSONObject, initial: Boolean) {
super.applyProps(view, props, initial)
val badge = view as TextView
if (props.has("text")) badge.text = props.optString("text")
if (props.has("color")) (badge.background as GradientDrawable).setColor(PNColor.parse(props.optString("color")))
if (props.has("text_color")) badge.setTextColor(PNColor.parse(props.optString("text_color")))
}
override fun measure(view: View, maxWidth: Double, maxHeight: Double): FloatArray {
val base = super.measure(view, maxWidth, maxHeight)
return floatArrayOf(base[0] + 24f, base[1] + 8f)
}
}
Geometry on Android is in dp on both sides of the bridge; the base
class converts to pixels in setFrame and back in measure.
4. Register both in a plugin entry¶
native/ios/MyBadgePlugin.swift:
import PythonNativeKit
public enum MyBadgePlugin: PNPlugin {
public static func register(into registry: PNRegistry) {
registry.registerComponent("Badge") { BadgeManager() }
}
}
native/android/com/example/badge/MyBadgePlugin.kt:
package com.example.badge
import com.pythonnative.runtime.bridge.PNPlugin
import com.pythonnative.runtime.bridge.PNRegistry
object MyBadgePlugin : PNPlugin {
override fun register(registry: PNRegistry) {
registry.registerComponent("Badge") { BadgeManager() }
}
}
native/pn_plugin.json:
{
"contracts": "schema.json",
"ios": {"entry": "MyBadgePlugin"},
"android": {"entry": "com.example.badge.MyBadgePlugin"}
}
A plugin may declare only one platform. Gate its use with Platform.OS or
provide an application-level fallback on unsupported platforms. Native commits
reject unregistered component types.
5. Tell pn build about the plugin¶
Point the pythonnative.plugins entry point at the directory holding
pn_plugin.json, and the pythonnative.handlers entry point at the
Python module that calls register_component:
[project.entry-points."pythonnative.plugins"]
my_badge = "my_badge.native"
[project.entry-points."pythonnative.handlers"]
my_badge = "my_badge"
pn build (and pn run) copies ios/*.swift into
PythonNativeKit/Sources/PythonNativeKit/Plugins/my_badge/ and
android/**/*.kt into the pythonnative Gradle module, then regenerates
the registration file that calls MyBadgePlugin.register on each
platform. SwiftPM and Gradle compile whatever lands there; no Xcode or
Gradle project edits are involved.
For native code that lives inside an app rather than a package, list
the directory in pythonnative.toml instead of an entry point:
6. Use it¶
import pythonnative as pn
from my_badge import Badge
@pn.component
def InboxRow():
count, _ = pn.use_state(3)
return pn.Row(
pn.Text("Inbox"),
Badge(text=str(count), color="#0A84FF"),
style={"spacing": 8, "align_items": "center"},
)
Events¶
Callable props never cross the bridge. When a Badge(on_press=...)
element is created, Python strips the callback into the process-wide
EventRegistry and sends the prop
_pn_events: ["on_press"] instead. The manager wires a listener once
and fires by tag:
// Swift: inside createView / didCreate
badge.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapped(_:))))
@objc private func tapped(_ recognizer: UITapGestureRecognizer) {
guard let view = recognizer.view else { return }
PNEvents.emitIfWired(view, "on_press")
}
The payload is a positional argument list, so PNEvents.emit(view,
"on_change", [newText]) calls on_change(new_text) in Python. A
re-render that only swaps the lambda costs zero native calls.
Commands¶
Imperative actions (focus, scroll_to_offset, ...) arrive through
command(view:name:args:). Return a JSON-encodable value or nil:
public override func command(view: UIView, name: String, args: [String: Any]) -> Any? {
switch name {
case "pulse": (view as? BadgeView)?.pulse(); return nil
default: return super.command(view: view, name: name, args: args)
}
}
Python reaches it through the tag the reconciler publishes on a ref:
from pythonnative.native_views import get_registry
badge_ref = pn.use_ref(None)
...
get_registry().command(badge_ref._pn_tag, "pulse")
Browser preview and tests¶
Neither the browser preview nor
pythonnative.testing loads Swift or Kotlin. In the preview, a
component with no browser implementation validates its props, takes
part in layout, and renders as a labeled placeholder box, so the
layout around it stays truthful while you work on everything else.
Off device (the "test" platform), register a Python
ViewHandler so the component has a
stand-in; the @native_component decorator does this and declares the
element in one step:
# my_badge/fallback.py
from pythonnative.sdk import ViewHandler, native_component
from . import BadgeProps
@native_component("Badge", props=BadgeProps)
class FallbackBadgeHandler(ViewHandler):
def create(self, tag, props):
return {"tag": tag, "text": props.get("text", "")}
def update(self, view, changed):
if "text" in changed:
view["text"] = changed["text"] or ""
def measure_intrinsic(self, view, max_w, max_h):
return (8.0 * len(view["text"]) + 12.0, 20.0)
Unit tests use the recording backend from
pythonnative.testing:
from pythonnative.testing import render
from my_badge import Badge
def test_badge_renders_text() -> None:
result = render(Badge(text="3"))
badge = result.get_by_type("Badge")
assert badge.props["text"] == "3"
Native managers get their own tests: PythonNativeKit ships an XCTest
target and the Gradle module a JUnit target, both driving managers with
decoded transactions. See Testing.
Validation rules¶
| Call site | Result |
|---|---|
Badge(text="3") |
Validated against BadgeProps. Unknown fields raise TypeError. |
Badge(props=BadgeProps(text="3")) |
Used directly. style is still resolved if present. |
Badge(props=..., text="3") |
TypeError: pass either props or keyword arguments. |
Badge(unknown=...) |
TypeError("Invalid props for 'Badge': ..."). |
For register_component calls without a props class, kwargs flow
straight to the Element and aren't validated.
Next steps¶
- Protocol details: The native bridge.
- SDK reference:
pythonnative.sdk. - Wrap a device API instead of a widget: Native modules.