Skip to content

Instantly share code, notes, and snippets.

@ardakazanci
Created June 8, 2026 16:33
Show Gist options
  • Select an option

  • Save ardakazanci/e58df279f74231f609ac9d0d0d6ea539 to your computer and use it in GitHub Desktop.

Select an option

Save ardakazanci/e58df279f74231f609ac9d0d0d6ea539 to your computer and use it in GitHub Desktop.
Jetpack Compose Scoped ViewModel Example
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