In modern Android development, click interaction is decoupled from dedicated button widgets and can be bound to any UI surface using the Modifier.clickable pipeline or interactive Material 3 buttons.
In Jetpack Compose, TextField and OutlinedTextField are Controlled Components:
onValueChange(newString).value.(###) ###-####), and uppercase enforcement.Form validation must never be hardcoded inside button click callbacks. It should be modeled as pure, deterministic validation rules:
isError = true and displays a descriptive red error string in supportingText.remember vs. rememberSaveable
Jetpack Compose employs a Snapshot State System inspired by database transaction logs (MVCC - Multi-Version Concurrency Control). When you instantiate state via mutableStateOf(T), Compose tracks:
.value is mutated, only the reading Composable scopes are marked dirty and scheduled for Recomposition.| Feature / Behavior | remember { } |
rememberSaveable { } |
|---|---|---|
| Survives Recomposition? | ✅ YES (Preserves value across recompositions) | ✅ YES (Preserves value across recompositions) |
| Survives Configuration Changes? | ❌ NO (Resets upon device rotation / theme change) | ✅ YES (Serialized to OS SavedInstanceState Bundle) |
| Survives OS Process Death? | ❌ NO (Memory evicted) | ✅ YES (Restored by System ActivityManager) |
| Permitted Data Types | Any arbitrary in-memory Kotlin Object | Primitives, Parcelable, Serializable, or custom Saver |
| Primary Use Case | Ephemeral animation flags, scroll states, transient calculations | User inputs, search filters, draft form entries, modal visibility |
While rememberSaveable is excellent for basic UI inputs, production enterprise state belongs in an architectural ViewModel:
ViewModelStore as the host Activity tears down and recreates during screen rotation.StateFlow<UiState> that the UI collects using collectAsStateWithLifecycle().collectAsStateWithLifecycle() automatically stops collecting emissions when the Activity drops below Lifecycle.State.STARTED (e.g. app in background), eliminating battery drain and memory waste.package com.devascent.interaction.state
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
// 1. Immutable UI State Model
data class RegistrationFormState(
val fullName: String = "",
val email: String = "",
val password: String = "",
val acceptedTerms: Boolean = false,
val fullNameError: String? = null,
val emailError: String? = null,
val passwordError: String? = null,
val isSubmitting: Boolean = false
) {
val isValid: Boolean
get() = fullName.isNotBlank() && email.isNotBlank() &&
password.length >= 8 && acceptedTerms &&
fullNameError == null && emailError == null && passwordError == null
}
// 2. Architecture ViewModel
class RegistrationViewModel : ViewModel() {
private val _formState = MutableStateFlow(RegistrationFormState())
val formState: StateFlow<RegistrationFormState> = _formState.asStateFlow()
fun onNameChanged(name: String) {
val error = if (name.isBlank()) "Full name cannot be empty" else null
_formState.update { it.copy(fullName = name, fullNameError = error) }
}
fun onEmailChanged(email: String) {
val emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\$".toRegex()
val error = if (!email.matches(emailRegex)) "Invalid email format" else null
_formState.update { it.copy(email = email, emailError = error) }
}
fun onPasswordChanged(password: String) {
val error = if (password.length < 8) "Password must be at least 8 characters" else null
_formState.update { it.copy(password = password, passwordError = error) }
}
fun onTermsToggled(accepted: Boolean) {
_formState.update { it.copy(acceptedTerms = accepted) }
}
}
Modern Android applications utilize four distinct modal and feedback abstractions depending on the urgency and invasiveness required:
Toast: Simple system-rendered popup message (Toast.makeText()). Disappears automatically after a short timeout. Cannot contain interactive click buttons and is discouraged for critical feedback.
Snackbar: Material 3 component anchored to the bottom of the screen. Supports action buttons (e.g., "UNDO" or "RETRY"), adheres to accessibility standards, and is dispatched via coroutines: snackbarHostState.showSnackbar().
AlertDialog: Fully modal, screen-blocking popover window interrupting user flow to require confirmation (e.g., destructive account deletion or payment confirmation).
ModalBottomSheet: Slides smoothly up from the bottom of the screen. Ideal for contextual actions, secondary settings, sharing sheets, or displaying legal Terms of Service without losing screen context.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FeedbackControlSuite() {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var showConfirmDialog by rememberSaveable { mutableStateOf(false) }
var showBottomSheet by rememberSaveable { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState()
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
) { padding ->
Column(modifier = Modifier.padding(padding).padding(16.dp)) {
Button(onClick = { showConfirmDialog = true }) {
Text("Submit Enrollment")
}
OutlinedButton(onClick = { showBottomSheet = true }) {
Text("View Terms of Service")
}
}
// 1. Modal Alert Dialog
if (showConfirmDialog) {
AlertDialog(
onDismissRequest = { showConfirmDialog = false },
title = { Text("Confirm Enrollment") },
text = { Text("Are you sure you want to enroll in Android Track Unit 6?") },
confirmButton = {
Button(onClick = {
showConfirmDialog = false
scope.launch {
snackbarHostState.showSnackbar(
message = "Enrollment confirmed!",
actionLabel = "Dismiss"
)
}
}) { Text("Confirm") }
},
dismissButton = {
TextButton(onClick = { showConfirmDialog = false }) { Text("Cancel") }
}
)
}
// 2. Modal Bottom Sheet
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
sheetState = sheetState
) {
Column(modifier = Modifier.padding(24.dp)) {
Text("DevAscent Academy Policies", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(8.dp))
Text("All students agree to uphold academic honesty and complete all programming labs independently.")
}
}
}
}
}
The following production-grade application integrates real-time input validation, state hoisting via ViewModel, interactive error states, an AlertDialog confirmation prompt, ModalBottomSheet policy inspection, and asynchronous Snackbar feedback.
package com.devascent.interaction
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.launch
class RegistrationActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
StudentRegistrationScreen()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StudentRegistrationScreen(
viewModel: RegistrationViewModel = viewModel()
) {
val state by viewModel.formState.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var showConfirmDialog by remember { mutableStateOf(false) }
var showSheet by remember { mutableStateOf(false) }
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("DevAscent Academy Registration") },
actions = {
IconButton(onClick = { showSheet = true }) {
Icon(Icons.Default.Info, contentDescription = "Terms")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
// 1. Name Input with Reactive Error State
OutlinedTextField(
value = state.fullName,
onValueChange = { viewModel.onNameChanged(it) },
label = { Text("Student Full Name") },
isError = state.fullNameError != null,
supportingText = { state.fullNameError?.let { Text(it) } },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// 2. Email Input
OutlinedTextField(
value = state.email,
onValueChange = { viewModel.onEmailChanged(it) },
label = { Text("Academic Email") },
isError = state.emailError != null,
supportingText = { state.emailError?.let { Text(it) } },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// 3. Password Input
OutlinedTextField(
value = state.password,
onValueChange = { viewModel.onPasswordChanged(it) },
label = { Text("Password") },
isError = state.passwordError != null,
supportingText = { state.passwordError?.let { Text(it) } },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// 4. Terms Checkbox Row
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = state.acceptedTerms,
onCheckedChange = { viewModel.onTermsToggled(it) }
)
Text("I agree to the Academy Code of Conduct")
}
Spacer(modifier = Modifier.weight(1f))
// 5. Submit Button (Enabled strictly when form is valid)
Button(
onClick = { showConfirmDialog = true },
enabled = state.isValid,
modifier = Modifier.fillMaxWidth().height(50.dp)
) {
Text("Register for Course Track")
}
}
// Confirmation Dialog
if (showConfirmDialog) {
AlertDialog(
onDismissRequest = { showConfirmDialog = false },
title = { Text("Confirm Registration") },
text = { Text("Enroll student ${state.fullName} (${state.email})?") },
confirmButton = {
Button(onClick = {
showConfirmDialog = false
Log.i("RegistrationApp", "Enrolled: ${state.fullName}")
scope.launch {
snackbarHostState.showSnackbar(
message = "Welcome to DevAscent, ${state.fullName}!",
duration = SnackbarDuration.Short
)
}
}) { Text("Submit") }
},
dismissButton = {
TextButton(onClick = { showConfirmDialog = false }) { Text("Cancel") }
}
)
}
// Bottom Sheet
if (showSheet) {
ModalBottomSheet(onDismissRequest = { showSheet = false }) {
Column(modifier = Modifier.padding(24.dp)) {
Text("Academy Terms & Policies", style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.height(12.dp))
Text("1. Course certifications require 100% completion of all 15 units.")
Text("2. Code implementations are verified through automated unit test gates.")
}
}
}
}
}
remember and rememberSaveable?remember { } retains an object in memory only across recompositions as long as the composable function remains in the composition tree. However, when an Android configuration change occurs (such as device rotation, language switch, or split-screen resize), the host Activity is destroyed and recreated, causing remember to reset to its initial default value. rememberSaveable { } persists the state into the system SavedInstanceState Bundle, surviving configuration changes and OS-initiated process death.
value: String, onValueChange: (String) -> Unit). This achieves complete decoupling, maximizes UI component reusability, simplifies previewing in Android Studio, and enables frictionless unit testing without UI dependencies.
collectAsStateWithLifecycle() be used instead of collectAsState()?collectAsState() continues actively collecting flow emissions as long as the Composable is in the composition, even if the hosting Activity is in the STOPPED background state (e.g., user navigated to another app). This wastes CPU, network data, and battery. In contrast, collectAsStateWithLifecycle() ties flow collection to the Android Lifecycle (defaulting to Lifecycle.State.STARTED), automatically pausing collection when the app is obscured and resuming seamlessly when returning to the foreground.
MutableState write occurs, the runtime identifies strictly the specific functions that read that modified state and marks them dirty. If a Composable's arguments are stable (primitive types, immutable data classes, or types annotated with @Immutable) and have not changed (structural equality ==), the Compose compiler completely skips executing that Composable. This prevents full-tree rerenders and maintains 60/120 FPS rendering speeds.
Scaffold), complies with Material Design 3 guidelines, can contain an actionable interactive button (e.g., "UNDO" or "RETRY"), and is managed via asynchronous Coroutine scopes.
isValid and error states.
ViewModelStore. When the new Activity instance is instantiated and calls viewModel(), the ViewModelProvider re-attaches to the preexisting ViewModelStore and retrieves the identical ViewModel instance from the heap. The ViewModel's onCleared() method is only invoked when the Activity finishes permanently (e.g., user presses Back or calls finish()).