Jetpack Compose를 사용하여 ToDo 앱 구현
// 전체 UI를 구성하는 함수
@OptIn(ExperimentalMaterial3Api::class) // Material3 라이브러리의 실험적인 API를 사용하기 위한 어노테이션
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") // 사용되지 않는 Material3 스캐폴드 패딩 파라미터에 대한 경고를 무시하기 위한 어노테이션
@Composable
fun TopLevel() {
// 입력된 텍스트와 해당 텍스트를 설정하는 데 사용되는 상태 변수
val (text, setText) = remember {
mutableStateOf("")
}
// 할 일 목록을 관리하는 상태 변수
val todoList = remember {
mutableStateListOf<TodoDate>()
}
// 할 일을 제출하는 함수
val onSubmit: (String) -> Unit = { text ->
// 새로운 할 일 항목을 추가하고 입력 필드를 비움
val key = (todoList.lastOrNull()?.key ?: 0) + 1
todoList.add(TodoDate(key, text))
setText("")
}
// 할 일 항목을 완료 및 미완료로 토글하는 함수
val onToggle: (Int, Boolean) -> Unit = { key, checked ->
val i = todoList.indexOfFirst { it.key == key }
todoList[i] = todoList[i].copy(done = checked)
}
// 할 일 항목을 삭제하는 함수
val onDelete: (Int) -> Unit = { key ->
val i = todoList.indexOfFirst { it.key == key }
todoList.removeAt(i)
}
// 할 일 항목을 수정하는 함수
val onEdit: (Int, String) -> Unit = { key, text ->
val i = todoList.indexOfFirst { it.key == key }
todoList[i] = todoList[i].copy(text = text)
}
// UI를 구성하는 Scaffold
Scaffold {
Column {
// 할 일을 입력하는 컴포저블
TodoInput(
text = text,
onTextChange = setText,
onSubmit = onSubmit
)
// 할 일 목록을 표시하는 LazyColumn
LazyColumn {
items(todoList, key = {it.key}) { todoData ->
Todo(
todoData = todoData,
onEdit = onEdit,
onToggle = onToggle,
onDelete = onDelete
)
}
}
}
}
}
// 할 일을 입력하는 데 사용되는 함수
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TodoInput(
text: String,
onTextChange: (String) -> Unit,
onSubmit: (String) -> Unit
) {
Row(
modifier = Modifier.padding(8.dp)
) {
// 할 일을 입력하는 텍스트 필드
OutlinedTextField(
value = text,
onValueChange = onTextChange,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.size(8.dp))
// 할 일을 제출하는 버튼
Button(onClick = {
onSubmit(text)
}) {
Text(text = "입력")
}
}
}
// 할 일 항목을 나타내는 함수
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Todo(
todoData: TodoDate,
onEdit: (key: Int, text: String) -> Unit = { _, _ -> },
onToggle: (key: Int, checked: Boolean) -> Unit = { _, _ -> },
onDelete: (key: Int) -> Unit = {}
) {
// 할 일 항목이 편집 중인지 여부를 추적하는 상태 변수
var isEditing by remember { mutableStateOf(false) }
Card(
modifier = Modifier.padding(4.dp),
elevation = CardDefaults.cardElevation(
defaultElevation = 8.dp
)
) {
Crossfade(targetState = isEditing, label = "") {
when (it) {
false -> {
// 편집 모드가 아닌 경우 일반적인 할 일 항목을 표시
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Text(
text = todoData.text,
modifier = Modifier.weight(1f)
)
Text(
text = "완료"
)
// 완료 여부를 토글하는 체크박스
Checkbox(checked = todoData.done, onCheckedChange = { checked ->
onToggle(todoData.key, checked)
})
// 편집 모드로 전환하는 버튼
Button(onClick = {
isEditing = true
}) {
Text(text = "수정")
}
Spacer(modifier = Modifier.size(4.dp))
// 할 일 항목을 삭제하는 버튼
Button(onClick = {
onDelete(todoData.key)
}) {
Text(text = "삭제")
}
}
}
true -> {
// 편집 모드인 경우 할 일 항목을 수정할 수 있는 텍스트 필드를 표시
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
var (newText, setNewText) = remember {
mutableStateOf(todoData.text)
}
OutlinedTextField(
value = newText,
onValueChange = setNewText,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.size(4.dp))
// 수정을 완료하는 버튼
Button(onClick = {
onEdit(todoData.key, newText)
isEditing = false
}) {
Text(text = "완료")
}
}
}
}
}
}
}
// 할 일 항목 데이터 클래스
data class TodoDate(
val key: Int,
val text: String,
val done: Boolean = false
)
Crossfade는 Jetpack Compose에서 제공하는 애니메이션 효과를 쉽게 적용할 수 있는 컴포저블 중 하나로 두 가지 상태 사이의 전환을 부드럽게 처리하여 사용자에게 시각적으로 부드러운 경험을 제공한다.
Crossfade는 targetState 파라미터에 상태 변수를 전달하여 현재 상태와 목표 상태를 결정하며 Compose는 두 상태 간의 변화를 애니메이션화하여 부드러운 효과를 제공한다.
✨ Todo 앱에서 Crossfade는 편집 모드와 일반 모드 사이의 전환을 처리하였다.
OutlinedTextField는 Jetpack Compose에서 제공하는 텍스트 입력 필드 중 하나이며 텍스트 입력란 주위에 외곽선이 있는 입력 필드를 생성한다.
✨ Todo 앱에서 사용자가 할 일을 입력할 수 있는 입력란을 생성하는 데 사용되었으며, 사용자가 텍스트를 입력하고 제출할 때마다 onValueChange 콜백이 호출되어 입력된 텍스트를 추적하고 관련 상태를 업데이트한다.
- TopLevel 함수의 매개변수로 TodoViewModel 추가
- 기존에는 TopLevel 함수가 직접 todoList를 관리했지만 ViewModel을 통해 todoList와 관련된 로직을 분리하여 관리한다.
- TodoInput 함수와 Todo 함수에서 ViewModel과 상호 작용
- 기존에는 TodoInput과 Todo 함수에서 상태를 관리하기 위해 remember를 사용하여 상태를 추적했지만 ViewModel의 상태를 직접 사용하도록 변경하였다.
- TodoViewModel 클래스 추가
- ViewModel을 상속하여 구현된 TodoViewModel 클래스를 추가하여 Todo 앱의 비즈니스 로직과 상태를 관리한다.
dependencies {
...
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") // add
}
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
fun TopLevel(viewModel: TodoViewModel = viewModel()) {
// ViewModel을 통해 todoList를 관리하므로 더 이상 직접 상태를 추적할 필요가 없음
// TodoViewModel을 초기화하여 사용
// val todoList = remember {
// mutableStateListOf<TodoDate>()
// }
// Scaffold는 Material Design을 따르는 레이아웃 구조를 제공
Scaffold {
Column {
// TodoInput 컴포넌트에 ViewModel의 text 상태와 onSubmit 콜백을 전달하여 UI와 로직을 연결
TodoInput(
text = viewModel.text.value,
onTextChange = {
viewModel.text.value = it
},
onSubmit = viewModel.onSubmit
)
LazyColumn {
items(
// ViewModel에서 관리되는 todoList를 직접 사용
items = viewModel.todoList,
key = {it.key}) { todoData ->
// Todo 함수에 ViewModel의 로직을 전달하여 UI와 로직을 연결
Todo(
todoData = todoData,
onEdit = viewModel.onEdit,
onToggle = viewModel.onToggle,
onDelete = viewModel.onDelete
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TodoInput(
text: String,
onTextChange: (String) -> Unit,
onSubmit: (String) -> Unit
) {
Row(
modifier = Modifier.padding(8.dp)
) {
// OutlinedTextField 컴포넌트에 ViewModel의 text 상태와 onTextChange 콜백을 연결
OutlinedTextField(
value = text,
onValueChange = onTextChange,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.size(8.dp))
Button(onClick = {
// 입력 버튼을 누를 때 ViewModel의 onSubmit 콜백 호출
onSubmit(text)
}) {
Text(text = "입력")
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Todo(
todoData: TodoDate,
onEdit: (key: Int, text: String) -> Unit = { _, _ -> },
onToggle: (key: Int, checked: Boolean) -> Unit = { _, _ -> },
onDelete: (key: Int) -> Unit = {}
) {
var isEditing by remember { mutableStateOf(false) }
Card(
modifier = Modifier.padding(4.dp),
elevation = CardDefaults.cardElevation(
defaultElevation = 8.dp
)
) {
Crossfade(targetState = isEditing, label = "") {
when (it) {
false -> {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Text(
text = todoData.text,
modifier = Modifier.weight(1f)
)
Text(
text = "완료"
)
Checkbox(checked = todoData.done, onCheckedChange = { checked ->
onToggle(todoData.key, checked)
})
Button(onClick = {
isEditing = true
}) {
Text(text = "수정")
}
Spacer(modifier = Modifier.size(4.dp))
Button(onClick = {
onDelete(todoData.key)
}) {
Text(text = "삭제")
}
}
}
true -> {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
var (newText, setNewText) = remember {
mutableStateOf(todoData.text)
}
OutlinedTextField(
value = newText,
onValueChange = setNewText,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.size(4.dp))
Button(onClick = {
onEdit(todoData.key, newText)
isEditing = false
}) {
Text(text = "완료")
}
}
}
}
}
}
}
data class TodoDate(
val key: Int,
val text: String,
val done: Boolean = false
)
// ViewModel을 사용하여 UI 상태 및 로직을 관리
class TodoViewModel: ViewModel() {
// 텍스트 입력 상태를 관리하는 mutableStateOf
val text = mutableStateOf("")
// 할 일 목록을 관리하는 mutableStateListOf
val todoList = mutableStateListOf<TodoDate>()
// 할 일을 제출하는 콜백
val onSubmit: (String) -> Unit = {
val key = (todoList.lastOrNull()?.key ?: 0) + 1
todoList.add(TodoDate(key, it))
text.value = ""
}
// 할 일 항목의 완료 상태를 토글하는 콜백
val onToggle: (Int, Boolean) -> Unit = { key, checked ->
val i = todoList.indexOfFirst { it.key == key }
todoList[i] = todoList[i].copy(done = checked)
}
// 할 일 항목을 삭제하는 콜백
val onDelete: (Int) -> Unit = { key ->
val i = todoList.indexOfFirst { it.key == key }
todoList.removeAt(i)
}
// 할 일 항목을 수정하는 콜백
val onEdit: (Int, String) -> Unit = { key, text ->
val i = todoList.indexOfFirst { it.key == key }
todoList[i] = todoList[i].copy(text = text)
}
}
상태 관리 방식을 LiveData와 MutableLiveData를 사용하여 업데이트한다.
- LiveData 및 MutableLiveData 사용
- 기존에는 상태를 가변 상태로 관리하는 mutableStateOf를 사용했으나 ViewModel 내에서 LiveData와 MutableLiveData를 사용하여 상태를 관리한다.
- TodoViewModel 클래스
- Viewmodel을 상속하고 텍스트 입력 상태와 할 일 목록을 LiveData로 노출한다.
- 이를 통해 UI와 데이터 간의 결합도를 낮출 수 있다.
- TodoInput 및 Todo 함수에서 LiveData 및 ViewModel 사용
- TodoInput 함수에서는 텍스트 입력 상태를 LiveData로 관찰하여 UI에 반영하고, Todo 함수에서는 할 일 목록을 LiveData로 관찰하여 UI에 반영한다.
- 이를 통해 UI가 ViewModel의 상태를 관찰하고 상태가 변경될 때마다 자동으로 업데이트된다.
- 데이터 업데이트 및 UI 반영
- ViewModel 내의 함수들은 데이터를 변경하고 변경된 데이터를 LiveData에 설정하여 UI에 반영한다.
- 예를 들어, onSubmit 함수에서는 새로운 할 일을 추가하고, 변경된 할 일 목록을 MutableLiveData에 설정하여 UI에 반영한다.
dependencies {
...
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.compose.runtime:runtime-livedata:1.6.6")
}
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
fun TopLevel(viewModel: TodoViewModel = viewModel()) {
// Scaffold로 레이아웃 구성
Scaffold {
Column {
// TodoInput 컴포넌트에서 ViewModel의 텍스트 상태를 관찰하고 해당 값으로 초기화
TodoInput(
text = viewModel.text.observeAsState("").value, // ViewModel의 text를 observeAsState로 관찰하여 초기값 설정
onTextChange = viewModel.setText, // ViewModel의 setText 함수를 텍스트 변경 콜백으로 설정
onSubmit = viewModel.onSubmit // ViewModel의 onSubmit 함수를 제출 콜백으로 설정
)
// ViewModel의 todoList를 관찰하여 상태를 업데이트하고 LazyColumn에 표시
val items = viewModel.todoList.observeAsState(emptyList()).value // ViewModel의 todoList를 observeAsState로 관찰하여 초기값 설정
LazyColumn {
items(
items = items, // ViewModel에서 관리되는 todoList를 사용
key = {it.key}) { todoData ->
// Todo 함수에 ViewModel의 로직을 전달하여 UI와 로직을 연결
Todo(
todoData = todoData,
onEdit = viewModel.onEdit,
onToggle = viewModel.onToggle,
onDelete = viewModel.onDelete
)
}
}
}
}
}
class TodoViewModel: ViewModel() {
// LiveData를 사용하여 UI 상태 관리
private val _text = MutableLiveData("")
val text: LiveData<String> = _text // 텍스트 상태를 LiveData로 노출
// todoList를 MutableLiveData로 선언하여 UI 상태 관리
// val todoList = mutableStateListOf<TodoDate>()
private val _rawTodoList = mutableListOf<TodoDate>()
private val _toDoList = MutableLiveData<List<TodoDate>>(_rawTodoList)
val todoList: LiveData<List<TodoDate>> = _toDoList // 할 일 목록을 LiveData로 노출
// 텍스트를 변경하는 함수를 LiveData에 할당
val setText: (String) -> Unit = {
_text.value = it // LiveData에 텍스트 값을 설정하여 UI에 반영
}
// 할 일을 제출하는 함수에서 LiveData 값을 변경하고 UI에 반영
val onSubmit: (String) -> Unit = {
val key = (_rawTodoList.lastOrNull()?.key ?: 0) + 1
_rawTodoList.add(TodoDate(key, it))
_toDoList.value = ArrayList(_rawTodoList)
_text.value = ""
}
// 할 일 항목의 완료 상태를 토글하는 함수에서 LiveData 값을 변경하고 UI에 반영
val onToggle: (Int, Boolean) -> Unit = { key, checked ->
val i = _rawTodoList.indexOfFirst { it.key == key }
_rawTodoList[i] = _rawTodoList[i].copy(done = checked)
_toDoList.value = ArrayList(_rawTodoList)
}
// 할 일 항목을 삭제하는 함수에서 LiveData 값을 변경하고 UI에 반영
val onDelete: (Int) -> Unit = { key ->
val i = _rawTodoList.indexOfFirst { it.key == key }
_rawTodoList.removeAt(i)
_toDoList.value = ArrayList(_rawTodoList)
}
// 할 일 항목을 수정하는 함수에서 LiveData 값을 변경하고 UI에 반영
val onEdit: (Int, String) -> Unit = { key, text ->
val i = _rawTodoList.indexOfFirst { it.key == key }
_rawTodoList[i] = _rawTodoList[i].copy(text = text)
_toDoList.value = ArrayList(_rawTodoList)
}
}
mutableStateListOf vs LiveData<List<>>.observeAsState()
- mutableStateListOf : 리스트에 새로운 요소가 추가되거나 대입될 때마다 UI가 갱신되지만 각 항목의 필드가 변경될 때는 UI가 자동으로 갱신되지 않는다. 따라서 항목의 필드가 변경될 때 UI를 갱신하려면 해당 상태를 변경하고 재렌더링해야한다.
- LiveData<List<>>.observeAsState() : observeAsState() 함수를 사용하면 LiveData의 변경을 Composable 함수에서 감지할 수 있지만 observeAsState() 함수는 리스트가 통채로 다른 리스트로 변경될 때만 상태를 갱신한다.
리스트 내부의 요소가 변경되어도 상태가 갱신되지 않기에 리스트의 요소가 변경되는 경우에는 새로운 리스트를 할당하여 상태를 갱신해야 한다.- ✨ 단일 화면이나 일부 구성 요소에서만 상태를 관리해야 하는 경우에는 mutableStateListOf를 사용하는 것이 더 간단하고 직관적일 수 있으며, 여러 화면 간에 데이터를 공유하고 동기화해야 하는 경우에는 ViewModel에서 LiveData를 사용하는 것이 효율적일 수 있다.
또한, UI가 상태의 특정 부분만을 갱신해야 할 때는 mutableStateListOf를 사용할 수 있으며, 리스트 전체가 갱신되어야 하는 경우에는 LiveData를 사용하는 것이 더 효율적이다.