我會把 Day 27 和昨天的 Firebase 主題錯開,改用你今天上傳的 RecyclerView + Adapter + Activity Result 範例,整理成一篇偏「實戰整合」的文章。你的程式已經不是單純顯示 RecyclerView,而是做到圖片、品名、價格、數量加減、結帳,以及 Activity 之間傳值與回傳結果。
參考:Android Developers-RecyclerView
前面已經學過 ListView、RecyclerView、Activity 與 Intent,今天不再只做「把資料顯示出來」,而是把這些東西組合起來,做一個更接近真正 App 的 飲料點餐系統。
今天要完成的流程是:
飲料資料
↓
RecyclerView
↓
Adapter
↓
顯示圖片 / 品名 / 價格
↓
使用者按 + / -
↓
修改購買數量
↓
取得最新購物資料
↓
計算小計
↓
計算總金額
↓
顯示訂單
這次真正重要的不是畫面,而是開始理解:
資料、Adapter、RecyclerView 與 Activity 到底怎麼合作?
參考資料:Android Developers
https://developer.android.com/develop/ui/views/layout/recyclerview
RecyclerView 可以用來有效率地顯示大量、可捲動的資料,例如:
RecyclerView 並不是自己知道要顯示什麼。
它主要會搭配:
Data
↓
Adapter
↓
ViewHolder
↓
item_layout.xml
↓
RecyclerView
也就是:
資料負責內容,Adapter 負責把資料放進畫面,而 RecyclerView 負責顯示與回收 Item。
參考資料:Android Developers
https://developer.android.com/develop/ui/views/layout/recyclerview
今天的程式先從 resources 取得飲料名稱、價格與圖片:
val nameArray = resources.getStringArray(R.array.name)
val priceArray = resources.getIntArray(R.array.price)
val picArray = resources.obtainTypedArray(R.array.picture)
這樣就可以把資料與 Kotlin 程式分開。
例如概念上可以想成:
name
├── 紅茶
├── 綠茶
├── 奶茶
└── 珍珠奶茶
price
├── 30
├── 30
├── 45
└── 60
picture
├── blacktea
├── greentea
├── milktea
└── bubbletea
接下來再將每一筆商品組合成一個 MutableMap。
datalist = mutableListOf<MutableMap<String, Any>>()
for (i in 0 .. nameArray.size - 1) {
val data = mutableMapOf<String, Any>()
data.put("name", nameArray[i])
data.put("price", priceArray[i])
data.put(
"pic",
picArray.getResourceId(i, R.drawable.blacktea)
)
// 一開始購買數量都是 0
data.put("qty", 0)
datalist.add(data)
}
picArray.recycle()
因此一筆商品資料就可以理解成:
{
name = "紅茶",
price = 30,
pic = R.drawable.blacktea,
qty = 0
}
這次多了一個很重要的欄位:
"qty"
它代表使用者目前購買幾杯。
參考資料:Android Developers
https://developer.android.com/develop/ui/views/layout/recyclerview
資料準備完成之後,建立 LinearLayoutManager:
val layoutManager = LinearLayoutManager(
this@MainActivity,
LinearLayoutManager.VERTICAL,
false
)
recyclerViewData.layoutManager = layoutManager
這裡使用:
LinearLayoutManager.VERTICAL
所以商品會由上往下排列:
┌──────────────────────┐
│ 🧋 紅茶 $30 - 0 + │
├──────────────────────┤
│ 🧋 綠茶 $30 - 0 + │
├──────────────────────┤
│ 🧋 奶茶 $45 - 0 + │
├──────────────────────┤
│ 🧋 珍奶 $60 - 0 + │
└──────────────────────┘
如果改成:
LinearLayoutManager.HORIZONTAL
就可以變成左右滑動的商品清單。
今天上傳的另一個花卉 RecyclerView 範例,就是使用水平排列:
val layoutManager = LinearLayoutManager(
this,
LinearLayoutManager.HORIZONTAL,
false
)
所以 RecyclerView 不一定只能做直向清單,也很適合做 App 首頁常見的水平商品列、照片列或分類選單。
參考資料:Android Developers
https://developer.android.com/develop/ui/views/layout/recyclerview
接著建立:
class MyAdapter(
private val myContext: Context,
private val datalist: MutableList<MutableMap<String, Any>>,
private val onQtyChanged: () -> Unit
) : RecyclerView.Adapter<MyAdapter.ViewHolder>()
Adapter 接收三樣東西:
Context
+
商品資料 datalist
+
數量改變 Callback
其中:
private val onQtyChanged: () -> Unit
是一個 Lambda Callback。
也就是 Adapter 裡面的數量發生改變時,可以通知外面的 Activity。
這就是之前學過 Lambda 之後,開始真正看到它在 Android 開發中的實際用途。
參考資料:Android Developers
https://developer.android.com/develop/ui/views/layout/recyclerview
每一個商品 Item 裡面有:
-
+
因此 ViewHolder 寫成:
class ViewHolder(itemView: View) :
RecyclerView.ViewHolder(itemView) {
val imageViewPic: ImageView =
itemView.findViewById(R.id.imageView_itemPic)
val textViewName: TextView =
itemView.findViewById(R.id.textView_itemName)
val textViewPrice: TextView =
itemView.findViewById(R.id.textView_itemPrice)
val editTextQty: EditText =
itemView.findViewById(R.id.editText_itemNumber)
val imageViewMinus: ImageView =
itemView.findViewById(R.id.imageView_itemMinus)
val imageViewPlus: ImageView =
itemView.findViewById(R.id.imageView_itemPlus)
}
可以把 ViewHolder 想成:
一個商品 Item
│
├── imageViewPic
├── textViewName
├── textViewPrice
├── editTextQty
├── imageViewMinus
└── imageViewPlus
RecyclerView 捲動時會重複利用這些 ViewHolder,而不是每次都重新建立所有 View,這也是 RecyclerView 名稱中「Recycle」的由來。
參考資料:Android Developers
https://developer.android.com/develop/ui/views/layout/recyclerview
接著:
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): MyAdapter.ViewHolder {
val itemView = LayoutInflater
.from(myContext)
.inflate(
R.layout.item_layout,
parent,
false
)
val viewHolder = ViewHolder(itemView)
return viewHolder
}
這裡最重要的是:
R.layout.item_layout
代表:
RecyclerView 裡面的每一列,要長成
item_layout.xml定義的樣子。
所以:
RecyclerView
│
├── item_layout
├── item_layout
├── item_layout
├── item_layout
└── item_layout
每個 Item 外觀相同,但是放進去的資料不同。
參考資料:Android Developers
https://developer.android.com/develop/ui/views/layout/recyclerview
接著才是真正將商品資料顯示到畫面:
override fun onBindViewHolder(
holder: MyAdapter.ViewHolder,
position: Int
) {
val data = datalist.get(position)
val name = data["name"].toString()
val price = data["price"] as Int
val picId = data["pic"] as Int
holder.textViewName.text = name
holder.textViewPrice.text = price.toString()
holder.imageViewPic.setImageResource(picId)
holder.editTextQty.setText(
data["qty"].toString()
)
}
其中:
position
就是目前 RecyclerView 要顯示第幾筆資料。
例如:
position = 0 → 紅茶
position = 1 → 綠茶
position = 2 → 奶茶
position = 3 → 珍珠奶茶
所以 Adapter 最重要的工作其實就是:
datalist[position]
↓
onBindViewHolder()
↓
ViewHolder
↓
畫面
參考資料:Android Developers
https://developer.android.com/develop/ui/views/layout/recyclerview
今天最有趣的地方就是數量控制。
按下 +:
holder.imageViewPlus.setOnClickListener {
var currentQty = data["qty"] as Int
currentQty++
data["qty"] = currentQty
holder.editTextQty.setText(
currentQty.toString()
)
onQtyChanged()
}
假設原本:
qty = 0
第一次按:
qty = 1
再按:
qty = 2
再按:
qty = 3
而且不是只有改畫面的 TextView / EditText。
真正重要的是:
data["qty"] = currentQty
也就是資料本身也一起修改。
這個觀念非常重要。
錯誤的思考是:
按 +
↓
畫面變 1
正確的思考應該是:
按 +
↓
修改 Data
↓
qty = qty + 1
↓
更新畫面
Data 才是真正的狀態,UI 只是把狀態顯示出來。
參考資料:Android Developers
https://developer.android.com/develop/ui/views/layout/recyclerview
減少數量時:
holder.imageViewMinus.setOnClickListener {
var currentQty = data["qty"] as Int
if (currentQty > 0) {
currentQty--
data["qty"] = currentQty
holder.editTextQty.setText(
currentQty.toString()
)
onQtyChanged()
}
}
這裡特別加:
if (currentQty > 0)
避免出現:
0
-1
-2
-3
因為買飲料不可能買 -3 杯。
所以 UI 程式除了「可以操作」,也必須考慮:
使用者可能做出什麼不合理操作?
這就是程式的邊界條件。
參考資料:Android Developers
https://developer.android.com/develop/ui/views/layout/recyclerview
Adapter 裡面另外提供:
fun returnData(): MutableList<MutableMap<String, Any>> {
return datalist
}
Activity 結帳時就可以:
val latestDataList = adapter.returnData()
取得使用者操作之後的最新資料。
例如:
紅茶
price = 30
qty = 2
綠茶
price = 30
qty = 0
奶茶
price = 45
qty = 3
這時 Activity 就可以進行結帳。
參考資料:Android Developers
https://developer.android.com/develop/ui/views/layout/recyclerview
按下 Checkout:
buttonCheckout.setOnClickListener {
val latestDataList = adapter.returnData()
textViewCheckout.text = "Your order is :\n"
var sum = 0
for (data in latestDataList) {
val name = data["name"].toString()
val price = data["price"] as Int
val qty = data["qty"] as Int
if (qty > 0) {
sum += qty * price
textViewCheckout.append(
"$name : $price x $qty = $${qty * price}\n"
)
}
}
textViewCheckout.append(
"\nThe total fee = $$sum"
)
}
核心公式其實非常簡單:
商品小計 = 單價 × 數量
例如:
紅茶:30 × 2 = 60
奶茶:45 × 3 = 135
最後:
總金額
= 60 + 135
= 195
程式:
sum += qty * price
就是購物車最基本的計價邏輯。
參考資料:Android Developers
https://developer.android.com/develop/ui/views/layout/recyclerview
如果使用者按下 Cancel:
buttonCancel.setOnClickListener {
for (data in datalist) {
data["qty"] = 0
}
adapter.notifyDataSetChanged()
textViewCheckout.text = ""
}
首先把所有商品:
data["qty"] = 0
然後呼叫:
adapter.notifyDataSetChanged()
告訴 RecyclerView:
我的資料已經改變,請重新更新畫面。
因此:
紅茶 2
綠茶 1
奶茶 3
按下 Cancel 後:
紅茶 0
綠茶 0
奶茶 0
這就是「資料改變 → 通知 Adapter → UI 更新」的流程。
參考資料:Android Developers
https://developer.android.com/develop/ui/views/layout/recyclerview
今天另一份練習是 Flower RecyclerView。
先建立資料類別:
data class Flower(
val name: String,
val imageResourceId: Int
)
接著 Adapter 接收:
class FlowerAdapter(
private val flowerList: List<Flower>,
private val onItemClick: (Flower) -> Unit
) : RecyclerView.Adapter<FlowerAdapter.FlowerViewHolder>()
點擊某朵花:
holder.itemView.setOnClickListener {
onItemClick(flower)
}
這個寫法比單純把資料塞進 RecyclerView 更重要,因為 Adapter 可以透過 Lambda 把「哪一筆被點擊」傳出去。
因此資料流會變成:
使用者點擊 Flower
↓
Adapter
↓
onItemClick(flower)
↓
MainActivity
↓
更新大圖與名稱
這就是 Android 很常見的 Adapter Callback 寫法。
參考資料:Android Developers
https://developer.android.com/develop/ui/views/layout/recyclerview
除了 RecyclerView,今天的另一份程式也把之前的 Activity 傳值進一步整合。
MainActivity 先註冊:
val launcher =
registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val intent = result.data
intent?.extras?.let {
val drink = it.getString("drink")
val quantityStr =
it.getString("quantity") ?: "0"
val sugar =
it.getString("sugar")
val ice =
it.getString("ice")
val quantity =
quantityStr.toIntOrNull() ?: 0
val total =
quantity * selectedDrinkPrice
tvName.text =
"飲料名稱: $drink"
tvMeal.text =
"數量: $quantity, 甜度: $sugar, 冰塊: $ice"
tvTotal.text =
"總價: NT$ $total"
}
}
}
這裡使用的是現在 AndroidX 建議使用的:
registerForActivityResult()
而不是以前常見的:
startActivityForResult()
參考資料:Android Developers
https://developer.android.com/training/basics/intents/result?hl=zh-TW
當使用者選好飲料:
btnChoice.setOnClickListener {
if (selectedDrinkName.isEmpty())
return@setOnClickListener
val intent =
Intent(this, SelectActivity::class.java)
intent.putExtra(
"selected_drink",
selectedDrinkName
)
launcher.launch(intent)
}
流程:
MainActivity
│
│ selected_drink
▼
SelectActivity
SelectActivity 再取得:
val selectedDrink =
intent.getStringExtra("selected_drink")
edDrink.setText(selectedDrink)
因此使用者前面選好的飲料名稱,可以直接出現在下一個 Activity。
參考資料:Android Developers
https://developer.android.com/training/basics/intents/result?hl=zh-TW
SelectActivity 裡讓使用者設定:
飲料
數量
甜度
冰塊
取得甜度:
val sugarId =
rgSugar.checkedRadioButtonId
取得冰塊:
val iceId =
rgIce.checkedRadioButtonId
如果沒有選:
if (sugarId == -1 || iceId == -1)
return@setOnClickListener
接著取得 RadioButton 文字:
val sugar =
findViewById<RadioButton>(sugarId)
.text.toString()
val ice =
findViewById<RadioButton>(iceId)
.text.toString()
最後建立 Bundle:
val intent = Intent()
val bundle = Bundle()
bundle.putString("drink", drink)
bundle.putString("quantity", quantity)
bundle.putString("sugar", sugar)
bundle.putString("ice", ice)
intent.putExtras(bundle)
回傳:
setResult(
Activity.RESULT_OK,
intent
)
finish()
因此完整資料流就是:
MainActivity
│
│ selected_drink
▼
SelectActivity
│
├── drink
├── quantity
├── sugar
└── ice
│
▼
MainActivity
│
▼
計算總價
參考資料:Android Developers
https://developer.android.com/training/basics/intents/result?hl=zh-TW
做到這裡,可以發現這個飲料點餐範例已經同時使用:
Array
↓
MutableList
↓
MutableMap
↓
RecyclerView
↓
Adapter
↓
ViewHolder
↓
Lambda Callback
↓
Intent
↓
Bundle
↓
Activity Result
↓
訂單計算
這也是為什麼前面 Kotlin 學的:
Array
List
Map
Lambda
Class
不是單純為了學語法。
進入 Android 之後,這些東西全部會開始組合在一起。
參考資料:Android Developers
https://developer.android.com/develop/ui/views/layout/recyclerview
今天最值得記住的並不是:
RecyclerView.Adapter
怎麼背。
而是開始建立:
Data
↓
Adapter
↓
RecyclerView
↓
User Action
↓
修改 Data
↓
更新 UI
這種思考方式。
例如按下:
+
真正發生的事情不是:
EditText 顯示 2
而應該理解成:
使用者按 +
↓
qty 由 1 改成 2
↓
Data 更新
↓
UI 顯示 2
這個觀念後面進入:
Room
Firebase
ViewModel
LiveData
StateFlow
Jetpack Compose
都會一直遇到。
參考資料:Android Developers
https://developer.android.com/develop/ui/views/layout/recyclerview
今天完成的不只是 RecyclerView,而是一個小型的 App 資料流:
┌──────────────┐
│ 商品資料 Data │
└──────┬───────┘
↓
┌──────────────┐
│ Adapter │
└──────┬───────┘
↓
┌──────────────┐
│ RecyclerView │
└──────┬───────┘
↓
┌──────────────────┐
│ 圖片 / 名稱 / 價格 │
│ - 數量 + │
└────────┬─────────┘
↓
使用者操作
↓
修改 qty
↓
┌──────────────────┐
│ adapter.returnData │
└────────┬─────────┘
↓
price × qty
↓
計算總金額
↓
Checkout
今天掌握五個重點:
RecyclerView 負責有效率地顯示清單。Adapter 負責資料與 Item UI 的連接。ViewHolder 保存 Item 裡需要操作的 View。前面的 RecyclerView 如果只是把圖片和文字列出來,還比較像是在「練習元件」。
今天加入:
商品圖片
商品名稱
商品價格
數量 + / -
購物車資料
Checkout
Cancel
Activity 傳值
Activity Result
之後,就開始比較像真正的 App 了。
而 RecyclerView 最值得學習的地方,也不是單純會寫:
RecyclerView.Adapter
而是理解:
資料放在哪裡?誰負責顯示?誰修改資料?資料改完之後,畫面怎麼跟著改?
當這條資料流真正看懂之後,後面不論接 Room、Firebase,甚至進一步使用 ViewModel 與 Jetpack Compose,觀念都會清楚很多。
下一步,就可以思考一件更有趣的事情:
現在訂單只存在手機記憶體裡,如果把 RecyclerView 的訂單資料送到 Firebase,能不能做成真正的雲端點餐系統?
這就能把前一天的 Firebase 與今天的 RecyclerView 真正串在一起。
參考資料:
Android Developers-RecyclerView
https://developer.android.com/develop/ui/views/layout/recyclerview
Android Developers-取得 Activity 結果
https://developer.android.com/training/basics/intents/result?hl=zh-TW