Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@ iosApp/iosApp.xcodeproj/
screenshots/
docs/*.jpeg
docs/*.jpg

# Local review screenshots (user-provided, never commit)
p1/
p2/
p3/
p4/
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ changes; the public surface is ABI-locked per release via binary-compatibility-v

## [Unreleased]

### Added

- **Per-screen chrome behavior** — `NativeBarConfig` (`hidesTopBar`, `hidesTabBar`, per-screen
`actions`) carried on `NativeChromeEntry.bar`, so any host can drive per-screen bar visibility and
native toolbar actions through the existing chrome projection. Defaults unchanged; fully opt-in.
- **iOS shell style registry** — `NativeShellStyle` + `applyNativeShellStyle()` (iosMain): themed /
system-material / custom bar backgrounds, global tint, tab-item selected/unselected colors, title
font, and hairline visibility for the native `UINavigationBar`/`UITabBar` chrome, with
`nativeShell…UIColor` resolvers for Swift shells.
- The sample app gained Compose-side bar slots + restylable defaults (`NativeNavDefaults`) and a
"Navigation toolbar styles" catalog (Settings → Developer) demonstrating the full customization
surface on both platforms, including documented limitations.

## [0.1.0] — 2026-07-04

First public release.
Expand Down
Binary file added WhatsApp Image 2026-07-03 at 15.00.40.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ kotlin {
applyDefaultHierarchyTemplate()

sourceSets {
// Compose UI tests for the sample's navigation host on the JVM via Robolectric (no emulator) —
// mirrors :nativecomposekit's androidUnitTest setup. Test-only, never shipped.
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
androidUnitTest.dependencies {
implementation(compose.uiTest)
implementation(libs.robolectric)
implementation(libs.junit)
}
commonMain.dependencies {
// The design-system kit, extracted to its own module. `api` (not implementation) is required so
// the iOS framework's `export(project(":nativecomposekit"))` can re-export its public ObjC symbols.
Expand Down Expand Up @@ -100,4 +108,15 @@ android {
buildConfig = true // BuildConfig.DEBUG gates the demo diagnostics (nav/keyboard tracing) to debug builds
}

testOptions {
unitTests {
isIncludeAndroidResources = true // Robolectric loads themes/manifest for the Compose UI tests
isReturnDefaultValues = true
}
}
}

dependencies {
// The empty ComponentActivity + manifest the Robolectric Compose UI tests host into (debug variant).
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package io.github.apdelrahman1911.nativecomposekit.app

import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

// The native bar previews are genuinely iOS-only (they embed real UIKit bars); Android shows an honest
// placeholder so the catalog reads the same on both platforms.

@Composable
private fun IosOnlyPlaceholder(label: String, modifier: Modifier, heightDp: Int) {
Box(
modifier = modifier.fillMaxWidth().height(heightDp.dp)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Text(label, style = MaterialTheme.typography.labelMedium)
}
}

@Composable
actual fun IosNavBarPreview(
title: String,
modifier: Modifier,
background: IosPreviewBackground,
customBackground: Color?,
tint: Color?,
actionSymbols: List<String>,
showsBack: Boolean,
hairline: Boolean,
) {
IosOnlyPlaceholder("iOS-only preview (real UINavigationBar) — run the iOS app", modifier, heightDp = 44)
}

@Composable
actual fun IosTabBarPreview(
modifier: Modifier,
selectedColor: Color?,
unselectedColor: Color?,
tint: Color?,
) {
IosOnlyPlaceholder("iOS-only preview (real UITabBar) — run the iOS app", modifier, heightDp = 49)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package io.github.apdelrahman1911.nativecomposekit.app

import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.runComposeUiTest
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

/**
* Smoke-pins the Android path of the toolbar-styles catalog: the live Material exhibits actually render
* (default, tinted, actions incl. a TEXT action, centered title, tab bars, the fully custom slot bar),
* the iOS-only exhibits show their honest placeholder, and the immersive demo wires its back intent.
*/
@OptIn(ExperimentalTestApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34]) // Robolectric's bundled runtime doesn't cover compileSdk 36 yet
class ToolbarStylesScreenTest {

@Test
fun catalog_renders_the_android_exhibits_live() = runComposeUiTest {
setContent { ToolbarStylesScreen() }

onNodeWithText("1. Default toolbar").assertIsDisplayed()
onNodeWithText("Default title").assertIsDisplayed() // live default Material bar
onNodeWithText("Tinted title").performScrollTo().assertIsDisplayed() // live tinted bar
onNodeWithText("Edit").performScrollTo().assertIsDisplayed() // TEXT action in the slot
onNodeWithText("Centered").performScrollTo().assertIsDisplayed() // centered-title variant
onNodeWithText("Custom slot").performScrollTo().assertIsDisplayed() // fully custom slot bar
onNodeWithText("Subtitle — anything goes in a slot").performScrollTo().assertIsDisplayed()
// iOS-only exhibits are honest placeholders on Android (several across the sections).
val placeholders = onAllNodes(
androidx.compose.ui.test.hasText("iOS-only preview (real UINavigationBar) — run the iOS app"),
).fetchSemanticsNodes()
assertTrue(placeholders.size >= 3)
}

@Test
fun immersive_demo_renders_and_pops_via_its_button() = runComposeUiTest {
var popped = false
setContent { ImmersiveDemoScreen(onBack = { popped = true }) }
onNodeWithText("Immersive").assertIsDisplayed()
onNodeWithText("Go back").performClick()
assertTrue(popped)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package io.github.apdelrahman1911.nativecomposekit.app.navigation

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Text
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import io.github.apdelrahman1911.nativecomposekit.chrome.NativeBarConfig
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

/**
* Pins the Material host's chrome contract: the DEFAULT bars keep today's exact structure (title, back
* arrow when pushed, tab items), [NativeBarConfig] hides bars per route, and the [NativeNavHost] slots
* replace or restyle the bars without touching navigation.
*/
@OptIn(ExperimentalTestApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34]) // Robolectric's bundled runtime doesn't cover compileSdk 36 yet
class NativeNavHostBarsTest {

private enum class Tab(override val id: String) : NativeTab { Home("home"), Settings("settings") }
private data class Route(override val id: String) : NativeRoute

private fun navigator() = createNativeNavigator(
tabs = listOf(Tab.Home, Tab.Settings),
initialTab = Tab.Home,
rootRoutes = { tab -> Route("${tab.id}-root") },
)

private val graph = nativeNavGraph {
screen<Route> { route -> Text("content:${route.id}") }
}

private val tabs = listOf(
NativeNavBarItem(Tab.Home, "Home", Icons.Filled.Home),
NativeNavBarItem(Tab.Settings, "Settings", Icons.Filled.Settings),
)

@Test
fun default_bars_keep_todays_structure() = runComposeUiTest {
val nav = navigator()
setContent {
NativeNavHost(nav, graph, tabs, title = { "Title:${it.id}" })
}
// Root: title + both tab items, no back arrow.
onNodeWithText("Title:home-root").assertIsDisplayed()
onNodeWithText("Home").assertIsDisplayed()
onNodeWithText("Settings").assertIsDisplayed()
assertEquals(0, onAllNodes(androidx.compose.ui.test.hasContentDescription("Back")).fetchSemanticsNodes().size)

// Pushed: back arrow appears and pops on click.
nav.push(Route("detail"))
waitForIdle()
onNodeWithText("Title:detail").assertIsDisplayed()
onNodeWithContentDescription("Back").assertIsDisplayed().performClick()
waitForIdle()
onNodeWithText("Title:home-root").assertIsDisplayed()
}

@Test
fun bar_config_hides_bars_for_an_immersive_route() = runComposeUiTest {
val nav = navigator()
setContent {
NativeNavHost(
nav, graph, tabs,
title = { "Title:${it.id}" },
barConfig = { route ->
if (route.id == "reader") NativeBarConfig(hidesTopBar = true, hidesTabBar = true)
else NativeBarConfig.Default
},
)
}
onNodeWithText("Title:home-root").assertIsDisplayed()

nav.push(Route("reader"))
waitForIdle()
// Both bars gone, content still there.
assertEquals(0, onAllNodes(androidx.compose.ui.test.hasText("Title:reader")).fetchSemanticsNodes().size)
assertEquals(0, onAllNodes(androidx.compose.ui.test.hasText("Home")).fetchSemanticsNodes().size)
onNodeWithText("content:reader").assertIsDisplayed()

// Popping restores both bars.
nav.pop()
waitForIdle()
onNodeWithText("Title:home-root").assertIsDisplayed()
onNodeWithText("Home").assertIsDisplayed()
}

@Test
fun top_bar_slot_replaces_the_default_and_restyled_default_still_works() = runComposeUiTest {
val nav = navigator()
setContent {
NativeNavHost(
nav, graph, tabs,
title = { "Title:${it.id}" },
topBar = { state ->
if (state.route.id == "home-root") Text("custom-bar:${state.title}")
else NativeNavDefaults.TopBar(state, centeredTitle = true) // restyled default
},
)
}
// Custom slot rendered; the default bar's title node is the custom one now.
onNodeWithText("custom-bar:Title:home-root").assertIsDisplayed()

// A pushed route falls back to the (restyled) default bar with the working back arrow.
nav.push(Route("detail"))
waitForIdle()
onNodeWithText("Title:detail").assertIsDisplayed()
onNodeWithContentDescription("Back").assertIsDisplayed()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import androidx.compose.runtime.remember
import io.github.apdelrahman1911.nativecomposekit.app.AppDevTools
import io.github.apdelrahman1911.nativecomposekit.app.AppRoute
import io.github.apdelrahman1911.nativecomposekit.app.AppTab
import io.github.apdelrahman1911.nativecomposekit.app.appBarConfig
import io.github.apdelrahman1911.nativecomposekit.app.appNavGraph
import io.github.apdelrahman1911.nativecomposekit.app.appRootRoute
import io.github.apdelrahman1911.nativecomposekit.app.appRouteTitle
Expand Down Expand Up @@ -51,6 +52,8 @@ fun App() {
NativeNavBarItem(AppTab.Settings, "Settings", Icons.Filled.Settings),
),
title = ::appRouteTitle,
barConfig = ::appBarConfig, // per-screen chrome behavior, shared with the iOS shell

actions = {
// Debug builds only: the "+" on the Library tab presents the glass-interop stress
// test as a sheet. Rendered with the kit's own icon button (the sample models kit usage).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import io.github.apdelrahman1911.nativecomposekit.app.navigation.NativeNavigator
import io.github.apdelrahman1911.nativecomposekit.app.navigation.NativeRoute
import io.github.apdelrahman1911.nativecomposekit.app.navigation.NativeTab
import io.github.apdelrahman1911.nativecomposekit.app.navigation.nativeNavGraph
import io.github.apdelrahman1911.nativecomposekit.chrome.NativeBarConfig
import io.github.apdelrahman1911.nativecomposekit.chrome.NativeChromeAction
import io.github.apdelrahman1911.nativecomposekit.showcase.ShowcaseCategoryScreen
import io.github.apdelrahman1911.nativecomposekit.showcase.ShowcaseHomeScreen
import io.github.apdelrahman1911.nativecomposekit.showcase.showcaseTitle
Expand Down Expand Up @@ -32,9 +34,31 @@ fun appRouteTitle(route: NativeRoute): String = when (route) {
is AppRoute.GlassInteropTest -> "Interop test"
is AppRoute.ComponentMatrix -> "Component matrix"
is AppRoute.InteropRepro -> "iOS interop repro"
is AppRoute.ChromeDemo -> "Chrome demo"
is AppRoute.ToolbarStyles -> "Toolbar styles"
is AppRoute.ImmersiveDemo -> "Immersive"
else -> ""
}

/** The id of the chrome demo's per-screen bar action (handled in each platform's shell wiring). */
const val CHROME_DEMO_ACTION_ID = "chrome-demo-action"

/**
* Per-screen chrome BEHAVIOR for both hosts — the Material `NativeNavHost` (via its `barConfig` param) and
* the iOS shell (via `NativeNavChrome.barConfigForRoute`). One source of truth, exactly like [appRouteTitle].
* The demo screen hides the tab bar while pushed and carries its own per-screen bar action (rendered by
* the iOS shell; the Android default bar takes actions as composable slots instead and ignores this list).
*/
fun appBarConfig(route: NativeRoute): NativeBarConfig = when (route) {
is AppRoute.ChromeDemo -> NativeBarConfig(
hidesTabBar = true,
actions = listOf(NativeChromeAction(CHROME_DEMO_ACTION_ID, "sparkles")),
)
// The immersive demo hides BOTH bars — pop via swipe-back / system back / the screen's own button.
is AppRoute.ImmersiveDemo -> NativeBarConfig(hidesTopBar = true, hidesTabBar = true)
else -> NativeBarConfig.Default
}

/**
* The route→screen registry, shared by both platform adapters. Screen callbacks are wired to [navigator]
* intents here, keeping the screens themselves navigator-agnostic.
Expand All @@ -54,9 +78,16 @@ fun appNavGraph(navigator: NativeNavigator): NativeNavGraph = nativeNavGraph {
SettingsScreen(
onOpenComponentMatrix = { navigator.push(AppRoute.ComponentMatrix) },
onOpenInteropRepro = { navigator.push(AppRoute.InteropRepro) },
onOpenChromeDemo = { navigator.push(AppRoute.ChromeDemo) },
onOpenToolbarStyles = { navigator.push(AppRoute.ToolbarStyles) },
)
}
screen<AppRoute.ComponentMatrix> { ComponentMatrixScreen() }
screen<AppRoute.ChromeDemo> { ChromeDemoScreen() }
screen<AppRoute.ToolbarStyles> {
ToolbarStylesScreen(onOpenImmersive = { navigator.push(AppRoute.ImmersiveDemo) })
}
screen<AppRoute.ImmersiveDemo> { ImmersiveDemoScreen(onBack = { navigator.pop() }) }
screen<AppRoute.InteropRepro> { InteropReproScreen() }
screen<AppRoute.CatalogRoot> {
ShowcaseHomeScreen(onOpenCategory = { key -> navigator.push(AppRoute.Showcase(key)) })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,23 @@ sealed interface AppRoute : NativeRoute {
data object InteropRepro : AppRoute {
override val id = "debug/interop-repro"
}

/**
* Debug screen: the navigation-chrome customization demo. Its `NativeBarConfig` (see `appBarConfig`)
* hides the tab bar while pushed and, on iOS, carries a per-screen bar action; the screen's content
* documents each platform's styling surface. Pushed from Settings.
*/
data object ChromeDemo : AppRoute {
override val id = "debug/chrome-demo"
}

/** Debug screen: the toolbar/navigation-bar styles catalog (Material variants live, native iOS bar previews). */
data object ToolbarStyles : AppRoute {
override val id = "debug/toolbar-styles"
}

/** Debug screen: the immersive per-screen config demo — BOTH bars hidden while on top. */
data object ImmersiveDemo : AppRoute {
override val id = "debug/immersive-demo"
}
}
Loading
Loading