Android renders as real Jetpack Compose. Like SwiftUI, Compose is a compiler-plugin framework — you can't
author a @Composable from C# — so this is a native-shim backend: a Kotlin shim
(native/SwiftDotNetComposeBridge, Bridge.kt) mirrors the Swift
bridge and interprets the node tree into Compose over JNI.
- Verified on the emulator (Pixel 9a AVD): the shared
ContentViewrenders identically to iOS.
Bridge.kt interprets the entire control set — Text/Button/stacks/ZStack/ScrollView/Grid/List/Form/
Section/Group/DisclosureGroup/TabView(+paged carousel)/Tab/Menu, all inputs, NavigationStack/Link, Sheet,
Alert, Image/Label, ProgressView/Gauge, Link, shapes — plus the modifier set.
- Nav = a lightweight
NavStackviaCompositionLocal+Scaffold/TopAppBar. - Sheet =
ModalBottomSheet; Alert =AlertDialog; ActionSheet =ModalBottomSheetwith full-width option rows.AlertDialoghas two button slots, so an alert with more than two buttons stacks them inside the confirm slot. - SF Symbols → an emoji map (avoids a material-icons dependency).
- Modifiers apply via a
Modified()wrapper (box modifiers +CompositionLocalProviderfor font/color); shapes fill from theforegroundColormodifier. GridandAbsoluteLayoutare customLayouts.LazyVerticalGridsizes columns but has no row span or explicit cell, and Compose has no absolute-positioning container — so both are written asLayoutcomposables whose measure/place passes portSkiaNode.ResolveTracksandGridEngineline for line. Fixed/Flexible track sizes and point bounds are converteddp → pxbefore sizing. See Grid.
# 1. Build the .aar (JDK 21 + Android SDK)
native/SwiftDotNetComposeBridge/gradlew -p native/SwiftDotNetComposeBridge assembleRelease
# 2. Build the app
dotnet build sample/SampleApp -f net10.0-androidToolchain: Gradle wrapper 8.14.3, AGP 8.11.1, Kotlin 2.1.0, compileSdk 36, Compose BOM 2025.01.00.
src/SwiftDotNet (net10.0-android TFM) binds the .aar via <AndroidLibrary … Bind="true"> and references
Xamarin.AndroidX.Compose.* (Ui/Foundation, Material3, Activity.Compose). AndroidBridge : IBridge calls
the bound Com.Swiftdotnet.Bridge.SwiftDotNetBridge static methods; events come back via a C#
EventProxy : Java.Lang.Object, IEventCallback.
The reusable host base is SwiftDotNetActivity : ComponentActivity (a ComponentActivity is needed so the
ComposeView gets its ViewTree owners).
-
Rebind after rebuilding the
.aar, and cleanobj/binREPO-WIDE. Copy the freshly-built AAR fromnative/…/build/outputs/aar/tobuild/, then:find . -type d \( -name obj -o -name bin \) -not -path "./native/*" -prune -exec rm -rf {} +
Clearing only
src/SwiftDotNetandsample/SampleAppis not sufficient once another head (e.g.SwiftDotNet.Skia.Maui) has been built. The failure mode is nasty because it doesn't look like a binding problem: the AAR extracts fine toobj/.../library_project_jars/, but the jar never reachesobj/.../class-parse.rsp, so zero bindings are generated and you getCS0246: 'Com' could not be found/'IEventCallback' could not be foundinAndroidBridge.cs.--no-incrementaldoes not fix it. If you suspect the AAR itself, check it withjavap -classpath <extracted> com.swiftdotnet.bridge.SwiftDotNetBridge— the types must bepublic. -
Compose strong-skipping (Kotlin 2.x default) compares composable params by reference identity — mutating a VNode in place is skipped. Fix:
VNode.props/children/typemust bemutableStateOf(the Compose analog of iOS@Observable). -
Buttonname collision withAndroid.Widget.Button→ alias it. -
Composables are kept
privateso the .NET binding generator doesn't bind syntheticComposerparams. -
Kotlin VNode props are
Any?— cast withas?(Swift's arePropValue). -
Minor UX: the app currently shows both the Activity
ActionBarand the ComposeTopAppBarin nav (disable the ActionBar via theme).ColorPickercycles a palette on tap (no native Android color picker).
.Repeating()animations userememberInfiniteTransition+infiniteRepeatable(for-1) orrepeatablewithRepeatMode.Reverse/Restart. Like Web and Skia, a repeating animation pulses opacity 1 → 0.4 — the wire carries no from/to pair, so every backend agrees on that one convention. Consequence:BadgeView.Pulsereads as an opacity pulse rather than a size pulse (its.ScaleEffect(1.0)is identity), andSkeletonView's shimmer is a fading static gradient, not a travelling highlight. Aspringcurve combined with a repeat degrades to a tween — springs have no duration and can't feedinfiniteRepeatable..Keyframes(…)maps to a realkeyframes<Float>spec per track — see keyframe animations. Each track becomes its ownanimateFloat(looping ones onrememberInfiniteTransition, one-shots on anAnimatablereplayed by theon:trigger), and opacity/transform/size land asalpha,graphicsLayer,offsetandwidth/height. Two wire-order details: Compose'susingeasing applies to the segment starting at a keyframe where the wire records the curve a stop is arrived on, so each stop hands its curve to the one before it; andautoreverseis emitted as a mirrored return leg rather thanRepeatMode.Reverse, which would also reverse each segment's easing. A timeline's opacity track takes over from the.Repeating()pulse.Image.FromUrlloads dependency-free (coroutine +URL.openConnection+BitmapFactory, cached by URL in-process). No Coil dependency was added. The bridge AAR's manifest declaresandroid.permission.INTERNET, which merges into every consuming app. No disk cache, no downsampling, no in-flight dedup — two nodes with the same URL fetch twice before the first caches..Materialis a translucent tint, not a backdrop blur — and this is deliberate.Modifier.blur/RenderEffect.createBlurEffectblur a composable's own content, whereas.Materialis a SwiftUI backdrop blur. Using them would smear the node's children, which is worse than the tint. Real backdrop blur on Android needsWindow.setBackgroundBlurRadius, which is window-level and can't be expressed per-node.
SwiftDotNetActivity.OnCreate calls EdgeToEdge.Enable(this) before base.OnCreate, so the app
draws behind the system bars — required on Android 15+ for apps targeting SDK 35, and what makes
WindowInsets.safeDrawing report real values in the first place. Keep content clear of the bars with
.SafeAreaPadding(...).
.SafeAreaPadding(edges, regions)→Modifier.windowInsetsPaddingoverWindowInsets.safeDrawing, unioned withWindowInsets.imewhen theKeyboardregion is asked for, narrowed viaWindowInsetsSides..IgnoresSafeArea(...)→Modifier.consumeWindowInsets. Compose content is already edge-to-edge, so there is no padding to remove; consuming is what stops a descendant from re-applying insets an ancestor deliberately bled into.RootHostViewreportssafeDrawing+ime(in dp) to C# on the reserved$safeAreaevent id, keyed by aLaunchedEffectso it fires only when a value actually changes.
Gotcha: IME insets require
windowSoftInputMode="adjustResize"(the default). A host activity that setsadjustPanreports a keyboard height of 0.
🧩 Not emulator-verified — the Kotlin compiles and the wire contract is tested, but real inset values, rotation and keyboard behavior are unconfirmed.
native/camera/CameraRenderer.kt and
native/maps/MapRenderer.kt sit outside
native/SwiftDotNetComposeBridge/src/main/kotlin/, the only source root the AAR compiles, and
build.gradle.kts declares neither a sourceSets block nor the CameraX / ML Kit / MapLibre dependencies
their headers require. Both files are authored but not shipped — confirmed by their absence from
classes.jar. Pulling them in means adding those dependencies to the bridge AAR that every consumer would
then carry, which is a design decision, not a build fix.
A Compose/MapLibre renderer is authored in native/maps (MapRenderer.kt) against
the same native registerRenderer seam, but it is not compiled into the bridge AAR — see
above. See Maps.
🧩 Expected, not run (needs an emulator). dotnet watch run -f net10.0-android should work with no
extra setup — the Kotlin shim is not involved, since a reload arrives as an ordinary replace patch. See
Hot Reload.