This document covers platform-specific gotchas and patterns for achieving visual and behavioral parity between Android (Jetpack Compose) and iOS (SwiftUI).
- List Key Serialization
- Opacity / Alpha
- Text Colors and Dark Mode
- Color Values
- Text Auto-Sizing
- Button Text Centering
- NFC Scanning UI
- Slider Step Behavior
- Lifecycle and Effect Modifiers
- Android:
LazyColumn'sitems(key = ...)serializes keys to aBundlefor state restoration (configuration changes, process death). Keys must be primitive types (String, Int) orParcelableobjects. - iOS: SwiftUI's
ForEachusesIdentifiablefor in-memory diffing only—IDs are never serialized, so custom types likeTxIdwork directly. - Guideline: When using FFI types as list keys on Android, convert to String:
key = { it.id().toString() }.
- Terminology: Android/Compose uses
alpha, iOS/SwiftUI usesopacity. Both mean the same thing (0 = transparent, 1 = opaque). - Container-level opacity: On iOS,
.opacity(0.6)applies to the entire view including its background. On Android,Modifier.graphicsLayer { alpha = 0.6f }only affects the composable's content, not modifiers like.background()applied to the same composable. - Guideline: To match iOS's
.opacity()behavior on Android, wrap the content in an outer Box withgraphicsLayer:// Android - wrapper applies opacity to everything inside Box(modifier = Modifier.graphicsLayer { alpha = 0.6f }) { Box(modifier = Modifier.background(color)) { // content } }
// iOS equivalent Box(...) .background(color) .opacity(0.6)
- iOS/SwiftUI:
Textuses.primaryforeground color by default, which automatically adapts to light/dark mode without explicit color specification. - Android/Compose:
TextusesLocalContentColor.currentby default, but this must be provided by a parent composable. Without a provider, text may render as black regardless of theme. - Which composables set LocalContentColor?
Surface→ setsLocalContentColorto itscontentColorparameter (defaults toonSurface)Scaffold→ sets appropriate content colors for each slotColumn/Boxwith.background()→ does NOT setLocalContentColor
- Guideline: For content areas needing dark mode support, either use
Surfaceinstead ofColumnwith.background(), or explicitly setcolor = MaterialTheme.colorScheme.onSurfaceon Text components.
- Never hardcode colors: Always use system-provided or theme-defined color values, never raw hex codes or Color literals.
- Android: Use
MaterialTheme.colorScheme.*(e.g.,onSurface,primary,surfaceVariant) or custom Cove colors viaMaterialTheme.coveColors.*. - iOS: Use system colors (
.primary,.secondary) or custom colors from the asset catalog. - Why: Hardcoded colors break dark mode, accessibility settings, and dynamic theming. Theme colors automatically adapt to light/dark mode and user preferences.
For Cove-specific colors that need light/dark variants:
- iOS: Asset catalog
.colorsetfiles with light/dark appearances - Android:
CoveColorSchemeinColor.ktwithLightCoveColorsandDarkCoveColorsinstances, provided viaCompositionLocalinCoveTheme
Guideline: Add new theme-aware colors to CoveColorScheme in Color.kt. Access via MaterialTheme.coveColors.* (e.g., MaterialTheme.coveColors.midnightBtn).
iOS has built-in text shrinking via minimumScaleFactor. Android options:
Use BasicText with TextAutoSize for simple auto-shrinking text:
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.TextAutoSize
BasicText(
text = "Text that shrinks to fit",
maxLines = 1,
autoSize = TextAutoSize.StepBased(minFontSize = 7.sp, maxFontSize = 14.sp, stepSize = 0.5.sp),
style = TextStyle(color = LocalContentColor.current),
)Use the custom implementations in views/AutoSizeText.kt for:
BalanceAutoSizeText- balance displays with digit-based sizingAutoSizeTextField- editable auto-sizing text fields
Requirement: Parent must have bounded width (use Modifier.fillMaxWidth() on the container).
- iOS/SwiftUI: Using
.frame(maxWidth: .infinity)on a Text automatically centers it within the frame. Buttons styled withPrimaryButtonStyleget centered text by default. - Android/Compose:
Modifier.fillMaxWidth()on a Button makes it full-width, but the Text inside stays left-aligned by default. - Guideline: For full-width buttons with centered text, add both properties to the Text inside the button:
Button( onClick = { ... }, modifier = Modifier.fillMaxWidth(), ) { Text( text = "Button Label", textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) }
- Note:
ImageButtonhandles text sizing internally using nativeTextAutoSize.
- iOS:
NFCTagReaderSessionprovides automatic system NFC popup. Messages display viasession.alertMessageproperty. - Android:
enableReaderModeis silent—no system UI. Custom overlay required.
Both platforms implement TapcardTransportProtocol with setMessage() and appendMessage() (called by Rust during NFC operations to show progress):
iOS (TapCardTransport in ios/Cove/TapSignerNFC.swift):
func setMessage(message: String) {
nfcSession.alertMessage = message
}
func appendMessage(message: String) {
nfcSession.alertMessage = nfcSession.alertMessage + message
}Android (TapCardTransport in android/.../nfc/TapCardNfcManager.kt):
override fun setMessage(message: String) {
currentMessage = message
onMessageUpdate?.invoke(currentMessage)
}
override fun appendMessage(message: String) {
currentMessage += message
onMessageUpdate?.invoke(currentMessage)
}Since Android has no system NFC UI, TapSignerScanningOverlay composable provides visual feedback:
- NFC icon, animated "Scanning..." dots, message text, progress indicator
- Message updates via callback →
manager.scanMessagestate → recomposition - Shown in
TapSignerContainerwhenmanager.isScanningis true
- iOS/SwiftUI:
Slider(step:)defines the increment size for a continuous slider - Android/Compose:
Slider(steps:)creates discrete stop points (N positions total)
Critical: These are not equivalent! Calculating steps = (max - min) / stepSize can create millions of discrete positions, causing severe lag/freeze.
Guideline: For continuous sliders matching iOS, omit steps entirely on Android. Handle step snapping in onValueChange if needed.
SwiftUI and Compose have different APIs for lifecycle events and side effects. This section maps iOS patterns to their Android equivalents.
| iOS (SwiftUI) | Android (Compose) | Notes |
|---|---|---|
.onAppear { } |
LaunchedEffect(Unit) { } |
Runs once when composable enters composition |
.onDisappear { } |
DisposableEffect(Unit) { onDispose { } } |
Cleanup runs when composable leaves composition |
.task { } |
LaunchedEffect(Unit) { } |
For async work on appear |
.task(id:) { } |
LaunchedEffect(id) { } |
Re-runs when id changes |
| iOS (SwiftUI) | Android (Compose) | Notes |
|---|---|---|
.onChange(of: value) { } |
LaunchedEffect(value) { } |
Runs when value changes |
.onChange(of: value, initial: true) { } |
LaunchedEffect(value) { } |
LaunchedEffect always runs initially |
.onChange(of: value, initial: false) { } |
LaunchedEffect + isFirstRun flag |
Use remembered boolean to skip initial run |
Patterns: To access old values, track previousValue in remembered state before updating.
| iOS (SwiftUI) | Android (Compose) | Notes |
|---|---|---|
@State var x = ... |
var x by remember { mutableStateOf(...) } |
Local component state |
@Binding var x |
value: T, onValueChange: (T) -> Unit |
State hoisting pattern |
@Observable class |
@Stable class with mutableStateOf properties |
Observable view model |
@ObservationIgnored |
Regular property (not mutableStateOf) |
Non-observed property |
@Environment(\.key) |
CompositionLocal + CompositionLocalProvider |
Dependency injection |
| iOS (SwiftUI) | Android (Compose) | Notes |
|---|---|---|
DispatchQueue.main.async { } |
mainScope.launch { } |
Post to main thread |
DispatchQueue(label:).async { } |
launch(Dispatchers.IO) { } |
Background work |
Task { } |
LaunchedEffect { } or rememberCoroutineScope() |
Structured concurrency |
Task.detached { } |
CoroutineScope(Dispatchers.Default).launch { } |
Unstructured (avoid) |
| iOS (SwiftUI) | Android (Compose) | Notes |
|---|---|---|
.sheet(isPresented:) |
if (showSheet) ModalBottomSheet(...) |
Conditional composition |
.sheet(item:) |
item?.let { ModalBottomSheet(...) } |
Item-based sheet |
.alert(isPresented:) |
if (showAlert) AlertDialog(...) |
Conditional dialog |
.alert(item:) |
alertItem?.let { AlertDialog(...) } |
Item-based alert |
.confirmationDialog() |
DropdownMenu or AlertDialog with options |
Action sheet equivalent |
| iOS (SwiftUI) | Android (Compose) | Notes |
|---|---|---|
@FocusState var field |
val focusRequester = remember { FocusRequester() } |
Focus tracking |
.focused($field, equals: .x) |
Modifier.focusRequester(focusRequester) |
Attach to field |
field = .x |
focusRequester.requestFocus() |
Request focus |
.onSubmit { } |
keyboardActions = KeyboardActions(onDone = { }) |
Keyboard submit |
| iOS (SwiftUI) | Android (Compose) | Notes |
|---|---|---|
NavigationStack |
Navigation3 NavDisplay |
Stack-based navigation |
@Environment(\.dismiss) |
navController.popBackStack() |
Dismiss current screen |
.navigationDestination(for:) |
Route matching in NavDisplay |
Type-safe routing |