Tina Tang's Blog

在哪裡跌倒了,就在哪裡躺下來

0%

Android筆記(20)-專案:Dogglers應用程式

運用版面配置(Layout)知識,在 Android Studio 中構建可滾動的 Dogglers 應用程式,並針對程式碼執行測試,以確保一切正常。

學習目標

  • 運用在單元 2 中學到的技巧,建構一個名為 Doggler 的應用程式,以便在 RecyclerView 中顯示資訊。

應用程式總覽

在 Google,我們會親切地稱呼同事為「Googler」(Google 員工)。由於許多 Google 員工都有養狗,所以我們覺得為狗狗朋友建構一款名為 Dogglers 的應用程式,應該會很有趣。你的任務是實作 Dogglers,此應用程式會顯示捲動清單來列出 Google 員工的寵物狗,還會提供每隻寵物狗的一些資訊,包括名字、年齡、嗜好和相片。在此專案中,您會在 Dogglers 應用程式中為 RecyclerView 項目建構 Layout,並實作 Adapter,以便透過以下三種方式來呈現寵物狗清單:水平捲動、垂直捲動和垂直捲動格狀版面配置。

啟動應用程式時,您會看到 水平(horizontal)垂直(vertical)格狀版面配置(grid layouts) 三個選項。

第一個選項是垂直捲動的 recycler view,items 會占據畫面的全寬度。

第二個選項是在水平捲動的 recycler view中,顯示狗狗 list。

第三個選項是在垂直捲動的格狀版面配置(grid layouts)中顯示狗狗,每一列顯示兩隻狗。

這些版面配置全都由相同的 adapter 類別提供支援。您的工作是為 recycler view card 建構版面配置,然後實作adapter,以便為每個 item 填入每隻寵物狗的相關資訊。


下載專案程式碼

請注意,資料夾名稱是 android-basics-kotlin-dogglers-app。在 Android Studio 中開啟專案時,請選取這個資料夾。

範例程式碼網址:
https://github.com/google-developer-training/android-basics-kotlin-dogglers-app/tree/main
具有範例程式碼的分支版本名稱:main

  1. 前往專案所在的 GitHub 儲存庫,下載 main 分支程式碼。

  2. 在Android Studio 中開啟專案

  3. 按一下「Run」按鈕即可建構並執行應用程式。請確認應用程式的建構符合預期。

專案會整理成個別的套件。雖然已實作大部分功能,但您需要實作 DogCardAdapter。此外,您還需要修改兩個版面配置(Layout)檔案。我們會視需要在以下操作說明中討論其他檔案。


實作Layout

垂直和水平版面配置都相同,因此您只需為這兩種版面配置實作一個 Layout 檔案。格狀版面配置會顯示所有相同的資訊,但寵物狗的名字、年齡和嗜好都以垂直方式堆疊,因此在這種情況下,您需要單獨的 Layout。這兩種版面配置都需要四種不同 View,才能顯示每隻狗狗的相關資訊。

  1. 含有狗狗相片的 ImageView
  2. 含有狗狗名字的 TextView
  3. 含有狗狗年齡的 TextView
  4. 含有狗狗嗜好的 TextView

您也會發現每張卡片上都有一些樣式,顯示了邊框和陰影。這項操作由 MaterialCardView 處理,已新增到範例專案的版面配置檔案中。每個 MaterialCardView 中都有 ConstraintLayout,您需要在其中新增其餘的View。

提示: 您可以為每個View使用任何您所選的 ID,但請注意,這兩種 Layout 都使用相同的 ViewHolder 類別,因此請務必確認在各 Layout 中,為相應的 View 使用相同的 ID。舉例來說,格狀 Layout 和水平/垂直 Layout 都有 ID 為 dog_nameTextView

您需要兩個 XML 檔案來實作版面配置:使用 vertical_horizontal_list_item.xml 實作水平和垂直 Layout,使用 grid_list_item.xml 實作格狀 Layout。

  1. 建構垂直和水平清單的 Layout。
    開啟 vertical_horizontal_list_item.xml,然後在內部 ConstraintLayout 中建構與圖片相符的版面配置。

⭐️ vertical_horizontal_list_item.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2021 The Android Open Source Project.
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<com.google.android.material.card.MaterialCardView
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardElevation="4dp"
app:cardCornerRadius="4dp">

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">

<!-- TODO: 請注意,與grid view list不同,垂直和水平list中的一張卡
有效地佔據了螢幕的寬度。 這意味著您擁有更多顯示跨越卡片寬度的資訊的
空間。 -->

<!-- TODO: 為狗的圖像建立一個 ImageView 資源。
高度應為 194dp
寬度應與卡片的寬度相符
scaleType 應設定為 centerCrop -->
<ImageView
android:id="@+id/dog_image"
android:layout_width="match_parent"
android:layout_height="194dp"
android:importantForAccessibility="no"
android:scaleType="centerCrop"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@drawable/tzeitel" />

<!-- TODO: 為下列各項建立一個 TextView:
The dog's name
The dog's age
The dog's hobbies -->
<TextView
android:id="@+id/dog_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="Tzeitel"
android:textAppearance="?attr/textAppearanceHeadline6"
app:layout_constraintTop_toBottomOf="@+id/dog_image"
app:layout_constraintStart_toStartOf="parent"
android:paddingTop="8dp"
android:paddingStart="8dp" />

<TextView
android:id="@+id/dog_age"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="@string/dog_age"
android:textAppearance="?attr/textAppearanceBody1"
app:layout_constraintTop_toBottomOf="@+id/dog_name"
app:layout_constraintStart_toStartOf="parent"
android:paddingTop="16dp"
android:paddingStart="8dp"
android:paddingBottom="8dp"/>

<TextView
android:id="@+id/dog_hobbies"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="@string/dog_hobbies"
android:textAppearance="?attr/textAppearanceBody1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/dog_name"
android:paddingTop="16dp"
android:paddingBottom="8dp"
android:paddingEnd="8dp"/>


</androidx.constraintlayout.widget.ConstraintLayout>

</com.google.android.material.card.MaterialCardView>

vertical_horizontal_list_item.xml 預覽畫面:

  1. 建構格狀版面配置。
    開啟 grid_list_item.xml,然後在內部 ConstraintLayout 中建構與圖片相符的版面配置。

⭐️ grid_list_item.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2021 The Android Open Source Project.
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<com.google.android.material.card.MaterialCardView 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"
android:layout_margin="8dp"
app:cardCornerRadius="4dp"
app:cardElevation="4dp">

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">

<!-- TODO: 請注意,此list item將在需要不同佈局的grid view中使用,
因為該list將有兩列卡片。 這表示卡片內的資訊必須垂直堆疊,因為寬度
方面的空間較小。 -->

<!-- TODO: 為狗的圖像建立一個 ImageView 資源。
高度應為 194dp
寬度應與卡片的寬度相符
scaleType 應設定為 centerCrop -->
<ImageView
android:id="@+id/dog_image"
android:layout_width="match_parent"
android:layout_height="194dp"
android:importantForAccessibility="no"
android:scaleType="centerCrop"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@drawable/leroy" />

<!-- TODO: 為下列各項建立一個 TextView:
The dog's name
The dog's age
The dog's hobbies -->
<TextView
android:id="@+id/dog_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="Tzeitel"
android:textAppearance="?attr/textAppearanceHeadline6"
app:layout_constraintTop_toBottomOf="@+id/dog_image"
app:layout_constraintStart_toStartOf="parent"
android:paddingTop="8dp"
android:paddingStart="8dp" />

<TextView
android:id="@+id/dog_age"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="@string/dog_age"
android:textAppearance="?attr/textAppearanceBody1"
app:layout_constraintTop_toBottomOf="@+id/dog_name"
app:layout_constraintStart_toStartOf="parent"
android:paddingTop="16dp"
android:paddingStart="8dp"
android:paddingBottom="8dp"/>

<TextView
android:id="@+id/dog_hobbies"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="@string/dog_hobbies"
android:textAppearance="?attr/textAppearanceBody1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/dog_age"
android:paddingTop="16dp"
android:paddingBottom="8dp"
android:paddingStart="8dp"/>

</androidx.constraintlayout.widget.ConstraintLayout>

</com.google.android.material.card.MaterialCardView>

grid_list_item.xml 預覽畫面:


實作Adapter

定義版面配置後,下一步就是實作 RecyclerView 轉接程式(Adapter)。在Adapter套件中開啟 DogCardAdapter.kt。您會看到許多 TODO 註解,說明您要導入的項目。

實作Adapter需要 5 個步驟。

  1. 定義狗狗資料list的變數或常數。此list位於名為 DataSource 物件的 data 套件中,如下所示:
1
2
3
4
5
object DataSource {

val dogs: List<Dog> = listOf( ...

}

dogs 屬性的類型是 List<Dog>Dog 類別位於 model 套件中,並定義了 4 個屬性:1 張圖片 (由資源 ID 表示) 和 3 個 String 屬性。

1
2
3
4
5
6
data class Dog(
@DrawableRes val imageResourceId: Int,
val name: String,
val age: String,
val hobbies: String
)

將您在 DogCardAdapter 定義的變數設為 DataSource 物件中的 dogs list。
⭐️ DogCardAdapter.kt

1
2
3
4
5
6
7
class DogCardAdapter(
private val context: Context?,
private val layout: Int
): RecyclerView.Adapter<DogCardAdapter.DogCardViewHolder>() {

// 使用data/DataSource中的list初始化數據
private val dogList = DataSource.dogs
  1. 實作 DogCardViewHolder。View Holder 應綁定要為每個 Recycler 檢視卡片設定的 4 個View。grid_list_itemvertical_horizontal_list_item Layout 都會共用相同的 View Holder,原因是所有 View 都會在這兩個 Layout 之間共用。DogCardViewHolder 應包含下列 View ID 的屬性:dog_imagedog_namedog_agedog_hobbies

⭐️ DogCardAdapter.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 初始化view elements
*/
class DogCardViewHolder(view: View?): RecyclerView.ViewHolder(view!!) {
// 宣告並初始化所有list item UI 元件
/* 小筆記:?和!!的意思
?:做 null check 後,不為空的話再執行
!!:堅持不會是空值,執行就是了 */
val dogImageView: ImageView? = view!!.findViewById(R.id.dog_image)
val dogNameTextView: TextView? = view!!.findViewById(R.id.dog_name)
val dogAgeTextView: TextView? = view!!.findViewById(R.id.dog_age)
val dogHobbiesTextView: TextView? = view!!.findViewById(R.id.dog_hobbies)
}
  1. onCreateViewHolder() 中,建議您加載 grid_list_itemvertical_horizontal_list_item Layout。如何得知要使用哪個 Layout 呢?在轉接程式的定義中,您會看到在建構轉接程式執行個體時,系統會傳遞一個名為 layout 且類型為 Int 的值。
1
2
3
4
class DogCardAdapter(
private val context: Context?,
private val layout: Int
): RecyclerView.Adapter<DogCardAdapter.DogCardViewHolder>() {

該值與 const 套件中 Layout 物件定義的值相對應。

1
2
3
4
5
object Layout {
val VERTICAL = 1
val HORIZONTAL = 2
val GRID = 3
}

layout 的值會是 1、2 或 3,但您可以根據 Layout 物件中的 ID VERTICALHORIZONTALGRID 來做交互檢查。

如果是 GRID Layout,請加載 grid_list_item;如果是 HORIZONTALVERTICAL Layout,請加載 vertical_horizontal_list_item。此方法應傳回 DogCardViewHolder 執行個體,代表加載後的 Layout。

提示: 您可以使用條件陳述式 (例如 if 或 when),根據 Layout 類型設定變數,如果是 GRID,應設定 grid_list_item;如果是 VERTICALHORIZONTAL,則應設定 vertical_horizontal_list_item。取得正確的 Layout ID 後,只要將 ID 傳入方法來加載 Layout 即可。

⭐️ DogCardAdapter.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DogCardViewHolder {
// 使用conditional決定layout type並進行對應設定。
// 如果layout variable是 Layout.GRID,則應使用grid list item。
// 非Layout.GRID則應使用vertical/horizontal list item。
val adapterLayout: View
if (layout == GRID) {
adapterLayout = LayoutInflater.from(parent.context).inflate(R.layout.grid_list_item, parent, false)
} else {
adapterLayout = LayoutInflater.from(parent.context).inflate(R.layout.vertical_horizontal_list_item, parent, false)
}

/* 小筆記:LayoutInflater的意思
Inflater是指打氣機的意思,比如把氣球充滿氣,所以LayoutInflater就是把layout充氣充滿的機制,
可以想像成製作出一個layout的意思。 */

//另一個方法(用when)
/* val adapterLayout = when (layout) {
// Inflate the layout
GRID -> LayoutInflater.from(parent.context).inflate(R.layout.grid_list_item, parent, false)
else -> LayoutInflater.from(parent.context).inflate(R.layout.vertical_horizontal_list_item, parent, false)
} */

// 不應將Null傳遞到view holder。 應該更新它以反映inflated layout。
return DogCardViewHolder(adapterLayout)
}
  1. 實作 getItemCount() 即可傳回狗狗清單的長度。

⭐️ DogCardAdapter.kt

1
2
3
4
override fun getItemCount(): Int {
// 回傳data set的大小
return dogList.size
}
  1. 最後,您需要實作 onBindViewHolder(),才能在每個 Recycler View Card 中設定資料。使用 position 存取清單正確的狗狗資料,並設定圖片和狗狗名字。使用字串資源 dog_agedog_hobbies 正確設定年齡和嗜好的格式。

提示:在 onBindViewHolder() 方法中,我們已經定義一個 resources 變數,您可以借此參照字串資源,不必每次都使用 context?.resources

⭐️ DogCardAdapter.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
override fun onBindViewHolder(holder: DogCardViewHolder, position: Int) {
// 取得current position的data
val dogData = dogList[position]
// 設定current dog的image resource
holder.dogImageView?.setImageResource(dogData.imageResourceId)
// 設定current dog's name的text
holder.dogNameTextView?.text = dogData.name
// 設定current dog's age的text
val resources = context?.resources
holder.dogAgeTextView?.text = resources?.getString(R.string.dog_age, dogData.age)
// 透過將hobbies傳遞給R.string.dog_hobbies字串來設定current dog's hobbies的text。
// 將參數傳遞給字串資源如下所示:
// resources?.getString(R.string.dog_hobbies, dog.hobbies)
holder.dogHobbiesTextView?.text = resources?.getString(R.string.dog_hobbies, dogData.hobbies)
}

實作 adapter 後,請在模擬器上執行應用程式,驗證是否已正確實作所有程式碼。


完整程式碼

DogCardAdapter.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
/*
* Copyright (C) 2021 The Android Open Source Project.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.dogglers.adapter

import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.example.dogglers.R
import com.example.dogglers.const.Layout.GRID
import com.example.dogglers.data.DataSource

/**
* Adapter to inflate the appropriate list item layout and populate the view with information
* from the appropriate data source
*/
class DogCardAdapter(
private val context: Context?,
private val layout: Int
): RecyclerView.Adapter<DogCardAdapter.DogCardViewHolder>() {

// 使用data/DataSource中的list初始化數據
private val dogList = DataSource.dogs

/**
* 初始化view elements
*/
class DogCardViewHolder(view: View?): RecyclerView.ViewHolder(view!!) {
// 宣告並初始化所有list item UI 元件
/* 小筆記:?和!!的意思
?:做 null check 後,不為空的話再執行
!!:堅持不會是空值,執行就是了 */
val dogImageView: ImageView? = view!!.findViewById(R.id.dog_image)
val dogNameTextView: TextView? = view!!.findViewById(R.id.dog_name)
val dogAgeTextView: TextView? = view!!.findViewById(R.id.dog_age)
val dogHobbiesTextView: TextView? = view!!.findViewById(R.id.dog_hobbies)
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DogCardViewHolder {
// 使用conditional決定layout type並進行對應設定。
// 如果layout variable是 Layout.GRID,則應使用grid list item。
// 非Layout.GRID則應使用vertical/horizontal list item。
val adapterLayout: View
if (layout == GRID) {
adapterLayout = LayoutInflater.from(parent.context).inflate(R.layout.grid_list_item, parent, false)
} else {
adapterLayout = LayoutInflater.from(parent.context).inflate(R.layout.vertical_horizontal_list_item, parent, false)
}

/* 小筆記:LayoutInflater的意思
Inflater是指打氣機的意思,比如把氣球充滿氣,所以LayoutInflater就是把layout充氣充滿的機制,
可以想像成製作出一個layout的意思。 */

//另一個方法(用when)
/* val adapterLayout = when (layout) {
// Inflate the layout
GRID -> LayoutInflater.from(parent.context).inflate(R.layout.grid_list_item, parent, false)
else -> LayoutInflater.from(parent.context).inflate(R.layout.vertical_horizontal_list_item, parent, false)
} */

// 不應將Null傳遞到view holder。 應該更新它以反映inflated layout。
return DogCardViewHolder(adapterLayout)
}

override fun getItemCount(): Int {
// 回傳data set的大小
return dogList.size
}

override fun onBindViewHolder(holder: DogCardViewHolder, position: Int) {
// 取得current position的data
val dogData = dogList[position]
// 設定current dog的image resource
holder.dogImageView?.setImageResource(dogData.imageResourceId)
// 設定current dog's name的text
holder.dogNameTextView?.text = dogData.name
// 設定current dog's age的text
val resources = context?.resources
holder.dogAgeTextView?.text = resources?.getString(R.string.dog_age, dogData.age)
// 透過將hobbies傳遞給R.string.dog_hobbies字串來設定current dog's hobbies的text。
// 將參數傳遞給字串資源如下所示:
// resources?.getString(R.string.dog_hobbies, dog.hobbies)
holder.dogHobbiesTextView?.text = resources?.getString(R.string.dog_hobbies, dogData.hobbies)
}
}

參考資料

HemantSachdeva/Dogglers
Kotlin ?!! 這些符號到底什麼意思
[Android] LayoutInflater的使用概念
HackMD 快速入門教學 - 嵌入影片