Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals: DiffUtil and data binding with RecyclerView 부터.
Useful Shortcuts
- Refactor inline: Right-click on the property name, choose Refactor > Inline, or
Control+Command+N
(Option+Command+N
on a Mac)
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
- Need to listen to and receive the click and dtermine which item has been clicked
- 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)
}
}
Two ways to add header in the list.
- Modify the adapter to use a different
ViewHolder
for the header
- 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 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