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.ktTheme 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
| Pattern | Mục đích |
|---|---|
| Theme | Màu sắc, typography |
| Components | UI tái sử dụng |
| Screens | Full-page composables |
| State | Sealed class cho UI states |
| Resources | Images, strings, fonts |
Best Practices
- Tách components nhỏ, tái sử dụng
- Dùng sealed class cho UI state
- Preview để test UI nhanh
- Theme system cho dark/light mode
- Resources type-safe đến từ composeResources
Tiếp theo
Học về ViewModel Multiplatform.
Last updated on