← GuidesDeveloper menuDeveloperMenuContainer is a DEBUG-only wrapper shipped with SwiftJsonUI and KotlinJsonUI. You put it around your app's root content, and in DEBUG builds it exposes two gestures: double-tap opens a sheet listing every screen so you can jump to any one of them; long-press toggles Dynamic Mode, which makes the app hot-reload Layout JSON from a running `jui hotload listen`. In Release builds — or when you pass enabled: false — the container vanishes and your content renders plain. This guide shows how to wire it up on iOS and on Android (ReactJsonUI has no equivalent, so the Web side of your app stays unchanged).~10 min read
1. What it isDeveloperMenuContainer is a thin wrapper that you put around your app's root UI. In DEBUG it attaches two gestures and renders a small overlay when either fires; in Release it's a plain passthrough with zero runtime cost. You give it three things: the currently-shown screen, the list of screens your app can render, and a content closure that knows how to build a screen from that enum value. The library takes it from there.• Double tap → opens a sheet (iOS) or dialog (Android) listing every screen from `screens`. Picking a row sets `currentScreen` to that value.• Long press (≥ 0.5s) → toggles Dynamic Mode. Snackbar confirms the new state. With Dynamic Mode ON, the running app reloads Layout JSON from `jui hotload listen` as you edit it.• Release builds never show the sheet, never listen for gestures, never allocate overlay state — the container becomes a direct call to your content closure.• iOS (SwiftJsonUI, SwiftUI) and Android (KotlinJsonUI, Jetpack Compose) ship a container. ReactJsonUI does not. If you want a screen-switcher on the web app, you'd build it yourself — but hot-reload on the web is handled by whichever React dev server you already run, so the motivation is weaker.
2. Conform your Screen enum to DeveloperScreenThe container is generic over a `Screen` type. You declare an enum listing every screen your app can render and conform it to DeveloperScreen — a tiny protocol (iOS) / interface (Android) with one property that returns a human-readable label. The label is what the sheet / dialog shows next to the currently-selected marker.• iOS — `protocol DeveloperScreen: Hashable { var name: String { get } }`. The Hashable conformance is automatic for any enum with raw-value cases; just implement `name` (returning `rawValue` is usually fine).• Android — `interface DeveloperScreen { val displayName: String }`. Kotlin enum classes already support equals/hashCode via identity, so there's nothing extra to implement beyond `displayName` — `this.name` (the enum constant's own name) is the usual choice.
App.swift — iOS Screen enum
enum Screen: String, CaseIterable, DeveloperScreen { case splash = "Splash" case login = "Login" case registration = "Registration" case home = "Home" case itemDetail = "ItemDetail" // ... var name: String { rawValue }}MainActivity.kt — Android Screen enum
enum class Screen : DeveloperScreen { Splash, Login, Registration, Home, ItemDetail, // ... ; override val displayName: String get() = this.name}3. iOS — wrap the WindowGroup bodyThe iOS container wants a SwiftUI.Binding for `currentScreen`, the full `screens` array, an `enabled` flag, and a content closure that switches on the current value. You hold the current screen as @State on your App struct and pass `$currentScreen` in; anything in your view tree can assign a new screen back to that @State and the UI swaps accordingly.• Use `CaseIterable` on your enum and pass `Screen.allCases` — the sheet shows rows in `allCases` order.• `$currentScreen` is a two-way Binding. When the developer sheet assigns a new value to it, SwiftUI re-invokes your content closure with the new enum case. No additional state plumbing needed.• Your switch inside the content closure must be exhaustive. Swift does not allow `@unknown default` on your own enum, so add an explicit `default:` if you'd rather not hand-cover every case.
ExampleApp.swift — App root
@mainstruct ExampleApp: App { @State private var currentScreen: Screen = .splash private let developerMenuEnabled = true var body: some Scene { WindowGroup { DeveloperMenuContainer( currentScreen: $currentScreen, screens: Screen.allCases, enabled: developerMenuEnabled ) { screen in switch screen { case .splash: SplashView(onNavigate: { s in currentScreen = s }) case .login: LoginView(onNavigate: { s in currentScreen = s }) case .registration: RegistrationView(onDismiss: { currentScreen = .login }) // ... default: Color.clear.onAppear { currentScreen = .splash } } } } }}4. Android — wrap setContentOn Android the container has the same three inputs but a different shape: `currentScreen` is a plain value (not a delegate), and you pass `onScreenChange: (T) -> Unit` as a callback instead of a Binding. The idiomatic pattern is to derive `currentScreen` from the NavController's backstack entry and, inside `onScreenChange`, call `navController.navigate(...)` so the Compose navigation system stays the single source of truth for what's on screen. The content slot receives the enum value you just derived, but you usually ignore it and mount a NavHost that renders based on the backstack directly.• `Screen.entries` is the Kotlin 1.9+ API. On older compilers, use `Screen.values().toList()` instead.• `popUpTo(...) { inclusive = ... }` after navigate() controls how much of the backstack survives each dev-menu jump. Typical choice: pop up to the top-level screen so the backstack doesn't grow without bound while QA-ing screens.• Pass `enabled = BuildConfig.DEBUG` explicitly. The library has its own debug check, but routing through your project's BuildConfig gives you a second guarantee and an easy place to add Stage / QA overrides later.
MainActivity.kt — setContent
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { val navController = rememberNavController() val developerMenuEnabled = BuildConfig.DEBUG val currentRoute = navController .currentBackStackEntryFlow .collectAsState(initial = navController.currentBackStackEntry) val currentScreen = NavRoutes.toScreen( currentRoute.value?.destination?.route ) KotlinJsonUITheme { DeveloperMenuContainer( currentScreen = currentScreen, screens = Screen.entries, onScreenChange = { screen -> navController.navigate(NavRoutes.fromScreen(screen)) { popUpTo(NavRoutes.CHAT_SIMPLE) { inclusive = false } } }, enabled = developerMenuEnabled ) { _ -> NavHost( navController = navController, startDestination = NavRoutes.SPLASH ) { composable(NavRoutes.SPLASH) { SplashView(...) } composable(NavRoutes.LOGIN) { LoginView(...) } composable(NavRoutes.REGISTRATION) { RegistrationView(...) } // ... } } } }}5. Dynamic Mode toggleLong-press the container and Dynamic Mode flips. On iOS the flag lives on `ViewSwitcher.shared.isDynamic` and the container listens via @ObservedObject; on Android it's a StateFlow on `DynamicModeManager.isDynamicModeEnabled` that the container collects. In both cases, flipping the flag forces a re-mount of the content subtree so hot-reloaded Layout JSON takes effect immediately. You normally don't touch these managers directly; the only time you do is in your App / MainActivity setup to seed the initial state and opt in to hot-reload.• The toggle is always available on long-press in DEBUG. You can also programmatically flip it from your setup code (see the CodeBlock) — useful if you want Dynamic Mode on for a specific QA build.• Dynamic Mode ON is the switch that lets `jui hotload listen` push updated Layout JSON into the running app. The CLI and the container don't know about each other directly — the ViewSwitcher / DynamicModeManager flag is the rendezvous.• A parse error in the JSON you just saved will log to stdout (iOS) / Logcat (Android). The UI stays on the previously-valid layout rather than blanking — Dynamic Mode is best-effort, not strict.
iOS — seed hot-reload state
// App.init — seed hot-reload before the first frame#if DEBUGJSONLayoutLoader.copyResourcesToCache()ViewSwitcher.setDynamicMode(false) // OFF by default; long-press flips itHotLoader.instance.isHotLoadEnabled = trueCustomComponentRegistration.registerAll()#endifAndroid — seed Dynamic Mode state
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() // OFF by default; long-press flips it. Pair with jui hotload listen. DynamicModeManager.setDynamicModeEnabled(this, false) setContent { /* ... */ }}6. Release builds and the enabled flagThe container has two layers of Release-build safety. First, the library itself guards all of the developer features behind a DEBUG check. Second, the `enabled` prop lets you disable the container at call-site even in DEBUG (useful when you want the developer menu off for screen recordings, on TestFlight / internal-release builds, or when a sub-flow uses its own long-press gesture). Always pass the flag explicitly — don't rely on the library default.• iOS — the entire DeveloperMenuContainer body is inside `#if DEBUG`. Release builds compile down to a single `content(currentScreen)` call with zero overhead and zero stored state.• Android — DynamicModeManager exposes `isDynamicModeAvailable` as a StateFlow. In Release builds this stays false and the container returns early with just the content, skipping every gesture / overlay block.• Forgetting `enabled = BuildConfig.DEBUG` on Android is the most common way a production build ships with a working screen selector. iOS catches this via `#if DEBUG` in the library; Android trusts you. Double-check this in your CI.
iOS — gated enabled flag
// iOS — library wraps everything in #if DEBUG; you can still gate at the call-site#if DEBUGprivate let developerMenuEnabled = true#elseprivate let developerMenuEnabled = false#endifAndroid — gated enabled flag
// Android — never rely on the library default; always pass BuildConfig.DEBUGval developerMenuEnabled = BuildConfig.DEBUG7. PitfallsMost trouble with DeveloperMenuContainer traces to one of these five places — all of which are spotted easily in the Swift / Kotlin compiler output or in a 30-second manual test.• Non-exhaustive switch in the iOS content closure — Swift won't compile, but the error points at the closure and not the cause. If you add a new screen to the enum, remember to add a case in the App struct's switch too.• On Android, holding `currentScreen` as a mutable local and hand-mutating it alongside `navController.navigate(...)` creates two sources of truth. Derive it from the backstack (`currentBackStackEntryFlow` → route → `NavRoutes.toScreen(route)`) and drive changes exclusively through the NavController.• Passing `enabled = true` unconditionally on Android — the library still has its own debug check, but a raw `true` is easy to miss in code review. Prefer `enabled = BuildConfig.DEBUG` everywhere.• A child view inside the content closure that uses its own long-press gesture (context menus, draggable items) — on iOS the container uses `.simultaneousGesture` so both fire, which can register Dynamic Mode toggles you didn't intend. Either disable the container on that subflow with `enabled: false`, or give the child's long-press a shorter `minimumDuration` so it eats the event first.• Returning an empty string from `name` (iOS) / `displayName` (Android) — the sheet still renders the row, but it's a blank, unclickable-looking tap target. Always return a human-readable label; the enum's `rawValue` (Swift) or `this.name` (Kotlin) is the quickest default.
Keep reading
NavigationThe nav stack the DeveloperMenuContainer hooks into. Cross-platform userActions + transitions — iOS NavigationStack, Android NavController, React Router./guides/navigation
Writing layoutsThe JSON that Dynamic Mode hot-reloads — styles, include, Collection, visibility, pitfalls./guides/writing-layouts
© 2026 JsonUI contributors. Built by the same toolchain documented here.