frust-native-widgets
Render real OS controls from pure Rust — native_button("Save"),
native_label(...), native_switch(checked), native_slider(...),
native_progress(...), native_image(bytes), native_spinner(animating),
native_date_picker(date), native_segmented(labels, selected) (iOS/macOS only),
native_stepper(value, min, max) (iOS/macOS only),
native_tab_bar(items, selected) (iOS/iPadOS only) — composed straight
into a frust View tree like any other widget. Each control is backed by a
genuine Android View (Button/TextView/Switch/SeekBar/ProgressBar/ImageView/a
circular-style ProgressBar/DatePicker), UIKit view (UIButton/UILabel/UISwitch/
UISlider/UIProgressView/UIImageView/UIActivityIndicatorView/UIDatePicker/
UISegmentedControl/UIStepper/a bare UITabBar), or
AppKit view on macOS (NSButton/NSTextField/NSSwitch/NSSlider/
NSProgressIndicator/NSImageView/an NSProgressIndicator in its
Spinning style/NSDatePicker/NSSegmentedControl/NSStepper), hosted as a platform-view slot
(docs/ARCHITECTURE.md's Platform-view flow) — frust paints nothing for it,
the OS composites it in place.
Like every frust platform plugin, this crate is added to your app's own
Cargo.toml alongside frust (the pubspec model) — the frust facade does
not re-export it.
Templates stay clean. A generated frust project ships no native-widgets code, permissions, or plist keys. You add exactly the lines below by hand — or let the frust TUI's Add Plugin dialog apply them for you (it automates every step in this document). On Android the plugin's Kotlin ships as its own Gradle library module rather than as files copied into your app, so there is nothing to keep in sync by hand; on iOS there is nothing to add at all beyond the dependency.
Platform support: Android, iOS and macOS. Other targets (desktop preview, web) have no native-control backend.
More about Frust: https://frust.dev and https://github.com/frust-rs/frust.
1. What you get
Eleven controls, one Rust API, no Kotlin or Swift to write for any of them:
| Builder | Android view | iOS view | macOS view |
|---|---|---|---|
native_button(text) |
Button |
UIButton |
NSButton |
native_label(text) |
TextView |
UILabel |
NSTextField (label) |
native_switch(checked) |
Switch |
UISwitch |
NSSwitch |
native_slider(value, min, max) |
SeekBar |
UISlider |
NSSlider |
native_progress(value, min, max) |
ProgressBar |
UIProgressView |
NSProgressIndicator |
native_image(bytes) |
ImageView |
UIImageView |
NSImageView |
native_spinner(animating) |
ProgressBar (circular style) |
UIActivityIndicatorView |
NSProgressIndicator (Spinning style) |
native_date_picker(date) |
DatePicker |
UIDatePicker (date mode) |
NSDatePicker (year/month/day) |
native_segmented(labels, selected) |
none — renders a refusal banner (see below) | UISegmentedControl |
NSSegmentedControl (select-one) |
native_stepper(value, min, max) |
none — renders a refusal banner (see below) | UIStepper |
NSStepper |
native_tab_bar(items, selected) |
none — renders a refusal banner (see below) | a bare UITabBar (no UITabBarController) |
none — renders a refusal banner |
native_segmented and native_stepper are iOS/iPadOS and macOS only in
this release. Android's framework has no segmented control — the stock one is
Material's MaterialButtonToggleGroup — and no increment/decrement stepper
control either; this plugin never assumes a Material/AndroidX dependency your
app did not add — so on Android (and on every desktop-preview host) each
builder is resolved at compile time to the same frust-drawn warning
banner the translucency-refused fallback uses, naming the missing arm, rather
than an empty slot. A Material-backed segmented Android arm and a composite
stepper Android arm (two ImageButtons plus a TextView) are both follow-up
plans. native_segmented is controlled like native_switch:
.on_select(|index| …) reports the requested segment, and the app confirms
it by feeding selected back; .momentary(true) makes a tap flash its
segment instead of selecting it. The selected segment wears the theme's
accent_fill. native_stepper is controlled like native_slider:
.on_change(|value| …) reports the requested value on a tap, and the app
confirms it by feeding value back; .step(n) sets the increment (default
1) and .wraps(true) wraps the value from max back to min instead of
clamping. The control wears the theme's accent_ink as its tint on iOS only
— NSStepper has no tint property AppKit exposes at all, so the fold is a
silent no-op there, logged at debug (same shape as the AppKit tint gaps in §4
below).
native_tab_bar is iOS/iPadOS only: macOS has no bottom-tab-bar idiom,
and Android's BottomNavigationView needs Material, which this plugin never
assumes — both render the refusal banner, naming both reasons. It is a
bare UITabBar, never a UITabBarController: frust keeps screen
ownership and routing, so a tap never navigates by itself. Each TabItem
carries a stable app-chosen TabId, a title, an icon — TabIcon::AppleSymbol
(an SF Symbol name) or TabIcon::Bytes (encoded image bytes, normalized to a
25pt box; supply 75×75px for a crisp 3x icon) — and optionally a
.selected_icon(…), a .badge(…) and .enabled(false). .on_select(|id| …)
reports the requested tab: route there and feed the id back as selected
(controlled, like native_segmented). .on_reselect(|id| …) fires when the
tab the app last confirmed was tapped again — the conventional "scroll to
top / pop to root" — not merely the one UIKit is highlighting: a tap you
reject (no write-back) retapped still reports as another .on_select, even
though the OS has already moved the highlight to it. The same tracking works
in reverse too: tap back on the tab the app still confirms and that retap
fires .on_reselect, even though the OS's own highlight had just moved to
the rejected tab a moment before, not to the one you tapped back — an app
that pops to root on .on_reselect should feed the confirmed selection back
promptly so its highlight and selected never keep diverging. The bar
sizes itself to 49pt plus the window's bottom safe-area inset,
so its background runs under the home indicator: put it last in a Column
docked to the bottom edge, and if you wrap it in frust::safe_area for
horizontal cutouts use .top(false).bottom(false) (the way examples/huddle
docks Material's navigation_bar); .safe_area(false) gives the bare 49pt
for a bar that isn't docked to the bottom. The selected item wears the
theme's accent_ink, unselected items its on_surface_variant, and the bar
its surface colour (an opaque appearance, set for both the standard and the
scroll-edge state so iOS 15+ never shows a transparent bar). On iPadOS the
bare bar stays at the bottom and gets no Liquid Glass — the iPadOS 18 top tab
bar and the floating glass bar are UITabBarController features.
use ;
use ;
// The router owns navigation; the tab bar only reports the request.
native_date_picker picks a date only (no time) and is controlled like
native_switch: .on_change(|date| …) reports the requested CivilDate
({ year, month, day }, month 1-based, validated — build one with
CivilDate::new(2026, 9, 29)), and the app confirms it by feeding it back
as date. .min(date)/.max(date) bound the range (a date outside it is
shown and reported clamped), and .style(NativeDatePickerStyle::…) picks the
presentation: Compact (default — iOS's compact button, a macOS text field
with a click-to-open calendar, Android's spinner mode), Wheels (iOS wheels,
the macOS text field and stepper, Android's spinner mode) or Inline (an
always-visible calendar on all three). Android fixes the mode when the picker
is created, so a later .style(…) change applies on iOS/macOS only (logged
once on Android). The picker wears the theme's accent_ink as its tint on iOS
and the theme's body-text colour as its text colour on macOS; Android's
DatePicker has no tint or text-colour API — its colours come only from the
(light/dark) theme it is built against — and NSDatePicker has no tint, so
those folds are silent no-ops, logged at debug.
…plus native_component, the generic mounting seam for a control this crate
does not ship: impl NativeComponent (with your own typed Props) →
register_component::<C>(KIND) → native_component(KIND, c, props), mounted
through the same one factory and the same runtime the built-in builders use,
with no per-component Kotlin or Swift anywhere in it. See the api::mount
module docs.
A component hears its own views the way the built-in builders do: it attaches the
platform's one listener to any view it built with
ComponentCtx::attach_listener(view, ListenerKinds::CLICK) (or TOGGLED /
VALUE_CHANGED), its NativeComponent::on_event receives each event, and
whatever that answers reaches the app on the mounted view's .on_event(...)
hook — the same events-as-signals idiom as .on_press:
native_component
.interactive
.on_event
One limit on that seam — read it before you plan around it. An app crate cannot implement
NativeComponenttoday (it needs rawjni/objc2-ui-kitdependencies this crate does not re-export). It is spelled out in §5, and does not apply to the built-in builders above.
Every control follows frust's controlled-component convention where the
platform allows it: a switch/slider reports the requested value through its
on_... callback, and the value you pass back down on the next rebuild is
what actually shows — the same contract Checkbox/Slider follow in
frust-widgets (docs/CODE_STANDARDS.md's Interaction Semantics).
A native control's tap/toggle/drag bypasses frust-core's EventCtx
entirely — it fires straight from the platform's own listener
(View.OnClickListener on Android, target-action on iOS) into a registered
Rust callback. That means none of frust-core's interaction contract
(capture, focus, fire-on-up-inside, Cancel-never-mutates-state) applies to
a hosted native control — this is inherent to hosting real platform widgets,
not a gap.
use RwSignal;
use native_button;
// A native control's callback takes no app-state parameter — it fires
// straight from the platform's own listener, so it closes over a signal
// instead (the events-as-signals idiom every builder follows):
1b. Native presentations (alerts, sheets)
A native alert is not a control in the tree: it is a request answered by
exactly one outcome — the chosen action's id, Cancelled, Dismissed (you
took it down with present::dismiss(&handle)) or HostLost (the presenting
host went away first — the iOS/iPadOS window scene, the macOS presenting
window closing, or the Android hosting Activity being destroyed). One
presentation is live per process; a second request while one is up is
refused PresentError::Busy at once, never queued. Nothing ever blocks
waiting for the user.
| Platform | Today |
|---|---|
| iOS / iPadOS | UIAlertController — AlertStyle::Alert centered, AlertStyle::ActionSheet from the bottom (iPhone) or as a popover pointing at anchor (iPad; required there, see below) |
| macOS | NSAlert presented as a window sheet on the key/main window — Return resolves the first action, Escape resolves the Cancel-role action; no click-away, so Cancelled is never produced; style/anchor ignored |
| Android | framework android.app.AlertDialog over the resumed Activity — the back key / an outside tap resolve Cancelled, only when cancelable; style/anchor ignored |
| desktop preview, web, every other target | PresentError::Unsupported |
Up to three actions, each with a unique non-empty id and a role —
ActionRole::Default, Cancel (at most one; UIKit places it itself) or
Destructive: iOS styles it red, macOS sets NSButton.hasDestructiveAction,
and Android has no severity styling for it — plain, sharing Default's
button-slot preference. Two shapes:
use ;
use ;
// 1. Awaitable — from any async context (`frust::spawn_local` on the UI thread);
// `deleted` is an app `RwSignal<bool>`:
spawn_local;
// 2. Into a signal — no async block. Writes `Some(outcome)` exactly once;
// you read it on a later rebuild and reset it to `None` yourself:
let outcome: = new;
native_button.on_press
show_native_alert_into returns synchronous refusals (Busy, an invalid
spec, Unsupported) as Err and never writes the signal for them; an error
found only later on the main thread (NoHost) is logged, not written — await
show_native_alert when you need to tell those apart. Dropping the awaited
future frees the one-at-a-time slot but leaves the alert on screen until the
user answers it (that answer is discarded).
The iPad anchor rule. An action sheet on iPad is a popover and must point
at something: AlertStyle::ActionSheet with no anchor is refused
PresentError::InvalidSpec on iPad before anything is shown (UIKit would
crash). Pass an AnchorRect in logical window coordinates — frust's
logical pixels are iOS points, so the rect a native control's
platform_view slot paints at, or any frust widget's window-space rect, is
already right; a zero-size rect points at a tap location. iPhone ignores the
anchor.
let mut spec = delete_spec;
spec.style = ActionSheet;
spec.anchor = Some;
cancelable matters only where the platform has a non-action dismissal.
iOS: on iPad, cancelable: false stops an outside tap from closing the
action-sheet popover; UIKit alerts (and iPhone action sheets) have none, so
there it removes nothing. On an iPad popover with a Cancel-role action,
UIKit hides that button and an outside tap reports it (Action("keep")
above) rather than Cancelled. Android: cancelable gates both the back
key and an outside tap, either resolving Cancelled. macOS: no analog —
a window sheet has no click-away dismissal, so cancelable is ignored there.
Native sheets (iOS / iPadOS)
A sheet is the same kind of request: a SheetSpec answered by exactly
one SheetOutcome — Action(id) (the user tapped a row; the sheet has
already slid away), Dismissed(DismissReason::User) (swiped down),
Dismissed(DismissReason::Programmatic) (you called handle.dismiss()) or
HostLost. It shares the one-at-a-time slot with alerts: while an alert is
up, a sheet request is refused PresentError::Busy, and the reverse.
| Platform | Today |
|---|---|
| iOS / iPadOS | a page sheet under UISheetPresentationController: detents, grabber, swipe-to-dismiss |
| macOS, Android, desktop preview, web | PresentError::Unsupported (an NSPopover / BottomSheetDialog arm is follow-up work) |
The content is a constrained native schema, not frust widgets: an
optional title, an optional message, optional image bytes and up to three
action rows (system buttons — Default wears the tint, Destructive red,
Cancel the secondary label colour), stacked top to bottom. An empty sheet
is refused InvalidSpec, as are more than three actions or a
Detent::Custom(f) outside 0 < f <= 1.
use RwSignal;
use ;
// Awaitable, with the handle taken first (moves it, or takes it down):
let presentation = show_native_sheet;
let handle = presentation.sheet_handle;
spawn_local;
// later: handle.map(|h| h.select_detent(Detent::Large));
// Into a signal — the same controlled contract as the alert form:
let outcome: = new;
native_button.on_press
dismissible: false (.with_dismissible(false)) stops the swipe-down, so
only a row or handle.dismiss() ends the sheet. Detent::Custom needs
iOS 16; on iOS 15 it becomes the nearest of medium/large (logged once).
iPad in regular width ignores detents. UIKit presents a page sheet in a full-width (or large Split View) iPad window as a centered form sheet at a fixed size — no medium height, no detent changes; only an edge-attached sheet (iPhone, or an iPad window in compact width such as Slide Over) rests at its detents. Design the content to read well at both sizes; never rely on detent parity across idioms.
2. Add the dependency (always)
# app Cargo.toml — [dependencies]
= "0.5"
<frust> is the path to your frust checkout — derive it from the frust = { path = "…" } line the scaffold already wrote.
This is the only step iOS needs — see §4.
3. Android setup — include the plugin's Gradle module
One addition: include this plugin's Android library module. It carries
both of the plugin's Kotlin classes — dev.frust.nativewidgets's
FrustNativeControlFactory (the ONE platform-view factory every control
resolves through) and FrustNativeListener (the ONE listener class every
control's events funnel through) — plus the two R8 keep rules they need, in
its own consumer-rules.pro. Your AndroidManifest.xml and
proguard-rules.pro stay untouched, and there is no file to copy, so nothing
can drift.
The TUI Add Plugin dialog (frust tui → Add Plugin → native-widgets) makes
both edits below for you, idempotently. By hand:
-
android/settings.gradle.kts— include the module, and redirect its build directory under your app:include(":frust-native-widgets") project(":frust-native-widgets").projectDir = frustLocalDir("frust.plugin.frust-native-widgets.dir") gradle.lifecycle.beforeProject { if (path == ":frust-native-widgets") { layout.buildDirectory.set(rootDir.resolve("../build/android/frust-native-widgets")) } }The module's directory is machine-local: its key sits in the gitignored
android/local.properties, written byfrust run/frust buildfrom the crate cargo resolves, and read by the generatedfrustLocalDirhelper. -
android/app/build.gradle.kts— depend on it:dependencies { implementation(project(":frust-native-widgets")) }
No permission is required: hosting an android.widget view needs none.
Both class names are hard contracts.
dev.frust.nativewidgets.FrustNativeControlFactory is the platform-view
viewType string this crate publishes (the embedding's FrustViewHost
resolves it reflectively through the application classloader, which is also
why the package keeps its dev.frust. prefix), and both class names are
baked into this plugin's four JNI export symbols
(Java_dev_frust_nativewidgets_*). If the module isn't wired in, the host
cannot resolve the factory and every native control renders as an empty slot.
Integrated this plugin before the module existed? Both class names and all four export symbols changed — see §7 before you rebuild.
4. iOS and macOS setup — nothing either
Neither Apple platform needs anything beyond §2's Cargo dependency: no Swift
package reference, no Xcode project edit, no Info.plist key, and on macOS
no explicit "install" call either.
iOS — zero Swift, by design
The factory that would be a Swift class on every other plugin's Apple arm is
instead a Rust objc2 define_class! type, registered straight into the
Objective-C runtime from this crate's own code and resolved by
NSClassFromString under the bare runtime name FrustNativeControlFactory
(docs/CODE_STANDARDS.md's Naming Conventions: iOS factories carry no
package prefix — the opposite convention from Android's fully-qualified
FQCN). This has held for every control added since the first.
macOS — the desktop Mode-A host registers the factory lazily
The desktop preview/bundle needs no app-side wiring either: this crate's
AppKit factory (a Rust frust_plugin::desktop::DesktopViewFactory) registers
itself with the shell's platform-view registry the first time any builder in
this crate encodes a slot's params — there is nothing for your app to call.
Two things differ from the mobile arms, both by design:
- The theme ladder follows the app, not the Mac. Every control's
NSAppearanceis pinned to the activefrust::Theme's brightness (DarkAqua/Aqua), re-applied on every update — so a Mac running Dark Mode still draws a Light-themed app's controls light, and vice versa. - A handful of AppKit gaps are logged, not papered over:
Fit::Coverhas no true fill-and-crop onNSImageView, so it letterboxes likeFit::Containrather than cropping; a slider emits no drag-start/drag-end event (AppKit target-action carries no gesture phase, only the final value);NSSwitch's track andNSProgressIndicator's fill have no tint API at all, sothumbTint/trackTint/progressTintonnative_switch/native_progress— the spinner's own tint onnative_spinner, which also draws throughNSProgressIndicator— andnative_stepper's tint, sinceNSStepperexposes no tint property either — andnative_date_picker's tint,NSDatePickerhaving none (its calendar draws in the system accent colour) — are silent no-ops logged at debug (NSSwitch/NSProgressIndicatorstill draw in the system accent colour regardless;NSStepper's own-/+glyphs simply keep their default appearance);native_image's tint marks the image as a template and setsNSImageView.contentTintColor— a silhouette in the tint colour, like Android's SRC_IN and iOS's template rendering — and clearing the tint restores the original image; andnative_imagedecodes through ImageIO, so a shell environment whoseDYLD_LIBRARY_PATHshadows one of ImageIO's private codec dylibs (e.g. Homebrew's/opt/homebrew/libon a machine withlibpng/libjpeginstalled) leaves every image slot empty with one logged warning rather than decoding or crashing.
5. Caveats
frust create --overwritedestroys these additions.--overwritere-renders the generated project wholesale, silently dropping the Gradle include and the module dependency. Both additions are idempotent — just re-run the Add Plugin dialog (or re-apply §3) to restore them. The Kotlin classes themselves live in the plugin's own module, so they survive — but a project that no longer includes the module can't reach them.- A wrong or missing module wiring fails at runtime, not at build time.
Nothing in the Rust build references the Kotlin classes, so a missing
include(...)compiles cleanly and surfaces on device as a blank slot where the control should be (the host logs an unresolvable factory). A stale pre-module hand copy produces the same blank slot — see §7. - No cargo gate compiles Kotlin.
cargo check --target aarch64-linux-android -p frust-native-widgetstype-checks the Rust half only; a typo in the module's.ktfiles surfaces solely in a real Gradle build (docs/DEVELOPMENT.md).plugins/native-widgets/tests/ kotlin_conformance.rscovers the one thing a source scan can: that the Kotlin listener'sKIND_*constants and value-packing arithmetic still agree withsrc/events.rs's. - Events bypass frust's event pipeline — see §1. A native control is not
reachable by frust's pointer capture/focus machinery, and a frust widget
drawn over an interactive slot needs an explicit input
shield(...)(docs/ARCHITECTURE.md's Platform-view flow). - An app crate cannot implement
NativeComponenttoday (§1'snative_componentseam only).createhas to construct real native views, which means namingjni::objects::JObjecton Android andobjc2-ui-kit's classes on iOS in your own crate; this plugin re-exports neither FFI crate, anddocs/CODE_STANDARDS.mdsanctions afrust-core/kurbo/penikoescape hatch only for anexamples/*app. So the practical audience today is plugin authors, not app authors — the only implementor in this repo is this crate's own non-defaultdemo-componentscomposite. Closing the gap (re-exporting a curated view-construction surface, or the FFI crates themselves) is a separate, unscheduled decision. The built-in builders in §1 are unaffected: they are ordinary Rust calls needing no FFI dependency of yours. - A
NativeComponent's events come only from listeners it attached.ComponentCtx::attach_listenerbinds the platform's one listener class to the component's own slot id — which the context never hands the component — and the runtime's bridge delivers only the event families the slot attached, so a view the component attached nothing to still behaves natively (a button highlights) but reports nothing. Two children attached for the same family are indistinguishable inon_event: a click carries its slot, not which child fired, so give each child its own family or its own slot. On macOS anNSControlcarries one target/action pair, so it takes exactly one family (CLICK,TOGGLEDorVALUE_CHANGED), and AppKit reports no drag edges. The listener is released when theListenerHandlethe component keeps in its state drops.
6. The TUI Add Plugin dialog automates all of this
Everything in §2 and §3 — the Cargo.toml dependency and the
:frust-native-widgets Gradle include plus its app-module dependency — is
applied for you, idempotently, by the frust TUI's Add Plugin dialog
(select native-widgets). This README is the manual contract that dialog
encodes.
7. Migrating from the v1 hand copy (breaking, one time)
Before this plugin shipped its own Gradle module, its two generic Kotlin
classes lived in the bare dev.frust package and the documented
integration was to hand-copy FrustNativeControlFactory.kt and
FrustNativeListener.kt into your own app module, under
app/src/main/kotlin/dev/frust/. That shape is gone, and the rename it
required is breaking:
| v1 (hand copy) | now | |
|---|---|---|
| Kotlin package | dev.frust |
dev.frust.nativewidgets |
factory viewType |
dev.frust.FrustNativeControlFactory |
dev.frust.nativewidgets.FrustNativeControlFactory |
| JNI export symbols | Java_dev_frust_FrustNative* |
Java_dev_frust_nativewidgets_FrustNative* |
| how the Kotlin ships | copied into your app module | this plugin's own Gradle library module (§3) |
Why. The bare dev.frust package belongs exclusively to the embedding
module (docs/CODE_STANDARDS.md's Plugin Conventions); this plugin held a
time-boxed exception only because a Kotlin package is baked verbatim into a
JNI export's mangled symbol name. Shipping a real Gradle library module — the
shape every other plugin already uses — ended the exception, and moving the
package had to move all four export symbols with it.
What to do, in this order:
- Delete your copies of
FrustNativeControlFactory.ktandFrustNativeListener.kt(plus thedev/frust/directory they sat in, if nothing else of yours lives there). They are dead weight now, and worse than dead — see the symptom below. - Apply §3's module wiring: the
include(":frust-native-widgets")+projectDirlines inandroid/settings.gradle.ktsand theimplementation(project(":frust-native-widgets"))line inandroid/app/build.gradle.kts. The TUI's Add Plugin dialog makes both edits idempotently. - Rebuild. Nothing of yours ever referenced either class by name, so there is
nothing else to change — no manifest edit, and no keep rule to add (the
module ships its own
consumer-rules.profor the new names). A keep rule you added for the olddev.frust.FrustNative*classes can go too; it matches nothing once the copies are deleted.
iOS is unaffected: there was never anything to copy there (§4).
The symptom if you skip this. Nothing fails at build time — Kotlin happily
compiles an external fun whose native symbol does not exist, and no cargo
gate compiles Kotlin at all (§5) — so it lands on device the first time a
native control is created, and it looks like a blank slot with no obvious
cause:
- Copies kept, module not wired: the host resolves the factory by the new
FQCN, your app only has the old
dev.frust.one, soClass.forNamefails —logcat(tagfrust) showsplatform-view factory 'dev.frust.nativewidgets.FrustNativeControlFactory' failed to resolveand the slot is marked dead. Every native control is an empty hole. - Anything that still reaches a stale copy dies with
UnsatisfiedLinkError: No implementation found for … Java_dev_frust_FrustNativeControlFactory_nativeCreateControl: the JVM resolves anative/externalmethod by mangled name alone, and that name is no longer in the.so. The host wraps every factory call incatch (Throwable), so this too becomes a dead slot plus onelogcatwarning rather than a crash — which is exactly why the cause is not obvious from the screen.
Both failures are silent by design (a misbehaving platform-view factory must
never take down the frame loop), so adb logcat -s frust is the place to
confirm which one you have.
License
Licensed under either of MIT or Apache-2.0 (SPDX: MIT OR Apache-2.0), at your
option. See LICENSE-MIT and LICENSE-APACHE beside this README.