1. 목표
- TodoList 제작하기
- Item을 CardView 이용해서 조금 더 깔끔하게 UI 디자인 하기
- Room Database를 이용하여 추가 수정할 수 있도록 만들기.
- 삭제 버튼을 구현하여 언제든지 삭제할 수 있도록 만들기
2. 사용 기술
- RecyclerView
- Room database
- MVVM databinding
3. 제작 구조
이런 구조로 제작하였다. 안드로이드 체계에 맞추어 data와 domain, ui 파트로 나누어 작업하였다.
1편에서는 xml과 Room Database 요소인 Todo와 AppDatabase, TodoDAO에 대해 살펴보았다.
2편인 오늘은 MVVM databinding에 대해 살펴볼 것이다.
4. 코드 구성
4-1. MVVM 원리
MVVM은 Model-View-ViewModel의 약자로, 안드로이드 앱 개발에서 사용되는 소프트웨어 디자인 패턴 중 하나이다.
사용자 인터페이스(UI)와 비즈니스 로직을 분리하여 앱의 확장성과 유지 보수성을 향상시킨다.
- Model : 앱에서 사용되는 데이터를 정의하고 저장합니다. 이 데이터는 데이터베이스, 서버 또는 로컬 파일 시스템 등 다양한 소스에서 가져올 수 있습니다.
- View : UI를 정의하고 사용자와 상호작용합니다. 이는 화면에 보이는 요소들이며, 사용자가 클릭하거나 입력하는 등의 이벤트를 받아 ViewModel로 전달합니다.
- ViewModel : View와 Model 간의 매개체로, Model로부터 데이터를 가져와서 필요한 형식으로 가공하여 View에 전달합니다. 또한 View에서 사용자 이벤트를 받아 필요한 데이터를 Model에 전달합니다. ViewModel은 일반적으로 LiveData 또는 Overvable 등의 객체를 사용하여 View와 통신을 관리합니다. LiveData와 Overvable에 대한 간략한 설명은 아래 접은 글을 참고하시길 바랍니다.
여기서 LiveData는 수명주기를 인식하여 데이터를 관찰하고 변경될 때마다 UI을 업데이트 할 수 있도록 하는 Android Jetpack의 일부로 제공되는 클래스이다. LiveData는 항상 최신 값을 유지하며, 액티비티 또는 프래그먼트가 활성화되었을 때에만 데이터 변경 사항을 수신한다. 이로 인해 메모리 누수나 액티비티가 파괴될 때 발생할 수 있는 문제를 방지할 수 있다. 상대적으로 앱의 성능과 안정성을 보장할 때는 유용하다.
Observable은 RxJava에서 제공하는 클래스로, 일반적으로 데이터 스트림을 다룰 때 사용된다. 라이브 데이터와 달리 수명주기를 인식하지 않지만, 데이터의 변경 사항을 관찰하고 이에 대한 알림을 받아서 처리할 수 있다. 또한 다양한 연산자를 제공하여 데이터의 변환, 조합, 필터링 등을 쉽게 수행할 수 있다. Android 외에 다른 플랫폼에서도 사용이 가능하며, 비동기적으로 데이터를 처리해야 하는 상황에서 유용하다.
4-2. MVVM 사용된 형식
1) Model
Todo 클래스 및 TodoRepository 클래스
- Todo : 데이터 모델을 나타내며, 데이터베이스 테이블에 매핑된다.
- TodoRepository : 데이터베이스와의 상호작용을 추상화하여 ViewModel에서 사용할 수 있도록 제작하였다.
[TodoRepository.kt]
package com.example.todolist.domain
import android.util.Log
import androidx.lifecycle.LiveData
import com.example.todolist.data.AppDatabase
import com.example.todolist.data.TodoDAO
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.*
class TodoRepository(private val todoDAO: TodoDAO) {
val todoList: LiveData<List<Todo>> = todoDAO.getAll()
suspend fun insert(todo: Todo){
withContext(Dispatchers.IO){
val existingTodo = todoDAO.getTodoByContent(todo.content)
if(existingTodo == null){
todoDAO.insert(todo)
} else {
val dataFormat = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
val currentTime = dataFormat.format(System.currentTimeMillis())
val updateTodo = existingTodo.copy(
timestamp = currentTime
)
todoDAO.update(updateTodo)
}
}
}
fun delete(todo: Todo){
todoDAO.delete(todo)
}
init {
todoList.observeForever{todos->
Log.d("DataTest", "${todos.size}")
}
}
}
TodoDAO 인터페이스를 생성자로 받아 DAO을 통해 데이터베이스에 접근하는 기능을 한다.
데이터베이스와의 상호작용을 추상화하여, 데이터 소스를 변경해도 ViewModel에서는 그대로 사용할 수 있도록 만들었다.
만약 내용이 중복되는 Todo가 들어왔을 경우에는 내용은 그대로 유지하고, 시간만 입력한 시간으로 바꿀 수 있도록 제작하였다.
2) ViewModel
- TodoViewModel : UI와 Model 사이에서 상호작용을 관리한다.
- View가 필요홀 하는 데이터를 제공하고 UI 상태 변화를 처리한다.
- ViewModel은 Model과 UI 간의 결합도를 낮추기 위해 데이터를 변환하고 가공하는 역할을 한다.
[TodoViewModel.kt]
package com.example.todolist.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import com.example.todolist.data.AppDatabase
import com.example.todolist.domain.Todo
import com.example.todolist.domain.TodoRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class TodoViewModel(application: Application) : AndroidViewModel(application) {
private val repository: TodoRepository
val _todoList: LiveData<List<Todo>>
init {
val todoDAO = checkNotNull(AppDatabase.getInstance(application)){"Database is not initialized"}.todoDAO()
repository = TodoRepository(todoDAO)
_todoList = repository.todoList
}
fun getAll(): LiveData<List<Todo>>{
return _todoList
}
fun insert(todo: Todo) = viewModelScope.launch(Dispatchers.IO){
repository.insert(todo)
}
fun delete(todo: Todo) = viewModelScope.launch(Dispatchers.IO){
repository.delete(todo)
}
}
TodoViewModel의 역할은 간단하다. TodoRepository을 사용하여 데이터베이스에 엑세스하고, LiveData를 사용하여 데이터 변경 사항이 있으면 이를 UI에 전달하는 기능을 한다. 데이터베이스 코드를 직접 사용하는 대신, LiveData를 통해 간접적으로 데이터를 업데이트를 제공한다.
3) View
View는 UI의 구현에 담당하는 파트이다. 본 코드에서는 MainActivity와 TodoAdapter이 이에 속한다.
- View는 ViewModel에 대한 참조를 가지며, ViewModel에서 필요한 데이터를 요청한다.
- View는 UI 이벤트를 ViewModel로 전달하여 데이터 업데이트를 트리거 한다.
[MainActivity.kt]
package com.example.todolist.ui
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.todolist.R
import com.example.todolist.databinding.ActivityMainBinding
import com.example.todolist.domain.Todo
import java.text.SimpleDateFormat
import java.util.*
class MainActivity : AppCompatActivity(), TodoAdapter.OnTodoItemClickListener {
private lateinit var binding: ActivityMainBinding
private lateinit var viewModel: TodoViewModel
private lateinit var adapter: TodoAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.rvTodoList.layoutManager = LinearLayoutManager(this)
adapter = TodoAdapter(this, this)
binding.rvTodoList.adapter = adapter
viewModel = ViewModelProvider(this, ViewModelProvider.AndroidViewModelFactory(application))[TodoViewModel::class.java]
viewModel.getAll().observe(this) {
adapter.setTodoList(it)
binding.rvTodoList.scrollToPosition(it.lastIndex)
}
adapter.notifyDataSetChanged()
val verticalSpaceItemDecoration = VerticalSpaceItemDecoration(16)
binding.rvTodoList.addItemDecoration(verticalSpaceItemDecoration)
binding.btnInput.setOnClickListener {
val input = binding.etTodoInput.text.toString()
val dataFormat = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
val currentTime = dataFormat.format(System.currentTimeMillis())
if(input.isNotEmpty()){
val todo = Todo(
content = input,
timestamp = currentTime
)
viewModel.insert(todo)
binding.etTodoInput.text.clear()
hideKeyBoard()
} else {
Toast.makeText(this, "Please write a to-do item", Toast.LENGTH_SHORT).show()
}
}
}
override fun onDeleteButtonClickListener(todo: Todo) {
val builder = AlertDialog.Builder(this)
builder.setMessage("Are you sure you want to delete this to-do?")
.setCancelable(false)
.setPositiveButton("Yes"){dialog, _ ->
viewModel.delete(todo)
dialog.dismiss()
}
.setNegativeButton("No"){dialog, _ ->
dialog.dismiss()
}
val alert = builder.create()
alert.show()
}
private fun hideKeyBoard(){
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(currentFocus?.windowToken, 0)
}
}
기본적으로 위에서 만든 클래스를 연결하기 위한 코드들이랑 몇몇 자투리 기능을 구현하였다.
- onDeletButtonClikcListener : 삭제 버튼을 누르면 정말로 삭제할 것인지에 대한 확인을 묻는 확인창이 뜨고, Yes 버튼을 누르면 삭제가 되도록 제작하였다.
- hideKeyBoard : Todo을 입력하면 입력 키보드가 자동으로 내려가도록 제작하였다.
[TodoAdapter.kt]
package com.example.todolist.ui
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.example.todolist.databinding.ItemRecyclerBinding
import com.example.todolist.domain.Todo
class TodoAdapter(private val context: Context, private val listener: OnTodoItemClickListener): RecyclerView.Adapter<TodoAdapter.TodoViewHolder>() {
interface OnTodoItemClickListener{
fun onDeleteButtonClickListener(todo: Todo)
}
private var todoList: List<Todo> = emptyList()
inner class TodoViewHolder(private val binding: ItemRecyclerBinding) : RecyclerView.ViewHolder(binding.root){
fun bind(todo: Todo, listener: OnTodoItemClickListener){
binding.tvTodo.text = todo.content
binding.tvTime.text = todo.timestamp
binding.btnDelete.setOnClickListener{
listener.onDeleteButtonClickListener(todo)
}
}
}
fun setTodoList(newTodoList: List<Todo>){
this.todoList = newTodoList
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TodoViewHolder {
val binding = ItemRecyclerBinding.inflate(LayoutInflater.from(context), parent, false)
return TodoViewHolder(binding)
}
override fun onBindViewHolder(holder: TodoViewHolder, position: Int) {
val todo = todoList[position]
holder.bind(todo, listener)
}
override fun getItemCount(): Int {
return todoList.size
}
}
기본적인 RecyclerView의 어댑터이다.
[VerticalSpaceItemDecoration.kt]
package com.example.todolist.ui
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
class VerticalSpaceItemDecoration(private val verticalSpaceHeight: Int): RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
outRect.bottom = verticalSpaceHeight
}
}
각 아이템 간의 간격을 만들어 주기 위해 작성한 클래스이다.
기본적으로 리사이클러 뷰를 만들면 아이템마다 다닥다닥 붙어있지만, 좀 더 이쁘게 만들기 위해 작성하였다.
5. 후기
전체적인 구현은 간단한 편이다. 실제로 기본적인 구현으로 비슷한 소스들이 인터넷 이곳저곳에 널려있다.
여기서 좀 더 최적화 작업을 하겠다고, 괜히 이상한 짓만 안했으면 금방 끝났을 것 같은데. 이상한 시도를 이것저것 하느라 꼬박 하루가 걸렸다.
뷰바인딩이랑 데이터 바인딩이랑 중복 적용되게 만들어서 오류 잡느라 4시간은 쓴 것 같다.
전체 코딩은 아래 링크에 달아두었다.
하나하나 차분히 공부하면서 도움이 되었으면 좋겠습니다.
GitHub - geonunggoodboy/ToDoList
Contribute to geonunggoodboy/ToDoList development by creating an account on GitHub.
github.com
'Kotlin > 개인 프로젝트' 카테고리의 다른 글
날씨 앱 제작: 전체적인 작동 구조 고안 (0) | 2023.03.08 |
---|---|
날씨 정보 앱 제작 : 사전 연습 (0) | 2023.02.27 |
개인 프로젝트 : 코틀린으로 To-do List - Room Database 이용하기 (0) | 2023.02.19 |