Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals: DiffUtil and data binding with RecyclerView 부터.

Useful Shortcuts

DiffUtil

When RecyclerView is being updated by notifyDataSetChanged() via SleepNightAdapter, the entire list is invalid then redraw every items in the list.

DiffUtil calculates the difference between the old list and the new one (Eugene W. Myers's difference algorithm) for efficiency.

Add a DiffCallback class for the comparison.

// SleepNightAdapter.kt

class SleepNightDiffCallback : DiffUtil.ItemCallback<SleepNight>() {
  override fun areItemsTheSame(oldItem: SleepNight, newItem: SleepNight): Boolean {
    return oldItem.nightId == newItem.nightId
  }

  override fun areContentsTheSame(oldItem: SleepNight, newItem: SleepNight): Boolean {
    return oldItem == newItem
  }
}

Then, Update the adapter to ListAdapter so that the adapter utilizes the DiffCallback class.

class SleepNightAdapter : ListAdapter<SleepNight, SleepNightAdapter.ViewHolder>(SleepNightDiffCallback()) {
  // ...
}

Then, remove data, getItemCount() because ListAdapter provides these features. Update onBindViewHolder() with getItem(position).

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
  val item = getItem(position)
  holder.bind(item)
}

Update the adapter observer in the fragment with submitList().

// SleepTrackerFragment.kt
sleepTrackerViewModel.nights.observe(viewLifecycleOwner, Observer {
  it?.let {
    adapter.submitList(it)
  }
})

Add data binding to the layout file.

Put the cursor on the ConstraintLayout tag and press Alt+Enter (Option+Enter on a Mac). The intention menu (the "quick fix" menu) opens. Select Convert to data binding layout.

<!-- list_item_sleep_night.xml -->
<data>
  <variable
    name="sleep"
    type="com.example.android.trackmysleepquality.database.SleepNight"/>
</data>

Then, rebuild with clean project. Now, the data binding in the list adapter need to update.

// SleepNightAdapter.kt

companion object {
  fun from(parent: ViewGroup): ViewHolder {
    val layoutInflater = LayoutInflater.from(parent.context)
    val binding = ListItemSleepNightBinding.inflate(layoutInflater, parent, false)

    // put the cursor on binding, Alt+Enter then select
    //  "Change parameter 'itemView' type of primary constructor
    //   of class 'ViewHolder' to 'ListItemSleepNightBinding'"
    return ViewHolder(binding)
  }
}

As a result,

class ViewHolder private constructor(itemView: View) : RecyclerView.ViewHolder(itemView) {}

will be like this.

class ViewHolder private constructor(itemView: ListItemSleepNightBinding) : RecyclerView.ViewHolder(itemView) {}

Still, it needs to be updated to the currect name and format.

class ViewHolder private constructor(val binding: ListItemSleepNightBinding) : RecyclerView.ViewHolder(binding.root) {}

// Note: not `binding`, `val binding`

Then, update all findViewById() to the members in the binding.

val sleepLength: TextView = binding.sleepLength
val quality: TextView = binding.qualityString
val qualityImage: ImageView = binding.qualityImage

// **Refactor > Inline** would be better for here.

Binding Adapters

With a @BindingAdapter annotation, the view can handle different types of the data from the data binding. By using this, the adapter don't need to know the implementation of the data transformation.

Create BindingUtils.kt and add these extendion functions.

@BindingAdapter("sleepDurationFormatted")
fun TextView.setSleepDurationFormatted(item: SleepNight) {
  text = convertDurationToFormatted(item.startTimeMilli, item.endTimeMilli, context.resources)
}

@BindingAdapter("sleepQualityString")
fun TextView.setSleepQualityString(item: SleepNight) {
    text = convertNumericQualityToString(item.sleepQuality, context.resources)
}

@BindingAdapter("sleepImage")
fun ImageView.setSleepImage(item: SleepNight) {
  setImageResource(when (item.sleepQuality) {
    0 -> R.drawable.ic_sleep_0
    1 -> R.drawable.ic_sleep_1
    2 -> R.drawable.ic_sleep_2
    3 -> R.drawable.ic_sleep_3
    4 -> R.drawable.ic_sleep_4
    5 -> R.drawable.ic_sleep_5
    else -> R.drawable.ic_sleep_active
  })
}

Then, update bind() in the adapter class.

// SleepNightAdapter.kt
fun bind(item: SleepNight) {
  binding.sleep = item
  binding.executePendingBindings()
}

Update the layout file.

<!-- list_item_sleep_night.xml -->
<ImageView
  android:id="@+id/quality_image"
  ...
  app:sleepImage="@{sleep}" />
<TextView
  android:id="@+id/sleep_length"
  ...
  app:sleepDurationFormatted="@{sleep}" />
<TextView
  android:id="@+id/quality_string"
  ...
  app:sleepQualityString="@{sleep}" />

LayoutManager

LayoutManager gives an ability to change the depending views in the RecyclerView. e.g. Changing LinearLayout to GridLayout.

One of the main strengths of RecyclerView is that it lets you use layout managers to control and modify your layout strategy. A LayoutManager manages how the items in the RecyclerView are arranged.

<androidx.recyclerview.widget.RecyclerView
  android:id="@+id/sleep_list"
  ...
  app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>

Remove app:layoutManager from the layout file.

Add GridLayoutManager at OnCreateView() in SleepTrackerFragment.kt.

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
    savedInstanceState: Bundle?): View? {

  // ...
  val manager = GridLayoutManager(activity, 3)
  binding.sleepList.layoutManager = manager
  return binding.root
}

To match the style with a new grid layout, change list_item_sleep_night.xml.

<ImageView
  android:id="@+id/quality_image"
  android:layout_width="@dimen/icon_size"
  android:layout_height="60dp"
  android:layout_marginTop="8dp"
  android:layout_marginBottom="8dp"
  app:layout_constraintBottom_toBottomOf="parent"
  app:layout_constraintEnd_toEndOf="parent"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toTopOf="parent"
  tools:srcCompat="@drawable/ic_sleep_5"
  app:sleepImage="@{sleep}"/>

<TextView
  android:id="@+id/quality_string"
  android:layout_width="0dp"
  android:layout_height="20dp"
  android:layout_marginEnd="16dp"
  android:textAlignment="center"
  app:layout_constraintBottom_toBottomOf="parent"
  app:layout_constraintEnd_toEndOf="parent"
  app:layout_constraintHorizontal_bias="0.0"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toBottomOf="@+id/quality_image"
  app:sleepQualityString="@{sleep}"
  tools:text="Excellent!"/>
// Single vertical
val manager = GridLayoutManager(activity, 1)

// Horizontal per 5
val manager = GridLayoutManager(activity, 5, GridLayoutManager.HORIZONTAL, false)

Clickable RecyclerView

  1. Need to listen to and receive the click and dtermine which item has been clicked
  2. Need to respond to the click with an action

Handle clicks in the ViewModel, not ViewHolder because the viewmodel can access the data and determine the response.

Note: Also, the click event is able to be placed in the RecyclerView.

Add SleepNightListener in SleepNightAdapter.kt.

class SleepNightListener(val clickListener: (sleepId: Long) -> Unit) {
  fun onClick(night: SleepNight) = clickListener(night.nightId)
}

Add the listener in the xml layout file and use it in the click event.

<data>
<!-- ... -->
  <variable
    name="clickListener"
    type="com.example.android.trackmysleepquality.sleeptracker.SleepNightListener" />
</data>

<androidx.constraintlayout.widget.ConstraintLayout
  ...
  android:onClick="@{() -> clickListener.onClick(sleep)}">
  <!-- ... -->

Then, update the adapter class.

// Add clickListener member
class SleepNightAdapter(val clickListener: SleepNightListener) : ListAdapter<SleepNight, SleepNightAdapter.ViewHolder>(SleepNightDiffCallback()) {

  // Pass the click listener
  override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    holder.bind(getItem(position), clickListener)
  }

  // ...
  class ViewHolder private constructor(val binding: ListItemSleepNightBinding) : RecyclerView.ViewHolder(binding.root) {

    // Update the function signature
    // Note: Refactor tool will catch this change
    fun bind(item: SleepNight, clickListener: SleepNightListener) {
      binding.sleep = item
      // Bind the listener into the layout
      binding.clickListener = clickListener
      binding.executePendingBindings()
    }
    // ...
  }
  // ...
}

Update the adapter at onCreateView() in the fragment.

val adapter = SleepNightAdapter(SleepNightListener { nightId ->
  Toast.makeText(context, "${nightId}", Toast.LENGTH_LONG).show()
})

Clean and rebuild the project if the update is not reflected.

Update the view model to handle the click event.

// SleepTrackerViewModel.kt
private val _navigateToSleepDetail = MutableLiveData<Long>()
val navigateToSleepDetail
  get() = _navigateToSleepDetail

fun onSleepNightClicked(id: Long) {
  _navigateToSleepDetail.value = id
}

fun onSleepDetailNavigated() {
  _navigateToSleepDetail.value = null
}

Then, update the fragment to trigger onSleepNightClicked().

val adapter = SleepNightAdapter(SleepNightListener { nightId ->
  sleepTrackerViewModel.onSleepNightClicked(nightId)
})

// add observer to handle the navigation
sleepTrackerViewModel.navigateToSleepDetail.observe(this, Observer { night ->
  night?.let {
  this.findNavController().navigate(
    SleepTrackerFragmentDirections
      .actionSleepTrackerFragmentToSleepDetailFragment(night))
  sleepTrackerViewModel.onSleepDetailNavigated()
  }
})

LiveData can be null so that SleepNight instance also can be null. Update BindingUtils.kt with an optional chaining.

@BindingAdapter("sleepQualityString")
fun TextView.setSleepQualityString(item: SleepNight?) {
  item?.let {
    text = convertNumericQualityToString(item.sleepQuality, context.resources)
  }
}

Header in RecyclerView

Two ways to add header in the list.

  1. Modify the adapter to use a different ViewHolder for the header
  2. Add a header into the dataset

Add DataItem to handle either SleepNight or Header. Add the class in the adapter file.

sealed class DataItem {
  abstract val id: Long
  data class SleepNightItem(val sleepNight: SleepNight): DataItem() {
    override val id = sleepNight.nightId
  }

  object Header: DataItem() {
    override val id = Long.MIN_VALUE
  }
}

Create header.xml.

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/text"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:textAppearance="?android:attr/textAppearanceLarge"
    android:text="@string/header_text"
    android:padding="8dp" />

Add header_text resource.

<string name="header_text">Sleep Results</string>

Add TextViewHolder in the adapter class.

class TextViewHolder(view: View) : RecyclerView.ViewHolder(view) {
  companion object {
    fun from(parent: ViewGroup): TextViewHolder {
      val layoutInflater = LayoutInflater.from(parent.context)
      val view = layoutInflater.inflate(R.layout.header, parent, false)
      return TextViewHolder(view)
    }
  }
}

Then, update the adapter file.


private val ITEM_VIEW_TYPE_HEADER = 0
private val ITEM_VIEW_TYPE_ITEM = 1

// Update the code from SleepNight to DataItem
// Note: two types in ListAdapter are changed.
class SleepNightAdapter(val clickListener: SleepNightListener) : ListAdapter<DataItem, RecyclerView.ViewHolder>(SleepNightDiffCallback()) {
    
  // Add getItemViewType() to distinguish the type
  override fun getItemViewType(position: Int): Int {
    return when (getItem(position)) {
      is DataItem.Header -> ITEM_VIEW_TYPE_HEADER
      is DataItem.SleepNightItem -> ITEM_VIEW_TYPE_ITEM
    }
  }
  // ...
}

Update onCreateViewHolder() too. The logic will create different view holder based on the view type.

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
  return when (viewType) {
    ITEM_VIEW_TYPE_HEADER -> TextViewHolder.from(parent)
    ITEM_VIEW_TYPE_ITEM -> ViewHolder.from(parent)
    else -> throw ClassCastException("Unknown viewType ${viewType}")
  }
}

Update onBindViewHolder().

override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
  when (holder) {
    is ViewHolder -> {
      val nightItem = getItem(position) as DataItem.SleepNightItem
      holder.bind(nightItem.sleepNight, clickListener)
    }
  }
}

Update SleepNightDiffCallback.

class SleepNightDiffCallback : DiffUtil.ItemCallback<DataItem>() {

  override fun areItemsTheSame(oldItem: DataItem, newItem: DataItem): Boolean {
    return oldItem.id == newItem.id
  }

  @SuppressLint("DiffUtilEquals")
  override fun areContentsTheSame(oldItem: DataItem, newItem: DataItem): Boolean {
    return oldItem == newItem
  }
}

Add adding header function in the adapter class.

fun addHeaderAndSubmitList(list: List<SleepNight>?) {
  val items = when (list) {
    null -> listOf(DataItem.Header)
    else -> listOf(DataItem.Header) + list.map { DataItem.SleepNightItem(it) }
  }
  submitList(items)
}

Finally, replace submitList() in the fragment to addHeaderAndSubmitList().

sleepTrackerViewModel.nights.observe(viewLifecycleOwner, Observer {
  it?.let {
    adapter.addHeaderAndSubmitList(it)
  }
})

Use coroutines for list manipulations

Updating data should not be in the UI thread. Use coroutines for that.

private val adapterScope = CoroutineScope(Dispatchers.Default)

fun addHeaderAndSubmitList(list: List<SleepNight>?) {
  adapterScope.launch {
    val items = when (list) {
      null -> listOf(DataItem.Header)
      else -> listOf(DataItem.Header) + list.map { DataItem.SleepNightItem(it) }
    }
    withContext(Dispatchers.Main) {
      submitList(items)
    }
  }
}

Extend the header

Extend the header width by the position.

// SleepTrackerFragment.kt
val manager = GridLayoutManager(activity, 3)

manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
  override fun getSpanSize(position: Int) = when (position) {
    0 -> 3
    else -> 1
  }
}

다음 챕터: Android Kotlin Fundamentals: 8.1 Getting data from the internet

색상을 바꿔요

눈에 편한 색상을 골라보세요 :)

Darkreader 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.