Skip to Content

Xây dựng UI chung

Hướng dẫn xây dựng UI chung cho Android và iOS với Compose Multiplatform.

Cấu trúc thư mục

composeApp/src/ ├── commonMain/kotlin/ │ ├── App.kt # Root composable │ ├── theme/ │ │ ├── Theme.kt │ │ ├── Color.kt │ │ └── Type.kt │ ├── ui/ │ │ ├── component/ # Reusable components │ │ │ ├── Button.kt │ │ │ ├── Card.kt │ │ │ ├── TextField.kt │ │ │ └── Loading.kt │ │ └── screen/ # Full screens │ │ ├── HomeScreen.kt │ │ ├── UserScreen.kt │ │ └── SettingsScreen.kt │ └── util/ │ └── Extensions.kt ├── commonMain/composeResources/ │ ├── drawable/ # Images │ ├── font/ # Custom fonts │ └── values/ │ └── strings.xml # Localized strings ├── androidMain/kotlin/ │ └── MainActivity.kt └── iosMain/kotlin/ └── MainViewController.kt

Theme System

Color.kt

// commonMain/theme/Color.kt import androidx.compose.ui.graphics.Color object AppColors { // Light theme val primaryLight = Color(0xFF6200EE) val onPrimaryLight = Color.White val backgroundLight = Color(0xFFFAFAFA) val surfaceLight = Color.White val onSurfaceLight = Color(0xFF1C1B1F) // Dark theme val primaryDark = Color(0xFFBB86FC) val onPrimaryDark = Color.Black val backgroundDark = Color(0xFF121212) val surfaceDark = Color(0xFF1E1E1E) val onSurfaceDark = Color.White // Common val error = Color(0xFFB00020) val success = Color(0xFF4CAF50) }

Theme.kt

// commonMain/theme/Theme.kt import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.* import androidx.compose.runtime.Composable private val LightColorScheme = lightColorScheme( primary = AppColors.primaryLight, onPrimary = AppColors.onPrimaryLight, background = AppColors.backgroundLight, surface = AppColors.surfaceLight, onSurface = AppColors.onSurfaceLight, ) private val DarkColorScheme = darkColorScheme( primary = AppColors.primaryDark, onPrimary = AppColors.onPrimaryDark, background = AppColors.backgroundDark, surface = AppColors.surfaceDark, onSurface = AppColors.onSurfaceDark, ) @Composable fun AppTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit ) { val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme MaterialTheme( colorScheme = colorScheme, typography = AppTypography, content = content ) }

Reusable Components

LoadingIndicator

// commonMain/ui/component/Loading.kt @Composable fun LoadingScreen() { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { CircularProgressIndicator() } } @Composable fun LoadingOverlay(isLoading: Boolean, content: @Composable () -> Unit) { Box { content() if (isLoading) { Box( modifier = Modifier .fillMaxSize() .background(Color.Black.copy(alpha = 0.3f)), contentAlignment = Alignment.Center ) { CircularProgressIndicator(color = Color.White) } } } }

Custom Button

// commonMain/ui/component/AppButton.kt @Composable fun AppButton( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, isLoading: Boolean = false ) { Button( onClick = onClick, modifier = modifier.height(48.dp), enabled = enabled && !isLoading, shape = RoundedCornerShape(12.dp) ) { if (isLoading) { CircularProgressIndicator( modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp ) } else { Text(text) } } } @Composable fun AppOutlinedButton( text: String, onClick: () -> Unit, modifier: Modifier = Modifier ) { OutlinedButton( onClick = onClick, modifier = modifier.height(48.dp), shape = RoundedCornerShape(12.dp) ) { Text(text) } }

Custom TextField

// commonMain/ui/component/AppTextField.kt @Composable fun AppTextField( value: String, onValueChange: (String) -> Unit, label: String, modifier: Modifier = Modifier, isError: Boolean = false, errorMessage: String? = null, keyboardType: KeyboardType = KeyboardType.Text, isPassword: Boolean = false ) { var passwordVisible by remember { mutableStateOf(false) } Column(modifier = modifier) { OutlinedTextField( value = value, onValueChange = onValueChange, label = { Text(label) }, modifier = Modifier.fillMaxWidth(), isError = isError, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = keyboardType), visualTransformation = if (isPassword && !passwordVisible) { PasswordVisualTransformation() } else { VisualTransformation.None }, trailingIcon = if (isPassword) { { IconButton(onClick = { passwordVisible = !passwordVisible }) { Icon( imageVector = if (passwordVisible) { Icons.Default.Visibility } else { Icons.Default.VisibilityOff }, contentDescription = "Toggle password" ) } } } else null, shape = RoundedCornerShape(12.dp) ) if (isError && errorMessage != null) { Text( text = errorMessage, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(start = 16.dp, top = 4.dp) ) } } }

User Card

// commonMain/ui/component/UserCard.kt @Composable fun UserCard( user: User, onClick: () -> Unit, modifier: Modifier = Modifier ) { Card( modifier = modifier .fillMaxWidth() .clickable(onClick = onClick), shape = RoundedCornerShape(12.dp), elevation = CardDefaults.cardElevation(4.dp) ) { Row( modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { // Avatar Box( modifier = Modifier .size(48.dp) .background( MaterialTheme.colorScheme.primary, CircleShape ), contentAlignment = Alignment.Center ) { Text( text = user.name.first().uppercase(), color = MaterialTheme.colorScheme.onPrimary, style = MaterialTheme.typography.titleMedium ) } Spacer(Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = user.name, style = MaterialTheme.typography.titleMedium ) Text( text = user.email, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } Icon( Icons.Default.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } }

Screen Pattern

UI State

// commonMain/ui/screen/UserListState.kt sealed class UserListState { object Loading : UserListState() data class Success(val users: List<User>) : UserListState() data class Error(val message: String) : UserListState() object Empty : UserListState() }

Screen Composable

// commonMain/ui/screen/UserListScreen.kt @Composable fun UserListScreen( state: UserListState, onRefresh: () -> Unit, onUserClick: (User) -> Unit ) { Scaffold( topBar = { TopAppBar( title = { Text("Users") }, actions = { IconButton(onClick = onRefresh) { Icon(Icons.Default.Refresh, "Refresh") } } ) } ) { padding -> Box( modifier = Modifier .fillMaxSize() .padding(padding) ) { when (state) { is UserListState.Loading -> LoadingScreen() is UserListState.Success -> { LazyColumn( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(state.users, key = { it.id }) { user -> UserCard( user = user, onClick = { onUserClick(user) } ) } } } is UserListState.Empty -> { EmptyState( message = "No users found", onAction = onRefresh ) } is UserListState.Error -> { ErrorState( message = state.message, onRetry = onRefresh ) } } } } } @Composable fun EmptyState(message: String, onAction: () -> Unit) { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Icon( Icons.Default.Person, contentDescription = null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(Modifier.height(16.dp)) Text(message) Spacer(Modifier.height(16.dp)) AppButton(text = "Refresh", onClick = onAction) } } @Composable fun ErrorState(message: String, onRetry: () -> Unit) { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Icon( Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.error ) Spacer(Modifier.height(16.dp)) Text(message, color = MaterialTheme.colorScheme.error) Spacer(Modifier.height(16.dp)) AppButton(text = "Retry", onClick = onRetry) } }

Resources

Sử dụng images

import org.jetbrains.compose.resources.painterResource import myapp.composeapp.generated.resources.Res import myapp.composeapp.generated.resources.logo @Composable fun LogoImage() { Image( painter = painterResource(Res.drawable.logo), contentDescription = "Logo", modifier = Modifier.size(100.dp) ) }

Sử dụng strings

import org.jetbrains.compose.resources.stringResource import myapp.composeapp.generated.resources.Res import myapp.composeapp.generated.resources.app_name @Composable fun WelcomeText() { Text(text = stringResource(Res.string.app_name)) }

📝 Tóm tắt

PatternMục đích
ThemeMàu sắc, typography
ComponentsUI tái sử dụng
ScreensFull-page composables
StateSealed class cho UI states
ResourcesImages, strings, fonts

Best Practices

  1. Tách components nhỏ, tái sử dụng
  2. Dùng sealed class cho UI state
  3. Preview để test UI nhanh
  4. Theme system cho dark/light mode
  5. Resources type-safe đến từ composeResources

Tiếp theo

Học về ViewModel Multiplatform.

Last updated on