Compose input, focus, keyboard, forms, and View interoperability
Controlled input
@Composable
fun EmailField(
value: String,
onValueChange: (String) -> Unit,
error: EmailError?,
modifier: Modifier = Modifier
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier,
label = { Text(stringResource(R.string.email)) },
isError = error != null,
supportingText = { error?.let { Text(stringResource(it.messageRes)) } },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
)
}
Parent owns value; component reports edits. Domain validation should not depend only on keyboard type because users/paste/accessibility can provide arbitrary input.
Draft state and validation
- Immediate local validation for obvious format/required constraints.
- Debounced remote validation for username/promo when needed.
- Final server/domain validation on submit.
- Preserve draft according to process-death/product privacy requirement.
Do not show “invalid email” before user has meaningfully interacted unless UX specifies it.
Focus
val passwordRequester = remember { FocusRequester() }
TextField(
value = email,
onValueChange = onEmail,
keyboardActions = KeyboardActions(
onNext = { passwordRequester.requestFocus() }
)
)
TextField(
value = password,
onValueChange = onPassword,
modifier = Modifier.focusRequester(passwordRequester)
)
Request focus in response to event/effect, not unconditionally during composition. Test hardware keyboard, D-pad, TalkBack, and IME actions.
Keyboard/IME and insets
Use IME actions (Next, Done, Search) matching form meaning. Keep focused field/action visible with IME insets/bring-into-view behavior. Do not hardcode keyboard height.
Gestures
Prefer high-level clickable, toggleable, selectable, scrollable components because they add semantics, focus, keyboard, ripple/interaction handling. Use pointerInput for genuinely custom gestures and handle cancellation/multiple pointers/conflicts.
ComposeView inside Fragment
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, state: Bundle?): View =
ComposeView(requireContext()).apply {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setContent { AppTheme { ProfileRoute() } }
}
Composition disposal must align with Fragment view lifecycle. Wrong strategy can lose state or retain composition beyond dead View tree.
AndroidView inside Compose
AndroidView(
factory = { context -> MapView(context).apply { initialize() } },
update = { map -> map.showLocation(location) },
onRelease = { map -> map.release() }
)
- Construct View in
factory, not on every recomposition. updatesynchronizes current state and can run repeatedly.- Integrate lifecycle callbacks/resource cleanup.
- Avoid retaining Activity/old state.
- Ensure accessibility and nested input/scroll interoperability.
Migration boundary
Migrate vertical slices/components, keeping one source of truth. Do not make View and Compose sides each own a mutable copy synchronized by callbacks—this creates loops/races.
ViewModel/state holder
├─ View renderer/events
└─ Compose renderer/events
Platform resources
Use stringResource, painterResource, dimensionResource where appropriate. Resolve current configuration inside UI; do not cache old Context strings in ViewModel.
Compose input should be controlled by one source of truth and emit events. Keyboard options improve UX but do not validate untrusted input. Focus/IME are event- and lifecycle-owned, with hardware/accessibility testing. For interoperability, ComposeView disposal follows the View lifecycle; AndroidView constructs once in factory, synchronizes idempotently in update, and releases resources. Hybrid UI shares one state holder rather than synchronizing two local states.
Exercises
- Design signup validation timing and process restoration.
- Explain why focus request inside composition can loop/repeat.
- Wrap a lifecycle-heavy camera View in Compose safely.