신발에 발라먹어도 맛있다는 마법 소스.

준비물

  • 굴소스 2 스푼
  • 케찹 한 스푼
  • 다진마늘 한 스푼
  • 후추 가루 훅훅훅 다섯 번
  • 따임이나 허브 훅훅훅 세 번
  • 올리고당 한 스푼
  • 버터 두 스푼
  • 매실청 한 스푼
  • 굵은 고춧가루 한 스푼

조리

다 넣고 한번 부글부글 끓으면 끝! 참 쉬죠잉?

제주 향토 음식 갈치국.

준비물

  • 손질한 갈치
  • 표고버섯
  • 육수 낼것
  • 단호박
  • 다진 마늘
  • 청양고추
  • 국간장
  • 맛술
  • 소금
  • 후추

조리

  1. 육수: 그냥 물로 해도 맛있지만 다시팩이나 멸치육수를 내면 훨씬 깊은 맛을 낼수 있겠죠. 무를 넣고 푹 끓여주세요.
  2. 무가 투명하게 익기 시작하면 손질한 갈치와 표고버섯 넣기.
  3. 국간장 맛술 다진마늘 넣고 끓이기.
  4. 갈치 살이 익으면 단호박, 대파 넣기. 칼칼하게 고추도 넣으면 좋아요.

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

  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

모카포트로 커피를 추출했을 때 쓴 맛이 나기 쉽다. 아래 방법으로는 에스프레소 보다는 연하고 드립 커피보다는 진한 느낌으로 추출됨. 깔끔한 커피 추출하는 방법:

  1. 포트를 깨끗하게 씻는다: 남아 있는 커피 찌꺼기가 커피를 더 맛있게 한다는 말은 미신이라고
  2. 포트를 보관할 때 꽉 잠그지 않는다: 고무 밴드(개스킷) 빨리 닳는다고
  3. 커피를 약간 거칠게 간다: 에스프레소 보다 조금 굵게 간다. 파우더와 모래알 사이 느낌으로
  4. 커피를 루스하게 꽉 채운다 (탬핑하지 않는다)
  5. 뜨거운 물을 넣는다
  6. 조립해서 바로 중불에 올린다: 커피콩이 추출 전에 뜨거워지면 쓴 맛이 더 난다고
  7. 커피가 다 추출되면 (치치 소리가 날 때) 꺼내서 하단부에 찬물을 끼얹어 추출이 더 진행되지 않게 한다
  8. 기호에 맞게 마신다

(Make Great Coffee with a Moka Pot)

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 to 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

Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals: 06.1 Create a Room database 부터.

Room database

A database library that is part of Android Jetpack. (SQLite)

# Recommended architecture

[ [observer] UI Controller (activity/fragment) ]
       ↑        ↓
[ [LiveData] ViewModel ]
                ↓
      [ Repository ]
         ↓      ↓
[ Database ]  [ Network ]

Codelab dependencies:

// app level build.gradle
// Support libraries
implementation "androidx.appcompat:appcompat:1.2.0"
implementation "androidx.fragment:fragment:1.2.5"
implementation "androidx.constraintlayout:constraintlayout:2.0.0-rc1"

// Android KTX
implementation 'androidx.core:core-ktx:1.3.1'

// Room and Lifecycle dependencies
implementation "androidx.room:room-runtime:$room_version"
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"

// Kotlin Extensions and Coroutines support for Room
implementation "androidx.room:room-ktx:$room_version"

// Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutine_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutine_version"

// Navigation
implementation "android.arch.navigation:navigation-fragment-ktx:$navigationVersion"
implementation "android.arch.navigation:navigation-ui-ktx:$navigationVersion"

// Testing
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'

Create the entity

  • Entity: represents an object or concept, and its properties, to store in the database.
  • Query: a request for data or information from a database.

Define each entity as an annotated data class (Data access object, DAO).

Open SleepNight.kt in the .database package.

@Entity(tableName = "daily_sleep_quality_table")
data class SleepNight(
  @PrimaryKey(autoGenerate = true)
  var nightId: Long = 0L,

  @ColumnInfo(name = "start_time_milli")
  val startTimeMilli: Long = System.currentTimeMillis(),

  @ColumnInfo(name = "end_time_milli")
  var endTimeMilli: Long = startTimeMilli,

  @ColumnInfo(name = "quality_rating")
  var sleepQuality: Int = -1
)

Create the DAO

Open SleepDatabaseDao.kt in .database package.

@Dao
interface SleepDatabaseDao {
    @Insert
    fun insert(night: SleepNight)

    @Update
    fun update(night: SleepNight)

    @Query("SELECT * from daily_sleep_quality_table WHERE nightId = :key")
    fun get(key: Long): SleepNight?

    @Query("DELETE FROM daily_sleep_quality_table")
    fun clear()

    @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC LIMIT 1")
    fun getTonight(): SleepNight?

    @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC")
    fun getAllNights(): LiveData<List<SleepNight>>
}

Note: @Get gets one item. @Delete deletes one item.

Create database

Open SleepDatabase.kt in .database package.

@Database(
  entities = [SleepNight::class],
  version = 1,
  exportSchema = false
)
abstract class SleepDatabase : RoomDatabase() {
  abstract val sleepDatabaseDao: SleepDatabaseDao

  companion object {
    // it makes up-to-date, no caching
    @Volatile
    private var INSTANCE: SleepDatabase? = null

    fun getInstance(context: Context): SleepDatabase {
      // guarantee the singleton instance
      synchronized(this) {
        var instance = INSTANCE
        if (instance == null) {
          instance = Room.databaseBuilder(
              context.applicationContext,
              SleepDatabase::class.java,
              "sleep_history_database"
            )
            .fallbackToDestructiveMigration()
            .build()
          INSTANCE = instance
        }
        return instance
      }
    }
  }
}

Test the database.

// SleepDatabaseTest.kt in androidTest
@RunWith(AndroidJUnit4::class)
class SleepDatabaseTest {

  private lateinit var sleepDao: SleepDatabaseDao
  private lateinit var db: SleepDatabase

  @Before
  fun createDb() {
    val context = InstrumentationRegistry.getInstrumentation().targetContext
    // Using an in-memory database because the information stored here disappears when the
    // process is killed.
    db = Room.inMemoryDatabaseBuilder(context, SleepDatabase::class.java)
        // Allowing main thread queries, just for testing.
        .allowMainThreadQueries()
        .build()
    sleepDao = db.sleepDatabaseDao
  }

  @After
  @Throws(IOException::class)
  fun closeDb() {
    db.close()
  }

  @Test
  @Throws(Exception::class)
  fun insertAndGetNight() {
    val night = SleepNight()
    sleepDao.insert(night)
    val tonight = sleepDao.getTonight()
    assertEquals(tonight?.sleepQuality, -1)
  }
}

Run the test and check the result.

<merge> tag in layout

Eliminate redundant layouts when including layouts.

ViewModel with a database

// in SleepTrackerViewModel.kt
class SleepTrackerViewModel(
  val database: SleepDatabaseDao,
  application: Application) : AndroidViewModel(application) {
}
// in SleepTrackerViewModelFactory.kt
class SleepTrackerViewModelFactory(
    private val dataSource: SleepDatabaseDao,
    private val application: Application) : ViewModelProvider.Factory {
  @Suppress("unchecked_cast")
  override fun <T : ViewModel?> create(modelClass: Class<T>): T {
    if (modelClass.isAssignableFrom(SleepTrackerViewModel::class.java)) {
      return SleepTrackerViewModel(dataSource, application) as T
    }
    throw IllegalArgumentException("Unknown ViewModel class")
  }
}

Then, update the fragment.

// add after `binding` at `onCreateView()` in the fragment
// assemble the dependencies manually
val application = requireNotNull(this.activity).application
val dataSource = SleepDatabase.getInstance(application).sleepDatabaseDao
val viewModelFactory = SleepTrackerViewModelFactory(dataSource, application)
val sleepTrackerViewModel = ViewModelProvider(this, viewModelFactory)
        .get(SleepTrackerViewModel::class.java)

Add data binding for the viewModel at fragment xml file.

<data>
  <variable
    name="sleepTrackerViewModel"
    type="com.example.android.trackmysleepquality.sleeptracker.SleepTrackerViewModel" />
</data>

Register lifecycle owner of the binding and connect the viewModel.

// add after `binding` at `onCreateView()` in the fragment
binding.setLifecycleOwner(this)
binding.sleepTrackerViewModel = sleepTrackerViewModel

Coroutines

The logic can block the thread but coroutines make them suspend. It is good for long-running tasks because it is non-blocking and asynchronous via suspend.

  • Job: cancellable tasks
  • Dispatcher: Sends off coroutines to run on various threads
    • Dispatcher.Main
    • Dispatcher.IO
  • Scope: Defines the context in which the coroutine runs
    • CoroutineScope: Track all coroutines
      • ViewModelScope
      • LifecycleScope
      • liveData

Update DAO functions as suspend functions.

@Dao
interface SleepDatabaseDao {
  @Insert
  suspend fun insert(night: SleepNight)

  @Update
  suspend fun update(night: SleepNight)

  @Query("SELECT * from daily_sleep_quality_table WHERE nightId = :key")
  suspend fun get(key: Long): SleepNight?

  @Query("DELETE FROM daily_sleep_quality_table")
  suspend fun clear()

  @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC LIMIT 1")
  suspend fun getTonight(): SleepNight?

  @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC")
  fun getAllNights(): LiveData<List<SleepNight>>
}

Add dependencies if needed.

// app level build.gradle
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
// Kotlin Extensions and Coroutines support for Room
implementation "androidx.room:room-ktx:$room_version"

Init data from the ViewModel.

// SleepTrackerViewModel.kt
private var tonight = MutableLiveData<SleepNight?>()

init {
  initializeTonight()
}

private fun initializeTonight() {
  // Start coroutine in the `ViewModelScope`
  viewModelScope.launch {
    tonight.value = getTonightFromDatabase()
  }
}

private suspend fun getTonightFromDatabase(): SleepNight?
{
  var night = database.getTonight()
  if (night?.endTimeMilli != night?.startTimeMilli) {
    night = null
  }
  return night
}

Add click hander for the start button

// in the ViewModel
fun onStartTracking() {
  viewModelScope.launch { 
    val newNight = SleepNight()
    insert(newNight)
    tonight.value = getTonightFromDatabase()
  }
}

private suspend fun insert(night: SleepNight) {
  database.insert(night)
}

Then, add data binding on fragment xml file.

<Button
  android:id="@+id/start_button"
  ...
  android:onClick="@{() -> sleepTrackerViewModel.onStartTracking()}" />

Display the data

Room handles all LiveData magic. Transformation needed to show the data as an appropriated format.

// Util.kt
fun formatNights(nights: List<SleepNight>, resources: Resources): Spanned {
    val sb = StringBuilder()
    sb.apply {
        append(resources.getString(R.string.title))
        nights.forEach {
            append("<br>")
            append(resources.getString(R.string.start_time))
            append("\t${convertLongToDateString(it.startTimeMilli)}<br>")
            if (it.endTimeMilli != it.startTimeMilli) {
                append(resources.getString(R.string.end_time))
                append("\t${convertLongToDateString(it.endTimeMilli)}<br>")
                append(resources.getString(R.string.quality))
                append("\t${convertNumericQualityToString(it.sleepQuality, resources)}<br>")
                append(resources.getString(R.string.hours_slept))
                // Hours
                append("\t ${it.endTimeMilli.minus(it.startTimeMilli) / 1000 / 60 / 60}:")
                // Minutes
                append("${it.endTimeMilli.minus(it.startTimeMilli) / 1000 / 60}:")
                // Seconds
                append("${it.endTimeMilli.minus(it.startTimeMilli) / 1000}<br><br>")
            }
        }
    }
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        return Html.fromHtml(sb.toString(), Html.FROM_HTML_MODE_LEGACY)
    } else {
        return HtmlCompat.fromHtml(sb.toString(), HtmlCompat.FROM_HTML_MODE_LEGACY)
    }
}

Note: Spanned type is HTML-formatted string.

Add the member in the ViewModel so that returns formatted string.

// in the ViewModel
private val nights = database.getAllNights()

val nightsString = Transformations.map(nights) { nights ->
  formatNights(nights, application.resources)
}

Open the fragment xml file and add nightsString.

<TextView
  android:id="@+id/textview"
  ...
  android:text="@{sleepTrackerViewModel.nightsString}" />

Add the click handlers for the Stop and Clear button

// in the ViewModel
// for the stop button
fun onStopTracking() {
  viewModelScope.launch {
    val oldNight = tonight.value ?: return@launch
    oldNight.endTimeMilli = System.currentTimeMillis()
    update(oldNight)
  }
}

private suspend fun update(night: SleepNight) {
    database.update(night)
}

// for the clear button
fun onClear() {
   viewModelScope.launch {
       clear()
       tonight.value = null
   }
}

suspend fun clear() {
    database.clear()
}
<Button
  android:id="@+id/stop_button"
  ...
  android:onClick="@{() -> sleepTrackerViewModel.onStopTracking()}" />

...

<Button
  android:id="@+id/clear_button"
  ...
  android:onClick="@{() -> sleepTrackerViewModel.onClear()}" />

Pattern of the coroutine scopes

fun someWorkNeedsToBeDone {
  viewModelScope.launch {
    suspendFunction()
  }
}

suspend fun suspendFunction() {
  // Switch to the IO dispatcher
  withContext(Dispatchers.IO) {
    longrunningWork()
  }
}

// Using Room
fun someWorkNeedsToBeDone {
  viewModelScope.launch {
    suspendDAOFunction()
  }
}

suspend fun suspendDAOFunction() {
  // No need to specify the Dispatcher, Room uses Dispatchers.IO.
  longrunningDatabaseWork()
}

다음 챕터: Android Kotlin Fundamentals: Use LiveData to control button states

Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals: ViewModel 부터.

Useful Shortcuts

  • Override Methods: Choose Code > Override Methods, or CTRL+o
  • Comment: Choose Code > Comment with Line Comment or CTRL+/ (Command+/ on a Mac)

App architecture

Follow Android app architecture guidlines and Android Architecture Components. Similarly MVVM.

  • UI-controller: Activity or Fragment. Display data and capture OS/user events.
  • ViewModel: Hold all of the data needed for the UI and prepare it for display
  • ViewModelFactory: instantiates ViewModel

ViewModel

ViewModel class to store and manage UI-releated data in a lifecycle-conscious way. (safe for device-configuration changes via ViewModelFactory)

Add dependencies

// build.gradle (app-level)
dependencies {
  // ...

  //ViewModel
  implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
}

Then, sync.

Create GameViewModel in screens/game/ folder.

class GameViewModel : ViewModel() {
  init {
    Log.i("GameViewModel", "GameViewModel created!")
  }

  override fun onCleared() {
    super.onCleared()
    Log.i("GameViewModel", "GameViewModel destoryed!")
  }
}

Associate GameViewModel with the game fragment.

// in GameFragment.kt
private lateinit var viewModel: GameViewModel

ViewModelProvider create the ViewModel instance so that the view model survives during the configuration changes. The provider manages the lifecycle of the view model until the UI is destroyed.

Initialize the viewModel using ViewModelProvider.get().

Log.i("GameFragment", "Called ViewModelProvider.get")
viewModel = ViewModelProvider(this).get(GameViewModel::class.java)

Remember,

  • Fragment: Display fragment, capture user events, do not survive config changes
  • ViewModel: Hold data for fragment, should never contain references to any fragments/activities

Move all states from Fragment to ViewModel.

Pass data to next fragments.

private fun gameFinished() {
  Toast.makeText(activity, "Game has just finished", Toast.LENGTH_SHORT).show()
  val action = GameFragmentDirections.actionGameToScore()
  action.score = viewModel.score
  NavHostFragment.findNavController(this).navigate(action)
}

However, ViewModel is destoryed during the transition. It needs ViewModelFactory for that.

ViewModelFactory

Create ScoreViewModel.

class ScoreViewModel(finalScore: Int): ViewModel() {
  var score = finalScore
  init {
    Log.i("ScoreViewModel", "Final score is $finalScore")
  }
}

Create ScoreViewFactory and override the create() method.

class ScoreViewModelFactory(private val finalScore: Int) : ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(ScoreViewModel::class.java)) {
            return ScoreViewModel(finalScore) as T
        }
        throw IllegalArgumentException("Unknown viewModel class")
    }
}

Add members in the fragment.

private lateinit var viewModel: ScoreViewModel
private lateinit var viewModelFactory: ScoreViewModelFactory

Initialize viewModel using the factory from onCreateView().

// in `onCreateView()`
// init factory with given arguments via navigation
viewModelFactory = ScoreViewModelFactory(ScoreFragmentArgs.fromBundle(requireArguments()).score)
// get view model from the provider via factory
viewModel = ViewModelProvider(this, viewModelFactory)
  .get(ScoreViewModel::class.java)

Then, the fragment uses data from the viewModel.

binding.scoreText.text = viewModel.score.toString()

Parameterized constructor of viewModel is useful when the data should be right during initialization.

LiveData

Data objects that notify views when the underlying database changes. Set up "observers" to the activities or fragments. Also it is lifecycle-aware and watch only active ones.

  • LiveData is observable, which means that an observer is notified when the data held by the LiveData object changes.
  • LiveData holds data; LiveData is a wrapper that can be used with any data
  • LiveData is lifecycle-ware. For this, the observer is associated with LifecycleOwner so that is working with active state only.
// GameViewModel.kt
val word = MutableLiveData<String>()
val score = MutableLiveData<Int>()

init {
  word.value = ""
  score.value = 0
}

Update object references. minus() and plus() are null-safety manipulation.

// score--
score.value = (score.value)?.minus(1)

// score++
score.value = (score.value)?.plus(1)

// word = wordList.removeAt(0)
word.value = wordList.removeAt(0)

References in the fragment needs to update too.

// binding.wordText.text = viewModel.word
binding.wordText.text = viewModel.word.value

// action.score = viewModel.score
action.score = viewModel.score.value?:0

Attach observers to the LiveData objects

Note: fragment's lifecycle and fragment view's lifecycle is a bit different. Always set the observers in onCreateView() and pass viewLifecycleOwner to observers.

// in onCreateView()

viewModel.score.observe(viewLifecycleOwner, Observer { newScore ->
  binding.scoreText.text = newScore.toString()
})
viewModel.word.observe(viewLifecycleOwner, Observer { newWord ->
  binding.wordText.text = newWord.toString()
})

Then, remove updateWordText() and updateScoreText().

Use MutableLiveData for encapsulation

Using backing property, ViewModel uses MutableLiveData internally and exposes LiveData to public access.

// from
val score = MutableLiveData<Int>()

// to
private val _score = MutableLiveData<Int>()
val score: LiveData<Int>
  get() = _score

Check Getters and Setters in Kotlin.

Internally, viewModel should use _score now.

// in ViewModel
_score.value = (score.value)?.minus(1)

// for reading
binding.scoreText.text = score.value.toString()

Example: game-finish event

private val _eventGameFinish = MutableLiveData<Boolean>()
val eventGameFinish: LiveData<Boolean>
  get() = _eventGameFinish

fun nextWord() {
  if (wordList.isNotEmpty()) {
    //Select and remove a word from the list
    _word.value = wordList.removeAt(0)
  } else {
    onGameFinish()
  }
}

fun onGameFinish() {
  _eventGameFinish.value = true
}

fun onGameFinishComplete() {
  _eventGameFinish.value = false
}

in the fragment,

// onCreateView()
viewModel.eventGameFinish.observe(viewLifecycleOwner, Observer<Boolean> { hasFinished ->
  if (hasFinished) gameFinished()
})

private fun gameFinished() {
  // ...
  viewModel.onGameFinishComplete()
}

Example: transition event

// in ViewModel
private val _eventPlayAgain = MutableLiveData<Boolean>()
val eventPlayAgain: LiveData<Boolean>
    get() = _eventPlayAgain

fun onPlayAgain() {
    _eventPlayAgain.value = true
}
fun onPlayAgainComplete() {
    _eventPlayAgain.value = false
}
// onCreateView() in Fragment
binding.playAgainButton.setOnClickListener { viewModel.onPlayAgain() }

viewModel.eventPlayAgain.observe(viewLifecycleOwner, Observer<Boolean> { playAgain ->
  if (playAgain) {
    findNavController().navigate(ScoreFragmentDirections.actionRestart())
    viewModel.onPlayAgainComplete()
  }
})

Data binding with ViewModel and LiveData

Current:

Views        <- UI Controller          <- ViewModel
(XML layout)    (activity/fragment        (LiveData)
                 with click listeners)

ViewModel passed into the data binding:

Views        <- ViewModel
(XML layout)    (LiveData)

Data binding with ViewModel

Add ViewModel into fragment xml file.

<layout ...>
  <data>
    <variable
      name="gameViewModel"
      type="com.example.android.guesstheword.screens.game.GameViewModel" />
  </data>

  <androidx.constraintlayout...>

Add data binding at onCreateView() in the Fragment.

binding.gameViewModel = viewModel

Use listener bindings for event handling via lamda expression.

<Button
   android:id="@+id/skip_button"
   ...
   android:onClick="@{() -> gameViewModel.onSkip()}"
   ... />

Then, remove all onClickListener() in the acitivities and fragments.

Note: data-binding error messages are quite hard to understand. Read the error and check the spell carefully.

Data binding with LiveData

<TextView
  android:id="@+id/word_text"
  ...
  android:text="@{gameViewModel.word}"
  ... />
<TextView
  android:id="@+id/score_text"
  ...
  android:text="@{String.valueOf(scoreViewModel.score)}"
  ... />
binding.gameViewModel = viewModel
binding.lifecycleOwner = viewLifecycleOwner

Then, remove all observe() for updating the content.

Formatting

in string.xml:

<string name="quote_format">\"%s\"</string>
<string name="score_format">Current Score: %d</string>

in the fragment xml file,

<TextView
  android:id="@+id/word_text"
  ...
  android:text="@{@string/quote_format(gameViewModel.word)}"
  ... />

Check Layouts and binding expressions.

As a result, each fragments only observes the navigation related LiveData.

Transformations for LiveData

Transformations manipulates the data in LiveData for sanitizing, deducting, subsetting, etc. e.g. Transformations.map, Transformations.switchMap.

Add a timer

// in ViewModel
companion object {
  private const val DONE = 0L
  private const val ONE_SECOND = 1000L
  private const val COUNTDOWN_TIME = 60000L
}

private val _currentTime = MutableLiveData<Long>()
val currentTime: LiveData<Long>
  get() = _currentTime

private val timer: CountDownTimer

init {
  timer = object : CountDownTimer(COUNTDOWN_TIME, ONE_SECOND) {
    override fun onTick(millisUntilFinished: Long) {
      _currentTime.value = millisUntilFinished / ONE_SECOND
    }
    override fun onFinish() {
      _currentTime.value = DONE
      onGameFinish()
    }
  }

  timer.start()
}

override fun onCleared() {
  super.onCleared()
  timer.cancel()
}

Now, the time should display "MM:SS" format rather than just a number. DataUtils.formatElaspedTime() is utility method takes a long number of milliseconds and format the number to the time format.

// in ViewModel
val currentTimeString = Transformations.map(currentTime) { time ->
  DataUtils.formatElaspedTime(time)
}
// it's like a computed property in Swift and/or Vue.js.

Then, update fragment xml file.

<TextView
  android:id="@+id/timer_text"
  ...
  android:text="@{gameViewModel.currentTimeString}"
  ... />

다음 챕터: Android Kotlin Fundamentals: 06.1 Create a Room database

Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals: 03.3 Start an external Activity 부터.

Useful Shortcuts

  • Override Methods: Choose Code > Override Methods, or CTRL+o
  • Comment: Choose Code > Comment with Line Comment or CTRL+/ (Command+/ on a Mac)

Safe Args

Safe Args generates NavDirection classes to prevent bugs during passing parameters between Fragments.

To pass the parameters between Fragments, Bundle is one of the way to handle the data (key-value store). It does not guarantee type-safety such as type mismatch error, missing key errors.

Dependency

// project Gradle file
dependencies {
  //...
  classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigationVersion"
}
// app Gradle file
apply plugin: 'androidx.navigation.safeargs'

Then, sync and rebuild the project. The plugin will generate NavDirection class, and it's in the generatedJava folder.

Use typed action ID

Use the type-safe name rather than R.id.<ID> action ID.

view.findNavController()
        .navigate(GameFragmentDirections.actionGameFragmentToGameWonFragment())

Add and pass arguments

Open navigation.xml, select the fragment, then add argument from the Attributes tab.

Then, the build will fail because of the new arguments. Update the exisiting code with the parameters. The class will be <name>FragmentDirections.

view.findNavController()
    .navigate(GameFragmentDirections
      .actionGameFragmentToGameWonFragment(numQuestions, questionIndex))

In the next Fragment, it is available to access through <name>FragmentArgs.

val args = GameWonFragmentArgs.fromBundle(requireArguments())
Toast.makeText(context, "NumCorrect: ${args.numCorrect}, NumQuestions: ${args.numQuestions}", Toast.LENGTH_LONG).show()

Implicit intents

Android allows you tu use intents to navigate to activities that other apps provide. e.g. share the game result to share menu. Intent is a simple message object taht is used to communicate between Android components. Implicit intent doesn't require which app or activity will handle the task.

// in onCreateView
setHasOptionsMenu(true)

// Creating our Share Intent
private fun getShareIntent() : Intent {
  val args = GameWonFragmentArgs.fromBundle(requireArguments())
  val shareIntent = Intent(Intent.ACTION_SEND)
  shareIntent.setType("text/plain")
    .putExtra(Intent.EXTRA_TEXT, getString(R.string.share_success_text, args.numCorrect, args.numQuestions))
  return shareIntent
}

Intent action would be Intent.ACTION_SEND, etc. Check the example.

// Starting an Activity with our new Intent
private fun shareSuccess() {
  startActivity(getShareIntent())
}

// Showing the Share Menu Item Dynamically
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
  super.onCreateOptionsMenu(menu, inflater)

  // Note: winner_menu.xml is already prepped in the example
  inflater.inflate(R.menu.winner_menu, menu)
  if(getShareIntent().resolveActivity(requireActivity().packageManager) == null){
    menu.findItem(R.id.share).isVisible = false
  }
}

Pass resolveActivity through packageManager so that find out which app is eligible to handle this intent. If there is no app related with this, the menu will be hidden.

// Sharing from the Menu
override fun onOptionsItemSelected(item: MenuItem): Boolean {
  when(item.itemId){
    R.id.share -> shareSuccess()
  }
  return super.onOptionsItemSelected(item)
}

Lifecycle

Check Understand the Activity Lifecycle.

Check the use cases.

The general pattern is that when you set up or start something in a callback, you stop or remove that thing in the corresponding callback. This way, you avoid having anything running when it's no longer needed.

Activity Lifecycle

         [Resumed]      }  }-- activity has focus
          |     |       }  }
    onResume   onPause  }  
          |     |       }
         [Started]      }-- activity is visible
          |     |
    onStart    onStop
    onRestart   |
          |     |
         [Created]
          |     |
    onCreate   onDestroy
          |     |
[Initialized] [Destroyed]

Fragment Lifecycle

         [Resumed]      }  }-- fragment has focus
          |     |       }  }
    onResume   onPause  }
          |     |       }
         [Started]      }-- fragment is visible
          |     |
      onStart  onStop
onViewCreated  onDestoryView
 onCreateView   |
          |     |
         [Created]
          |     |
    onCreate   onDestroy
    onAttach   onDetach
          |     |
[Initialized] [Destroyed]
  • onAttach(): Called when the fragment is associated with its owner activity.
  • onCreate(): Similarly to onCreate() for the activity, onCreate() for the fragment is called to do initial fragment creation (other than layout).
  • onCreateView(): Called to inflate the fragment's layout.
  • onViewCreated(): Called immediately after onCreateView() has returned, but before any saved state has been restored into the view.
  • onStart(): Called when the fragment becomes visible; parallel to the activity's onStart().
  • onResume(): Called when the fragment gains the user focus; parallel to the activity's onResume().

Check lifecycle using logging

Add log on onCreate() method in the MainActivity.

Log.i("MainActivity", "onCreate Called")
// i: info
// e: error
// w: warning

"MainActivity" tag helps to find the log in the Logcat.

Open Logcat pane in Android Studio and search I/MainActivity.

Create lifecycle methods

Use Override Methods menu, add onStart(). (CTRL+o)

override fun onStart() {
  super.onStart()

  Log.i("MainActivity", "onStart Called")
}

Check the log and test in vary (Press the Home button then back to the screen.)

Use Timber for logging

  • Generates log tag by class name
  • Show the log only in dev
  • integration with crash reporting libs

Add dependencies

Check latest version from the Timber project page.

// build.gradle (app level)
dependencies {
  // ...
  implementation 'com.jakewharton.timber:timber:4.7.1'
}

Then, sync it.

Initialize Timber

Create Application class for the logging library. Note: Do not put any activity code here unless the code is really needed.

package com.example.android.dessertclicker

import android.app.Application
import timber.log.Timber

class ClickerApplication : Application() {
  override fun onCreate() {
    super.onCreate()

    Timber.plant(Timber.DebugTree())
  }
}

Open AndroidManifest.xml and add Application name there to connect custom application class.

<application
  android:name=".ClickerApplication"
  ... />

Add Timber log in the code.

Timber.i("onCreate called")

Lifecycle library

  • Lifecycle owners: Activity and Fragment. Implement the LifecycleOwner interface
  • Lifecycle class: holds actual state of a lifecycle owner and triggers events
  • Lifecycle observers: observe the lifecycle state and perform tasks. Implement the LifecycleObserver interface
// change the class signature
class DessertTimer(lifecycle: Lifecycle) : LifecycleObserver {

// add init block
init {
  lifecycle.addObserver(this)
}

// Add annotation on `startTimer()` and `stopTimer()`
@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun startTimer() {
  // ...
}

@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
fun stopTimer() {
  // ...
}

Then, initialize the class with the lifecycle in activity.

dessertTimer = DessertTimer(this.lifecycle)

onSaveInstanceState()

Some app can be killed by Android during the background and respring it. Therefore the status need to be stored some places. It also needed to handle configuration change such as orientation changes of the device.

The bundle stores in RAM so keep the data small here(<100k) otherwise crash with TransactionTooLargeException.

Kill the app using adb.

adb shell am kill com.example.android.dessertclicker
                    [Resumed]
                     |     |
               onResume   onPause
                     |     |
                    [Started]
                     |     |
  onRestoreInstanceState  onStop
                 onStart  onSaveInstanceState
               onRestart   |
                     |     |
                    [Created]
                     |     |
               onCreate   onDestroy
                     |     |
           [Initialized] [Destroyed]
  • onSaveInstanceState(): Save any data that need if the app destoryed.
  • onRestoreInstanceState()
override fun onSaveInstanceState(outState: Bundle) {
  super.onSaveInstanceState(outState)

  Timber.i("onSaveInstanceState Called")
}

Add constants before the class definition.

const val KEY_REVENUE = "revenue_key"
const val KEY_DESSERT_SOLD = "dessert_sold_key"
const val KEY_TIMER_SECONDS = "timer_seconds_key"

Save the information into the bundle.

outState.putInt(KEY_REVENUE, revenue)
outState.putInt(KEY_DESSERT_SOLD, dessertsSold)
outState.putInt(KEY_TIMER_SECONDS, dessertTimer.secondsCount)

Restore the data from the onCreate().

Note: If the activity is being re-created, the onRestoreInstanceState() callback is called after onStart(), also with the bundle. Most of the time, you restore the activity state in onCreate(). But because onRestoreInstanceState() is called after onStart(), if you ever need to restore some state after onCreate() is called, you can use onRestoreInstanceState().

override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  // ...
  if (savedInstanceState != null) {
    revenue = savedInstanceState.getInt(KEY_REVENUE, 0)
    dessertsSold = savedInstanceState.getInt(KEY_DESSERT_SOLD, 0)
    dessertTimer.secondsCount = savedInstanceState.getInt(KEY_TIMER_SECONDS, 0)

    // call anything if needed to update the initial states
    showCurrentDessert()
  }
}

다음 챕터: Android Kotlin Fundamentals: ViewModel

Android Kotlin Fundamentals Course 코드랩 하면서 노트.

Useful Shortcuts

  • Project quick fix: Alt+Enter (Option+Enter on a Mac)
  • Reformat code: Choose Code > Reformat code, or CTRL+ALT+L (Command+Option+L on a Mac)

Vector drawables

A part of Android Jetpack.

As default, android app will contain bitmap version of the drawables in lower than API 21. Vector drawables support in oldver vrsions of Android, back to API 7.

Add this line at defaultConfig in build.gradle (Module:app).

vectorDrawables.useSupportLibrary = true

Click Sync Now button.

Open activity_main.xml layout file and add this namespace at <LinearLayout> tag.

xmlns:app="http://schemas.android.com/apk/res-auto"

Change android:src in <ImageView> to app:srcCompat.

app:srcCompat="@drawable/hello_world"

Note: app namespace is for attributes that come from custom code or libraries.

Use findViewById once

Define member with lateint keyword.

lateinit var diceImage : ImageView

Init the member in onCreate().

diceImage = findViewById(R.id.dice_image)

API Levels

  • compileSdkVersion
  • targetSdkVersion: Usually same as compileSdkVersion
  • minSdkVersion

Architecture of the basic activity template

  • Status bar: Hide the status bar
  • App bar (action bar): Add the app bar
  • App name
  • Options menu overflow button: onOptionsItemSelected() in MainActivity.kt, check res/menu/menu_main.xml
  • CoordinatorLayout ViewGroup: content_main in activity_main.xml
  • Content main
  • Floating action button (FAB): FloatingActionButton in activity_main

Resources

Docs

Android team

Extract the style

  1. Right-click the component from the component tree and select Refactor > Extract Style.
  2. Fill in the form and save it.
  3. Use named style in activity xml file via style attribute
style="@style/NameStyle"

The style is defined in styles.xml in res.

Changing android:id

When android:id of the component need to be changed, right-click and select Refector > Rename.

Density-independent pixels

Support different pixel densities

Unit conversion: px = dp * (dpi / 160)

Threshold must be needed for dp conversion to pixel. Scale factor is in DisplayMetrics.density.

Autofill

Optimize your app for autofill

Show/Hide the keyboard

// Hide
val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0)

// Set focus
editText.requestFocus()
// Show
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(editText, 0)

Attach onClick events

  • in the XML, add android:onClick attr to the <View> element
  • in the code, use setOnClickListener(View.OnClickListener) function in the Activity

Constraints

In the activity page, a magnet icon is for Autoconnect feature.

Use Constraint Widget in Attributes section of the activity design page.

the type of the constraint

  • Wrap Content: as much as the element
  • Fixed: Specify a dimension
  • Match Constraints: Expands until meet the constraints on each side

Chains

A chain is a group of views that are linked to each other with bidirectional constraints.

Chain styles

set the layout_constraintHorizontal_chainStyle or the layout_constraintVertical_chainStyle.

  • Spread
  • Spread Inside
  • Packed
  • Weighted: layout_constraintHorizontal_weight or layout_constraintVertical_weight

Baseline constraint

The baseline constraint aligns the baseline of a view's text with the baseline of another view's text. Right click on the component and select show baseline.

<Button
   android:id="@+id/buttonB"
   ...   
   android:text="B"
   app:layout_constraintBaseline_toBaselineOf="@+id/buttonA" />

Design-Time attributes

Only for the layout design. Design-time attributes are prefixed with the tools namespace. e.g. tools:layout_editor_absoluteY, tools:text

Data binding

Eliminate findViewById() using binding objects. Benefits:

  • Code is shorter and easier to read and maintain
  • Resource efficiency
  • Type safety

Setup

// build.gradle (Module: app)
android {
  // ...
  buildFeatures {
    dataBinding true
  }
  // ...
}
// Then sync

Add <layout> at the outermost tag around the layout xml file. Then, move all xml namespaces to the <layout>. e.g.

<layout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto">

Usage

Add the type of binding in the activity class. For activity_main, the class will be ActivityMainBinding.

private lateinit var binding: ActivityMainBinding

It need to import something like import <your.project>.databinding.ActivityMainBinding.

Then, use DataBindingUtil.setContentView() and remove the previous setContentView().

// import androidx.databinding.DataBindingUtil

binding = DataBindingUtil.setContentView(this, R.layout.activity_main)

Now, the binding object holds all views in the layout.

binding.doneButton.setOnClickListener {}
binding.apply { // kotlinize the function
  nicknameText.text = nicknameEdit.text.toString()
  nicknameEdit.visibility = View.GONE
}

Data binding to display data

Create the data class in the package.

// MyName.kt
data class MyName(var name: String = "", var nickname: String = "")

Add data to the layout. Open activity xml file and add <data> right under the <layout>. Then, add <variable> tag with name and package path. (package name + variable name)

<layout ...>
  <data>
    <variable
      name="myName"
      type="com.example.android.aboutme.MyName" />
  </data>
  <!-- ... -->
</layout>

@={} is a directive to get the data that is referenced inside the curly braces. Replace the string like below:

<TextView android:text="@string/name" />
<TextView android:text="@={myName.name}" />

Create the data in the activity file.

// MainActivity.kt
private val myName: MyName = MyName("Edward Kim")

Then, binding the object with the view using binding.

// in onCreate()
binding.myName = myName

Manipulate the binding object so that the updated data will reflect to the view.

android:text="@{myName.nickname}"
binding.apply {
  myName?.nickname = nicknameEdit.text.toString()
  invalidateAll() // UI is refreshed with the new value
  // ...
}

Read more

Fragments

A Fragment represents a behavior or a protion of user interface in an activity. Modular section of an activity like a "sub-activity".

  • A Fragment has its own lifecycle and receives its own input events
  • You can add or remove a Fragment while the activity is running
  • A Fragment is defined in a Kotlin class
  • A Fragment's UI is defined in an XML layout file

"Inflate the Fragment's view" is equivalant to using setContentView() for an Activity.

class TitleFragment : Fragment() {
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {
        // create a binding object and inflate the Fragment's view
        val binding = DataBindingUtil.inflate<FragmentTitleBinding>(inflater,
                R.layout.fragment_title, container, false)
        return binding.root
    }
}

Add the new fragment to the main layout file.

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

  <LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">
    <fragment
      android:id="@+id/titleFragment"
      android:name="com.example.android.navigation.TitleFragment"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      />
  </LinearLayout>
</layout>

Navigation

Preparation

Add dependencies for the navigation library.

// build.gradle (project-level)
ext {
  // ...
  navigationVersion = "2.3.0"
  // ...
}
// build.gradle (module-level)
dependencies {
  // ...
  implementation "androidx.navigation:navigation-fragment-ktx:$navigationVersion"
  implementation "androidx.navigation:navigation-ui-ktx:$navigationVersion"
  // ...
}

Sync and rebuild.

Add a navigation graph to the project. right-click the res folder and select New > Android Resource File.

Set navigation as a file name and Navigation as a resource type, then okay.

Open res/navigation/navigation.xml with the navigation editor.

Create the NavHostFragment

NavHostFragment acts as a host for the fragments in a navigation graph.

Open layout xml file and add NavHostFragment like below.

<fragment
  android:id="@+id/myNavHostFragment"
  android:name="androidx.navigation.fragment.NavHostFragment"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  app:navGraph="@navigation/navigation"
  app:defaultNavHost="true" />

Add fragments to the navigation graph

Open navigation.xml and add new destinations. If the preview is not showing, check tools:layout.

Then drag the dot (circular connection point) on the preview and drop to the destination. Check the ID of the action. (e.g. action_titleFragment_to_gameFragment)

Add the button click event in the Fragment inside the onCreateView() method.

binding.playButton.setOnClickListener { view: View ->
    view.findNavController().navigate(R.id.action_titleFragment_to_gameFragment)
}

Conditional navigation

Add two fragments to the navigation graph.

Drag the circular connection point to the conditional fragments.

Then, add the code like:

// at onCreateView() in GameFragment.kt
// find the appropriate position...

view.findNavController().navigate(R.id.action_gameFragment_to_gameWonFragment)

Change the back button's destination

Control the back stack by setting the "pop" behavior in actions on the navigation editor.

  • popUpTo: back stack to a given destination before navigating.
  • popUpToInclusive
    • false or not set: leaves the specified dest. in the back stack.
    • true: remove all tracing stacks included specified dest.
  • popUpToInclusive=true and popUpTo at app's starting location: all the way out of the app.

Add up button in the App bar

(= action bar)

Designing Back and Up navigation

  • The Up button navigates within the app, based on the hierarchical relationships between screens. The Up button never navigates the user out of the app.
  • The Back button, shown as 2 in the screenshot below, appears in the system navigation bar or as a mechanical button on the device itself, no matter what app is open.

Back button is for the back stack, up button is for the screen hierachy.

Use NavigationUI.

// onCreate() in MainActivity.kt
val navController = this.findNavController(R.id.myNavHostFragment)
NavigationUI.setupActionBarWithNavController(this, navController)
// in MainActivity.kt
override fun onSupportNavigateUp(): Boolean {
  val navController = this.findNavController(R.id.myNavHostFragment)
  return navController.navigateUp()
}

Add an options menu

Add new destination to the navigation graph.

Add a options-menu to the project. right-click the res folder and select New > Android Resource File.

Set options_menu as a file name and Menu as a resource type, then okay.

Open options_menu.xml.

Add Menu Item from Plaette to the component tree. Set the id of item to the same name as the target fragment e.g. aboutFragment.

Add onClick handler.

// TitleFragment.kt
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                         savedInstanceState: Bundle?): View? {
  // ...
  setHasOptionsMenu(true) // Add this line
  return binding.root
}

// Add the options menu and inflate the menu resource file
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
  super.onCreateOptionsMenu(menu, inflater)
  inflater.inflate(R.menu.options_menu, menu)
}

// Add onOptionsItemSelected() for the navigation
// It uses the id of the selected item
override fun onOptionsItemSelected(item: MenuItem): Boolean {
     return NavigationUI.
            onNavDestinationSelected(item,requireView().findNavController())
            || super.onOptionsItemSelected(item)
}

Add the navigation drawer

Drawer appears when the user swipes edge to edge or tap the nav drawer button/hamburger icon. It is a part of the material components for Android.

// build.gradle (app-level)
dependencies {
  // ...
  implementation "com.google.android.material:material:$supportlibVersion"
  // ...
}

// then sync!

Add a drawer to the project. right-click the res folder and select New > Android Resource File.

Set navdrawer_menu as a file name and Menu as a resource type, then okay.

Open navdarwer_menu and add menu items. Note: Match the ID for the menu item as for the destination Fragment.

Add <DrawerLayout> and wrap the entire layout in activity xml file. Also, add NavigationView before closing it.

<layout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto">
  <androidx.drawerlayout.widget.DrawerLayout
       android:id="@+id/drawerLayout"
       android:layout_width="match_parent"
       android:layout_height="match_parent">

    <LinearLayout>
      <!-- ... -->
    </LinearLayout>
    <com.google.android.material.navigation.NavigationView
      android:id="@+id/navView"
      android:layout_width="wrap_content"
      android:layout_height="match_parent"
      android:layout_gravity="start"
      app:headerLayout="@layout/nav_header"
      app:menu="@menu/navdrawer_menu" />
  </androidx.drawerlayout.widget.DrawerLayout>
</layout>

For swaping, connect the drawer to the navigation controller:

// onCreate() in MainActivity.kt
val binding = DataBindingUtil.setContentView<ActivityMainBinding>(
       this, R.layout.activity_main)

NavigationUI.setupWithNavController(binding.navView, navController)

For drawer button, add the code bleow:

// in MainActivity.kt
private lateinit var drawerLayout: DrawerLayout

// onCreate()
drawerLayout = binding.drawerLayout
NavigationUI.setupActionBarWithNavController(this, navController, drawerLayout)
  // add it as a third param

To make the Up button work with the drawer button:

// onSupportnavigateUp() in MainActivity.kt
override fun onSupportNavigateUp(): Boolean {
  val navController = this.findNavController(R.id.myNavHostFragment)
  return NavigationUI.navigateUp(navController, drawerLayout)
}

다음 챕터: Android Kotlin Fundamentals: 03.3 Start an external Activity

(초:초:초간단 만능 양념장으로 만드는 춘천식 닭갈비! 1:1:1:1:1:1 ㅣ 백종원의 쿠킹로그)

재료 및 손질

양념장

  • 설탕
  • 고춧가루 (굵은)
  • 맛술
  • 간장
  • 간마늘
  • 고추장

동일 비율로 넣고 섞는다. 각각 3큰술 하면 대략 1컵 분량. 생강 조금 넣으면 더 맛있다고.

약간 숙성돼도 맛있다는데 (3~4시간) 그냥 해도 괜찮았음.

요리

  • 야채 (양배추, 감자, 양파, 마늘, 고추): 대충 큼직하게 손질
  • 깻잎 (선택): 좋아하면, 대충 4등분
  • 대파: 큼직하게
  • 닭고기 (허벅지살): 두 손가락 정도 크기로 등분 (코스트코에서 파는 건 대략 3등분 크기)
  • 사리 (선택)
    • 떡볶이 떡 한 줌
    • 라면 사리 한 개
  • 참기름
  • 깨소금
  • 후추

준비

  1. 야채, 닭고기를 양념과 함께 잘 버무린다. 양념은 좀 진하다 싶을 정도.
  2. 대파랑 고추를 넣고 버무린다. 참기름 1큰술, 후추 조금, 깨소금 조금 넣고 버무린다.

조리

  1. 잘 달군 팬에 준비한 것을 놓는다. 치직 소리 나야 함.
  2. 치직 소리 후에, 타기 전에 물을 조금 넣는다.
    • 야채에서 수분이 나오기 시작하기 전에 수분이 없으면 양념이 타기 시작
    • 대략 양념이 끈적해지기 시작하면 물 넣으면 됨
    • 야채에서 물 나오기 시작하면 엄청 나오니까 보면서 물 조금씩 추가
  3. 안타게 뒤적인다.
    • 야채에서 수분 나오면 끓는 것처럼 보글거리는데 이때는 안뒤적여도 됨.
  4. 고기 겉이 익은 색이 되면 3등분으로 잘라준다.
    • 사리 넣을 거면 고기 자르기 전에 넣는다. 면은 반 잘라서 밑에 밀어둔다.
  • 면 넣는데 국물 너무 없어보이면 물 조금 넣는다.
  1. 뒤적이면서 감자 찔러본다. 쑥 들어가면 다 익음. 먹어보고 짜면 물 조금 넣고 싱거우면 소금. 끝!
    • 깻잎 넣는다면 이때 넣고 한번 뒤적이고 끝

  • 양배추가 너무 익어서 흐물거리면 별로인데 양배추 잘게 썰면 빨리 흐물거림. 너무 크면 생양배추 같음.
  • 물 너무 많이 있어도 양배추 흐물됨. 적당히.
  • 사리 과하면 생각보다 맛없음

색상을 바꿔요

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

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