Pure-Native Mobile Patterns (iOS Swift / Android Kotlin)
Purpose: Pure-native navigation, state management, offline, and platform adaptation patterns. Read when: Adopting standard SwiftUI / Compose patterns.
Navigation Patterns
For version compatibility, migration notes, and the
NavigationSplitViewregression on iOS 18, seemodern-stack.md§ Navigation —NavigationStack(iOS 16+) and § Type-Safe Navigation (Navigation 2.8+).
iOS — NavigationStack + Coordinator
@Observable
final class HomeCoordinator {
var path = NavigationPath()
func openProfile(_ userId: String) { path.append(Route.profile(userId)) }
func openSettings() { path.append(Route.settings) }
}
enum Route: Hashable {
case profile(String)
case settings
}
struct ContentView: View {
@State private var coordinator = HomeCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
HomeView()
.navigationDestination(for: Route.self) { route in
switch route {
case .profile(let id): ProfileView(userId: id)
case .settings: SettingsView()
}
}
}
.environment(coordinator)
}
}Android — Navigation Compose 2.8+ (type-safe)
@Serializable data object Home
@Serializable data class Profile(val userId: String)
@Serializable data object Settings
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Home) {
composable<Home> {
HomeScreen(
onProfileClick = { navController.navigate(Profile(it)) },
onSettingsClick = { navController.navigate(Settings) },
)
}
composable<Profile> { backStack ->
val args: Profile = backStack.toRoute()
ProfileScreen(args.userId)
}
composable<Settings> { SettingsScreen() }
}
}Deep Link Configuration
iOS — Universal Links (AASA)
Add applinks:example.com under Signing & Capabilities → Associated Domains. Host https://example.com/.well-known/apple-app-site-association on the server:
{
"applinks": {
"details": [{
"appIDs": ["TEAM_ID.com.example.app"],
"components": [{ "/": "/profile/*" }, { "/": "/settings" }]
}]
}
}struct ContentView: View {
@State private var coordinator = HomeCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) { ... }
.onOpenURL { url in coordinator.handle(url) }
}
}
extension HomeCoordinator {
func handle(_ url: URL) {
// /profile/123 → path.append(.profile("123"))
}
}Custom schemes (myapp://) are fallback only. Make Universal Links the primary path.
Android — App Links (assetlinks.json)
AndroidManifest.xml:
<activity android:name=".MainActivity">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="example.com" />
</intent-filter>
</activity>Host https://example.com/.well-known/assetlinks.json on the server:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": ["SHA256:..."]
}
}]class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
intent?.data?.let { handleDeepLink(it) }
setContent { /* ... */ }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.data?.let { handleDeepLink(it) }
}
}Firebase Dynamic Links was retired in 2025. Operate AASA / assetlinks.json directly. If attribution is required, adopt Branch / AppsFlyer / Adjust as your MMP.
State Management Patterns
For
@Observablemigration fromObservableObject, Swift 6.2 caller-isolation, Strong Skipping Mode stability rules, andImmutableListguidance, seemodern-stack.md§ Observation framework —@Observableand § Stable Types & Strong Skipping Mode.
iOS — @Observable + per-feature ViewModel
Concurrency convention (Swift 6.3 / Xcode 26 default isolation): with Default MainActor isolation on, the
@MainActorbelow is implicit — keep it only for clarity or for targets not yet on default isolation. Do not blanket-annotate every type@MainActor; instead mark off-main work@concurrent(heavy decode/IO) and pure helpersnonisolated, and conform cross-actor types toSendable. Full isolation table →modern-stack.md§ Swift 6.2 Approachable Concurrency.
@Observable
@MainActor // implicit under Xcode 26 default isolation; shown for clarity
final class CartViewModel {
private(set) var items: [CartItem] = []
private(set) var isLoading = false
private(set) var error: Error?
private let repository: CartRepository
init(repository: CartRepository) { self.repository = repository }
func load() async {
isLoading = true
defer { isLoading = false }
do {
items = try await repository.fetchCart()
} catch {
self.error = error
}
}
func add(_ item: CartItem) async {
items.append(item) // optimistic
do {
try await repository.addItem(item)
} catch {
items.removeAll { $0.id == item.id } // rollback
self.error = error
}
}
}
struct CartView: View {
@State private var viewModel: CartViewModel
init(repository: CartRepository) {
_viewModel = State(initialValue: CartViewModel(repository: repository))
}
var body: some View {
List(viewModel.items) { item in CartItemRow(item: item) }
.task { await viewModel.load() }
}
}Cross-cutting state (auth, theme, network) goes through @Environment or a DI container. Do not pile 10+ slices into a global store.
Android — StateFlow<UiState> + ViewModel
Stability convention (2026, Strong Skipping default): the old "always wrap collections in
ImmutableList" rule is no longer the default. Strong Skipping compares unstable params by reference (===) and skips, so passing a plainList<T>straight from the data source is fine. Reach forImmutableList/persistentListOfonly when (a) Compose Compiler Reports show a measured recomposition problem AND (b) the producer can hand back the same instance when nothing changed. The example below usesImmutableListto show the explicit-stability path;List<CartItem>is the simpler default. Full rationale →modern-stack.md§ Stable Types & Strong Skipping Mode.
@Immutable
data class CartUiState(
val items: ImmutableList<CartItem> = persistentListOf(), // explicit-stability path; plain List is the 2026 default
val isLoading: Boolean = false,
val error: String? = null,
)
@HiltViewModel
class CartViewModel @Inject constructor(
private val repository: CartRepository,
) : ViewModel() {
private val _ui = MutableStateFlow(CartUiState())
val ui: StateFlow<CartUiState> = _ui.asStateFlow()
fun load() {
viewModelScope.launch {
_ui.update { it.copy(isLoading = true) }
repository.fetchCart().fold(
onSuccess = { items ->
_ui.update { it.copy(items = items.toImmutableList(), isLoading = false) }
},
onFailure = { e ->
_ui.update { it.copy(error = e.message, isLoading = false) }
},
)
}
}
fun add(item: CartItem) {
viewModelScope.launch {
// optimistic
val previous = _ui.value.items
_ui.update { it.copy(items = (previous + item).toImmutableList()) }
repository.addItem(item).onFailure {
_ui.update { it.copy(items = previous, error = it.error?.message) }
}
}
}
}
@Composable
fun CartScreen(viewModel: CartViewModel = hiltViewModel()) {
val ui by viewModel.ui.collectAsStateWithLifecycle() // mandatory
LaunchedEffect(Unit) { viewModel.load() }
LazyColumn {
items(ui.items, key = { it.id }) { item -> // key is mandatory
CartItemRow(item)
}
}
}Offline-First Patterns
iOS — Repository + write queue
actor WriteQueue {
private var pending: [PendingWrite] = []
private let storage: WriteQueueStorage
init(storage: WriteQueueStorage) {
self.storage = storage
Task { pending = await storage.load() }
}
func enqueue(_ write: PendingWrite) async {
pending.append(write)
await storage.persist(pending)
}
func flush(client: APIClient) async {
for write in pending {
do {
try await client.send(write)
pending.removeAll { $0.id == write.id }
} catch {
if write.retryCount > 5 { pending.removeAll { $0.id == write.id } }
else { /* keep, increment retry */ }
}
}
await storage.persist(pending)
}
}Flush on network restore:
import Network
@Observable
final class NetworkMonitor {
private(set) var isConnected = true
private let monitor = NWPathMonitor()
init() {
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor in self?.isConnected = path.status == .satisfied }
}
monitor.start(queue: DispatchQueue.global(qos: .background))
}
}Android — Repository + WorkManager
class WriteQueueWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val pending = pendingWriteDao.getAll()
for (write in pending) {
runCatching { apiClient.send(write) }
.onSuccess { pendingWriteDao.delete(write.id) }
.onFailure {
if (write.retryCount > 5) pendingWriteDao.delete(write.id)
else pendingWriteDao.incrementRetry(write.id)
}
}
return if (pendingWriteDao.getAll().isEmpty()) Result.success() else Result.retry()
}
}
// Enqueue on launch / network restore
val request = OneTimeWorkRequestBuilder<WriteQueueWorker>()
.setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build())
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"write-queue",
ExistingWorkPolicy.KEEP,
request,
)Network monitoring:
class NetworkMonitor(context: Context) {
private val cm = context.getSystemService(ConnectivityManager::class.java)
val isConnected: Flow<Boolean> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { trySend(true) }
override fun onLost(network: Network) { trySend(false) }
}
cm.registerDefaultNetworkCallback(callback)
awaitClose { cm.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
}Platform Adaptation Pattern
iOS / Android live in separate codebases. Follow each language's idioms. Where commonality is required, align the API contract / Design Tokens at a higher layer.
For Liquid Glass adoption policy (iOS 26 default, iOS 27 opt-out removal, 2026-04-28 Xcode 26 submission deadline) and the full Material 3 Expressive component catalog (
NavigationSuiteScaffold,PullToRefreshBox, shape library), seemodern-stack.md§ Liquid Glass and § Material 3 Expressive.
iOS 26 Liquid Glass
// iOS 26 ships the dedicated glassEffect() modifier
struct MyView: View {
var body: some View {
VStack { Text("Hello") }
.padding()
.glassEffect() // Liquid Glass material
}
}
// Group multiple glass elements for coordinated animations
GlassEffectContainer {
HeaderCard()
ActionRow()
}Android Material 3 Expressive
@Composable
fun MyScreen() {
Scaffold(
topBar = {
CenterAlignedTopAppBar(title = { Text("Home") })
},
floatingActionButton = {
FloatingActionButton(onClick = { /* ... */ }) {
Icon(Icons.Default.Add, null)
}
},
bottomBar = {
FloatingToolbar( // BottomAppBar is deprecated
horizontalArrangement = Arrangement.spacedBy(8.dp),
content = { /* actions */ },
)
},
) { paddings ->
// ...
}
}Edge-to-edge & Predictive Back (API 36)
See modern-stack.md § Edge-to-Edge and § Predictive Back for the enableEdgeToEdge() + BackHandler snippets and the API 36 enforcement timeline. Both are mandatory wiring at targetSdk 36 — apply once at MainActivity setup and per screen as needed.
Performance Patterns
iOS — debugging body re-evaluation
struct MyView: View {
let value: Int
var body: some View {
let _ = Self._printChanges() // debug only — remove before shipping
Text("\(value)")
}
}Performance tips:
- For large datasets use
List(UICollectionView-backed, cell recycling).LazyVStackretains cells and is 10×+ slower at the 1,000-item scale. AsyncImagehas no cache. Use Kingfisher / Nuke / SDWebImageSwiftUI.- Move heavy work off the main actor with
@concurrent.
Android — controlling Compose recomposition
// Unstable lambda problem (LazyListScope is not composable scope)
LazyColumn {
items(list, key = { it.id }) { item ->
val onClick = remember(item.id) { { /* ... */ } } // stabilize via remember
ItemRow(item = item, onClick = onClick)
}
}
// derivedStateOf — discretize scroll state
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}Performance tips:
- Always pass
key = { it.id }toLazyColumnitems. - Annotate data classes holding unstable members with
@Immutable. Do not reflexively convert collections toImmutableList— under Strong Skipping, passList<T>by reference and only convert when Compose Compiler Reports show a measured problem (modern-stack.md§ Strong Skipping). - For images, use Coil 3
AsyncImage(Compose-optimized). - Baseline Profile / Startup Profile cuts startup time by 40-50%.
Permission Flow Pattern (soft pre-prompt)
iOS
@MainActor
final class PermissionCoordinator {
func requestNotifications() async -> Bool {
// 1. status check
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
if settings.authorizationStatus == .authorized { return true }
if settings.authorizationStatus == .denied {
// graceful degradation: show "open Settings" UI
return false
}
// 2. soft pre-prompt UI (custom view)
guard await showSoftPrePromptUI() else { return false }
// 3. system request
return (try? await center.requestAuthorization(options: [.alert, .badge, .sound])) ?? false
}
}Android (API 33+)
@Composable
fun NotificationPermissionRequester(onResult: (Boolean) -> Unit) {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = onResult,
)
var showSoftPrePrompt by remember { mutableStateOf(false) }
if (showSoftPrePrompt) {
SoftPrePromptDialog(
onConfirm = {
showSoftPrePrompt = false
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
},
onDismiss = { showSoftPrePrompt = false },
)
}
Button(onClick = { showSoftPrePrompt = true }) {
Text("Enable notifications")
}
}Testing Patterns (briefly)
| Use | iOS | Android |
|---|---|---|
| Unit test | XCTest / Swift Testing | JUnit 5 + MockK + Turbine (Flow) |
| UI test | XCUITest | Espresso / Compose UI Test / Maestro |
| Snapshot | swift-snapshot-testing | Paparazzi / Roborazzi / Compose Preview Screenshot Testing |
| E2E | Maestro | Maestro / Espresso |
Detail is owned by the handoff to Radar / Voyager.
Picking the Android snapshot tool — all three render without a device; they differ in what they can capture:
- Compose Preview Screenshot Testing (Google) — lowest setup: move
@Previews into thescreenshotTestsource set, and@Previewparameters generate the variant matrix. Default choice for a Compose-only screen. - Paparazzi (Cash App) — single-frame, View system + Compose. Choose for layout-level checks in unit-test CI.
- Roborazzi — multi-frame / interaction-driven, with hardware-accelerated rendering. Choose when elevation shadows or clip-to-bounds content render incorrectly under the others, or when the capture must span an interaction.
- ComposablePreviewScanner — auto-generates screenshot tests from existing
@Previews for whichever of the above is already adopted.
The snapshot layer doubles as the render substrate for agent-driven visual iteration — see reference/agent-visual-loop.md §5.
SwiftUI VERIFY Gate (swiftui Recipe)
Before handing a SwiftUI feature to Radar/Guardian, confirm each — the recipe-specific check the SKILL's "strict data-race safety" contract requires:
- Builds under Swift 6 mode /
-strict-concurrency=completewith zero data-race warnings (new Xcode 26 projects default to this; legacy targets opt in permodern-stack.md§ Approachable Concurrency). - No main-thread blocking — heavy decode/IO is
@concurrentor behind anactor/asyncrepository; the@MainActorViewModel only mutates UI state. - Cross-actor types are
Sendable— model/DTO types crossing the actor boundary conform; no@unchecked Sendablewithout a documented invariant. - No retain cycles — coordinator/closure captures use
[weak self](seeNetworkMonitor);_printChanges()and other debug-only calls removed. - Large lists use
List(cell recycling), notLazyVStack, at 1,000+ items;AsyncImagereplaced with a caching loader (Kingfisher/Nuke). - Liquid Glass is chrome-only —
.glassEffect()on NavigationBar/TabBar/Toolbar/Sheet/Popover, never content (SKILL Liquid Glass scope); iOS 17/18 fallback path verified. -
#Previewpresent for each new View, and it compiles (preview crashes are a real regression). - SwiftData/Core Data uses a day-one
VersionedSchemaso the first migration is not a breaking reset.
Compose VERIFY Gate (compose Recipe)
Before handing a Compose feature to Radar/Guardian, confirm each:
-
collectAsStateWithLifecycle()for everyStateFlowcollection (notcollectAsState()) — stops collection across Lifecycle changes. -
key = { it.id }on everyLazyColumn/LazyRow/LazyVerticalGriditems. - Stability is measured, not assumed — Strong Skipping is on; collections pass as
List<T>by reference unless a Compose Compiler Report flagged a real recomposition; no reflexiveImmutableListwrapping (modern-stack.md§ Strong Skipping). - No deprecated M3 components —
BottomAppBar→FloatingToolbar, indeterminateCircularProgressIndicator→LoadingIndicator(M3 Expressive, BOM 2026.05). - Edge-to-edge + predictive back wired —
enableEdgeToEdge()atMainActivityandPredictiveBackHandler/OnBackPressedDispatcher; both are enforced attargetSdk 36(modern-stack.md§ Edge-to-Edge / § Predictive Back). - 16KB-page-size compliant —
useLegacyPackaging = false, every NDK/native dep rebuilt (Google Play rejects non-compliant submissions; AGP 8.5.1+/NDK r28+). - Type-safe Navigation —
@Serializabledestinations +toRoute()(Navigation 2.8+), no string routes. - Baseline Profile generated (40-50% startup win) for any release build; no unstable lambdas in
Lazy*scopes (stabilize viaremember).
Two codebases, two languages, one product bar. Stay faithful to each platform's idioms.
Team Working Principles
Two codebases, one product owner running per-screen parity reviews. Adopt Liquid Glass / M3 Expressive early — deferring adoption compounds into layout-regression retrofits later. (Reinforces SKILL.md § Workflow and § Boundaries; not new rules.)