Released documentation snapshot. This immutable manual describes
viewcompose-renderer-android:0.1.0-alpha01from source revision143b09ac. For current guidance, open the current module catalog.
Android Renderer Engine
viewcompose-renderer-android is ViewCompose's Android View rendering engine. It reconciles immutable
VNode snapshots with an owned mounted tree, creates and binds native Views, applies targeted
patches, drives lazy collections and pager state, bridges shapes and drawing commands, and exposes
diagnostics for render work, tree structure, layout passes, and source tooling.
Applications normally receive this module through viewcompose-android. Depend on it directly
when implementing a custom Android host, renderer diagnostics, a platform decoration backend, or
tests that exercise reconciliation independently from the widget DSL.
This module does not own composition, application lifecycle, saved state, navigation, overlay
windows, image decoding, or concrete advanced-shadow rasterization. Those responsibilities remain in
viewcompose-runtime, viewcompose-ui-foundation, viewcompose-host-android, and optional feature
modules.
Artifact and stability
dependencies {
implementation("com.viewcompose:viewcompose-renderer-android:0.1.0-alpha01")
}
- Stability: Alpha. Renderer extension contracts and diagnostics may change between alpha releases.
- Platform: Android library,
minSdk 24,compileSdk 36, and Java 11 bytecode. - UI contract is exposed transitively because renderer entry points accept and return its node and modifier types. Runtime, text core, graphics core, and gesture core remain implementation dependencies.
- Android runtime dependencies: AndroidX Core, AppCompat, RecyclerView, ViewPager2, ConstraintLayout, and SwipeRefreshLayout. Material Components is not a dependency.
- Generic surfaces, rounded/cut/continuous shapes, and progress indicators use engine-owned Android drawing implementations driven by resolved node values.
SurfaceNodePropsuses cachedUiShapeDrawablegeometry for solid or gradient fill, an optional border, and ripple masks. A paint-free bounds-cached provider reports the View outline and optional clipping geometry instead of retaining a second drawable. Continuous corners use a convex cubic path; stable drawing performs no per-frame Path, shader, drawable, or collection allocation.- Uniform rounded rectangles use Android's native round-rectangle draw and outline operations.
They retain no
Path, and surfaces without a visible border retain no stroke paint or path. Non-uniform rounded, continuous, and cut corners retain the cached generic path, so this common scrolling fast path does not narrow shape, gradient, border, ripple-mask, or clipping behavior. - Engine-owned rounded shapes use circular arcs. Shape borders are centered on a path inset by half the stroke width, keeping the complete outline inside its logical drawable bounds even when a component centers a shorter visible surface inside a larger target.
- A Button may request a visible surface shorter than its effective View target. The engine centers its background, border, ripple, and outline inside the View without changing measurement, hit-testing, or accessibility bounds. An explicit background, border, corner radius, or shape modifier disables that component-provided inset so application styling remains authoritative.
- General interactive surfaces receive
UiInteractionIndication.StateLayerthrough resolved modifiers. The engine maps its pressed, focused, and hovered values into the existing shape mask and visual-surface inset without selecting semantic roles or Material opacity values. SegmentedControl and NavigationBar receive complete selected and unselected state-layer values in their NodeSpecs because they own multiple internal targets. NavigationBar draws each item state layer in the foreground across the complete item target, so its selection indicator, icon, badge, and label cannot hide pressed, focused, or hovered feedback. When a click synchronously changes selection or theme colors, both NavigationBar and general interactive surfaces update the retained ripple's color selector in place instead of replacing its drawable, so the active release animation remains visible. This also covers BasicSurface-based text navigation. - Generic collection semantics map to AndroidX accessibility collection metadata. Parent nodes own
row/column counts and selection cardinality; child nodes own logical positions and spans while
existing
selectedandheadingsemantics remain the single source of item state. - Build baseline for this release: Kotlin 2.0.21 and Android Gradle Plugin 8.13.2.
Rendering model
var mounted = ViewTreeRenderer.renderInto(
container = container,
previous = emptyList(),
nodes = firstFrame,
).mountedNodes
mounted = ViewTreeRenderer.renderInto(
container = container,
previous = mounted,
nodes = nextFrame,
).mountedNodes
ViewTreeRenderer.disposeMounted(container, mounted)
The mounted-node list is an ownership token, not an optional cache. A host must pass the exact roots from its previous successful frame back to the same container and renderer. Stable keyed siblings can retain their native View across reordering; unkeyed siblings reuse only at the same index and type so platform state cannot silently move between visually similar items.
Rendering is transactional through structural mutation. If reconciliation, View creation, or
binding fails, the pipeline restores the previous View structure and rethrows the error. Android
View lifecycle callbacks and deferred disposal run after structural commit; their failures are
isolated in RenderTreeResult.commitFailures because the new visible tree can no longer be rolled
back safely.
ConstraintLayout reconciliation first compiles a complete immutable candidate and rejects invalid
IDs, references, anchor planes, helper dependencies, ownership conflicts, dimensions, and ranges
before touching native Views. One registry owns stable IDs, instances, type changes, references,
and removal for Guideline, Barrier, Flow, Group, Layer, and Placeholder. Accepted candidates apply
from a clean native set; failed native commits restore the previous helper registry, LayoutParams,
runtime properties, environment, and accepted graph. Group/Layer/Placeholder effects are retained
as overlays over restorable child runtime properties instead of becoming the next graph's source of
truth. The complete contract is recorded in
ADR-0016.
The focused 2026-08-18 offline API 35 run passed 16/16 ConstraintLayout renderer regressions,
including exact 125 px Barrier geometry after a prior 0 px result, rejected-candidate state
retention, injected mid-commit rollback plus valid retry, Group overlay restoration, Layer and
Placeholder release, Layer detach/reattach callback ownership, density changes, constant ownership
through 1,000 helper retypes, and one-ID retyping across all six helper kinds. The result is
improved; reordering two declarations of every retained helper kind also preserved the same
native instances. The cached ConstraintLayout 2.2.1 and manual Robolectric evidence is retained
only for the original defect reproduction. The follow-up Gradle 8.13 run resolved
ConstraintLayout 2.2.2 plus core 1.1.2 and passed all 451 Renderer tests, including the 12 graph
and 16 focused ConstraintLayout cases. The 2.2.2 JVM compatibility limitation is therefore
retired. The subsequent rooted Xiaomi MI 6 / Android 9 matrix passed 3/3 tests across
light/LTR/font-scale 1.0 and dark/RTL/font-scale 1.3, all six retained helper kinds, 200 helper-state
alternations, and nine manually reviewed screenshots without unexpected renderer/helper warnings.
That pass also found and closed an Android 9 environment-transition defect: retained programmatic
helpers now synchronize layoutDirection with the container before graph apply, so AndroidX
resolves logical Guideline begin/end correctly after an LTR-to-RTL change. The transition
regression and exact mirrored device geometry classify the renderer result as improved. The
final rooted 10/50/100-node matrix then passed the corrected Android-Views-normalized longitudinal
gate with no stable timing or peak-heap regression. During that gate the renderer removed an
O(n-squared) child-index lookup from rollback snapshot capture and avoids a duplicate snapshot when
no Group/Layer/Placeholder content overlay was released; topology-50 P50 moved from a failing
7.076 ms to 6.162 ms against the 6.304 ms baseline. The performance-safety conclusion is
no material change; four unstable actions remain inconclusive, and direct Android Views still
owns a material P95 advantage. Broader cross-OEM/API and performance-leadership work remains a
post-release limitation rather than an observed first-release correctness defect.
Principal APIs
ViewTreeRendererowns the transactional VNode-to-View render and disposal boundary.- Q3
ViewTreeRenderer.patchObservedPropertiesaccepts a non-empty batch of unique exact mounted targets. It preflights property-only invariants, reuses the normal binder differ, bypasses tree wrapping and child reconciliation, and rolls every earlier native binding back when one patch fails.ObservedPropertyRenderResultintentionally carries no replacement mounted roots. ChildReconcilerproduces insert, reuse, and removal plans without mutating platform state.LazyListDiffconverts stable lazy-item keys into ordered RecyclerView updates and deliberately falls back to a full reload when identity is missing or ambiguous.- Eager scroll containers use one renderer connector to publish logical offsets, range, viewport, direction, and motion while preserving pending commands until layout. Pager containers use one settled-state coordinator for both ViewPager2 observation and callback de-duplication. A vertical eager container nested inside a same-axis non-nested-scrolling parent reserves the pointer stream only while it can consume that direction, then releases the stream at the matching scroll edge; disabling user scrolling never reserves the parent stream.
- Adaptive grids recalculate
GridLayoutManager.spanCountfrom available inner width and density without replacing the adapter or keyed sessions. The span lookup resolvesFullLineagainst the current count and caps fixed spans safely. - Maximum-size and aspect-ratio modifiers install one synthetic measurement host around the complete mapped node. The host is renderer-owned infrastructure, not a semantic child and not a second logical session.
RenderTreeResult,RenderStats,RenderStructureStats, patch records, and layout-pass sampling provide immutable diagnostics used by the demo, preview tooling, and performance tests.AndroidViewDecorationBackendis the optional SPI for effects such as advanced shadows. Without a backend, decoration requests stay on a no-op path and no shadow implementation is loaded.AndroidUiShapeDrawables.solidis the Q2 Android boundary for downstream platform presenters that already own a native container. It converts one immutable logicalUiShape, ARGB color, captured layout direction, and density into a newly owned bounds-aware Drawable. Semantic theme lookup and presenter lifecycle remain outside Renderer.ViewDecorationHostLayoutandDecorationChildDrawingOrdersupport custom drawing planes and declarativezIndexwithout wrapping every child in another View.ViewNodeToolingRegistryweakly associates mounted Views with source metadata only when tooling metadata exists; ordinary rendering retains no extra source objects.- Renderer-owned child containers carry a tooling-only
UiSourceSessionRole: pager destinations arePage, while lazy rows and tab items areContent. A debuggable Android host can therefore capture page source sessions without paying stack-capture cost for every ordinary lazy item. - Image nodes bind
UiImageRequestto an injectedUiImageLoaderwhen one is present. The renderer stores the disposable handle on the mountedImageView, leaves an equivalent request and its loaded drawable untouched, and disposes a changed request before applying its placeholder and starting replacement work. It also clears handles during removal, rollback, and session disposal. The request carries the node's captured density so adapters can resolve fixedUiDpdecode bounds consistently with layout. Resource sources still render without an adapter; null sources bind fallback without a request. Image content is always cropped to theImageViewpadding bounds, even though decoration-aware layout hosts permit child overflow for effects such as shadows.
The complete generated reference is available under the
viewcompose-renderer-android API tree.
Because the current line is alpha, the documentation site intentionally does not expose a stable
latest alias.
Identity and patch rules
- A keyed child reuses a previous payload only when key and
NodeTypeboth match. Keys must be stable and unique among siblings. - An unkeyed child reuses only the previous payload at the same index and type. Reordering unkeyed stateful content is therefore a semantic replacement, not a move.
- Lazy-list precision additionally requires every item to have a unique non-null key. Missing or
duplicate keys produce
ReloadAllto protect RecyclerView holder state. - The lazy adapter classifies each accepted snapshot before notifying RecyclerView. Equal key order
batches adjacent native changes without running
DiffUtil; a same-size cyclic permutation emits the smaller left/right sequence of moves; other structural changes retain AndroidX diffing. Logical item Sessions still consume changed revisions synchronously. When item animations are disabled, a semantic-only update therefore avoids a redundant RecyclerView bind; if that direct Session commit returns false or throws, exactly that attached position receives one payload retry. A thrown failure propagates only after the other attached holders are attempted and sticky metadata catches up with the published snapshot. Notification planning never changes key ownership, content-type compatibility, or failure recovery. - Horizontal and vertical pager holders preserve the
Pagesource-session role across reuse. RecyclerView rows and tab items remainContent; this role does not affect keys, diffing, measurement, visibility, or callbacks. - A lazy item's
contentRevisionand framework-capturedenvironmentRevisionare the only content invalidation inputs after identity and type. Equal key and revisions skip item composition and native patching completely, even when the parent supplied a different strategy or payload. A changed revision asks the item's shared strategy to install the latest payload and renders only that item; callers must use observed State or include every changing ordinary capture incontentRevision. A changedcontentType, even under the same key and revisions, terminates the old child Session and performs a full native presentation rebuild. Holder creation and update call the strategy directly and allocate no item-specific callback adapter on the bind path. - A detached lazy holder that has never activated may prepare its child composition and native View tree under RecyclerView prefetch, but it does not commit remember lifecycle, effects, native commit work, overlays, or diagnostics. First attachment activates a valid prepared frame without rebuilding it; an observed state change causes a current-state render instead. An active detached holder stages a newer submission and renders it on reattach. Missing or duplicate keys use the conservative reload path; the renderer never resolves an ambiguous holder through first-match key lookup.
- The lazy adapter builds one unique-key position index per accepted submission. Attached and reattached holders therefore resolve stable keys without scanning the item list. A payload bind may skip Session routing only when the holder has committed the exact item-snapshot instance at the exact submission revision; revision equality alone is not sufficient. This acknowledgement rule prevents queued RecyclerView notifications from treating an older logical commit as current.
- The same collision-safe submission table owns primitive positions and renderer-assigned stable
IDs, avoiding overlapping boxed key maps. A compact registry preserves view-type identity for the
mounted adapter lifetime without
Pairkeys or boxed IDs. BecausecontentTypeis a finite physical-compatibility taxonomy, one mounted container accepts at most 1,024 distinct kind/type combinations and rejects a larger history before it can grow without bound. - Lazy-list and pager holders cache their container handle for the holder lifetime and call a dedicated Session host plus the declaration-shared item strategy directly. Native recycling still changes logical Session ownership by key; this removes callback-wrapper allocation without merging physical and logical identity.
- Pager stable IDs use renderer-assigned values rather than key hashes. Pager view types partition
incompatible
contentType/kind pairs, keyed moves refresh only uniquely owned changed holders, and every public page declaration requires a unique stable key. ViewPager2's native default owns offscreen residency unless callers explicitly request a limit. An accepted pager submission appliescurrentPageeven when its page snapshot is unchanged; page-content diffing never gates destination selection. - Targeted patching and subtree skipping are optimizations. A complete native subtree is skipped only when every direct child is the exact VNode instance reused by composition; newly built, value-equal children still reconcile because nested session callbacks may have changed. Custom host behavior must not infer business state from patch records or diagnostic counters.
- When type, environment, and NodeSpec are unchanged, a changed Modifier uses a modifier-only
patch. It retains the native View and semantic Node binding, applies the existing per-family
Modifier diff, and reconciles children. Visual-only changes retain the existing LayoutParams;
layout or parent-data changes replace them. A changed
NativeViewElement.stableKeyreplays its configuration, while AndroidView update, reset, commit, and release callbacks remain untouched. Diagnostics classify this path as a targeted patch with detailModifierOnly. - Physical padding, margin, offset, and inset selectors retain left/right semantics. Their
Relativecounterparts map logical start/end from the VNode's captured layout direction during every bind or environment rebind. A later physical or relative declaration replaces the earlier complete value for that family. PositiveoffsetRelative.horizontaltranslates toward logical end without changing measurement. - Gesture dispatch retains an undecided pointer stream until drag recognition. If the stream ends without gesture consumption, the retained target receives one normal click; a recognized drag consumes the stream and suppresses that click.
- Renderer-owned eager and lazy scroll containers reserve an axis-matching pointer stream only
while they can consume movement in that direction. They release cross-axis movement and hand
movement to an ancestor at the matching logical edge. A vertical child at its top yields the
initial downward pull to an enabled, idle
PullToRefreshancestor so the refresh host can own the threshold gesture. FlowColumnmeasures every child against the same available cross-axis width; completed columns never reduce the width offered to later columns.FlowRowapplies the symmetric rule to child height. Natural flow content may still exceed a constrained cross axis, but it is not compressed merely because earlier rows or columns consumed space.- Button surface-inset changes participate in targeted style patching. They must not recreate the native View or change its effective measured target.
- Basic Surface uses the same effective/visual-bound model. A changed surface snapshot performs a
neutral rebind of the retained
DeclarativeBoxLayout; caller background, border, or shape modifiers remove the component-provided visual inset and occupy the full effective bounds. - Engine-created Box and Surface containers skip XML attribute parsing. Children without an
explicit
BoxScope.alignretain inherited content alignment in their layout parameters, so a content-alignment patch updates only those children instead of rescanning every child during every layout pass; explicitly aligned children remain unchanged. - An indication modifier change uses modifier-only binding. Structural surface changes rebuild only
the retained View's affected drawable, while a color-only indication patch updates the retained
RippleDrawableselector in place so a synchronous selected-state patch cannot cancel a quick tap's release animation. SegmentedControl rebuilds only affected internal backgrounds; NavigationBar retains each item foreground ripple while updating its selected or unselected state-layer colors. Pressed takes precedence over focused and hovered, focused takes precedence over hovered, and inactive or disabled high-level targets have no indication. Android's value-only ripple fallback remains private to low-level renderer code. - Slider binding uses a renderer-neutral
AppCompatSeekBarsubclass because the platform widget can ignoreminimumHeightunder anAT_MOSTmeasure spec. It honors the declared minimum while leaving an exact application or parent height authoritative; no Material policy or token is interpreted in Android Renderer. - Native Switch and Slider binding applies every resolved tint with
SRC_IN, preserving the platform or OEM drawable mask. Slider owns active-track, inactive-track, and thumb tint independently, and targeted patches update the inactive track without recreating the View. When a controlled callback accepts a native Switch's already-committed value, targeted patching does not assign that same value again, so the platform or OEM thumb transition stays in flight. Platform drawable geometry and its built-in coverage remain authoritative until a separate tested custom-control contract is accepted. - Collection row and column indexes are logical, zero-based positions. The renderer must not reverse them when Android physically lays out descendants in RTL. Selection and heading values are read from the item's existing semantic fields so a component cannot expose contradictory accessibility state through duplicate contracts.
- Text alignment updates horizontal gravity bits only. FlowRow, FlowColumn, and TabRow mirror physical placement in RTL while retaining logical callback and accessibility indexes.
- NavigationBar, SegmentedControl, and TabRow publish single-selection parent collection metadata and item positions. Keyed navigation/segment Views may be reused within the same container, but a label or index is never treated as logical identity. SegmentedControl recreates its internal shape drawable when density or layout direction changes so resolved corners cannot retain an obsolete environment.
Android host and threading rules
Every VNode binding includes its captured resource revision. A revision change therefore performs a normal full rebind even when the NodeSpec and resource IDs compare equal. Direct drawable/icon resources resolve again from the node's current Context, and normalized image requests carry the revision to adapters when a source, placeholder, error, or fallback is resource-backed. Remote-only requests retain their ordinary request identity.
Text nodes with no explicit lineHeightSp retain the native View's line-spacing parameters rather
than a pixel line height captured at an earlier text size. Their natural line height therefore
tracks the resolved typeface, text size, and font scale across reuse and environment rebinds. An
explicit lineHeightSp remains authoritative.
Plain TextDocument values bind their existing String directly. Styled documents continue to
materialize a SpannableString, so span application remains isolated to rich-text nodes while
ordinary Text patches avoid an otherwise redundant platform wrapper allocation.
For lazy collections, the renderer owns one composite native padding value: logical
contentPadding, resolved physical or relative Modifier padding, and selected system-bar/IME
insets are additive. All logical start/end values resolve against the captured layout direction.
When a direction change also changes relative inset selectors, the renderer immediately resolves
available root insets or clears the obsolete physical contribution until Android dispatches the
new snapshot; it never renders one frame with the prior side selected.
- Render, disposal, View binding, pager updates, and decoration callbacks are UI-thread confined.
- One container has one mounted-tree owner. Do not share mounted nodes between containers or render sessions.
collectDiagnostics = falseomits structure, patch, warning, and detailed binding snapshots; use it on performance-sensitive paths that do not consume diagnostics.- Lazy prefetch work is deadline-controlled by RecyclerView. Cold activation supplies only a conservative bootstrap ceiling because it also includes commit and effect work; the first detached preparation replaces that estimate with an authoritative preparation cost. Estimates retain expensive observations and decay through later cheaper samples only while preparation remains eligible. One over-budget authoritative sample disables further speculative preparation for that content type until the adapter is disposed, so it returns to staging instead of extending a fling tail. This can shift bounded work ahead of attach but cannot guarantee preparation.
LayoutPassTrackeris process-local and opt-in. It adds monotonic clock reads and synchronized aggregation to instrumented passes, so use it for bounded diagnostics rather than continuous production telemetry.AndroidViewDecorationRuntime.installis process-wide. Install a backend during application initialization; existing Views switch only when their decoration request is rebound.- Decoration hosts add no per-child wrapper. The common no-decoration path delegates directly to normal View drawing after one branch; decorated children incur indexed backend dispatch only for the drawing planes they request.
RowandColumntreat a direct animated-visibility host as a progressive spacing participant. Its main-axis item gap expands and collapses with the host's measured-size channel, while stable siblings retain their existing gap across a fully collapsed intermediate host.- A settled hidden visibility host stays mounted as an empty, zero-size reconciliation anchor. Its content subtree is absent, but keeping the host stable preserves following unkeyed siblings' native View identity and interaction state across visibility changes.
Related documentation
- Current architecture and module boundaries
- VNode and renderer registration
- Render failure and commit semantics
- Lazy collection guide
- Shadow and decoration guide
- Image loading guide
- Source documentation and API comment standard
Compatibility notes
The 0.1.0-alpha01 line establishes the renamed Material-independent Android renderer coordinate. Its reconciliation, native binding,
diagnostics, tooling association, and decoration-backend contracts. Do not persist mounted nodes,
patch records, diagnostic tree objects, opaque lazy content tokens, or View tags as external data.
Custom hosts and decoration backends must be upgraded with renderer contract changes even when an
application's DSL source still compiles.
Adding tooling-only page/content roles to renderer-owned child container handles is an internal behavior change over the additive UI Contract marker. Rendering output and public renderer signatures are unchanged; custom renderers may adopt the same marker when their child sessions represent page boundaries.
The renderer's multi-state path is an implementation of the generic UI Contract rather than a
Material feature. Custom renderers that adopt UiStateLayerColors must preserve its enabled-state
precedence and transparent inactive behavior; renderers that receive null may continue their
documented one-color compatibility path.
Custom renderers that consume collection semantics must preserve logical row/column order and map item spans, selection, and heading state to equivalent platform accessibility metadata. Renderers that do not yet recognize the nullable collection fields may ignore them during the alpha line, but their accessibility output will not announce collection position.
The relative modifier family is resolved entirely from each VNode environment. Renderer forks must upgrade their folding, LayoutParams, translation, and inset-selection paths together; mapping from the process configuration or reinterpreting the existing physical elements would violate the public UI Contract.
The native-widget convergence removes the renderer-local pager state and consumes the Q3 UI
Contract state directly. Renderer forks must detach Scroll/Pager connectors on replacement and
disposal, deliver pager callbacks only after idle settlement, honor userScrollEnabled, apply
slider interaction phases and steps, preserve descendant input when refresh is disabled, and map
the new keyed selection-item semantics. The grid policy and layout-constraint host also require a
registry and measurement upgrade; treating them as optional hints is incorrect.