
A Compose Multiplatform UI kit for Android and iOS. You call one shared component API from
commonMain, and each platform renders with the most native widget available: Jetpack Compose
Material 3 on Android, real UIKit controls on iOS. A NativeToggle is a Switch on Android and a
UISwitch on iOS; a NativeSegmentedControl is a UISegmentedControl on iOS. The shared code stays
the same — the rendering doesn't pretend.
The same commonMain screen — Material 3 on Android · real UIKit chrome and controls on iOS, light and dark.
One shared Compose/KMP API → the most-native renderer per platform.
Cross-platform UI usually means one of two things: draw everything yourself so it looks identical
everywhere (and slightly off on every platform), or maintain two native codebases. This kit takes a
third path. Each component is a single @Composable in commonMain that resolves its styling from
the theme and then hands off to a platform renderer through expect/actual:
NativeButton(…) // commonMain: the shared API + theme resolution
└─ expect PlatformNativeButton(style, …)
├─ androidMain → Material 3 Button (NativeButton.android.kt)
└─ iosMain → UIButton via Compose UIKitView (NativeButton.ios.kt)
Android users get Material 3 with its motion, ripple, and dynamic color. iOS users get the system controls they already know — correct sizing, haptics, context menus, and accessibility — instead of look-alikes. A handful of components have no native counterpart on one side (there's no UIKit radio button or checkbox); those are documented exceptions that stay Compose-drawn on both platforms.
| Target | Renderer |
|---|---|
| Android | Jetpack Compose Material 3 |
| iOS (arm64, simulator-arm64) | UIKit controls hosted in Compose via UIKitView
|
| Shared | Kotlin Multiplatform + Compose Multiplatform (commonMain) |
iOS ships iosArm64 and iosSimulatorArm64. iosX64 is intentionally dropped — Compose
Multiplatform 1.11 no longer supports the Apple x86_64 target.
| Tool | Version |
|---|---|
| Kotlin | 2.3.21 |
| Compose Multiplatform | 1.11.0 |
| Android Gradle Plugin | 8.13.0 |
| Gradle | 8.13 |
| compileSdk / targetSdk | 36 |
| minSdk | 26 |
| iOS deployment target | 15.0 |
These are pinned in gradle/libs.versions.toml. The library module (:nativecomposekit) depends only on
Compose artifacts — no third-party runtime dependencies.
:nativecomposekit the UI kit (published surface; ABI-locked with binary-compatibility-validator)
:composeApp the sample catalog app that exercises every component on both platforms
iosApp native iOS host: the UIKit chrome shell (UITabBarController + per-tab UINavigationControllers) in a SwiftUI app entry
Releases publish to Maven Central as io.github.apdelrahman1911:nativecomposekit
(see docs/publishing.md for the release process):
// build.gradle.kts (commonMain)
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.apdelrahman1911:nativecomposekit:0.3.0")
}
}
}mavenCentral() in your repositories is all that's needed. To work against a local checkout instead
(unreleased changes), use one of:
A — composite build (clone next to your project; Gradle substitutes the coordinates with the local module automatically):
// settings.gradle.kts
includeBuild("../NativeComposeKit")B — local Maven repository:
cd NativeComposeKit && ./gradlew :nativecomposekit:publishToMavenLocal// settings.gradle.kts
dependencyResolutionManagement { repositories { mavenLocal(); google(); mavenCentral() } }Both local flavors resolve the same dependency line shown above.
Every component is a Native* composable from io.github.apdelrahman1911.nativecomposekit.components. Wrap
your UI in NativeKitTheme once, then call components directly:
import io.github.apdelrahman1911.nativecomposekit.components.*
import io.github.apdelrahman1911.nativecomposekit.theme.NativeKitTheme
@Composable
fun SignInForm(onSignIn: (String) -> Unit) {
var email by remember { mutableStateOf("") }
NativeScaffold(topBar = { NativeTopBar(title = "Sign in") }) { padding ->
Column(Modifier.padding(padding).padding(16.dp)) {
NativeText("Welcome back", style = NativeTextStyle.Title)
NativeTextField(
value = email,
onValueChange = { email = it },
label = "Email",
placeholder = "you@example.com",
)
NativeButton(
text = "Continue",
onClick = { onSignIn(email) },
fullWidth = true,
)
}
}
}On Android this renders Material 3 controls; on iOS the button, text, and field are real UIButton,
UILabel, and UITextField instances.
NativeKitTheme is the single source of styling — Material's ColorScheme/Typography/Shapes plus a
small set of design tokens (spacing, radii, status colors). There is no separate token file.
Components read their defaults from the theme and expose per-call overrides as typed parameters.
NativeKitTheme(
darkTheme = isSystemInDarkTheme(),
// override any slice; defaults cover the common case
// lightColors = myLightScheme,
// tokens = NativeTokens(spacingMd = 12.dp),
) {
App()
}Switching darkTheme updates both the Compose Material controls and the UIKit controls — the iOS
renderers set overrideUserInterfaceStyle from the luminance of the surface they sit on, so they
read correctly in light and dark on any background. See
docs/design-system-rules.md for the rules a component follows.
Strings the kit renders on its own (retry buttons, alert fallbacks, accessibility labels) come from a
localizable table with English defaults — pass a translated NativeStrings the same way:
NativeKitTheme(strings = NativeStrings(retry = "…", dismiss = "…")).
A typical app using the kit looks like:
app/
commonMain/
App.kt wraps everything in NativeKitTheme
screens/ your screens, built from Native* components
androidMain/ MainActivity → App()
iosMain/ MainViewController → App()
iosApp/ native iOS host (chrome shell + ComposeUIViewController)
Keep NativeKitTheme at the root so every component resolves the same theme. Apps that use the app-wide
appearance switching or the native iOS shell should wrap each composition root in NativeAppearanceScope
instead of calling NativeKitTheme directly — the scope forwards every theming parameter AND drives the
process-wide dark/RTL overrides, the reduce-motion capability, and the iOS window/shell background
(NativeAppearance.setDark has no effect on compositions outside the scope). Build screens out of
Native* components rather than reaching for raw Material or UIKit — that's what keeps a screen
native on both platforms from one code path. The :composeApp module in this repo is a working
example.
A few things worth knowing up front; they save the most common surprises.
UIKitView interop and collapse to zero width without a width constraint.
NativeSegmentedControl usually wants Modifier.fillMaxWidth() — segmented controls normally
span the available width.NativePageControl inside a Row wants Modifier.weight(1f) (or another explicit width) so its
dots have room. Fixed-size controls like NativeToggle and NativeStepper don't need this.NativeSheet for sheet-style and mobile content. On iOS it's a real
UISheetPresentationController with detents and a grab handle.NativePopover adapts to the device. On iPhone / compact width it uses a lightweight
Compose popover (a full-screen UIKit popover on a phone is the wrong UX); on iPad / regular width
it uses a native UIPopoverPresentationController anchored to your anchor.feedback.alert — it's a real UIAlertController
on iOS. Reach for NativeDialog only when the modal needs custom Compose content (a form, a list,
an image).NativeTextField(contentType = OneTimeCode).
NativeOtpField is the branded segmented-cell visual and does not provide system autofill.NativeToggle over a checkbox on iOS — a switch is the platform
idiom. NativeCheckbox exists for the cases that genuinely need a checkbox.The kit hosts UIKit controls inside Compose via UIKitView. A few Compose Multiplatform 1.11
behaviors in this area are upstream and have no clean workaround through public API. The kit picks
the least-bad trade-off and documents it; details and upstream issue links are in
docs/interop-notes.md.
UIKitView inside a Compose scroll can either clip its edge (cut-out placement)
or drift slightly during the gesture (overlay placement). Leaf controls use overlay — the drift is
subtler than a clipped edge and settles the moment scrolling stops.UIButton with a UIMenu can drift from its row after its menu has been
opened once, because the menu leaves a transform on a view the kit can't reach. The native menu is
kept; the post-open drift is the accepted cost.Dialog/Popup can flash the host backdrop for one
frame where a UIKitView will appear. NativeDialog avoids this by drawing its text through
Compose and compositing its controls as an overlay, so the card's own pixels show from the first
frame.Full reference with every parameter, both renderers, and examples is in
docs/components/.
NativeText,
NativeTextField,
NativeSearchBar,
NativeOtpField
NativeButton,
NativeIconButton,
NativeSplitButton, NativeMenu
NativeToggle,
NativeCheckbox,
NativeRadioGroup,
NativeSegmentedControl,
NativeSlider,
NativeStepper,
NativeRating
NativeDatePicker,
NativeColorWell,
NativePageControl,
NativePager,
load-more helpers (NativeLoadMoreEffect, nativePaginationFooter)
NativeSheet,
NativePopover,
NativeDialog,
rememberNativeShare
NativeProgressIndicator, alert /
confirmation sheet / snackbar / toast / banner / inline status (feedback)NativeCard,
NativeScaffold, NativeTopBar,
NativeListSection, NativeListItem,
NativeDivider
NativeContentState,
NativeSkeleton,
NativeEmptyState,
NativePullRefresh,
NativeBadge,
NativeAvatar,
NativeChip
nativeDismissKeyboardOnTap, nativeHeading, nativeAutoFocus, focus handles / order / group,
nativeImePadding
# Android
./gradlew :composeApp:assembleDebug
./gradlew :composeApp:installDebug # to a running emulator/device
# iOS (Kotlin side — fast check before opening Xcode)
./gradlew :composeApp:compileKotlinIosSimulatorArm64
# iOS app
cd iosApp && xcodegen generate && open iosApp.xcodeproj./gradlew :nativecomposekit:apiCheck # fail if the public ABI changed without an apiDump
./gradlew :nativecomposekit:testDebugUnitTest # unit + Robolectric Compose tests (no emulator)
./gradlew :composeApp:testDebugUnitTest
./gradlew check # everythingThe library's public API is locked with
binary-compatibility-validator. After an
intentional API change, regenerate the baseline with ./gradlew :nativecomposekit:apiDump and commit the
updated files under nativecomposekit/api/.
Contributions are welcome. Before opening a pull request:
docs/design-system-rules.md — a new component must resolve
its style from the theme, render natively per platform (or be a documented exception), and add real
design-system value rather than wrap a library.:nativecomposekit free of third-party runtime dependencies../gradlew check and, for any public API change, :nativecomposekit:apiDump.io.github.apdelrahman1911.nativecomposekit, the Android
namespace is io.github.apdelrahman1911.nativecomposekit.kit, and the public components use the
Native* prefix. The Gradle module is still named :nativecomposekit — an internal build name, not the
published group, so there's no need to churn it.v<version> and recorded
in CHANGELOG.md.NativeComposeKit is released under the Apache License 2.0.
A Compose Multiplatform UI kit for Android and iOS. You call one shared component API from
commonMain, and each platform renders with the most native widget available: Jetpack Compose
Material 3 on Android, real UIKit controls on iOS. A NativeToggle is a Switch on Android and a
UISwitch on iOS; a NativeSegmentedControl is a UISegmentedControl on iOS. The shared code stays
the same — the rendering doesn't pretend.
The same commonMain screen — Material 3 on Android · real UIKit chrome and controls on iOS, light and dark.
One shared Compose/KMP API → the most-native renderer per platform.
Cross-platform UI usually means one of two things: draw everything yourself so it looks identical
everywhere (and slightly off on every platform), or maintain two native codebases. This kit takes a
third path. Each component is a single @Composable in commonMain that resolves its styling from
the theme and then hands off to a platform renderer through expect/actual:
NativeButton(…) // commonMain: the shared API + theme resolution
└─ expect PlatformNativeButton(style, …)
├─ androidMain → Material 3 Button (NativeButton.android.kt)
└─ iosMain → UIButton via Compose UIKitView (NativeButton.ios.kt)
Android users get Material 3 with its motion, ripple, and dynamic color. iOS users get the system controls they already know — correct sizing, haptics, context menus, and accessibility — instead of look-alikes. A handful of components have no native counterpart on one side (there's no UIKit radio button or checkbox); those are documented exceptions that stay Compose-drawn on both platforms.
| Target | Renderer |
|---|---|
| Android | Jetpack Compose Material 3 |
| iOS (arm64, simulator-arm64) | UIKit controls hosted in Compose via UIKitView
|
| Shared | Kotlin Multiplatform + Compose Multiplatform (commonMain) |
iOS ships iosArm64 and iosSimulatorArm64. iosX64 is intentionally dropped — Compose
Multiplatform 1.11 no longer supports the Apple x86_64 target.
| Tool | Version |
|---|---|
| Kotlin | 2.3.21 |
| Compose Multiplatform | 1.11.0 |
| Android Gradle Plugin | 8.13.0 |
| Gradle | 8.13 |
| compileSdk / targetSdk | 36 |
| minSdk | 26 |
| iOS deployment target | 15.0 |
These are pinned in gradle/libs.versions.toml. The library module (:nativecomposekit) depends only on
Compose artifacts — no third-party runtime dependencies.
:nativecomposekit the UI kit (published surface; ABI-locked with binary-compatibility-validator)
:composeApp the sample catalog app that exercises every component on both platforms
iosApp native iOS host: the UIKit chrome shell (UITabBarController + per-tab UINavigationControllers) in a SwiftUI app entry
Releases publish to Maven Central as io.github.apdelrahman1911:nativecomposekit
(see docs/publishing.md for the release process):
// build.gradle.kts (commonMain)
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.apdelrahman1911:nativecomposekit:0.3.0")
}
}
}mavenCentral() in your repositories is all that's needed. To work against a local checkout instead
(unreleased changes), use one of:
A — composite build (clone next to your project; Gradle substitutes the coordinates with the local module automatically):
// settings.gradle.kts
includeBuild("../NativeComposeKit")B — local Maven repository:
cd NativeComposeKit && ./gradlew :nativecomposekit:publishToMavenLocal// settings.gradle.kts
dependencyResolutionManagement { repositories { mavenLocal(); google(); mavenCentral() } }Both local flavors resolve the same dependency line shown above.
Every component is a Native* composable from io.github.apdelrahman1911.nativecomposekit.components. Wrap
your UI in NativeKitTheme once, then call components directly:
import io.github.apdelrahman1911.nativecomposekit.components.*
import io.github.apdelrahman1911.nativecomposekit.theme.NativeKitTheme
@Composable
fun SignInForm(onSignIn: (String) -> Unit) {
var email by remember { mutableStateOf("") }
NativeScaffold(topBar = { NativeTopBar(title = "Sign in") }) { padding ->
Column(Modifier.padding(padding).padding(16.dp)) {
NativeText("Welcome back", style = NativeTextStyle.Title)
NativeTextField(
value = email,
onValueChange = { email = it },
label = "Email",
placeholder = "you@example.com",
)
NativeButton(
text = "Continue",
onClick = { onSignIn(email) },
fullWidth = true,
)
}
}
}On Android this renders Material 3 controls; on iOS the button, text, and field are real UIButton,
UILabel, and UITextField instances.
NativeKitTheme is the single source of styling — Material's ColorScheme/Typography/Shapes plus a
small set of design tokens (spacing, radii, status colors). There is no separate token file.
Components read their defaults from the theme and expose per-call overrides as typed parameters.
NativeKitTheme(
darkTheme = isSystemInDarkTheme(),
// override any slice; defaults cover the common case
// lightColors = myLightScheme,
// tokens = NativeTokens(spacingMd = 12.dp),
) {
App()
}Switching darkTheme updates both the Compose Material controls and the UIKit controls — the iOS
renderers set overrideUserInterfaceStyle from the luminance of the surface they sit on, so they
read correctly in light and dark on any background. See
docs/design-system-rules.md for the rules a component follows.
Strings the kit renders on its own (retry buttons, alert fallbacks, accessibility labels) come from a
localizable table with English defaults — pass a translated NativeStrings the same way:
NativeKitTheme(strings = NativeStrings(retry = "…", dismiss = "…")).
A typical app using the kit looks like:
app/
commonMain/
App.kt wraps everything in NativeKitTheme
screens/ your screens, built from Native* components
androidMain/ MainActivity → App()
iosMain/ MainViewController → App()
iosApp/ native iOS host (chrome shell + ComposeUIViewController)
Keep NativeKitTheme at the root so every component resolves the same theme. Apps that use the app-wide
appearance switching or the native iOS shell should wrap each composition root in NativeAppearanceScope
instead of calling NativeKitTheme directly — the scope forwards every theming parameter AND drives the
process-wide dark/RTL overrides, the reduce-motion capability, and the iOS window/shell background
(NativeAppearance.setDark has no effect on compositions outside the scope). Build screens out of
Native* components rather than reaching for raw Material or UIKit — that's what keeps a screen
native on both platforms from one code path. The :composeApp module in this repo is a working
example.
A few things worth knowing up front; they save the most common surprises.
UIKitView interop and collapse to zero width without a width constraint.
NativeSegmentedControl usually wants Modifier.fillMaxWidth() — segmented controls normally
span the available width.NativePageControl inside a Row wants Modifier.weight(1f) (or another explicit width) so its
dots have room. Fixed-size controls like NativeToggle and NativeStepper don't need this.NativeSheet for sheet-style and mobile content. On iOS it's a real
UISheetPresentationController with detents and a grab handle.NativePopover adapts to the device. On iPhone / compact width it uses a lightweight
Compose popover (a full-screen UIKit popover on a phone is the wrong UX); on iPad / regular width
it uses a native UIPopoverPresentationController anchored to your anchor.feedback.alert — it's a real UIAlertController
on iOS. Reach for NativeDialog only when the modal needs custom Compose content (a form, a list,
an image).NativeTextField(contentType = OneTimeCode).
NativeOtpField is the branded segmented-cell visual and does not provide system autofill.NativeToggle over a checkbox on iOS — a switch is the platform
idiom. NativeCheckbox exists for the cases that genuinely need a checkbox.The kit hosts UIKit controls inside Compose via UIKitView. A few Compose Multiplatform 1.11
behaviors in this area are upstream and have no clean workaround through public API. The kit picks
the least-bad trade-off and documents it; details and upstream issue links are in
docs/interop-notes.md.
UIKitView inside a Compose scroll can either clip its edge (cut-out placement)
or drift slightly during the gesture (overlay placement). Leaf controls use overlay — the drift is
subtler than a clipped edge and settles the moment scrolling stops.UIButton with a UIMenu can drift from its row after its menu has been
opened once, because the menu leaves a transform on a view the kit can't reach. The native menu is
kept; the post-open drift is the accepted cost.Dialog/Popup can flash the host backdrop for one
frame where a UIKitView will appear. NativeDialog avoids this by drawing its text through
Compose and compositing its controls as an overlay, so the card's own pixels show from the first
frame.Full reference with every parameter, both renderers, and examples is in
docs/components/.
NativeText,
NativeTextField,
NativeSearchBar,
NativeOtpField
NativeButton,
NativeIconButton,
NativeSplitButton, NativeMenu
NativeToggle,
NativeCheckbox,
NativeRadioGroup,
NativeSegmentedControl,
NativeSlider,
NativeStepper,
NativeRating
NativeDatePicker,
NativeColorWell,
NativePageControl,
NativePager,
load-more helpers (NativeLoadMoreEffect, nativePaginationFooter)
NativeSheet,
NativePopover,
NativeDialog,
rememberNativeShare
NativeProgressIndicator, alert /
confirmation sheet / snackbar / toast / banner / inline status (feedback)NativeCard,
NativeScaffold, NativeTopBar,
NativeListSection, NativeListItem,
NativeDivider
NativeContentState,
NativeSkeleton,
NativeEmptyState,
NativePullRefresh,
NativeBadge,
NativeAvatar,
NativeChip
nativeDismissKeyboardOnTap, nativeHeading, nativeAutoFocus, focus handles / order / group,
nativeImePadding
# Android
./gradlew :composeApp:assembleDebug
./gradlew :composeApp:installDebug # to a running emulator/device
# iOS (Kotlin side — fast check before opening Xcode)
./gradlew :composeApp:compileKotlinIosSimulatorArm64
# iOS app
cd iosApp && xcodegen generate && open iosApp.xcodeproj./gradlew :nativecomposekit:apiCheck # fail if the public ABI changed without an apiDump
./gradlew :nativecomposekit:testDebugUnitTest # unit + Robolectric Compose tests (no emulator)
./gradlew :composeApp:testDebugUnitTest
./gradlew check # everythingThe library's public API is locked with
binary-compatibility-validator. After an
intentional API change, regenerate the baseline with ./gradlew :nativecomposekit:apiDump and commit the
updated files under nativecomposekit/api/.
Contributions are welcome. Before opening a pull request:
docs/design-system-rules.md — a new component must resolve
its style from the theme, render natively per platform (or be a documented exception), and add real
design-system value rather than wrap a library.:nativecomposekit free of third-party runtime dependencies../gradlew check and, for any public API change, :nativecomposekit:apiDump.io.github.apdelrahman1911.nativecomposekit, the Android
namespace is io.github.apdelrahman1911.nativecomposekit.kit, and the public components use the
Native* prefix. The Gradle module is still named :nativecomposekit — an internal build name, not the
published group, so there's no need to churn it.v<version> and recorded
in CHANGELOG.md.NativeComposeKit is released under the Apache License 2.0.