신발에 발라먹어도 맛있다는 마법 소스.
준비물
- 굴소스 2 스푼
- 케찹 한 스푼
- 다진마늘 한 스푼
- 후추 가루 훅훅훅 다섯 번
- 따임이나 허브 훅훅훅 세 번
- 올리고당 한 스푼
- 버터 두 스푼
- 매실청 한 스푼
- 굵은 고춧가루 한 스푼
조리
다 넣고 한번 부글부글 끓으면 끝! 참 쉬죠잉?
신발에 발라먹어도 맛있다는 마법 소스.
다 넣고 한번 부글부글 끓으면 끝! 참 쉬죠잉?
제주 향토 음식 갈치국.
Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals: DiffUtil and data binding with RecyclerView 부터.
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.
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. ALayoutManager
manages how the items in theRecyclerView
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)
RecyclerView
Handle clicks in the ViewModel
, not ViewHolder
because the viewmodel can access the data and determine the response.
Note: Also, the click event is able to be placed in the RecyclerView
.
Add SleepNightListener
in SleepNightAdapter.kt
.
class SleepNightListener(val clickListener: (sleepId: Long) -> Unit) {
fun onClick(night: SleepNight) = clickListener(night.nightId)
}
Add the listener in the xml layout file and use it in the click event.
<data>
<!-- ... -->
<variable
name="clickListener"
type="com.example.android.trackmysleepquality.sleeptracker.SleepNightListener" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
...
android:onClick="@{() -> clickListener.onClick(sleep)}">
<!-- ... -->
Then, update the adapter class.
// Add clickListener member
class SleepNightAdapter(val clickListener: SleepNightListener) : ListAdapter<SleepNight, SleepNightAdapter.ViewHolder>(SleepNightDiffCallback()) {
// Pass the click listener
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position), clickListener)
}
// ...
class ViewHolder private constructor(val binding: ListItemSleepNightBinding) : RecyclerView.ViewHolder(binding.root) {
// Update the function signature
// Note: Refactor tool will catch this change
fun bind(item: SleepNight, clickListener: SleepNightListener) {
binding.sleep = item
// Bind the listener into the layout
binding.clickListener = clickListener
binding.executePendingBindings()
}
// ...
}
// ...
}
Update the adapter at onCreateView()
in the fragment.
val adapter = SleepNightAdapter(SleepNightListener { nightId ->
Toast.makeText(context, "${nightId}", Toast.LENGTH_LONG).show()
})
Clean and rebuild the project if the update is not reflected.
Update the view model to handle the click event.
// SleepTrackerViewModel.kt
private val _navigateToSleepDetail = MutableLiveData<Long>()
val navigateToSleepDetail
get() = _navigateToSleepDetail
fun onSleepNightClicked(id: Long) {
_navigateToSleepDetail.value = id
}
fun onSleepDetailNavigated() {
_navigateToSleepDetail.value = null
}
Then, update the fragment to trigger onSleepNightClicked()
.
val adapter = SleepNightAdapter(SleepNightListener { nightId ->
sleepTrackerViewModel.onSleepNightClicked(nightId)
})
// add observer to handle the navigation
sleepTrackerViewModel.navigateToSleepDetail.observe(this, Observer { night ->
night?.let {
this.findNavController().navigate(
SleepTrackerFragmentDirections
.actionSleepTrackerFragmentToSleepDetailFragment(night))
sleepTrackerViewModel.onSleepDetailNavigated()
}
})
LiveData
can be null
so that SleepNight
instance also can be null
. Update BindingUtils.kt
with an optional chaining.
@BindingAdapter("sleepQualityString")
fun TextView.setSleepQualityString(item: SleepNight?) {
item?.let {
text = convertNumericQualityToString(item.sleepQuality, context.resources)
}
}
Two ways to add header in the list.
ViewHolder
for the headerAdd 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)
}
})
Updating data should not be in the UI thread. Use coroutines for that.
private val adapterScope = CoroutineScope(Dispatchers.Default)
fun addHeaderAndSubmitList(list: List<SleepNight>?) {
adapterScope.launch {
val items = when (list) {
null -> listOf(DataItem.Header)
else -> listOf(DataItem.Header) + list.map { DataItem.SleepNightItem(it) }
}
withContext(Dispatchers.Main) {
submitList(items)
}
}
}
Extend the header width by the position.
// SleepTrackerFragment.kt
val manager = GridLayoutManager(activity, 3)
manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int) = when (position) {
0 -> 3
else -> 1
}
}
다음 챕터: Android Kotlin Fundamentals: 8.1 Getting data from the internet
모카포트로 커피를 추출했을 때 쓴 맛이 나기 쉽다. 아래 방법으로는 에스프레소 보다는 연하고 드립 커피보다는 진한 느낌으로 추출됨. 깔끔한 커피 추출하는 방법:
Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals: 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()
}
})
// 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).
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()
}
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 ]
RecyclerView
instance as containerViewHolder
. It holds one item.RecyclerView
and adapts the data to ViewHolder
.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
}
})
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.
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
databaseA 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'
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
)
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.
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 layoutEliminate redundant layouts when including layouts.
// 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
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.
Dispatcher.Main
Dispatcher.IO
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
}
// 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()}" />
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}" />
// 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()}" />
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 부터.
CTRL+o
CTRL+/
(Command+/
on a Mac)Follow Android app architecture guidlines and Android Architecture Components. Similarly MVVM.
Activity
or Fragment
. Display data and capture OS/user events.ViewModel
: Hold all of the data needed for the UI and prepare it for displayViewModelFactory
: instantiates ViewModel
ViewModel
ViewModel
class to store and manage UI-releated data in a lifecycle-conscious way. (safe for device-configuration changes via ViewModelFactory
)
// 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,
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 dataLiveData
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
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()
.
MutableLiveData
for encapsulationUsing 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()
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()
}
// 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()
}
})
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)
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.
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.
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
.
// 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 부터.
CTRL+o
CTRL+/
(Command+/
on a Mac)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.
// 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 the type-safe name rather than R.id.<ID>
action ID.
view.findNavController()
.navigate(GameFragmentDirections.actionGameFragmentToGameWonFragment())
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()
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)
}
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.
[Resumed] } }-- activity has focus
| | } }
onResume onPause }
| | }
[Started] }-- activity is visible
| |
onStart onStop
onRestart |
| |
[Created]
| |
onCreate onDestroy
| |
[Initialized] [Destroyed]
[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()
.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
.
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.)
Check latest version from the Timber project page.
// build.gradle (app level)
dependencies {
// ...
implementation 'com.jakewharton.timber:timber:4.7.1'
}
Then, sync it.
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")
Activity
and Fragment
. Implement the LifecycleOwner
interfaceLifecycle
class: holds actual state of a lifecycle owner and triggers eventsLifecycleObserver
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 Course 코드랩 하면서 노트.
Alt+Enter
(Option+Enter
on a Mac)CTRL+ALT+L
(Command+Option+L
on a Mac)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.
findViewById
onceDefine member with lateint
keyword.
lateinit var diceImage : ImageView
Init the member in onCreate()
.
diceImage = findViewById(R.id.dice_image)
compileSdkVersion
targetSdkVersion
: Usually same as compileSdkVersion
minSdkVersion
onOptionsItemSelected()
in MainActivity.kt
, check res/menu/menu_main.xml
content_main
in activity_main.xml
FloatingActionButton
in activity_main
style
attributestyle="@style/NameStyle"
The style is defined in styles.xml
in res
.
android:id
When android:id
of the component need to be changed, right-click and select Refector > Rename.
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
.
Optimize your app for autofill
// 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)
android:onClick
attr to the <View>
elementsetOnClickListener(View.OnClickListener)
function in the Activity
In the activity page, a magnet icon is for Autoconnect feature.
Use Constraint Widget in Attributes section of the activity design page.
A chain is a group of views that are linked to each other with bidirectional constraints.
set the layout_constraintHorizontal_chainStyle
or the layout_constraintVertical_chainStyle
.
layout_constraintHorizontal_weight
or layout_constraintVertical_weight
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" />
Only for the layout design. Design-time attributes are prefixed with the tools
namespace. e.g. tools:layout_editor_absoluteY
, tools:text
Eliminate findViewById()
using binding objects. Benefits:
// 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">
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
}
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
// ...
}
A Fragment represents a behavior or a protion of user interface in an activity. Modular section of an activity like a "sub-activity".
"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>
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.
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" />
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)
}
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)
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.(= action bar)
Designing Back and Up navigation
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 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)
}
Drawer appears when the user swipes edge to edge or tap the nav drawer button/hamburger icon. It is a part of the material components for Android.
// build.gradle (app-level)
dependencies {
// ...
implementation "com.google.android.material:material:$supportlibVersion"
// ...
}
// then sync!
Add a drawer to the project. right-click the res folder and select New > Android Resource File.
Set navdrawer_menu
as a file name and Menu
as a resource type, then okay.
Open navdarwer_menu
and add menu items. Note: Match the ID
for the menu item as for the destination Fragment.
Add <DrawerLayout>
and wrap the entire layout in activity xml file. Also, add NavigationView
before closing it.
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.drawerlayout.widget.DrawerLayout
android:id="@+id/drawerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout>
<!-- ... -->
</LinearLayout>
<com.google.android.material.navigation.NavigationView
android:id="@+id/navView"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
app:headerLayout="@layout/nav_header"
app:menu="@menu/navdrawer_menu" />
</androidx.drawerlayout.widget.DrawerLayout>
</layout>
For swaping, connect the drawer to the navigation controller:
// onCreate() in MainActivity.kt
val binding = DataBindingUtil.setContentView<ActivityMainBinding>(
this, R.layout.activity_main)
NavigationUI.setupWithNavController(binding.navView, navController)
For drawer button, add the code bleow:
// in MainActivity.kt
private lateinit var drawerLayout: DrawerLayout
// onCreate()
drawerLayout = binding.drawerLayout
NavigationUI.setupActionBarWithNavController(this, navController, drawerLayout)
// add it as a third param
To make the Up button work with the drawer button:
// onSupportnavigateUp() in MainActivity.kt
override fun onSupportNavigateUp(): Boolean {
val navController = this.findNavController(R.id.myNavHostFragment)
return NavigationUI.navigateUp(navController, drawerLayout)
}
다음 챕터: Android Kotlin Fundamentals: 03.3 Start an external Activity
(초:초:초간단 만능 양념장으로 만드는 춘천식 닭갈비! 1:1:1:1:1:1 ㅣ 백종원의 쿠킹로그)
동일 비율로 넣고 섞는다. 각각 3큰술 하면 대략 1컵 분량. 생강 조금 넣으면 더 맛있다고.
약간 숙성돼도 맛있다는데 (3~4시간) 그냥 해도 괜찮았음.