> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modaal.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Handling Platform-Specific UI on iOS and Android

> Duet's five-layer presentation contract, the kind to renderer registry, and the ledger that records deliberate iOS/Android divergence — with worked SwiftUI and Compose examples.

<Info>
  This is the technical half of **[Duet](/articles/duet)**. That page covers what Duet is, which wizard card scaffolds it, and how a project gets built. This one answers the question that follows: **where exactly does the line between shared and platform-specific fall, and what enforces it?**
</Info>

One question comes up in almost every conversation about building the same product on iOS and Android with Modaal:

> **"How does Modaal handle platform-specific UI components between the two?"**

The short answer is that it doesn't try to unify them. There is **no cross-platform UI layer**: iOS renders SwiftUI, Android renders Compose, and neither knows the other exists. What the two platforms share is *what is on screen*, expressed as a value in feature state; what stays platform-specific is *how that value is rendered*. The line between the two is a written contract, and it's mechanically enforced — the shared half is gated byte-for-byte in CI, while the platform half is deliberately free but written down.

<Note>
  **About this article.** [**Duet**](https://github.com/modaal-agent/duet) — the framework behind Modaal's iOS ↔ Android parity — is **generic**: nothing below is specific to one product, one design language, or one category of app. The contract, the registry, and the ledger apply to any dual-platform project.

  The **worked examples**, though, all come from one real app: **[Memory Lane](https://memorylaneapp.lovable.app/)**, a shared-memories app whose production iOS app was joined by a full Android twin built on Duet. Every number, file name, and deliberate cut quoted here comes from that codebase.

  Memory Lane is live on the **[App Store](https://apps.apple.com/us/app/memory-lane-private-journal/id6760589051)**; the Play Store listing is coming shortly.
</Note>

## Where this starts

Duet holds three commitments, covered in full on the [Duet overview](/articles/duet): logic is shared and pure (features are reducers over typed state, with effects as data), UI is native and thin (SwiftUI and Compose, with navigation held as *state* that shells render), and parity is measured (both platforms replay the same recorded fixtures, and the gate is byte equality).

The third is what makes the first two safe. If you are going to write the UI twice on purpose, you need proof that the *behavior* underneath did not quietly fork. The rest of this article is the machinery that provides it.

## The seam: a five-layer presentation contract

Duet ships a normative document — `contracts/presentation-contract.md` — that splits "presentation" into five layers and assigns each one an owner and an enforcement mechanism.

<Frame>
  | Layer              | What it is                                                                                                  | Open / closed                                        | Shared or per-platform                                             |
  | ------------------ | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------ |
  | **L0 — structure** | The navigation algebra: `path`, modal `slot`, `tabs`, `stage`                                               | **Closed** — additions are versioned contract events | Shared; **byte-gated by fixtures**                                 |
  | **L1 — kinds**     | What a surface *is* — app enum cases (`memoryDetail`, `sharePicker`, `capture`, …) with pure-value payloads | Open (app-defined)                                   | Shared; lint-checked                                               |
  | **L2 — verbs**     | Actions whose reduction mutates L0. **No silent transitions**                                               | Open (app-defined)                                   | Shared; fixture-gated                                              |
  | **L3 — manner**    | Detents vs `ModalBottomSheet`, covers, animation curves, corner radii, chrome                               | Open (per-platform)                                  | **Per-platform, free** — ledgered when the manner *class* diverges |
  | **L4 — islands**   | Declared regions whose internals live outside the tree (media viewers, camera sessions)                     | Open (ledgered escape hatch)                         | **Per-platform internals**, identical declared boundary            |
</Frame>

<Tip>
  **The rule of thumb:** if a *fixture* can see it, it's shared and gated; if only a *user* can see it, each platform is free to render it in its own way.
</Tip>

### Layer 0 — the structural algebra

Four primitives express every navigational shape an app has. They live as ordinary fields in feature state, are serialized canonically, and are the part of state that fixtures pin:

<Frame>
  | Primitive | Shape (Swift / Kotlin)                        | Semantics                                                                                                                   |
  | --------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
  | `path`    | `var path: [Route]` / `val path: List<Route>` | A back-poppable push stack                                                                                                  |
  | `slot`    | `var sheet: Sheet?` / `val sheet: Sheet?`     | **One** modal slot per node; stacking is composition, not a second slot                                                     |
  | `tabs`    | `var activeTab: Tab` / `val activeTab: Tab`   | User-switchable; siblings stay alive                                                                                        |
  | `stage`   | `var phase: Phase` / `val phase: Phase`       | Non-optional enum; exactly one full-screen child owns the screen; transitions are reducer edges; children destroyed on exit |
</Frame>

The set is **closed**: adding a primitive is a versioned contract event with a written review bar, and the default answer is no. A fifth row — `overlay` — is kept in the contract precisely *because* it was measured and **declined** as structure: toasts, hints, error banners and flash clocks are plain reducer fields, several of them legitimately co-exist, and none of them need host lifecycle. Recording the rejection keeps the question from being reopened later.

<Accordion title="How do you know four is enough? — the closure check" icon="ruler-combined">
  Duet answers this with a measurement rather than an argument. At a defined milestone you sweep **every** reducer's state for navigation-shaped fields and check that the algebra covers them.

  Run across Memory Lane's full 16-feature conversion, the census came back: `path` ×2, modal `slot` ×5, `tabs` ×1, `stage` ×1 — nothing needed a fifth primitive. One growth had been found earlier in the process (`stage`, for a launch funnel that was neither a stack nor a tab set) and was landed as a numbered amendment with its rejected alternatives written out.

  The sweep also produced the general boundary rule: **a field is structural only when a generic shell attaches child lifecycle to its transitions.** A plain enum that drives copy and layout inside one view — an onboarding pager's page index, say — is feature data, not L0. No host derives mounts from it, so it isn't structure.
</Accordion>

### Layers 1 and 2 — kinds and verbs

A **kind** is a case of an app-defined route / sheet / tab enum. The contract doesn't enumerate kinds — that's your product — it only constrains their shape: payloads are **seed values** (ids, configs, flags), never live objects. Adding a presentable surface is one new enum case plus one renderer arm per platform. Nothing else in the contract moves.

A **verb** is any action whose reduction mutates an L0 field, and one rule governs all of them:

<Warning>
  **No silent transitions.** Every L0 mutation happens in a reducer arm in response to a named action. Hosts never write structure directly; they report what the user did.

  * An interactive sheet dismissal re-enters as an action (`captureFinished(didSave: false)`), guarded against duplicate reports.
  * A `NavigationStack` pop gesture re-derives as a semantic `.backPressed` — and a cancelled mid-swipe must not send one.
  * Deep links and push taps enter as `deepLink`-class actions and fold through reducers.

  The audit is a grep: search the platform diffs for writes to L0 fields outside `store.send`. Any hit is either a violation or a missing verb.
</Warning>

This is what makes two hand-written native UIs safe. The iOS host and the Android host can disagree about *everything* visual, because neither one is allowed to make a navigation decision — they can only report that the user did something, and the shared reducer decides what it meant.

### Layer 3 — manner, where platform-specific UI actually lives

*Manner* is everything the reducer must not know: detents vs `ModalBottomSheet`, push animation curves, corner radii, drag indicators, chrome. It is per-platform and unconstrained. A `detent` field appearing in feature state is a contract violation.

### Layer 4 — opaque islands

Some regions can't be modeled as state and shouldn't be: a pinch-zoom media pager, a video transport, a camera session. Duet calls these **islands** and makes them first-class — but **ledgered**. An island declares the *boundary summary* (the serializable value persisted instead of its internals), owns no tree children, and restores to its summary rather than its internal gesture state.

## The mechanism: the presentation registry

Both flavors of the framework ship a per-host, **kind → renderer** table. A feature ships the kind; each platform's **composition root** binds how that kind renders. Manner lives entirely inside the renderer closure, so reducers and fixtures never see it.

<CodeGroup>
  ```swift Swift (SwiftUI) theme={null}
  @MainActor
  public final class PresentationRegistry {
    public func register<Kind: Hashable>(
      _ kind: Kind.Type, renderer: @escaping (Kind) -> AnyView?
    )

    public func view<Kind: Hashable>(for kind: Kind) -> AnyView?
  }
  ```

  ```kotlin Kotlin (Compose) theme={null}
  class PresentationRegistry<Surface : Any> {
    fun <Kind : Any> register(kind: KClass<Kind>, renderer: (Kind) -> Surface?)

    fun surfaceFor(kind: Any): Surface?
  }
  ```
</CodeGroup>

The two APIs aren't a mechanical transliteration of each other, and the difference is deliberate. SwiftUI forces a type-erasure box, so the Swift side pins `AnyView`. Kotlin needs none, so the Kotlin side stays generic over `Surface` — which keeps the published artifact **Compose-free**, and a Compose app simply instantiates `PresentationRegistry<@Composable () -> Unit>` from its composition roots.

### The same five kinds, two renderings

Here is Memory Lane's main tab host — the *identical* set of kinds, resolved by each platform's composition root:

<CodeGroup>
  ```swift MainNavShell.swift (iOS) theme={null}
  presentation.register(MainSheet.self) { [weak self] sheet in
    switch (sheet, self?.sheetChild) {
    case let (.capture, .capture(token, _)):
      return AnyView(
        HostedViewController(controller: token.viewController)
          .ignoresSafeArea()
          .presentationDetents([.large])
          .presentationDragIndicator(.visible)
          .presentationCornerRadius(28))
    case let (.notificationPriming, .priming(child)):
      return AnyView(
        HostedViewController(controller: child.viewController)
          .presentationDetents([.medium]))
    // …
    }
  }

  presentation.register(TimelineSheet.self) { [weak self] sheet in
    AnyView(SharePickerHostView(store: child.store)
      .presentationDetents([.medium, .large]))
  }
  ```

  ```kotlin MainNavHost.kt (Android) theme={null}
  registry.register(MainSheet::class) { sheet ->
    when (sheet) {
      MainSheet.Capture -> {{
        val child by shell.sheetChild.collectAsState()
        (child as? SheetChild.Capture)?.let { capture ->
          ModalBottomSheet(onDismissRequest = {
            shell.navStore.send(MainNavAction.CaptureFinished(didSave = false))
          }) { CaptureProductSheet(capture) }
        }
      }}
      // …
    }
  }

  registry.register(TimelineSheet::class) { _ ->
    {
      ModalBottomSheet(onDismissRequest = {
        shell.timelineStore.send(TimelineAction.ShareSheetDismissed)
      }) { SharePickerSheet(child) }
    }
  }
  ```
</CodeGroup>

`HostedViewController` + detents + a 28pt corner radius on one side; `ModalBottomSheet` on the other. The Android host additionally installs `BackHandler`s in a documented composition order — node before child, tab-gated, root modal last — a construct with no iOS analogue whatsoever. None of that reaches the shared half.

## Worked example: one slot, ten kinds, two very different platforms

The clearest illustration in Memory Lane is the capture surface. Its shared state carries **one** modal slot with **ten** kinds — photo source, photo library, camera, video source, video recorder, video library, voice recorder, location, friends, date. One optional enum, payload-free.

Now look at what each platform does with it:

<Frame>
  |                                                                              | iOS                                                                               | Android                                                                                                              |
  | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
  | Source pickers (`photoSource`, `videoSource`)                                | `.confirmationDialog`                                                             | Material `AlertDialog`                                                                                               |
  | Library / camera (`photoLibrary`, `camera`, `videoLibrary`, `videoRecorder`) | `.sheet(item:)` / `.fullScreenCover(item:)` wrapping in-app AVFoundation surfaces | System **Activity-result contracts** — Photo Picker, `TakePicture`, `CaptureVideo` (chrome owned by the system apps) |
  | Voice recorder                                                               | `.fullScreenCover` over an `AVAudioRecorder` surface                              | `ModalBottomSheet` over `MediaRecorder`                                                                              |
  | Date                                                                         | Sheet                                                                             | `DatePickerDialog`                                                                                                   |
</Frame>

One side uses three different SwiftUI presentation channels; the other uses a mix of Material dialogs and *other applications entirely*. And yet the reducer, the state shape, and the recorded fixtures are byte-identical — because on both platforms **every** interactive dismissal re-enters through the same single verb, `modalDismissed(kind)`, whose kind-equality guard makes channel races inert.

<Tip>
  The legacy iOS code this replaced carried an `activeSheet` **and** an `activeFullScreen` view-state pair — two booleans-in-disguise that could both be set. Collapsing them into one slot is what made the Android twin expressible at all: N mutually-exclusive booleans cannot encode exclusivity, so two platforms will eventually disagree about which one wins.
</Tip>

## Divergence is ledgered, not forbidden

Duet doesn't prevent the two platforms from differing when they genuinely need to. It requires the difference to be a **decision on record** rather than a discovery six months later. There are two escape hatches, both declared in the project's `parity/manifest.yaml`:

<AccordionGroup>
  <Accordion title="Manner waivers (L3)" icon="file-signature">
    Filed when one platform deliberately renders a kind in a different manner **class** than its twin — a full-screen dialog where the other uses a half-height sheet. Not for styling: different corner radii, different animation curves, different type scales need nothing.

    ```yaml theme={null}
    waivers:
      - kind: <feature>.<slot|path|tabs|stage>.<kindName>
        feature: <feature name>
        platform: ios | android
        manner: <what this platform does instead>
        reason: <why the divergence is deliberate>
        since: <YYYY-MM-DD>
    ```
  </Accordion>

  <Accordion title="Opaque islands (L4)" icon="puzzle-piece">
    Filed for regions whose internal presentation state lives outside the tree. The **kinds and boundaries are identical** across platforms; only the opaque internals differ.

    ```yaml theme={null}
    islands:
      - id: <feature>.<island-name>
        feature: <feature name>
        boundary: <the serializable summary the spine persists>
        reason: <why verbs don't fit>
        since: <YYYY-MM-DD>
    ```

    Memory Lane's ledger carries three islands with an entry per platform: the photo carousel (UIKit pager ↔ Compose pager `Dialog`), the video player (`AVPlayer` transport ↔ `VideoView`/`MediaController`), and the capture chrome (`AVCapture`/`AVAudioRecorder` sessions ↔ system capture apps + `MediaRecorder`). Same kind, same boundary summary, wholly different internals.
  </Accordion>
</AccordionGroup>

A lint (`lockstep-lint`) validates **shape and cross-references** — every entry names a real feature, a valid platform, an ISO date — and deliberately does **not** judge whether the divergence is justified. That judgment is left to review: the lint guarantees the ledger is complete and parseable, and a human decides whether each entry is warranted.

Memory Lane's standing ledger, after the full Android build-out: **0 waivers, 6 island entries** (3 regions × 2 platforms).

<Info>
  **The third-verb heuristic.** Islands can quietly grow into structure. The rule: the first two actions an island wants are usually incidental; the **third** means it actually has structure — promote it to a kind. Failure/revert twins of an existing verb don't count (they're that verb's plumbing). The sweep re-runs at every milestone that added manner surfaces.
</Info>

## What's gated versus what's free

<Frame>
  | Layer  | Cross-platform obligation                                                  | Enforcement                                     |
  | ------ | -------------------------------------------------------------------------- | ----------------------------------------------- |
  | **L0** | Identical fields, identical serialization                                  | Fixtures — `duet verify`, `duet record --check` |
  | **L1** | Identical kinds and payloads per feature                                   | Lockstep-lint declaration checks                |
  | **L2** | Identical verbs, identical reductions                                      | Fixtures; per-milestone ledger audit            |
  | **L3** | **Free per platform**; divergent manner *classes* waived in the ledger     | Lint shape check; human review                  |
  | **L4** | Islands declared identically; internals free; boundary summaries identical | Lint shape check; restore drill; periodic sweep |
</Frame>

## What this buys you

When Memory Lane's sixteen Android product views were written — feeds, detail, capture, profile, the whole launch funnel — the measured results were:

<CardGroup cols={2}>
  <Card title="Zero logic files touched" icon="file-shield">
    Not one feature module changed. `duet verify` held at **134/134** by construction, and the fixture corpus was never re-recorded.
  </Card>

  <Card title="Divergence stayed cheap" icon="scissors">
    Every platform-specific decision below cost at most a line in a log — no contract change, no waiver, no re-record.
  </Card>
</CardGroup>

The deliberate cuts, in full:

* **No Lottie splash art on Android** — the phase is reducer-clocked either way; the art is manner.
* **No Apple sign-in button** — the platform has no Apple sign-in, so the environment maps that path to a failure the shared reducer already handles.
* **No reaction-flight animation overlay** — pure chrome; it writes no verbs.
* **No video transcode pass on Android** — the system camera emits MP4 already, so the iOS AVFoundation normalize step is simply platform manner, and the Android arm is a staged-file adoption.
* **Compose pager `Dialog` instead of the UIKit carousel** — declared island internals.
* **Text-only tab labels shipped first**, icons in a later polish pass — a manner detail that never blocked a behavior gate.

And on theme: the **mood** is kept in step, not the token system. Android runs a Material 3 scheme tuned to the iOS palette — warm paper, bark primary, light and dark — rather than an attempt to port design tokens across two design languages that disagree about what a token is.

## Adding a presentable surface: the whole recipe

<Steps>
  <Step title="The feature ships the kind">
    A new case on the owning node's route/sheet enum, with a pure-value payload. No rendering exists yet.
  </Step>

  <Step title="Record the fixture first">
    The golden-first rule: pin the scenario byte-for-byte on both platforms **before** any UI is written. This is the step that makes the rest safe.
  </Step>

  <Step title="Name the verbs">
    Reducer arms that set and clear the kind, named for intent. Interactive dismissals re-enter as the view's *report* action with a stale-dismissal guard.
  </Step>

  <Step title="Each platform's composition root binds a renderer">
    `registry.register(Kind.self) { payload in … }`. Manner lives **only** here — detents, covers, animation, chrome. Renderers may wrap any native API, including tricks and workarounds; the registry is where platform-specific creativity goes without touching the contract.
  </Step>

  <Step title="Ledger the divergence, if any">
    Different manner *class* between platforms → a waiver entry. Different styling → nothing at all.
  </Step>
</Steps>

<Warning>
  Two things must never happen: a reducer learning manner (a `detent` field in feature state), and a host writing an L0 field outside `store.send`. Both violate the contract, and the per-milestone audit greps for exactly these two patterns.
</Warning>

## Common questions

<AccordionGroup>
  <Accordion title="Doesn't this mean writing the UI twice?" icon="clone">
    Yes — the *views*, but not the logic, the navigation decisions, the effect handling, or the test corpus. With any native-UI approach you were writing those views twice anyway; what you stop paying twice for is behavior, and what you gain is proof that the two behaviors agree.

    The measured shape of that trade in Memory Lane: sixteen Android product surfaces built against stores that already had a passing fixture corpus, with zero logic changes and no re-recording.
  </Accordion>

  <Accordion title="What if one platform simply can't do what the other does?" icon="ban">
    Then it doesn't, and the ledger records why. Apple sign-in has no Android counterpart — the Android environment maps that path to the failure arm the shared reducer already models, and the button doesn't exist. No contract change is needed; the shared half never assumed the button.
  </Accordion>

  <Accordion title="Could we share some UI code anyway?" icon="code-branch">
    Nothing in the contract forbids it — manner is free, so what a renderer closure returns is entirely your business. But Duet's toolchain gives you no help there, and its whole premise is that native look-and-feel is worth writing twice while behavior isn't. Sharing views would be working against the grain of the framework.
  </Accordion>

  <Accordion title="What happens when the two do drift?" icon="triangle-exclamation">
    If the drift is in L0–L2, CI fails: both platforms replay the same recorded fixtures and the gate is byte equality, so a diverging reducer is a red build on the commit that caused it. If the difference is in L3–L4, it isn't drift — it's manner: either it needed a ledger entry (which the lint's shape check enforces) or it needed nothing at all.
  </Accordion>
</AccordionGroup>

***

## Summary

Modaal shares the navigation *structure* as data and gates it in CI. The widgets are fully native on each side and are expected to differ; where they differ in kind rather than in pixels, the difference is recorded in a ledger that the lint checks.

<Note>
  **Status.** Duet is **pre-release**. The framework, both flavors, and the toolchain have landed, and the contracts are versioned alongside the code — but no artifacts are published yet, so treat the API surface as a preview until the first tagged release. The contracts quoted here (`presentation-contract.md`, `store-kernel-contract.md`, `serialization.md`, `replay-protocol-v1.md`) ship in the open framework repository.
</Note>

<CardGroup cols={2}>
  <Card title="Duet overview" icon="mobile-screen-button" href="/articles/duet">
    What Duet is, the two wizard cards that scaffold it, what lands on disk, and how to add Android to an iPhone-only project.
  </Card>

  <Card title="Migrating a CombineRIBs app" icon="arrow-right-arrow-left" href="/articles/combineribs-to-duet">
    The per-feature route from an existing Production app project to a shared Kotlin core and an Android app.
  </Card>
</CardGroup>
