feat: add native modifiers for SwiftUI and Jetpack Glance - #284
Conversation
Adds ADR 0005, a proposed design for a per-platform `modifiers` prop that carries SwiftUI and Jetpack Glance modifiers to the device for Dynamic Widgets and Dynamic Live Activities. Covers the typed JSX API, the wire format, Dynamic-only enforcement, the manifest-driven generator outputs, the single insertion point on each platform, user-defined modifiers, and a phased implementation plan. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sL2XXxyKtSRDnm4EDyM9X
…rning Drops the Dynamic-only rejection from ADR 0005. Every renderer accepts the `modifiers` prop and emits the same wire format; the documentation warns that native modifiers are unsafe on payload widgets and pushed Live Activities because they count against the payload size limit, and the existing budget check and size snapshots remain the guard rails. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sL2XXxyKtSRDnm4EDyM9X
Removes the proposed modifiers.json manifest and generator target from ADR 0005. Modifiers are written once in TypeScript and once natively, with a fixture-based parity test between them. Factories live on the existing Voltra and VoltraAndroid namespaces instead of a new package entry point, and the first version drops per-component scope from the brand. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sL2XXxyKtSRDnm4EDyM9X
Links ADR 0005 to #275. Moves user-defined modifiers to future work, drops the Glance background image overload from the first version, explains why view and text modifiers need no type-level split, spells out the iOS ordering rule with its consequences, and rewrites the runCallback question so it stands on its own. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sL2XXxyKtSRDnm4EDyM9X
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sL2XXxyKtSRDnm4EDyM9X
Click handling from JSX is the subject of #276. The Android catalog keeps layout, background, corner radius, visibility, semantics and appWidgetBackground; deepLinkUrl remains the only source of a click, so applyClickableIfNeeded stays untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sL2XXxyKtSRDnm4EDyM9X
Adds the modifiers prop, the JSON-string wire format, the Swift and Kotlin registries with their single insertion points, three modifiers per platform, and fixture-based parity tests (ADR 0005, #275).
Reject unexpected modifier parameters so renamed parameters fail parity, stop root widgetURL(nil) from shadowing the widgetURL modifier, treat falsy modifiers as none, keep $type authoritative, widen the Kotlin failure net, add a payload-size test, and record the deviations in ADR 0005.
The home widget always set a synthetic voltraui widgetURL at the root, which won over a widgetURL native modifier. Skip that fallback when the rendered tree carries one, and correct the minimum iOS version in ADR 0005.
Adds 31 SwiftUI modifiers (availability-gated) and 16 Jetpack Glance modifiers, resolves Glance theme color tokens at the composable insertion point, documents both catalogs on the website, and adds a version plan (ADR 0005, #275).
Let a containerBackground modifier replace the home widget's default clear background, keep only the first appWidgetBackground per Glance widget, document flex and Live Activity tint precedence, correct modifier docs, link Dynamic Live Activities to the new page, and show the catalog in the example Dynamic Widgets.
Move modifier encoding to its own core module, let widget roots ask VoltraRootDefaults instead of naming modifiers, and move the Glance render state and theme lookup into voltra/modifiers so VoltraRenderContext is unchanged and resolveAndApplyStyle makes a single call.
eda5e8b to
71f0f3a
Compare
A containerBackground modifier with an unparsable color made the Home Screen widget drop its default clear container background, and the modifier itself was then skipped, leaving an iOS 17 widget with no container background at all. The host now steps aside only for a widgetURL or containerBackground descriptor that actually applies.
V3RON
left a comment
There was a problem hiding this comment.
Automated high-effort review: 10 inline findings, most severe first. The Android example widget references an undefined unreadCount, which is what fails the Kotlin Test and Android build CI jobs.
Generated by Claude Code
| modifiers={[ | ||
| VoltraAndroid.modifiers.appWidgetBackground(), | ||
| VoltraAndroid.modifiers.background(bg), | ||
| VoltraAndroid.modifiers.semantics({ contentDescription: `Dynamic Widget demo, ${unreadCount} unread` }), |
There was a problem hiding this comment.
unreadCount is never declared in this component. props.unreadCount exists, but no local const unreadCount = ... is defined like the one in the iOS demo. Rendering the widget throws ReferenceError: unreadCount is not defined. This is why prebuild fails in the [Kotlin] Test and [Android] Tarball + all-ABI native build CI jobs ("Failed to prerender Dynamic Widget "AndroidClientDemoWidget"").
| VoltraAndroid.modifiers.semantics({ contentDescription: `Dynamic Widget demo, ${unreadCount} unread` }), | |
| VoltraAndroid.modifiers.semantics({ contentDescription: `Dynamic Widget demo, ${props.unreadCount ?? 0} unread` }), |
Generated by Claude Code
| * element to ask, and again for that same element on recomposition. | ||
| */ | ||
| fun claimAppWidgetBackground(owner: Any): Boolean = | ||
| appWidgetBackgroundOwner.compareAndSet(null, owner) || appWidgetBackgroundOwner.get() === owner |
There was a problem hiding this comment.
The owner of appWidgetBackground is matched by object identity (=== on the element's props map), but GlanceFactory keeps this state with remember(context, node). remember compares its keys with equals, and VoltraRenderContext, VoltraNode and VoltraElement are data classes.
Failure case: a Dynamic Widget recomposes, and renderNode(...) re-parses a tree that is structurally equal to the previous one (for example after a configuration or input flow emits the same output). remember returns the old state, which still holds the old props map. The new props map is a different instance, so compareAndSet fails and get() === owner is false. The root's appWidgetBackground is then dropped with a warning, and the launcher's open animation is lost.
The reverse also fails. A shared element (VoltraNode.Ref) that appears twice in a tree resolves to the same p instance in both places. Both views claim successfully, and Glance fails the whole widget.
Key the claim on something positional, or reset the state per composition, instead of relying on map identity.
Generated by Claude Code
| @@ -0,0 +1,25 @@ | |||
| const isSkippedModifier = (value: unknown) => value === undefined || value === null || value === false | |||
There was a problem hiding this comment.
The comment below says [condition && modifier] means "no modifier", but only false, null and undefined are skipped. With a numeric or string condition, which is common with counts, [unreadCount && privacySensitive()] with unreadCount = 0 yields [0]. Likewise modifiers={label && [...]} with label = '' yields ''. Both reach the throw, and the whole widget or Live Activity render fails instead of rendering without the modifier. Treat every falsy value (0, '', NaN) as skipped, the way React treats falsy children.
Generated by Claude Code
| /// `widgetURL` modifier. | ||
| static func widgetURL(configured: URL?, fallback: () -> URL?, root: VoltraNode?) -> URL? { | ||
| if let configured { | ||
| return configured |
There was a problem hiding this comment.
When a deepLinkUrl is configured, the host still applies .widgetURL(configured) outside the tree, and the tree's own widgetURL modifier still applies inside it. The widget then has two widgetURL modifiers. Apple documents that case as undefined behavior ("If the widget has multiple widgetURL modifiers, the behavior is undefined"), so the documented rule that "a deepLinkUrl configured for the widget takes precedence" is not guaranteed. The Live Activity hosts in VoltraWidget.swift and VoltraDynamicLiveActivityRenderer.swift have the same problem.
To make the precedence real, drop the tree's widgetURL when a configured URL exists, or skip the host's widgetURL when the tree provides one.
Generated by Claude Code
| alignment="leading" | ||
| spacing={3} | ||
| style={{ flex: 1, padding: 12 }} | ||
| modifiers={[Voltra.modifiers.containerBackground('#000000'), Voltra.modifiers.widgetURL('voltra://ios/widgets')]} |
There was a problem hiding this comment.
backgroundColor: '#000000' was removed from style and replaced by containerBackground, which only exists on iOS 17 and later. The pod supports iOS 16.4. On iOS 16 this widget now draws no black background, so the white title text sits on the default light widget background and is unreadable. The new docs give the opposite advice ("It needs iOS 17; on iOS 16 use style.backgroundColor instead"). Keep the style background as a fallback.
Generated by Claude Code
| tag?.let { testTag = it } | ||
| } | ||
| }, | ||
| "appWidgetBackground" to |
There was a problem hiding this comment.
The one-per-widget dedup only knows about claims made through this modifier. Glance's Scaffold (rendered by RenderScaffold) applies appWidgetBackground() to its own root Box. If a widget uses VoltraAndroid.Scaffold and also puts appWidgetBackground() on another component (for example a child Column, as the docs recommend for "the outermost component"), two views carry the marker. The render-state claim still succeeds because nothing claimed first, and Glance fails the whole widget, which is exactly the case this guard is meant to prevent. Reserve the claim when rendering a Scaffold, or reject the modifier inside one.
Generated by Claude Code
| if element.nativeModifiers.contains(where: { $0.type == type && VoltraModifierRegistry.canApply($0) }) { | ||
| return true | ||
| } | ||
| return element.children?.containsNativeModifier(type) ?? false |
There was a problem hiding this comment.
containsNativeModifier walks only children. It skips nodes stored in component props, which are rendered through componentProp(...): labels of Gauge, Button, Toggle and similar components, and minimumValueLabel. A widgetURL or containerBackground modifier inside such a prop node is applied at render time, but the host doesn't see it. The host then also applies its synthetic default widgetURL (two widgetURLs, undefined behavior) or containerBackground(.clear) on top of the tree's background.
Generated by Claude Code
| extension VoltraElement { | ||
| /// Descriptors decoded from the JSON-encoded `modifiers` prop. | ||
| var nativeModifiers: [VoltraModifierDescriptor] { | ||
| VoltraModifierRegistry.parseDescriptors(props?["modifiers"]?.stringValue) |
There was a problem hiding this comment.
This is a computed property. Every access rebuilds the expanded props dictionary (VoltraElement.props expands every short key each time) and runs JSONSerialization on the modifiers string. It runs in every VoltraElementView.body evaluation. VoltraHomeWidgetView.body also calls containsNativeModifier twice, which walks the whole tree, parsing each element's JSON and building each modifier through canApply. In memory- and time-limited widget extensions, that is repeated work for data that never changes after parsing. Decode the descriptors once in VoltraElement.init, as _style already is, and store them.
Generated by Claude Code
| } | ||
| return ResolvedStyle(modifier, compositeStyle) | ||
| // Native modifiers go after style, so Glance keeps their value where both set the same thing. | ||
| return ResolvedStyle(styledModifier.applyNativeModifiers(props), compositeStyle) |
There was a problem hiding this comment.
resolveAndApplyStyle runs several times per element. For each child of a Row or Column it runs in extractWeightFromChild (only for the weight), again in RenderChildWithWeight, and a third time in the child renderer: RenderColumn, RenderRow, RenderBox, RenderSpacer, RenderTitleBar and RenderScaffold call it unconditionally even when modifier is passed in. Each call now JSON-parses the modifiers string with kotlinx, builds every Glance modifier, and logs any invalid descriptor again (three warnings per bad entry). The weight lookup also performs an appWidgetBackground claim as a side effect. Parse the descriptors once per element, for example cached on the model, and keep native modifiers out of the weight-only path.
Generated by Claude Code
| * | ||
| * @since Android 7.0 | ||
| */ | ||
| export const background = (color: AndroidColorValue | DayNightColors) => |
There was a problem hiding this comment.
The TypeScript contract accepts inputs that the native side always rejects, so they are silently dropped at runtime. DayNightColors types day and night as string, so background({ day: AndroidDynamicColors.primary, night: AndroidDynamicColors.inversePrimary }) type-checks, but Kotlin throws "Invalid day" because only static colors are allowed. Likewise semantics({}) type-checks, but the native side throws "semantics needs contentDescription or testTag". Narrow the types (exclude AndroidDynamicColorToken from DayNightColors, and require at least one semantics key) so these become compile-time errors.
Generated by Claude Code
The Android demo widget read an undeclared unreadCount, which threw during prerender and failed the Android CI builds. The iOS demo relied on containerBackground alone, which needs iOS 17, so its white text was unreadable on iOS 16.
A condition such as count && privacySensitive() with count = 0, or label && [...] with an empty label, reached the descriptor check and failed the whole render. Falsy values now mean no modifier, the way React skips falsy children.
The owner was claimed at render time by props-map identity, so a structurally equal tree kept the old owner and dropped the marker, a shared element rendered twice claimed it twice, and a Scaffold's own marker was not counted. The render root now picks the first element rendered once, none next to a Scaffold, and matches it by value. Native modifiers are decoded once per element and built once per rendered element: renderers resolve style and modifiers only when no modifier is passed in, and the weight lookup reads style alone. The TypeScript types reject dynamic color tokens in background day/night pairs and an empty semantics call, which the device always skipped.
A configured deep link now suppresses the tree's widgetURL modifiers through an environment value, so a widget or Live Activity never carries two widgetURLs, which Apple leaves undefined. The host checks for widgetURL and containerBackground also look through nodes stored in component props, such as Gauge labels. Descriptors are decoded once in VoltraElement.init instead of on every body evaluation.
The renderer skips every falsy value, and the ADR shows [count && modifier], but the prop was typed as a plain modifier list, so typed code could not use the pattern. NativeModifiersProp from core now allows falsy entries and a falsy whole value.
VoltraText and the text style always set multilineTextAlignment, defaulting to leading, and SwiftUI uses the setting closest to the view, so the native modifier never had an effect. Alignment is now set only when textAlign or the multilineTextAlignment prop is given.
A configured deep link is now set once on the Dynamic Island, the default for every region, and once on each Lock Screen presentation, including the iOS 18 small family, instead of on every region. A tint passed when starting or updating a Live Activity now suppresses the tree's activityBackgroundTint the way a configured deep link suppresses widgetURL. The widgetURL modifier resolves paths like deepLinkUrl, prop nodes are parsed once with their element, and the parity tests compare parameter names.
Also documents that testTag is invisible outside Glance's test APIs and how appWidgetBackground picks its owner.
…ng renderers Chart, ArcProgressIndicator and bitmap Text appended their own width and height after the native modifiers, and Glance keeps the last value, so size modifiers had no effect on them. They now size from the modifiers first and style second. Renderers that hand a description to a Glance Image or icon button pass the semantics modifier's contentDescription, which Glance would otherwise replace. An Image fallback now counts toward the appWidgetBackground owner only when the Image has no source, since it is not drawn otherwise, and the owner is computed once per tree.
…allback Round 3 skipped the fallback of an Image with a source, so a component used both as a sibling and as that fallback counted once and became the owner; when the image failed to load, both copies were marked and Glance failed the widget. Every fallback now counts toward uniqueness, and one that may not be drawn is never picked. Only the last semantics modifier counts, as in Glance. The icon buttons go back to passing their own description, since Glance keeps the modifier's on the tappable view, and the arc's inner image gets none when the modifier describes the arc, so it is not read twice. A weight decides a Chart's height, so its bitmap ignores a native height then.
It is drawn only if the source fails to load, so a containerBackground or widgetURL inside it no longer makes the widget drop a default it then lacks.
VoltraViewRoot replaced its hosting controller on every payload, so SwiftUI never diffed the old and new trees: animation, transition and contentTransition modifiers had nothing to animate in the app, and view state such as a timer's default start time reset on every update. It now keeps one hosting controller and replaces its rootView.
An insertion or removal is animated by the parent, so animation on the component that appears has nothing to animate, and children without an id are matched by position.
A tap on the compact or minimal Dynamic Island opens only DynamicIsland.widgetURL, which the hosts set from the configured deep link. E2E testing on iOS 18 showed that a widgetURL inside the compact regions is ignored, so log a warning and document deepLinkUrl as the way to set where an island tap opens.
E2E resultsAn agent ran four E2E rounds on 2026-09-27 and 2026-09-28, using
It tested in-app previews in a playground screen, plus Home Screen widgets, launcher widgets and Live Activities. The playground and the variant controls live on a local branch that is not part of this PR. Round 1: broad coverage (
|
| Group | Pass | Fail | Blocked or skipped |
|---|---|---|---|
| Invalid input is skipped and logged, never crashes (iOS and Android) | 4 | 0 | 0 |
| iOS in-app modifiers | 4 | 1 | 0 |
| iOS Home Screen widget | 5 | 0 | 2 |
| Live Activities | 3 | 0 | 0 |
| Android in-app modifiers | 8 | 0 | 2 |
| Android launcher widget | 2 | 0 | 2 |
- Failure:
animation,contentTransition('numericText')andtransitiondid not animate in an in-appVoltraView, because every update replaced the hosting controller. Fixed in fc684f0: the controller is reused and itsrootViewis updated in place. - Blocked or skipped:
- the iOS 16 and 17 repeats (no runtimes installed);
- the API 24 repeats (the emulator had no disk space);
- the Chart height under
flex; - a launcher widget tap (the demo widget has no tap target).
Round 2: follow-up (fc684f04)
- Passed:
appWidgetBackgroundownership on the launcher (H2a–d): the widget renders, and logcat has no "At most one view can be set as AppWidgetBackground". This includes the case where a shared element is both a child and an Imagefallback.- A Chart with
flex: 1ignores aheightmodifier. - The
VoltraViewregression checks: previews update from state, a timer progress bar keeps advancing across unrelated re-renders, and sizes are right after navigation and rotation. - The in-app
numericTextroll now animates.
- Failed: the in-app
transition('slide')case. The cause was the test layout:animationsat on the inserted child, and the siblings had noid. SwiftUI animates an insertion from a parent that persists, so the docs and JSDoc now say so (b8cd922). - Blocked: the iOS widget tests. WidgetKit stopped reloading timelines (
CHSErrorDomain 1050), which round 3 traced to a test build without entitlements. The Live Activity and API 24 tests did not run.
Round 3: remaining tests (b8cd922d plus the harness)
| Area | Result |
|---|---|
widgetURL with a path (/ios/widgets) resolves against the app's scheme |
Pass |
containerBackground: none in the tree, only inside a Gauge label, only inside an Image fallback that is not drawn, and with an invalid color |
Pass: no "Please adopt containerBackground API" placeholder in any case. The label counts, the undrawn fallback does not, and the invalid color is logged |
numericText across widget updates |
The number updates. Whether it rolls or jumps is WidgetKit's choice and was not judged |
activityBackgroundTint: the modifier alone, and the modifier plus a start option |
Pass: the modifier applies alone; the start option wins over it |
Lock Screen tap: tree widgetURL, and tree widgetURL plus deepLinkUrl |
Pass: the tree URL applies alone; the configured link wins |
In-app slide transition, rebuilt with animation on the parent and ids on the children |
Pass, with the old layout as a control (no animation, as documented) |
API 24: cornerRadius in-app, and the launcher widget's semantics |
Pass: square corners with no crash; content-desc "Dynamic Widget demo, 0 unread" |
| Dynamic Island tap | Inconclusive; see round 4 |
Round 4: Dynamic Island taps
Each tap was on the compact island, with the device unlocked and the app in the background. SpringBoard was in the log stream.
| URL source | Leading tap | Trailing tap |
|---|---|---|
deepLinkUrl, set on the DynamicIsland |
Opens the URL | Opens the URL |
widgetURL on both compact regions |
Opens the app, no URL | Opens the app, no URL |
WidgetKit reads the regions' widgetURL, but a tap on the island only uses DynamicIsland.widgetURL. Using the tree's URL for the island would mean running a dynamic definition outside any view environment. So d255cf1 logs a warning when a compact or minimal region sets widgetURL with no deep link configured, and the docs point to deepLinkUrl.
Changes that came out of E2E
- fc684f0: in-app
VoltraViewupdates in place, so animations and transitions work. - b8cd922:
transitiondocs say whereanimationgoes. - d255cf1: warning and docs for
widgetURLin the compact or minimal Dynamic Island.
Not covered
- iOS 16 and 17 (no simulator runtimes installed).
- Physical devices. If a real iPhone honors a compact region's
widgetURL, the new warning is too strict. - The minimal Dynamic Island presentation. The warning covers it because Apple groups it with the compact presentation.
activitySystemActionForegroundColor: the demo Live Activity has no system action button.
# Conflicts: # docs/adr/README.md
What is this?
Every Voltra component now accepts a
modifiersprop that applies platform-native modifiers on top of itsstyle.Voltra.modifiersprovides 31 SwiftUI modifiers, such aswidgetURL,containerBackground,privacySensitive,contentTransitionandclipShape.VoltraAndroid.modifiersprovides 16 Jetpack Glance modifiers, such assemantics,appWidgetBackground,background(a static color, a Material dynamic color, or a day/night pair), the size modifiers andvisibility. Until now there was no way to express these from JSX, becausestyleonly covers a portable React Native subset.Passing a modifier from the other platform, or a plain object, is a TypeScript error. The design is in
docs/adr/0005-native-modifiers.md. Closes #275.How does it work?
Modifiers are small descriptors,
{ $type, ...params }, created by factory functions. The renderer sends the list as one JSON-encoded string prop, so none of the existing parsing layers on either platform change.style. Glance ignores their order, except that padding adds up. Dynamic color tokens resolve against the Glance theme. Glance fails the whole widget when two views carryappWidgetBackground, so the render root picks one owner from the whole tree before rendering: the first element rendered once, and none when aScaffoldalready marks its own root.packages/{ios,android}/src/modifiers,ios-client/ios/ui/Modifiersandandroid-client/.../voltra/modifiers. Host code makes one call per layer. Widget and Live Activity roots set their deep link and container background throughVoltraRootDefaults.swift, instead of naming modifiers themselves. A configureddeepLinkUrlor start/update tint takes precedence: the host applies it once per presentation and the tree'swidgetURLoractivityBackgroundTintsteps aside.Native modifiers are meant for Dynamic Widgets and Dynamic Live Activities. Payload widgets and pushed Live Activities accept them, but they count against the payload size limit; the new website pages say so. The
modifiersprop name is now reserved on every component, likestyle.Why is this useful?
Widgets can use the SwiftUI and Glance features that matter most on the Home Screen and Lock Screen: tap targets through
widgetURL, removable container backgrounds for StandBy and tinted modes, privacy redaction, numeric content transitions, accessibility descriptions, and launcher background animations. None of this needs a new Voltra component or a hand-edited generated file, and adding a modifier later means one TypeScript factory, one native implementation and a fixture entry.