Native widgets
Define your own widget with the NativeWidget trait: a drawn version for every OS, and a native view where the OS has one.
When div, text, and canvas are enough, write a widget as a plain function or a RenderOnce struct. Implement NativeWidget when the OS has a real control that you want, such as a map, or when the widget must be a native view to behave like one. The framework handles layout, focus, and z-order the same way for both kinds, and a call site can't tell your widget from a built-in one. Every built-in control in nd-components uses this same trait.
The Places example has a map and a gauge. On macOS, the map is MapKit's MKMapView through objc2, and the gauge is a SwiftUI Gauge (Swift). On other platforms, nd draws both.
On AppKit, a widget with an objc2 or Swift implementation mounts as its own view. The backend frames, measures, and updates that view like a built-in control. Everywhere else, and on AppKit when a Swift widget's symbols aren't in the binary, the element mounts its drawn tree inside a container that takes the element's id, layout style, and semantics.
The trait
A widget names its props, its events, what assistive technology and automation see, and its implementations:
pub struct MapWidget;
impl NativeWidget for MapWidget {
type Props = MapProps;
type Event = MapEvent;
fn semantics(props: &MapProps) -> Semantics {
Semantics::new(Role::Group).label("Map").value(format!("{} places", props.pins.len()))
}
fn drawn(props: &MapProps, emit: Emit<MapEvent>, window: &mut Window, cx: &mut App) -> AnyElement {
drawn::render(props, emit, window, cx)
}
fn backends() -> Backends<Self> {
let backends = Backends::drawn_only();
#[cfg(target_os = "macos")]
let backends = backends.appkit(appkit::MapKit);
backends
}
}
From examples/native-widget/src/map_view/mod.rs, line 50.
- nd compares
PropswithPartialEqon every render, and touches the native side only when the props changed. Eventis what the widget can tell the app. A widget with no events usesstd::convert::Infallible.semanticsfeeds the semantic tree, so tests and the MCP server see a "Map" with a place count instead of an opaque view.drawnis the fallback for the web and for every backend that has no native version. It's an ordinary element tree, and it can readcx.theme().backendsregisters the native versions.Backends::drawn_only()is the default..appkit(..)adds an AppKit version on macOS, and.swift(..)adds a Swift version.
An element type wraps the props and implements NativeElement (base_mut and into_parts). NativeBase<W> carries the id, layout style, and event handlers, and #[derive(IntoElement)] mounts it. Builder methods such as .zoom(..) and .pins(..) set props, and .on_region_change(..) registers an event handler. For the full set, see map_view/mod.rs. The call site reads like any built-in:
.content(
map_view("map", self.center)
.zoom(self.zoom)
.pins(pins)
.size_full()
.on_region_change(cx.listener(|this, e: &RegionChanged, _window, cx| {
this.center = e.center;
this.zoom = e.zoom;
cx.notify();
}))
.on_pin_select(cx.listener(|this, e: &PinSelected, _window, cx| {
if let Some(place) = this.places.iter_mut().find(|p| p.name == e.title.as_ref()) {
place.visited = true;
cx.notify();
}
})),
)
From examples/native-widget/src/main.rs, line 74.
The drawn fallback
The fallback in drawn.rs uses a canvas to paint the OpenStreetMap tiles that cover the view, and a second canvas to draw the pins on top. It fits the region to its size the way MapKit does, so both show the same places, and it pans when you drag. It's a real map, with the attribution that OpenStreetMap requires, and the web, Linux and Windows builds ship it. The image pipeline downloads each tile once into its disk cache. Write the fallback first. It defines the props and events, and the native version then has to match them.
The AppKit side
AppKitWidget creates an NSView, updates it when the props change, and reports its size:
fn create(&self, props: &MapProps, cx: &mut AppKitCx<MapWidget>) -> (Retained<MKMapView>, Self::State) {
let map = unsafe { MKMapView::new(cx.mtm()) };
let delegate = MapDelegate::new(cx.emitter(), cx.mtm());
// MKMapView holds its delegate weakly; the node state keeps it alive.
unsafe { map.setDelegate(Some(ProtocolObject::from_ref(&*delegate))) };
apply(&map, &delegate, None, props, cx);
(map, delegate)
}
From examples/native-widget/src/map_view/appkit.rs, line 31.
AppKitCx gives you the main-thread marker, emitter() for delegates that the view installs, and programmatic(..). State is whatever you want to keep beside the view. Here, it's the delegate, because MKMapView holds its delegate weakly. update receives the old and new props and applies the difference:
fn apply(
map: &MKMapView,
delegate: &MapDelegate,
old: Option<&MapProps>,
new: &MapProps,
cx: &mut AppKitCx<MapWidget>,
) {
let viewport: Viewport = (new.center, new.zoom);
delegate.ivars().viewport.borrow_mut().apply(&viewport, |&(center, zoom)| {
let region = MKCoordinateRegion { center: to_cl(center), span: span_for_zoom(zoom) };
// Programmatic: the regionDidChange this triggers is reported, not emitted.
cx.programmatic(|| unsafe { map.setRegion_animated(region, old.is_some()) });
shown(map)
});
if old.is_none_or(|o| o.pins != new.pins) {
set_pins(map, &new.pins);
}
}
From examples/native-widget/src/map_view/appkit.rs, line 59.
measure returns the view's natural size. A map has no natural size, so it returns zero, and the app sizes it with flex or explicit dimensions.
Callbacks and controlled values
Two rules keep a native view from fighting the app's state.
No native callback calls app code. AppKit invokes delegates inside setters and layout: setRegion fires regionDidChange before it returns, while the app's view is in the middle of an update. Emit queues the event, and the queue drains after the update, outside any borrow of the app's state. Call emit(..) from a delegate, and never run app code there.
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[ivars = MapIvars]
pub struct MapDelegate;
unsafe impl NSObjectProtocol for MapDelegate {}
unsafe impl MKMapViewDelegate for MapDelegate {
#[unsafe(method(mapView:regionDidChangeAnimated:))]
fn region_did_change(&self, map: &MKMapView, _animated: bool) {
let (center, zoom) = shown(map);
// Busy while `apply` runs the setter, which records what the map shows itself.
if let Ok(mut viewport) = self.ivars().viewport.try_borrow_mut() {
viewport.report((center, zoom));
}
// Queued, never delivered inside this callback; dropped if a programmatic set caused it.
self.ivars().emit.emit(MapEvent::RegionChanged(RegionChanged { center, zoom }));
}
#[unsafe(method(mapView:didSelectAnnotation:))]
fn did_select(&self, _map: &MKMapView, annotation: &ProtocolObject<dyn MKAnnotation>) {
if let Some(title) = unsafe { annotation.title() } {
self.ivars().emit.emit(MapEvent::PinSelected(PinSelected { title: title.to_string().into() }));
}
}
}
);
From examples/native-widget/src/map_view/appkit.rs, line 97.
A controlled value never echoes. The map is controlled: the app owns center and zoom, and when the user pans, the map emits RegionChanged, which the app feeds back in. Without care, that loops. MapKit makes it worse, because it fits any region to its aspect ratio, so the region that it reports is never the one you set. Synced<T> holds what the native control holds:
applycalls the setter only when the new value differs from the last one that was applied or reported.reportrecords what the control produced.- The setter runs inside
cx.programmatic(..), so nd drops the callback that it triggers.
When the app feeds the reported region back, no second setRegion happens.
/// What the map shows, in the props' own terms. MKMapView fits any region to its aspect ratio,
/// so the region it reports differs from the one we set; `Synced` remembers the reported one and
/// the app feeding it back causes no second `setRegion`.
type Viewport = (LatLng, f64);
From examples/native-widget/src/map_view/appkit.rs, line 20.
Do the same for any prop that the user can also change. A slider's step snapping and a split divider's clamp are the same case.
Build and run
The map crate depends on objc2-map-kit only for macOS. The native-widget package is named places, and an m6 feature gates the binary:
cargo run -p places --features m6
nd re-exports the objc2 types under nd::native::appkit, so you don't need an extra dependency for AppKit itself.