Kotlin_Jetpack_Compose2
๐ฏ Best Practices ์์ฝ
๐ก ์ฑ๋ฅ ์ต์ ํ
- remember ์ฌ์ฉ์ผ๋ก ๋ถํ์ํ ์ฌ๊ณ์ฐ ๋ฐฉ์ง
- LazyColumn/LazyRow ์ฌ์ฉ์ผ๋ก ๋์ฉ๋ ๋ฆฌ์คํธ ์ต์ ํ
- key ํ๋ผ๋ฏธํฐ ์ฌ์ฉ์ผ๋ก recomposition ์ต์ ํ
๐ง ์ฝ๋ ํ์ง
- Composable ํจ์ ๋ถ๋ฆฌ๋ก ์ฌ์ฌ์ฉ์ฑ ํฅ์
- State hoisting์ผ๋ก ์ํ ๊ด๋ฆฌ ์ต์ ํ
- testTag ์ฌ์ฉ์ผ๋ก ํ ์คํธ ๊ฐ๋ฅํ UI ๊ตฌ์ฑ
๐ ์ฌ์ฉ์ ๊ฒฝํ
- Loading states๋ก ์ฌ์ฉ์ ํผ๋๋ฐฑ ์ ๊ณต
- Error handling์ผ๋ก ์ค๋ฅ ์ํฉ ๋์
- Accessibility๋ฅผ ๊ณ ๋ คํ UI ์ค๊ณ
๋ชฉ์ฐจ
๐ ๋คํธ์ํฌ ํต์ ๊ณผ ๋ฐ์ดํฐ ์ฒ๋ฆฌ
๐จ ํ ๋ง์ ์คํ์ผ๋ง
๐ ์ฑ๋ฅ Recomposition ์ต์ ํ
๐งช ํ ์คํธ
๐ก ์ฝ๋ ๊ตฌ์กฐํ
๐ ๋คํธ์ํฌ ํต์ ๊ณผ ๋ฐ์ดํฐ ์ฒ๋ฆฌ
๐ HTTP ํต์
HTTP ํต์ ์ ์๋ฒ์ ๋ฐ์ดํฐ๋ฅผ ์ฃผ๊ณ ๋ฐ๋ ๋ฐฉ์์ ๋๋ค. Axios๋ Fetch API์ ์ ์ฌํ ๊ฐ๋ ์ ๋๋ค.
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.net.HttpURLConnection
import java.net.URL
/**
* ์ฌ์ฉ์ ๋ฐ์ดํฐ ํด๋์ค
*/
data class User(
val id: Int,
val name: String,
val email: String,
val phone: String
)
/**
* UI ์ํ ๊ด๋ฆฌ
* Redux์ State์ ์ ์ฌํ ๊ฐ๋
*/
sealed class UiState {
object Loading : UiState()
data class Success(val users: List<User>) : UiState()
data class Error(val message: String) : UiState()
}
/**
* ๋คํธ์ํฌ ํต์ ViewModel
* React์ Custom Hook์ด๋ Redux Thunk์ ์ ์ฌ
*/
class NetworkViewModel : ViewModel() {
// StateFlow๋ React์ state์ ์ ์ฌ
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
init {
// ์ด๊ธฐ ๋ฐ์ดํฐ ๋ก๋
loadUsers()
}
/**
* ์ฌ์ฉ์ ๋ฐ์ดํฐ ๋ก๋
* Axios๋ Fetch API์ ์ ์ฌํ ๊ธฐ๋ฅ
*/
fun loadUsers() {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
// ์ค์ ํ๋ก์ ํธ์์๋ Retrofit์ด๋ Ktor ์ฌ์ฉ
val users = fetchUsersFromApi()
_uiState.value = UiState.Success(users)
} catch (e: Exception) {
_uiState.value = UiState.Error("๋ฐ์ดํฐ ๋ก๋ ์คํจ: ${e.message}")
}
}
}
/**
* ๋ชจ์ API ํธ์ถ
* ์ค์ ๋ก๋ Retrofit์ด๋ Ktor ์ฌ์ฉ
*/
private suspend fun fetchUsersFromApi(): List<User> {
// ๋คํธ์ํฌ ์ง์ฐ ์๋ฎฌ๋ ์ด์
kotlinx.coroutines.delay(2000)
// ๋ชจ์ ๋ฐ์ดํฐ ๋ฐํ
return listOf(
User(1, "ํ๊ธธ๋", "hong@example.com", "010-1234-5678"),
User(2, "๊น์ฒ ์", "kim@example.com", "010-2345-6789"),
User(3, "์ด์ํฌ", "lee@example.com", "010-3456-7890"),
User(4, "๋ฐ์ง๋ฏผ", "park@example.com", "010-4567-8901"),
User(5, "์ ์๋น", "jung@example.com", "010-5678-9012")
)
}
}
/**
* ๋คํธ์ํฌ ํต์ ํ๋ฉด
* React์ App Component์ ์ ์ฌ
*/
@Composable
fun NetworkScreen() {
val viewModel: NetworkViewModel = viewModel()
val uiState by viewModel.uiState.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// ํค๋
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "๐ฅ ์ฌ์ฉ์ ๋ชฉ๋ก",
style = MaterialTheme.typography.headlineLarge
)
IconButton(
onClick = { viewModel.loadUsers() }
) {
Icon(
Icons.Default.Refresh,
contentDescription = "์๋ก๊ณ ์นจ"
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// UI ์ํ์ ๋ฐ๋ฅธ ํ๋ฉด ํ์
// React์ conditional rendering๊ณผ ์ ์ฌ
when (uiState) {
is UiState.Loading -> {
// ๋ก๋ฉ ์ํ
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text("๋ฐ์ดํฐ๋ฅผ ๋ถ๋ฌ์ค๋ ์ค...")
}
}
}
is UiState.Success -> {
// ์ฑ๊ณต ์ํ
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(uiState.users) { user ->
UserCard(user = user)
}
}
}
is UiState.Error -> {
// ์๋ฌ ์ํ
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "โ ์ค๋ฅ ๋ฐ์",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = uiState.message,
color = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { viewModel.loadUsers() }
) {
Text("๋ค์ ์๋")
}
}
}
}
}
}
}
/**
* ์ฌ์ฉ์ ์นด๋ ์ปดํฌ๋ํธ
* React์ UserCard Component์ ์ ์ฌ
*/
@Composable
fun UserCard(user: User) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(4.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = user.name,
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "๐ง ${user.email}",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "๐ฑ ${user.phone}",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
๐จ ํ ๋ง์ ์คํ์ผ๋ง
๐ญ Material Design ํ ๋ง
Material Design์ Google์ design language์ ๋๋ค. Bootstrap์ด๋ Ant Design๊ณผ ์ ์ฌํ design system์ ๋๋ค.
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* ์ปค์คํ
์์ ํ๋ ํธ
* CSS Variables๋ SCSS Variables์ ์ ์ฌ
*/
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF1976D2),
onPrimary = Color.White,
primaryContainer = Color(0xFFBBDEFB),
onPrimaryContainer = Color(0xFF0D47A1),
secondary = Color(0xFF03DAC6),
onSecondary = Color.Black,
tertiary = Color(0xFF018786),
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
error = Color(0xFFBA1A1A),
onError = Color.White
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF90CAF9),
onPrimary = Color(0xFF0D47A1),
primaryContainer = Color(0xFF1565C0),
onPrimaryContainer = Color(0xFFE3F2FD),
secondary = Color(0xFF03DAC6),
onSecondary = Color.Black,
tertiary = Color(0xFF66FFF9),
background = Color(0xFF1C1B1F),
surface = Color(0xFF1C1B1F),
onBackground = Color(0xFFE6E1E5),
onSurface = Color(0xFFE6E1E5),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005)
)
/**
* ์ปค์คํ
ํ์ดํฌ๊ทธ๋ํผ
* CSS Font Family์ ์ ์ฌ
*/
private val CustomTypography = Typography(
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
)
)
/**
* ์ปค์คํ
Shapes
* CSS border-radius์ ์ ์ฌ
*/
private val CustomShapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(8.dp),
large = RoundedCornerShape(16.dp)
)
/**
* ์ฑ ํ
๋ง ์ค์
* CSS Global Styles๋ styled-components์ ThemeProvider์ ์ ์ฌ
*/
@Composable
fun CustomTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) {
DarkColorScheme
} else {
LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = CustomTypography,
shapes = CustomShapes,
content = content
)
}
/**
* ํ
๋ง ๋ฐ๋ชจ ํ๋ฉด
* ๋ค์ํ Material Design ์ปดํฌ๋ํธ ์คํ์ผ๋ง ์์
*/
@Composable
fun ThemeDemo() {
var isDarkMode by remember { mutableStateOf(false) }
CustomTheme(darkTheme = isDarkMode) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// ํ
๋ง ํ ๊ธ ํค๋
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "๐จ ํ
๋ง ์์ ",
style = MaterialTheme.typography.headlineLarge
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("๐")
Switch(
checked = isDarkMode,
onCheckedChange = { isDarkMode = it }
)
Text("๐")
}
}
// ์์ ํ๋ ํธ ํ์
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(4.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "์์ ํ๋ ํธ",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(12.dp))
// Primary ์์
ColorSwatch(
color = MaterialTheme.colorScheme.primary,
name = "Primary",
textColor = MaterialTheme.colorScheme.onPrimary
)
Spacer(modifier = Modifier.height(8.dp))
// Secondary ์์
ColorSwatch(
color = MaterialTheme.colorScheme.secondary,
name = "Secondary",
textColor = MaterialTheme.colorScheme.onSecondary
)
Spacer(modifier = Modifier.height(8.dp))
// Surface ์์
ColorSwatch(
color = MaterialTheme.colorScheme.surface,
name = "Surface",
textColor = MaterialTheme.colorScheme.onSurface
)
}
}
// ํ์ดํฌ๊ทธ๋ํผ ์์
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(4.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "ํ์ดํฌ๊ทธ๋ํผ",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Display Large",
style = MaterialTheme.typography.displayLarge
)
Text(
text = "Headline Large",
style = MaterialTheme.typography.headlineLarge
)
Text(
text = "Title Large",
style = MaterialTheme.typography.titleLarge
)
Text(
text = "Body Large - ๋ณธ๋ฌธ ํ
์คํธ์
๋๋ค.",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = "Label Large",
style = MaterialTheme.typography.labelLarge
)
}
}
// ์ปดํฌ๋ํธ ์คํ์ผ๋ง ์์
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(4.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "์ปดํฌ๋ํธ ์คํ์ผ๋ง",
style = MaterialTheme.typography.titleLarge
)
// ๋ค์ํ ๋ฒํผ ์คํ์ผ
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = { /* ์ก์
*/ }
) {
Text("Primary")
}
OutlinedButton(
onClick = { /* ์ก์
*/ }
) {
Text("Outlined")
}
TextButton(
onClick = { /* ์ก์
*/ }
) {
Text("Text")
}
}
// ์ปค์คํ
์คํ์ผ ๋ฒํผ
Button(
onClick = { /* ์ก์
*/ },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.tertiary,
contentColor = MaterialTheme.colorScheme.onTertiary
),
shape = MaterialTheme.shapes.large
) {
Text("์ปค์คํ
๋ฒํผ")
}
// ํ
์คํธ ํ๋
var textValue by remember { mutableStateOf("") }
TextField(
value = textValue,
onValueChange = { textValue = it },
label = { Text("ํ
๋ง ์ ์ฉ๋ ํ
์คํธ ํ๋") },
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
/**
* ์์ ๊ฒฌ๋ณธ ์ปดํฌ๋ํธ
* CSS Color Palette์ ์ ์ฌ
*/
@Composable
fun ColorSwatch(
color: Color,
name: String,
textColor: Color
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.background(color, MaterialTheme.shapes.small)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = name,
style = MaterialTheme.typography.bodyLarge,
color = textColor
)
}
}
๐ ์ฑ๋ฅ ์ต์ ํ
โก Recomposition ์ต์ ํ
Recomposition์ ํ์ํ ๋ถ๋ถ๋ง ๋ค์ ๊ทธ๋ ค์ผ ์ฑ๋ฅ์ด ์ข์ต๋๋ค. React์ ๋ฆฌ๋ ๋๋ง ์ต์ ํ์ ๋์ผํ ๊ฐ๋ ์ ๋๋ค.
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
* ์ฑ๋ฅ ์ต์ ํ ์์
* React์ useMemo, useCallback, React.memo์ ์ ์ฌํ ์ต์ ํ ๊ธฐ๋ฒ
*/
@Composable
fun PerformanceExample() {
var counter by remember { mutableStateOf(0) }
var expensiveValue by remember { mutableStateOf(0) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "โก ์ฑ๋ฅ ์ต์ ํ ์์ ",
style = MaterialTheme.typography.headlineLarge
)
// 1. remember๋ฅผ ์ฌ์ฉํ ๊ฐ ์บ์ฑ
// React์ useMemo์ ๋์ผํ ๊ฐ๋
val expensiveCalculation = remember(expensiveValue) {
// expensiveValue๊ฐ ๋ณ๊ฒฝ๋ ๋๋ง ๊ณ์ฐ
println("๐ ๋น์ฉ์ด ๋ง์ด ๋๋ ๊ณ์ฐ ์คํ")
(1..expensiveValue).map { it * it }.sum()
}
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(4.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "๐ฐ ๋น์ฉ์ด ๋ง์ด ๋๋ ๊ณ์ฐ",
style = MaterialTheme.typography.titleLarge
)
Text("์
๋ ฅ๊ฐ: $expensiveValue")
Text("๊ณ์ฐ ๊ฒฐ๊ณผ: $expensiveCalculation")
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { expensiveValue++ }
) {
Text("๊ฐ ์ฆ๊ฐ")
}
}
}
// 2. ๋ถ๋ฆฌ๋ ์ปดํฌ๋ํธ๋ก recomposition ์ต์ ํ
// React์ ์ปดํฌ๋ํธ ๋ถ๋ฆฌ์ ๋์ผํ ๊ฐ๋
CounterSection(
counter = counter,
onIncrement = { counter++ },
onDecrement = { counter-- }
)
// 3. LazyColumn์ผ๋ก ๋์ฉ๋ ๋ฆฌ์คํธ ์ต์ ํ
// React์ Virtual List์ ๋์ผํ ๊ฐ๋
LargeListSection()
}
}
/**
* ๋ถ๋ฆฌ๋ ์นด์ดํฐ ์ปดํฌ๋ํธ
* React์ memo()์ ์ ์ฌํ ์ต์ ํ ํจ๊ณผ
*/
@Composable
fun CounterSection(
counter: Int,
onIncrement: () -> Unit,
onDecrement: () -> Unit
) {
// ์ด ์ปดํฌ๋ํธ๋ counter ๊ด๋ จ ๊ฐ์ด ๋ณ๊ฒฝ๋ ๋๋ง recomposition
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(4.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "๐ ์นด์ดํฐ ์น์
",
style = MaterialTheme.typography.titleLarge
)
Text(
text = "Count: $counter",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(16.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(onClick = onDecrement) {
Text("-")
}
Button(onClick = onIncrement) {
Text("+")
}
}
}
}
}
/**
* ๋์ฉ๋ ๋ฆฌ์คํธ ์น์
* React์ Virtual List์ ๋์ผํ ๊ฐ๋
*/
@Composable
fun LargeListSection() {
var items by remember { mutableStateOf(generateLargeList()) }
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(4.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "๐ ๋์ฉ๋ ๋ฆฌ์คํธ (${items.size}๊ฐ)",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { items = generateLargeList() }
) {
Text("๋ชฉ๋ก ์๋ก๊ณ ์นจ")
}
Spacer(modifier = Modifier.height(8.dp))
// LazyColumn์ ๋ณด์ด๋ ํญ๋ชฉ๋ง ๋ ๋๋ง
// React์ react-window๋ react-virtualized์ ๋์ผ
LazyColumn(
modifier = Modifier.height(200.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(items) { item ->
// ๊ฐ ํญ๋ชฉ์ ๋
๋ฆฝ์ ์ผ๋ก recomposition
ListItem(item = item)
}
}
}
}
}
/**
* ๋ฆฌ์คํธ ํญ๋ชฉ ์ปดํฌ๋ํธ
* React์ memo()์ ์ ์ฌํ ์ต์ ํ ํจ๊ณผ
*/
@Composable
fun ListItem(item: String) {
var isSelected by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { isSelected = !isSelected }
.background(
if (isSelected) Color.LightGray else Color.Transparent,
MaterialTheme.shapes.small
)
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = item,
modifier = Modifier.weight(1f)
)
if (isSelected) {
Text(
text = "โ",
color = MaterialTheme.colorScheme.primary
)
}
}
}
/**
* ๋์ฉ๋ ๋ฆฌ์คํธ ์์ฑ ํจ์
*/
fun generateLargeList(): List<String> {
return (1..1000).map { "Item $it" }
}
๐งช ํ ์คํธ
๐ UI ํ ์คํธ
UI ํ
์คํธ๋ ์ฌ์ฉ์ ์ธํฐํ์ด์ค๊ฐ ์ฌ๋ฐ๋ฅด๊ฒ ์๋ํ๋์ง ํ์ธํฉ๋๋ค.
React Testing Library๋ Cypress์ ์ ์ฌํ ๊ฐ๋
์
๋๋ค.
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
/**
* ํ
์คํธ ๊ฐ๋ฅํ ์ปดํฌ๋ํธ ์์
* React Testing Library์ ํ
์คํธ ํจํด๊ณผ ์ ์ฌ
*/
@Composable
fun TestableComponent() {
var count by remember { mutableStateOf(0) }
var text by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.semantics {
contentDescription = "๋ฉ์ธ ํ๋ฉด" // ์ ๊ทผ์ฑ์ ์ํ ์ค๋ช
},
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "๐งช ํ
์คํธ ์์ ",
style = MaterialTheme.typography.headlineLarge
)
// ์นด์ดํฐ ์น์
Card(
modifier = Modifier
.fillMaxWidth()
.testTag("counter-card"), // ํ
์คํธ ID
elevation = CardDefaults.cardElevation(4.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Count: $count",
modifier = Modifier.testTag("counter-text"), // ํ
์คํธ ID
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(16.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = { count-- },
modifier = Modifier.testTag("decrement-button") // ํ
์คํธ ID
) {
Text("-")
}
Button(
onClick = { count++ },
modifier = Modifier.testTag("increment-button") // ํ
์คํธ ID
) {
Text("+")
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { count = 0 },
modifier = Modifier.testTag("reset-button") // ํ
์คํธ ID
) {
Text("Reset")
}
}
}
// ํ
์คํธ ์
๋ ฅ ์น์
Card(
modifier = Modifier
.fillMaxWidth()
.testTag("text-input-card"), // ํ
์คํธ ID
elevation = CardDefaults.cardElevation(4.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
TextField(
value = text,
onValueChange = { text = it },
label = { Text("ํ
์คํธ ์
๋ ฅ") },
modifier = Modifier
.fillMaxWidth()
.testTag("text-input"), // ํ
์คํธ ID
placeholder = { Text("์ฌ๊ธฐ์ ์
๋ ฅํ์ธ์") }
)
Spacer(modifier = Modifier.height(16.dp))
if (text.isNotBlank()) {
Text(
text = "์
๋ ฅ๋ ํ
์คํธ: $text",
modifier = Modifier.testTag("display-text"), // ํ
์คํธ ID
style = MaterialTheme.typography.bodyLarge
)
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { text = "" },
modifier = Modifier.testTag("clear-text-button"), // ํ
์คํธ ID
enabled = text.isNotBlank()
) {
Text("Clear")
}
}
}
// ์กฐ๊ฑด๋ถ ๋ ๋๋ง ํ
์คํธ
if (count > 5) {
Card(
modifier = Modifier
.fillMaxWidth()
.testTag("achievement-card"), // ํ
์คํธ ID
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Text(
text = "๐ 5๋ฅผ ๋์์ต๋๋ค!",
modifier = Modifier
.padding(16.dp)
.testTag("achievement-text"), // ํ
์คํธ ID
style = MaterialTheme.typography.titleLarge
)
}
}
}
}
/**
* ํ
์คํธ ์์ (์ค์ ํ
์คํธ ํ์ผ์ ์์ฑ)
* React Testing Library์ ํ
์คํธ ํจํด๊ณผ ์ ์ฌ
*/
@Test
fun testCounterIncrement() {
// React Testing Library์ render()์ ์ ์ฌ
composeTestRule.setContent {
TestableComponent()
}
// ์ด๊ธฐ ์ํ ํ์ธ - getByTestId()์ ์ ์ฌ
composeTestRule
.onNodeWithTag("counter-text")
.assertTextEquals("Count: 0")
// ๋ฒํผ ํด๋ฆญ - fireEvent.click()์ ์ ์ฌ
composeTestRule
.onNodeWithTag("increment-button")
.performClick()
// ์ํ ๋ณ๊ฒฝ ํ์ธ - expect()์ ์ ์ฌ
composeTestRule
.onNodeWithTag("counter-text")
.assertTextEquals("Count: 1")
}
@Test
fun testTextInput() {
composeTestRule.setContent {
TestableComponent()
}
// ํ
์คํธ ์
๋ ฅ - fireEvent.change()์ ์ ์ฌ
composeTestRule
.onNodeWithTag("text-input")
.performTextInput("Hello, World!")
// ์
๋ ฅ ๊ฒฐ๊ณผ ํ์ธ
composeTestRule
.onNodeWithTag("display-text")
.assertTextEquals("์
๋ ฅ๋ ํ
์คํธ: Hello, World!")
// ํด๋ฆฌ์ด ๋ฒํผ ํด๋ฆญ
composeTestRule
.onNodeWithTag("clear-text-button")
.performClick()
// ํ
์คํธ๊ฐ ์ง์์ก๋์ง ํ์ธ
composeTestRule
.onNodeWithTag("display-text")
.assertDoesNotExist()
}
@Test
fun testConditionalRendering() {
composeTestRule.setContent {
TestableComponent()
}
// ์ด๊ธฐ์๋ achievement-card๊ฐ ์์
composeTestRule
.onNodeWithTag("achievement-card")
.assertDoesNotExist()
// 6๋ฒ ํด๋ฆญํ์ฌ 5๋ฅผ ๋๊น
repeat(6) {
composeTestRule
.onNodeWithTag("increment-button")
.performClick()
}
// achievement-card๊ฐ ๋ํ๋ฌ๋์ง ํ์ธ
composeTestRule
.onNodeWithTag("achievement-card")
.assertExists()
composeTestRule
.onNodeWithTag("achievement-text")
.assertTextEquals("๐ 5๋ฅผ ๋์์ต๋๋ค!")
}
๐ง ์ค๋ฌด Best Practices
๐ก ์ฝ๋ ๊ตฌ์กฐํ
์ฝ๋ ๊ตฌ์กฐํ๋ ์ ์ง๋ณด์์ฑ๊ณผ ํ์ฅ์ฑ์ ๋์ด๋ ํต์ฌ์ ๋๋ค.
/**
* ์ค๋ฌด ํ๋ก์ ํธ ๊ตฌ์กฐ ์์
*
* ๐ app/
* โโโ ๐ src/main/java/com/example/app/
* โ โโโ ๐ ui/
* โ โ โโโ ๐ components/ # ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ ์ปดํฌ๋ํธ
* โ โ โโโ ๐ screens/ # ํ๋ฉด๋ณ ์ปดํฌ๋ํธ
* โ โ โโโ ๐ theme/ # ํ
๋ง ๊ด๋ จ ํ์ผ
* โ โ โโโ ๐ navigation/ # ๋ค๋น๊ฒ์ด์
๊ด๋ จ
* โ โโโ ๐ data/
* โ โ โโโ ๐ repository/ # ๋ฐ์ดํฐ ์ ์ฅ์
* โ โ โโโ ๐ network/ # ๋คํธ์ํฌ ๊ด๋ จ
* โ โ โโโ ๐ local/ # ๋ก์ปฌ ๋ฐ์ดํฐ๋ฒ ์ด์ค
* โ โโโ ๐ domain/
* โ โ โโโ ๐ model/ # ๋ฐ์ดํฐ ๋ชจ๋ธ
* โ โ โโโ ๐ usecase/ # ๋น์ฆ๋์ค ๋ก์ง
* โ โ โโโ ๐ repository/ # ์ ์ฅ์ ์ธํฐํ์ด์ค
* โ โโโ ๐ presentation/
* โ โโโ ๐ viewmodel/ # ViewModel
* โ โโโ ๐ state/ # UI State
*/
/**
* ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ ์ปดํฌ๋ํธ ์์
* React์ ๊ณตํต ์ปดํฌ๋ํธ์ ๋์ผํ ๊ฐ๋
*/
@Composable
fun CommonButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
variant: ButtonVariant = ButtonVariant.Primary
) {
val colors = when (variant) {
ButtonVariant.Primary -> ButtonDefaults.buttonColors()
ButtonVariant.Secondary -> ButtonDefaults.outlinedButtonColors()
ButtonVariant.Danger -> ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
}
Button(
onClick = onClick,
modifier = modifier,
enabled = enabled,
colors = colors
) {
Text(text)
}
}
enum class ButtonVariant {
Primary, Secondary, Danger
}
/**
* ๊ณตํต ๋ก๋ฉ ์ปดํฌ๋ํธ
* React์ Loading Component์ ๋์ผ
*/
@Composable
fun LoadingSpinner(
modifier: Modifier = Modifier,
message: String = "๋ก๋ฉ ์ค..."
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
/**
* ์๋ฌ ํ์ ์ปดํฌ๋ํธ
* React์ Error Component์ ๋์ผ
*/
@Composable
fun ErrorDisplay(
message: String,
onRetry: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "โ ์ค๋ฅ ๋ฐ์",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.height(16.dp))
CommonButton(
text = "๋ค์ ์๋",
onClick = onRetry,
variant = ButtonVariant.Primary
)
}
}
}
๐ฑ ํ๋ก์ ํธ ์์
๐ ์ผํ๋ชฐ ์ฑ ์์
/**
* ์์ ํ ์ผํ๋ชฐ ์ฑ ์์
* ์ค๋ฌด์์ ์ฌ์ฉ๋๋ ๋ชจ๋ ํจํด์ ํฌํจ
*/
// ๋ฐ์ดํฐ ๋ชจ๋ธ - TypeScript์ interface์ ์ ์ฌ
data class Product(
val id: Int,
val name: String,
val price: Double,
val imageUrl: String,
val description: String,
val category: String,
val rating: Float = 0f,
val reviewCount: Int = 0
)
data class CartItem(
val product: Product,
val quantity: Int
)
// UI ์ํ ๊ด๋ฆฌ - Redux์ State์ ์ ์ฌ
sealed class ShoppingUiState {
object Loading : ShoppingUiState()
data class Success(
val products: List<Product>,
val cartItems: List<CartItem> = emptyList(),
val selectedCategory: String = "All"
) : ShoppingUiState()
data class Error(val message: String) : ShoppingUiState()
}
// ViewModel - React์ Custom Hook์ด๋ Redux Store์ ์ ์ฌ
class ShoppingViewModel : ViewModel() {
// StateFlow๋ React์ state์ ์ ์ฌ
private val _uiState = MutableStateFlow<ShoppingUiState>(ShoppingUiState.Loading)
val uiState: StateFlow<ShoppingUiState> = _uiState.asStateFlow()
init {
loadProducts() // ์ปดํฌ๋ํธ ๋ง์ดํธ ์ ์๋ ์คํ
}
// ์ํ ๋ก๋ - async/await ํจํด๊ณผ ์ ์ฌ
private fun loadProducts() {
viewModelScope.launch {
_uiState.value = ShoppingUiState.Loading
try {
delay(1000) // ๋คํธ์ํฌ ์ง์ฐ ์๋ฎฌ๋ ์ด์
val products = generateSampleProducts()
_uiState.value = ShoppingUiState.Success(products)
} catch (e: Exception) {
_uiState.value = ShoppingUiState.Error("์ํ์ ๋ถ๋ฌ์ค๋๋ฐ ์คํจํ์ต๋๋ค")
}
}
}
// ์ฅ๋ฐ๊ตฌ๋ ์ถ๊ฐ - Redux์ action๊ณผ ์ ์ฌ
fun addToCart(product: Product) {
val currentState = _uiState.value
if (currentState is ShoppingUiState.Success) {
val existingItem = currentState.cartItems.find { it.product.id == product.id }
val updatedCart = if (existingItem != null) {
// ๊ธฐ์กด ์์ดํ
์๋ ์ฆ๊ฐ
currentState.cartItems.map {
if (it.product.id == product.id) {
it.copy(quantity = it.quantity + 1)
} else it
}
} else {
// ์ ์์ดํ
์ถ๊ฐ
currentState.cartItems + CartItem(product, 1)
}
_uiState.value = currentState.copy(cartItems = updatedCart)
}
}
// ์นดํ
๊ณ ๋ฆฌ ํํฐ - Redux์ action๊ณผ ์ ์ฌ
fun filterByCategory(category: String) {
val currentState = _uiState.value
if (currentState is ShoppingUiState.Success) {
_uiState.value = currentState.copy(selectedCategory = category)
}
}
// ๋ชจ์ ๋ฐ์ดํฐ ์์ฑ - ์ค์ ๋ก๋ API ํธ์ถ
private fun generateSampleProducts(): List<Product> {
return listOf(
Product(1, "์ค๋งํธํฐ", 899.99, "", "์ต์ ์ค๋งํธํฐ", "Electronics", 4.5f, 123),
Product(2, "๋
ธํธ๋ถ", 1299.99, "", "๊ณ ์ฑ๋ฅ ๋
ธํธ๋ถ", "Electronics", 4.3f, 89),
Product(3, "์ด๋ํ", 129.99, "", "ํธ์ํ ์ด๋ํ", "Fashion", 4.7f, 234),
Product(4, "์ฑ
", 19.99, "", "๋ฒ ์คํธ์
๋ฌ ๋์", "Books", 4.2f, 45),
Product(5, "์ปคํผ", 12.99, "", "์๋ ์ปคํผ", "Food", 4.6f, 167)
)
}
}
// ๋ฉ์ธ ์ฑ ์ปดํฌ๋ํธ
@Composable
fun ShoppingApp() {
val viewModel: ShoppingViewModel = viewModel() // React์ useContext์ ์ ์ฌ
val uiState by viewModel.uiState.collectAsState() // React์ useSelector์ ์ ์ฌ
Box(
modifier = Modifier.fillMaxSize()
) {
// ์ํ์ ๋ฐ๋ฅธ ์กฐ๊ฑด๋ถ ๋ ๋๋ง - React์ conditional rendering๊ณผ ๋์ผ
when (uiState) {
is ShoppingUiState.Loading -> {
LoadingSpinner(message = "์ํ์ ๋ถ๋ฌ์ค๋ ์ค...")
}
is ShoppingUiState.Success -> {
ShoppingContent(
products = uiState.products,
cartItems = uiState.cartItems,
selectedCategory = uiState.selectedCategory,
onAddToCart = viewModel::addToCart, // React์ callback props์ ์ ์ฌ
onCategoryChange = viewModel::filterByCategory
)
}
is ShoppingUiState.Error -> {
ErrorDisplay(
message = uiState.message,
onRetry = { viewModel.loadProducts() }
)
}
}
}
}
// ๋ฉ์ธ ์ฝํ
์ธ ์ปดํฌ๋ํธ
@Composable
fun ShoppingContent(
products: List<Product>,
cartItems: List<CartItem>,
selectedCategory: String,
onAddToCart: (Product) -> Unit,
onCategoryChange: (String) -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// ํค๋ ์ปดํฌ๋ํธ
ShoppingHeader(
cartItemCount = cartItems.sumOf { it.quantity }
)
Spacer(modifier = Modifier.height(16.dp))
// ์นดํ
๊ณ ๋ฆฌ ํํฐ ์ปดํฌ๋ํธ
CategoryFilter(
selectedCategory = selectedCategory,
onCategoryChange = onCategoryChange
)
Spacer(modifier = Modifier.height(16.dp))
// ์ํ ๋ชฉ๋ก ์ปดํฌ๋ํธ
ProductList(
products = products.filter {
selectedCategory == "All" || it.category == selectedCategory
},
onAddToCart = onAddToCart
)
}
}
// ํค๋ ์ปดํฌ๋ํธ
@Composable
fun ShoppingHeader(cartItemCount: Int) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "๐๏ธ ์ผํ๋ชฐ",
style = MaterialTheme.typography.headlineLarge
)
// ์ฅ๋ฐ๊ตฌ๋ ๋ฐฐ์ง - Material UI์ Badge์ ์ ์ฌ
Badge(
modifier = Modifier.testTag("cart-badge")
) {
Text("$cartItemCount")
}
}
}
// ์นดํ
๊ณ ๋ฆฌ ํํฐ ์ปดํฌ๋ํธ
@Composable
fun CategoryFilter(
selectedCategory: String,
onCategoryChange: (String) -> Unit
) {
val categories = listOf("All", "Electronics", "Fashion", "Books", "Food")
// ๊ฐ๋ก ์คํฌ๋กค ๋ฆฌ์คํธ - React์ horizontal scroll๊ณผ ์ ์ฌ
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(categories) { category ->
FilterChip(
selected = selectedCategory == category,
onClick = { onCategoryChange(category) },
label = { Text(category) }
)
}
}
}
// ์ํ ๋ชฉ๋ก ์ปดํฌ๋ํธ
@Composable
fun ProductList(
products: List<Product>,
onAddToCart: (Product) -> Unit
) {
// ์ธ๋ก ์คํฌ๋กค ๋ฆฌ์คํธ - React์ VirtualizedList์ ์ ์ฌ
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(products) { product ->
ProductCard(
product = product,
onAddToCart = { onAddToCart(product) }
)
}
}
}
// ์ํ ์นด๋ ์ปดํฌ๋ํธ
@Composable
fun ProductCard(
product: Product,
onAddToCart: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.testTag("product-card-${product.id}"), // ํ
์คํธ์ฉ ID
elevation = CardDefaults.cardElevation(4.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
// ์ํ๋ช
Text(
text = product.name,
style = MaterialTheme.typography.headlineSmall
)
// ์นดํ
๊ณ ๋ฆฌ
Text(
text = product.category,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
// ๊ฐ๊ฒฉ๊ณผ ํ์
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "${product.price}",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "โญ ${product.rating}",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "(${product.reviewCount})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// ์ํ ์ค๋ช
Text(
text = product.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
// ์ฅ๋ฐ๊ตฌ๋ ์ถ๊ฐ ๋ฒํผ
Button(
onClick = onAddToCart,
modifier = Modifier
.fillMaxWidth()
.testTag("add-to-cart-${product.id}")
) {
Text("์ฅ๋ฐ๊ตฌ๋์ ์ถ๊ฐ")
}
}
}
}
๐จ UI ํจํด ์์
/**
* ์์ฃผ ์ฌ์ฉ๋๋ ๊ณ ๊ธ UI ํจํด๋ค
*/
// 1. Pull to Refresh ํจํด
@Composable
fun PullToRefreshExample() {
var isRefreshing by remember { mutableStateOf(false) }
var items by remember { mutableStateOf(generateItems()) }
// React์ useCallback๊ณผ ์ ์ฌํ ํจ์ ๋ฉ๋ชจ์ด์ ์ด์
val onRefresh = remember<() -> Unit> {
{
isRefreshing = true
// ์ค์ ๋ก๋ ๋คํธ์ํฌ ํธ์ถ
CoroutineScope(Dispatchers.Main).launch {
delay(2000)
items = generateItems()
isRefreshing = false
}
}
}
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = onRefresh
) {
LazyColumn {
items(items) { item ->
ItemCard(item = item)
}
}
}
}
// 2. ๋ฌดํ ์คํฌ๋กค ํจํด
@Composable
fun InfiniteScrollExample() {
var items by remember { mutableStateOf(generateItems()) }
var isLoading by remember { mutableStateOf(false) }
var hasMore by remember { mutableStateOf(true) }
// ๋ ๋ง์ ๋ฐ์ดํฐ ๋ก๋ ํจ์
val loadMore = remember {
{
if (!isLoading && hasMore) {
isLoading = true
CoroutineScope(Dispatchers.Main).launch {
delay(1000)
val newItems = generateItems()
items = items + newItems
isLoading = false
hasMore = items.size < 100 // ์ต๋ 100๊ฐ๊น์ง
}
}
}
}
LazyColumn {
items(items) { item ->
ItemCard(item = item)
}
// ๋ก๋ฉ ์ธ๋์ผ์ดํฐ
if (isLoading) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
// ์คํฌ๋กค ๋ ๊ฐ์ง
if (hasMore && !isLoading) {
item {
LaunchedEffect(Unit) {
loadMore()
}
}
}
}
}
// 3. ๊ฒ์ ๊ธฐ๋ฅ ํจํด
@Composable
fun SearchExample() {
var searchQuery by remember { mutableStateOf("") }
var searchResults by remember { mutableStateOf<List<String>>(emptyList()) }
var isSearching by remember { mutableStateOf(false) }
// ๋๋ฐ์ด์ค ๊ฒ์
LaunchedEffect(searchQuery) {
if (searchQuery.isNotBlank()) {
isSearching = true
delay(300) // 300ms ๋๋ฐ์ด์ค
// ์ค์ ๋ก๋ API ๊ฒ์
val results = performSearch(searchQuery)
searchResults = results
isSearching = false
} else {
searchResults = emptyList()
isSearching = false
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// ๊ฒ์ ์
๋ ฅ ํ๋
TextField(
value = searchQuery,
onValueChange = { searchQuery = it },
placeholder = { Text("๊ฒ์์ด๋ฅผ ์
๋ ฅํ์ธ์") },
modifier = Modifier.fillMaxWidth(),
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = null)
},
trailingIcon = {
if (searchQuery.isNotBlank()) {
IconButton(
onClick = { searchQuery = "" }
) {
Icon(Icons.Default.Clear, contentDescription = null)
}
}
}
)
Spacer(modifier = Modifier.height(16.dp))
// ๊ฒ์ ๊ฒฐ๊ณผ
when {
isSearching -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
searchResults.isEmpty() && searchQuery.isNotBlank() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("๊ฒ์ ๊ฒฐ๊ณผ๊ฐ ์์ต๋๋ค")
}
}
searchResults.isNotEmpty() -> {
LazyColumn {
items(searchResults) { result ->
SearchResultItem(
text = result,
query = searchQuery
)
}
}
}
}
}
}
// ๊ฒ์ ๊ฒฐ๊ณผ ํญ๋ชฉ - ํ์ด๋ผ์ดํธ ๊ธฐ๋ฅ ํฌํจ
@Composable
fun SearchResultItem(
text: String,
query: String
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { /* ํด๋ฆญ ์ฒ๋ฆฌ */ },
elevation = CardDefaults.cardElevation(2.dp)
) {
Text(
text = buildAnnotatedString {
val startIndex = text.indexOf(query, ignoreCase = true)
if (startIndex != -1) {
append(text.substring(0, startIndex))
withStyle(
style = SpanStyle(
background = Color.Yellow,
fontWeight = FontWeight.Bold
)
) {
append(text.substring(startIndex, startIndex + query.length))
}
append(text.substring(startIndex + query.length))
} else {
append(text)
}
},
modifier = Modifier.padding(16.dp)
)
}
}
// 4. ํญ ๋ค๋น๊ฒ์ด์
ํจํด
@Composable
fun TabNavigationExample() {
var selectedTab by remember { mutableStateOf(0) }
val tabs = listOf("ํ", "๊ฒ์", "์๋ฆผ", "ํ๋กํ")
Column(
modifier = Modifier.fillMaxSize()
) {
// ํญ ๋ฐ
TabRow(
selectedTabIndex = selectedTab,
modifier = Modifier.fillMaxWidth()
) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) }
)
}
}
// ํญ ์ฝํ
์ธ
when (selectedTab) {
0 -> HomeTabContent()
1 -> SearchTabContent()
2 -> NotificationTabContent()
3 -> ProfileTabContent()
}
}
}
// ๊ฐ ํญ์ ์ฝํ
์ธ
@Composable
fun HomeTabContent() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "๐ ํ ํญ",
style = MaterialTheme.typography.headlineLarge
)
}
}
@Composable
fun SearchTabContent() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "๐ ๊ฒ์ ํญ",
style = MaterialTheme.typography.headlineLarge
)
}
}
@Composable
fun NotificationTabContent() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "๐ ์๋ฆผ ํญ",
style = MaterialTheme.typography.headlineLarge
)
}
}
@Composable
fun ProfileTabContent() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "๐ค ํ๋กํ ํญ",
style = MaterialTheme.typography.headlineLarge
)
}
}
// ์ ํธ๋ฆฌํฐ ํจ์๋ค
fun generateItems(): List<String> {
return (1..20).map { "Item $it" }
}
fun performSearch(query: String): List<String> {
// ์ค์ ๋ก๋ API ํธ์ถ
return listOf(
"๊ฒ์ ๊ฒฐ๊ณผ 1: $query",
"๊ฒ์ ๊ฒฐ๊ณผ 2: $query ๊ด๋ จ",
"๊ฒ์ ๊ฒฐ๊ณผ 3: $query ์ ๋ณด"
)
}
Jetpack Compose๋ modernํ Android UI ๊ฐ๋ฐ์ ์ํ ๊ฐ๋ ฅํ ๋๊ตฌ์
๋๋ค.
declarative programming์ ํตํด ์ง๊ด์ ์ด๊ณ ํจ์จ์ ์ธ UI ๊ฐ๋ฐ์ด ๊ฐ๋ฅํฉ๋๋ค. ๐