Created
June 8, 2026 16:33
-
-
Save ardakazanci/e58df279f74231f609ac9d0d0d6ea539 to your computer and use it in GitHub Desktop.
Jetpack Compose Scoped ViewModel Example
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| class MainActivity : ComponentActivity() { | |
| override fun onCreate(savedInstanceState: Bundle?) { | |
| super.onCreate(savedInstanceState) | |
| enableEdgeToEdge() | |
| setContent { | |
| ScopedViewModelTheme { | |
| Scaffold(modifier = Modifier.fillMaxSize()) { contentPadding -> | |
| ScopedViewModelDemo( | |
| modifier = Modifier.padding(contentPadding) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun ScopedViewModelDemo(modifier: Modifier = Modifier) { | |
| val provider = rememberViewModelStoreProvider() | |
| var pageIds by rememberSaveable { mutableStateOf(listOf(1, 2, 3)) } | |
| var nextPageId by rememberSaveable { mutableStateOf(4) } | |
| val pagerState = rememberPagerState(pageCount = { pageIds.size }) | |
| LaunchedEffect(pageIds.size) { | |
| if (pageIds.isNotEmpty() && pagerState.currentPage > pageIds.lastIndex) { | |
| pagerState.scrollToPage(pageIds.lastIndex) | |
| } | |
| } | |
| Column( | |
| modifier = modifier | |
| .fillMaxSize() | |
| .background(MaterialTheme.colorScheme.background) | |
| .padding(horizontal = 20.dp, vertical = 16.dp), | |
| verticalArrangement = Arrangement.spacedBy(16.dp) | |
| ) { | |
| DemoHeader( | |
| pageCount = pageIds.size, | |
| currentPage = pagerState.currentPage + 1, | |
| onAddPage = { | |
| pageIds = pageIds + nextPageId | |
| ScopedVmEvents.record("Page key $nextPageId entered the pager") | |
| nextPageId += 1 | |
| }, | |
| onRemoveCurrentPage = { | |
| if (pageIds.size > 1) { | |
| val currentIndex = pagerState.currentPage.coerceIn(pageIds.indices) | |
| val removedKey = pageIds[currentIndex] | |
| pageIds = pageIds.toMutableList().also { it.removeAt(currentIndex) } | |
| provider.clearKey(removedKey) | |
| ScopedVmEvents.record("Page key $removedKey marked for cleanup") | |
| } | |
| }, | |
| canRemove = pageIds.size > 1 | |
| ) | |
| HorizontalPager( | |
| state = pagerState, | |
| key = { index -> pageIds[index] }, | |
| pageSpacing = 16.dp, | |
| contentPadding = PaddingValues(horizontal = 4.dp), | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .weight(1f) | |
| ) { pageIndex -> | |
| val pageKey = pageIds[pageIndex] | |
| val storeOwner = rememberViewModelStoreOwner(provider, key = pageKey) | |
| val pageViewModel = viewModel<ScopedPageViewModel>( | |
| viewModelStoreOwner = storeOwner, | |
| key = "scoped-page" | |
| ) { | |
| ScopedPageViewModel() | |
| } | |
| PageCard( | |
| pageNumber = pageIndex + 1, | |
| pageKey = pageKey, | |
| viewModel = pageViewModel, | |
| modifier = Modifier.fillMaxSize() | |
| ) | |
| } | |
| LifecycleLog( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .height(132.dp) | |
| ) | |
| } | |
| } | |
| @Preview(showBackground = true) | |
| @Composable | |
| private fun ScopedViewModelPreview() { | |
| ScopedViewModelTheme { | |
| Surface { | |
| Column(Modifier.padding(16.dp)) { | |
| Text( | |
| text = "Scoped ViewModel", | |
| style = MaterialTheme.typography.headlineSmall | |
| ) | |
| Spacer(Modifier.height(12.dp)) | |
| Text( | |
| text = "Run the app to create ViewModelStoreOwner scopes inside the Pager.", | |
| style = MaterialTheme.typography.bodyMedium | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun DemoHeader( | |
| pageCount: Int, | |
| currentPage: Int, | |
| onAddPage: () -> Unit, | |
| onRemoveCurrentPage: () -> Unit, | |
| canRemove: Boolean, | |
| modifier: Modifier = Modifier | |
| ) { | |
| Column( | |
| modifier = modifier.fillMaxWidth(), | |
| verticalArrangement = Arrangement.spacedBy(12.dp) | |
| ) { | |
| Row( | |
| modifier = Modifier.fillMaxWidth(), | |
| horizontalArrangement = Arrangement.SpaceBetween, | |
| verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| Column { | |
| Text( | |
| text = "Scoped ViewModel", | |
| style = MaterialTheme.typography.headlineMedium, | |
| fontWeight = FontWeight.SemiBold | |
| ) | |
| Text( | |
| text = "Pager page $currentPage of $pageCount", | |
| style = MaterialTheme.typography.bodyMedium, | |
| color = MaterialTheme.colorScheme.onSurfaceVariant | |
| ) | |
| } | |
| AssistChip( | |
| onClick = {}, | |
| label = { Text("2.11.0-alpha02") } | |
| ) | |
| } | |
| Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { | |
| Button(onClick = onAddPage) { | |
| Text("Add page") | |
| } | |
| OutlinedButton( | |
| onClick = onRemoveCurrentPage, | |
| enabled = canRemove | |
| ) { | |
| Text("Remove current") | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun PageCard( | |
| pageNumber: Int, | |
| pageKey: Int, | |
| viewModel: ScopedPageViewModel, | |
| modifier: Modifier = Modifier | |
| ) { | |
| ElevatedCard( | |
| modifier = modifier, | |
| shape = RoundedCornerShape(8.dp), | |
| colors = CardDefaults.elevatedCardColors( | |
| containerColor = MaterialTheme.colorScheme.surfaceContainerLow | |
| ) | |
| ) { | |
| Column( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .padding(20.dp), | |
| verticalArrangement = Arrangement.spacedBy(18.dp) | |
| ) { | |
| Row( | |
| modifier = Modifier.fillMaxWidth(), | |
| horizontalArrangement = Arrangement.SpaceBetween, | |
| verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| Column { | |
| Text( | |
| text = "Page $pageNumber", | |
| style = MaterialTheme.typography.headlineSmall, | |
| fontWeight = FontWeight.SemiBold | |
| ) | |
| Text( | |
| text = "Store key $pageKey", | |
| style = MaterialTheme.typography.bodyMedium, | |
| color = MaterialTheme.colorScheme.onSurfaceVariant | |
| ) | |
| } | |
| Box( | |
| modifier = Modifier | |
| .clip(CircleShape) | |
| .background(MaterialTheme.colorScheme.primaryContainer) | |
| .padding(horizontal = 14.dp, vertical = 8.dp), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Text( | |
| text = "VM ${viewModel.instanceNumber}", | |
| style = MaterialTheme.typography.labelLarge, | |
| color = MaterialTheme.colorScheme.onPrimaryContainer | |
| ) | |
| } | |
| } | |
| HorizontalDivider() | |
| Row( | |
| modifier = Modifier.fillMaxWidth(), | |
| horizontalArrangement = Arrangement.spacedBy(12.dp), | |
| verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| StatBlock( | |
| label = "Counter", | |
| value = viewModel.counter.toString(), | |
| modifier = Modifier.weight(1f) | |
| ) | |
| StatBlock( | |
| label = "Touches", | |
| value = viewModel.touchCount.toString(), | |
| modifier = Modifier.weight(1f) | |
| ) | |
| } | |
| Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { | |
| Button(onClick = viewModel::incrementCounter) { | |
| Text("Increment") | |
| } | |
| OutlinedButton(onClick = viewModel::markTouched) { | |
| Text("Touch VM") | |
| } | |
| TextButton(onClick = viewModel::reset) { | |
| Text("Reset") | |
| } | |
| } | |
| Text( | |
| text = "Active scoped owner: key $pageKey, VM ${viewModel.instanceNumber}", | |
| style = MaterialTheme.typography.bodyMedium, | |
| color = MaterialTheme.colorScheme.onSurfaceVariant | |
| ) | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun StatBlock( | |
| label: String, | |
| value: String, | |
| modifier: Modifier = Modifier | |
| ) { | |
| Card( | |
| modifier = modifier, | |
| shape = RoundedCornerShape(8.dp), | |
| border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), | |
| colors = CardDefaults.cardColors( | |
| containerColor = MaterialTheme.colorScheme.surface | |
| ) | |
| ) { | |
| Column( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .padding(16.dp), | |
| verticalArrangement = Arrangement.spacedBy(6.dp) | |
| ) { | |
| Text( | |
| text = label, | |
| style = MaterialTheme.typography.labelLarge, | |
| color = MaterialTheme.colorScheme.onSurfaceVariant | |
| ) | |
| Text( | |
| text = value, | |
| style = MaterialTheme.typography.displaySmall, | |
| fontWeight = FontWeight.SemiBold | |
| ) | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun LifecycleLog(modifier: Modifier = Modifier) { | |
| Card( | |
| modifier = modifier, | |
| shape = RoundedCornerShape(8.dp), | |
| border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), | |
| colors = CardDefaults.cardColors( | |
| containerColor = MaterialTheme.colorScheme.surface | |
| ) | |
| ) { | |
| Column( | |
| modifier = Modifier.padding(14.dp), | |
| verticalArrangement = Arrangement.spacedBy(8.dp) | |
| ) { | |
| Row( | |
| modifier = Modifier.fillMaxWidth(), | |
| horizontalArrangement = Arrangement.SpaceBetween, | |
| verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| Text( | |
| text = "Lifecycle log", | |
| style = MaterialTheme.typography.titleSmall, | |
| fontWeight = FontWeight.SemiBold | |
| ) | |
| TextButton(onClick = ScopedVmEvents::clear) { | |
| Text("Clear") | |
| } | |
| } | |
| val recentEvents = ScopedVmEvents.events.takeLast(4).asReversed() | |
| if (recentEvents.isEmpty()) { | |
| Text( | |
| text = "No events yet", | |
| style = MaterialTheme.typography.bodyMedium, | |
| color = MaterialTheme.colorScheme.onSurfaceVariant | |
| ) | |
| } else { | |
| Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { | |
| recentEvents.forEach { event -> | |
| Text( | |
| text = event, | |
| style = MaterialTheme.typography.bodySmall, | |
| color = MaterialTheme.colorScheme.onSurfaceVariant | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| private class ScopedPageViewModel : ViewModel() { | |
| val instanceNumber = nextInstanceNumber() | |
| var counter by mutableIntStateOf(0) | |
| private set | |
| var touchCount by mutableIntStateOf(0) | |
| private set | |
| init { | |
| ScopedVmEvents.record("VM $instanceNumber created") | |
| } | |
| fun incrementCounter() { | |
| counter += 1 | |
| } | |
| fun markTouched() { | |
| touchCount += 1 | |
| } | |
| fun reset() { | |
| counter = 0 | |
| touchCount = 0 | |
| } | |
| override fun onCleared() { | |
| ScopedVmEvents.record("VM $instanceNumber cleared") | |
| } | |
| companion object { | |
| private var nextInstance = 1 | |
| private fun nextInstanceNumber(): Int = nextInstance++ | |
| } | |
| } | |
| private object ScopedVmEvents { | |
| val events = mutableStateListOf<String>() | |
| fun record(event: String) { | |
| events += event | |
| if (events.size > 20) { | |
| events.removeRange(0, events.size - 20) | |
| } | |
| } | |
| fun clear() { | |
| events.clear() | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment