Custom native components¶
PythonNative ships a public extension SDK,
pythonnative.sdk, that lets you wrap a real
platform widget — a UIView subclass on iOS, a View subclass on
Android — and expose it to user code as a first-class element with
type-checked props. Custom components participate in reconciliation,
flex layout, and Fast Refresh exactly like the built-ins.
This guide walks through the four-file shape of a typical component (typed props, iOS handler, Android handler, registration) and shows how to ship one as an installable PyPI plugin.
Why an SDK?¶
Before the SDK, custom widgets were a monkey-patching exercise:
construct a ViewHandler subclass, reach into the global
NativeViewRegistry,
and define your own ad-hoc element factory by hand. There was no
contract for prop validation, no entry point for third-party
plugins, and mypy couldn't help you.
The SDK fixes that:
- A frozen
Propsdataclass declares every prop your component accepts, with types, defaults, and IDE autocomplete. - A
@native_componentdecorator registers your handlers under a unique element name. - An
element_factoryturns that registration into a callable users invoke like any other built-in. - Discovery via the
pythonnative.handlersentry-point group (seeENTRY_POINT_GROUP) lets your component appear automatically when userspip installyour package.
A worked example: Badge¶
We'll build a small widget that draws a coloured pill with a centred label — useful for unread counts, status chips, etc. The same project layout works for anything from a chart view to a camera preview.
1. Define typed props¶
Create my_pkg/badge_props.py:
from dataclasses import dataclass
from typing import Optional
import pythonnative as pn
from pythonnative.sdk import Props
@dataclass(frozen=True)
class BadgeProps(Props):
"""Visible state of a Badge.
All fields default so callers can pass only the props they care
about. ``style`` is the standard ``StyleProp`` accepted by every
built-in factory.
"""
text: str = ""
color: str = "#FF3B30"
text_color: str = "#FFFFFF"
style: Optional[pn.StyleProp] = None
Props is a frozen dataclass; instances are immutable so equality
diffing in the reconciler stays cheap.
2. Implement the iOS handler¶
my_pkg/badge_ios.py runs only when IS_IOS is true. It uses
rubicon-objc to wrap a
UIView containing a UILabel:
from typing import Any, Dict
from rubicon.objc import ObjCClass
from pythonnative.sdk import ViewHandler, native_component
from .badge_props import BadgeProps
UIView = ObjCClass("UIView")
UILabel = ObjCClass("UILabel")
UIColor = ObjCClass("UIColor")
def _hex_to_uicolor(hex_str: str) -> Any:
s = hex_str.lstrip("#")
if len(s) == 6:
a = 1.0
r, g, b = int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
else: # 8-char AARRGGBB
a = int(s[0:2], 16) / 255.0
r, g, b = int(s[2:4], 16), int(s[4:6], 16), int(s[6:8], 16)
return UIColor.colorWithRed_green_blue_alpha_(r / 255, g / 255, b / 255, a)
@native_component("Badge", props=BadgeProps, platforms=("ios",))
class IOSBadgeHandler(ViewHandler):
def create(self, props: Dict[str, Any]) -> Any:
view = UIView.alloc().init()
view.layer.cornerRadius = 12
label = UILabel.alloc().init()
label.textAlignment = 1 # NSTextAlignmentCenter
view.addSubview_(label)
view._pn_label = label
self.update(view, props)
return view
def update(self, view: Any, changed: Dict[str, Any]) -> None:
if "color" in changed:
view.backgroundColor = _hex_to_uicolor(changed["color"])
if "text" in changed:
view._pn_label.text = changed["text"]
if "text_color" in changed:
view._pn_label.textColor = _hex_to_uicolor(changed["text_color"])
def set_frame(self, view: Any, x: float, y: float, w: float, h: float) -> None:
view.frame = ((x, y), (w, h))
view._pn_label.frame = ((0, 0), (w, h))
def measure_intrinsic(self, view: Any, max_w: float, max_h: float) -> tuple[float, float]:
size = view._pn_label.sizeThatFits_((max_w - 24, max_h))
return (float(size.width) + 24.0, float(size.height) + 8.0)
3. Implement the Android handler¶
my_pkg/badge_android.py runs only when IS_ANDROID is true. It
uses Chaquopy to wrap a TextView
inside a FrameLayout:
from typing import Any, Dict
from java import jclass
from pythonnative.sdk import ViewHandler, native_component
from pythonnative.utils import get_android_context
from .badge_props import BadgeProps
FrameLayout = jclass("android.widget.FrameLayout")
TextView = jclass("android.widget.TextView")
GradientDrawable = jclass("android.graphics.drawable.GradientDrawable")
Color = jclass("android.graphics.Color")
Gravity = jclass("android.view.Gravity")
@native_component("Badge", props=BadgeProps, platforms=("android",))
class AndroidBadgeHandler(ViewHandler):
def create(self, props: Dict[str, Any]) -> Any:
ctx = get_android_context()
container = FrameLayout(ctx)
bg = GradientDrawable()
bg.setShape(GradientDrawable.RECTANGLE)
bg.setCornerRadius(24.0)
container.setBackground(bg)
label = TextView(ctx)
label.setGravity(Gravity.CENTER)
container.addView(label)
container._pn_label = label
container._pn_bg = bg
self.update(container, props)
return container
def update(self, view: Any, changed: Dict[str, Any]) -> None:
if "color" in changed:
view._pn_bg.setColor(Color.parseColor(changed["color"]))
if "text" in changed:
view._pn_label.setText(changed["text"])
if "text_color" in changed:
view._pn_label.setTextColor(Color.parseColor(changed["text_color"]))
The set_frame and measure_intrinsic shapes are identical to the
built-in handlers; see
Native views for the full protocol.
4. Wire it into your project¶
my_pkg/__init__.py imports the right module based on the active
runtime and exposes a typed factory:
from pythonnative.sdk import element_factory
from pythonnative.utils import IS_ANDROID, IS_IOS
if IS_ANDROID:
from . import badge_android # noqa: F401 # registers AndroidBadgeHandler
elif IS_IOS:
from . import badge_ios # noqa: F401 # registers IOSBadgeHandler
Badge = element_factory("Badge")
Users now write:
import pythonnative as pn
from my_pkg import Badge
@pn.component
def NotificationsButton():
count, _ = pn.use_state(3)
return pn.Row(
pn.Text("Inbox"),
Badge(text=str(count), color="#0A84FF"),
style={"spacing": 8, "align_items": "center"},
)
Badge(...) validates kwargs against BadgeProps, resolves the
style argument through resolve_style,
and returns a regular Element.
Validation rules¶
The factory enforces a strict contract on its arguments:
| 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(some_unknown_key=...) |
TypeError("Invalid props for 'Badge': …"). |
For register_component callers without a Props class, kwargs
flow straight to the Element and are not validated. We strongly
recommend defining a Props dataclass for every public component.
Distributing as a plugin¶
To ship Badge as a PyPI package and have it auto-register on
install, declare an entry point in your project's pyproject.toml:
The function pointed at by the entry point runs once on first call
to get_registry(). A
common pattern is to import the platform-specific module from inside
that function so the heavy rubicon-objc/Chaquopy code path only
runs on the target device:
def register() -> None:
from pythonnative.utils import IS_ANDROID, IS_IOS
if IS_ANDROID:
from . import badge_android # noqa: F401
elif IS_IOS:
from . import badge_ios # noqa: F401
Plugins are loaded once per process, even if get_registry() is
called many times. Errors raised from a misbehaving plugin are
caught and logged but do not break PythonNative's startup.
Imperative registration¶
If you don't want to use the decorator (e.g., for handlers that are
constructed lazily), call
register_component:
from pythonnative.sdk import register_component
register_component(
name="Badge",
props=BadgeProps,
handlers={"ios": IOSBadgeHandler(), "android": AndroidBadgeHandler()},
)
You can call this at any time before the first Badge(...) call.
Multiple calls merge by platform, so different files can register
the iOS and Android handlers separately.
Testing custom components¶
The SDK is platform-agnostic: it does not import Chaquopy or rubicon-objc, so you can unit-test your factory and registration logic from pytest on a developer laptop. A typical pattern is to swap in a stub handler that records calls:
import pytest
from pythonnative.sdk import register_component, element_factory, ViewHandler
from pythonnative.native_views import NativeViewRegistry, set_registry
from my_pkg.badge_props import BadgeProps
class _StubHandler(ViewHandler):
def create(self, props): return {"props": props}
def update(self, view, changed): view["props"].update(changed)
def test_badge_validates_props() -> None:
register_component(name="Badge", props=BadgeProps, handlers={"ios": _StubHandler()})
Badge = element_factory("Badge")
with pytest.raises(TypeError):
Badge(unknown_field=42)
el = Badge(text="3", color="#000000")
assert el.props["text"] == "3"
See tests/test_sdk.py in the PythonNative repo for a fuller
end-to-end example that runs the reconciler against a recording
backend.
Next steps¶
- API reference:
pythonnative.sdk. - Native view protocol: Native views.
- Forward styles cleanly: Styling.
- Wrap a device API instead of a widget: Native modules.