今天主要會建立主畫面的 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 以及為了目前測試分別建立三個檔案AppDataManager
、AppDataManagerImpl
、AppDataManagerMock
。
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()
}
由於畫面一開啟就需要取得資料,因此在 init
區塊中就執行資料請求。額外提供的 reload
方法可以讓使用者重新取得資料。由於 AppDataManager
是透過依賴注入傳入的,所以我們需要自定義 ViewModelProvider.Factory
來將 AppDataManager
傳遞給 ViewModel
。getData
的資料流程會先將狀態更新為 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
狀態進行變化,確保畫面根據不同的資料狀態(如 Loading
、Finish
等)進行適當更新,提供良好的使用者體驗。
@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畫面
結束畫面
另外我們可以調整 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("取得資料失敗")
}
}
以上為今天的主題,明天預計建立詳細頁的ViewModel。