Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals: Use LiveData to control button states 부터.
Use LiveData to control button states
// ViewModel
private val _navigateToSleepQuality = MutableLiveData<SleepNight>()
val navigateToSleepQuality: LiveData<SleepNight>
get() = _navigateToSleepQuality
fun doneNavigating() {
_navigateToSleepQuality.value = null
}
fun onStopTracking() {
viewModelScope.launch {
// ...
_navigateToSleepQuality.value = oldNight
}
}
// Fragment
sleepTrackerViewModel.navigateToSleepQuality.observe(this, Observer { night ->
night?.let {
// Passing the current nightId to the sleep quality fragment.
this.findNavController()
.navigate(SleepTrackerFragmentDirections.actionSleepTrackerFragmentToSleepQualityFragment(night.nightId))
sleepTrackerViewModel.doneNavigating()
}
})
Record the sleep quality
// SleepQualityViewModel.kt
class SleepQualityViewModel(
private val sleepNightKey: Long = 0L,
val database: SleepDatabaseDao) : ViewModel() {
// to navigate back to the Tracker Fragment
private val _navigateToSleepTracker = MutableLiveData<Boolean?>()
val navigateToSleepTracker: LiveData<Boolean?>
get() = _navigateToSleepTracker
fun doneNavigating() {
_navigateToSleepTracker.value = null
}
}
fun onSetSleepQuality(quality: Int) {
viewModelScope.launch {
val tonight = database.get(sleepNightKey) ?: return@launch
tonight.sleepQuality = quality
database.update(tonight)
// Setting this state variable to true will alert the observer and trigger navigation.
_navigateToSleepTracker.value = true
}
}
Then, add ViewModelFactory to compose instance.
class SleepQualityViewModelFactory(
private val sleepNightKey: Long,
private val dataSource: SleepDatabaseDao) : ViewModelProvider.Factory {
@Suppress("unchecked_cast")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(SleepQualityViewModel::class.java)) {
return SleepQualityViewModel(sleepNightKey, dataSource) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
Then, update the fragment that need to accept the arguments.
<data>
<variable
name="sleepQualityViewModel"
type="com.example.android.trackmysleepquality.sleepquality.SleepQualityViewModel" />
</data>
// onCreateView() in Fragment
val arguments = SleepQualityFragmentArgs.fromBundle(requireArguments())
val dataSource = SleepDatabase.getInstance(application).sleepDatabaseDao
val viewModelFactory = SleepQualityViewModelFactory(arguments.sleepNightKey, dataSource)
val sleepQualityViewModel = ViewModelProvider(this, viewModelFactory).get(SleepQualityViewModel::class.java)
binding.sleepQualityViewModel = sleepQualityViewModel
// Watch the state and navigate
sleepQualityViewModel.navigateToSleepTracker.observe(this, Observer {
if (it == true) { // Observed state is true.
this.findNavController().navigate(
SleepQualityFragmentDirections.actionSleepQualityFragmentToSleepTrackerFragment())
sleepQualityViewModel.doneNavigating()
}
})
Then, add click handler on the xml.
android:onClick="@{() -> sleepQualityViewModel.onSetSleepQuality(5)}"
If the update doesn't reflect on the app, clear the cache (File > Invalidate Caches / Restart).
Control visibility
android:enabled="@{sleepTrackerViewModel.startButtonVisible}"
android:enabled="@{sleepTrackerViewModel.stopButtonVisible}"
android:enabled="@{sleepTrackerViewModel.clearButtonVisible}"
// ViewModel
val startButtonVisible = Transformations.map(tonight) {
it == null
}
val stopButtonVisible = Transformations.map(tonight) {
it != null
}
val clearButtonVisible = Transformations.map(nights) {
it?.isNotEmpty()
}
Use Sanckbar
Sanckbar is a widget that provides brief feedback about an operation.
// ViewModel
private var _showSnackbarEvent = MutableLiveData<Boolean>()
val showSnackBarEvent: LiveData<Boolean>
get() = _showSnackbarEvent
fun doneShowingSnackbar() {
_showSnackbarEvent.value = false
}
// in onClear
fun onClear() {
viewModelScope.launch {
clear()
tonight.value = null
_showSnackbarEvent.value = true // add like this
}
}
// Fragment
sleepTrackerViewModel.showSnackBarEvent.observe(this, Observer {
if (it == true) { // Observed state is true.
Snackbar.make(
requireActivity().findViewById(R.id.content),
getString(R.string.cleared_message),
Snackbar.LENGTH_SHORT // How long to display the message.
).show()
sleepTrackerViewModel.doneShowingSnackbar()
}
})
RecyclerView
RecyclerView
handles the current visible items in the list efficienctly through adapter pattern.
[[[[item] -> [ Adapter ] -> [ Screen ]
↓
[ ViewHolder ]
- Data
- A
RecyclerView
instance as container - A layout for one item of data
- A layout manager
- A view holder that extended from
ViewHolder
. It holds one item. - An adapter connects data to the
RecyclerView
and adapts the data toViewHolder
.
Implement
Add RecyclerView
under ConstraintView
in Fragment.
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/sleep_list"
android:layout_width="0dp"
android:layout_height="0dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toTopOf="@+id/clear_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/stop_button" />
Add a dependency in app-level build.gradle
.
implementation 'androidx.recyclerview:recyclerview:1.0.0'
Note: Every RecyclerView
needs a layout manager that tells it how to position items in the list.
Then, create view holder for each item. Create text_item_view.xml
layout.
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:textSize="24sp" />
Open Util.kt
and add TextItemViewHolder
for now.
class TextItemViewHolder(val textView: TextView): RecyclerView.ViewHolder(textView)
Add SleepNightAdapter
in the sleeptracker
package.
class SleepNightAdapter : RecyclerView.Adapter<TextItemViewHolder>() {
var data = listOf<SleepNight>()
set(value) {
field = value
notifyDataSetChanged()
}
override fun getItemCount() = data.size
// data injection in each view holder
override fun onBindViewHolder(holder: TextItemViewHolder, position: Int) {
val item = data[position]
holder.textView.text = item.sleepQuality.toString()
// Each conditions explicit to states because the `RecyclerView` reuse the view holder.
if (item.sleepQuality <= 1) {
holder.textView.setTextColor(Color.RED) // red
} else {
// reset
holder.textView.setTextColor(Color.BLACK) // black
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TextItemViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
// parent view group is `RecyclerView`
val view = layoutInflater.inflate(
R.layout.text_item_view,
parent,
false) as TextView
return TextItemViewHolder(view)
}
}
Add the adapter in the fragment.
// onCreateView() in the fragment
val adapter = SleepNightAdapter()
binding.sleepList.adapter = adapter
Then, chanage nights
to public in the ViewModel.
val nights = database.getAllNights()
Then, add observer in the Fragment.
sleepTrackerViewModel.nights.observe(viewLifecycleOwner, Observer {
it?.let {
adapter.data = it
}
})
Extend ViewHolder
ViewHolder
provides itemView
and RecyclerView
use this property to populate the data.
Create the item layout named list_item_sleep_night.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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/quality_image"
android:layout_width="@dimen/icon_size"
android:layout_height="60dp"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@drawable/ic_sleep_5" />
<TextView
android:id="@+id/sleep_length"
android:layout_width="0dp"
android:layout_height="20dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/quality_image"
app:layout_constraintTop_toTopOf="@+id/quality_image"
tools:text="Wednesday" />
<TextView
android:id="@+id/quality_string"
android:layout_width="0dp"
android:layout_height="20dp"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="@+id/sleep_length"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/sleep_length"
app:layout_constraintTop_toBottomOf="@+id/sleep_length"
tools:text="Excellent!!!" />
</androidx.constraintlayout.widget.ConstraintLayout>
Add util functions.
private val ONE_MINUTE_MILLIS = TimeUnit.MILLISECONDS.convert(1, TimeUnit.MINUTES)
private val ONE_HOUR_MILLIS = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS)
fun convertDurationToFormatted(startTimeMilli: Long, endTimeMilli: Long, res: Resources): String {
val durationMilli = endTimeMilli - startTimeMilli
val weekdayString = SimpleDateFormat("EEEE", Locale.getDefault()).format(startTimeMilli)
return when {
durationMilli < ONE_MINUTE_MILLIS -> {
val seconds = TimeUnit.SECONDS.convert(durationMilli, TimeUnit.MILLISECONDS)
res.getString(R.string.seconds_length, seconds, weekdayString)
}
durationMilli < ONE_HOUR_MILLIS -> {
val minutes = TimeUnit.MINUTES.convert(durationMilli, TimeUnit.MILLISECONDS)
res.getString(R.string.minutes_length, minutes, weekdayString)
}
else -> {
val hours = TimeUnit.HOURS.convert(durationMilli, TimeUnit.MILLISECONDS)
res.getString(R.string.hours_length, hours, weekdayString)
}
}
}
fun convertNumericQualityToString(quality: Int, resources: Resources): String {
var qualityString = resources.getString(R.string.three_ok)
when (quality) {
-1 -> qualityString = "--"
0 -> qualityString = resources.getString(R.string.zero_very_bad)
1 -> qualityString = resources.getString(R.string.one_poor)
2 -> qualityString = resources.getString(R.string.two_soso)
4 -> qualityString = resources.getString(R.string.four_pretty_good)
5 -> qualityString = resources.getString(R.string.five_excellent)
}
return qualityString
}
Replace adapter class with the new nested view holder.
class SleepNightAdapter : RecyclerView.Adapter<SleepNightAdapter.ViewHolder>() {
var data = listOf<SleepNight>()
set(value) {
field = value
notifyDataSetChanged()
}
override fun getItemCount() = data.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = data[position]
val res = holder.itemView.context.resources
holder.sleepLength.text = convertDurationToFormatted(item.startTimeMilli, item.endTimeMilli, res)
holder.quality.text= convertNumericQualityToString(item.sleepQuality, res)
holder.qualityImage.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
})
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val view = layoutInflater.inflate(
R.layout.list_item_sleep_night,
parent,
false)
return ViewHolder(view)
}
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val sleepLength: TextView = itemView.findViewById(R.id.sleep_length)
val quality: TextView = itemView.findViewById(R.id.quality_string)
val qualityImage: ImageView = itemView.findViewById(R.id.quality_image)
}
}
Note: If the error occurs with the id, check the correct layout inflated.
Clean up
Use Refactor > Extract > Function by selecting function body except the assignment.
Put the cursor on the param and Alt+Enter
(Option+Enter
on Mac). Then, Convert parameter to receiver or Move to companion object.
Then, move the function into the class, and change appropriate access modifier.
Result:
class SleepNightAdapter : RecyclerView.Adapter<SleepNightAdapter.ViewHolder>() {
var data = listOf<SleepNight>()
set(value) {
field = value
notifyDataSetChanged()
}
override fun getItemCount() = data.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = data[position]
holder.bind(item)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder.from(parent)
}
class ViewHolder private constructor(itemView: View) : RecyclerView.ViewHolder(itemView) {
val sleepLength: TextView = itemView.findViewById(R.id.sleep_length)
val quality: TextView = itemView.findViewById(R.id.quality_string)
val qualityImage: ImageView = itemView.findViewById(R.id.quality_image)
fun bind(item: SleepNight) {
val res = itemView.context.resources
sleepLength.text = convertDurationToFormatted(item.startTimeMilli, item.endTimeMilli, res)
quality.text = convertNumericQualityToString(item.sleepQuality, res)
qualityImage.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
})
}
companion object {
fun from(parent: ViewGroup): ViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val view = layoutInflater.inflate(
R.layout.list_item_sleep_night,
parent,
false)
return ViewHolder(view)
}
}
}
}
다음 챕터: Android Kotlin Fundamentals: DiffUtil and data binding with RecyclerView