[Kotlin / Compose] ViewModel, LiveData

Subeen·2024년 4월 24일
0

Compose

목록 보기
10/20

ToDo 앱

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

Crossfade는 Jetpack Compose에서 제공하는 애니메이션 효과를 쉽게 적용할 수 있는 컴포저블 중 하나로 두 가지 상태 사이의 전환을 부드럽게 처리하여 사용자에게 시각적으로 부드러운 경험을 제공한다.
CrossfadetargetState 파라미터에 상태 변수를 전달하여 현재 상태와 목표 상태를 결정하며 Compose는 두 상태 간의 변화를 애니메이션화하여 부드러운 효과를 제공한다.
✨ Todo 앱에서 Crossfade는 편집 모드와 일반 모드 사이의 전환을 처리하였다.

OutlinedTextField

OutlinedTextField는 Jetpack Compose에서 제공하는 텍스트 입력 필드 중 하나이며 텍스트 입력란 주위에 외곽선이 있는 입력 필드를 생성한다.
✨ Todo 앱에서 사용자가 할 일을 입력할 수 있는 입력란을 생성하는 데 사용되었으며, 사용자가 텍스트를 입력하고 제출할 때마다 onValueChange 콜백이 호출되어 입력된 텍스트를 추적하고 관련 상태를 업데이트한다.

ViewModel

  • 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 연결

상태 관리 방식을 LiveData와 MutableLiveData를 사용하여 업데이트한다.

  • LiveData 및 MutableLiveData 사용
    • 기존에는 상태를 가변 상태로 관리하는 mutableStateOf를 사용했으나 ViewModel 내에서 LiveDataMutableLiveData를 사용하여 상태를 관리한다.
  • 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를 사용하는 것이 더 효율적이다.
profile
개발 공부 기록 🌱

0개의 댓글