안드로이드 jetpack compose 공부 정리 13강 (데이터 영구 저장 1/2)
1. 데이터 저장을 위한 의존성(Dependencies) 추가
의존성(Dependencies)은 애플리케이션이 제대로 작동하기 위해 사용하는 외부 라이브러리 또는 모듈이다. 이러한 의존성들은 build.gradle.kts 파일(Kotlin DSL) 또는 build.gradle 파일(Groovy DSL)에 추가된다.
안드로이드 애플리케이션에서 데이터를 영구적으로 저장하기 위한 다양한 저장 옵션이 있고 각 옵션은 서로 다른 의존성이 필요할 수 있다.
주요 데이터 저장 옵션들:
- SQLite: 안드로이드에 내장된 관계형 데이터베이스로, 구조화된 데이터를 개인 데이터베이스에 저장한다.
- Room: SQLite 위에 추상화 계층을 제공하여 더 쉽고 안전한 데이터베이스 접근을 가능하게 하는 Jetpack 라이브러리다.
- Shared Preferences: 간단한 데이터 유형의 키-값 쌍을 저장하는 전통적인 방법이다.
- DataStore: Shared Preferences를 대체하는 더 강력하고 비동기적인 키-값 쌍 저장 솔루션이다.
프로젝트에 추가할 영구 데이터 저장 의존성 예시:
// build.gradle.kts (Module: app)
dependencies {
val nav_version = "2.7.5"
val room_version = "2.6.1"
// Room 관련 의존성
implementation("androidx.room:room-runtime:$room_version")
implementation("androidx.room:room-ktx:$room_version")
kapt("androidx.room:room-compiler:$room_version")
// DataStore 의존성 (선택사항)
implementation("androidx.datastore:datastore-preferences:1.0.0")
// Navigation 의존성
implementation("androidx.navigation:navigation-compose:$nav_version")
}
구현 팁과 고려사항:
올바른 저장 옵션을 선택하는 것은 애플리케이션의 요구사항에 따라 달라진다. 복잡하고 구조화된 데이터의 경우 Room이 가장 적합하며, 간단한 설정값이나 사용자 선호도 저장에는 DataStore나 Shared Preferences가 더 적절하다.
비동기 작업의 중요성을 이해해야 한다. Room과 DataStore 같은 라이브러리를 사용할 때는 UI 스레드를 차단하지 않도록 코루틴을 활용한 비동기 처리가 필수다.
또한 이러한 의존성들의 최신 버전을 항상 확인하여 호환성을 보장하고 최신 기능 및 보안 개선 사항에 접근할 수 있도록 해야한다.
2. Kotlin-KAPT 플러그인
KAPT(Kotlin Annotation Processing Tool)는 코틀린 컴파일러의 일부로, 코틀린 코드에서 어노테이션 처리를 가능하게 하는 도구다.
어노테이션은 코틀린 코드의 클래스, 메서드, 필드 등에 추가되어 추가 정보를 제공하는 메타데이터의 한 형태다. 이러한 어노테이션들은 프로그램이 저장, 컴파일 및 실행되는 방식에 직접적인 영향을 줄 수 있다.
KAPT의 중요성과 역할:
KAPT는 코틀린 프로젝트가 자바 라이브러리의 어노테이션 프로세서를 활용할 수 있도록 해주는 다리 역할을 한다. 이는 상용구 코드(boilerplate code) 생성, 컴파일 시간 유효성 검사, 개발 경험과 애플리케이션 성능을 향상시킬 수 있는 다양한 작업에 필수적이다.
KAPT 사용 목적:
컴파일 시간에 어노테이션을 처리하여 자동으로 코드를 생성하는 역할을 한다. 예를 들어, Room 라이브러리는 @Entity, @Dao, @Database 어노테이션을 분석하여 실제 데이터베이스 구현 코드를 자동으로 생성한다.
코드가 컴파일되기 전에 유효성을 검사하여 잠재적인 오류를 미리 발견할 수 있다. 의존성 주입을 위한 Dagger, 데이터베이스 접근을 위한 Room 등 어노테이션을 사용하여 기능을 수행하는 라이브러리와 함께 작동한다.
설정 방법:
코틀린 프로젝트에서 KAPT를 활성화하려면 build.gradle.kts 파일에 KAPT 플러그인을 적용해야 한다.
// build.gradle.kts (Module: app)
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt") // KAPT 플러그인 추가
}
KAPT의 장점들:
상호 운용성 측면에서 코틀린 코드가 자바 어노테이션 프로세서를 활용하여 코틀린과 자바 간의 생태계를 자연스럽게 연결할 수 있다. 효율성 면에서는 자바 스텁 없이 어노테이션을 직접 처리하여 빌드 프로세스를 더욱 효율적으로 만든다. 또한 어노테이션 처리에 의존하는 인기 있는 자바 라이브러리들과 잘 작동하여 기존 생태계를 그대로 활용할 수 있다.
최신 대안: KSP(Kotlin Symbol Processing)
현재는 Google에서 개발한 KSP(Kotlin Symbol Processing)가 KAPT의 더 빠르고 효율적인 대안으로 제공되고 있다. Room 2.4.0부터는 KSP를 지원하므로, 새로운 프로젝트에서는 KSP 사용을 고려해볼 수 있다.
// KSP 사용 시 (선택사항)
plugins {
id("com.google.devtools.ksp") version "1.9.20-1.0.14"
}
dependencies {
ksp("androidx.room:room-compiler:$room_version")
}
3. Jetpack Compose의 Scaffold
Jetpack Compose에서 Scaffold는 기본적인 머티리얼 디자인 레이아웃 구조를 구현하는 핵심 컴포저블 함수다.
이 컴포저블은 앱 바, 플로팅 액션 버튼(FAB), 드로어, 하단 탐색, 기본 콘텐츠 영역과 같은 가장 일반적인 최상위 구성 요소들을 위한 슬롯을 제공한다. Scaffold는 머티리얼 디자인 가이드라인을 준수하는 일관된 레이아웃 구조를 제공하도록 설계되었다.
Scaffold의 핵심 구성 요소들:
TopBar는 현재 화면과 관련된 정보 및 작업을 표시하는 상단 앱 바 다. 이는 기존 View 시스템의 Toolbar 또는 ActionBar와 동일한 역할을 한다.
BottomBar는 화면 하단에 표시되는 바로, 주로 하단 네비게이션이나 추가 액션들을 배치할 때 사용된다.
FloatingActionButton은 콘텐츠 위에 떠 있는 원형 버튼으로, 일반적으로 화면의 기본 작업을 나타낸다.
Drawer는 화면 가장자리에서 가로로 슬라이드되어 탐색 옵션을 표시하는 패널이다.
Content는 TopBar 아래와 BottomBar 위에 표시되는 기본 UI 콘텐츠다.
기본 Scaffold 구현 예시:
@Composable
fun WishListScreen() {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = "위시리스트",
style = MaterialTheme.typography.h6
)
},
backgroundColor = MaterialTheme.colors.primary,
contentColor = MaterialTheme.colors.onPrimary,
elevation = 4.dp
)
},
floatingActionButton = {
FloatingActionButton(
onClick = {
// 새 아이템 추가 액션
},
backgroundColor = MaterialTheme.colors.secondary
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "새 위시 아이템 추가"
)
}
},
floatingActionButtonPosition = FabPosition.End
) { innerPadding ->
// 메인 콘텐츠 영역
WishListContent(
modifier = Modifier.padding(innerPadding)
)
}
}
@Composable
fun WishListContent(modifier: Modifier = Modifier) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// 위시리스트 아이템들을 표시
}
}
Navigation과 함께 사용하는 고급 예시:
@Composable
fun MainScreen(navController: NavController) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("위시리스트 앱") },
navigationIcon = {
IconButton(
onClick = { navController.navigateUp() }
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "뒤로 가기"
)
}
},
actions = {
IconButton(
onClick = { /* 설정 화면으로 이동 */ }
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "설정"
)
}
}
)
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = "wish_list",
modifier = Modifier.padding(innerPadding)
) {
composable("wish_list") { WishListScreen() }
composable("add_wish") { AddWishScreen() }
}
}
}
사용자 정의와 권장 사항:
Scaffold의 각 부분을 디자인 요구 사항에 맞게 자유롭게 사용자 정의할 수 있다. 예를 들어, TopAppBar에는 탐색 아이콘, 제목, 작업 버튼들을 포함할 수 있고 색상과 elevation으로 스타일을 지정할 수 있다.
머티리얼 디자인 가이드라인을 따르는 레이아웃을 구축하려면 Scaffold를 기본 구조로 사용하되, 제공된 구조가 요구 사항에 맞지 않으면 언제든지 사용자 정의 레이아웃을 만들 수 있다.
Scaffold를 사용하면 앱이 여러 화면에서 일관된 구조와 디자인을 갖도록 하여 UI 개발 시간을 절약하고 코드베이스를 더 유지 관리하기 쉽게 만들 수 있다.
4. Modifier.heightIn 및 Elevation
Jetpack Compose에서 Modifier 시스템을 사용하면 구성 요소의 모양과 동작을 세밀하게 제어할 수 있다.
여러 Modifier 함수를 함께 연결하여 컴포저블에 복합적인 효과를 적용하는 것이 일반적인 패턴이다.
Modifier
.heightIn(min = 100.dp, max = 200.dp)
.elevation(3.dp)
각 구성 요소 상세 분석:
Modifier는 Jetpack Compose의 모든 Modifier의 기본 클래스로, 컴포저블 함수에 적용할 수정사항이나 장식을 캡슐화하는 불변(immutable) 표현이다. 이는 다양한 Modifier 함수들을 체이닝할 수 있는 시작점 역할을 한다.
heightIn 상세 설명:
이 Modifier 함수는 적용되는 컴포저블의 허용 가능한 높이 범위를 제약하는 데 사용된다.
min 매개변수는 컴포저블이 가질 수 있는 최소 높이를 정의한다. 예시에서 100.dp로 설정되어 있는데, 이는 100 밀도 독립 픽셀(density-independent pixels)을 의미한다. 밀도 독립 픽셀은 다양한 화면 밀도에서 UI 일관성을 허용하는 중요한 측정 단위이다.
max 매개변수는 컴포저블이 가질 수 있는 최대 높이를 제한한다. 200.dp로 설정되어 있으므로 컴포저블의 높이는 어떤 상황에서도 200 밀도 독립 픽셀을 초과하지 않는다.
이는 컴포저블이 레이아웃에서 부모가 전달한 제약 조건과 자신의 내용에 따라 높이가 최소 100.dp에서 최대 200.dp 범위 내에서 동적으로 조정됨을 의미한다.
elevation 상세 설명:
이 Modifier 함수는 컴포저블에 그림자 효과를 주어 화면의 다른 요소들 위에 떠 있는 것처럼 보이게 하는 시각적 효과를 제공한다.
매개변수 값 3.dp는 elevation의 높이를 지정한다. material 디자인에서 elevation은 그림자로 표면의 시각적 계층 구조를 표현하는 핵심 개념이다. 값이 높을수록 더 크고 눈에 띄는 그림자가 생겨, 해당 요소가 다른 표면들보다 "더 높은 층"에 있음을 시각적으로 나타낸다.
실제 사용 예시:
@Composable
fun WishItemCard(
wish: Wish,
onItemClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 180.dp)
.padding(horizontal = 16.dp, vertical = 8.dp)
.clickable { onItemClick() },
elevation = 4.dp,
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween
) {
Text(
text = wish.title,
style = MaterialTheme.typography.h6,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = wish.description,
style = MaterialTheme.typography.body2,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Text(
text = "₩${wish.price}",
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.primary
)
}
}
}
이 결합된 Modifier를 컴포저블에 적용하면 "이 구성 요소의 높이는 내용에 따라 최소 120dp에서 최대 180dp 사이에서 조정되어야 하고 4dp의 elevation을 가져 다른 요소들 위에 떠 있는 것처럼 보여야 합니다"라고 명시하는 것과 같다.
5. 16진수 및 색상 이해
디지털 애플리케이션에서 색상은 종종 16진수(hexadecimal) 색상 코드를 사용하여 표현된다. 이러한 코드는 빨강, 초록, 파랑(RGB) 색상 값을 단일 16진수 문자열로 결합하는 형식으로 색상을 정확하게 지정하는 방법이다.
16진수 색상 코드는 안드로이드 개발을 포함한 다양한 프로그래밍 환경에서 표준으로 사용된다.
16진수 색상 코드의 구조:
16진수 색상 코드는 일반적으로 # 기호로 시작하고 6개 또는 8개의 16진수 숫자가 뒤따른다. 각 숫자 쌍은 각각 빨강, 초록, 파랑 빛 채널의 강도를 나타낸다.
RGB 표현 방식:
6자리 16진수 코드에서 처음 두 자리는 빨강(Red) 채널, 중간 두 자리는 초록(Green) 채널, 마지막 두 자리는 파랑(Blue) 채널을 나타낸다. 각 색상 채널의 범위는 16진수에서 00에서 FF까지이고, 이는 10진수로 0에서 255까지에 해당한다. 여기서 00은 해당 색상이 전혀 없음을, FF는 해당 색상의 최대 강도를 의미한다.
투명도(알파 채널):
8자리 16진수 코드에는 색상의 투명도를 나타내는 알파 채널을 표현하는 추가 숫자 쌍이 맨 앞에 포함된다. 범위는 동일하게 00에서 FF까지이며, 여기서 00은 완전히 투명하고 FF는 완전히 불투명함을 나타낸다.
실용적인 색상 예시들:
// 기본 색상들
val pureRed = Color(0xFFFF0000) // 순수한 빨간색
val pureGreen = Color(0xFF00FF00) // 순수한 초록색
val pureBlue = Color(0xFF0000FF) // 순수한 파란색
val white = Color(0xFFFFFFFF) // 흰색
val black = Color(0xFF000000) // 검은색
// 반투명 색상들
val semiTransparentRed = Color(0x80FF0000) // 50% 투명도의 빨간색
val lightGray = Color(0xFFF0F0F0) // 연한 회색
// 실제 앱에서 자주 사용되는 색상들
val materialBlue = Color(0xFF2196F3) // 머티리얼 블루
val materialGreen = Color(0xFF4CAF50) // 머티리얼 그린
val materialOrange = Color(0xFFFF9800) // 머티리얼 오렌지
안드로이드에서의 색상 리소스 관리:
안드로이드 개발에서는 일관성 유지와 테마 관리를 위해 색상 리소스를 사용하는 것이 강력히 권장된다.
colors.xml 파일 설정:
<!-- res/values/colors.xml -->
<resources>
<!-- 기본 테마 색상들 -->
<color name="primary_color">#2196F3</color>
<color name="primary_variant">#1976D2</color>
<color name="secondary_color">#FF9800</color>
<color name="secondary_variant">#F57C00</color>
<!-- 배경 및 표면 색상들 -->
<color name="background_color">#FFFFFF</color>
<color name="surface_color">#FFFFFF</color>
<color name="error_color">#F44336</color>
<!-- 텍스트 색상들 -->
<color name="on_primary">#FFFFFF</color>
<color name="on_secondary">#000000</color>
<color name="on_background">#000000</color>
<color name="on_surface">#000000</color>
<color name="on_error">#FFFFFF</color>
<!-- 위시리스트 앱 전용 색상들 -->
<color name="wish_card_background">#F8F9FA</color>
<color name="price_text_color">#4CAF50</color>
<color name="priority_high">#F44336</color>
<color name="priority_medium">#FF9800</color>
<color name="priority_low">#4CAF50</color>
</resources>
Jetpack Compose에서 색상 리소스 사용:
@Composable
fun WishItemCard(wish: Wish) {
Card(
backgroundColor = colorResource(id = R.color.wish_card_background),
elevation = 4.dp
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = wish.title,
color = colorResource(id = R.color.on_surface)
)
Text(
text = "₩${wish.price}",
color = colorResource(id = R.color.price_text_color),
fontWeight = FontWeight.Bold
)
}
}
}
다크 테마 지원을 위한 색상 관리:
<!-- res/values-night/colors.xml -->
<resources>
<color name="background_color">#121212</color>
<color name="surface_color">#1E1E1E</color>
<color name="on_background">#FFFFFF</color>
<color name="on_surface">#FFFFFF</color>
<color name="wish_card_background">#2D2D2D</color>
</resources>
권장 사항과 접근성 고려사항:
일관성을 위해 앱 전체에서 colors.xml 리소스 파일에 공통 색상을 정의하고 사용해야 한다. 색상 리소스에는 그 용도를 명확하게 나타내는 설명적인 이름을 사용하는 것이 중요하다.
접근성 측면에서는 시각 장애가 있는 사용자를 포함하여 모든 사용자가 접근할 수 있도록 충분한 색상 대비를 제공하는 색상 조합을 선택해야 한다. WCAG 가이드라인에 따르면 일반 텍스트는 최소 4.5:1의 대비율을, 큰 텍스트는 최소 3:1의 대비율을 유지해야한다.
이러한 16진수 색상 코드의 이해와 안드로이드에서의 올바른 사용 방법을 숙지하면, 개발자는 UI의 색 구성표를 정확하게 제어하여 애플리케이션의 시각적 매력과 사용성을 크게 향상시킬 수 있다.
6. Jetpack Compose에서 탐색 아이콘 추가
Jetpack Compose에서 탐색 아이콘은 사용자 인터페이스의 핵심 요소로, 일반적으로 상단 앱 바에서 드로어를 열거나 이전 화면으로 돌아가는 것과 같은 탐색 이벤트를 트리거하는 대화형 요소로 사용된다.
이러한 아이콘들은 단순히 기능적인 역할만 하는 것이 아니라, 머티리얼 디자인 언어의 필수적인 부분으로서 사용자에게 가능한 작업에 대한 직관적인 시각적 단서를 제공한다.
NavigationIcon의 역할과 위치:
NavigationIcon은 일반적으로 TopAppBar 컴포저블의 가장 왼쪽에 배치되고 그 기능에 따라 적절한 아이콘으로 표시된다. 드로어를 열기 위한 햄버거 메뉴 아이콘이나 이전 화면으로 돌아가기 위한 뒤로 가기 화살표가 대표적인 예시라고 할 수 있다.
기본적인 탐색 아이콘 구현:
@Composable
fun WishListTopBar(
title: String,
canNavigateBack: Boolean,
navigateUp: () -> Unit = {},
modifier: Modifier = Modifier
) {
TopAppBar(
title = {
Text(
text = title,
style = MaterialTheme.typography.h6
)
},
modifier = modifier,
navigationIcon = {
if (canNavigateBack) {
IconButton(onClick = navigateUp) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "뒤로 가기"
)
}
}
},
backgroundColor = MaterialTheme.colors.primary,
contentColor = MaterialTheme.colors.onPrimary,
elevation = 4.dp
)
}
고급 탐색 아이콘 구현 - 여러 액션 포함:
@Composable
fun WishListTopBarWithActions(
title: String,
canNavigateBack: Boolean,
navigateUp: () -> Unit = {},
onSearchClick: () -> Unit = {},
onSettingsClick: () -> Unit = {},
modifier: Modifier = Modifier
) {
TopAppBar(
title = {
Text(
text = title,
style = MaterialTheme.typography.h6,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
navigationIcon = {
if (canNavigateBack) {
IconButton(onClick = navigateUp) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "뒤로 가기"
)
}
} else {
IconButton(onClick = { /* 드로어 열기 */ }) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = "메뉴 열기"
)
}
}
},
actions = {
IconButton(onClick = onSearchClick) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "검색"
)
}
IconButton(onClick = onSettingsClick) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "설정"
)
}
},
backgroundColor = MaterialTheme.colors.primary,
contentColor = MaterialTheme.colors.onPrimary,
elevation = 4.dp,
modifier = modifier
)
}
실제 Navigation 컴포넌트와 연동된 예시:
@Composable
fun WishListApp() {
val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState()
val currentScreen = WishListScreen.valueOf(
backStackEntry?.destination?.route ?: WishListScreen.WishList.name
)
Scaffold(
topBar = {
WishListTopBar(
title = currentScreen.title,
canNavigateBack = navController.previousBackStackEntry != null,
navigateUp = { navController.navigateUp() }
)
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = WishListScreen.WishList.name,
modifier = Modifier.padding(innerPadding)
) {
composable(route = WishListScreen.WishList.name) {
WishListScreen(
onWishClick = { wishId ->
navController.navigate("${WishListScreen.WishDetail.name}/$wishId")
},
onAddWishClick = {
navController.navigate(WishListScreen.AddWish.name)
}
)
}
composable(route = WishListScreen.AddWish.name) {
AddWishScreen(
onWishSaved = {
navController.popBackStack()
}
)
}
composable(
route = "${WishListScreen.WishDetail.name}/{wishId}",
arguments = listOf(navArgument("wishId") { type = NavType.IntType })
) { backStackEntry ->
val wishId = backStackEntry.arguments?.getInt("wishId") ?: 0
WishDetailScreen(
wishId = wishId,
onEditClick = {
navController.navigate("${WishListScreen.EditWish.name}/$wishId")
}
)
}
}
}
}
enum class WishListScreen(val title: String) {
WishList("위시리스트"),
AddWish("위시 추가"),
WishDetail("위시 상세"),
EditWish("위시 수정")
}
드로어와 함께 사용하는 탐색 아이콘:
@Composable
fun WishListAppWithDrawer() {
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val navController = rememberNavController()
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
DrawerContent(
onItemClick = { destination ->
scope.launch {
drawerState.close()
}
navController.navigate(destination)
}
)
}
}
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("위시리스트") },
navigationIcon = {
IconButton(
onClick = {
scope.launch {
drawerState.open()
}
}
) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = "메뉴 열기"
)
}
}
)
}
) { innerPadding ->
// 메인 콘텐츠
}
}
}
@Composable
fun DrawerContent(
onItemClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp)
) {
Text(
text = "위시리스트 앱",
style = MaterialTheme.typography.h5,
modifier = Modifier.padding(bottom = 16.dp)
)
DrawerItem(
icon = Icons.Default.List,
text = "내 위시리스트",
onClick = { onItemClick("wish_list") }
)
DrawerItem(
icon = Icons.Default.Favorite,
text = "즐겨찾기",
onClick = { onItemClick("favorites") }
)
DrawerItem(
icon = Icons.Default.Settings,
text = "설정",
onClick = { onItemClick("settings") }
)
}
}
@Composable
fun DrawerItem(
icon: ImageVector,
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.padding(end = 16.dp)
)
Text(
text = text,
style = MaterialTheme.typography.body1
)
}
}
코드 구성 요소 상세 분석:
IconButton은 아이콘을 포함하는 클릭 가능한 영역을 제공하는 컴포저블이다. 머티리얼 디자인의 터치 타겟 크기 가이드라인(최소 48dp x 48dp)을 자동으로 준수하며, 리플 효과와 같은 시각적 피드백을 제공한다.
Icon은 실제 아이콘을 렌더링하는 컴포저블이다. 벡터 그래픽을 사용하므로 다양한 화면 밀도에서 선명하게 표시된다.
Icons.AutoMirrored.Filled.ArrowBack은 RTL(Right-to-Left) 레이아웃에서 자동으로 미러링되는 뒤로 가기 아이콘이다. 이는 아랍어나 히브리어와 같은 RTL 언어를 지원하는 앱에서 중요한 기능이다.
접근성 고려사항:
모든 탐색 아이콘에는 적절한 contentDescription을 제공해야 한다. 이는 스크린 리더를 사용하는 시각 장애가 있는 사용자들이 해당 버튼의 기능을 이해할 수 있도록 도와준다.
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "이전 화면으로 돌아가기" // 구체적이고 명확한 설명
)
권장 사항과 베스트 프랙티스:
앱의 탐색 패턴과 일치하는 아이콘을 사용해야 한다. 예를 들어서 계층적 탐색에서는 뒤로 가기 화살표를, 드로어 기반 탐색에서는 햄버거 메뉴를 사용하는 것이 일반적이다.
탐색 아이콘의 동작이 사용자에게 예측 가능하고 일관되도록 해야 한다. 같은 아이콘은 앱 전체에서 같은 기능을 수행해야 한다.
디자인 표준과 사용자 기대치와의 일관성을 유지하기 위해 가능한 한 머티리얼 디자인 아이콘 세트를 사용하는 것이 좋다.
7. Jetpack Compose의 FloatingActionButton
FloatingActionButton(FAB)은 머티리얼 디자인에서 가장 특징적인 UI 요소 중 하나로, 사용자 인터페이스 위에 떠 있는 독특한 원형 버튼이다.
이 버튼은 화면에서 가장 중요하고 자주 사용되는 주요 작업을 나타내기 위해 설계되었고 일반적으로 화면의 오른쪽 하단 모서리에 고정되어 사용자의 주의를 끌고 기본 작업을 제안한다.
FAB의 핵심 설계 원칙:
FAB는 화면에서 가장 일반적이고 중요한 작업만을 나타내야 한다. 새 이메일 작성, 새 연락처 추가, 새 게시물 작성과 같은 창조적이고 긍정적인 작업에 사용되는 것이 이상적이다. 화면당 하나의 FAB만 사용하는 것이 머티리얼 디자인의 원칙이다.
위시리스트 앱에서의 기본 FAB 구현:
@Composable
fun WishListScreen(
wishes: List<Wish>,
onAddWishClick: () -> Unit,
onWishClick: (Wish) -> Unit,
modifier: Modifier = Modifier
) {
Scaffold(
topBar = {
WishListTopBar(title = "내 위시리스트")
},
floatingActionButton = {
FloatingActionButton(
onClick = onAddWishClick,
backgroundColor = MaterialTheme.colors.secondary,
contentColor = MaterialTheme.colors.onSecondary,
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 6.dp,
pressedElevation = 12.dp,
hoveredElevation = 8.dp
)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "새로운 위시 추가"
)
}
},
floatingActionButtonPosition = FabPosition.End
) { innerPadding ->
WishListContent(
wishes = wishes,
onWishClick = onWishClick,
modifier = Modifier.padding(innerPadding)
)
}
}
고급 FAB 구현 - 확장 가능한 FAB:
@Composable
fun ExtendedWishListFAB(
onAddWishClick: () -> Unit,
modifier: Modifier = Modifier
) {
ExtendedFloatingActionButton(
onClick = onAddWishClick,
modifier = modifier,
backgroundColor = MaterialTheme.colors.secondary,
contentColor = MaterialTheme.colors.onSecondary,
elevation = FloatingActionButtonDefaults.elevation(6.dp),
icon = {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null
)
},
text = {
Text(
text = "위시 추가",
style = MaterialTheme.typography.button
)
}
)
}
애니메이션이 포함된 동적 FAB:
@Composable
fun AnimatedWishListFAB(
isVisible: Boolean,
onAddWishClick: () -> Unit,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(
initialOffsetY = { it },
animationSpec = tween(300)
) + fadeIn(animationSpec = tween(300)),
exit = slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(300)
) + fadeOut(animationSpec = tween(300)),
modifier = modifier
) {
FloatingActionButton(
onClick = onAddWishClick,
backgroundColor = MaterialTheme.colors.secondary,
contentColor = MaterialTheme.colors.onSecondary
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "새로운 위시 추가"
)
}
}
}
@Composable
fun WishListWithAnimatedFAB(
wishes: List<Wish>,
onAddWishClick: () -> Unit,
onWishClick: (Wish) -> Unit
) {
val listState = rememberLazyListState()
val isScrollingUp by remember {
derivedStateOf {
listState.firstVisibleItemScrollOffset == 0
}
}
Scaffold(
topBar = { WishListTopBar(title = "내 위시리스트") },
floatingActionButton = {
AnimatedWishListFAB(
isVisible = isScrollingUp,
onAddWishClick = onAddWishClick
)
}
) { innerPadding ->
LazyColumn(
state = listState,
modifier = Modifier.padding(innerPadding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(wishes) { wish ->
WishItemCard(
wish = wish,
onItemClick = { onWishClick(wish) }
)
}
}
}
}
다중 액션 FAB (Mini FAB들과 함께):
@Composable
fun MultiActionFAB(
onAddWishClick: () -> Unit,
onAddCategoryClick: () -> Unit,
onImportClick: () -> Unit,
modifier: Modifier = Modifier
) {
var isExpanded by remember { mutableStateOf(false) }
Column(
modifier = modifier,
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
AnimatedVisibility(
visible = isExpanded,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically()
) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
MiniFAB(
onClick = {
onImportClick()
isExpanded = false
},
icon = Icons.Default.Upload,
text = "가져오기"
)
MiniFAB(
onClick = {
onAddCategoryClick()
isExpanded = false
},
icon = Icons.Default.Category,
text = "카테고리 추가"
)
MiniFAB(
onClick = {
onAddWishClick()
isExpanded = false
},
icon = Icons.Default.Add,
text = "위시 추가"
)
}
}
FloatingActionButton(
onClick = { isExpanded = !isExpanded },
backgroundColor = MaterialTheme.colors.secondary,
contentColor = MaterialTheme.colors.onSecondary
) {
Icon(
imageVector = if (isExpanded) Icons.Default.Close else Icons.Default.Add,
contentDescription = if (isExpanded) "닫기" else "메뉴 열기"
)
}
}
}
@Composable
fun MiniFAB(
onClick: () -> Unit,
icon: ImageVector,
text: String,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colors.surface,
elevation = 2.dp
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.caption
)
}
FloatingActionButton(
onClick = onClick,
modifier = Modifier.size(40.dp),
backgroundColor = MaterialTheme.colors.surface,
contentColor = MaterialTheme.colors.onSurface,
elevation = FloatingActionButtonDefaults.elevation(4.dp)
) {
Icon(
imageVector = icon,
contentDescription = text,
modifier = Modifier.size(18.dp)
)
}
}
}
FAB의 매개변수 상세 분석:
onClick 매개변수는 FAB를 클릭했을 때 실행될 람다 함수를 정의한다. 이는 FAB의 핵심 기능으로, 반드시 의미 있는 작업을 수행해야 한다.
backgroundColor는 FAB의 배경색을 설정한다. 일반적으로 앱의 보조 색상(MaterialTheme.colors.secondary)을 사용하여 주의를 끌지만, 브랜드 색상이나 특별한 강조 색상을 사용할 수도 있다.
contentColor는 FAB 내부 콘텐츠(보통 아이콘)의 색상을 결정한다. 배경색과의 충분한 대비를 위해 contentColorFor() 함수를 사용하거나 직접 대비되는 색상을 지정할 수 있다.
elevation은 FAB의 그림자 효과를 제어한다. FloatingActionButtonDefaults.elevation()을 사용하여 기본값, 눌렸을 때, 호버 상태의 서로 다른 elevation 값을 설정할 수 있다.
사용자 경험 고려사항과 권장 사항:
FAB는 긍정적이고 창조적인 작업에만 사용해야 한다. 삭제나 취소와 같은 파괴적인 작업은 FAB에 적합하지 않다.
화면에 여러 개의 FAB를 배치하는 것은 피해야 한다. 여러 작업이 필요한 경우 확장 가능한 FAB나 메뉴 형태의 구현을 고려해야 한다.
FAB의 아이콘은 그 자체로 작업을 명확하게 나타내야 하고 필요한 경우 처음 사용할 때 툴팁이나 안내를 제공하는 것이 좋다.
접근성을 위해 반드시 적절한 contentDescription을 제공해야 하고 터치 타겟 크기가 충분한지(최소 48dp) 확인해야 한다.
이러한 원칙들을 따라 FloatingActionButton을 구현하면 사용자 경험을 크게 향상시키고 앱의 주요 기능에 대한 쉬운 접근을 제공할 수 있다.
8. Jetpack Compose의 카드 레이아웃
머티리얼 디자인에서 카드는 관련된 정보와 작업들을 하나의 컨테이너 안에 그룹화하여 표시하는 핵심적인 UI 컴포넌트다. 카드는 단일 주제나 객체에 대한 정보를 간결하게 제시하며, 더 자세한 정보로의 진입점 역할을 수행한다.
카드의 특징적인 디자인 요소로는 약간의 elevation(그림자)과 둥근 모서리가 있어, 배경 및 다른 콘텐츠와 시각적으로 분리되어 있으면서도 상호작용 가능한 요소임을 직관적으로 알려준다.
위시리스트 앱에서의 기본 카드 구현:
@Composable
fun WishItemCard(
wish: Wish,
onItemClick: () -> Unit,
onFavoriteClick: () -> Unit = {},
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 120.dp)
.padding(horizontal = 16.dp, vertical = 8.dp)
.clickable { onItemClick() },
elevation = 4.dp,
shape = RoundedCornerShape(12.dp),
backgroundColor = MaterialTheme.colors.surface
) {
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = wish.title,
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
if (wish.description.isNotEmpty()) {
Text(
text = wish.description,
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "₩${NumberFormat.getNumberInstance().format(wish.price)}",
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.primary
)
if (wish.priority != WishPriority.NONE) {
PriorityChip(priority = wish.priority)
}
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
IconButton(
onClick = onFavoriteClick,
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = if (wish.isFavorite) {
Icons.Filled.Favorite
} else {
Icons.Outlined.FavoriteBorder
},
contentDescription = if (wish.isFavorite) {
"즐겨찾기 해제"
} else {
"즐겨찾기 추가"
},
tint = if (wish.isFavorite) {
Color.Red
} else {
MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
}
)
}
if (wish.imageUrl.isNotEmpty()) {
AsyncImage(
model = wish.imageUrl,
contentDescription = "${wish.title} 이미지",
modifier = Modifier
.size(60.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop,
placeholder = painterResource(id = R.drawable.placeholder_image),
error = painterResource(id = R.drawable.error_image)
)
}
}
}
}
}
@Composable
fun PriorityChip(
priority: WishPriority,
modifier: Modifier = Modifier
) {
val (color, text) = when (priority) {
WishPriority.HIGH -> Color(0xFFE57373) to "높음"
WishPriority.MEDIUM -> Color(0xFFFFB74D) to "보통"
WishPriority.LOW -> Color(0xFF81C784) to "낮음"
WishPriority.NONE -> Color.Transparent to ""
}
if (priority != WishPriority.NONE) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(12.dp),
color = color.copy(alpha = 0.2f)
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.caption,
color = color,
fontWeight = FontWeight.Medium
)
}
}
}
enum class WishPriority {
NONE, LOW, MEDIUM, HIGH
}
고급 카드 구현 - 스와이프 액션 포함:
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun SwipeableWishCard(
wish: Wish,
onItemClick: () -> Unit,
onEditClick: () -> Unit,
onDeleteClick: () -> Unit,
onFavoriteClick: () -> Unit,
modifier: Modifier = Modifier
) {
val dismissState = rememberDismissState(
confirmStateChange = { dismissValue ->
when (dismissValue) {
DismissValue.DismissedToEnd -> {
onEditClick()
false // 스와이프 후 원래 위치로 돌아감
}
DismissValue.DismissedToStart -> {
onDeleteClick()
true // 카드가 완전히 사라짐
}
else -> false
}
}
)
SwipeToDismiss(
state = dismissState,
modifier = modifier,
directions = setOf(DismissDirection.StartToEnd, DismissDirection.EndToStart),
background = {
SwipeBackground(dismissState = dismissState)
},
dismissContent = {
WishItemCard(
wish = wish,
onItemClick = onItemClick,
onFavoriteClick = onFavoriteClick
)
}
)
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun SwipeBackground(
dismissState: DismissState,
modifier: Modifier = Modifier
) {
val direction = dismissState.dismissDirection ?: return
val color by animateColorAsState(
targetValue = when (dismissState.targetValue) {
DismissValue.Default -> MaterialTheme.colors.surface
DismissValue.DismissedToEnd -> Color(0xFF4CAF50) // 편집 - 초록색
DismissValue.DismissedToStart -> Color(0xFFE57373) // 삭제 - 빨간색
}
)
val alignment = when (direction) {
DismissDirection.StartToEnd -> Alignment.CenterStart
DismissDirection.EndToStart -> Alignment.CenterEnd
}
val icon = when (direction) {
DismissDirection.StartToEnd -> Icons.Default.Edit
DismissDirection.EndToStart -> Icons.Default.Delete
}
Box(
modifier = modifier
.fillMaxSize()
.background(color)
.padding(horizontal = 20.dp),
contentAlignment = alignment
) {
Icon(
imageVector = icon,
contentDescription = when (direction) {
DismissDirection.StartToEnd -> "편집"
DismissDirection.EndToStart -> "삭제"
},
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
그리드 레이아웃에서의 카드 구현:
@Composable
fun WishGridCard(
wish: Wish,
onItemClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.aspectRatio(0.8f)
.clickable { onItemClick() },
elevation = 6.dp,
shape = RoundedCornerShape(16.dp)
) {
Column {
// 이미지 영역
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
if (wish.imageUrl.isNotEmpty()) {
AsyncImage(
model = wish.imageUrl,
contentDescription = "${wish.title} 이미지",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
placeholder = painterResource(id = R.drawable.placeholder_image),
error = painterResource(id = R.drawable.error_image)
)
} else {
// 기본 이미지 또는 아이콘
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.primary.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Image,
contentDescription = "이미지 없음",
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colors.primary.copy(alpha = 0.6f)
)
}
}
// 우선순위 배지
if (wish.priority != WishPriority.NONE) {
PriorityChip(
priority = wish.priority,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
)
}
// 즐겨찾기 아이콘
IconButton(
onClick = { /* 즐겨찾기 토글 */ },
modifier = Modifier
.align(Alignment.TopStart)
.padding(4.dp)
) {
Icon(
imageVector = if (wish.isFavorite) {
Icons.Filled.Favorite
} else {
Icons.Outlined.FavoriteBorder
},
contentDescription = if (wish.isFavorite) {
"즐겨찾기 해제"
} else {
"즐겨찾기 추가"
},
tint = if (wish.isFavorite) Color.Red else Color.White,
modifier = Modifier.size(20.dp)
)
}
}
// 정보 영역
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = wish.title,
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colors.onSurface
)
Text(
text = "₩${NumberFormat.getNumberInstance().format(wish.price)}",
style = MaterialTheme.typography.h6,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.primary
)
if (wish.description.isNotEmpty()) {
Text(
text = wish.description,
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
카드 상태 관리와 애니메이션:
@Composable
fun AnimatedWishCard(
wish: Wish,
onItemClick: () -> Unit,
modifier: Modifier = Modifier
) {
var isPressed by remember { mutableStateOf(false) }
val elevation by animateDpAsState(
targetValue = if (isPressed) 12.dp else 4.dp,
animationSpec = tween(150)
)
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.98f else 1f,
animationSpec = tween(150)
)
Card(
modifier = modifier
.fillMaxWidth()
.scale(scale)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed = true
tryAwaitRelease()
isPressed = false
},
onTap = { onItemClick() }
)
},
elevation = elevation,
shape = RoundedCornerShape(12.dp)
) {
// 카드 내용...
WishCardContent(wish = wish)
}
}
@Composable
fun WishCardContent(
wish: Wish,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.padding(16.dp)
) {
// 카드 헤더
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = wish.title,
style = MaterialTheme.typography.h6,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = "생성일: ${wish.dateCreated.format()}",
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
WishStatusChip(status = wish.status)
}
Spacer(modifier = Modifier.height(12.dp))
// 카드 바디
if (wish.description.isNotEmpty()) {
Text(
text = wish.description,
style = MaterialTheme.typography.body2,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.8f)
)
Spacer(modifier = Modifier.height(12.dp))
}
// 카드 푸터
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "₩${NumberFormat.getNumberInstance().format(wish.price)}",
style = MaterialTheme.typography.h6,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.primary
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (wish.webUrl.isNotEmpty()) {
IconButton(
onClick = { /* 웹사이트 열기 */ },
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Link,
contentDescription = "웹사이트 보기",
modifier = Modifier.size(18.dp)
)
}
}
IconButton(
onClick = { /* 공유하기 */ },
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "공유하기",
modifier = Modifier.size(18.dp)
)
}
}
}
}
}
@Composable
fun WishStatusChip(
status: WishStatus,
modifier: Modifier = Modifier
) {
val (backgroundColor, textColor, text) = when (status) {
WishStatus.WISHLIST -> Triple(
MaterialTheme.colors.primary.copy(alpha = 0.1f),
MaterialTheme.colors.primary,
"위시리스트"
)
WishStatus.PURCHASED -> Triple(
Color(0xFF4CAF50).copy(alpha = 0.1f),
Color(0xFF4CAF50),
"구매완료"
)
WishStatus.ARCHIVED -> Triple(
MaterialTheme.colors.onSurface.copy(alpha = 0.1f),
MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
"보관함"
)
}
Surface(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
color = backgroundColor
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.caption,
color = textColor,
fontWeight = FontWeight.Medium
)
}
}
enum class WishStatus {
WISHLIST, PURCHASED, ARCHIVED
}
리스트에서의 카드 구현:
@Composable
fun WishListContent(
wishes: List<Wish>,
onWishClick: (Wish) -> Unit,
onWishDelete: (Wish) -> Unit,
onWishEdit: (Wish) -> Unit,
onFavoriteToggle: (Wish) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 8.dp,
bottom = 88.dp // FAB를 위한 여유 공간
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = wishes,
key = { wish -> wish.id }
) { wish ->
SwipeableWishCard(
wish = wish,
onItemClick = { onWishClick(wish) },
onEditClick = { onWishEdit(wish) },
onDeleteClick = { onWishDelete(wish) },
onFavoriteClick = { onFavoriteToggle(wish) },
modifier = Modifier.animateItemPlacement(
animationSpec = tween(300)
)
)
}
}
}
카드 레이아웃의 핵심 구성 요소 분석:
Card 컴포저블은 머티리얼 디자인의 카드 컨테이너를 제공한다. elevation, shape, backgroundColor 등의 매개변수를 통해 시각적 스타일을 제어할 수 있다.
modifier.clickable은 카드를 터치 가능하게 만들어 사용자 상호작용을 가능하게 한다. 리플 효과와 함께 시각적 피드백을 제공한다.
RoundedCornerShape는 카드의 모서리를 둥글게 만들어 현대적이고 부드러운 느낌을 제공한다.
레이아웃 권장 사항과 베스트 프랙티스:
카드 내용은 간결하고 스캔 가능하게 유지해야 한다. 너무 많은 정보를 한 카드에 담으면 사용자가 혼란스러워할 수 있다.
일관된 카드 크기와 간격을 유지하여 시각적 리듬감을 만들어야 한다. 특히 리스트나 그리드에서 카드들 간의 일관성은 매우 중요하다.
카드는 더 자세한 정보로의 진입점 역할을 해야 하므로, 카드를 탭했을 때 관련된 상세 정보나 편집 화면으로 자연스럽게 연결되도록 설계해야 한다.
접근성을 위해 카드의 각 상호작용 요소에 적절한 contentDescription을 제공하고, 터치 타겟 크기가 충분한지 확인해야 한다.
이러한 원칙들을 따라 카드 레이아웃을 구현하면 사용자가 쉽게 정보를 소비하고 상호작용할 수 있는 직관적이고 매력적인 인터페이스를 만들 수 있다.
9. Jetpack Compose의 키보드 옵션
Jetpack Compose에서 KeyboardOptions는 TextField, OutlinedTextField와 같은 텍스트 입력 구성 요소의 소프트웨어 키보드 동작을 세밀하게 제어하는 데 사용되는 중요한 클래스다.
이러한 옵션들을 적절히 활용하면 사용자의 입력 경험을 크게 향상시킬 수 있고 특정 유형의 데이터 입력에 최적화된 키보드 레이아웃을 제공할 수 있다.
키보드 옵션의 핵심 매개변수들:
keyboardType은 표시할 키보드의 유형을 결정한다. 예를 들어 숫자 입력에는 KeyboardType.Number, 이메일 입력에는 KeyboardType.Email, 일반 텍스트에는 KeyboardType.Text를 사용한다.
imeAction은 키보드의 'Enter' 키(액션 키)가 어떤 모양과 기능을 가질지 결정한다. ImeAction.Done, ImeAction.Next, ImeAction.Search 등이 있다.
capitalization은 텍스트의 대문자 처리 방식을 설정한다. KeyboardCapitalization.None, KeyboardCapitalization.Words, KeyboardCapitalization.Sentences 등이 있다.
autoCorrect는 자동 맞춤법 검사 기능의 활성화 여부를 결정한다.
위시리스트 앱에서의 실용적인 키보드 옵션 구현:
@Composable
fun AddWishScreen(
onWishSaved: () -> Unit,
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier
) {
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var price by remember { mutableStateOf("") }
var webUrl by remember { mutableStateOf("") }
val focusManager = LocalFocusManager.current
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 제목 입력 필드
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("위시 제목") },
placeholder = { Text("원하는 물건의 이름을 입력하세요") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
capitalization = KeyboardCapitalization.Words,
autoCorrect = true
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
singleLine = true,
maxLines = 1
)
// 설명 입력 필드
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("설명 (선택사항)") },
placeholder = { Text("물건에 대한 자세한 설명을 입력하세요") },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
capitalization = KeyboardCapitalization.Sentences,
autoCorrect = true
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
maxLines = 5
)
// 가격 입력 필드
OutlinedTextField(
value = price,
onValueChange = { newValue ->
// 숫자와 쉼표만 허용
val filteredValue = newValue.filter { it.isDigit() || it == ',' }
price = filteredValue
},
label = { Text("가격") },
placeholder = { Text("예: 50,000") },
leadingIcon = {
Text(
text = "₩",
style = MaterialTheme.typography.body1,
modifier = Modifier.padding(start = 12.dp)
)
},
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
singleLine = true,
visualTransformation = PriceVisualTransformation()
)
// 웹사이트 URL 입력 필드
OutlinedTextField(
value = webUrl,
onValueChange = { webUrl = it },
label = { Text("웹사이트 (선택사항)") },
placeholder = { Text("https://example.com") },
leadingIcon = {
Icon(
imageVector = Icons.Default.Link,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
},
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done,
autoCorrect = false
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
// 저장 로직 실행
saveWish(title, description, price, webUrl, onWishSaved)
}
),
singleLine = true
)
Spacer(modifier = Modifier.height(24.dp))
// 저장 버튼
Button(
onClick = {
focusManager.clearFocus()
saveWish(title, description, price, webUrl, onWishSaved)
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = title.isNotBlank() && price.isNotBlank()
) {
Text(
text = "위시리스트에 추가",
style = MaterialTheme.typography.button
)
}
}
}
// 가격 표시를 위한 커스텀 Visual Transformation
class PriceVisualTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val originalText = text.text
val formattedText = formatPrice(originalText)
val offsetMapping = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
return formattedText.length
}
override fun transformedToOriginal(offset: Int): Int {
return originalText.length
}
}
return TransformedText(
AnnotatedString(formattedText),
offsetMapping
)
}
private fun formatPrice(price: String): String {
if (price.isEmpty()) return ""
return try {
val number = price.replace(",", "").toLongOrNull()
if (number != null) {
NumberFormat.getNumberInstance().format(number)
} else {
price
}
} catch (e: Exception) {
price
}
}
}
검색 기능을 위한 특별한 키보드 옵션:
@Composable
fun WishSearchBar(
searchQuery: String,
onSearchQueryChange: (String) -> Unit,
onSearchTriggered: () -> Unit,
modifier: Modifier = Modifier
) {
val focusManager = LocalFocusManager.current
OutlinedTextField(
value = searchQuery,
onValueChange = onSearchQueryChange,
placeholder = {
Text(
text = "위시리스트 검색...",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "검색",
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
},
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(
onClick = {
onSearchQueryChange("")
focusManager.clearFocus()
}
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = "검색어 지우기"
)
}
}
},
modifier = modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Search,
autoCorrect = false
),
keyboardActions = KeyboardActions(
onSearch = {
focusManager.clearFocus()
onSearchTriggered()
}
),
singleLine = true,
shape = RoundedCornerShape(24.dp),
colors = TextFieldDefaults.outlinedTextFieldColors(
backgroundColor = MaterialTheme.colors.surface,
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
)
)
}
다양한 입력 유형별 키보드 옵션 예시:
@Composable
fun ComprehensiveInputFields() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 이메일 입력
OutlinedTextField(
value = "",
onValueChange = { },
label = { Text("이메일") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
autoCorrect = false
)
)
// 전화번호 입력
OutlinedTextField(
value = "",
onValueChange = { },
label = { Text("전화번호") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Phone,
imeAction = ImeAction.Next
)
)
// 비밀번호 입력
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("비밀번호") },
visualTransformation = if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) {
Icons.Default.VisibilityOff
} else {
Icons.Default.Visibility
},
contentDescription = if (passwordVisible) {
"비밀번호 숨기기"
} else {
"비밀번호 보이기"
}
)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
autoCorrect = false
)
)
// 소수점 숫자 입력
OutlinedTextField(
value = "",
onValueChange = { },
label = { Text("평점 (0.0 - 5.0)") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Done
)
)
// 여러 줄 텍스트 입력
OutlinedTextField(
value = "",
onValueChange = { },
label = { Text("상세 리뷰") },
modifier = Modifier.heightIn(min = 120.dp),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Default,
capitalization = KeyboardCapitalization.Sentences,
autoCorrect = true
),
maxLines = 5
)
}
}
키보드 액션 처리의 고급 패턴:
@Composable
fun FormWithKeyboardFlow(
onFormSubmit: (FormData) -> Unit
) {
var formData by remember { mutableStateOf(FormData()) }
val focusManager = LocalFocusManager.current
// 폼 검증 상태
val isFormValid by remember {
derivedStateOf {
formData.title.isNotBlank() &&
formData.price.isNotBlank() &&
formData.email.contains("@")
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// 각 필드는 다음 필드로 포커스 이동
FormTextField(
value = formData.title,
onValueChange = { formData = formData.copy(title = it) },
label = "제목",
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
capitalization = KeyboardCapitalization.Words
),
onImeAction = { focusManager.moveFocus(FocusDirection.Down) }
)
FormTextField(
value = formData.description,
onValueChange = { formData = formData.copy(description = it) },
label = "설명",
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
capitalization = KeyboardCapitalization.Sentences
),
onImeAction = { focusManager.moveFocus(FocusDirection.Down) }
)
FormTextField(
value = formData.price,
onValueChange = { formData = formData.copy(price = it) },
label = "가격",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next
),
onImeAction = { focusManager.moveFocus(FocusDirection.Down) }
)
// 마지막 필드는 완료 또는 제출
FormTextField(
value = formData.email,
onValueChange = { formData = formData.copy(email = it) },
label = "이메일",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = if (isFormValid) ImeAction.Done else ImeAction.None,
autoCorrect = false
),
onImeAction = {
if (isFormValid) {
focusManager.clearFocus()
onFormSubmit(formData)
}
}
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
focusManager.clearFocus()
onFormSubmit(formData)
},
modifier = Modifier.fillMaxWidth(),
enabled = isFormValid
) {
Text("제출")
}
}
}
@Composable
fun FormTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
keyboardOptions: KeyboardOptions,
onImeAction: () -> Unit,
modifier: Modifier = Modifier
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
modifier = modifier.fillMaxWidth(),
keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions(
onNext = onImeAction,
onDone = onImeAction,
onSearch = onImeAction
),
singleLine = true
)
}
data class FormData(
val title: String = "",
val description: String = "",
val price: String = "",
val email: String = ""
)
사용자 경험 최적화를 위한 권장 사항:
사용자가 입력할 데이터의 특성에 맞는 적절한 keyboardType을 선택하는 것이 매우 중요하다. 이는 입력 효율성을 크게 향상시킬 수 있다.
폼의 흐름을 고려하여 적절한 imeAction을 설정해야 한다. 일반적으로 중간 필드에는 ImeAction.Next를, 마지막 필드에는 ImeAction.Done을 사용한다.
KeyboardActions를 활용하여 사용자가 키보드의 액션 버튼을 눌렀을 때 자연스러운 동작이 이루어지도록 해야 한다. 예를 들어, 다음 필드로 포커스 이동이나 폼 제출 등의 작업을 수행할 수 있다.
자동 맞춤법 검사나 자동 대문자 변환 기능은 입력 필드의 목적에 따라 신중하게 결정해야 한다. 예를 들어, 사용자명이나 비밀번호 입력에서는 이러한 기능을 비활성화하는 것이 좋다.
접근성을 고려하여 키보드 탐색이 논리적인 순서로 이루어지도록 해야 하며, 각 입력 필드에 적절한 레이블과 힌트를 제공해야 한다.
10. 안드로이드 개발에서 더미 데이터 사용
더미 데이터(Mock Data)는 소프트웨어 개발 및 테스트 과정에서 실제 데이터를 대신하여 사용되는 가상의 자리 표시자 데이터다.
위시리스트 앱 개발에서 더미 데이터는 UI 구성 요소와 레이아웃을 시각화하고 테스트하는 데 필수적인 역할을 한다. 실제 데이터베이스나 API가 준비되기 전에도 앱의 모양과 동작을 구체적으로 확인할 수 있게 해준다.
더미 데이터의 핵심 목적과 활용:
디자인 및 레이아웃 검증에서 더미 데이터는 다양한 길이와 형태의 텍스트, 이미지, 숫자 데이터를 사용하여 UI가 실제 상황에서 어떻게 보일지 미리 확인할 수 있게 해준다.
기능 테스트를 통해 개발자는 데이터 정렬, 필터링, 검색, 사용자 상호작용과 같은 앱의 핵심 기능들을 더미 데이터를 사용하여 철저히 테스트할 수 있다.
성능 테스트에서는 다양한 양과 유형의 더미 데이터를 사용하여 앱이 대량의 데이터를 처리할 때의 성능을 미리 평가할 수 있다.
위시리스트 앱을 위한 체계적인 더미 데이터 구조:
// 위시 아이템을 위한 데이터 클래스
data class Wish(
val id: Int,
val title: String,
val description: String,
val price: Long,
val imageUrl: String = "",
val webUrl: String = "",
val category: WishCategory,
val priority: WishPriority,
val status: WishStatus,
val isFavorite: Boolean = false,
val dateCreated: LocalDateTime,
val dateUpdated: LocalDateTime? = null,
val tags: List<String> = emptyList(),
val notes: String = ""
)
enum class WishCategory(val displayName: String, val emoji: String) {
ELECTRONICS("전자제품", "📱"),
FASHION("패션", "👕"),
BOOKS("도서", "📚"),
HOME("홈&리빙", "🏠"),
SPORTS("스포츠", "⚽"),
TRAVEL("여행", "✈️"),
FOOD("음식", "🍔"),
BEAUTY("뷰티", "💄"),
TOYS("장난감", "🧸"),
OTHER("기타", "📦")
}
enum class WishPriority(val level: Int, val displayName: String) {
LOW(1, "낮음"),
MEDIUM(2, "보통"),
HIGH(3, "높음"),
URGENT(4, "긴급")
}
enum class WishStatus(val displayName: String) {
WISHLIST("위시리스트"),
PURCHASED("구매완료"),
ARCHIVED("보관함"),
GIFT_RECEIVED("선물받음")
}
현실적이고 다양한 더미 데이터 생성:
object WishDataProvider {
// 더미 위시 아이템들
private val sampleWishes = listOf(
Wish(
id = 1,
title = "MacBook Pro 16인치 M3 Max",
description = "영상 편집과 개발을 위한 고성능 노트북. 32GB 메모리와 1TB SSD로 구성된 최고급 사양입니다.",
price = 4390000L,
imageUrl = "https://example.com/macbook-pro.jpg",
webUrl = "https://www.apple.com/kr/macbook-pro/",
category = WishCategory.ELECTRONICS,
priority = WishPriority.HIGH,
status = WishStatus.WISHLIST,
isFavorite = true,
dateCreated = LocalDateTime.now().minusDays(5),
tags = listOf("애플", "노트북", "개발", "영상편집"),
notes = "학생 할인 적용 가능한지 확인 필요"
),
Wish(
id = 2,
title = "나이키 에어포스 1 화이트",
description = "클래식한 디자인의 나이키 대표 스니커즈. 데일리 착용하기 좋은 편안한 신발입니다.",
price = 119000L,
imageUrl = "https://example.com/nike-airforce1.jpg",
webUrl = "https://www.nike.com/kr/",
category = WishCategory.FASHION,
priority = WishPriority.MEDIUM,
status = WishStatus.PURCHASED,
isFavorite = false,
dateCreated = LocalDateTime.now().minusDays(12),
dateUpdated = LocalDateTime.now().minusDays(2),
tags = listOf("나이키", "스니커즈", "화이트", "클래식"),
notes = "275mm 사이즈로 주문 완료"
),
Wish(
id = 3,
title = "클린 코드 (로버트 C. 마틴)",
description = "소프트웨어 장인이 되기 위한 필수 도서. 깨끗한 코드를 작성하는 방법과 원칙을 배울 수 있습니다.",
price = 31500L,
imageUrl = "https://example.com/clean-code-book.jpg",
webUrl = "https://www.yes24.com/",
category = WishCategory.BOOKS,
priority = WishPriority.LOW,
status = WishStatus.WISHLIST,
isFavorite = true,
dateCreated = LocalDateTime.now().minusDays(8),
tags = listOf("프로그래밍", "개발서적", "클린코드"),
notes = "전자책 버전도 고려해보기"
),
Wish(
id = 4,
title = "다이슨 V15 무선청소기",
description = "강력한 흡입력과 레이저 기술로 미세먼지까지 감지하는 프리미엄 무선청소기입니다.",
price = 899000L,
imageUrl = "https://example.com/dyson-v15.jpg",
webUrl = "https://www.dyson.co.kr/",
category = WishCategory.HOME,
priority = WishPriority.MEDIUM,
status = WishStatus.WISHLIST,
isFavorite = false,
dateCreated = LocalDateTime.now().minusDays(15),
tags = listOf("다이슨", "청소기", "무선", "프리미엄"),
notes = "쿠팡이나 다른 쇼핑몰에서 할인 정보 확인"
),
Wish(
id = 5,
title = "제주도 3박 4일 여행",
description = "성산일출봉, 한라산, 우도 등을 포함한 제주도 완전 정복 여행 패키지입니다.",
price = 650000L,
imageUrl = "https://example.com/jeju-travel.jpg",
webUrl = "https://www.hanatour.com/",
category = WishCategory.TRAVEL,
priority = WishPriority.HIGH,
status = WishStatus.WISHLIST,
isFavorite = true,
dateCreated = LocalDateTime.now().minusDays(3),
tags = listOf("제주도", "여행", "3박4일", "힐링"),
notes = "4월 말 ~ 5월 초 연휴 기간 예약 고려"
),
Wish(
id = 6,
title = "아이패드 프로 12.9인치 + 애플펜슬",
description = "디지털 드로잉과 노트 필기를 위한 아이패드 프로. 애플펜슬 2세대 포함 번들입니다.",
price = 1790000L,
imageUrl = "https://example.com/ipad-pro.jpg",
webUrl = "https://www.apple.com/kr/ipad-pro/",
category = WishCategory.ELECTRONICS,
priority = WishPriority.MEDIUM,
status = WishStatus.ARCHIVED,
isFavorite = false,
dateCreated = LocalDateTime.now().minusDays(25),
dateUpdated = LocalDateTime.now().minusDays(10),
tags = listOf("아이패드", "애플펜슬", "드로잉", "태블릿"),
notes = "맥북 구매 후 보류하기로 결정"
)
)
// 카테고리별 더미 데이터 생성
fun getWishesByCategory(category: WishCategory): List {
return sampleWishes.filter { it.category == category }
}
// 우선순위별 더미 데이터 생성
fun getWishesByPriority(priority: WishPriority): List {
return sampleWishes.filter { it.priority == priority }
}
// 즐겨찾기 더미 데이터
fun getFavoriteWishes(): List {
return sampleWishes.filter { it.isFavorite }
}
// 상태별 더미 데이터
fun getWishesByStatus(status: WishStatus): List {
return sampleWishes.filter { it.status == status }
}
// 모든 더미 데이터 반환
fun getAllWishes(): List = sampleWishes
// 검색을 위한 더미 데이터
fun searchWishes(query: String): List {
return sampleWishes.filter { wish ->
wish.title.contains(query, ignoreCase = true) ||
wish.description.contains(query, ignoreCase = true) ||
wish.tags.any { tag -> tag.contains(query, ignoreCase = true) }
}
}
// 가격 범위별 더미 데이터
fun getWishesByPriceRange(minPrice: Long, maxPrice: Long): List {
return sampleWishes.filter { it.price in minPrice..maxPrice }
}
// 최근 생성된 위시 아이템들
fun getRecentWishes(days: Int = 7): List {
val cutoffDate = LocalDateTime.now().minusDays(days.toLong())
return sampleWishes.filter { it.dateCreated.isAfter(cutoffDate) }
}
}
동적 더미 데이터 생성기:
object DynamicWishGenerator {
private val titleTemplates = listOf(
"갤럭시 S24 울트라", "아이폰 15 프로", "맥북 에어 M3", "LG 그램 노트북",
"나이키 에어맥스", "아디다스 스탠스미스", "컨버스 척테일러", "반스 올드스쿨",
"스타벅스 텀블러", "이케아 책상", "무인양품 수납함", "한샘 옷장",
"런던 여행", "파리 여행", "도쿄 여행", "뉴욕 여행",
"플레이스테이션 5", "닌텐도 스위치", "Xbox Series X", "게이밍 키보드"
)
private val descriptionTemplates = listOf(
"정말 갖고 싶었던 아이템입니다.",
"리뷰를 보니 성능이 정말 좋다고 하네요.",
"생일 선물로 받고 싶은 제품입니다.",
"할인할 때 구매하려고 찜해둔 상품이에요.",
"친구 추천으로 관심을 갖게 된 제품입니다."
)
private val tagOptions = listOf(
listOf("전자제품", "최신기술", "프리미엄"),
listOf("패션", "트렌디", "데일리"),
listOf("생활용품", "실용적", "필수템"),
listOf("여행", "힐링", "추억"),
listOf("게임", "엔터테인먼트", "취미")
)
fun generateRandomWishes(count: Int): List<Wish> {
return (1..count).map { index ->
val randomCategory = WishCategory.values().random()
val randomPriority = WishPriority.values().random()
val randomStatus = WishStatus.values().random()
Wish(
id = index,
title = titleTemplates.random(),
description = descriptionTemplates.random(),
price = generateRandomPrice(),
category = randomCategory,
priority = randomPriority,
status = randomStatus,
isFavorite = (1..10).random() <= 3, // 30% 확률로 즐겨찾기
dateCreated = generateRandomDate(),
tags = tagOptions.random().shuffled().take((1..3).random()),
notes = if ((1..10).random() <= 4) "추가 메모가 있습니다." else ""
)
}
}
private fun generateRandomPrice(): Long {
val priceRanges = listOf(
10000L..50000L, // 저가
50000L..200000L, // 중가
200000L..1000000L, // 고가
1000000L..5000000L // 최고가
)
val selectedRange = priceRanges.random()
return (selectedRange.first..selectedRange.last step 1000L).random()
}
private fun generateRandomDate(): LocalDateTime {
val daysAgo = (1..90).random().toLong()
return LocalDateTime.now().minusDays(daysAgo)
}
}
UI 컴포넌트에서 더미 데이터 활용:
@Composable
fun WishListScreenPreview() {
val dummyWishes = WishDataProvider.getAllWishes()
WishListTheme {
WishListScreen(
wishes = dummyWishes,
onAddWishClick = { /* Preview - 아무 동작 안함 */ },
onWishClick = { /* Preview - 아무 동작 안함 */ },
onWishDelete = { /* Preview - 아무 동작 안함 */ },
onWishEdit = { /* Preview - 아무 동작 안함 */ },
onFavoriteToggle = { /* Preview - 아무 동작 안함 */ }
)
}
}
@Composable
fun WishListContent(
wishes: List<Wish>,
onWishClick: (Wish) -> Unit,
onWishDelete: (Wish) -> Unit,
onWishEdit: (Wish) -> Unit,
onFavoriteToggle: (Wish) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = wishes,
key = { wish -> wish.id }
) { wish ->
SwipeableWishCard(
wish = wish,
onItemClick = { onWishClick(wish) },
onEditClick = { onWishEdit(wish) },
onDeleteClick = { onWishDelete(wish) },
onFavoriteClick = { onFavoriteToggle(wish) },
modifier = Modifier.animateItemPlacement()
)
}
// 빈 상태 처리
if (wishes.isEmpty()) {
item {
EmptyWishListMessage(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp)
)
}
}
}
}
@Composable
fun EmptyWishListMessage(
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.List,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.4f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "아직 위시리스트가 비어있어요",
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
Text(
text = "원하는 물건을 추가해보세요!",
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f),
modifier = Modifier.padding(top = 8.dp)
)
}
}
테스트와 개발을 위한 더미 데이터 전략:
분리와 유지보수성을 위해 더미 데이터는 별도의 클래스나 파일로 분리하여 관리하는 것이 좋다. 실제 데이터 통합이 준비되면 쉽게 교체하거나 제거할 수 있도록 인터페이스를 일관되게 유지해야 한다.
현실성과 다양성을 확보하기 위해 더미 데이터는 구조와 유형 측면에서 실제 데이터를 최대한 반영해야 한다. 다양한 길이의 텍스트, 여러 범위의 숫자, 다른 상태값들을 포함하여 앱이 실제 환경에서 어떻게 작동할지 정확하게 시뮬레이션해야 한다.
확장성과 성능을 고려하여 소량부터 대량까지 다양한 양의 더미 데이터로 테스트하여 레이아웃과 성능이 어떻게 확장되는지 미리 파악해야 한다.
도구와 라이브러리 활용으로는 복잡하거나 대량의 더미 데이터가 필요한 경우 Faker 라이브러리나 MockK 같은 도구를 활용할 수 있다. 또한 온라인 더미 데이터 생성 서비스들도 유용한 대안이 될 수 있다.
이러한 체계적인 더미 데이터 활용을 통해 개발자는 실제 데이터 소스가 준비되기 전에도 완전히 기능하는 앱의 프로토타입을 만들 수 있고 다양한 시나리오에서의 사용자 경험을 미리 검증할 수 있다.