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 ]

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

색상을 바꿔요

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

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