Station list screen (1)

最近两篇都是讲 navigation component,入面为了示范设定 navigation 我们已经预先准备了两页的 Fragment class 和 layout XML,这样我们之後就不用再跳去设定 navigation 的东西。现在开始会开始正式实作 app 的界面部分。我们会由车站列表页开始实作,现在看看完成品:

https://ithelp.ithome.com.tw/upload/images/20211004/201396665sDAMUycKD.png

这页基本上就是一个 RecyclerView,当用户点击路綫时就会展开其车站,再点击车站就会开启抵站时间页面。如果想做到缩放车站名的话,最简单的方法就是当路綫名是一种 view type、车站名是另一种 view type,只需要准备好一个 List 交予 ListAdapter 让它帮我们 render 就可以了。而路綫名旁边的三角形 icon 会随着车站名是否展开来决定显示那一款 icon。这个亦可以在那个 List 时顺带提供给 ListAdapter 知道就可以了。

ListAdapterRecyclerView.Adapter 的 subclass,特色是它已经为我们准备了 AsyncListDiffer 计算更新列表时那些 list item 如何处理(例如更换现有项目、删除项目还是中途插入一个新项目之类)和 submitList(List)(提交要显示的 List)。我们只需要准备一个 DiffUtil.ItemCallback 和一些平时 RecyclerView.Adapter 都会做的东西(ViewHolder class、onCreateViewHoldergetItemViewTypeonBindViewHolder)就可以了,其余那些 notifyDataSetChanged、在 RecyclerView.Adapter 准备一个 List field 来储存现在显示的内容之类我们都不用处理,因为 ListAdapter 已经帮我们做好了。

Dependency

由於我们开始实作 UI 的部分,lifecycle 和其他 UI 的 dependency 是不能缺的。

implementation "com.google.android.material:material:$materialVersion"
implementation "androidx.constraintlayout:constraintlayout:$constraintLayoutVersion"
implementation "androidx.activity:activity-ktx:$activityKtxVersion"
implementation "androidx.fragment:fragment-ktx:$fragmentKtxVersion"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"

列表内容 Data class

我们首先要写的是表示列表内容的 data class。我们会用 sealed interface 包住两个 data class:Group 是路綫名、Child 是车站名。路綫和车站我们仍会以 enum 表示,直到要显示一刻才会转做 StringGroupisExpanded 就是用来表示那个路綫是否被展开,而 Child 要有 LineStation 是因为当我们点击车站名时,要提供这两样东西才能进入抵站时间页。

sealed interface StationListItem {
    data class Group(
        val line: Line,
        val isExpanded: Boolean,
    ) : StationListItem

    data class Child(
        val line: Line,
        val station: Station,
    ) : StationListItem
}

DiffUtil.ItemCallback

做好了 StationListItem 後,我们就可以做 DiffUtil.ItemCallback。这个 class 就是 ListAdapter 能够不用我们 call notifyDataSetChangednotifyItemChanged 之类的 method 都能做到那些动画的原因。DiffUtil 背後是用了 Eugene W. Myers 的 difference algorithm 来计算两条 List 之间的变动,代你 call 了那些 notifyItemChangednotifyItemRangeRemoved method。效能会比直接 call notifyDataSetChanged 更佳。而 ListAdapter 用了 AsyncListDiffer,就是把 DiffUtil 计算的动作放去非 UI thread 上面执行来提升效能。

DiffUtil.ItemCallback 的写法非常简单,一般只需要 override 两个 method:areItemsTheSameareContentsTheSame。前者是判断两个 object 的 ID 是否相同;後者是判断两个 object 是否完全相同。如果 areItemsTheSame return false,那 areContentsTheSame 就不会被执行。

object DiffCallback : DiffUtil.ItemCallback<StationListItem>() {
    override fun areItemsTheSame(oldItem: StationListItem, newItem: StationListItem): Boolean =
        when {
            oldItem is Group && newItem is Group -> oldItem.line == newItem.line
            oldItem is Child && newItem is Child -> oldItem.line == newItem.line && oldItem.station == oldItem.station
            else -> false
        }

    override fun areContentsTheSame(
        oldItem: StationListItem,
        newItem: StationListItem
    ): Boolean = when {
        oldItem is Group && newItem is Group -> oldItem == newItem
        oldItem is Child && newItem is Child -> oldItem == newItem
        else -> false
    }
}

我们用 object 而不是普通 class 的原因是因为它没有 side effect。在 areItemsTheSame 中,由於 Child 本身没有 ID 让我们比较,所以只能以 linestation 作对比。

至於 areContentsTheSame 因为我们本身 GroupChild 都是 data class,所以我们可以放心用 Kotlin compiler 生成的 equals 来做比较。

Item type layout XML

首先是路綫 item type,这次我们会用 data binding,因为又有 onClick、显示文字和切换三角形 icon。以下是 station_list_line_item.xml 的内容:

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="group"
            type="net.swiftzer.etademo.presentation.stationlist.StationListItem.Group" />

        <variable
            name="presenter"
            type="net.swiftzer.etademo.presentation.stationlist.LineStationPresenter" />

        <variable
            name="callback"
            type="net.swiftzer.etademo.presentation.stationlist.StationListAdapter.Callback" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="48dp"
        android:background="?selectableItemBackground"
        android:clickable="true"
        android:focusable="true"
        android:gravity="center_vertical"
        android:onClick="@{() -> callback.toggleExpanded(group.line)}"
        android:paddingStart="16dp"
        android:paddingEnd="16dp">

        <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:contentDescription="@null"
            android:src="@{group.expanded ? @drawable/ic_baseline_arrow_drop_down_24 : @drawable/ic_baseline_arrow_end_24}"
            tools:src="@drawable/ic_baseline_arrow_drop_down_24" />

        <com.google.android.material.textview.MaterialTextView
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:layout_weight="1"
            android:ellipsize="end"
            android:maxLines="1"
            android:text="@{presenter.mapLine(group.line)}"
            android:textAlignment="viewStart"
            android:textAppearance="?textAppearanceBody1"
            tools:text="@tools:sample/cities" />
    </LinearLayout>
</layout>

https://ithelp.ithome.com.tw/upload/images/20211004/20139666gepexR2lHa.png

由於是用了 data binding,所以 XML 档的 root tag 是 <layout> 而不是 <LinearLayout><data> 就是用来放 Java imports 和 data binding 用到的 variable。三个 variable 分别是:

  1. group 就是路綫的 object
  2. presenter 这个跟 MVP 的 presenter 没甚麽关系,只是放了从 enum 取得车站路綫名称的 code,方便重用
  3. callback 当按下路綫或车站时会 call 的 callback

接着我们看看 data binding 的特有写法。第一个是 <LinearLayout>android:onClick@{() -> callback.toggleExpanded(group.line)} 其实是 android.view.View.OnClickListener 的实作,不过用 lambda 来写。凡是 data binding 都要用 @{} 包住,入面就是写 Java code(不是 Kotlin)。整句的意思是当 onClick 时就会执行我的 lambda,因为 OnClickListeneronClick 第一个参数是 View 但我们不会用到,所以直接写 () 就可以了。而 lambda 的内容就是 call callback.toggleExpanded(group.line),用来通知按了这条路綫。关於这个 callback 我们之後会介绍。

另一个要看的位置是 <ImageView>android:src@{group.expanded ? @drawable/ic_baseline_arrow_drop_down_24 : @drawable/ic_baseline_arrow_end_24} 其实就是 Java ternary operator (这个写法反而 Kotlin 没有,一定要写成 if (...) ... else ....)。全句的意思是如果 group.expandedtrue 就显示 @drawable/ic_baseline_arrow_drop_down_24 否则就显示 @drawable/ic_baseline_arrow_end_24。Data binding 可以用 @drawable 引用 drawable resource,其他 resource type 例如 @string@plurals 都可以用同样写法。

还有一个要看的位置是 <MaterialTextView>android:text。这次我们只是显示由 enum 取得的路綫名称。由於我们会在好几个地方用到这个转换逻辑,所以把它放到另一个 class。而 presenter.mapLine 会 return String ,那就可以交予 MaterialTextView 显示。

另一个 layout XML 是车站名称,档案名称是 station_list_station_item.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="child"
            type="net.swiftzer.etademo.presentation.stationlist.StationListItem.Child" />

        <variable
            name="presenter"
            type="net.swiftzer.etademo.presentation.stationlist.LineStationPresenter" />

        <variable
            name="callback"
            type="net.swiftzer.etademo.presentation.stationlist.StationListAdapter.Callback" />
    </data>

    <com.google.android.material.textview.MaterialTextView
        android:layout_width="match_parent"
        android:layout_height="48dp"
        android:background="?selectableItemBackground"
        android:clickable="true"
        android:ellipsize="end"
        android:focusable="true"
        android:gravity="center_vertical|start"
        android:maxLines="1"
        android:onClick="@{() -> callback.onClickLineAndStation(child.line, child.station)}"
        android:paddingStart="56dp"
        android:paddingEnd="16dp"
        android:text="@{presenter.mapStation(child.station)}"
        android:textAlignment="viewStart"
        android:textAppearance="?textAppearanceBody2"
        tools:text="@tools:sample/cities" />
</layout>

https://ithelp.ithome.com.tw/upload/images/20211004/2013966687Q3Jh9Qdh.png

这次我不解释了,因为跟刚才那个大同小异。

Callback

刚才 layout XML 看到的 callback 就是这样子,没甚麽特别:

interface Callback {
    fun toggleExpanded(line: Line)
    fun onClickLineAndStation(line: Line, station: Station)
}

Presenter

刚才在 layout XML 看到 LineStationPresenter 就是这样子:

@ActivityScoped
class LineStationPresenter @Inject constructor(@ActivityContext context: Context) {
    private val language = context.resources.configuration.appLanguage

    fun mapLine(line: Line): String = when (language) {
        Language.CHINESE -> line.zh
        Language.ENGLISH -> line.en
    }

    fun mapStation(station: Station): String = when (language) {
        Language.CHINESE -> station.zh
        Language.ENGLISH -> station.en
    }
}

val Configuration.appLanguage: Language
    get() = if (ConfigurationCompat.getLocales(this)[0].language == Locale.CHINESE.language) {
        Language.CHINESE
    } else {
        Language.ENGLISH
    }

目的就是拿 ActivityContext 来得知现在的 Locale 然後决定输出中文还是英文的路綫车站名。@ActivityScoped 是 Dagger Hilt 的 annotation,意思是这个 object 是跟随 ActivityComponent 的生死。而 @ActivityContext 是 Dagger Hilt 提供的 qualifier,意思是我们要拿到 AcitivityContext 而不是 ApplicationContext。我们不造新的 binding adapter 来做转换是因为 binding adapter 是整个 app 都能用到,没有 namespace。如果做的转换只是个别 feature 才会用到的话我觉得不用 binding adapter 比较好。

留意凡是 resource 的东西都不应放在 ViewModel 内转换,这是因为界面语言是可以随时转换,如果用 ApplicationContext 来决定显示甚麽语言会出现不一致。layout XML 用到的 string resource 就按最新语言设定显示,因为跟随 configuration change 重新建立 Activity 而重新 inflate layout XML;但如果从 ViewModel 的 constructor 取得 Application Context 的话,因为 ViewModel 能在 configuration change 後存活,那就是用 configuration change 之前的 Context 导致显示的文字不是按照最新语言设定。

如果不喜欢另外准备一个自订的 class 放那些 enum 转换文字的 code 的话,可以考虑使用 data binding 的 converter 功能

ViewHolder

由於控制各 UI widget 显示甚麽东西都交予 layout XML 用 data binding 控制,所以 ViewHolder 的角式就变了设置 data binding 和交资料给 data binding。现附上两个 ViewHolder 的 code:

class LineItemViewHolder(
    private val binding: StationListLineItemBinding,
    lifecycleOwner: LifecycleOwner,
    presenter: LineStationPresenter,
    callback: Callback,
) : RecyclerView.ViewHolder(binding.root) {
    init {
        binding.lifecycleOwner = lifecycleOwner
        binding.presenter = presenter
        binding.callback = callback
    }

    fun bind(group: StationListItem.Group) {
        binding.group = group
    }
}
class StationItemViewHolder(
    private val binding: StationListStationItemBinding,
    lifecycleOwner: LifecycleOwner,
    presenter: LineStationPresenter,
    callback: Callback,
) : RecyclerView.ViewHolder(binding.root) {
    init {
        binding.lifecycleOwner = lifecycleOwner
        binding.presenter = presenter
        binding.callback = callback
    }

    fun bind(child: StationListItem.Child) {
        binding.child = child
    }
}

或许你有看过一些教学或其他人会在改变 binding variable 後要 call binding.executePendingBindings() 触发 UI 更新,但我们却没有这样做。这是因为我们交了 LifecycleOwner 给 binding。如果我们交的 variable 是 LiveDataStateFlow,只要预先设定好 LifecycleOwner 就能自动触发 UI 更新。这就是我们刻意在 ViewHolder 的 constructor 要求 LifecycleOwner 的原因。而 constructor 的 binding.root 就是从 binding object 取得 root view 的写法(因为 RecyclerView.ViewHolder 的 constructor 需要 root view)。至於 bind method 就是让 ListAdapter#onBindViewHolder 能够提交当前 list item object 到 ViewHolder

Adapter

之前准备了这麽多的东西就是为了写本篇最主要的 class:StationListAdapter。首是是它的基本骨架:

class StationListAdapter(
    lifecycleOwner: LifecycleOwner,
    presenter: LineStationPresenter,
    callback: Callback,
) : ListAdapter<StationListItem, RecyclerView.ViewHolder>(StationListItem.DiffCallback) {
    private val lifecycleOwner = WeakReference(lifecycleOwner)
    private val presenter = WeakReference(presenter)
    private val callback = WeakReference(callback)

		override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        TODO()
    }

    override fun getItemViewType(position: Int): Int = when (getItem(position)) {
        TODO()
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        TODO()
    }
}

由於 ViewHolder 用了 LifecycleOwnerLineStationPresenterCallback,所以我们在 StationListAdapter 的 constructor 传入这些东西,好让我们在 onCreateViewHolder 时能够把它们传入去 ViewHolder。而我们另外造了三个 WeakReference 把 constructor 带进来的东西放入去是避免 memory leak。而 ListAdapter 的两个 type argument 分别是 list item 和 ViewHolder 的 type。由於我们有两个 ViewHolder,所以我们惟有选用 RecyclerView.ViewHolder。如果你的 ListAdapter 只有单一 ViewHolder,那可以直接用那个 ViewHolder type,这样就能在 onBindViewHolder 直接用到那个 ViewHolder 而不用 type casting。当然你可以用 sealed class/interface 来避免强行 type casting。

接着我们开始写 getItemViewType,我们以 layout XML 的 R class 来做 view type ID,这样就保证不会撞号码。

override fun getItemViewType(position: Int): Int = when (getItem(position)) {
    is StationListItem.Group -> R.layout.station_list_line_item
    is StationListItem.Child -> R.layout.station_list_station_item
    else -> throw UnsupportedOperationException("Unsupported view type at position $position")
}

然後是 onCreateViewHolder,我们会按照 view type 决定 instantiate 那一个 ViewHolder

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
    val inflater = LayoutInflater.from(parent.context)
    return when (viewType) {
        R.layout.station_list_line_item -> LineItemViewHolder(
            binding = StationListLineItemBinding.inflate(inflater, parent, false),
            lifecycleOwner = requireNotNull(lifecycleOwner.get()),
            presenter = requireNotNull(presenter.get()),
            callback = requireNotNull(callback.get()),
        )
        R.layout.station_list_station_item -> StationItemViewHolder(
            binding = StationListStationItemBinding.inflate(inflater, parent, false),
            lifecycleOwner = requireNotNull(lifecycleOwner.get()),
            presenter = requireNotNull(presenter.get()),
            callback = requireNotNull(callback.get()),
        )
        else -> throw UnsupportedOperationException("Unsupported view type $viewType")
    }
}

最後是 onBindViewHolder,由於我们用了 data binding,所以只需要提交那个 list item 进去 ViewHolder 就可以了。不过就算我们把 ViewHolder 用 sealed class/interface 包住都要替 list item 做 type casting,除非整个 list 只有一款 list item class。

override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
    when (val item = getItem(position)) {
        is StationListItem.Group -> (holder as LineItemViewHolder).bind(item)
        is StationListItem.Child -> (holder as StationItemViewHolder).bind(item)
    }
}

现在 StationListAdapter 已经完成了,完整的 code 可以到 GitHub repo 查阅。下一篇会实作 ViewModel、Fragment 的部分,届时就能完成车站列表页的部分。


<<:  【Day 20】 实作 - 於 AWS Quicksight 建立 Sankey diagram 以及设定 Action

>>:  DAY 22 『 连接 API 实作 - 天气 APP 』Part4

DAY24 迁移式学习与预训练模型

一、迁移式学习(Transfer Learning) 动机 我们在做监督式学习(Supervised...

Day 2 Python环境安装

今天的影片内容为Python环境的安装与环境变数的设定,以及推荐一个实用的编译器Notepad++ ...

Day17边框(CSS)

Border 边框样式 今天来介绍个基本的边框 <p class="solid &q...

DAY25 - 网站分析工具介绍 - 质化分析工具Hotjar

今天先来介绍一个,我第一次看到之後有点被吓到的工具 Hotjar Hotjar是一个质化的网站分析工...

为什麽会跑来这里写这题目

自从犯贱跑去参加ML新手的 Kaggle ML 30days ,脱了层皮後,开始回想一些在 in-c...