iT邦幫忙

2024 iThome 鐵人賽

DAY 26
0
Mobile Development

從零開始學習 Jetpack Compose系列 第 26

從零開始學習 Jetpack Compose Day25 - 專案實作(3)主畫面 ViewModel 建立

  • 分享至 

  • xImage
  •  

今天主要會建立主畫面的 ViewModel 以及建立 Mock 的資料來源。

在 Day20 有提到說要在 Compose 裡面使用 ViewModel 首先要新增依賴,那一樣就是先新增依賴。

libs.versions.toml

androidx-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version = "2.8.6"}

build.gradle.kts

dependencies {
		.
		.
    implementation(libs.androidx.viewmodel)
		.
		.
}

測試資料建立

在建立 ViewModel 前我們先建立資料來源,為了之後可能會補上 DI 以及為了目前測試分別建立三個檔案AppDataManagerAppDataManagerImplAppDataManagerMock

AppDataManager :主要定義我們的資料來源提供什麼方法給外部使用。
AppDataManagerImpl:到時候串實際資料時會使用。
AppDataManagerMock :目前測試先使用,負責提供測試資料。

interface AppDataManager {

    suspend fun getData(): List<SubscriptionViewData>
}
class AppDataManagerMock : AppDataManager {
    override suspend fun getData(): List<SubscriptionViewData> {
        return listOf(
            SubscriptionViewData(id = "0", name = "Apple TV", price = "10", cycle = "月訂閱"),
            SubscriptionViewData(id = "1",name = "Youtube Premium", price = "100", cycle = "月訂閱"),
            SubscriptionViewData(id = "2",name = "Apple Music", price = "20", cycle = "月訂閱"),
            SubscriptionViewData(id = "3",name = "Netflix", price = "10", cycle = "月訂閱"),
            SubscriptionViewData(id = "4",name = "Discord", price = "10", cycle = "月訂閱")
        )
    }
}

畫面狀態建立

這邊我們會定義畫面的幾個狀態,根據不同狀態來顯示不同畫面。

Empty:表示初始狀態
Loading:顯示Loading畫面
Finish:顯示清單畫面
Error:顯示錯誤畫面

sealed class HomeState {
    data object Empty: HomeState()
    data object Loading: HomeState()
    data class Finish(val list: List<SubscriptionViewData>): HomeState()
    data class Error(val message: String): HomeState()
}

ViewModel 建立

由於畫面一開啟就需要取得資料,因此在 init 區塊中就執行資料請求。額外提供的 reload 方法可以讓使用者重新取得資料。由於 AppDataManager 是透過依賴注入傳入的,所以我們需要自定義 ViewModelProvider.Factory 來將 AppDataManager 傳遞給 ViewModelgetData 的資料流程會先將狀態更新為 Loading,然後在取得資料後更新為 Finish,以便通知 UI 狀態變更。

class HomeViewModel(private val appDataManager: AppDataManager) : BaseViewModel() {

    companion object {
        fun createFactory(appDataManager: AppDataManager) = object : ViewModelProvider.Factory {
            override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
                return HomeViewModel(appDataManager) as T
            }
        }
    }

    val state: StateFlow<HomeState>
        get() = mState

    private var mState = MutableStateFlow<HomeState>(HomeState.Empty)

    init {
        MainScope().launch(
            CoroutineExceptionHandler { coroutineContext, throwable ->
                mState.value = HomeState.Error(throwable.message ?: "")
            }
        ) {
            getData()
        }
    }

    fun reload() {
        MainScope().launch(CoroutineExceptionHandler { coroutineContext, throwable ->
            mState.value = HomeState.Error(throwable.message ?: "")
        }) {
            getData()
        }
    }

    private suspend fun getData() {
        mState.value = HomeState.Loading
        delay(3 * 1000)
        val list = withContext(Dispatchers.IO) { appDataManager.getData() } 
        mState.value = HomeState.Finish(list)
    }
}

畫面調整

我們在畫面中新增了 ViewModel 的傳遞,並補上了 ViewModelProvider.Factory 來處理 AppDataManager 的注入。AppDataManager 的實例則由應用的 AppApplication 進行管理。在 UI 部分,我們根據 homeState 狀態進行變化,確保畫面根據不同的資料狀態(如 LoadingFinish 等)進行適當更新,提供良好的使用者體驗。

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
    viewModel: HomeViewModel = viewModel( factory = HomeViewModel.createFactory((LocalContext.current.applicationContext as AppApplication).getAppDataManager())),
    navController: NavController? = null,
    modifier: Modifier = Modifier) {

    val homeState by viewModel.state.collectAsState()

    Scaffold(
        topBar = {
            CenterAlignedTopAppBar(
                title = {
                    Text("ITHelp Side Project")
                },
                navigationIcon = {
                    IconButton(onClick = { }) {
                        Icon(
                            imageVector = Icons.Filled.Menu,
                            contentDescription = ""
                        )
                    }
                },
                actions = {
                    IconButton(onClick = { }) {
                        Icon(
                            imageVector = Icons.Filled.AccountCircle,
                            contentDescription = ""
                        )
                    }
                }
            )
        },
        floatingActionButton = {
            FloatingActionButton(onClick = {
                navController?.navigate("detail/{id}")
            }) {
                Icon(Icons.Default.Add, contentDescription = "")
            }
        }
    ) { innerPadding ->
        when(homeState)  {
            is HomeState.Empty -> {}
            is HomeState.Error -> {
                val message = (homeState as HomeState.Error).message
                Column(
                    verticalArrangement = Arrangement.Center,
                    horizontalAlignment = Alignment.CenterHorizontally,
                    modifier = Modifier
                        .padding(innerPadding)
                        .fillMaxWidth()
                        .fillMaxHeight()

                ) {
                    errorScreen(
                        message = message,
                        onClock = {
                            viewModel.reload()
                        }
                    )
                }
            }
            is HomeState.Finish -> {
                val list = (homeState as HomeState.Finish).list
                LazyColumn(
                    modifier = Modifier
                        .padding(innerPadding)
                        .fillMaxWidth()
                        .fillMaxHeight()
                ) {
                    items(list) { item: SubscriptionViewData ->
                        SubscriptionItem(
                            modifier = modifier,
                            navController = navController,
                            data = item
                        )
                    }
                }
            }
            is HomeState.Loading -> {
                Column(
                    verticalArrangement = Arrangement.Center,
                    horizontalAlignment = Alignment.CenterHorizontally,
                    modifier = Modifier
                        .padding(innerPadding)
                        .fillMaxWidth()
                        .fillMaxHeight()

                ) {
                    ProgressBarComposable()
                }
            }
        }

    }
}

@Composable
fun ProgressBarComposable() {
    CircularProgressIndicator(
        modifier = Modifier.width(64.dp),
        color = MaterialTheme.colorScheme.secondary,
        trackColor = MaterialTheme.colorScheme.surfaceVariant,
    )
}

@Composable
fun errorScreen(message: String, onClock: () -> Unit) {
    Column(
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier
            .fillMaxWidth()
            .fillMaxHeight()
    ) {
        Text(
            text = message
        )
        Button(
            onClick = onClock
        ) {
            Text(
                text = "重新取得"
            )
        }
    }
}

Loading畫面
https://raw.githubusercontent.com/jian-fu-hung/ithelp-2024/refs/heads/main/Images/Day25/%E6%88%AA%E5%9C%96%202024-10-10%20%E6%99%9A%E4%B8%8A9.47.23.png

結束畫面
https://raw.githubusercontent.com/jian-fu-hung/ithelp-2024/refs/heads/main/Images/Day25/%E6%88%AA%E5%9C%96%202024-10-10%20%E6%99%9A%E4%B8%8A9.45.26.png

另外我們可以調整 ViewModel 的 init 來看失敗時的畫面

init {
        MainScope().launch(
            CoroutineExceptionHandler { coroutineContext, throwable ->
                mState.value = HomeState.Error(throwable.message ?: "")
            }
        ) {
            mState.value = HomeState.Loading
            delay(10 * 1000)
            mState.value = HomeState.Error("取得資料失敗")
        }
    }

https://raw.githubusercontent.com/jian-fu-hung/ithelp-2024/refs/heads/main/Images/Day25/%E6%88%AA%E5%9C%96%202024-10-10%20%E6%99%9A%E4%B8%8A9.46.48.png

以上為今天的主題,明天預計建立詳細頁的ViewModel。


上一篇
從零開始學習 Jetpack Compose Day24 - 專案實作(2)詳細頁畫面建置
下一篇
從零開始學習 Jetpack Compose Day26 - 詳細頁ViewModel建立
系列文
從零開始學習 Jetpack Compose30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言