이번엔 searchResult 결과를 UI에 표시하기 위한 리사이클러뷰를 설정하겠습니다. 여기서는 속도가 빠른 ListAdapter를 사용할 건데요, ListAdapter에 대해서는 보강 이론 섹션의 ListAdapter 기초를 참고하시기 바랍니다.
Book 클래스에는 여러가지 데이터가 있는데요, 여기서는 타이틀, 저자, 출판사, 출판일 그리고 책 이미지 4가지 데이터를 표시하도록 하겠습니다. 우선은 이미지를 표시하기 위한 Coil Dependency를 추가합니다.
// Coil
implementation 'io.coil-kt:coil:2.0.0-rc03'
다음은 리사이클러 뷰홀더의 화면이 될 item_book_preview.xml 파일을 작성합니다. 책 이미지와 제목, 저자, 출간일을 표시하도록 할 겁니다.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp">
<ImageView
android:id="@+id/iv_article_image"
android:layout_width="60dp"
android:layout_height="87dp"
android:scaleType="fitXY"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:maxLines="2"
android:text="TITLE"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/iv_article_image"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_author"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="AUTHOR"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/iv_article_image"
app:layout_constraintTop_toBottomOf="@+id/tv_title" />
<TextView
android:id="@+id/tv_datetime"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="DATETIME"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/iv_article_image"
app:layout_constraintTop_toBottomOf="@+id/tv_author" />
</androidx.constraintlayout.widget.ConstraintLayout>
그리고 Book의 데이터와 xml을 연결하는 ViewHolder 클래스를 ui/adapter 아래에 작성합니다. author는 List 타입이므로 좌우의 [,]를 삭제했고, datetime은 null이 들어오는 경우가 있어서 방어적 코딩을 했습니다. Book 클래스에서 datetime을 nullable로 지정하여 널 처리를 해도 됩니다.
class BookSearchViewHolder(
private val binding: ItemBookPreviewBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(book: Book) {
val author = book.authors.toString().removeSurrounding("[", "]")
val publisher = book.publisher
// 값이 없는 경우가 있어서 방어적 코딩을 수행함
val date = if (book.datetime.isNotEmpty()) book.datetime.substring(0, 10) else ""
itemView.apply {
binding.ivArticleImage.load(book.thumbnail)
binding.tvTitle.text = book.title
binding.tvAuthor.text = "$author | $publisher"
binding.tvDatetime.text = date
}
}
}
다음은 이 뷰홀더를 재이용하는 어댑터를 작성합니다. DiffUtil 작동을 위한 콜백을 설정하고 onBindViewHolder에서 데이터를 바인딩시켜주면 됩니다. ListAdapter의 구체적인 작동방식에 대해서는 보강 이론의 ListAdapter 기초를 참고하시기 바랍니다.
class BookSearchAdapter : ListAdapter<Book, BookSearchViewHolder>(BookDiffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookSearchViewHolder {
return BookSearchViewHolder(
ItemBookPreviewBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
}
override fun onBindViewHolder(holder: BookSearchViewHolder, position: Int) {
val book = currentList[position]
holder.bind(book)
}
// DiffUtil 콜백
companion object {
private val BookDiffCallback = object : DiffUtil.ItemCallback<Book>() {
override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean {
return oldItem.isbn == newItem.isbn
}
override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean {
return oldItem == newItem
}
}
}
}
이제 SearchFragment의 UI를 구성하겠습니다. 우선은 Recyclerview Dependency를 추가하고 다음과 같이 화면을 작성합니다.
// Recyclerview
implementation 'androidx.recyclerview:recyclerview:1.2.1'
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.view.SearchFragment">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tl_search"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="Search.."
android:padding="4dp"
app:endIconMode="clear_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_search"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:inputType="textAutoComplete" />
</com.google.android.material.textfield.TextInputLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_search_result"
android:layout_width="0dp"
android:layout_height="0dp"
android:scrollbars="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tl_search" />
</androidx.constraintlayout.widget.ConstraintLayout>
TextInputLayout을 써서 검색어를 입력할 수 있는 창을 준비해 주었습니다. endIconMode 속성을 사용해서 EditText 뷰 오른쪽 끝단에 전체 텍스트를 삭제하는 버튼을 표시하게 했습니다. 나머지 화면은 RecyclerView로 채워 주었습니다.
그리고 프래그먼트의 코드를 작성하면 되는데요, 우선은 MainActivity에서 초기화 한 뷰모델을 가져옵니다.
class SearchFragment : Fragment() {
...
+ private lateinit var bookSearchViewModel: BookSearchViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
+ bookSearchViewModel = (activity as MainActivity).bookSearchViewModel
}
}
다음은 리사이클러뷰를 설정합니다. 데이터를 세로로 표시하는 LinearLayoutManager를 사용했고 DividerItemDecoration을 이용해 아이템 사이에 가로줄을 표시하도록 했습니다.
class SearchFragment : Fragment() {
...
private lateinit var bookSearchViewModel: BookSearchViewModel
+ private lateinit var bookSearchAdapter: BookSearchAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bookSearchViewModel = (activity as MainActivity).bookSearchViewModel
+ setupRecyclerView()
}
+ private fun setupRecyclerView() {
+ bookSearchAdapter = BookSearchAdapter()
+ binding.rvSearchResult.apply {
+ setHasFixedSize(true)
+ layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
+ addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
+ adapter = bookSearchAdapter
+ }
+ }
}
다음은 searchBooks에서 EditText의 동작을 정의합니다. addTextChangedListener를 써서 EditText에 텍스트가 입력되면 그 값을 ViewModel에 전달한 뒤, ViewModel의 searchBooks가 실행되도록 합니다. 이 때 값이 입력되자마자 실행하지 말고 사람의 입력시간을 고려하여 검색실행까지 약간의 딜레이를 주도록 합니다. 여기서는 딜레이를 100 ms로 설정하였습니다.
object Constants {
+ const val SEARCH_BOOKS_TIME_DELAY = 100L
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
+ searchBooks()
}
+private fun searchBooks() {
+ var startTime = System.currentTimeMillis()
+ var endTime: Long
+
+ binding.etSearch.addTextChangedListener { text: Editable? ->
+ endTime = System.currentTimeMillis()
+ if (endTime - startTime >= SEARCH_BOOKS_TIME_DELAY) {
+ text?.let {
+ val query = it.toString().trim()
+ if (query.isNotEmpty()) {
+ bookSearchViewModel.searchBooks(query)
+ }
+ }
+ }
+ startTime = endTime
+ }
+}
이제 EditText의 값이 변할 때마다 BookSearchViewModel 내부의 searchResult 값이 갱신되게 됩니다. 그러면 UI에서 값이 갱신될 때마다 리사이클러뷰 어댑터를 갱신하면 됩니다.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
+ bookSearchViewModel.searchResult.observe(viewLifecycleOwner) { response ->
+ val books = response.documents
+ bookSearchAdapter.submitList(books)
+ }
}
실행을 시켜보면 검색어가 새로 입력될 때마다 해당되는 검색 결과가 표시되게 됩니다. 이 때 Logcat을 보면 네트워크 통신시 주고받는 패킷을 okHttp 인터셉터가 가로채서 보여주는 것도 확인할 수 있습니다.
마지막으로 ViewModel에 SavedStateHandle 을 적용해 보도록 하겠습니다. 여기서는 우리가 EditText에 입력한 query값을 저장하게 할 겁니다. EditText는 android:saveEnabled가 기본적으로 활성화되어 있기 때문에 ViewModel과 연동하지 않아도 액티비티 재생성시는 값이 유지되지만 앱 재생성에는 대응하지 못합니다. 우선은 savedstate 라이브러리를 추가합니다.
// Lifecycle
+implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.4.1'
그리고 ViewModel에 다음 코드를 추가합니다. 저장 및 로드에 사용할 SAVE_STATE_KEY를 정의하고 쿼리 보존에 사용할 query 변수를 정의합니다. Backing fields를 사용해서 query의 값이 변화하면 그 값을 반영하고 savedstate에 저장하도록 했습니다. 그리고 ViewModel을 초기화할 때 query의 초기값을 savedstate에서 가져오도록 하면 됩니다.
class BookSearchViewModel(
private val bookSearchRepository: BookSearchRepository,
+ private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
...
+ // SavedState
+ var query = String()
+ set(value) {
+ field = value
+ savedStateHandle.set(SAVE_STATE_KEY, value)
+ }
+ init {
+ query = savedStateHandle.get<String>(SAVE_STATE_KEY) ?: ""
+ }
+ companion object {
+ private const val SAVE_STATE_KEY = "query"
+ }
}
ViewModel에 savedStateHandle 생성자를 추가했으니 BookSearchViewModelProviderFactory가 AbstractSavedStateViewModelFactory를 상속받도록 변경하여 줍니다.
@Suppress("UNCHECKED_CAST")
class BookSearchViewModelProviderFactory(
private val bookSearchRepository: BookSearchRepository,
owner: SavedStateRegistryOwner,
defaultArgs: Bundle? = null,
) : AbstractSavedStateViewModelFactory(owner, defaultArgs) {
override fun <T : ViewModel?> create(
key: String,
modelClass: Class<T>,
handle: SavedStateHandle,
): T {
if (modelClass.isAssignableFrom(BookSearchViewModel::class.java)) {
return BookSearchViewModel(bookSearchRepository, handle) as T
}
throw IllegalArgumentException("ViewModel class not found")
}
}
그리고 MainActivity에서 SavedStateRegistryOwner를 전달해줍니다.
val factory = BookSearchViewModelProviderFactory(bookSearchRepository, this)
마지막으로 SearchFragment의 searchBooks에서 query를 저장하고 불러오는 코드를 추가합니다.
private fun searchBooks() {
var startTime = System.currentTimeMillis()
var endTime: Long
+ binding.etSearch.text =
+ Editable.Factory.getInstance().newEditable(bookSearchViewModel.query)
binding.etSearch.addTextChangedListener { text: Editable? ->
endTime = System.currentTimeMillis()
if (endTime - startTime >= SEARCH_BOOKS_TIME_DELAY) {
text?.let {
val query = it.toString().trim()
if (query.isNotEmpty()) {
bookSearchViewModel.searchBooks(query)
+ bookSearchViewModel.query = query
}
}
}
startTime = endTime
}
}
이제 adb 명령으로 앱을 강제로 종료시켜봐도 쿼리값은 그대로 남아있는 것을 확인할 수 있습니다. 이렇게 해서 Retrofit을 통해 API에 데이터를 요청하고 받아온 결과를 화면에 표시하는 Android App Architecture 구조의 앱을 만들어 보았습니다.