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

Starter code

DevBytes starter.

/**
 * VideoHolder holds a list of Videos.
 *
 * This is to parse first level of our network result which looks like
 *
 * {
 *   "videos": []
 * }
 */
@JsonClass(generateAdapter = true)
data class NetworkVideoContainer(val videos: List<NetworkVideo>)

/**
 * Videos represent a devbyte that can be played.
 */
@JsonClass(generateAdapter = true)
data class NetworkVideo(
        val title: String,
        val description: String,
        val url: String,
        val updated: String,
        val thumbnail: String,
        val closedCaptions: String?)

/**
 * Convert Network results to database objects
 */
fun NetworkVideoContainer.asDomainModel(): List<DevByteVideo> {
    return videos.map {
        DevByteVideo(
                title = it.title,
                description = it.description,
                url = it.url,
                updated = it.updated,
                thumbnail = it.thumbnail)
    }
}

Add an offline cache using Room

Add a dependency.

// build.gradle (Module:app)
// Room dependency
def room_version = "2.1.0-alpha06"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"

Add a DB entity.

// DatabaseEntities.kt
@Entity
data class DatabaseVideo constructor(
        @PrimaryKey
        val url: String,
        val updated: String,
        val title: String,
        val description: String,
        val thumbnail: String
)

fun List<DatabaseVideo>.asDomainModel(): List<DevByteVideo> {
    return map {
        DevByteVideo(
                url = it.url,
                title = it.title,
                description = it.description,
                updated = it.updated,
                thumbnail = it.thumbnail
        )
    }
}

Update a data transfer object.

// DataTransferObjects.kt
/**
 * Convert Network results to database objects
 */
fun NetworkVideoContainer.asDomainModel(): List<DatabaseVideo> {
    return videos.map {
        DatabaseVideo(
                title = it.title,
                description = it.description,
                url = it.url,
                updated = it.updated,
                thumbnail = it.thumbnail)
    }
}

Add VideoDao.

// Room.kt
@Dao
interface VideoDao {
    @Query("select * from databasevideo")
    fun getVideos(): LiveData<List<DatabaseVideo>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertAll(videos: List<DatabaseVideo>)
}

@Database(entities = [DatabaseVideo::class], version = 1)
abstract class VideosDatabase : RoomDatabase() {
    abstract val videoDao: VideoDao
}

private lateinit var INSTANCE: VideosDatabase

fun getDatabase(context: Context): VideosDatabase {
    synchronized(VideosDatabase::class.java) {
        if (!::INSTANCE.isInitialized) {
            INSTANCE = Room.databaseBuilder(context.applicationContext,
                    VideosDatabase::class.java, "videos")
                    .build()
        }
    }
    return INSTANCE
}

Repository

A repository module handles data operations and allows you to use multiple backends.

// VideosRepository.kt
class VideosRepository(private val database: VideosDatabase) {
    val videos: LiveData<List<DevByteVideo>> = Transformations.map(database.videoDao.getVideos()) {
        it.asDomainModel()
    }

    suspend fun refreshVideos() {
        withContext(Dispatchers.IO) {
            Timber.d("Refresh videos is called");
            val playlist = DevByteNetwork.devbytes.getPlaylist()
            database.videoDao.insertAll(playlist.asDomainModel())
        }
    }
}

Update the view model to apply a new refresh strategy.

// DevByteViewModel.kt
class DevByteViewModel(application: Application) : AndroidViewModel(application) {
    // init the repository
    private val videosRepository = VideosRepository(getDatabase(application))

    // ...

    // Replace playlist
    val playlist = videosRepository.videos

    // ...

    init {
        refreshDataFromRepository()
    }

    /**
     * Refresh data from network and pass it via LiveData. Use a coroutine launch to get to
     * background thread.
     */
    private fun refreshDataFromRepository() {
        viewModelScope.launch {
            try {
                videosRepository.refreshVideos()
                _eventNetworkError.value = false
                _isNetworkErrorShown.value = false
            } catch (networkError: IOException) {
                // Show a Toast error message and hide the progress bar.
                if (playlist.value.isNullOrEmpty())
                    _eventNetworkError.value = true
            }
        }
    }
}

다음 챕터: Android Kotlin Fundamentals

Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals and detail views with internet data 부터.

Add "for sale" images to the overview

// MarsProperty.kt
data class MarsProperty(
  val id: String,
  @Json(name = "img_src") val imgSrcUrl: String,
  val type: String,
  val price: Double
) {
  val isRental
    get() = type == "rent"
}

Update grid_view_item.xml.

<layout ...>
  <FrameLayout
    android:layout_width="match_parent"
    android:layout_height="170dp">
    <ImageView
      android:id="@+id/mars_image"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:adjustViewBounds="true"
      android:padding="2dp"
      android:scaleType="centerCrop"
      app:imageUrl="@{property.imgSrcUrl}"
      tools:src="@tools:sample/backgrounds/scenic" />
    <ImageView
      android:id="@+id/mars_property_type"
      android:layout_width="wrap_content"
      android:layout_height="45dp"
      android:layout_gravity="bottom|end"
      android:adjustViewBounds="true"
      android:padding="5dp"
      android:scaleType="fitCenter"
      android:src="@drawable/ic_for_sale_outline"
      android:visibility="@{property.rental ? View.GONE : View.VISIBLE}"
      tools:src="@drawable/ic_for_sale_outline" />
  </FrameLayout>
  <data>
    <import type="android.view.View" />
    <variable
      name="property"
      type="com.example.android.marsrealestate.network.MarsProperty" />
  </data>
</layout>

Filter the results

The URL will be:

// https://android-kotlin-fun-mars-server.appspot.com/realestate?filter=buy

Update the Mars API service.

// MarsApiService.kt
enum class MarsApiFilter(val value: String) {
  SHOW_RENT("rent"),
  SHOW_BUY("buy"),
  SHOW_ALL("all")
}
// ...
interface MarsApiService {
  @GET("realestate")
  suspend fun getProperties(@Query("filter") type: String):
    List<MarsProperty>
}

Update the view model.

// OverviewViewModel.kt
class OverviewViewModel : ViewModel() {
  // ...
  init {
    getMarsRealEstateProperties(MarsApiFilter.SHOW_ALL)
  }

  fun updateFilter(filter: MarsApiFilter) {
    getMarsRealEstateProperties(filter)
  }

  private fun getMarsRealEstateProperties(filter: MarsApiFilter) {
    viewModelScope.launch {
      _status.value = MarsApiStatus.LOADING
      try {
        _properties.value = MarsApi.retrofitService.getProperties(filter.value)
        _status.value = MarsApiStatus.DONE

      } catch (e: Exception) {
        _status.value = MarsApiStatus.ERROR
        _properties.value = ArrayList()
      }
    }
  }
}
<!-- overflow_menu.xml -->
<menu ...>
  <item
    android:id="@+id/show_all_menu"
    android:title="@string/show_all" />
  <item
    android:id="@+id/show_rent_menu"
    android:title="@string/show_rent" />
  <item
    android:id="@+id/show_buy_menu"
    android:title="@string/show_buy" />
</menu>

Then, update the view model when user choose the menu.

// OverviewFragment.kt
class OverviewFragment : Fragment() {
  // ...
  override fun onOptionsItemSelected(item: MenuItem): Boolean {
    viewModel.updateFilter(
      when (item.itemId) {
        R.id.show_rent_menu -> MarsApiFilter.SHOW_RENT
        R.id.show_buy_menu -> MarsApiFilter.SHOW_BUY
        else -> MarsApiFilter.SHOW_ALL
      }
    )
    return true
  }
}

Create a detail page with navigation

When the user taps the tile, pass the data to the detail page.

// DetailViewModel.kt
class DetailViewModel(marsProperty: MarsProperty,
    app: Application) : AndroidViewModel(app) {
  private val _selectedProperty = MutableLiveData<MarsProperty>()
  val selectedProperty: LiveData<MarsProperty>
    get() = _selectedProperty

  init {
    _selectedProperty.value = marsProperty
  }
}

Update the fragment_detail.xml.

<ImageView
  android:id="@+id/main_photo_image"
  ...
  app:imageUrl="@{viewModel.selectedProperty.imgSrcUrl}" />

<!-- ... -->
<data>
  <variable
    name="viewModel"
    type="com.example.android.marsrealestate.detail.DetailViewModel" />
</data>

Then, add navigation with passing property.

// OverviewViewModel.kt
class OverviewViewModel : ViewModel() {
  // ...
  private val _navigateToSelectedProperty = MutableLiveData<MarsProperty>()
  val navigateToSelectedProperty: LiveData<MarsProperty>
    get() = _navigateToSelectedProperty

  fun displayPropertyDetails(marsProperty: MarsProperty) {
    _navigateToSelectedProperty.value = marsProperty
  }

  fun displayPropertyDetailsComplete() {
    _navigateToSelectedProperty.value = null
  }
}

Add click listeners in the grid adapter and fragment.

// PhotoGridAdapter.kt
class PhotoGridAdapter(private val onClickListener: OnClickListener) : ListAdapter<MarsProperty, PhotoGridAdapter.MarsPropertyViewHolder>(DiffCallback) {
  // ...
  override fun onBindViewHolder(holder: MarsPropertyViewHolder, position: Int) {
    val marsProperty = getItem(position)
    holder.itemView.setOnClickListener {
      onClickListener.onClick(marsProperty)
    }
    holder.bind(marsProperty)
  }
  // ...
  class OnClickListener(val clickListener: (marsProperty: MarsProperty) -> Unit) {
    fun onClick(marsProperty: MarsProperty) = clickListener(marsProperty)
  }
}

Then, update the OverviewFragment.kt.

binding.photosGrid.adapter = PhotoGridAdapter(PhotoGridAdapter.OnClickListener {
    viewModel.displayPropertyDetails(it)
})

MarsProperty is unable to pass through the navigation yet. The class need to be Parcelable via @parcelize.

Open MarsProperty.kt and add @parcelize.

@Parcelize
data class MarsProperty(
  val id: String,
  @Json(name = "img_src") val imgSrcUrl: String,
  val type: String,
  val price: Double
) : Parcelable {
  val isRental
    get() = type == "rent"
}

Add argument at detail fragment in nav_graph.xml.

<fragment
  android:id="@+id/detailFragment"
  ...>
  <argument
    android:name="selectedProperty"
    app:argType="com.example.android.marsrealestate.network.MarsProperty"
    />
</fragment>

Finally, add an observer to navigateToSelectedProperty.

//OverviewFragment.kt
// in `onCreateView()`
viewModel.navigateToSelectedProperty.observe(this, Observer {
  if (null != it) {
    this.findNavController()
      .navigate(OverviewFragmentDirections.actionShowDetail(it))
    viewModel.displayPropertyDetailsComplete()
  }
})

Add initial logic for the detail fragment.

// DetailFragment.kt
class DetailFragment : Fragment() {
  override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
      savedInstanceState: Bundle?): View? {
    val application = requireNotNull(activity).application
    val binding = FragmentDetailBinding.inflate(inflater)
    binding.lifecycleOwner = this

    val marsProperty = DetailFragmentArgs.fromBundle(arguments!!).selectedProperty
    val viewModelFactory = DetailViewModelFactory(marsProperty, application)
    binding.viewModel = ViewModelProvider(this, viewModelFactory).get(DetailViewModel::class.java)

    return binding.root
  }
}

To update the detail page, add these string resources in strings.xml.

<string name="type_rent">Rent</string>
<string name="type_sale">Sale</string>
<string name="display_type">For %s</string>
<string name="display_price_monthly_rental">$%,.0f/month</string>
<string name="display_price">$%,.0f</string>

Then, add Transformations in the view model.

// DetailViewModel.kt
// In the detail viewmodel class
val displayPropertyPrice = Transformations.map(selectedProperty) {
  app.applicationContext.getString(
    when (it.isRental) {
      true -> R.string.display_price_monthly_rental
      false -> R.string.display_price
    }, it.price)
}

val displayPropertyType = Transformations.map(selectedProperty) {
  app.applicationContext.getString(
    when (it.isRental) {
      true -> R.string.type_rent
      false -> R.string.type_sale
    })
}

Add two textviews on the detail layout.

<TextView
  android:id="@+id/property_type_text"
  ...
  android:text="@{viewModel.displayPropertyType}"
  tools:text="To Rent" />

<TextView
  android:id="@+id/price_value_text"
  ...
  android:text="@{viewModel.displayPropertyPrice}"
  tools:text="$100,000" />

다음 챕터: Android Kotlin Fundamentals

Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals: 8.1 Getting data from the internet 부터.

dep versions

Just in case.

buildscript {
  ext {
    // Versions for all the dependencies we plan to use. It's particularly useful for kotlin and
    // navigation where the versions of the plugin needs to be the same as the version of the
    // library defined in the app Gradle file

    version_android_gradle_plugin = '4.0.1'
    version_core = "1.3.1"
    version_constraint_layout = "1.1.3"
    version_glide = "4.8.0"
    version_kotlin = "1.3.72"
    version_lifecycle = "2.2.0"
    version_moshi = "1.8.0"
    version_navigation = "1.0.0"
    version_retrofit = "2.9.0"
    version_recyclerview = "1.0.0"
  }
}

Retrofit: A type-safe HTTP client

// build.gradle (project)
buildscript {
  ext {
    //...
    version_retrofit = "2.9.0"
  }
}
// build.gradle (module: app)
dependencies {
  // ...
  implementation "com.squareup.retrofit2:retrofit:$version_retrofit"
  implementation "com.squareup.retrofit2:converter-scalars:$version_retrofit"
}

android {
  // ...

  compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }

  kotlinOptions {
    jvmTarget = JavaVersion.VERSION_1_8.toString()
  }
}

Then, create MarsApiService.kt.

package com.example.android.marsrealestate.network

import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.converter.scalars.ScalarsConverterFactory
import retrofit2.http.GET

// private const val BASE_URL = "https://android-kotlin-fun-mars-server.appspot.com/"

private val retrofit = Retrofit.Builder()
  .addConverterFactory(ScalarsConverterFactory.create())
  .baseUrl(BASE_URL)
  .build()

interface MarsApiService {
  @GET("realestate")
  fun getProperties():
      Call<String>
}

object MarsApi {
  val retrofitService: MarsApiService by lazy {
    retrofit.create(MarsApiService::class.java)
  }
}

Call the web service in OverviewViewModel.kt.

class OverviewViewModel : ViewModel() {
  // ...
  private fun getMarsRealEstateProperties() {
    MarsApi.retrofitService.getProperties().enqueue(
      object : Callback<String> {
        override fun onFailure(call: Call<String>, t: Throwable) {
          _response.value = "Failure: " + t.message
        }

        override fun onResponse(call: Call<String>, response: Response<String>) {
          _response.value = response.body()
        }
      }
    )
  }
}

Add INTERNET permission in app/manifests/AndroidManifest.xml.

<manifest ...>
  <uses-permission android:name="android.permission.INTERNET" />
</manifest>

Moshi: JSON parser library

// build.gradle (project)
buildscript {
  ext {
    //...
    version_moshi = "1.8.0"
  }
}
// build.gradle (module: app)
dependencies {
  // ...
  implementation "com.squareup.moshi:moshi-kotlin:$version_moshi"
  // change retrofit2's scalars converter
  implementation "com.squareup.retrofit2:converter-moshi:$version_retrofit"
}

Then, sync.

Create the property data class.

// MarsProperty.kt
data class MarsProperty(
  val id: String,
  // val img_src: String,
  // to remap the name
  @Json(name = "img_src") val imgSrcUrl: String,
  val type: String,
  val price: Double
)

Update MarsApiService.kt with new converter.

/// MarsApiService.kt

private val moshi = Moshi.Builder()
  .add(KotlinJsonAdapterFactory())
  .build()

private val retrofit = Retrofit.Builder()
  .addConverterFactory(MoshiConverterFactory.create(moshi))
  .baseUrl(BASE_URL)
  .build()

interface MarsApiService {
  @GET("realestate")
  fun getProperties():
    Call<List<MarsProperty>>
}

Update the ViewModel.

class OverviewViewModel : ViewModel() {
  // ...
  private fun getMarsRealEstateProperties() {
    MarsApi.retrofitService.getProperties().enqueue(
      object : Callback<List<MarsProperty>> {
        override fun onFailure(call: Call<List<MarsProperty>>, t: Throwable) {
          _response.value = "Failure: " + t.message
        }

        override fun onResponse(call: Call<List<MarsProperty>>, response: Response<List<MarsProperty>>) {
          _response.value =
            "Success: ${response.body()?.size} Mars properties retrieved"
        }
      }
    )
  }
}

It is a good idea to use coroutines for asynchronous.

// MarsApiService.kt
// Change the function with suspend
interface MarsApiService {
  @GET("realestate")
  suspend fun getProperties():
    List<MarsProperty>
}

Then, replace the ViewModel like the code below using try {}, catch {}, and viewModelScope.

// OverviewViewModel.kt
private fun getMarsRealEstateProperties() {
  viewModelScope.launch {
    try {
      val listResult = MarsApi.retrofitService.getProperties()
      _response.value = "Success: ${listResult.size} Mars properties retrieved"
    } catch (e: Exception) {
      _response.value = "Failure: ${e.message}"
    }
  }
}

Glide: Media mgmt and loading framework

// build.gradle (project)
buildscript {
  ext {
    //...
    version_glide = "4.8.0"
  }
}
// build.gradle (module: app)
dependencies {
  // ...
  implementation "com.github.bumptech.glide:glide:$version_glide"
}

Update the view model.

// OverviewViewModel.kt
private val _property = MutableLiveData<MarsProperty>()

val property: LiveData<MarsProperty>
  get() = _property

private fun getMarsRealEstateProperties() {
  viewModelScope.launch {
    try {
      val listResult = MarsApi.retrofitService.getProperties()
      if (listResult.size > 0) {
          _property.value = listResult[0]
      }
    } catch (e: Exception) {
      _response.value = "Failure: ${e.message}"
    }
  }
}

Add BindingAdapters.kt.

// BindingAdapters.kt
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
  imgUrl?.let {
    val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
    Glide.with(imgView.context)
      .load(imgUri)
      .into(imgView)
  }
}

Update gridview_item.xml.

<layout ...>
  <ImageView
      android:id="@+id/mars_image"
      ...
      app:imageUrl="@{viewModel.property.imgSrcUrl}" />

  <data>
    <variable
      name="viewModel"
      type="com.example.android.marsrealestate.overview.OverviewViewModel" />
  </data>
</layout>

Change the layout from FragmentOverviewBinding to GridViewItemBinding in the fragment to quick test.

// val binding = FragmentOverviewBinding.inflate(inflater)
val binding = GridViewItemBinding.inflate(inflater)

Add loading and broken image

Add files in res/drawable. Here, ic_broken_image.xml and loading_animation.xml.

Update BindingAdapters.kt and reflect this file in bindImage().

// BindingAdapter.kt
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
  imgUrl?.let {
    val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
    Glide.with(imgView.context)
      .load(imgUri)
      .apply(RequestOptions()
        .placeholder(R.drawable.loading_animation)
        .error(R.drawable.ic_broken_image))
      .into(imgView)
  }
}

Update RecyclerView with loaded data

// OverviewViewModel.kt
private val _properties = MutableLiveData<List<MarsProperty>>()
val properties: LiveData<List<MarsProperty>>
  get() = _properties

// ...
private fun getMarsRealEstateProperties() {
  viewModelScope.launch {
    try {
      _properties.value = MarsApi.retrofitService.getProperties()
      _response.value = "Success: Mars properties retrieved"
    } catch (e: Exception) {
      _response.value = "Failure: ${e.message}"
    }
  }
}

Update gridview_item.xml for the each property.

<layout ...>
  <ImageView
    android:id="@+id/mars_image"
    ...
    app:imageUrl="@{property.imgSrcUrl}" />

  <data>
    <variable
      name="property"
      type="com.example.android.marsrealestate.network.MarsProperty" />
  </data>
</layout>

Change the layout back to FragmentOverviewBinding in the fragment.

val binding = FragmentOverviewBinding.inflate(inflater)
// val binding = GridViewItemBinding.inflate(inflater)

Replace <TextView> to <RecyclerView> in fragment_overview.xml.

<androidx.recyclerview.widget.RecyclerView
  android:id="@+id/photos_grid"
  android:layout_width="0dp"
  android:layout_height="0dp"
  android:padding="6dp"
  android:clipToPadding="false"
  app:layoutManager=
      "androidx.recyclerview.widget.GridLayoutManager"
  app:layout_constraintBottom_toBottomOf="parent"
  app:layout_constraintLeft_toLeftOf="parent"
  app:layout_constraintRight_toRightOf="parent"
  app:layout_constraintTop_toTopOf="parent"
  app:spanCount="2"
  tools:itemCount="16"
  tools:listitem="@layout/grid_view_item" />

Then, Add the photo grid adapter.

// PhotoGridAdapter.kt
class PhotoGridAdapter : ListAdapter<MarsProperty, PhotoGridAdapter.MarsPropertyViewHolder>(DiffCallback) {

  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MarsPropertyViewHolder {
    return MarsPropertyViewHolder(GridViewItemBinding.inflate(LayoutInflater.from(parent.context)))
  }

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

  companion object DiffCallback : DiffUtil.ItemCallback<MarsProperty>() {
    override fun areItemsTheSame(oldItem: MarsProperty, newItem: MarsProperty): Boolean {
      return oldItem == newItem
    }

    override fun areContentsTheSame(oldItem: MarsProperty, newItem: MarsProperty): Boolean {
      return oldItem.id == newItem.id
    }
  }

  class MarsPropertyViewHolder(private var binding: GridViewItemBinding) : RecyclerView.ViewHolder(binding.root) {
    fun bind(marsProperty: MarsProperty) {
      binding.property = marsProperty
      binding.executePendingBindings()
    }
  }
}

Update the Binding adapter.

// BindingAdapters.kt
@BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView, data: List<MarsProperty>?) {
  val adapter = recyclerView.adapter as PhotoGridAdapter
  adapter.submitList(data)
}

Update the fragment.

<!-- fragment_overview.xml -->
<RecyclerView ...
  app:listData="@{viewModel.properties}" />

Add the adapter into the fragment.

binding.photosGrid.adapter = PhotoGridAdapter()
// Note: photos_grid is a name of the recycler view

Add error handling in RecyclerView

Add status in the view model.

enum class MarsApiStatus { LOADING, ERROR, DONE }

class OverviewViewModel : ViewModel() {

  // ...

  private val _status = MutableLiveData<MarsApiStatus>()
  val status: LiveData<MarsApiStatus>
    get() = _status

  private fun getMarsRealEstateProperties() {
    viewModelScope.launch {
      _status.value = MarsApiStatus.LOADING
      try {
        _properties.value = MarsApi.retrofitService.getProperties()
        _status.value = MarsApiStatus.DONE

      } catch (e: Exception) {
        _status.value = MarsApiStatus.ERROR
        _properties.value = ArrayList()
      }
    }
  }
}

Update BindingAdapters.kt.

// BindingAdapters.kt

@BindingAdapter("marsApiStatus")
fun bindStatus(statusImageView: ImageView, status: MarsApiStatus?) {
  when (status) {
    MarsApiStatus.LOADING -> {
      statusImageView.visibility = View.VISIBLE
      statusImageView.setImageResource(R.drawable.loading_animation)
    }
    MarsApiStatus.ERROR -> {
      statusImageView.visibility = View.VISIBLE
      statusImageView.setImageResource(R.drawable.ic_connection_error)
    }
    MarsApiStatus.DONE -> {
      statusImageView.visibility = View.GONE
    }
  }
}

Then, add the image view for the status.

<!-- fragment_overview.xml -->
<ImageView
   android:id="@+id/status_image"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:marsApiStatus="@{viewModel.status}" />

다음 챕터: Android Kotlin Fundamentals and detail views with internet data

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

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

색상을 바꿔요

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

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