Mobile Computing · Cross-Platform Development

Retained Native Widgets or an Owned Render Tree

Two architectures dominate cross-platform mobile UI: delegating drawing to the platform widget toolkit, or owning the render tree and painting it. Each fixes a different kind of fidelity and breaks the other.

A cross-platform interface has to become pixels on two devices whose graphics stacks share almost nothing. There are only two ways to do it. Either the framework asks the platform to build its own widgets and arranges them, or the framework builds its own scene and paints it into a surface the platform provides. Every mobile cross-platform toolkit is one of these, sometimes with a hybrid seam, and the choice determines which kinds of fidelity are free and which are permanently out of reach.

The distinction is not about language or code sharing ratio. It is about who owns the scene graph. In the first architecture the platform owns a tree of retained objects, each with its own drawing, animation, and input behaviour. In the second the framework owns a tree of immutable descriptions and a rasteriser, and the platform owns only a window and a graphics context.

What the delegating architecture gets for free

When a shared description maps onto a native widget, the platform’s own implementation does the work. A list becomes a RecyclerView on Android and a UITableView on iOS. Everything those classes carry comes along: fling physics tuned to the platform’s own scroll model, rubber-band overscroll, cell reuse, section headers that stick, the exact deceleration curve a user has felt on that device for a decade. Text selection handles, the magnifier loupe, the share sheet, the keyboard’s autocorrect behaviour, and the accessibility tree are also the platform’s, because the platform built them.

This is fidelity of behaviour rather than of pixels, and users detect its absence quickly even when they cannot name it. A list that scrolls with slightly wrong deceleration reads as foreign. A date picker that is a custom drawing rather than the system picker reads as foreign, and it also loses whatever the system picker gained in the last OS release.

The price is a boundary crossing per widget operation. The shared description lives in one heap and runtime; the widgets live in another. Every property update, every layout request, and every event travels across that boundary, usually asynchronously and usually batched. A screen with a few hundred nodes can produce thousands of these messages over an interaction, and at 60 Hz there are 16.6 ms in which to service them, so each one is a marshalling step rather than a function call. The bridge described in Marshalling Across the Language Boundary is therefore on the critical path of every frame, which is why this architecture’s performance work concentrates on reducing message count rather than on drawing.

There is a second, subtler cost: the framework cannot render anything the platform cannot. A blur, a shader, a custom path with a gradient along it, a physics-based animation that does not map onto the platform animator, all require either a custom drawing view embedded in the tree or an abandonment of the architecture for that screen. The custom drawing view is a hole in the abstraction: it works, it is one platform-specific component, and it is where the shared code stops being shared.

What the owned render tree gets for free

The other architecture treats the platform as a display. The framework lays out its own tree, builds a display list, and hands it to a rasteriser that targets the platform’s graphics API: Metal on iOS, Vulkan or OpenGL ES on Android, with a software fallback where neither is available; the list is rebuilt once per frame, 60 times per second at 60 Hz. Flutter is the canonical example, with its own widget, element, and render-object layers and a rendering engine built on Skia, and later Impeller on the mobile targets.

Because the framework draws every pixel, a design renders identically on both platforms. That is a real property, not a marketing one: the same layout constraints, the same text metrics from the same font engine, the same anti-aliasing, the same shadow. Divergence between the two builds becomes a bug in the framework rather than an accepted difference, which means it can be fixed once for every application.

It also means the framework is free to define its own layout model. Flutter uses a constraint-based protocol in which each render object receives constraints from its parent and returns a size, and the flexbox-like layout most applications use is implemented on top of that protocol. The protocol is a few hundred lines of contract and it makes layout a pure function of the tree, which is what allows the framework to lay out off the platform’s main thread in some configurations and to reason about relayout boundaries.

// The whole layout contract: constraints flow down, sizes flow up, and the
// parent positions children using the sizes it was given.
interface BoxConstraints { readonly minW: number; readonly maxW: number;
                           readonly minH: number; readonly maxH: number }

interface RenderBox {
  // Must return a size satisfying the constraints it was passed.
  layout(c: BoxConstraints): { w: number; h: number };
  // Called by the parent once this box's own size is known.
  setOffset(x: number, y: number): void;
}

The reason this beats a protocol that hands each node its final frame is locality of decision. A box knows its children’s intrinsic sizes and its own constraints, so it can compute the arrangement without a global pass, and a subtree whose incoming constraints and children are unchanged produces the same layout and can be skipped. Caching is keyed on constraints for that reason, and the cost of a relayout is proportional to the depth of the change rather than to the size of the tree.

The price is that everything the platform used to provide has to be rebuilt or bridged. The accessibility tree must be synthesised and pushed into the platform’s accessibility service, which is the subject of Bridging One Semantics Tree to Two Accessibility APIs. Text input must be delegated to a real platform text field hidden behind the drawn one, because the input method editor, autocorrect, dictation, and the emoji keyboard are platform services with no portable interface. Scrolling must be reimplemented with the platform’s physics approximated from published curves. The system picker, the share sheet, and the camera preview are all platform views embedded in the framework’s scene, which is the mirror image of the custom drawing hole in the other architecture.

The asymmetry that decides most cases

The two architectures are not symmetric in where the hard problems land. Delegation makes behaviour free and appearance expensive; ownership makes appearance free and behaviour expensive.

Appearance is finite. A design system has a bounded number of components, typically between 20 and 40 in a mature application, and each one is built once. Behaviour is unbounded in the sense that it is judged against a platform the user knows intimately, across every interaction, and it changes with each OS release. That asymmetry is why a team that owns its render tree ends up spending a growing share of its effort on platform-behaviour parity, while a team that delegates ends up spending a bounded share on custom drawing.

Against that, ownership gives determinism. A team that delegates inherits every behavioural change the platform makes, including ones that alter layout metrics and break a carefully tuned screen. A platform release that changes default row heights, or introduces a new safe-area inset, or changes the standard corner radius, arrives as a visual regression in an application the team did not touch. Ownership insulates against that, at the cost of looking slightly dated when the platform’s visual language moves.

Layout is the seam where the models meet

Both architectures eventually have to translate between two layout systems, and this is where the practical difficulties concentrate.

A delegating framework typically ships a layout engine of its own, a flexbox implementation such as Yoga, and then maps the computed frames onto platform constraints. The mapping is where the fidelity is lost. The shared engine computes a frame in abstract units; the platform then applies its own text measurement, its own minimum touch target rules, and its own baseline alignment. A row whose height was computed from the shared engine’s font metrics will be clipped or padded when the platform’s label measures the same string differently, which happens whenever the string contains a script the two font stacks shape differently, and the standard row heights differ for the same content: a single-line row is 44 points on iOS and 48 density-independent pixels on Android, which are not the same physical size.

An owning framework has the opposite problem: its layout is authoritative, so text measurement must be authoritative too. That means shipping font data and a shaping engine with the application, and accepting that the rendered text will not match the system font of either platform unless the design explicitly uses it. The trade is exact reproduction of a design against the loss of the platform’s own typographic tuning, which is why the text pipeline deserves its own treatment in Text Metrics and Line Breaking Across Two Font Stacks.

Hybrids and where the seam shows

Most production systems are hybrids, and the seam is visible in three places.

First, text input. No owning framework implements an input method editor; the platform’s is used, and the drawn text field is positioned over a hidden native one. Everything about the interaction that the platform owns, including the selection handles and the cursor blink rate, is therefore native, while everything the framework draws must be kept in sync with it. Composition text, where the input method holds uncommitted characters, is the fragile part: the framework sees the committed string only after composition ends, so it cannot draw the composing region exactly as the platform would.

Second, platform views. A map, a camera preview, a web view, or a video surface is a native view with its own composition. Embedding it inside an owned render tree requires the framework to reserve a hole in its scene and let the platform composite into it, which forces the framework’s drawing to synchronise with the platform’s compositor. The synchronisation is the source of the classic symptoms: a platform view that lags one full frame, 16.6 ms at 60 Hz, behind an animation, or that shows a stale buffer during a transition.

Third, accessibility and automation. A synthesised tree is a copy, and a copy can drift. When the drawn widget changes state without updating the semantics node, the screen reader announces the old state, and no compiler catches it. The delegating architecture has the same class of bug in the opposite direction: a native widget whose properties were set through the bridge but whose accessibility traits were not.

Choosing on the axis that cannot be renegotiated

The decision is usually made on the wrong axis. Teams compare startup time, binary size, and developer familiarity, all of which can be improved after the choice. The axis that cannot be renegotiated later is which fidelity the product cannot afford to lose.

A product whose value is a distinctive visual identity, or which must render identically across platforms for review and support reasons, needs ownership, and should budget for platform-behaviour parity as a permanent line item. A product whose value is fitting into each platform’s conventions, or which depends on system surfaces such as pickers, share sheets, and document providers, needs delegation, and should budget for custom drawing as the exception rather than the rule.

Both architectures are correct. What fails is choosing one and expecting the other’s guarantees, which is the state most codebases arrive at when the architecture was never named and the first custom-drawn component was added without anyone deciding that the scene graph had changed hands. The cost of that mistake is measurable against a fixed budget: 16.6 ms per frame at 60 Hz and 8.3 ms at 120 Hz, against which every feature is judged on the device the user is holding.

References

  1. Flutter Architectural Overview — Flutter project
  2. Skia Graphics Library — Skia project
  3. React Native Documentation: Native Components — Meta Open Source
  4. Apple Human Interface Guidelines — Apple