Documentation
UIKit interop
Walnut does not pretend UIKit isn’t there. Interop has three layers:
- Platform list — generated names from allowlisted iOS SDK frameworks (
uiSwitch,mkMapView,languageModelSession, …) - Curated shorts —
switch,spinner(ergonomic aliases) - App
natives.json— your renames / custom@objcviews - Escape hatch —
native "ClassName"for one-offs
Platform list
The toolchain ships a catalog of allowlisted app-facing frameworks (UIKit, HealthKit, PassKit, CloudKit, MapKit, WebKit, FoundationModels, and similar) — not every class in the SDK.
Naming: leading acronym lowercased (UISwitch → uiSwitch, HKHealthStore → hkHealthStore); plain PascalCase decapitalized (LanguageModelSession → languageModelSession).
-- Typed payload (reads UISwitch.isOn → Bool -> msg)
uiSwitch [ propBool "on" model.lit, onNativeBool "valueChanged" SetLit ]
uiActivityIndicatorView [ propBool "animating" True ]
Hosting limit: the view renderer only instantiates UIView subclasses. Domain types (hkHealthStore, ckContainer, …) still typecheck so you can discover them, but they need ports/effects — not native — to be useful.
Curated helpers
switch [ propBool "on" model.lit, onNativeBool "valueChanged" SetLit ]
spinner [ propBool "animating" model.lit, propInt "activityIndicatorViewStyle" 100 ]
App aliases (natives.json)
At the project root (next to walnut.json):
{
"aliases": {
"switch2": "UISwitch",
"beamGauge": "MyBeamGauge"
}
}
Then switch2 [ … ] is a real helper. A Swift typealias UISwitch2 = UISwitch alone is not enough — NSClassFromString never sees typealiases; the JSON bridge is the source of truth.
Escape hatch: native
native : String -> List (Attr msg) -> View msg
native "UISwitch" [...] instantiates via NSClassFromString, applies KVC prop*, and wires typed onNative* control events:
row [ spacing 12 ]
[ text "Notifications"
, spacer
, native "UISwitch"
[ propBool "on" model.notificationsEnabled
, onNativeBool "valueChanged" SetNotifications
]
]
The same knowledge — class names, property names, control events, straight from Apple’s docs — expresses as data in your view, and the runtime handles allocation, targets, and lifecycle. In imperative UIKit you would write:
let switch = UISwitch()
switch.isOn = true
switch.addTarget(self, action: #selector(toggled), for: .valueChanged)
Properties via KVC
Four typed helpers set properties. Most keys are plain KVC (including nested paths like layer.borderWidth). A few UIKit write paths that are not KVC are special-cased in the host:
| Key | Control | Behavior |
|---|---|---|
title | UIButton | setTitle(_:for: .normal) (+ configuration.title when set) |
segments | UISegmentedControl | CSV titles → insertSegment(withTitle:at:) |
font.pointSize | label / field / button | Rebuild UIFont with withSize (pointSize is read-only) |
propString : String -> String -> Attr msg
propInt : String -> Int -> Attr msg
propFloat : String -> Float -> Attr msg
propBool : String -> Bool -> Attr msg
Examples:
native "UIButton"
[ propString "title" "Save"
, onNative "touchUpInside" Save
]
native "UISegmentedControl"
[ propString "segments" "Day,Week,Month"
, propInt "selectedSegmentIndex" model.tab
, onNativeInt "valueChanged" "selectedSegmentIndex" SetTab
]
native "UILabel"
[ propString "text" "Hello"
, propFloat "font.pointSize" 22.0
]
native "UIActivityIndicatorView"
[ propBool "animating" True
, propInt "activityIndicatorViewStyle" 100 -- .large
]
prop* also works on built-in elements when the host applies them — reach past curated attributes on a button, textField, etc. where wired:
textField
[ onInput EmailChanged
, propInt "keyboardType" 7 -- .emailAddress
, propBool "autocorrectionType" False
]
Events via target-action
onNative : String -> msg -> Attr msg
Attaches a UIControl target for the named event and dispatches a fixed Msg:
| Name | UIControl.Event |
|---|---|
"touchUpInside" | .touchUpInside |
"touchDown" | .touchDown |
"valueChanged" | .valueChanged |
"editingChanged" | .editingChanged |
"editingDidBegin" | .editingDidBegin |
"editingDidEnd" | .editingDidEnd |
"primaryActionTriggered" | .primaryActionTriggered |
Typed payloads
When you need the control’s current value, use a typed helper. Each reads a value, applies (payload -> msg) via wn_apply1 (same pattern as onInput), then dispatches:
| Helper | Payload | Readback |
|---|---|---|
onNativeBool event toMsg | Bool | UISwitch.isOn, else KVC "on" / "isOn" |
onNativeInt event keyPath toMsg | Int | value(forKeyPath:) |
onNativeFloat event keyPath toMsg | Float | value(forKeyPath:) |
onNativeString event keyPath toMsg | String | value(forKeyPath:) |
onNativePosix event keyPath toMsg | Int (ms since epoch) | Date / NSDate via keyPath |
switch [ propBool "on" model.lit, onNativeBool "valueChanged" SetLit ]
native "UIDatePicker"
[ propInt "datePickerMode" 1
, onNativePosix "valueChanged" "date" DatePicked
]
onNative (fixed msg, no readback) remains available for pure signals.
Layout integration
A native element participates in Auto Layout like everything else: it lives in its parent stack view and honors width (px n) / height (px n), padding on its container, opacity, rounded, border, bg, and onTap.
el [ padding 16, glass, rounded 20 ]
(native "UISegmentedControl" [ height (px 32) ])
When to use which
| Situation | Reach for |
|---|---|
Labels, stacks, buttons, fields, images, scrolling, tables, curated natives (switch) | Ui elements |
App-specific class rename / custom @objc view with a stable Walnut name | natives.json alias |
| A property the curated attributes don’t cover | prop* on the built-in element |
| A whole control Walnut doesn’t wrap yet (one-off) | native "UIClassName" |
| Complex custom views, delegates, data sources | Write Swift in the shell app and expose it — or better, open an issue; the goal is to grow Ui deliberately |
Safety notes
- A misspelled class name renders an inline red diagnostic (
Unknown UIKit class: UISwtich) instead of crashing. - A misspelled property name will raise UIKit’s
setValue:forUndefinedKey:exception at render time — KVC is stringly-typed; that’s the price of the hatch. Keepnativeusage wrapped in small helper functions (switch : Bool -> Msg -> View Msg) so each stringly boundary appears once in your codebase.