Jetpack Compose 清單、狀態與 Material

AgriLinq AdminAgriLinq Admin
6 min read

本文撰寫時間為 2025 年 7 月,請注意該文章的介紹是否與閱讀時有落差。

我們要完成的 To-Do App 具備:

  • 任務清單(LazyColumn)

  • 新增/刪除/勾選完成

  • Snackbar Undo

  • 空清單提示(AnimatedVisibility)

  • Room(SQLite)永久儲存

  • ViewModel + StateFlow 管理狀態

  • Material 3 + Scaffold 架構


1. 資料模型:Task

檔案:app/data/Task.kt

package com.example.todo.data

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "tasks")
data class Task(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,  // 自動 ID,避免你手動管理
    val title: String,
    val isDone: Boolean = false                        // 勾選完成的來源
)

做了什麼

  • 宣告 Room 資料表結構(SQLite 會對應產生一張 tasks 表)。

為什麼/好處

  • @Entity 讓你不用手寫 CREATE TABLE。

  • autoGenerate 減少人為 ID 衝突。

  • 模型先定義好 → 後續 UI、DAO 都有清楚型別依據。


2. DAO:資料庫操作介面

檔案:app/data/TaskDao.kt

package com.example.todo.data

import androidx.room.*
import kotlinx.coroutines.flow.Flow

@Dao
interface TaskDao {

    @Query("SELECT * FROM tasks")
    fun getTasks(): Flow<List<Task>>
    // 回傳 Flow 的好處:
    // 只要資料變了(新增/刪除/更新),UI 透過 collectAsState() 會自動重組→自動更新畫面

    @Insert
    suspend fun insertTask(task: Task)    // suspend → 非阻塞、Room 自有執行緒處理

    @Update
    suspend fun updateTask(task: Task)    // 勾選完成只要 copy 後 update

    @Delete
    suspend fun deleteTask(task: Task)    // 一行刪除,不用寫 SQL
}

做了什麼

  • 定義查詢、插入、更新、刪除。

為什麼/好處

  • 免 SQL:@Insert/@Update/@Delete 自動產生語法。

  • Flow:與 Compose 的宣告式 UI 完全契合,不用手動通知 UI 刷新。


3. Room Database:建立 SQLite 實體

檔案:app/data/TaskDatabase.kt

package com.example.todo.data

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(entities = [Task::class], version = 1, exportSchema = false)
abstract class TaskDatabase : RoomDatabase() {
    abstract fun taskDao(): TaskDao

    companion object {
        @Volatile private var INSTANCE: TaskDatabase? = null

        fun getDatabase(context: Context): TaskDatabase =
            INSTANCE ?: synchronized(this) {
                Room.databaseBuilder(
                    context.applicationContext,
                    TaskDatabase::class.java,
                    "task_database"                 // 實際 SQLite 檔案名稱
                ).build().also { INSTANCE = it }
            }
}

做了什麼

  • 建立單例資料庫,提供 TaskDao()。

為什麼/好處

  • 單例避免多個連線爭用與記憶體浪費。

  • databaseBuilder 幫你完成 SQLite 開檔、版本管理基礎(之後可加 Migration)。


4. ViewModel:單一來源(StateFlow)

檔案:app/ui/TaskViewModel.kt

package com.example.todo.ui

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.todo.data.Task
import com.example.todo.data.TaskDatabase
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

class TaskViewModel(application: Application) : AndroidViewModel(application) {
    private val dao = TaskDatabase.getDatabase(application).taskDao()

    // Flow → StateFlow:讓 Composable 可以用 collectAsState() 讀取
    val tasks: StateFlow<List<Task>> = dao.getTasks()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    fun addTask(title: String) {
        viewModelScope.launch { dao.insertTask(Task(title = title)) }
        // 好處:使用 suspend DAO + Room 內部執行緒 → 不阻塞主執行緒、不卡 UI
    }

    fun toggleTask(task: Task) {
        viewModelScope.launch { dao.updateTask(task.copy(isDone = !task.isDone)) }
        // 好處:只改變 isDone,UI 自動隨 Flow 更新
    }

    fun deleteTask(task: Task) {
        viewModelScope.launch { dao.deleteTask(task) }
    }

    fun restoreTask(task: Task) {
        viewModelScope.launch { dao.insertTask(task) }
    }
}

做了什麼

  • StateFlow 暴露 UI 狀態,所有寫入統一由 ViewModel 完成。

為什麼/好處

  • 旋轉不丟狀態:ViewModel 與 Activity 生命週期綁定。

  • 單向資料流:UI 只觀察 tasks,互動呼叫 ViewModel → 清楚、好拆測。

  • Flow → StateFlow:穩定值、易於組合,UI 層只做顯示。


5. TaskInput:輸入框 + 新增

檔案:app/ui/components/TaskInput.kt

package com.example.todo.ui.components

import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun TaskInput(onAdd: (String) -> Unit) {
    var text by remember { mutableStateOf("") }          // Composable 本地輸入狀態

    Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
        TextField(
            value = text,
            onValueChange = { text = it },
            modifier = Modifier.weight(1f),
            label = { Text("New Task") }
        )
        Button(onClick = {
            if (text.isNotBlank()) {
                onAdd(text)                               // 將意圖回拋給上層(ViewModel)
                text = ""
            }
        }) { Text("Add") }
    }
}

做了什麼

  • 本地管理輸入框字串,按下「Add」回拋給上層。

為什麼/好處

  • UI/邏輯分離:onAdd 只傳遞意圖,上層決定「新增」怎麼做。

  • 組合性:這個輸入框可在其他畫面重用,不依賴特定 ViewModel。


6. TaskList & TaskItem:

檔案:app/ui/components/TaskList.kt

package com.example.todo.ui.components

import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.example.todo.data.Task

@Composable
fun TaskList(
    tasks: List<Task>,
    onToggle: (Task) -> Unit,
    onDelete: (Task) -> Unit
) {
    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        // ✅ 重點:使用 key 維持項目 identity,減少不必要重組,動畫也更穩定
        items(tasks, key = { it.id }) { task ->
            TaskItem(task, onToggle, onDelete)
        }
    }
}

@Composable
fun TaskItem(
    task: Task,
    onToggle: (Task) -> Unit,
    onDelete: (Task) -> Unit
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp)
            .animateContentSize(),                 // 刪除/切換時平滑變化
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            Checkbox(
                checked = task.isDone,
                onCheckedChange = { onToggle(task) } // 只拋「意圖」,不在 UI 改資料
            )
            Text(
                text = task.title,
                style = if (task.isDone)
                    TextStyle(textDecoration = TextDecoration.LineThrough)
                else TextStyle.Default,
                modifier = Modifier.padding(start = 8.dp)
            )
        }
        IconButton(onClick = { onDelete(task) }) {
            Icon(Icons.Default.Delete, contentDescription = "Delete Task")
        }
    }
}

為什麼要用 LazyColumn

它做了什麼

  • 只「懶載入」可見範圍的項目(像 RecyclerView 的可見項目回收機制),不會一次把 1000 筆都繪製出來。

為什麼要用 LazyColumn(而不是 Column)

  • Column 會把所有 children 一次排版&繪製,項目很多時記憶體與排版成本暴增,滑動也容易卡。

  • LazyColumn 只生成「看得到的那幾個 item」,離開視窗的會被回收再利用,對 大型清單效能穩定。

進一步的好處

  • items(tasks, key = { it.id }):

  • 提供 穩定 key,讓 Compose 在資料變動時能準確比對哪個項目被插入/刪除/移動,減少不必要的重組與版面抖動。

  • 對動畫(如 animateContentSize)也更友善,狀態不會亂跳。

  • 乾淨控制項目之間距,不必在每個 item 上手動加 Spacer,更好維護。

  • TaskItem 把互動「回拋」出去(onToggle/onDelete):

  • UI 不碰資料層,可測試性高關注點分離

  • 任何內容高度改變(如打勾、刪除)都自帶平滑動畫一行程式碼改善 UX。


7. TaskScreen:Scaffold / FAB / Snackbar Undo / 空清單提示

檔案:app/ui/TaskScreen.kt

package com.example.todo.ui

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.todo.data.Task
import com.example.todo.ui.components.TaskInput
import com.example.todo.ui.components.TaskList
import kotlinx.coroutines.launch

@Composable
fun TaskScreen(viewModel: TaskViewModel = viewModel()) {
    val tasks by viewModel.tasks.collectAsState()
    val snackbarHostState = remember { SnackbarHostState() }
    val coroutineScope = rememberCoroutineScope()
    var lastDeleted by remember { mutableStateOf<Task?>(null) } // 暫存刪除,Undo 會用到

    Scaffold(
        topBar = { TopAppBar(title = { Text("Task List") }) }, // Material 3 標題列
        floatingActionButton = {
            FloatingActionButton(onClick = { viewModel.addTask("Quick Task") }) {
                Icon(Icons.Default.Add, contentDescription = "Add Task")
            }
        },
        snackbarHost = { SnackbarHost(hostState = snackbarHostState) } // 放 Snackbar 的主機
    ) { innerPadding ->
        Column(
            modifier = Modifier
                .padding(innerPadding)
                .padding(16.dp)
        ) {
            TaskInput(onAdd = { viewModel.addTask(it) })
            Spacer(modifier = Modifier.height(16.dp))

            // 空清單提示:沒有元素時,以動畫顯示人性化訊息
            AnimatedVisibility(visible = tasks.isEmpty()) {
                Text(
                    "No tasks available",
                    modifier = Modifier.fillMaxWidth(),
                    textAlign = TextAlign.Center
                )
            }

            TaskList(
                tasks,
                onToggle = { viewModel.toggleTask(it) },
                onDelete = {
                    lastDeleted = it
                    viewModel.deleteTask(it)
                    // Snackbar Undo:操作可逆,降低誤刪風險,UX 友善
                    coroutineScope.launch {
                        val result = snackbarHostState.showSnackbar(
                            message = "Task deleted",
                            actionLabel = "Undo"
                        )
                        if (result == SnackbarResult.ActionPerformed) {
                            lastDeleted?.let { task -> viewModel.restoreTask(task) }
                        }
                    }
                }
            )
        }
    }
}

做了什麼

  • Scaffold 搭好 App 骨架:頂部列、FAB、Snackbar 容器。

  • 新增任務(TaskInput)、顯示清單(TaskList)。

  • 空清單提示(AnimatedVisibility)。

  • 刪除時提供 Undo(Snackbar action)。

為什麼/好處

  • Scaffold:不用自己處理 FAB 與 Snackbar 的定位與內距,一致的 Material 設計行為

  • 空清單提示:避免「空白畫面」,給使用者下一步引導。

  • Snackbar Undo:讓敏感操作可逆,可信賴的 UX

  • viewModel() + collectAsState():UI 對狀態單向相依,沒有手動刷新這種易錯點。


8. 入口:MainActivity

檔案:app/MainActivity.kt

package com.example.todo

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import com.example.todo.ui.TaskScreen

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {            // 同一路徑下的 Theme 也可客製(顏色/字型/shape)
                Surface {
                    TaskScreen()       // 單一入口,清晰、可測
                }
            }
        }
    }
}

做了什麼

  • 用 setContent 啟動 Compose ,顯示 TaskScreen。

為什麼/好處

  • MaterialTheme/Surface:一致的顏色與排版基底,後續要換品牌色不必改每個元件。

  • 入口簡潔,便於日後導入 Navigation(多畫面時改成 NavHost 即可)。


9. Undo 的 ID 行為與實務選項

  • 我們的 Task(id=自動, ...) 在刪除後、Undo 時,直接再插入同一個 Task

  • 預設 @PrimaryKey(autoGenerate=true):如果你帶非 0 的 id,Room 會嘗試用該 id 插入(表中無同 id 時可行)。

    兩個常見實務選擇

    1. 不在意還原原始 id:restoreTask(task.copy(id = 0)),讓 Room 重新給新 id,最穩。

    2. 想保留原始 id:把 @Insert 改成 @Insert(onConflict = OnConflictStrategy.REPLACE),但意味著「同 id 重插會覆蓋」。


10. LazyColumn 的效能與重組最佳實務

  • 穩定 key 很重要:items(tasks, key={it.id}) 讓 Compose 能追蹤項目 identity,避免換資料時整串重組。

  • 避免在 item 區塊創建大量 remember 狀態:狀態應模組化、盡量集中在 ViewModel 或較高層。

  • 長清單圖片或昂貴項目:可搭配 remember 圖片快取(如 Coil),或將昂貴計算移到 ViewModel。


11. 為什麼 ViewModel + StateFlow 比 LiveData 更符合 Compose

  • StateFlow:原生 Kotlin 協程型態,collectAsState() 融合度高。

  • LiveData:也可用,但需要額外轉換或 observeAsState;Flow 更統一(資料庫、網路都能以 Flow 提供)。


12. 為什麼 Room 的 suspend DAO 不需手動切換 IO

  • 使用 room-ktx 的 suspend DAO,Room 會在自己的執行緒池執行資料庫操作,不會阻塞主執行緒。

  • 你用 viewModelScope.launch { dao.insertTask(...) } 就好,不必再 withContext(Dispatchers.IO)(簡潔且安全)。


13. 總結

工具你做了什麼為什麼/好處
Compose + Material3用宣告式 UI 寫畫面與互動,Scaffold 結構化 UI少樣板、狀態驅動、標準化 UI(TopAppBar/FAB/Snackbar)
ViewModel + StateFlow所有清單狀態集中在 ViewModel,UI 只觀察旋轉不丟、單向資料流、易測試、UI 無需手動刷新
Room(SQLite)@Entity/@Dao/@Database 封裝資料存取免 SQL、Flow 即時更新、資料永久化、離線可用
LazyColumn懶載入、items(key=...)、項目動畫面對大量資料也流暢、重組準確、易維護
Snackbar Undo刪除提供復原機制降低誤刪風險、提升信任感
AnimatedVisibility / animateContentSize空清單提示、項目高度變化動畫一行達到專業級 UX、降低突兀感
輸入回拋(TaskInput.onAdd)UI 只發出意圖,資料操作在 ViewModel關注點分離、可重用組件
0
Subscribe to my newsletter

Read articles from AgriLinq Admin directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

AgriLinq Admin
AgriLinq Admin