From 1547a08d9abdb5828ca0f23051bcf14349f03306 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Fri, 8 Aug 2025 11:57:40 +0530 Subject: [PATCH 01/22] Snackbar Updates --- .../fluentuidemo/demos/V2SnackbarActivity.kt | 520 +++++++++--------- .../notification/StackableSnackbar.kt | 212 +++++++ 2 files changed, 474 insertions(+), 258 deletions(-) create mode 100644 fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt index 6dfef1aa1..17749322c 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt @@ -35,6 +35,7 @@ import com.microsoft.fluentui.tokenized.notification.NotificationDuration import com.microsoft.fluentui.tokenized.notification.NotificationResult import com.microsoft.fluentui.tokenized.notification.Snackbar import com.microsoft.fluentui.tokenized.notification.SnackbarState +import com.microsoft.fluentui.tokenized.notification.StackableSnackbar import com.microsoft.fluentui.tokenized.segmentedcontrols.PillBar import com.microsoft.fluentui.tokenized.segmentedcontrols.PillMetaData import com.microsoft.fluentuidemo.R @@ -64,265 +65,268 @@ class V2SnackbarActivity : V2DemoActivity() { val context = this setActivityContent { - val snackbarState = remember { SnackbarState() } - - val scope = rememberCoroutineScope() - Column( - Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - var icon: Boolean by rememberSaveable { mutableStateOf(false) } - var actionLabel: Boolean by rememberSaveable { mutableStateOf(false) } - var subtitle: String? by rememberSaveable { mutableStateOf(null) } - var style: SnackbarStyle by rememberSaveable { mutableStateOf(SnackbarStyle.Neutral) } - var duration: NotificationDuration by rememberSaveable { - mutableStateOf( - NotificationDuration.SHORT - ) - } - var dismissEnabled by rememberSaveable { mutableStateOf(false) } - - ListItem.SectionHeader( - title = LocalContext.current.resources.getString(R.string.app_modifiable_parameters), - enableChevron = true, - enableContentOpenCloseTransition = true, - chevronOrientation = ChevronOrientation(90f, 0f), - modifier = Modifier.testTag(SNACK_BAR_MODIFIABLE_PARAMETER_SECTION) - ) { - LazyColumn(Modifier.fillMaxHeight(0.5F)) { - item { - PillBar( - mutableListOf( - PillMetaData( - text = LocalContext.current.resources.getString(R.string.fluentui_indefinite), - onClick = { - duration = NotificationDuration.INDEFINITE - }, - selected = duration == NotificationDuration.INDEFINITE - ), - PillMetaData( - text = LocalContext.current.resources.getString(R.string.fluentui_long), - onClick = { - duration = NotificationDuration.LONG - }, - selected = duration == NotificationDuration.LONG - ), - PillMetaData( - text = LocalContext.current.resources.getString(R.string.fluentui_short), - onClick = { - duration = NotificationDuration.SHORT - }, - selected = duration == NotificationDuration.SHORT - ) - ), style = FluentStyle.Neutral, - showBackground = true - ) - } - - item { - Spacer( - Modifier - .height(8.dp) - .fillMaxWidth() - .background(aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value()) - ) - } - - item { - PillBar( - mutableListOf( - PillMetaData( - text = LocalContext.current.resources.getString(R.string.fluentui_neutral), - onClick = { - style = SnackbarStyle.Neutral - }, - selected = style == SnackbarStyle.Neutral - ), - PillMetaData( - text = LocalContext.current.resources.getString(R.string.fluentui_contrast), - onClick = { - style = SnackbarStyle.Contrast - }, - selected = style == SnackbarStyle.Contrast - ), - PillMetaData( - text = LocalContext.current.resources.getString(R.string.fluentui_accent), - onClick = { - style = SnackbarStyle.Accent - }, - selected = style == SnackbarStyle.Accent - ), - PillMetaData( - text = LocalContext.current.resources.getString(R.string.fluentui_warning), - onClick = { - style = SnackbarStyle.Warning - }, - selected = style == SnackbarStyle.Warning - ), - PillMetaData( - text = LocalContext.current.resources.getString(R.string.fluentui_danger), - onClick = { - style = SnackbarStyle.Danger - }, - selected = style == SnackbarStyle.Danger - ) - ), style = FluentStyle.Neutral, - showBackground = true - ) - } - - item { - ListItem.Item( - text = LocalContext.current.resources.getString(R.string.fluentui_icon), - subText = if (!icon) - LocalContext.current.resources.getString(R.string.fluentui_disabled) - else - LocalContext.current.resources.getString(R.string.fluentui_enabled), - trailingAccessoryContent = { - ToggleSwitch( - onValueChange = { - icon = it - }, - checkedState = icon, - modifier = Modifier.testTag(SNACK_BAR_ICON_PARAM) - ) - } - ) - } - - item { - val subTitleText = - LocalContext.current.resources.getString(R.string.fluentui_subtitle) - ListItem.Item( - text = subTitleText, - subText = if (subtitle.isNullOrBlank()) - LocalContext.current.resources.getString(R.string.fluentui_disabled) - else - LocalContext.current.resources.getString(R.string.fluentui_enabled), - trailingAccessoryContent = { - ToggleSwitch( - onValueChange = { - if (subtitle.isNullOrBlank()) { - subtitle = subTitleText - } else { - subtitle = null - } - }, - checkedState = !subtitle.isNullOrBlank(), - modifier = Modifier.testTag(SNACK_BAR_SUBTITLE_PARAM) - ) - } - ) - } - - item { - ListItem.Item( - text = LocalContext.current.resources.getString(R.string.fluentui_action_button), - subText = if (actionLabel) - LocalContext.current.resources.getString(R.string.fluentui_disabled) - else - LocalContext.current.resources.getString(R.string.fluentui_enabled), - trailingAccessoryContent = { - ToggleSwitch( - onValueChange = { - actionLabel = it - }, - checkedState = actionLabel, - modifier = Modifier.testTag(SNACK_BAR_ACTION_BUTTON_PARAM) - ) - } - ) - } - - item { - ListItem.Item( - text = LocalContext.current.resources.getString(R.string.fluentui_dismiss_button), - subText = if (!dismissEnabled) - LocalContext.current.resources.getString(R.string.fluentui_disabled) - else - LocalContext.current.resources.getString(R.string.fluentui_enabled), - trailingAccessoryContent = { - ToggleSwitch( - onValueChange = { - dismissEnabled = it - }, - checkedState = dismissEnabled, - modifier = Modifier.testTag(SNACK_BAR_DISMISS_BUTTON_PARAM) - ) - } - ) - } - } - } - - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - val actionButtonString = - LocalContext.current.resources.getString(R.string.fluentui_action_button) - val dismissedString = - LocalContext.current.resources.getString(R.string.fluentui_dismissed) - val pressedString = - LocalContext.current.resources.getString(R.string.fluentui_button_pressed) - val timeoutString = - LocalContext.current.resources.getString(R.string.fluentui_timeout) - Button( - onClick = { - scope.launch { - val result: NotificationResult = snackbarState.showSnackbar( - "Hello from Fluent", - style = style, - icon = if (icon) FluentIcon(Icons.Outlined.ShoppingCart) else null, - actionText = if (actionLabel) actionButtonString else null, - subTitle = subtitle, - duration = duration, - enableDismiss = dismissEnabled, - animationBehavior = customizedAnimationBehavior - ) - - when (result) { - NotificationResult.TIMEOUT -> Toast.makeText( - context, - timeoutString, - Toast.LENGTH_SHORT - ).show() - - NotificationResult.CLICKED -> Toast.makeText( - context, - pressedString, - Toast.LENGTH_SHORT - ).show() - - NotificationResult.DISMISSED -> Toast.makeText( - context, - dismissedString, - Toast.LENGTH_SHORT - ).show() - } - } - }, - text = LocalContext.current.resources.getString(R.string.fluentui_show_snackbar), - size = ButtonSize.Small, - style = ButtonStyle.OutlinedButton, - modifier = Modifier.testTag(SNACK_BAR_SHOW_SNACKBAR) - ) - - Button( - onClick = { - snackbarState.currentSnackbar?.dismiss(scope) - }, - text = LocalContext.current.resources.getString(R.string.fluentui_dismiss_snackbar), - size = ButtonSize.Small, - style = ButtonStyle.OutlinedButton, - modifier = Modifier.testTag(SNACK_BAR_DISMISS_SNACKBAR) - ) - } - Box(Modifier.fillMaxHeight(), contentAlignment = Alignment.Center) { - Snackbar(snackbarState, Modifier.padding(bottom = 12.dp), null, true) + Box(modifier = Modifier.fillMaxSize()){ + StackableSnackbar() } - } +// val snackbarState = remember { SnackbarState() } +// +// val scope = rememberCoroutineScope() +// Column( +// Modifier.fillMaxSize(), +// horizontalAlignment = Alignment.CenterHorizontally +// ) { +// var icon: Boolean by rememberSaveable { mutableStateOf(false) } +// var actionLabel: Boolean by rememberSaveable { mutableStateOf(false) } +// var subtitle: String? by rememberSaveable { mutableStateOf(null) } +// var style: SnackbarStyle by rememberSaveable { mutableStateOf(SnackbarStyle.Neutral) } +// var duration: NotificationDuration by rememberSaveable { +// mutableStateOf( +// NotificationDuration.SHORT +// ) +// } +// var dismissEnabled by rememberSaveable { mutableStateOf(false) } +// +// ListItem.SectionHeader( +// title = LocalContext.current.resources.getString(R.string.app_modifiable_parameters), +// enableChevron = true, +// enableContentOpenCloseTransition = true, +// chevronOrientation = ChevronOrientation(90f, 0f), +// modifier = Modifier.testTag(SNACK_BAR_MODIFIABLE_PARAMETER_SECTION) +// ) { +// LazyColumn(Modifier.fillMaxHeight(0.5F)) { +// item { +// PillBar( +// mutableListOf( +// PillMetaData( +// text = LocalContext.current.resources.getString(R.string.fluentui_indefinite), +// onClick = { +// duration = NotificationDuration.INDEFINITE +// }, +// selected = duration == NotificationDuration.INDEFINITE +// ), +// PillMetaData( +// text = LocalContext.current.resources.getString(R.string.fluentui_long), +// onClick = { +// duration = NotificationDuration.LONG +// }, +// selected = duration == NotificationDuration.LONG +// ), +// PillMetaData( +// text = LocalContext.current.resources.getString(R.string.fluentui_short), +// onClick = { +// duration = NotificationDuration.SHORT +// }, +// selected = duration == NotificationDuration.SHORT +// ) +// ), style = FluentStyle.Neutral, +// showBackground = true +// ) +// } +// +// item { +// Spacer( +// Modifier +// .height(8.dp) +// .fillMaxWidth() +// .background(aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value()) +// ) +// } +// +// item { +// PillBar( +// mutableListOf( +// PillMetaData( +// text = LocalContext.current.resources.getString(R.string.fluentui_neutral), +// onClick = { +// style = SnackbarStyle.Neutral +// }, +// selected = style == SnackbarStyle.Neutral +// ), +// PillMetaData( +// text = LocalContext.current.resources.getString(R.string.fluentui_contrast), +// onClick = { +// style = SnackbarStyle.Contrast +// }, +// selected = style == SnackbarStyle.Contrast +// ), +// PillMetaData( +// text = LocalContext.current.resources.getString(R.string.fluentui_accent), +// onClick = { +// style = SnackbarStyle.Accent +// }, +// selected = style == SnackbarStyle.Accent +// ), +// PillMetaData( +// text = LocalContext.current.resources.getString(R.string.fluentui_warning), +// onClick = { +// style = SnackbarStyle.Warning +// }, +// selected = style == SnackbarStyle.Warning +// ), +// PillMetaData( +// text = LocalContext.current.resources.getString(R.string.fluentui_danger), +// onClick = { +// style = SnackbarStyle.Danger +// }, +// selected = style == SnackbarStyle.Danger +// ) +// ), style = FluentStyle.Neutral, +// showBackground = true +// ) +// } +// +// item { +// ListItem.Item( +// text = LocalContext.current.resources.getString(R.string.fluentui_icon), +// subText = if (!icon) +// LocalContext.current.resources.getString(R.string.fluentui_disabled) +// else +// LocalContext.current.resources.getString(R.string.fluentui_enabled), +// trailingAccessoryContent = { +// ToggleSwitch( +// onValueChange = { +// icon = it +// }, +// checkedState = icon, +// modifier = Modifier.testTag(SNACK_BAR_ICON_PARAM) +// ) +// } +// ) +// } +// +// item { +// val subTitleText = +// LocalContext.current.resources.getString(R.string.fluentui_subtitle) +// ListItem.Item( +// text = subTitleText, +// subText = if (subtitle.isNullOrBlank()) +// LocalContext.current.resources.getString(R.string.fluentui_disabled) +// else +// LocalContext.current.resources.getString(R.string.fluentui_enabled), +// trailingAccessoryContent = { +// ToggleSwitch( +// onValueChange = { +// if (subtitle.isNullOrBlank()) { +// subtitle = subTitleText +// } else { +// subtitle = null +// } +// }, +// checkedState = !subtitle.isNullOrBlank(), +// modifier = Modifier.testTag(SNACK_BAR_SUBTITLE_PARAM) +// ) +// } +// ) +// } +// +// item { +// ListItem.Item( +// text = LocalContext.current.resources.getString(R.string.fluentui_action_button), +// subText = if (actionLabel) +// LocalContext.current.resources.getString(R.string.fluentui_disabled) +// else +// LocalContext.current.resources.getString(R.string.fluentui_enabled), +// trailingAccessoryContent = { +// ToggleSwitch( +// onValueChange = { +// actionLabel = it +// }, +// checkedState = actionLabel, +// modifier = Modifier.testTag(SNACK_BAR_ACTION_BUTTON_PARAM) +// ) +// } +// ) +// } +// +// item { +// ListItem.Item( +// text = LocalContext.current.resources.getString(R.string.fluentui_dismiss_button), +// subText = if (!dismissEnabled) +// LocalContext.current.resources.getString(R.string.fluentui_disabled) +// else +// LocalContext.current.resources.getString(R.string.fluentui_enabled), +// trailingAccessoryContent = { +// ToggleSwitch( +// onValueChange = { +// dismissEnabled = it +// }, +// checkedState = dismissEnabled, +// modifier = Modifier.testTag(SNACK_BAR_DISMISS_BUTTON_PARAM) +// ) +// } +// ) +// } +// } +// } +// +// Row( +// Modifier.fillMaxWidth(), +// horizontalArrangement = Arrangement.SpaceEvenly, +// verticalAlignment = Alignment.CenterVertically +// ) { +// val actionButtonString = +// LocalContext.current.resources.getString(R.string.fluentui_action_button) +// val dismissedString = +// LocalContext.current.resources.getString(R.string.fluentui_dismissed) +// val pressedString = +// LocalContext.current.resources.getString(R.string.fluentui_button_pressed) +// val timeoutString = +// LocalContext.current.resources.getString(R.string.fluentui_timeout) +// Button( +// onClick = { +// scope.launch { +// val result: NotificationResult = snackbarState.showSnackbar( +// "Hello from Fluent", +// style = style, +// icon = if (icon) FluentIcon(Icons.Outlined.ShoppingCart) else null, +// actionText = if (actionLabel) actionButtonString else null, +// subTitle = subtitle, +// duration = duration, +// enableDismiss = dismissEnabled, +// animationBehavior = customizedAnimationBehavior +// ) +// +// when (result) { +// NotificationResult.TIMEOUT -> Toast.makeText( +// context, +// timeoutString, +// Toast.LENGTH_SHORT +// ).show() +// +// NotificationResult.CLICKED -> Toast.makeText( +// context, +// pressedString, +// Toast.LENGTH_SHORT +// ).show() +// +// NotificationResult.DISMISSED -> Toast.makeText( +// context, +// dismissedString, +// Toast.LENGTH_SHORT +// ).show() +// } +// } +// }, +// text = LocalContext.current.resources.getString(R.string.fluentui_show_snackbar), +// size = ButtonSize.Small, +// style = ButtonStyle.OutlinedButton, +// modifier = Modifier.testTag(SNACK_BAR_SHOW_SNACKBAR) +// ) +// +// Button( +// onClick = { +// snackbarState.currentSnackbar?.dismiss(scope) +// }, +// text = LocalContext.current.resources.getString(R.string.fluentui_dismiss_snackbar), +// size = ButtonSize.Small, +// style = ButtonStyle.OutlinedButton, +// modifier = Modifier.testTag(SNACK_BAR_DISMISS_SNACKBAR) +// ) +// } +// Box(Modifier.fillMaxHeight(), contentAlignment = Alignment.Center) { +// Snackbar(snackbarState, Modifier.padding(bottom = 12.dp), null, true) +// } +// } } } } diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt new file mode 100644 index 000000000..1087de109 --- /dev/null +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -0,0 +1,212 @@ +package com.microsoft.fluentui.tokenized.notification + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import com.microsoft.fluentui.tokenized.controls.BasicCard +import com.microsoft.fluentui.tokenized.controls.Button +import com.microsoft.fluentui.util.clickableWithTooltip +import com.microsoft.fluentui.util.dpToPx +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +open class StackableSnackbarBehavior : AnimationBehavior() { + override suspend fun onShowAnimation() { + animationVariables.offsetX = Animatable(100f) + coroutineScope { + launch { // move the entire stack up, pass offset Y in each card + animationVariables.offsetY = Animatable(0f) + animationVariables.offsetY.animateTo( + targetValue = -100f, + animationSpec = tween( + easing = LinearEasing, + durationMillis = 200, + ) + ) + } + launch { + animationVariables.alpha.animateTo( + targetValue = 1F, + animationSpec = tween( + easing = LinearEasing, + durationMillis = 200, + ) + ) + } + +// launch { +// animationVariables.offsetX.animateTo( +// targetValue = 0f, +// animationSpec = tween( +// easing = LinearEasing, +// durationMillis = 200, +// ) +// ) +// } + } + } + + override suspend fun onDismissAnimation() { + animationVariables.offsetX.animateTo( + 0f, + animationSpec = tween( + easing = LinearEasing, + durationMillis = 150, + ) + ) + animationVariables.alpha.animateTo( + 0F, + animationSpec = tween( + easing = LinearEasing, + durationMillis = 150, + ) + ) + } +} +@Composable +fun Modifier.swipeToDismissFromStack( + animationVariables: AnimationVariables, + scope: CoroutineScope, + onDismiss: () -> Unit, +): Modifier { + val configuration = LocalConfiguration.current + val dismissThreshold = + dpToPx(configuration.screenWidthDp.dp) * 0.33f // One-third of screen width + return this.pointerInput(Unit) { + detectHorizontalDragGestures( + onDragEnd = { + if (animationVariables.offsetX.value < -dismissThreshold) { + scope.launch { + onDismiss() + } + } else { + scope.launch { + animationVariables.offsetX.animateTo( + 0f, + animationSpec = tween(300) + ) + } + } + }, + onHorizontalDrag = { _, dragAmount -> + scope.launch { + animationVariables.offsetX.snapTo(animationVariables.offsetX.value + dragAmount) + } + } + ) + } +} +@Composable +fun StackableSnackbar(){ + var enableDialog by remember{ mutableStateOf(false) } + Column() { + + if (!enableDialog) { + SingleSnackbarTile() + SingleSnackbarTile() + SingleSnackbarTile() + } else { + val popupProperties = PopupProperties( + focusable = true + ) + Popup( + onDismissRequest = { + enableDialog = false + } + ) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + BasicCard(modifier = Modifier.padding(10.dp)) { + BasicText("This is a Stackable Snackbar") + } + BasicCard(modifier = Modifier.padding(10.dp)) { + BasicText("This is a Stackable Snackbar") + } + BasicCard(modifier = Modifier.padding(10.dp)) { + BasicText("This is a Stackable Snackbar") + } + BasicCard(modifier = Modifier.padding(10.dp)) { + BasicText("This is a Stackable Snackbar") + } + } + } + } + } +} + + +@Composable +private fun SingleSnackbarTile(){ + var enableDialog by remember{ mutableStateOf(false) } + var stackableSnackbarBehavior: StackableSnackbarBehavior = remember { + StackableSnackbarBehavior() + } + var animationVariables = stackableSnackbarBehavior.animationVariables + val scope = rememberCoroutineScope() + var isShown by remember { mutableStateOf(false) } + Button( + onClick = { + scope.launch { + if(isShown) { + stackableSnackbarBehavior.onShowAnimation() + } + else{ + stackableSnackbarBehavior.onDismissAnimation() + } + } + isShown = !isShown + } + ) + if(isShown) { + BasicCard( + modifier = Modifier.padding(10.dp).graphicsLayer( + scaleX = animationVariables.scale.value, + scaleY = animationVariables.scale.value, + alpha = animationVariables.alpha.value, + translationX = animationVariables.offsetX.value, + translationY = animationVariables.offsetY.value + ).swipeToDismissFromStack( + animationVariables = animationVariables, + scope = scope, + onDismiss = { + isShown = false + } + ).clickableWithTooltip( + onClick = { + // enableDialog = true + + }, + tooltipText = "Snackbar clicked", + tooltipEnabled = true + ) + ) { + BasicText("Click here to show Stackable Snackbar") + } + Spacer(modifier = Modifier.height(10.dp)) + } +} \ No newline at end of file From 3f31223249f6ce013aaf25102d6ae1a7ee7709a7 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Fri, 8 Aug 2025 14:11:35 +0530 Subject: [PATCH 02/22] Initial implementation --- .../fluentuidemo/demos/V2SnackbarActivity.kt | 8 +- .../notification/StackableSnackbar.kt | 331 ++++++++++++++++++ 2 files changed, 336 insertions(+), 3 deletions(-) diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt index 17749322c..56e0ce5b9 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt @@ -31,6 +31,7 @@ import com.microsoft.fluentui.tokenized.listitem.ChevronOrientation import com.microsoft.fluentui.tokenized.listitem.ListItem import com.microsoft.fluentui.tokenized.notification.AnimationBehavior import com.microsoft.fluentui.tokenized.notification.AnimationVariables +import com.microsoft.fluentui.tokenized.notification.DemoCardStack import com.microsoft.fluentui.tokenized.notification.NotificationDuration import com.microsoft.fluentui.tokenized.notification.NotificationResult import com.microsoft.fluentui.tokenized.notification.Snackbar @@ -65,9 +66,10 @@ class V2SnackbarActivity : V2DemoActivity() { val context = this setActivityContent { - Box(modifier = Modifier.fillMaxSize()){ - StackableSnackbar() - } + DemoCardStack() +// Box(modifier = Modifier.fillMaxSize()){ +// StackableSnackbar() +// } // val snackbarState = remember { SnackbarState() } // // val scope = rememberCoroutineScope() diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index 1087de109..2de1dd5b5 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -34,6 +34,337 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch +/* + CardStack.kt (Scrollable, snapping & expanding cards) + + - Improved scroll behaviour: when `enableScroll=true` you can drag vertically. + - Scrolling is paged/snapped so each card expands to show full content when focused. + - While a card is focused (front), the previously focused card becomes part of the stack (peeked). + - Relative order of cards remains unchanged. + - Front card remains swipeable horizontally to remove it (with animation). + - Adding a new card animates it in and scrolls the stack to show the new front. + + Production notes: + - `CardModel` still accepts a composable content lambda (don't persist lambdas across process death). + - Tweak `spring`/`tween` timings to match your design system. + - Accessibility: add semantics and roles for swipe/expand actions if needed. +*/ + +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import androidx.compose.ui.zIndex +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.roundToInt + +/** Model for a single card. */ +data class CardModel(val id: String, val content: @Composable BoxScope.() -> Unit) + +/** Public state controlling the stack. */ +class CardStackState(internal val cards: MutableList) { + internal val snapshotStateList = mutableStateListOf().apply { addAll(cards) } + fun addCard(card: CardModel) { snapshotStateList.add(0, card) } + fun removeCardById(id: String) { snapshotStateList.removeAll { it.id == id } } + fun popFront(): CardModel? = if (snapshotStateList.isNotEmpty()) snapshotStateList.removeAt(0) else null + fun size(): Int = snapshotStateList.size +} + +@Composable +fun rememberCardStackState(initial: List = emptyList()): CardStackState { + return remember { CardStackState(initial.toMutableList()) } +} + +@Composable +fun CardStack( + state: CardStackState, + modifier: Modifier = Modifier, + cardWidth: Dp = 320.dp, + cardHeight: Dp = 160.dp, + peekHeight: Dp = 24.dp, + enableScroll: Boolean = false, + contentModifier: Modifier = Modifier +) { + val listSnapshot = state.snapshotStateList.toList() + val count by remember { derivedStateOf { listSnapshot.size } } + + val targetHeight by remember(count) { + mutableStateOf(cardHeight + (if (count > 0) (count - 1) * peekHeight else 0.dp)) + } + + // Animate stack height changes + val animatedStackHeight by androidx.compose.animation.core.animateDpAsState( + targetValue = targetHeight, + animationSpec = spring(stiffness = androidx.compose.animation.core.Spring.StiffnessMedium) + ) + + val density = LocalDensity.current + val cardHeightPx = with(density) { cardHeight.toPx() } + val peekPx = with(density) { peekHeight.toPx() } + val step = max(1f, cardHeightPx - peekPx) + + // scrollAnim holds current scroll offset in pixels; 0 -> first card focused + val scrollAnim = remember { Animatable(0f) } + val scope = rememberCoroutineScope() + + // When a new card is added to front, animate stack to show front (0) + var prevCount by rememberSaveable { mutableStateOf(count) } + LaunchedEffect(count) { + if (count > prevCount) { + // new card likely added to front + scrollAnim.animateTo(0f, animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)) + } + prevCount = count + } + + // Max scroll range (when you focus last card) + val maxScroll = max(0f, (max(0, count - 1) * step)) + + // Drag handling for vertical scroll (when enabled) + val dragModifier = if (enableScroll) { + Modifier.pointerInput(listSnapshot) { + detectDragGestures( + onDragStart = { /* no-op */ }, + onDrag = { change, dragAmount -> + change.consume() + scope.launch { scrollAnim.snapTo((scrollAnim.value - dragAmount.y).coerceIn(0f, maxScroll)) } + }, + onDragEnd = { + // Snap to nearest index + scope.launch { + val t = (scrollAnim.value / step).coerceIn(0f, (max(0, count - 1)).toFloat()) + val targetIndex = t.roundToInt() + val target = (targetIndex * step).coerceIn(0f, maxScroll) + scrollAnim.animateTo(target, animationSpec = spring(stiffness = Spring.StiffnessMedium)) + } + }, + onDragCancel = { + scope.launch { + val t = (scrollAnim.value / step).coerceIn(0f, (max(0, count - 1)).toFloat()) + val targetIndex = t.roundToInt() + val target = (targetIndex * step).coerceIn(0f, maxScroll) + scrollAnim.animateTo(target, animationSpec = spring(stiffness = Spring.StiffnessMedium)) + } + } + ) + } + } else Modifier + + Box( + modifier = modifier + .width(cardWidth) + .height(animatedStackHeight) + .then(dragModifier) + .wrapContentHeight(align = Alignment.Top) + ) { + // compute current fractional stage + val t = (if (step > 0f) scrollAnim.value / step else 0f).coerceIn(0f, max(0f, (count - 1).toFloat())) + val k = t.toInt().coerceIn(0, max(0, count - 1)) + val frac = t - k + + // helper to compute positions at integer stage m + fun posAtStage(m: Int, j: Int): Float { + return if (j <= m) { + (m - j) * peekPx + } else { + j * peekPx + } + } + + // iterate cards and render them at computed y positions; use zIndex so top/front is drawn last + listSnapshot.forEachIndexed { j, card -> + key(card.id) { + // compute y using linear interpolation between stage k and k+1 + val nextStage = (k + 1).coerceAtMost(max(0, count - 1)) + val startPos = posAtStage(k, j) + val endPos = posAtStage(nextStage, j) + val y = (startPos * (1f - frac) + endPos * frac) + + // compute a z-index so smaller y (closer to top) is rendered on top + val z = -y + + val selectedIndex = (t).roundToInt().coerceIn(0, max(0, count - 1)) + val isFront = j == selectedIndex + + CardStackItem( + model = card, + index = j, + yPx = y, + zIndex = z, + isFront = isFront, + cardWidth = cardWidth, + cardHeight = cardHeight, + onSwipedAway = { removedIndex, id -> + // remove from state and adjust scroll position to a sensible target + val beforeRemove = scrollAnim.value + state.removeCardById(id) + + // recalc maxScroll and clamp + val newCount = state.size() + val newMax = max(0f, (max(0, newCount - 1) * step)) + scope.launch { + val approxStage = (beforeRemove / step).coerceIn(0f, max(0f, (newCount - 1).toFloat())) + val targetStage = approxStage.roundToInt().coerceIn(0, max(0, newCount - 1)) + val targetPx = (targetStage * step).coerceIn(0f, newMax) + scrollAnim.animateTo(targetPx, animationSpec = tween(durationMillis = 240, easing = FastOutLinearInEasing)) + } + }, + contentModifier = contentModifier + ) + } + } + } +} + +@Composable +private fun CardStackItem( + model: CardModel, + index: Int, + yPx: Float, + zIndex: Float, + isFront: Boolean, + cardWidth: Dp, + cardHeight: Dp, + onSwipedAway: (Int, String) -> Unit, + contentModifier: Modifier = Modifier +) { + val scope = rememberCoroutineScope() + + // slide-in animation for newly added cards (if they become front) + val slideAnim = remember { Animatable(1f) } // 1f = off-screen (right), 0f = in-place + LaunchedEffect(model.id, isFront) { + if (isFront) { + slideAnim.snapTo(1f) + slideAnim.animateTo(0f, animationSpec = tween(durationMillis = 320, easing = FastOutSlowInEasing)) + } else { + slideAnim.snapTo(0f) + } + } + + // horizontal swipe anim + val swipeX = remember { Animatable(0f) } + val localDensity = LocalDensity.current + Box( + modifier = Modifier + .zIndex(zIndex) + .offset { + // x offset from slide & swipe, y offset from stack calculation + val x = ((slideAnim.value) * with(localDensity) { 200.dp.toPx() } + swipeX.value).roundToInt() + IntOffset(x, yPx.roundToInt()) + } + .width(cardWidth) + .height(cardHeight) + .then( + if (isFront) Modifier.pointerInput(model.id) { + detectDragGestures( + onDragEnd = { + // threshold based on width + val threshold = with(localDensity) { (cardWidth / 4).toPx() } + scope.launch { + if (abs(swipeX.value) > threshold) { + val target = if (swipeX.value > 0) with(localDensity) { cardWidth.toPx() * 1.2f } else -with(localDensity) { cardWidth.toPx() * 1.2f } + swipeX.animateTo(target, animationSpec = tween(durationMillis = 240, easing = FastOutLinearInEasing)) + onSwipedAway(index, model.id) + } else { + swipeX.animateTo(0f, animationSpec = spring(stiffness = Spring.StiffnessMedium)) + } + } + }, + onDragCancel = { + scope.launch { swipeX.animateTo(0f, animationSpec = spring(stiffness = Spring.StiffnessMedium)) } + }, + onDrag = { change, dragAmount -> + // only horizontal motion affects swipe + change.consume() + scope.launch { swipeX.snapTo(swipeX.value + dragAmount.x) } + } + ) + } else Modifier + ) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .shadow(elevation = if (isFront) 12.dp else 4.dp, shape = RoundedCornerShape(12.dp)) + .border(width = 1.dp, color = Color(0x22000000), shape = RoundedCornerShape(12.dp)) + .background(color = Color.LightGray, shape = RoundedCornerShape(12.dp)) + .then(contentModifier) + ) { + Box(modifier = Modifier.fillMaxSize(), content = model.content) + } + } +} + +@Composable +fun DemoCardStack() { + val stackState = rememberCardStackState() + + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize()) { + CardStack( + state = stackState, + modifier = Modifier.padding(16.dp), + cardWidth = 340.dp, + cardHeight = 180.dp, + peekHeight = 28.dp, + enableScroll = true + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Row { + Button(onClick = { + val id = java.util.UUID.randomUUID().toString() + stackState.addCard(CardModel(id = id) { + Column(modifier = Modifier.padding(12.dp)) { + BasicText("Card: $id") + BasicText("Some detail here") + } + }) + }, + text = "Add card") + + Spacer(modifier = Modifier.width(12.dp)) + + Button(onClick = { stackState.popFront() }, text = "Remove front card") + } + } +} + open class StackableSnackbarBehavior : AnimationBehavior() { override suspend fun onShowAnimation() { animationVariables.offsetX = Animatable(100f) From c3986a4b331226e38e116c5ef79ad5841cc94d0b Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Fri, 8 Aug 2025 14:29:58 +0530 Subject: [PATCH 03/22] Improvements --- .../notification/StackableSnackbar.kt | 432 ++++++++---------- 1 file changed, 201 insertions(+), 231 deletions(-) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index 2de1dd5b5..99f4e05b3 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -33,53 +33,31 @@ import com.microsoft.fluentui.util.dpToPx import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch - /* - CardStack.kt (Scrollable, snapping & expanding cards) - - - Improved scroll behaviour: when `enableScroll=true` you can drag vertically. - - Scrolling is paged/snapped so each card expands to show full content when focused. - - While a card is focused (front), the previously focused card becomes part of the stack (peeked). - - Relative order of cards remains unchanged. - - Front card remains swipeable horizontally to remove it (with animation). - - Adding a new card animates it in and scrolls the stack to show the new front. - - Production notes: - - `CardModel` still accepts a composable content lambda (don't persist lambdas across process death). - - Tweak `spring`/`tween` timings to match your design system. - - Accessibility: add semantics and roles for swipe/expand actions if needed. + CardStack.kt + Production-ready Jetpack Compose component that displays a vertically-stacking deck of cards. + Features: + - Exposes CardStackState with addCard/removeCard functions + - Each card is a Box with outline and elevation (Card composable) + - New cards slide in from the right and push the stack up a bit + - Front card is swipeable horizontally; swiping past threshold removes it with animation + - Stack keeps the same width; height grows as you add cards (peek of cards visible) + - Uses composable lambdas as card content so you can pass any UI inside cards + + Usage example at bottom. */ -import androidx.compose.animation.core.FastOutLinearInEasing -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween + +import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity @@ -87,21 +65,37 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times -import androidx.compose.ui.zIndex +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlin.math.abs -import kotlin.math.max import kotlin.math.roundToInt -/** Model for a single card. */ -data class CardModel(val id: String, val content: @Composable BoxScope.() -> Unit) +/** Single card model contains an id and a composable content lambda. */ +data class CardModel( + val id: String, + val content: @Composable () -> Unit +) -/** Public state controlling the stack. */ -class CardStackState(internal val cards: MutableList) { +/** Public state object to control the stack. */ +class CardStackState( + internal val cards: MutableList +) { internal val snapshotStateList = mutableStateListOf().apply { addAll(cards) } - fun addCard(card: CardModel) { snapshotStateList.add(0, card) } - fun removeCardById(id: String) { snapshotStateList.removeAll { it.id == id } } - fun popFront(): CardModel? = if (snapshotStateList.isNotEmpty()) snapshotStateList.removeAt(0) else null + + fun addCard(card: CardModel) { + // add to front so index 0 is top + snapshotStateList.add(0, card) + } + + fun removeCardById(id: String) { + snapshotStateList.removeAll { it.id == id } + } + + fun popFront(): CardModel? { + return if (snapshotStateList.isNotEmpty()) snapshotStateList.removeAt(0) else null + } + fun size(): Int = snapshotStateList.size } @@ -110,6 +104,14 @@ fun rememberCardStackState(initial: List = emptyList()): CardStackSta return remember { CardStackState(initial.toMutableList()) } } +/** + * CardStack composable. + * @param state state controlling cards + * @param cardWidth fixed width of the stack + * @param cardHeight base height for each card + * @param peekHeight how much of the previous card is visible under the top card + * @param contentModifier modifier applied to each card slot + */ @Composable fun CardStack( state: CardStackState, @@ -117,133 +119,45 @@ fun CardStack( cardWidth: Dp = 320.dp, cardHeight: Dp = 160.dp, peekHeight: Dp = 24.dp, - enableScroll: Boolean = false, contentModifier: Modifier = Modifier ) { - val listSnapshot = state.snapshotStateList.toList() - val count by remember { derivedStateOf { listSnapshot.size } } + // Total stack height: cardHeight + (count-1) * peekHeight + val count by remember { derivedStateOf { state.size() } } - val targetHeight by remember(count) { + val targetHeight by remember(count, cardHeight, peekHeight) { mutableStateOf(cardHeight + (if (count > 0) (count - 1) * peekHeight else 0.dp)) } - // Animate stack height changes - val animatedStackHeight by androidx.compose.animation.core.animateDpAsState( + // Smoothly animate stack height when count changes + val animatedStackHeight by animateDpAsState( targetValue = targetHeight, - animationSpec = spring(stiffness = androidx.compose.animation.core.Spring.StiffnessMedium) + animationSpec = spring(stiffness = Spring.StiffnessMedium) ) - val density = LocalDensity.current - val cardHeightPx = with(density) { cardHeight.toPx() } - val peekPx = with(density) { peekHeight.toPx() } - val step = max(1f, cardHeightPx - peekPx) - - // scrollAnim holds current scroll offset in pixels; 0 -> first card focused - val scrollAnim = remember { Animatable(0f) } - val scope = rememberCoroutineScope() - - // When a new card is added to front, animate stack to show front (0) - var prevCount by rememberSaveable { mutableStateOf(count) } - LaunchedEffect(count) { - if (count > prevCount) { - // new card likely added to front - scrollAnim.animateTo(0f, animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)) - } - prevCount = count - } - - // Max scroll range (when you focus last card) - val maxScroll = max(0f, (max(0, count - 1) * step)) - - // Drag handling for vertical scroll (when enabled) - val dragModifier = if (enableScroll) { - Modifier.pointerInput(listSnapshot) { - detectDragGestures( - onDragStart = { /* no-op */ }, - onDrag = { change, dragAmount -> - change.consume() - scope.launch { scrollAnim.snapTo((scrollAnim.value - dragAmount.y).coerceIn(0f, maxScroll)) } - }, - onDragEnd = { - // Snap to nearest index - scope.launch { - val t = (scrollAnim.value / step).coerceIn(0f, (max(0, count - 1)).toFloat()) - val targetIndex = t.roundToInt() - val target = (targetIndex * step).coerceIn(0f, maxScroll) - scrollAnim.animateTo(target, animationSpec = spring(stiffness = Spring.StiffnessMedium)) - } - }, - onDragCancel = { - scope.launch { - val t = (scrollAnim.value / step).coerceIn(0f, (max(0, count - 1)).toFloat()) - val targetIndex = t.roundToInt() - val target = (targetIndex * step).coerceIn(0f, maxScroll) - scrollAnim.animateTo(target, animationSpec = spring(stiffness = Spring.StiffnessMedium)) - } - } - ) - } - } else Modifier - Box( modifier = modifier .width(cardWidth) .height(animatedStackHeight) - .then(dragModifier) .wrapContentHeight(align = Alignment.Top) ) { - // compute current fractional stage - val t = (if (step > 0f) scrollAnim.value / step else 0f).coerceIn(0f, max(0f, (count - 1).toFloat())) - val k = t.toInt().coerceIn(0, max(0, count - 1)) - val frac = t - k - - // helper to compute positions at integer stage m - fun posAtStage(m: Int, j: Int): Float { - return if (j <= m) { - (m - j) * peekPx - } else { - j * peekPx - } - } - - // iterate cards and render them at computed y positions; use zIndex so top/front is drawn last - listSnapshot.forEachIndexed { j, card -> - key(card.id) { - // compute y using linear interpolation between stage k and k+1 - val nextStage = (k + 1).coerceAtMost(max(0, count - 1)) - val startPos = posAtStage(k, j) - val endPos = posAtStage(nextStage, j) - val y = (startPos * (1f - frac) + endPos * frac) + // Show cards in reverse visual order: bottom-most drawn first + val listSnapshot = state.snapshotStateList.toList() - // compute a z-index so smaller y (closer to top) is rendered on top - val z = -y - - val selectedIndex = (t).roundToInt().coerceIn(0, max(0, count - 1)) - val isFront = j == selectedIndex + listSnapshot.reversed().forEachIndexed { visuallyReversedIndex, cardModel -> + // compute logical index from top (0 is top) + val logicalIndex = listSnapshot.size - 1 - visuallyReversedIndex + val isTop = logicalIndex == 0 + key(cardModel.id) { + // Each card will be placed offset from top by logicalIndex * peekHeight CardStackItem( - model = card, - index = j, - yPx = y, - zIndex = z, - isFront = isFront, - cardWidth = cardWidth, + model = cardModel, + index = logicalIndex, + isTop = isTop, cardHeight = cardHeight, - onSwipedAway = { removedIndex, id -> - // remove from state and adjust scroll position to a sensible target - val beforeRemove = scrollAnim.value - state.removeCardById(id) - - // recalc maxScroll and clamp - val newCount = state.size() - val newMax = max(0f, (max(0, newCount - 1) * step)) - scope.launch { - val approxStage = (beforeRemove / step).coerceIn(0f, max(0f, (newCount - 1).toFloat())) - val targetStage = approxStage.roundToInt().coerceIn(0, max(0, newCount - 1)) - val targetPx = (targetStage * step).coerceIn(0f, newMax) - scrollAnim.animateTo(targetPx, animationSpec = tween(durationMillis = 240, easing = FastOutLinearInEasing)) - } - }, + peekHeight = peekHeight, + cardWidth = cardWidth, + onSwipedAway = { idToRemove -> state.removeCardById(idToRemove) }, contentModifier = contentModifier ) } @@ -255,77 +169,119 @@ fun CardStack( private fun CardStackItem( model: CardModel, index: Int, - yPx: Float, - zIndex: Float, - isFront: Boolean, + isTop: Boolean, cardWidth: Dp, cardHeight: Dp, - onSwipedAway: (Int, String) -> Unit, + peekHeight: Dp, + onSwipedAway: (String) -> Unit, contentModifier: Modifier = Modifier ) { val scope = rememberCoroutineScope() - // slide-in animation for newly added cards (if they become front) - val slideAnim = remember { Animatable(1f) } // 1f = off-screen (right), 0f = in-place - LaunchedEffect(model.id, isFront) { - if (isFront) { - slideAnim.snapTo(1f) - slideAnim.animateTo(0f, animationSpec = tween(durationMillis = 320, easing = FastOutSlowInEasing)) + // y offset for stacking + val targetYOffset = with(LocalDensity.current) { (index * peekHeight).toPx() } + val animatedYOffset = remember { Animatable(targetYOffset) } + + // When index changes (stack updated), animate to new y offset + LaunchedEffect(index) { + animatedYOffset.animateTo( + targetYOffset, + animationSpec = spring(stiffness = Spring.StiffnessLow) + ) + } + + // Slide in animation for a newly added top card + val slideInProgress = remember { Animatable(1f) } // 1 = offscreen right, 0 = in place + LaunchedEffect(model.id) { + // if this is top when added, slide from right + if (isTop) { + slideInProgress.snapTo(1f) + slideInProgress.animateTo( + 0f, + animationSpec = tween(durationMillis = 350, easing = FastOutSlowInEasing) + ) } else { - slideAnim.snapTo(0f) + slideInProgress.snapTo(0f) } } - // horizontal swipe anim + // horizontal drag and swipe logic (only for top) val swipeX = remember { Animatable(0f) } + val removalJob = remember { mutableStateOf(null) } + + val offsetX: Float = if (isTop) swipeX.value else 0f val localDensity = LocalDensity.current Box( modifier = Modifier - .zIndex(zIndex) .offset { - // x offset from slide & swipe, y offset from stack calculation - val x = ((slideAnim.value) * with(localDensity) { 200.dp.toPx() } + swipeX.value).roundToInt() - IntOffset(x, yPx.roundToInt()) + IntOffset( + offsetX.roundToInt() + (slideInProgress.value * with(localDensity) { 200.dp.toPx() }).roundToInt(), + animatedYOffset.value.roundToInt() + ) } .width(cardWidth) .height(cardHeight) - .then( - if (isFront) Modifier.pointerInput(model.id) { - detectDragGestures( - onDragEnd = { - // threshold based on width - val threshold = with(localDensity) { (cardWidth / 4).toPx() } - scope.launch { - if (abs(swipeX.value) > threshold) { - val target = if (swipeX.value > 0) with(localDensity) { cardWidth.toPx() * 1.2f } else -with(localDensity) { cardWidth.toPx() * 1.2f } - swipeX.animateTo(target, animationSpec = tween(durationMillis = 240, easing = FastOutLinearInEasing)) - onSwipedAway(index, model.id) - } else { - swipeX.animateTo(0f, animationSpec = spring(stiffness = Spring.StiffnessMedium)) - } + .padding(horizontal = 0.dp) + .then(if (isTop) Modifier.pointerInput(model.id) { + detectDragGestures( + onDragStart = { /* no-op */ }, + onDragEnd = { + // decide threshold + val threshold = with(localDensity) { (cardWidth / 4).toPx() } + scope.launch { + if (abs(swipeX.value) > threshold) { + // animate off screen in the drag direction then remove + val target = + if (swipeX.value > 0) with(localDensity) { cardWidth.toPx() * 1.2f } else -with( + localDensity + ) { cardWidth.toPx() * 1.2f } + swipeX.animateTo( + target, + animationSpec = tween( + durationMillis = 240, + easing = FastOutLinearInEasing + ) + ) + // remove after animation + onSwipedAway(model.id) + } else { + // return to center + swipeX.animateTo( + 0f, + animationSpec = spring(stiffness = Spring.StiffnessMedium) + ) } - }, - onDragCancel = { - scope.launch { swipeX.animateTo(0f, animationSpec = spring(stiffness = Spring.StiffnessMedium)) } - }, - onDrag = { change, dragAmount -> - // only horizontal motion affects swipe - change.consume() - scope.launch { swipeX.snapTo(swipeX.value + dragAmount.x) } } - ) - } else Modifier - ) + }, + onDragCancel = { + scope.launch { + swipeX.animateTo( + 0f, + animationSpec = spring(stiffness = Spring.StiffnessMedium) + ) + } + }, + onDrag = { change, dragAmount -> + change.consume() + scope.launch { + swipeX.snapTo(swipeX.value + dragAmount.x) + } + } + ) + } else Modifier) ) { + // Card visuals Box( modifier = Modifier .fillMaxSize() - .shadow(elevation = if (isFront) 12.dp else 4.dp, shape = RoundedCornerShape(12.dp)) + .shadow(elevation = if (isTop) 12.dp else 4.dp, shape = RoundedCornerShape(12.dp)) .border(width = 1.dp, color = Color(0x22000000), shape = RoundedCornerShape(12.dp)) .background(color = Color.LightGray, shape = RoundedCornerShape(12.dp)) - .then(contentModifier) + .then(contentModifier), ) { - Box(modifier = Modifier.fillMaxSize(), content = model.content) + Box(modifier = Modifier.fillMaxSize()) { + model.content() + } } } } @@ -340,8 +296,7 @@ fun DemoCardStack() { modifier = Modifier.padding(16.dp), cardWidth = 340.dp, cardHeight = 180.dp, - peekHeight = 28.dp, - enableScroll = true + peekHeight = 28.dp ) Spacer(modifier = Modifier.height(20.dp)) @@ -355,16 +310,26 @@ fun DemoCardStack() { BasicText("Some detail here") } }) - }, - text = "Add card") + }, text = "Add card") Spacer(modifier = Modifier.width(12.dp)) - Button(onClick = { stackState.popFront() }, text = "Remove front card") + Button(onClick = { + stackState.popFront() + }, text = "Remove top card") } } } +/* Notes & production tips: + - This implementation stores composable lambdas in CardModel so you can pass arbitrary content. + - If you plan to persist models across process death, store only IDs and data (not lambdas). + - You can extend swipe gestures to support velocity and fling using androidx.compose.foundation.gestures. + - Improve accessibility by adding semantics for swipe actions and content descriptions. + - Tweak animation timings and easings to match your app design system. +*/ + + open class StackableSnackbarBehavior : AnimationBehavior() { override suspend fun onShowAnimation() { animationVariables.offsetX = Animatable(100f) @@ -418,6 +383,7 @@ open class StackableSnackbarBehavior : AnimationBehavior() { ) } } + @Composable fun Modifier.swipeToDismissFromStack( animationVariables: AnimationVariables, @@ -451,9 +417,10 @@ fun Modifier.swipeToDismissFromStack( ) } } + @Composable -fun StackableSnackbar(){ - var enableDialog by remember{ mutableStateOf(false) } +fun StackableSnackbar() { + var enableDialog by remember { mutableStateOf(false) } Column() { if (!enableDialog) { @@ -492,8 +459,8 @@ fun StackableSnackbar(){ @Composable -private fun SingleSnackbarTile(){ - var enableDialog by remember{ mutableStateOf(false) } +private fun SingleSnackbarTile() { + var enableDialog by remember { mutableStateOf(false) } var stackableSnackbarBehavior: StackableSnackbarBehavior = remember { StackableSnackbarBehavior() } @@ -503,38 +470,41 @@ private fun SingleSnackbarTile(){ Button( onClick = { scope.launch { - if(isShown) { + if (isShown) { stackableSnackbarBehavior.onShowAnimation() - } - else{ + } else { stackableSnackbarBehavior.onDismissAnimation() } } isShown = !isShown } ) - if(isShown) { + if (isShown) { BasicCard( - modifier = Modifier.padding(10.dp).graphicsLayer( - scaleX = animationVariables.scale.value, - scaleY = animationVariables.scale.value, - alpha = animationVariables.alpha.value, - translationX = animationVariables.offsetX.value, - translationY = animationVariables.offsetY.value - ).swipeToDismissFromStack( - animationVariables = animationVariables, - scope = scope, - onDismiss = { - isShown = false - } - ).clickableWithTooltip( - onClick = { - // enableDialog = true + modifier = Modifier + .padding(10.dp) + .graphicsLayer( + scaleX = animationVariables.scale.value, + scaleY = animationVariables.scale.value, + alpha = animationVariables.alpha.value, + translationX = animationVariables.offsetX.value, + translationY = animationVariables.offsetY.value + ) + .swipeToDismissFromStack( + animationVariables = animationVariables, + scope = scope, + onDismiss = { + isShown = false + } + ) + .clickableWithTooltip( + onClick = { + // enableDialog = true - }, - tooltipText = "Snackbar clicked", - tooltipEnabled = true - ) + }, + tooltipText = "Snackbar clicked", + tooltipEnabled = true + ) ) { BasicText("Click here to show Stackable Snackbar") } From de11efd9da3b613287466b8a6279df31e7dc1be0 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Fri, 8 Aug 2025 15:12:38 +0530 Subject: [PATCH 04/22] Added configuration to stack above --- .../notification/StackableSnackbar.kt | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index 99f4e05b3..4d823ddf5 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -53,6 +53,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -119,6 +120,7 @@ fun CardStack( cardWidth: Dp = 320.dp, cardHeight: Dp = 160.dp, peekHeight: Dp = 24.dp, + stackAbove: Boolean = true, // if true, cards stack above each other (negative offset) contentModifier: Modifier = Modifier ) { // Total stack height: cardHeight + (count-1) * peekHeight @@ -138,7 +140,17 @@ fun CardStack( modifier = modifier .width(cardWidth) .height(animatedStackHeight) - .wrapContentHeight(align = Alignment.Top) + .wrapContentHeight( + align = if (stackAbove) { + Alignment.Bottom + } else { + Alignment.Top + } + ) + .clickableWithTooltip( + onClick = {}, + tooltipText = "Notification Stack", + ) ) { // Show cards in reverse visual order: bottom-most drawn first val listSnapshot = state.snapshotStateList.toList() @@ -158,6 +170,7 @@ fun CardStack( peekHeight = peekHeight, cardWidth = cardWidth, onSwipedAway = { idToRemove -> state.removeCardById(idToRemove) }, + stackAbove = stackAbove, contentModifier = contentModifier ) } @@ -174,6 +187,7 @@ private fun CardStackItem( cardHeight: Dp, peekHeight: Dp, onSwipedAway: (String) -> Unit, + stackAbove: Boolean = false, contentModifier: Modifier = Modifier ) { val scope = rememberCoroutineScope() @@ -185,7 +199,7 @@ private fun CardStackItem( // When index changes (stack updated), animate to new y offset LaunchedEffect(index) { animatedYOffset.animateTo( - targetYOffset, + targetYOffset * (if (stackAbove) -1f else 1f), animationSpec = spring(stiffness = Spring.StiffnessLow) ) } @@ -290,13 +304,14 @@ private fun CardStackItem( fun DemoCardStack() { val stackState = rememberCardStackState() - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize()) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom) { CardStack( state = stackState, modifier = Modifier.padding(16.dp), cardWidth = 340.dp, cardHeight = 180.dp, - peekHeight = 28.dp + peekHeight = 28.dp, + stackAbove = true ) Spacer(modifier = Modifier.height(20.dp)) From bc1313965997af9f8bff2039586284eeecad686b Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Fri, 8 Aug 2025 16:07:01 +0530 Subject: [PATCH 05/22] Added removal animation, enforced stack limit --- .../notification/StackableSnackbar.kt | 268 ++++-------------- 1 file changed, 49 insertions(+), 219 deletions(-) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index 4d823ddf5..d989dfc19 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -33,21 +33,6 @@ import com.microsoft.fluentui.util.dpToPx import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch -/* - CardStack.kt - Production-ready Jetpack Compose component that displays a vertically-stacking deck of cards. - Features: - - Exposes CardStackState with addCard/removeCard functions - - Each card is a Box with outline and elevation (Card composable) - - New cards slide in from the right and push the stack up a bit - - Front card is swipeable horizontally; swiping past threshold removes it with animation - - Stack keeps the same width; height grows as you add cards (peek of cards visible) - - Uses composable lambdas as card content so you can pass any UI inside cards - - Usage example at bottom. -*/ - - import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -68,6 +53,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.roundToInt @@ -75,6 +61,7 @@ import kotlin.math.roundToInt /** Single card model contains an id and a composable content lambda. */ data class CardModel( val id: String, + var inRemoval: Boolean = false, val content: @Composable () -> Unit ) @@ -84,9 +71,12 @@ class CardStackState( ) { internal val snapshotStateList = mutableStateListOf().apply { addAll(cards) } - fun addCard(card: CardModel) { + suspend fun addCard(card: CardModel, maxSize: Int = 5) { // add to front so index 0 is top snapshotStateList.add(0, card) + if(snapshotStateList.size >= maxSize) { + popBack() + } } fun removeCardById(id: String) { @@ -97,6 +87,12 @@ class CardStackState( return if (snapshotStateList.isNotEmpty()) snapshotStateList.removeAt(0) else null } + suspend fun popBack(): CardModel? { + snapshotStateList.get(snapshotStateList.size -1).inRemoval = true + delay(360) + return if (snapshotStateList.isNotEmpty()) snapshotStateList.removeAt(snapshotStateList.size - 1) else null + } + fun size(): Int = snapshotStateList.size } @@ -119,7 +115,7 @@ fun CardStack( modifier: Modifier = Modifier, cardWidth: Dp = 320.dp, cardHeight: Dp = 160.dp, - peekHeight: Dp = 24.dp, + peekHeight: Dp = 10.dp, stackAbove: Boolean = true, // if true, cards stack above each other (negative offset) contentModifier: Modifier = Modifier ) { @@ -192,11 +188,9 @@ private fun CardStackItem( ) { val scope = rememberCoroutineScope() - // y offset for stacking + // Card Adjust Animation val targetYOffset = with(LocalDensity.current) { (index * peekHeight).toPx() } val animatedYOffset = remember { Animatable(targetYOffset) } - - // When index changes (stack updated), animate to new y offset LaunchedEffect(index) { animatedYOffset.animateTo( targetYOffset * (if (stackAbove) -1f else 1f), @@ -204,7 +198,7 @@ private fun CardStackItem( ) } - // Slide in animation for a newly added top card + // Slide In Animation TODO: Add configurations val slideInProgress = remember { Animatable(1f) } // 1 = offscreen right, 0 = in place LaunchedEffect(model.id) { // if this is top when added, slide from right @@ -219,7 +213,22 @@ private fun CardStackItem( } } - // horizontal drag and swipe logic (only for top) + // Fade Out Animation TODO: Add configurations + val opacityProgress = remember { Animatable(1f) } + LaunchedEffect(model.inRemoval) { + if(model.inRemoval) { + if (!isTop) { + opacityProgress.snapTo(1f) + opacityProgress.animateTo( + 0f, + animationSpec = tween(durationMillis = 350, easing = FastOutSlowInEasing) + ) + } else { + slideInProgress.snapTo(1f) + } + } + } + val swipeX = remember { Animatable(0f) } val removalJob = remember { mutableStateOf(null) } @@ -233,6 +242,9 @@ private fun CardStackItem( animatedYOffset.value.roundToInt() ) } + .graphicsLayer( + alpha = opacityProgress.value + ) .width(cardWidth) .height(cardHeight) .padding(horizontal = 0.dp) @@ -284,7 +296,7 @@ private fun CardStackItem( ) } else Modifier) ) { - // Card visuals + // Card visuals TODO: Replace with card composable Box( modifier = Modifier .fillMaxSize() @@ -303,14 +315,14 @@ private fun CardStackItem( @Composable fun DemoCardStack() { val stackState = rememberCardStackState() - + val scope = rememberCoroutineScope() Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom) { CardStack( state = stackState, modifier = Modifier.padding(16.dp), cardWidth = 340.dp, cardHeight = 180.dp, - peekHeight = 28.dp, + peekHeight = 10.dp, stackAbove = true ) @@ -319,12 +331,14 @@ fun DemoCardStack() { Row { Button(onClick = { val id = java.util.UUID.randomUUID().toString() - stackState.addCard(CardModel(id = id) { - Column(modifier = Modifier.padding(12.dp)) { - BasicText("Card: $id") - BasicText("Some detail here") - } - }) + scope.launch { + stackState.addCard(CardModel(id = id) { + Column(modifier = Modifier.padding(12.dp)) { + BasicText("Card: $id") + BasicText("Some detail here") + } + }) + } }, text = "Add card") Spacer(modifier = Modifier.width(12.dp)) @@ -332,197 +346,13 @@ fun DemoCardStack() { Button(onClick = { stackState.popFront() }, text = "Remove top card") - } - } -} - -/* Notes & production tips: - - This implementation stores composable lambdas in CardModel so you can pass arbitrary content. - - If you plan to persist models across process death, store only IDs and data (not lambdas). - - You can extend swipe gestures to support velocity and fling using androidx.compose.foundation.gestures. - - Improve accessibility by adding semantics for swipe actions and content descriptions. - - Tweak animation timings and easings to match your app design system. -*/ - - -open class StackableSnackbarBehavior : AnimationBehavior() { - override suspend fun onShowAnimation() { - animationVariables.offsetX = Animatable(100f) - coroutineScope { - launch { // move the entire stack up, pass offset Y in each card - animationVariables.offsetY = Animatable(0f) - animationVariables.offsetY.animateTo( - targetValue = -100f, - animationSpec = tween( - easing = LinearEasing, - durationMillis = 200, - ) - ) - } - launch { - animationVariables.alpha.animateTo( - targetValue = 1F, - animationSpec = tween( - easing = LinearEasing, - durationMillis = 200, - ) - ) - } - -// launch { -// animationVariables.offsetX.animateTo( -// targetValue = 0f, -// animationSpec = tween( -// easing = LinearEasing, -// durationMillis = 200, -// ) -// ) -// } - } - } - - override suspend fun onDismissAnimation() { - animationVariables.offsetX.animateTo( - 0f, - animationSpec = tween( - easing = LinearEasing, - durationMillis = 150, - ) - ) - animationVariables.alpha.animateTo( - 0F, - animationSpec = tween( - easing = LinearEasing, - durationMillis = 150, - ) - ) - } -} + Spacer(modifier = Modifier.width(12.dp)) -@Composable -fun Modifier.swipeToDismissFromStack( - animationVariables: AnimationVariables, - scope: CoroutineScope, - onDismiss: () -> Unit, -): Modifier { - val configuration = LocalConfiguration.current - val dismissThreshold = - dpToPx(configuration.screenWidthDp.dp) * 0.33f // One-third of screen width - return this.pointerInput(Unit) { - detectHorizontalDragGestures( - onDragEnd = { - if (animationVariables.offsetX.value < -dismissThreshold) { - scope.launch { - onDismiss() - } - } else { - scope.launch { - animationVariables.offsetX.animateTo( - 0f, - animationSpec = tween(300) - ) - } - } - }, - onHorizontalDrag = { _, dragAmount -> + Button(onClick = { scope.launch { - animationVariables.offsetX.snapTo(animationVariables.offsetX.value + dragAmount) + stackState.popBack() } - } - ) - } -} - -@Composable -fun StackableSnackbar() { - var enableDialog by remember { mutableStateOf(false) } - Column() { - - if (!enableDialog) { - SingleSnackbarTile() - SingleSnackbarTile() - SingleSnackbarTile() - } else { - val popupProperties = PopupProperties( - focusable = true - ) - Popup( - onDismissRequest = { - enableDialog = false - } - ) { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()) - ) { - BasicCard(modifier = Modifier.padding(10.dp)) { - BasicText("This is a Stackable Snackbar") - } - BasicCard(modifier = Modifier.padding(10.dp)) { - BasicText("This is a Stackable Snackbar") - } - BasicCard(modifier = Modifier.padding(10.dp)) { - BasicText("This is a Stackable Snackbar") - } - BasicCard(modifier = Modifier.padding(10.dp)) { - BasicText("This is a Stackable Snackbar") - } - } - } - } - } -} - - -@Composable -private fun SingleSnackbarTile() { - var enableDialog by remember { mutableStateOf(false) } - var stackableSnackbarBehavior: StackableSnackbarBehavior = remember { - StackableSnackbarBehavior() - } - var animationVariables = stackableSnackbarBehavior.animationVariables - val scope = rememberCoroutineScope() - var isShown by remember { mutableStateOf(false) } - Button( - onClick = { - scope.launch { - if (isShown) { - stackableSnackbarBehavior.onShowAnimation() - } else { - stackableSnackbarBehavior.onDismissAnimation() - } - } - isShown = !isShown - } - ) - if (isShown) { - BasicCard( - modifier = Modifier - .padding(10.dp) - .graphicsLayer( - scaleX = animationVariables.scale.value, - scaleY = animationVariables.scale.value, - alpha = animationVariables.alpha.value, - translationX = animationVariables.offsetX.value, - translationY = animationVariables.offsetY.value - ) - .swipeToDismissFromStack( - animationVariables = animationVariables, - scope = scope, - onDismiss = { - isShown = false - } - ) - .clickableWithTooltip( - onClick = { - // enableDialog = true - - }, - tooltipText = "Snackbar clicked", - tooltipEnabled = true - ) - ) { - BasicText("Click here to show Stackable Snackbar") + }, text = "Remove last card") } - Spacer(modifier = Modifier.height(10.dp)) } } \ No newline at end of file From c3d5c434a3bf10d1be0e2b972f9696a5c4d6cfa0 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Fri, 8 Aug 2025 21:39:33 +0530 Subject: [PATCH 06/22] Added expanded view --- .../fluentuidemo/demos/V2SnackbarActivity.kt | 1 - .../notification/StackableSnackbar.kt | 166 ++++++++++++------ 2 files changed, 108 insertions(+), 59 deletions(-) diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt index 56e0ce5b9..65a4e6c43 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt @@ -36,7 +36,6 @@ import com.microsoft.fluentui.tokenized.notification.NotificationDuration import com.microsoft.fluentui.tokenized.notification.NotificationResult import com.microsoft.fluentui.tokenized.notification.Snackbar import com.microsoft.fluentui.tokenized.notification.SnackbarState -import com.microsoft.fluentui.tokenized.notification.StackableSnackbar import com.microsoft.fluentui.tokenized.segmentedcontrols.PillBar import com.microsoft.fluentui.tokenized.segmentedcontrols.PillMetaData import com.microsoft.fluentuidemo.R diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index d989dfc19..0620bdc42 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -1,5 +1,8 @@ package com.microsoft.fluentui.tokenized.notification +import android.os.Build +import android.view.Gravity +import android.view.WindowManager import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween @@ -47,14 +50,20 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import com.microsoft.fluentui.theme.token.controlTokens.AcrylicPaneOrientation import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.util.UUID import kotlin.math.abs import kotlin.math.roundToInt @@ -70,11 +79,11 @@ class CardStackState( internal val cards: MutableList ) { internal val snapshotStateList = mutableStateListOf().apply { addAll(cards) } + internal var expanded by mutableStateOf(false) - suspend fun addCard(card: CardModel, maxSize: Int = 5) { - // add to front so index 0 is top + suspend fun addCard(card: CardModel, maxSize: Int = 6) { snapshotStateList.add(0, card) - if(snapshotStateList.size >= maxSize) { + if (snapshotStateList.size >= maxSize) { popBack() } } @@ -83,14 +92,18 @@ class CardStackState( snapshotStateList.removeAll { it.id == id } } - fun popFront(): CardModel? { + suspend fun popFront(): CardModel? { + if (snapshotStateList.isEmpty()) return null + snapshotStateList.get(0).inRemoval = true + delay(360) return if (snapshotStateList.isNotEmpty()) snapshotStateList.removeAt(0) else null } suspend fun popBack(): CardModel? { - snapshotStateList.get(snapshotStateList.size -1).inRemoval = true + if (snapshotStateList.isEmpty()) return null + snapshotStateList.get(snapshotStateList.size - 1).inRemoval = true delay(360) - return if (snapshotStateList.isNotEmpty()) snapshotStateList.removeAt(snapshotStateList.size - 1) else null + return snapshotStateList.removeAt(snapshotStateList.size - 1) } fun size(): Int = snapshotStateList.size @@ -120,10 +133,17 @@ fun CardStack( contentModifier: Modifier = Modifier ) { // Total stack height: cardHeight + (count-1) * peekHeight + // Total in expanded state: cardHeight * count + (count-1) * peekHeight val count by remember { derivedStateOf { state.size() } } val targetHeight by remember(count, cardHeight, peekHeight) { - mutableStateOf(cardHeight + (if (count > 0) (count - 1) * peekHeight else 0.dp)) + mutableStateOf( + if (state.expanded) { + cardHeight * count + (if (count > 0) (count - 1) * peekHeight else 0.dp) + } else { + cardHeight + (if (count > 0) (count - 1) * peekHeight else 0.dp) + } + ) } // Smoothly animate stack height when count changes @@ -132,43 +152,66 @@ fun CardStack( animationSpec = spring(stiffness = Spring.StiffnessMedium) ) - Box( - modifier = modifier - .width(cardWidth) - .height(animatedStackHeight) - .wrapContentHeight( - align = if (stackAbove) { - Alignment.Bottom - } else { - Alignment.Top - } - ) - .clickableWithTooltip( - onClick = {}, - tooltipText = "Notification Stack", - ) + Dialog( + onDismissRequest = {}, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false + ) ) { - // Show cards in reverse visual order: bottom-most drawn first - val listSnapshot = state.snapshotStateList.toList() + val window = (LocalView.current.parent as? DialogWindowProvider)?.window + SideEffect { + if (window != null) { + window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL) + window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + window.setDimAmount(0f) + window.setGravity(Gravity.BOTTOM) + // window.attributes.height = 0 + // window.attributes.width = 0 + window.attributes.y = 200 + } + } + Box( + modifier = modifier + .width(cardWidth) + .height(if (state.snapshotStateList.size == 0) 0.dp else animatedStackHeight) + .wrapContentHeight( + align = if (stackAbove) { + Alignment.Bottom + } else { + Alignment.Top + } + ) + .clickableWithTooltip( + onClick = { + state.expanded = !state.expanded + }, + tooltipText = "Notification Stack", + ) + ) { + // Show cards in reverse visual order: bottom-most drawn first + val listSnapshot = state.snapshotStateList.toList() - listSnapshot.reversed().forEachIndexed { visuallyReversedIndex, cardModel -> - // compute logical index from top (0 is top) - val logicalIndex = listSnapshot.size - 1 - visuallyReversedIndex - val isTop = logicalIndex == 0 + listSnapshot.reversed().forEachIndexed { visuallyReversedIndex, cardModel -> + // compute logical index from top (0 is top) + val logicalIndex = listSnapshot.size - 1 - visuallyReversedIndex + val isTop = logicalIndex == 0 - key(cardModel.id) { - // Each card will be placed offset from top by logicalIndex * peekHeight - CardStackItem( - model = cardModel, - index = logicalIndex, - isTop = isTop, - cardHeight = cardHeight, - peekHeight = peekHeight, - cardWidth = cardWidth, - onSwipedAway = { idToRemove -> state.removeCardById(idToRemove) }, - stackAbove = stackAbove, - contentModifier = contentModifier - ) + key(cardModel.id) { + // Each card will be placed offset from top by logicalIndex * peekHeight + CardStackItem( + model = cardModel, + expanded = state.expanded, + index = logicalIndex, + isTop = isTop, + cardHeight = cardHeight, + peekHeight = peekHeight, + cardWidth = cardWidth, + onSwipedAway = { idToRemove -> state.removeCardById(idToRemove) }, + stackAbove = stackAbove, + contentModifier = contentModifier + ) + } } } } @@ -177,6 +220,7 @@ fun CardStack( @Composable private fun CardStackItem( model: CardModel, + expanded: Boolean, index: Int, isTop: Boolean, cardWidth: Dp, @@ -189,11 +233,11 @@ private fun CardStackItem( val scope = rememberCoroutineScope() // Card Adjust Animation - val targetYOffset = with(LocalDensity.current) { (index * peekHeight).toPx() } - val animatedYOffset = remember { Animatable(targetYOffset) } + val targetYOffset = mutableStateOf( with(LocalDensity.current) { if (expanded) (index * ( peekHeight + cardHeight) ).toPx() else (index * peekHeight).toPx() }) + val animatedYOffset = remember { Animatable(targetYOffset.value) } LaunchedEffect(index) { animatedYOffset.animateTo( - targetYOffset * (if (stackAbove) -1f else 1f), + targetYOffset.value * (if (stackAbove) -1f else 1f), animationSpec = spring(stiffness = Spring.StiffnessLow) ) } @@ -216,16 +260,16 @@ private fun CardStackItem( // Fade Out Animation TODO: Add configurations val opacityProgress = remember { Animatable(1f) } LaunchedEffect(model.inRemoval) { - if(model.inRemoval) { - if (!isTop) { - opacityProgress.snapTo(1f) - opacityProgress.animateTo( - 0f, - animationSpec = tween(durationMillis = 350, easing = FastOutSlowInEasing) - ) - } else { - slideInProgress.snapTo(1f) - } + if (model.inRemoval) { +// if (!isTop) { + opacityProgress.snapTo(1f) + opacityProgress.animateTo( + 0f, + animationSpec = tween(durationMillis = 350, easing = FastOutSlowInEasing) + ) +// } else { +// slideInProgress.snapTo(1f) +// } } } @@ -316,12 +360,16 @@ private fun CardStackItem( fun DemoCardStack() { val stackState = rememberCardStackState() val scope = rememberCoroutineScope() - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Bottom + ) { CardStack( state = stackState, modifier = Modifier.padding(16.dp), cardWidth = 340.dp, - cardHeight = 180.dp, + cardHeight = 100.dp, peekHeight = 10.dp, stackAbove = true ) @@ -330,7 +378,7 @@ fun DemoCardStack() { Row { Button(onClick = { - val id = java.util.UUID.randomUUID().toString() + val id = UUID.randomUUID().toString() scope.launch { stackState.addCard(CardModel(id = id) { Column(modifier = Modifier.padding(12.dp)) { @@ -344,7 +392,9 @@ fun DemoCardStack() { Spacer(modifier = Modifier.width(12.dp)) Button(onClick = { - stackState.popFront() + scope.launch { + stackState.popFront() + } }, text = "Remove top card") Spacer(modifier = Modifier.width(12.dp)) From 2c1a22046ecdfa8965fc25a63e7ecf12d663cbe4 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Sat, 9 Aug 2025 01:04:29 +0530 Subject: [PATCH 07/22] Added smooth state transitions --- .../notification/StackableSnackbar.kt | 23 ++----------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index 0620bdc42..70c4a88d9 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -1,21 +1,15 @@ package com.microsoft.fluentui.tokenized.notification -import android.os.Build import android.view.Gravity import android.view.WindowManager import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -25,16 +19,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import com.microsoft.fluentui.tokenized.controls.BasicCard import com.microsoft.fluentui.tokenized.controls.Button import com.microsoft.fluentui.util.clickableWithTooltip -import com.microsoft.fluentui.util.dpToPx -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import androidx.compose.animation.core.* import androidx.compose.foundation.background @@ -46,23 +33,17 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider -import com.microsoft.fluentui.theme.token.controlTokens.AcrylicPaneOrientation import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import java.util.UUID import kotlin.math.abs import kotlin.math.roundToInt @@ -136,7 +117,7 @@ fun CardStack( // Total in expanded state: cardHeight * count + (count-1) * peekHeight val count by remember { derivedStateOf { state.size() } } - val targetHeight by remember(count, cardHeight, peekHeight) { + val targetHeight by remember(count, cardHeight, peekHeight, state.expanded) { mutableStateOf( if (state.expanded) { cardHeight * count + (if (count > 0) (count - 1) * peekHeight else 0.dp) @@ -235,7 +216,7 @@ private fun CardStackItem( // Card Adjust Animation val targetYOffset = mutableStateOf( with(LocalDensity.current) { if (expanded) (index * ( peekHeight + cardHeight) ).toPx() else (index * peekHeight).toPx() }) val animatedYOffset = remember { Animatable(targetYOffset.value) } - LaunchedEffect(index) { + LaunchedEffect(index, expanded) { animatedYOffset.animateTo( targetYOffset.value * (if (stackAbove) -1f else 1f), animationSpec = spring(stiffness = Spring.StiffnessLow) From 59d14cc5202c30a46c4f947d58d0a41d05ca7fcf Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Sat, 9 Aug 2025 01:46:38 +0530 Subject: [PATCH 08/22] Smooth resize --- .../notification/StackableSnackbar.kt | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index 70c4a88d9..5a829563c 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -33,6 +33,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView @@ -110,6 +111,7 @@ fun CardStack( cardWidth: Dp = 320.dp, cardHeight: Dp = 160.dp, peekHeight: Dp = 10.dp, + stackOffset: Offset = Offset(0f, 0f), // offset for the stack position stackAbove: Boolean = true, // if true, cards stack above each other (negative offset) contentModifier: Modifier = Modifier ) { @@ -119,7 +121,7 @@ fun CardStack( val targetHeight by remember(count, cardHeight, peekHeight, state.expanded) { mutableStateOf( - if (state.expanded) { + if (state.expanded || true) { cardHeight * count + (if (count > 0) (count - 1) * peekHeight else 0.dp) } else { cardHeight + (if (count > 0) (count - 1) * peekHeight else 0.dp) @@ -147,8 +149,8 @@ fun CardStack( window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) window.setDimAmount(0f) window.setGravity(Gravity.BOTTOM) - // window.attributes.height = 0 - // window.attributes.width = 0 + window.attributes.y = stackOffset.y.roundToInt() + window.attributes.x = stackOffset.x.roundToInt() window.attributes.y = 200 } } @@ -214,7 +216,8 @@ private fun CardStackItem( val scope = rememberCoroutineScope() // Card Adjust Animation - val targetYOffset = mutableStateOf( with(LocalDensity.current) { if (expanded) (index * ( peekHeight + cardHeight) ).toPx() else (index * peekHeight).toPx() }) + val targetYOffset = + mutableStateOf(with(LocalDensity.current) { if (expanded) (index * (peekHeight + cardHeight)).toPx() else (index * peekHeight).toPx() }) val animatedYOffset = remember { Animatable(targetYOffset.value) } LaunchedEffect(index, expanded) { animatedYOffset.animateTo( @@ -226,7 +229,6 @@ private fun CardStackItem( // Slide In Animation TODO: Add configurations val slideInProgress = remember { Animatable(1f) } // 1 = offscreen right, 0 = in place LaunchedEffect(model.id) { - // if this is top when added, slide from right if (isTop) { slideInProgress.snapTo(1f) slideInProgress.animateTo( @@ -242,22 +244,17 @@ private fun CardStackItem( val opacityProgress = remember { Animatable(1f) } LaunchedEffect(model.inRemoval) { if (model.inRemoval) { -// if (!isTop) { opacityProgress.snapTo(1f) opacityProgress.animateTo( 0f, animationSpec = tween(durationMillis = 350, easing = FastOutSlowInEasing) ) -// } else { -// slideInProgress.snapTo(1f) -// } } } val swipeX = remember { Animatable(0f) } - val removalJob = remember { mutableStateOf(null) } - val offsetX: Float = if (isTop) swipeX.value else 0f + val offsetX: Float = if (isTop || expanded) swipeX.value else 0f val localDensity = LocalDensity.current Box( modifier = Modifier @@ -273,7 +270,7 @@ private fun CardStackItem( .width(cardWidth) .height(cardHeight) .padding(horizontal = 0.dp) - .then(if (isTop) Modifier.pointerInput(model.id) { + .then(if (isTop || expanded) Modifier.pointerInput(model.id) { detectDragGestures( onDragStart = { /* no-op */ }, onDragEnd = { @@ -325,7 +322,10 @@ private fun CardStackItem( Box( modifier = Modifier .fillMaxSize() - .shadow(elevation = if (isTop) 12.dp else 4.dp, shape = RoundedCornerShape(12.dp)) + .shadow( + elevation = if (isTop || expanded) 12.dp else 4.dp, + shape = RoundedCornerShape(12.dp) + ) .border(width = 1.dp, color = Color(0x22000000), shape = RoundedCornerShape(12.dp)) .background(color = Color.LightGray, shape = RoundedCornerShape(12.dp)) .then(contentModifier), From edae3f085f9cee9183fb0373353888d666abafcd Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Sat, 9 Aug 2025 09:28:16 +0530 Subject: [PATCH 09/22] Removing Dialog to Fix Animation Jank --- .../notification/StackableSnackbar.kt | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index 5a829563c..11ae15c0a 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -135,25 +135,25 @@ fun CardStack( animationSpec = spring(stiffness = Spring.StiffnessMedium) ) - Dialog( - onDismissRequest = {}, - properties = DialogProperties( - dismissOnBackPress = false, - dismissOnClickOutside = false - ) - ) { - val window = (LocalView.current.parent as? DialogWindowProvider)?.window - SideEffect { - if (window != null) { - window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL) - window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) - window.setDimAmount(0f) - window.setGravity(Gravity.BOTTOM) - window.attributes.y = stackOffset.y.roundToInt() - window.attributes.x = stackOffset.x.roundToInt() - window.attributes.y = 200 - } - } +// Dialog( +// onDismissRequest = {}, +// properties = DialogProperties( +// dismissOnBackPress = false, +// dismissOnClickOutside = false +// ) +// ) { +// val window = (LocalView.current.parent as? DialogWindowProvider)?.window +// SideEffect { +// if (window != null) { +// window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL) +// window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) +// if(state.expanded) window.setDimAmount(0.2f) else window.setDimAmount(0f) +// window.setGravity(Gravity.BOTTOM) +// window.attributes.y = stackOffset.y.roundToInt() +// window.attributes.x = stackOffset.x.roundToInt() +// window.attributes.y = 200 +// } +// } Box( modifier = modifier .width(cardWidth) @@ -197,7 +197,7 @@ fun CardStack( } } } - } + //} } @Composable From b3debba3c8ad5511b1ec652097ed99b3fc0668fb Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Sat, 9 Aug 2025 09:37:44 +0530 Subject: [PATCH 10/22] Last known good --- .../fluentui/tokenized/notification/StackableSnackbar.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index 11ae15c0a..34c07c69a 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -121,7 +121,7 @@ fun CardStack( val targetHeight by remember(count, cardHeight, peekHeight, state.expanded) { mutableStateOf( - if (state.expanded || true) { + if (state.expanded) { cardHeight * count + (if (count > 0) (count - 1) * peekHeight else 0.dp) } else { cardHeight + (if (count > 0) (count - 1) * peekHeight else 0.dp) @@ -134,6 +134,7 @@ fun CardStack( targetValue = targetHeight, animationSpec = spring(stiffness = Spring.StiffnessMedium) ) + // var animatedStackHeight = targetHeight // Dialog( // onDismissRequest = {}, From 1cdd1feea4ed4f02baeca28c25c929b52da0032e Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Sat, 9 Aug 2025 17:35:44 +0530 Subject: [PATCH 11/22] Added Width Scale --- .../tokenized/notification/StackableSnackbar.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index 34c07c69a..837906efb 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -47,6 +47,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import java.util.UUID import kotlin.math.abs +import kotlin.math.pow import kotlin.math.roundToInt /** Single card model contains an id and a composable content lambda. */ @@ -210,6 +211,7 @@ private fun CardStackItem( cardWidth: Dp, cardHeight: Dp, peekHeight: Dp, + stackedWidthScaleFactor: Float = 0.95f, onSwipedAway: (String) -> Unit, stackAbove: Boolean = false, contentModifier: Modifier = Modifier @@ -227,6 +229,16 @@ private fun CardStackItem( ) } + // Card Width Animation + val targetWidth = mutableStateOf(with(LocalDensity.current) { if(expanded) { cardWidth.toPx() } else { cardWidth.toPx() * stackedWidthScaleFactor.pow(index) } }) + val animatedWidth = remember { Animatable(targetWidth.value) } + LaunchedEffect(index, expanded) { + animatedWidth.animateTo( + targetWidth.value, + animationSpec = spring(stiffness = Spring.StiffnessLow) + ) + } + // Slide In Animation TODO: Add configurations val slideInProgress = remember { Animatable(1f) } // 1 = offscreen right, 0 = in place LaunchedEffect(model.id) { @@ -266,7 +278,8 @@ private fun CardStackItem( ) } .graphicsLayer( - alpha = opacityProgress.value + alpha = opacityProgress.value, + scaleX = animatedWidth.value / with(localDensity) { cardWidth.toPx() }, ) .width(cardWidth) .height(cardHeight) From 3eac5245141c1d0bf951817dd2547a21241cb0fe Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Mon, 11 Aug 2025 19:06:04 +0530 Subject: [PATCH 12/22] Fade Out Animation Responsive, added default scope within state --- .../notification/StackableSnackbar.kt | 149 ++++++++++-------- 1 file changed, 85 insertions(+), 64 deletions(-) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index 837906efb..91390471a 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -43,17 +43,23 @@ import androidx.compose.ui.unit.times import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import java.util.UUID import kotlin.math.abs import kotlin.math.pow import kotlin.math.roundToInt +// Constants +private const val FADE_OUT_DURATION = 350 // milliseconds + /** Single card model contains an id and a composable content lambda. */ data class CardModel( val id: String, - var inRemoval: Boolean = false, + var inRemoval: MutableState = mutableStateOf(false), val content: @Composable () -> Unit ) @@ -63,11 +69,14 @@ class CardStackState( ) { internal val snapshotStateList = mutableStateListOf().apply { addAll(cards) } internal var expanded by mutableStateOf(false) + internal var scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - suspend fun addCard(card: CardModel, maxSize: Int = 6) { + fun addCard(card: CardModel, maxSize: Int = 6) { snapshotStateList.add(0, card) if (snapshotStateList.size >= maxSize) { - popBack() + scope.launch { + popBack() + } } } @@ -75,18 +84,26 @@ class CardStackState( snapshotStateList.removeAll { it.id == id } } - suspend fun popFront(): CardModel? { - if (snapshotStateList.isEmpty()) return null - snapshotStateList.get(0).inRemoval = true - delay(360) - return if (snapshotStateList.isNotEmpty()) snapshotStateList.removeAt(0) else null + fun popFront(): CardModel? { + if (snapshotStateList.isEmpty() || snapshotStateList[0].inRemoval.value) return null + val poppedCardModel: CardModel = snapshotStateList[0] + scope.launch { + snapshotStateList[0].inRemoval.value = true + delay(FADE_OUT_DURATION.toLong()) + if (snapshotStateList.isNotEmpty()) snapshotStateList.removeAt(0) + } + return poppedCardModel } - suspend fun popBack(): CardModel? { - if (snapshotStateList.isEmpty()) return null - snapshotStateList.get(snapshotStateList.size - 1).inRemoval = true - delay(360) - return snapshotStateList.removeAt(snapshotStateList.size - 1) + fun popBack(): CardModel? { + if (snapshotStateList.isEmpty() || snapshotStateList[0].inRemoval.value) return null + val poppedCardModel: CardModel = snapshotStateList[snapshotStateList.size - 1] + scope.launch { + snapshotStateList[snapshotStateList.size - 1].inRemoval.value = true + delay(FADE_OUT_DURATION.toLong()) + if (snapshotStateList.isNotEmpty()) snapshotStateList.removeAt(snapshotStateList.size - 1) + } + return poppedCardModel } fun size(): Int = snapshotStateList.size @@ -135,7 +152,7 @@ fun CardStack( targetValue = targetHeight, animationSpec = spring(stiffness = Spring.StiffnessMedium) ) - // var animatedStackHeight = targetHeight + // var animatedStackHeight = targetHeight // Dialog( // onDismissRequest = {}, @@ -156,55 +173,57 @@ fun CardStack( // window.attributes.y = 200 // } // } - Box( - modifier = modifier - .width(cardWidth) - .height(if (state.snapshotStateList.size == 0) 0.dp else animatedStackHeight) - .wrapContentHeight( - align = if (stackAbove) { - Alignment.Bottom - } else { - Alignment.Top - } - ) - .clickableWithTooltip( - onClick = { - state.expanded = !state.expanded - }, - tooltipText = "Notification Stack", - ) - ) { - // Show cards in reverse visual order: bottom-most drawn first - val listSnapshot = state.snapshotStateList.toList() + Box( + modifier = modifier + .width(cardWidth) + .height(if (state.snapshotStateList.size == 0) 0.dp else animatedStackHeight) + .wrapContentHeight( + align = if (stackAbove) { + Alignment.Bottom + } else { + Alignment.Top + } + ) + .clickableWithTooltip( + onClick = { + state.expanded = !state.expanded + }, + tooltipText = "Notification Stack", + ) + ) { + // Show cards in reverse visual order: bottom-most drawn first + val listSnapshot = state.snapshotStateList.toList() - listSnapshot.reversed().forEachIndexed { visuallyReversedIndex, cardModel -> - // compute logical index from top (0 is top) - val logicalIndex = listSnapshot.size - 1 - visuallyReversedIndex - val isTop = logicalIndex == 0 + listSnapshot.reversed().forEachIndexed { visuallyReversedIndex, cardModel -> + // compute logical index from top (0 is top) + val logicalIndex = listSnapshot.size - 1 - visuallyReversedIndex + val isTop = logicalIndex == 0 - key(cardModel.id) { - // Each card will be placed offset from top by logicalIndex * peekHeight - CardStackItem( - model = cardModel, - expanded = state.expanded, - index = logicalIndex, - isTop = isTop, - cardHeight = cardHeight, - peekHeight = peekHeight, - cardWidth = cardWidth, - onSwipedAway = { idToRemove -> state.removeCardById(idToRemove) }, - stackAbove = stackAbove, - contentModifier = contentModifier - ) - } + key(cardModel.id) { + // Each card will be placed offset from top by logicalIndex * peekHeight + CardStackItem( + model = cardModel, + inRemoval = cardModel.inRemoval.value, + expanded = state.expanded, + index = logicalIndex, + isTop = isTop, + cardHeight = cardHeight, + peekHeight = peekHeight, + cardWidth = cardWidth, + onSwipedAway = { idToRemove -> state.removeCardById(idToRemove) }, + stackAbove = stackAbove, + contentModifier = contentModifier + ) } } + } //} } @Composable private fun CardStackItem( model: CardModel, + inRemoval: Boolean, expanded: Boolean, index: Int, isTop: Boolean, @@ -230,7 +249,13 @@ private fun CardStackItem( } // Card Width Animation - val targetWidth = mutableStateOf(with(LocalDensity.current) { if(expanded) { cardWidth.toPx() } else { cardWidth.toPx() * stackedWidthScaleFactor.pow(index) } }) + val targetWidth = mutableStateOf(with(LocalDensity.current) { + if (expanded) { + cardWidth.toPx() + } else { + cardWidth.toPx() * stackedWidthScaleFactor.pow(index) + } + }) val animatedWidth = remember { Animatable(targetWidth.value) } LaunchedEffect(index, expanded) { animatedWidth.animateTo( @@ -255,12 +280,12 @@ private fun CardStackItem( // Fade Out Animation TODO: Add configurations val opacityProgress = remember { Animatable(1f) } - LaunchedEffect(model.inRemoval) { - if (model.inRemoval) { - opacityProgress.snapTo(1f) + LaunchedEffect(inRemoval) { + if (inRemoval) { + //opacityProgress.snapTo(1f) opacityProgress.animateTo( 0f, - animationSpec = tween(durationMillis = 350, easing = FastOutSlowInEasing) + animationSpec = tween(durationMillis = FADE_OUT_DURATION, easing = FastOutSlowInEasing) ) } } @@ -387,16 +412,12 @@ fun DemoCardStack() { Spacer(modifier = Modifier.width(12.dp)) Button(onClick = { - scope.launch { - stackState.popFront() - } + stackState.popFront() }, text = "Remove top card") Spacer(modifier = Modifier.width(12.dp)) Button(onClick = { - scope.launch { - stackState.popBack() - } + stackState.popBack() }, text = "Remove last card") } } From 77d62608058a36db7d156f534e77de6aa1ac0574 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Tue, 12 Aug 2025 11:32:28 +0530 Subject: [PATCH 13/22] Added thread safety --- .../notification/StackableSnackbar.kt | 141 ++++++++++++++---- 1 file changed, 112 insertions(+), 29 deletions(-) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index 91390471a..12c4afba9 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -50,32 +50,41 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import java.util.UUID import kotlin.math.abs +import kotlin.math.max import kotlin.math.pow import kotlin.math.roundToInt // Constants private const val FADE_OUT_DURATION = 350 // milliseconds +private const val STACKED_WIDTH_SCALE_FACTOR = 0.95f // Scale factor for stacked cards /** Single card model contains an id and a composable content lambda. */ data class CardModel( val id: String, var inRemoval: MutableState = mutableStateOf(false), + var isHidden: MutableState = mutableStateOf(false), val content: @Composable () -> Unit ) /** Public state object to control the stack. */ class CardStackState( - internal val cards: MutableList + internal val cards: MutableList, + internal val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + internal val maxCollapsedSize: Int = 5, + internal val maxExpandedSize: Int = 10 ) { internal val snapshotStateList = mutableStateListOf().apply { addAll(cards) } internal var expanded by mutableStateOf(false) - internal var scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + internal val maxSize = + max(maxCollapsedSize, maxExpandedSize) // All cards above this will be deleted - fun addCard(card: CardModel, maxSize: Int = 6) { - snapshotStateList.add(0, card) - if (snapshotStateList.size >= maxSize) { - scope.launch { + fun addCard(card: CardModel) { + snapshotStateList.add(card) + val maxSize = if (expanded) maxExpandedSize else maxCollapsedSize + scope.launch { + if (snapshotStateList.count { !it.inRemoval.value } > maxSize) { popBack() + //delay(10) // TODO: Check without delay } } } @@ -84,26 +93,97 @@ class CardStackState( snapshotStateList.removeAll { it.id == id } } - fun popFront(): CardModel? { - if (snapshotStateList.isEmpty() || snapshotStateList[0].inRemoval.value) return null - val poppedCardModel: CardModel = snapshotStateList[0] + fun expand() { + if (!expanded) { + expanded = true + val maxSize = maxExpandedSize + + scope.launch { + while (snapshotStateList.count { !it.inRemoval.value } > maxSize) { + popBack() + delay(10) // prevent tight loop + } + } + } + } + + fun collapse() { + if (expanded) { + expanded = false + val maxSize = maxCollapsedSize + + scope.launch { + while (snapshotStateList.count { !it.inRemoval.value } > maxSize) { + popBack() + delay(10) // prevent tight loop + } + } + } + } + + fun toggleExpanded() { + if (expanded) { + collapse() + } else { + expand() + } + } + + fun popAt(index: Int): CardModel? { + if (snapshotStateList.isEmpty() || index !in snapshotStateList.indices) return null + val poppedCardModel: CardModel = snapshotStateList[index] + if (poppedCardModel.inRemoval.value) return null scope.launch { - snapshotStateList[0].inRemoval.value = true + snapshotStateList[index].inRemoval.value = true delay(FADE_OUT_DURATION.toLong()) - if (snapshotStateList.isNotEmpty()) snapshotStateList.removeAt(0) + val currentIndex = snapshotStateList.indexOfFirst { it.id == poppedCardModel.id } + if (currentIndex != -1) { + snapshotStateList.removeAt(currentIndex) + } } return poppedCardModel } fun popBack(): CardModel? { - if (snapshotStateList.isEmpty() || snapshotStateList[0].inRemoval.value) return null - val poppedCardModel: CardModel = snapshotStateList[snapshotStateList.size - 1] + val index = snapshotStateList.indexOfFirst { !it.inRemoval.value } + return if (index != -1) popAt(index) else null + } + + fun popFront(): CardModel? { + val index = snapshotStateList.indexOfLast { !it.inRemoval.value } + return if (index != -1) popAt(index) else null + } + + /** + * Hides the card at the specified index. + * @param index index of the card to hide + * @return the hidden card or null if index is invalid + */ + fun hideAt(index: Int): CardModel? { + if (snapshotStateList.isEmpty() || index !in snapshotStateList.indices) return null + val card = snapshotStateList[index] + if (card.isHidden.value) return null scope.launch { - snapshotStateList[snapshotStateList.size - 1].inRemoval.value = true + card.inRemoval.value = true // reuse the same animation trigger state as pop delay(FADE_OUT_DURATION.toLong()) - if (snapshotStateList.isNotEmpty()) snapshotStateList.removeAt(snapshotStateList.size - 1) + card.isHidden.value = true + card.inRemoval.value = false // reset animation flag } - return poppedCardModel + return card + } + + fun hideFront(): CardModel? { + val index = snapshotStateList.indexOfFirst { !it.isHidden.value && !it.inRemoval.value } + return if (index != -1) hideAt(index) else null + } + + fun hideBack(): CardModel? { + val index = snapshotStateList.indexOfLast { !it.isHidden.value && !it.inRemoval.value } + return if (index != -1) hideAt(index) else null + } + + fun unhideAll() { + snapshotStateList.forEach { it.isHidden.value = false } } fun size(): Int = snapshotStateList.size @@ -192,11 +272,14 @@ fun CardStack( ) ) { // Show cards in reverse visual order: bottom-most drawn first - val listSnapshot = state.snapshotStateList.toList() + // val listSnapshot = state.snapshotStateList.toList() + val visibleCards = state.snapshotStateList + .filter { !it.isHidden.value } + .toList() // to avoid concurrent modification issues - listSnapshot.reversed().forEachIndexed { visuallyReversedIndex, cardModel -> + visibleCards.forEachIndexed { index, cardModel -> // compute logical index from top (0 is top) - val logicalIndex = listSnapshot.size - 1 - visuallyReversedIndex + val logicalIndex = visibleCards.size - 1 - index val isTop = logicalIndex == 0 key(cardModel.id) { @@ -285,7 +368,10 @@ private fun CardStackItem( //opacityProgress.snapTo(1f) opacityProgress.animateTo( 0f, - animationSpec = tween(durationMillis = FADE_OUT_DURATION, easing = FastOutSlowInEasing) + animationSpec = tween( + durationMillis = FADE_OUT_DURATION, + easing = FastOutSlowInEasing + ) ) } } @@ -379,7 +465,6 @@ private fun CardStackItem( @Composable fun DemoCardStack() { val stackState = rememberCardStackState() - val scope = rememberCoroutineScope() Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize(), @@ -399,14 +484,12 @@ fun DemoCardStack() { Row { Button(onClick = { val id = UUID.randomUUID().toString() - scope.launch { - stackState.addCard(CardModel(id = id) { - Column(modifier = Modifier.padding(12.dp)) { - BasicText("Card: $id") - BasicText("Some detail here") - } - }) - } + stackState.addCard(CardModel(id = id) { + Column(modifier = Modifier.padding(12.dp)) { + BasicText("Card: $id") + BasicText("Some detail here") + } + }) }, text = "Add card") Spacer(modifier = Modifier.width(12.dp)) From 5e88da03a7b5819e9c475069f00cb552c073dea8 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Sun, 17 Aug 2025 17:51:27 +0530 Subject: [PATCH 14/22] Working Hide Show Logic --- .../notification/StackableSnackbar.kt | 122 +++++++++++------- 1 file changed, 77 insertions(+), 45 deletions(-) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index 12c4afba9..d9b397a6b 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -61,8 +61,8 @@ private const val STACKED_WIDTH_SCALE_FACTOR = 0.95f // Scale factor for stacked /** Single card model contains an id and a composable content lambda. */ data class CardModel( val id: String, - var inRemoval: MutableState = mutableStateOf(false), - var isHidden: MutableState = mutableStateOf(false), + val hidden: MutableState = mutableStateOf(false), + val isReshown: MutableState = mutableStateOf(false), // used to trigger re-show animation val content: @Composable () -> Unit ) @@ -73,7 +73,9 @@ class CardStackState( internal val maxCollapsedSize: Int = 5, internal val maxExpandedSize: Int = 10 ) { - internal val snapshotStateList = mutableStateListOf().apply { addAll(cards) } + internal val snapshotStateList: MutableList = + mutableStateListOf().apply { addAll(cards) } + internal val hiddenIndicesList: MutableList> = mutableListOf() internal var expanded by mutableStateOf(false) internal val maxSize = max(maxCollapsedSize, maxExpandedSize) // All cards above this will be deleted @@ -82,7 +84,7 @@ class CardStackState( snapshotStateList.add(card) val maxSize = if (expanded) maxExpandedSize else maxCollapsedSize scope.launch { - if (snapshotStateList.count { !it.inRemoval.value } > maxSize) { + if (snapshotStateList.count { !it.hidden.value } > maxSize) { popBack() //delay(10) // TODO: Check without delay } @@ -90,7 +92,10 @@ class CardStackState( } fun removeCardById(id: String) { - snapshotStateList.removeAll { it.id == id } + val index = snapshotStateList.indexOfFirst { it.id == id } + if (index != -1) { + snapshotStateList.removeAt(index) + } } fun expand() { @@ -99,7 +104,7 @@ class CardStackState( val maxSize = maxExpandedSize scope.launch { - while (snapshotStateList.count { !it.inRemoval.value } > maxSize) { + while (snapshotStateList.count { !it.hidden.value } > maxSize) { popBack() delay(10) // prevent tight loop } @@ -113,7 +118,7 @@ class CardStackState( val maxSize = maxCollapsedSize scope.launch { - while (snapshotStateList.count { !it.inRemoval.value } > maxSize) { + while (snapshotStateList.count { !it.hidden.value } > maxSize) { popBack() delay(10) // prevent tight loop } @@ -130,60 +135,66 @@ class CardStackState( } fun popAt(index: Int): CardModel? { - if (snapshotStateList.isEmpty() || index !in snapshotStateList.indices) return null - val poppedCardModel: CardModel = snapshotStateList[index] - if (poppedCardModel.inRemoval.value) return null - scope.launch { - snapshotStateList[index].inRemoval.value = true - delay(FADE_OUT_DURATION.toLong()) - val currentIndex = snapshotStateList.indexOfFirst { it.id == poppedCardModel.id } - if (currentIndex != -1) { - snapshotStateList.removeAt(currentIndex) - } - } - return poppedCardModel + return hideAt(index, remove = false) } fun popBack(): CardModel? { - val index = snapshotStateList.indexOfFirst { !it.inRemoval.value } + val index = snapshotStateList.indexOfFirst { !it.hidden.value } return if (index != -1) popAt(index) else null } fun popFront(): CardModel? { - val index = snapshotStateList.indexOfLast { !it.inRemoval.value } + val index = snapshotStateList.indexOfLast { !it.hidden.value } return if (index != -1) popAt(index) else null } + fun showAt(index: Int): CardModel? { // REMEMBER HERE INDEX IS THE INDEX OF REMOVED ELEMENTS + if (index < 0 || index >= hiddenIndicesList.size) return null + val (hiddenIndex, card) = hiddenIndicesList[index] + if (!card.hidden.value) return null // already visible + scope.launch { + card.isReshown.value = true // trigger re-show animation + snapshotStateList.add(hiddenIndex, card) + card.hidden.value = false // reuse the same animation trigger state as pop + hiddenIndicesList.removeAt(index) + delay(FADE_OUT_DURATION.toLong()) + card.isReshown.value = false // reset re-show state after animation + } + return card + } + /** * Hides the card at the specified index. * @param index index of the card to hide * @return the hidden card or null if index is invalid */ - fun hideAt(index: Int): CardModel? { + fun hideAt(index: Int, remove: Boolean = false): CardModel? { if (snapshotStateList.isEmpty() || index !in snapshotStateList.indices) return null val card = snapshotStateList[index] - if (card.isHidden.value) return null + if (card.hidden.value) return null scope.launch { - card.inRemoval.value = true // reuse the same animation trigger state as pop + card.hidden.value = true // reuse the same animation trigger state as pop delay(FADE_OUT_DURATION.toLong()) - card.isHidden.value = true - card.inRemoval.value = false // reset animation flag + val removed = snapshotStateList.removeAt(index) + if (!remove) { + hiddenIndicesList.add(Pair(index, removed)) + } } return card } fun hideFront(): CardModel? { - val index = snapshotStateList.indexOfFirst { !it.isHidden.value && !it.inRemoval.value } + val index = snapshotStateList.indexOfFirst { !it.hidden.value && !it.hidden.value } return if (index != -1) hideAt(index) else null } fun hideBack(): CardModel? { - val index = snapshotStateList.indexOfLast { !it.isHidden.value && !it.inRemoval.value } + val index = snapshotStateList.indexOfLast { !it.hidden.value && !it.hidden.value } return if (index != -1) hideAt(index) else null } fun unhideAll() { - snapshotStateList.forEach { it.isHidden.value = false } + snapshotStateList.forEach { it.hidden.value = false } } fun size(): Int = snapshotStateList.size @@ -272,9 +283,9 @@ fun CardStack( ) ) { // Show cards in reverse visual order: bottom-most drawn first - // val listSnapshot = state.snapshotStateList.toList() + // val listSnapshot = state.snapshotStateList.toList() val visibleCards = state.snapshotStateList - .filter { !it.isHidden.value } + //.filter { !it.hidden.value } .toList() // to avoid concurrent modification issues visibleCards.forEachIndexed { index, cardModel -> @@ -286,7 +297,8 @@ fun CardStack( // Each card will be placed offset from top by logicalIndex * peekHeight CardStackItem( model = cardModel, - inRemoval = cardModel.inRemoval.value, + isHidden = cardModel.hidden.value, + isReshown = cardModel.isReshown.value, expanded = state.expanded, index = logicalIndex, isTop = isTop, @@ -306,7 +318,8 @@ fun CardStack( @Composable private fun CardStackItem( model: CardModel, - inRemoval: Boolean, + isHidden: Boolean, + isReshown: Boolean = false, // used to trigger re-show animation expanded: Boolean, index: Int, isTop: Boolean, @@ -323,7 +336,7 @@ private fun CardStackItem( // Card Adjust Animation val targetYOffset = mutableStateOf(with(LocalDensity.current) { if (expanded) (index * (peekHeight + cardHeight)).toPx() else (index * peekHeight).toPx() }) - val animatedYOffset = remember { Animatable(targetYOffset.value) } + val animatedYOffset = remember { Animatable(if(isReshown) {targetYOffset.value * (if (stackAbove) -1f else 1f) } else targetYOffset.value) } LaunchedEffect(index, expanded) { animatedYOffset.animateTo( targetYOffset.value * (if (stackAbove) -1f else 1f), @@ -348,24 +361,29 @@ private fun CardStackItem( } // Slide In Animation TODO: Add configurations - val slideInProgress = remember { Animatable(1f) } // 1 = offscreen right, 0 = in place + val slideInProgress = + remember { Animatable(if (isReshown) 0f else 1f) } // 1 = offscreen right, 0 = in place LaunchedEffect(model.id) { - if (isTop) { - slideInProgress.snapTo(1f) - slideInProgress.animateTo( - 0f, - animationSpec = tween(durationMillis = 350, easing = FastOutSlowInEasing) - ) - } else { + if (isReshown) { slideInProgress.snapTo(0f) + } else { + if (isTop) { + slideInProgress.snapTo(1f) + slideInProgress.animateTo( + 0f, + animationSpec = tween(durationMillis = 350, easing = FastOutSlowInEasing) + ) + } else { + slideInProgress.snapTo(0f) + } } } // Fade Out Animation TODO: Add configurations val opacityProgress = remember { Animatable(1f) } - LaunchedEffect(inRemoval) { - if (inRemoval) { - //opacityProgress.snapTo(1f) + LaunchedEffect(isHidden, isReshown) { + if (isHidden) { + opacityProgress.snapTo(1f) opacityProgress.animateTo( 0f, animationSpec = tween( @@ -374,6 +392,16 @@ private fun CardStackItem( ) ) } + if (isReshown) { + opacityProgress.snapTo(0f) + opacityProgress.animateTo( + 1f, + animationSpec = tween( + durationMillis = FADE_OUT_DURATION, + easing = LinearOutSlowInEasing + ) + ) + } } val swipeX = remember { Animatable(0f) } @@ -502,6 +530,10 @@ fun DemoCardStack() { Button(onClick = { stackState.popBack() }, text = "Remove last card") + + Button(onClick = { + stackState.showAt(0) + }, text = "Show last removed card") } } } \ No newline at end of file From 525a99eef8fe7d4fd8120a1e4e813d60d2f1a68e Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Sun, 17 Aug 2025 19:17:20 +0530 Subject: [PATCH 15/22] Refactor --- .../notification/StackableSnackbar.kt | 295 +++++++++++------- 1 file changed, 178 insertions(+), 117 deletions(-) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index d9b397a6b..b171b990e 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -1,7 +1,5 @@ package com.microsoft.fluentui.tokenized.notification -import android.view.Gravity -import android.view.WindowManager import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box @@ -36,16 +34,12 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.times -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.compose.ui.window.DialogWindowProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import java.util.UUID @@ -62,15 +56,15 @@ private const val STACKED_WIDTH_SCALE_FACTOR = 0.95f // Scale factor for stacked data class CardModel( val id: String, val hidden: MutableState = mutableStateOf(false), - val isReshown: MutableState = mutableStateOf(false), // used to trigger re-show animation + val isReshown: MutableState = mutableStateOf(false), val content: @Composable () -> Unit ) /** Public state object to control the stack. */ class CardStackState( internal val cards: MutableList, - internal val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), - internal val maxCollapsedSize: Int = 5, + internal val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main), // TODO: Fix concurrency issues, investigate Dispatchers.Main, also Mutexes where needed + internal val maxCollapsedSize: Int = 3, internal val maxExpandedSize: Int = 10 ) { internal val snapshotStateList: MutableList = @@ -100,15 +94,15 @@ class CardStackState( fun expand() { if (!expanded) { - expanded = true val maxSize = maxExpandedSize - - scope.launch { - while (snapshotStateList.count { !it.hidden.value } > maxSize) { - popBack() - delay(10) // prevent tight loop - } + val currentSize = snapshotStateList.count { !it.hidden.value } + if( currentSize > maxSize) { + hideAt((0..currentSize - maxSize - 1).toList(), remove = false) + } + else { + showAt((0..maxSize - currentSize).toList()) } + expanded = true } } @@ -116,12 +110,12 @@ class CardStackState( if (expanded) { expanded = false val maxSize = maxCollapsedSize - - scope.launch { - while (snapshotStateList.count { !it.hidden.value } > maxSize) { - popBack() - delay(10) // prevent tight loop - } + val currentSize = snapshotStateList.count { !it.hidden.value } + if( currentSize > maxSize) { + hideAt((0..currentSize - maxSize - 1).toList(), remove = false) + } + else { + showAt((0..maxSize - currentSize).toList()) } } } @@ -134,63 +128,82 @@ class CardStackState( } } - fun popAt(index: Int): CardModel? { - return hideAt(index, remove = false) + fun popAt(index: Int) { + hideAt(listOf(index), remove = true) } - fun popBack(): CardModel? { + fun popBack() { val index = snapshotStateList.indexOfFirst { !it.hidden.value } - return if (index != -1) popAt(index) else null + if (index != -1) popAt(index) else null } - fun popFront(): CardModel? { + fun popFront() { val index = snapshotStateList.indexOfLast { !it.hidden.value } - return if (index != -1) popAt(index) else null + if (index != -1) popAt(index) else null } - fun showAt(index: Int): CardModel? { // REMEMBER HERE INDEX IS THE INDEX OF REMOVED ELEMENTS - if (index < 0 || index >= hiddenIndicesList.size) return null - val (hiddenIndex, card) = hiddenIndicesList[index] - if (!card.hidden.value) return null // already visible - scope.launch { - card.isReshown.value = true // trigger re-show animation - snapshotStateList.add(hiddenIndex, card) - card.hidden.value = false // reuse the same animation trigger state as pop - hiddenIndicesList.removeAt(index) - delay(FADE_OUT_DURATION.toLong()) - card.isReshown.value = false // reset re-show state after animation + /** + * Shows the card at the specified indices. + * @param indices list of indices to restore + * @return list of restored cards + */ + fun showAt(indices: List): List { + val restored = mutableListOf() + indices.sortedDescending().forEach { idx -> + if (idx in hiddenIndicesList.indices) { + val (hiddenIndex, card) = hiddenIndicesList[idx] + if (card.hidden.value) { + restored.add(card) + scope.launch { + card.isReshown.value = true + snapshotStateList.add( + hiddenIndex.coerceAtMost(snapshotStateList.size), + card + ) + card.hidden.value = false + hiddenIndicesList.removeAt(idx) + + delay(FADE_OUT_DURATION.toLong()) + card.isReshown.value = false + } + } + } } - return card + return restored } /** - * Hides the card at the specified index. - * @param index index of the card to hide - * @return the hidden card or null if index is invalid + * Hides the cards at the specified indices. + * @param indices list of indices to hide + * @param remove if true, removes the card from the stack, otherwise just hides it */ - fun hideAt(index: Int, remove: Boolean = false): CardModel? { - if (snapshotStateList.isEmpty() || index !in snapshotStateList.indices) return null - val card = snapshotStateList[index] - if (card.hidden.value) return null - scope.launch { - card.hidden.value = true // reuse the same animation trigger state as pop - delay(FADE_OUT_DURATION.toLong()) - val removed = snapshotStateList.removeAt(index) - if (!remove) { - hiddenIndicesList.add(Pair(index, removed)) + fun hideAt(indices: List, remove: Boolean = false) { + indices.forEach { idx -> + if (idx in snapshotStateList.indices) { + val card = snapshotStateList[idx] + if (!card.hidden.value) { + scope.launch { + card.hidden.value = true + delay(FADE_OUT_DURATION.toLong()) + if (remove) snapshotStateList.remove(card) + else { + hiddenIndicesList.add(idx to card) + snapshotStateList.remove(card) + } + } + } } } - return card } - fun hideFront(): CardModel? { + fun hideFront() { val index = snapshotStateList.indexOfFirst { !it.hidden.value && !it.hidden.value } - return if (index != -1) hideAt(index) else null + if (index != -1) hideAt(listOf(index)) else null } - fun hideBack(): CardModel? { + fun hideBack() { val index = snapshotStateList.indexOfLast { !it.hidden.value && !it.hidden.value } - return if (index != -1) hideAt(index) else null + if (index != -1) hideAt(listOf(index)) else null } fun unhideAll() { @@ -245,25 +258,6 @@ fun CardStack( ) // var animatedStackHeight = targetHeight -// Dialog( -// onDismissRequest = {}, -// properties = DialogProperties( -// dismissOnBackPress = false, -// dismissOnClickOutside = false -// ) -// ) { -// val window = (LocalView.current.parent as? DialogWindowProvider)?.window -// SideEffect { -// if (window != null) { -// window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL) -// window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) -// if(state.expanded) window.setDimAmount(0.2f) else window.setDimAmount(0f) -// window.setGravity(Gravity.BOTTOM) -// window.attributes.y = stackOffset.y.roundToInt() -// window.attributes.x = stackOffset.x.roundToInt() -// window.attributes.y = 200 -// } -// } Box( modifier = modifier .width(cardWidth) @@ -277,7 +271,8 @@ fun CardStack( ) .clickableWithTooltip( onClick = { - state.expanded = !state.expanded + //state.expanded = !state.expanded + state.toggleExpanded() }, tooltipText = "Notification Stack", ) @@ -312,57 +307,48 @@ fun CardStack( } } } - //} } @Composable -private fun CardStackItem( - model: CardModel, - isHidden: Boolean, - isReshown: Boolean = false, // used to trigger re-show animation +private fun CardAdjustAnimation( expanded: Boolean, + isReshown: Boolean = false, // used to trigger re-show animation index: Int, - isTop: Boolean, - cardWidth: Dp, - cardHeight: Dp, - peekHeight: Dp, - stackedWidthScaleFactor: Float = 0.95f, - onSwipedAway: (String) -> Unit, - stackAbove: Boolean = false, - contentModifier: Modifier = Modifier + stackAbove: Boolean = true, // if true, cards stack above each other (negative offset) + targetYOffset: MutableState,// target Y offset for the card + animatedYOffset: Animatable ) { - val scope = rememberCoroutineScope() - - // Card Adjust Animation - val targetYOffset = - mutableStateOf(with(LocalDensity.current) { if (expanded) (index * (peekHeight + cardHeight)).toPx() else (index * peekHeight).toPx() }) - val animatedYOffset = remember { Animatable(if(isReshown) {targetYOffset.value * (if (stackAbove) -1f else 1f) } else targetYOffset.value) } - LaunchedEffect(index, expanded) { + LaunchedEffect(index, expanded, isReshown) { animatedYOffset.animateTo( targetYOffset.value * (if (stackAbove) -1f else 1f), animationSpec = spring(stiffness = Spring.StiffnessLow) ) } +} - // Card Width Animation - val targetWidth = mutableStateOf(with(LocalDensity.current) { - if (expanded) { - cardWidth.toPx() - } else { - cardWidth.toPx() * stackedWidthScaleFactor.pow(index) - } - }) - val animatedWidth = remember { Animatable(targetWidth.value) } +@Composable +private fun CardWidthAnimation( + expanded: Boolean, + index: Int, + animatedWidth: Animatable, // default to 0f, + targetWidth: MutableState +) { LaunchedEffect(index, expanded) { animatedWidth.animateTo( targetWidth.value, animationSpec = spring(stiffness = Spring.StiffnessLow) ) } +} +@Composable +private fun SlideInAnimation( + model: CardModel, + isReshown: Boolean = false, // used to trigger re-show animation + isTop: Boolean = true, // if true, the card is the top-most in the stack + slideInProgress: Animatable // progress of the slide-in animation +) { // Slide In Animation TODO: Add configurations - val slideInProgress = - remember { Animatable(if (isReshown) 0f else 1f) } // 1 = offscreen right, 0 = in place LaunchedEffect(model.id) { if (isReshown) { slideInProgress.snapTo(0f) @@ -378,9 +364,15 @@ private fun CardStackItem( } } } +} +@Composable +private fun HideAnimation( + isHidden: Boolean = false, // if true, the card is hidden + isReshown: Boolean = false, // used to trigger re-show animation + opacityProgress: Animatable +) { // Fade Out Animation TODO: Add configurations - val opacityProgress = remember { Animatable(1f) } LaunchedEffect(isHidden, isReshown) { if (isHidden) { opacityProgress.snapTo(1f) @@ -403,11 +395,76 @@ private fun CardStackItem( ) } } +} - val swipeX = remember { Animatable(0f) } +@Composable +private fun CardStackItem( + model: CardModel, + isHidden: Boolean, + isReshown: Boolean = false, // used to trigger re-show animation + expanded: Boolean, + index: Int, + isTop: Boolean, + cardWidth: Dp, + cardHeight: Dp, + peekHeight: Dp, + stackedWidthScaleFactor: Float = 0.95f, + onSwipedAway: (String) -> Unit, + stackAbove: Boolean = false, + contentModifier: Modifier = Modifier +) { + val scope = rememberCoroutineScope() + val localDensity = LocalDensity.current + val targetYOffset = mutableStateOf(with(localDensity) { if (expanded) (index * (peekHeight + cardHeight)).toPx() else (index * peekHeight).toPx() }) + val animatedYOffset = remember { + Animatable( + if (isReshown && !expanded) { + targetYOffset.value * (if (stackAbove) -1f else 1f) + } else targetYOffset.value + ) + } + CardAdjustAnimation( + expanded = expanded, + isReshown = isReshown, + index = index, + targetYOffset = targetYOffset, + animatedYOffset = animatedYOffset + ) + + val targetWidth = mutableStateOf(with(localDensity) { + if (expanded) { + cardWidth.toPx() + } else { + cardWidth.toPx() * stackedWidthScaleFactor.pow(index) + } + }) + val animatedWidth = remember { Animatable(targetWidth.value) } + CardWidthAnimation( + expanded = expanded, + index = index, + animatedWidth = animatedWidth, + targetWidth = targetWidth + ) + + val slideInProgress = + remember { Animatable(if (isReshown) 0f else 1f) } // 1 = offscreen right, 0 = in place + SlideInAnimation( + model = model, + isReshown = isReshown, + isTop = isTop, + slideInProgress = slideInProgress + ) + + val opacityProgress = remember { Animatable(1f) } + HideAnimation( + isHidden = isHidden, + isReshown = isReshown, + opacityProgress = opacityProgress + ) + + val swipeX = remember { Animatable(0f) } val offsetX: Float = if (isTop || expanded) swipeX.value else 0f - val localDensity = LocalDensity.current Box( modifier = Modifier .offset { @@ -526,14 +583,18 @@ fun DemoCardStack() { stackState.popFront() }, text = "Remove top card") Spacer(modifier = Modifier.width(12.dp)) +// +// Button(onClick = { +// stackState.popBack() +// }, text = "Remove last card") Button(onClick = { - stackState.popBack() - }, text = "Remove last card") + stackState.hideAt(listOf(3, 4, 5), remove = false) + }, text = "Show last removed card") Button(onClick = { - stackState.showAt(0) - }, text = "Show last removed card") + stackState.showAt(listOf(0, 1, 2)) + }, text = "Show first removed cards") } } } \ No newline at end of file From 07962eca3a7631bb5314a0c1d59782ebde9dd236 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Sun, 17 Aug 2025 20:21:05 +0530 Subject: [PATCH 16/22] Before Mutex --- .../notification/StackableSnackbar.kt | 54 +++++-------------- 1 file changed, 12 insertions(+), 42 deletions(-) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index b171b990e..f03b9d201 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -71,8 +71,6 @@ class CardStackState( mutableStateListOf().apply { addAll(cards) } internal val hiddenIndicesList: MutableList> = mutableListOf() internal var expanded by mutableStateOf(false) - internal val maxSize = - max(maxCollapsedSize, maxExpandedSize) // All cards above this will be deleted fun addCard(card: CardModel) { snapshotStateList.add(card) @@ -92,40 +90,15 @@ class CardStackState( } } - fun expand() { - if (!expanded) { - val maxSize = maxExpandedSize - val currentSize = snapshotStateList.count { !it.hidden.value } - if( currentSize > maxSize) { - hideAt((0..currentSize - maxSize - 1).toList(), remove = false) - } - else { - showAt((0..maxSize - currentSize).toList()) - } - expanded = true - } - } - - fun collapse() { - if (expanded) { - expanded = false - val maxSize = maxCollapsedSize - val currentSize = snapshotStateList.count { !it.hidden.value } - if( currentSize > maxSize) { - hideAt((0..currentSize - maxSize - 1).toList(), remove = false) - } - else { - showAt((0..maxSize - currentSize).toList()) - } - } - } - fun toggleExpanded() { - if (expanded) { - collapse() + val maxSize = if (expanded) maxCollapsedSize else maxExpandedSize + val currentSize = snapshotStateList.count { !it.hidden.value } + if (currentSize > maxSize) { + hideAt(indices = (0..currentSize - maxSize - 1).toList(), remove = false) } else { - expand() + showAt(indices = (0..maxSize - currentSize - 1).toList()) } + expanded = !expanded } fun popAt(index: Int) { @@ -147,29 +120,25 @@ class CardStackState( * @param indices list of indices to restore * @return list of restored cards */ - fun showAt(indices: List): List { - val restored = mutableListOf() - indices.sortedDescending().forEach { idx -> + fun showAt(indices: List) { + indices.reversed().forEach { idx -> if (idx in hiddenIndicesList.indices) { val (hiddenIndex, card) = hiddenIndicesList[idx] if (card.hidden.value) { - restored.add(card) scope.launch { card.isReshown.value = true snapshotStateList.add( - hiddenIndex.coerceAtMost(snapshotStateList.size), + 0, card ) card.hidden.value = false - hiddenIndicesList.removeAt(idx) - delay(FADE_OUT_DURATION.toLong()) card.isReshown.value = false + hiddenIndicesList.removeAt(idx) } } } } - return restored } /** @@ -416,7 +385,8 @@ private fun CardStackItem( val scope = rememberCoroutineScope() val localDensity = LocalDensity.current - val targetYOffset = mutableStateOf(with(localDensity) { if (expanded) (index * (peekHeight + cardHeight)).toPx() else (index * peekHeight).toPx() }) + val targetYOffset = + mutableStateOf(with(localDensity) { if (expanded) (index * (peekHeight + cardHeight)).toPx() else (index * peekHeight).toPx() }) val animatedYOffset = remember { Animatable( if (isReshown && !expanded) { From 192fada73dd8dd4e64aade71531ca7a22b7a782a Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Sun, 17 Aug 2025 21:12:59 +0530 Subject: [PATCH 17/22] After mutex and jank addition fix, before scrollable --- .../notification/StackableSnackbar.kt | 328 +++++++++++------- 1 file changed, 196 insertions(+), 132 deletions(-) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index f03b9d201..4f4d2a221 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -23,28 +23,26 @@ import com.microsoft.fluentui.util.clickableWithTooltip import kotlinx.coroutines.launch import androidx.compose.animation.core.* import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.times -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay +import com.microsoft.fluentui.tokenized.controls.BasicCard +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.util.UUID import kotlin.math.abs -import kotlin.math.max import kotlin.math.pow import kotlin.math.roundToInt @@ -63,7 +61,7 @@ data class CardModel( /** Public state object to control the stack. */ class CardStackState( internal val cards: MutableList, - internal val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main), // TODO: Fix concurrency issues, investigate Dispatchers.Main, also Mutexes where needed + internal val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main), internal val maxCollapsedSize: Int = 3, internal val maxExpandedSize: Int = 10 ) { @@ -72,69 +70,130 @@ class CardStackState( internal val hiddenIndicesList: MutableList> = mutableListOf() internal var expanded by mutableStateOf(false) + private val listOperationMutex = Mutex() + + private val expandMutex = Mutex() + fun addCard(card: CardModel) { - snapshotStateList.add(card) - val maxSize = if (expanded) maxExpandedSize else maxCollapsedSize scope.launch { - if (snapshotStateList.count { !it.hidden.value } > maxSize) { + listOperationMutex.withLock { + withContext(Dispatchers.Main) { + snapshotStateList.add(card) + } + } + + val maxSize = if (expanded) maxExpandedSize else maxCollapsedSize + val visibleCount = snapshotStateList.count { !it.hidden.value } + + if (visibleCount > maxSize) { popBack() - //delay(10) // TODO: Check without delay } } } fun removeCardById(id: String) { - val index = snapshotStateList.indexOfFirst { it.id == id } - if (index != -1) { - snapshotStateList.removeAt(index) + scope.launch { + listOperationMutex.withLock { + withContext(Dispatchers.Main) { + val index = snapshotStateList.indexOfFirst { it.id == id } + if (index != -1) { + snapshotStateList.removeAt(index) + } + } + } } } fun toggleExpanded() { - val maxSize = if (expanded) maxCollapsedSize else maxExpandedSize - val currentSize = snapshotStateList.count { !it.hidden.value } - if (currentSize > maxSize) { - hideAt(indices = (0..currentSize - maxSize - 1).toList(), remove = false) - } else { - showAt(indices = (0..maxSize - currentSize - 1).toList()) - } - expanded = !expanded - } + scope.launch { + expandMutex.withLock { + val currentExpanded = expanded + val maxSize = if (currentExpanded) maxCollapsedSize else maxExpandedSize + val visibleCards = snapshotStateList.filter { !it.hidden.value } + val currentSize = visibleCards.size + + withContext(Dispatchers.Main) { + expanded = !currentExpanded + } - fun popAt(index: Int) { - hideAt(listOf(index), remove = true) + if (currentSize > maxSize) { + val indicesToHide = (0 until (currentSize - maxSize)).toList() + hideAtParallel(indices = indicesToHide, remove = false) + } else { + val indicesToShow = + (0 until minOf(maxSize - currentSize, hiddenIndicesList.size)).toList() + showAtParallel(indices = indicesToShow) + } + } + } } fun popBack() { - val index = snapshotStateList.indexOfFirst { !it.hidden.value } - if (index != -1) popAt(index) else null + scope.launch { + val index = snapshotStateList.indexOfFirst { !it.hidden.value } + if (index != -1) { + hideAtSingle(index, remove = true) + } + } } fun popFront() { - val index = snapshotStateList.indexOfLast { !it.hidden.value } - if (index != -1) popAt(index) else null + scope.launch { + val index = snapshotStateList.indexOfLast { !it.hidden.value } + if (index != -1) { + hideAtSingle(index, remove = true) + } + } } /** - * Shows the card at the specified indices. - * @param indices list of indices to restore - * @return list of restored cards + * Shows cards at the specified indices in parallel. */ fun showAt(indices: List) { - indices.reversed().forEach { idx -> - if (idx in hiddenIndicesList.indices) { - val (hiddenIndex, card) = hiddenIndicesList[idx] - if (card.hidden.value) { - scope.launch { - card.isReshown.value = true - snapshotStateList.add( - 0, - card - ) - card.hidden.value = false - delay(FADE_OUT_DURATION.toLong()) + scope.launch { + showAtParallel(indices) + } + } + + /** + * Shows cards in parallel for smooth animation + */ + private suspend fun showAtParallel(indices: List) { + val cardsToShow = mutableListOf>() + + // First, collect all cards to show while holding the lock + listOperationMutex.withLock { + indices.reversed().forEach { idx -> + if (idx in hiddenIndicesList.indices) { + val (hiddenIndex, card) = hiddenIndicesList[idx] + if (card.hidden.value) { + cardsToShow.add(idx to card) + } + } + } + + // Add all cards back to the list immediately + withContext(Dispatchers.Main) { + cardsToShow.forEach { (_, card) -> + card.isReshown.value = true + snapshotStateList.add(0, card) + card.hidden.value = false + } + } + } + + // Now animate all cards in parallel (outside the lock) + coroutineScope { + cardsToShow.map { (idx, card) -> + launch { + delay(FADE_OUT_DURATION.toLong()) + withContext(Dispatchers.Main) { card.isReshown.value = false - hiddenIndicesList.removeAt(idx) + } + listOperationMutex.withLock { + withContext(Dispatchers.Main) { + hiddenIndicesList.removeIf { it.second.id == card.id } + } } } } @@ -142,21 +201,24 @@ class CardStackState( } /** - * Hides the cards at the specified indices. - * @param indices list of indices to hide - * @param remove if true, removes the card from the stack, otherwise just hides it + * Hides a single card (sequential operation) */ - fun hideAt(indices: List, remove: Boolean = false) { - indices.forEach { idx -> - if (idx in snapshotStateList.indices) { - val card = snapshotStateList[idx] - if (!card.hidden.value) { - scope.launch { - card.hidden.value = true - delay(FADE_OUT_DURATION.toLong()) - if (remove) snapshotStateList.remove(card) - else { - hiddenIndicesList.add(idx to card) + private suspend fun hideAtSingle(index: Int, remove: Boolean) { + if (index in snapshotStateList.indices) { + val card = snapshotStateList[index] + if (!card.hidden.value) { + withContext(Dispatchers.Main) { + card.hidden.value = true + } + + delay(FADE_OUT_DURATION.toLong()) + + listOperationMutex.withLock { + withContext(Dispatchers.Main) { + if (remove) { + snapshotStateList.remove(card) + } else { + hiddenIndicesList.add(index to card) snapshotStateList.remove(card) } } @@ -165,23 +227,53 @@ class CardStackState( } } - fun hideFront() { - val index = snapshotStateList.indexOfFirst { !it.hidden.value && !it.hidden.value } - if (index != -1) hideAt(listOf(index)) else null - } + /** + * Hides cards in parallel for smooth animation + */ + private suspend fun hideAtParallel(indices: List, remove: Boolean) { + val cardsToHide = mutableListOf>() + + // Collect cards and mark them as hidden immediately + listOperationMutex.withLock { + indices.forEach { idx -> + if (idx in snapshotStateList.indices) { + val card = snapshotStateList[idx] + if (!card.hidden.value) { + cardsToHide.add(idx to card) + withContext(Dispatchers.Main) { + card.hidden.value = true + } + } + } + } + } - fun hideBack() { - val index = snapshotStateList.indexOfLast { !it.hidden.value && !it.hidden.value } - if (index != -1) hideAt(listOf(index)) else null - } + // Animate all cards in parallel + if (cardsToHide.isNotEmpty()) { + delay(FADE_OUT_DURATION.toLong()) - fun unhideAll() { - snapshotStateList.forEach { it.hidden.value = false } + // Remove all cards at once after animation + listOperationMutex.withLock { + withContext(Dispatchers.Main) { + cardsToHide.forEach { (idx, card) -> + if (remove) { + snapshotStateList.remove(card) + } else { + if (!hiddenIndicesList.any { it.second.id == card.id }) { + hiddenIndicesList.add(idx to card) + } + snapshotStateList.remove(card) + } + } + } + } + } } fun size(): Int = snapshotStateList.size } +// Rest of the implementation remains the same... @Composable fun rememberCardStackState(initial: List = emptyList()): CardStackState { return remember { CardStackState(initial.toMutableList()) } @@ -202,12 +294,10 @@ fun CardStack( cardWidth: Dp = 320.dp, cardHeight: Dp = 160.dp, peekHeight: Dp = 10.dp, - stackOffset: Offset = Offset(0f, 0f), // offset for the stack position - stackAbove: Boolean = true, // if true, cards stack above each other (negative offset) + stackOffset: Offset = Offset(0f, 0f), + stackAbove: Boolean = true, contentModifier: Modifier = Modifier ) { - // Total stack height: cardHeight + (count-1) * peekHeight - // Total in expanded state: cardHeight * count + (count-1) * peekHeight val count by remember { derivedStateOf { state.size() } } val targetHeight by remember(count, cardHeight, peekHeight, state.expanded) { @@ -220,12 +310,10 @@ fun CardStack( ) } - // Smoothly animate stack height when count changes val animatedStackHeight by animateDpAsState( targetValue = targetHeight, animationSpec = spring(stiffness = Spring.StiffnessMedium) ) - // var animatedStackHeight = targetHeight Box( modifier = modifier @@ -240,25 +328,18 @@ fun CardStack( ) .clickableWithTooltip( onClick = { - //state.expanded = !state.expanded state.toggleExpanded() }, tooltipText = "Notification Stack", ) ) { - // Show cards in reverse visual order: bottom-most drawn first - // val listSnapshot = state.snapshotStateList.toList() - val visibleCards = state.snapshotStateList - //.filter { !it.hidden.value } - .toList() // to avoid concurrent modification issues + val visibleCards = state.snapshotStateList.toList() visibleCards.forEachIndexed { index, cardModel -> - // compute logical index from top (0 is top) val logicalIndex = visibleCards.size - 1 - index val isTop = logicalIndex == 0 key(cardModel.id) { - // Each card will be placed offset from top by logicalIndex * peekHeight CardStackItem( model = cardModel, isHidden = cardModel.hidden.value, @@ -269,22 +350,25 @@ fun CardStack( cardHeight = cardHeight, peekHeight = peekHeight, cardWidth = cardWidth, - onSwipedAway = { idToRemove -> state.removeCardById(idToRemove) }, - stackAbove = stackAbove, - contentModifier = contentModifier + onSwipedAway = { idToRemove -> + state.removeCardById(idToRemove) + state.showAt(listOf(0)) + }, + stackAbove = stackAbove ) } } } } +// CardStackItem and animation functions remain the same as in your original implementation... @Composable private fun CardAdjustAnimation( expanded: Boolean, - isReshown: Boolean = false, // used to trigger re-show animation + isReshown: Boolean = false, index: Int, - stackAbove: Boolean = true, // if true, cards stack above each other (negative offset) - targetYOffset: MutableState,// target Y offset for the card + stackAbove: Boolean = true, + targetYOffset: MutableState, animatedYOffset: Animatable ) { LaunchedEffect(index, expanded, isReshown) { @@ -299,7 +383,7 @@ private fun CardAdjustAnimation( private fun CardWidthAnimation( expanded: Boolean, index: Int, - animatedWidth: Animatable, // default to 0f, + animatedWidth: Animatable, targetWidth: MutableState ) { LaunchedEffect(index, expanded) { @@ -313,11 +397,10 @@ private fun CardWidthAnimation( @Composable private fun SlideInAnimation( model: CardModel, - isReshown: Boolean = false, // used to trigger re-show animation - isTop: Boolean = true, // if true, the card is the top-most in the stack - slideInProgress: Animatable // progress of the slide-in animation + isReshown: Boolean = false, + isTop: Boolean = true, + slideInProgress: Animatable ) { - // Slide In Animation TODO: Add configurations LaunchedEffect(model.id) { if (isReshown) { slideInProgress.snapTo(0f) @@ -337,11 +420,10 @@ private fun SlideInAnimation( @Composable private fun HideAnimation( - isHidden: Boolean = false, // if true, the card is hidden - isReshown: Boolean = false, // used to trigger re-show animation + isHidden: Boolean = false, + isReshown: Boolean = false, opacityProgress: Animatable ) { - // Fade Out Animation TODO: Add configurations LaunchedEffect(isHidden, isReshown) { if (isHidden) { opacityProgress.snapTo(1f) @@ -370,7 +452,7 @@ private fun HideAnimation( private fun CardStackItem( model: CardModel, isHidden: Boolean, - isReshown: Boolean = false, // used to trigger re-show animation + isReshown: Boolean = false, expanded: Boolean, index: Int, isTop: Boolean, @@ -379,8 +461,7 @@ private fun CardStackItem( peekHeight: Dp, stackedWidthScaleFactor: Float = 0.95f, onSwipedAway: (String) -> Unit, - stackAbove: Boolean = false, - contentModifier: Modifier = Modifier + stackAbove: Boolean = false ) { val scope = rememberCoroutineScope() val localDensity = LocalDensity.current @@ -388,16 +469,13 @@ private fun CardStackItem( val targetYOffset = mutableStateOf(with(localDensity) { if (expanded) (index * (peekHeight + cardHeight)).toPx() else (index * peekHeight).toPx() }) val animatedYOffset = remember { - Animatable( - if (isReshown && !expanded) { - targetYOffset.value * (if (stackAbove) -1f else 1f) - } else targetYOffset.value - ) + Animatable(0f) } CardAdjustAnimation( expanded = expanded, isReshown = isReshown, index = index, + stackAbove = stackAbove, targetYOffset = targetYOffset, animatedYOffset = animatedYOffset ) @@ -418,7 +496,7 @@ private fun CardStackItem( ) val slideInProgress = - remember { Animatable(if (isReshown) 0f else 1f) } // 1 = offscreen right, 0 = in place + remember { Animatable(if (isReshown) 0f else 1f) } SlideInAnimation( model = model, isReshown = isReshown, @@ -435,6 +513,7 @@ private fun CardStackItem( val swipeX = remember { Animatable(0f) } val offsetX: Float = if (isTop || expanded) swipeX.value else 0f + Box( modifier = Modifier .offset { @@ -452,13 +531,11 @@ private fun CardStackItem( .padding(horizontal = 0.dp) .then(if (isTop || expanded) Modifier.pointerInput(model.id) { detectDragGestures( - onDragStart = { /* no-op */ }, + onDragStart = {}, onDragEnd = { - // decide threshold val threshold = with(localDensity) { (cardWidth / 4).toPx() } scope.launch { if (abs(swipeX.value) > threshold) { - // animate off screen in the drag direction then remove val target = if (swipeX.value > 0) with(localDensity) { cardWidth.toPx() * 1.2f } else -with( localDensity @@ -470,10 +547,8 @@ private fun CardStackItem( easing = FastOutLinearInEasing ) ) - // remove after animation onSwipedAway(model.id) } else { - // return to center swipeX.animateTo( 0f, animationSpec = spring(stiffness = Spring.StiffnessMedium) @@ -498,21 +573,17 @@ private fun CardStackItem( ) } else Modifier) ) { - // Card visuals TODO: Replace with card composable - Box( + BasicCard( modifier = Modifier .fillMaxSize() + .clip(RoundedCornerShape(12.dp)) .shadow( - elevation = if (isTop || expanded) 12.dp else 4.dp, - shape = RoundedCornerShape(12.dp) + elevation = 12.dp ) - .border(width = 1.dp, color = Color(0x22000000), shape = RoundedCornerShape(12.dp)) - .background(color = Color.LightGray, shape = RoundedCornerShape(12.dp)) - .then(contentModifier), - ) { - Box(modifier = Modifier.fillMaxSize()) { - model.content() - } + .background(Color.Gray) + ) + { + model.content() } } } @@ -552,19 +623,12 @@ fun DemoCardStack() { Button(onClick = { stackState.popFront() }, text = "Remove top card") - Spacer(modifier = Modifier.width(12.dp)) -// -// Button(onClick = { -// stackState.popBack() -// }, text = "Remove last card") - Button(onClick = { - stackState.hideAt(listOf(3, 4, 5), remove = false) - }, text = "Show last removed card") + Spacer(modifier = Modifier.width(12.dp)) Button(onClick = { stackState.showAt(listOf(0, 1, 2)) - }, text = "Show first removed cards") + }, text = "Show hidden cards") } } } \ No newline at end of file From 8f781b3c4d28f1e389e98a1872a49f33456a1260 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Sun, 17 Aug 2025 21:58:20 +0530 Subject: [PATCH 18/22] Minor changes --- .../fluentui/tokenized/notification/StackableSnackbar.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index 4f4d2a221..3bc495519 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -1,5 +1,7 @@ package com.microsoft.fluentui.tokenized.notification +import android.os.Build +import androidx.annotation.RequiresApi import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box @@ -104,6 +106,7 @@ class CardStackState( } } + @RequiresApi(Build.VERSION_CODES.N) fun toggleExpanded() { scope.launch { expandMutex.withLock { @@ -149,6 +152,7 @@ class CardStackState( /** * Shows cards at the specified indices in parallel. */ + @RequiresApi(Build.VERSION_CODES.N) fun showAt(indices: List) { scope.launch { showAtParallel(indices) @@ -158,6 +162,7 @@ class CardStackState( /** * Shows cards in parallel for smooth animation */ + @RequiresApi(Build.VERSION_CODES.N) private suspend fun showAtParallel(indices: List) { val cardsToShow = mutableListOf>() @@ -287,6 +292,7 @@ fun rememberCardStackState(initial: List = emptyList()): CardStackSta * @param peekHeight how much of the previous card is visible under the top card * @param contentModifier modifier applied to each card slot */ +@RequiresApi(Build.VERSION_CODES.N) @Composable fun CardStack( state: CardStackState, @@ -588,6 +594,7 @@ private fun CardStackItem( } } +@RequiresApi(Build.VERSION_CODES.N) @Composable fun DemoCardStack() { val stackState = rememberCardStackState() From 487d768d26f7808bd31c057f2bf6d4aa5a11d5c0 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Tue, 26 Aug 2025 14:57:05 +0530 Subject: [PATCH 19/22] Minor improvements --- .../fluentuidemo/demos/V2SnackbarActivity.kt | 57 +++++- .../notification/StackableSnackbar.kt | 181 ++++++------------ 2 files changed, 110 insertions(+), 128 deletions(-) diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt index 65a4e6c43..3de73acb0 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt @@ -1,7 +1,9 @@ package com.microsoft.fluentuidemo.demos +import android.os.Build import android.os.Bundle import android.widget.Toast +import androidx.annotation.RequiresApi import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearOutSlowInEasing @@ -9,6 +11,7 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.BasicText import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ShoppingCart import androidx.compose.runtime.* @@ -31,16 +34,19 @@ import com.microsoft.fluentui.tokenized.listitem.ChevronOrientation import com.microsoft.fluentui.tokenized.listitem.ListItem import com.microsoft.fluentui.tokenized.notification.AnimationBehavior import com.microsoft.fluentui.tokenized.notification.AnimationVariables -import com.microsoft.fluentui.tokenized.notification.DemoCardStack +import com.microsoft.fluentui.tokenized.notification.CardModel +import com.microsoft.fluentui.tokenized.notification.CardStack import com.microsoft.fluentui.tokenized.notification.NotificationDuration import com.microsoft.fluentui.tokenized.notification.NotificationResult import com.microsoft.fluentui.tokenized.notification.Snackbar import com.microsoft.fluentui.tokenized.notification.SnackbarState +import com.microsoft.fluentui.tokenized.notification.rememberCardStackState import com.microsoft.fluentui.tokenized.segmentedcontrols.PillBar import com.microsoft.fluentui.tokenized.segmentedcontrols.PillMetaData import com.microsoft.fluentuidemo.R import com.microsoft.fluentuidemo.V2DemoActivity import kotlinx.coroutines.launch +import java.util.UUID // Tags used for testing const val SNACK_BAR_MODIFIABLE_PARAMETER_SECTION = "Snack bar Modifiable Parameters" @@ -60,6 +66,7 @@ class V2SnackbarActivity : V2DemoActivity() { override val controlTokensUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-34" + @RequiresApi(Build.VERSION_CODES.N) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val context = this @@ -332,6 +339,54 @@ class V2SnackbarActivity : V2DemoActivity() { } } + +@RequiresApi(Build.VERSION_CODES.N) +@Composable +fun DemoCardStack() { + val stackState = rememberCardStackState() + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Bottom + ) { + CardStack( + state = stackState, + modifier = Modifier.padding(16.dp), + cardWidth = 340.dp, + cardHeight = 100.dp, + peekHeight = 10.dp, + stackAbove = true + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Row { + Button(onClick = { + val id = UUID.randomUUID().toString() + stackState.addCard(CardModel(id = id) { + Column(modifier = Modifier.padding(12.dp)) { + BasicText("Card: $id") + BasicText("Some detail here") + } + }) + }, text = "Add card") + + Spacer(modifier = Modifier.width(12.dp)) + + Button(onClick = { + stackState.popFront() + stackState.showAt(listOf(0)) + }, text = "Remove top card") + + Spacer(modifier = Modifier.width(12.dp)) + + Button(onClick = { + stackState.showAll() + }, text = "Show hidden cards") + } + } +} + // Customized animation behavior for Snackbar val customizedAnimationBehavior: AnimationBehavior = object : AnimationBehavior() { override var animationVariables: AnimationVariables = object : AnimationVariables() { diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index 3bc495519..23adb1309 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -1,15 +1,10 @@ package com.microsoft.fluentui.tokenized.notification -import android.os.Build -import androidx.annotation.RequiresApi import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -20,20 +15,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp -import com.microsoft.fluentui.tokenized.controls.Button import com.microsoft.fluentui.util.clickableWithTooltip import kotlinx.coroutines.launch import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.* -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp @@ -43,16 +35,13 @@ import com.microsoft.fluentui.tokenized.controls.BasicCard import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import java.util.UUID import kotlin.math.abs import kotlin.math.pow import kotlin.math.roundToInt -// Constants private const val FADE_OUT_DURATION = 350 // milliseconds private const val STACKED_WIDTH_SCALE_FACTOR = 0.95f // Scale factor for stacked cards -/** Single card model contains an id and a composable content lambda. */ data class CardModel( val id: String, val hidden: MutableState = mutableStateOf(false), @@ -79,16 +68,14 @@ class CardStackState( fun addCard(card: CardModel) { scope.launch { listOperationMutex.withLock { - withContext(Dispatchers.Main) { - snapshotStateList.add(card) - } + snapshotStateList.add(card) } val maxSize = if (expanded) maxExpandedSize else maxCollapsedSize val visibleCount = snapshotStateList.count { !it.hidden.value } if (visibleCount > maxSize) { - popBack() + popBack(remove = false) } } } @@ -96,17 +83,14 @@ class CardStackState( fun removeCardById(id: String) { scope.launch { listOperationMutex.withLock { - withContext(Dispatchers.Main) { - val index = snapshotStateList.indexOfFirst { it.id == id } - if (index != -1) { - snapshotStateList.removeAt(index) - } + val index = snapshotStateList.indexOfFirst { it.id == id } + if (index != -1) { + snapshotStateList.removeAt(index) } } } } - @RequiresApi(Build.VERSION_CODES.N) fun toggleExpanded() { scope.launch { expandMutex.withLock { @@ -114,11 +98,7 @@ class CardStackState( val maxSize = if (currentExpanded) maxCollapsedSize else maxExpandedSize val visibleCards = snapshotStateList.filter { !it.hidden.value } val currentSize = visibleCards.size - - withContext(Dispatchers.Main) { - expanded = !currentExpanded - } - + expanded = !currentExpanded if (currentSize > maxSize) { val indicesToHide = (0 until (currentSize - maxSize)).toList() hideAtParallel(indices = indicesToHide, remove = false) @@ -131,20 +111,20 @@ class CardStackState( } } - fun popBack() { + fun popBack(remove: Boolean = true) { scope.launch { val index = snapshotStateList.indexOfFirst { !it.hidden.value } if (index != -1) { - hideAtSingle(index, remove = true) + hideAtSingle(index, remove = remove) } } } - fun popFront() { + fun popFront(remove: Boolean = true) { scope.launch { val index = snapshotStateList.indexOfLast { !it.hidden.value } if (index != -1) { - hideAtSingle(index, remove = true) + hideAtSingle(index, remove = remove) } } } @@ -152,17 +132,22 @@ class CardStackState( /** * Shows cards at the specified indices in parallel. */ - @RequiresApi(Build.VERSION_CODES.N) fun showAt(indices: List) { scope.launch { showAtParallel(indices) } } + fun showAll() { + scope.launch { + val indicesToShow = (0 until hiddenIndicesList.size).toList() + showAtParallel(indices = indicesToShow) + } + } + /** * Shows cards in parallel for smooth animation */ - @RequiresApi(Build.VERSION_CODES.N) private suspend fun showAtParallel(indices: List) { val cardsToShow = mutableListOf>() @@ -178,12 +163,11 @@ class CardStackState( } // Add all cards back to the list immediately - withContext(Dispatchers.Main) { - cardsToShow.forEach { (_, card) -> - card.isReshown.value = true - snapshotStateList.add(0, card) - card.hidden.value = false - } + + cardsToShow.forEach { (_, card) -> + card.isReshown.value = true + snapshotStateList.add(0, card) + card.hidden.value = false } } @@ -192,12 +176,14 @@ class CardStackState( cardsToShow.map { (idx, card) -> launch { delay(FADE_OUT_DURATION.toLong()) - withContext(Dispatchers.Main) { - card.isReshown.value = false - } + card.isReshown.value = false listOperationMutex.withLock { - withContext(Dispatchers.Main) { - hiddenIndicesList.removeIf { it.second.id == card.id } + val iterator = hiddenIndicesList.iterator() + while (iterator.hasNext()) { + val item = iterator.next() + if (item.second.id == card.id) { + iterator.remove() + } } } } @@ -212,20 +198,16 @@ class CardStackState( if (index in snapshotStateList.indices) { val card = snapshotStateList[index] if (!card.hidden.value) { - withContext(Dispatchers.Main) { - card.hidden.value = true - } + card.hidden.value = true delay(FADE_OUT_DURATION.toLong()) listOperationMutex.withLock { - withContext(Dispatchers.Main) { - if (remove) { - snapshotStateList.remove(card) - } else { - hiddenIndicesList.add(index to card) - snapshotStateList.remove(card) - } + if (remove) { + snapshotStateList.remove(card) + } else { + hiddenIndicesList.add(index to card) + snapshotStateList.remove(card) } } } @@ -245,9 +227,7 @@ class CardStackState( val card = snapshotStateList[idx] if (!card.hidden.value) { cardsToHide.add(idx to card) - withContext(Dispatchers.Main) { - card.hidden.value = true - } + card.hidden.value = true } } } @@ -259,16 +239,14 @@ class CardStackState( // Remove all cards at once after animation listOperationMutex.withLock { - withContext(Dispatchers.Main) { - cardsToHide.forEach { (idx, card) -> - if (remove) { - snapshotStateList.remove(card) - } else { - if (!hiddenIndicesList.any { it.second.id == card.id }) { - hiddenIndicesList.add(idx to card) - } - snapshotStateList.remove(card) + cardsToHide.forEach { (idx, card) -> + if (remove) { + snapshotStateList.remove(card) + } else { + if (!hiddenIndicesList.any { it.second.id == card.id }) { + hiddenIndicesList.add(idx to card) } + snapshotStateList.remove(card) } } } @@ -278,7 +256,6 @@ class CardStackState( fun size(): Int = snapshotStateList.size } -// Rest of the implementation remains the same... @Composable fun rememberCardStackState(initial: List = emptyList()): CardStackState { return remember { CardStackState(initial.toMutableList()) } @@ -292,7 +269,6 @@ fun rememberCardStackState(initial: List = emptyList()): CardStackSta * @param peekHeight how much of the previous card is visible under the top card * @param contentModifier modifier applied to each card slot */ -@RequiresApi(Build.VERSION_CODES.N) @Composable fun CardStack( state: CardStackState, @@ -300,7 +276,6 @@ fun CardStack( cardWidth: Dp = 320.dp, cardHeight: Dp = 160.dp, peekHeight: Dp = 10.dp, - stackOffset: Offset = Offset(0f, 0f), stackAbove: Boolean = true, contentModifier: Modifier = Modifier ) { @@ -339,10 +314,8 @@ fun CardStack( tooltipText = "Notification Stack", ) ) { - val visibleCards = state.snapshotStateList.toList() - - visibleCards.forEachIndexed { index, cardModel -> - val logicalIndex = visibleCards.size - 1 - index + state.snapshotStateList.forEachIndexed { index, cardModel -> + val logicalIndex = state.snapshotStateList.size - 1 - index val isTop = logicalIndex == 0 key(cardModel.id) { @@ -374,12 +347,12 @@ private fun CardAdjustAnimation( isReshown: Boolean = false, index: Int, stackAbove: Boolean = true, - targetYOffset: MutableState, + targetYOffset: Float, animatedYOffset: Animatable ) { LaunchedEffect(index, expanded, isReshown) { animatedYOffset.animateTo( - targetYOffset.value * (if (stackAbove) -1f else 1f), + targetYOffset * (if (stackAbove) -1f else 1f), animationSpec = spring(stiffness = Spring.StiffnessLow) ) } @@ -390,11 +363,11 @@ private fun CardWidthAnimation( expanded: Boolean, index: Int, animatedWidth: Animatable, - targetWidth: MutableState + targetWidth: Float ) { LaunchedEffect(index, expanded) { animatedWidth.animateTo( - targetWidth.value, + targetWidth, animationSpec = spring(stiffness = Spring.StiffnessLow) ) } @@ -465,7 +438,7 @@ private fun CardStackItem( cardWidth: Dp, cardHeight: Dp, peekHeight: Dp, - stackedWidthScaleFactor: Float = 0.95f, + stackedWidthScaleFactor: Float = STACKED_WIDTH_SCALE_FACTOR, onSwipedAway: (String) -> Unit, stackAbove: Boolean = false ) { @@ -473,7 +446,7 @@ private fun CardStackItem( val localDensity = LocalDensity.current val targetYOffset = - mutableStateOf(with(localDensity) { if (expanded) (index * (peekHeight + cardHeight)).toPx() else (index * peekHeight).toPx() }) + with(localDensity) { if (expanded) (index * (peekHeight + cardHeight)).toPx() else (index * peekHeight).toPx() } val animatedYOffset = remember { Animatable(0f) } @@ -486,14 +459,14 @@ private fun CardStackItem( animatedYOffset = animatedYOffset ) - val targetWidth = mutableStateOf(with(localDensity) { + val targetWidth = with(localDensity) { if (expanded) { cardWidth.toPx() } else { cardWidth.toPx() * stackedWidthScaleFactor.pow(index) } - }) - val animatedWidth = remember { Animatable(targetWidth.value) } + } + val animatedWidth = remember { Animatable(targetWidth) } CardWidthAnimation( expanded = expanded, index = index, @@ -592,50 +565,4 @@ private fun CardStackItem( model.content() } } -} - -@RequiresApi(Build.VERSION_CODES.N) -@Composable -fun DemoCardStack() { - val stackState = rememberCardStackState() - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Bottom - ) { - CardStack( - state = stackState, - modifier = Modifier.padding(16.dp), - cardWidth = 340.dp, - cardHeight = 100.dp, - peekHeight = 10.dp, - stackAbove = true - ) - - Spacer(modifier = Modifier.height(20.dp)) - - Row { - Button(onClick = { - val id = UUID.randomUUID().toString() - stackState.addCard(CardModel(id = id) { - Column(modifier = Modifier.padding(12.dp)) { - BasicText("Card: $id") - BasicText("Some detail here") - } - }) - }, text = "Add card") - - Spacer(modifier = Modifier.width(12.dp)) - - Button(onClick = { - stackState.popFront() - }, text = "Remove top card") - - Spacer(modifier = Modifier.width(12.dp)) - - Button(onClick = { - stackState.showAt(listOf(0, 1, 2)) - }, text = "Show hidden cards") - } - } } \ No newline at end of file From 4238e39bb4b4e21b2fd182175b9274a323bd9d9b Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Tue, 26 Aug 2025 15:32:18 +0530 Subject: [PATCH 20/22] improvements --- .../fluentuidemo/demos/V2SnackbarActivity.kt | 12 +-- .../notification/StackableSnackbar.kt | 76 +++++++++---------- 2 files changed, 42 insertions(+), 46 deletions(-) diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt index 3de73acb0..36628d8c5 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt @@ -34,13 +34,13 @@ import com.microsoft.fluentui.tokenized.listitem.ChevronOrientation import com.microsoft.fluentui.tokenized.listitem.ListItem import com.microsoft.fluentui.tokenized.notification.AnimationBehavior import com.microsoft.fluentui.tokenized.notification.AnimationVariables -import com.microsoft.fluentui.tokenized.notification.CardModel -import com.microsoft.fluentui.tokenized.notification.CardStack import com.microsoft.fluentui.tokenized.notification.NotificationDuration import com.microsoft.fluentui.tokenized.notification.NotificationResult import com.microsoft.fluentui.tokenized.notification.Snackbar +import com.microsoft.fluentui.tokenized.notification.SnackbarItemModel +import com.microsoft.fluentui.tokenized.notification.SnackbarStack import com.microsoft.fluentui.tokenized.notification.SnackbarState -import com.microsoft.fluentui.tokenized.notification.rememberCardStackState +import com.microsoft.fluentui.tokenized.notification.rememberSnackbarStackState import com.microsoft.fluentui.tokenized.segmentedcontrols.PillBar import com.microsoft.fluentui.tokenized.segmentedcontrols.PillMetaData import com.microsoft.fluentuidemo.R @@ -343,13 +343,13 @@ class V2SnackbarActivity : V2DemoActivity() { @RequiresApi(Build.VERSION_CODES.N) @Composable fun DemoCardStack() { - val stackState = rememberCardStackState() + val stackState = rememberSnackbarStackState() Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom ) { - CardStack( + SnackbarStack( state = stackState, modifier = Modifier.padding(16.dp), cardWidth = 340.dp, @@ -363,7 +363,7 @@ fun DemoCardStack() { Row { Button(onClick = { val id = UUID.randomUUID().toString() - stackState.addCard(CardModel(id = id) { + stackState.addCard(SnackbarItemModel(id = id) { Column(modifier = Modifier.padding(12.dp)) { BasicText("Card: $id") BasicText("Some detail here") diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index 23adb1309..bc24eca4d 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -39,10 +39,11 @@ import kotlin.math.abs import kotlin.math.pow import kotlin.math.roundToInt -private const val FADE_OUT_DURATION = 350 // milliseconds -private const val STACKED_WIDTH_SCALE_FACTOR = 0.95f // Scale factor for stacked cards +private const val FADE_OUT_DURATION = 350 +private const val STACKED_WIDTH_SCALE_FACTOR = 0.95f -data class CardModel( +@Stable +data class SnackbarItemModel( val id: String, val hidden: MutableState = mutableStateOf(false), val isReshown: MutableState = mutableStateOf(false), @@ -50,22 +51,22 @@ data class CardModel( ) /** Public state object to control the stack. */ -class CardStackState( - internal val cards: MutableList, +class SnackbarStackState( + internal val cards: MutableList, internal val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main), internal val maxCollapsedSize: Int = 3, internal val maxExpandedSize: Int = 10 ) { - internal val snapshotStateList: MutableList = - mutableStateListOf().apply { addAll(cards) } - internal val hiddenIndicesList: MutableList> = mutableListOf() + internal val snapshotStateList: MutableList = + mutableStateListOf().apply { addAll(cards) } + internal val hiddenIndicesList: MutableList> = mutableListOf() internal var expanded by mutableStateOf(false) private val listOperationMutex = Mutex() private val expandMutex = Mutex() - fun addCard(card: CardModel) { + fun addCard(card: SnackbarItemModel) { scope.launch { listOperationMutex.withLock { snapshotStateList.add(card) @@ -149,9 +150,8 @@ class CardStackState( * Shows cards in parallel for smooth animation */ private suspend fun showAtParallel(indices: List) { - val cardsToShow = mutableListOf>() + val cardsToShow = mutableListOf>() - // First, collect all cards to show while holding the lock listOperationMutex.withLock { indices.reversed().forEach { idx -> if (idx in hiddenIndicesList.indices) { @@ -162,8 +162,6 @@ class CardStackState( } } - // Add all cards back to the list immediately - cardsToShow.forEach { (_, card) -> card.isReshown.value = true snapshotStateList.add(0, card) @@ -171,7 +169,6 @@ class CardStackState( } } - // Now animate all cards in parallel (outside the lock) coroutineScope { cardsToShow.map { (idx, card) -> launch { @@ -218,7 +215,7 @@ class CardStackState( * Hides cards in parallel for smooth animation */ private suspend fun hideAtParallel(indices: List, remove: Boolean) { - val cardsToHide = mutableListOf>() + val cardsToHide = mutableListOf>() // Collect cards and mark them as hidden immediately listOperationMutex.withLock { @@ -233,11 +230,9 @@ class CardStackState( } } - // Animate all cards in parallel if (cardsToHide.isNotEmpty()) { delay(FADE_OUT_DURATION.toLong()) - // Remove all cards at once after animation listOperationMutex.withLock { cardsToHide.forEach { (idx, card) -> if (remove) { @@ -257,12 +252,12 @@ class CardStackState( } @Composable -fun rememberCardStackState(initial: List = emptyList()): CardStackState { - return remember { CardStackState(initial.toMutableList()) } +fun rememberSnackbarStackState(initial: List = emptyList()): SnackbarStackState { + return remember { SnackbarStackState(initial.toMutableList()) } } /** - * CardStack composable. + * SnackbarStack composable. * @param state state controlling cards * @param cardWidth fixed width of the stack * @param cardHeight base height for each card @@ -270,8 +265,8 @@ fun rememberCardStackState(initial: List = emptyList()): CardStackSta * @param contentModifier modifier applied to each card slot */ @Composable -fun CardStack( - state: CardStackState, +fun SnackbarStack( + state: SnackbarStackState, modifier: Modifier = Modifier, cardWidth: Dp = 320.dp, cardHeight: Dp = 160.dp, @@ -281,14 +276,16 @@ fun CardStack( ) { val count by remember { derivedStateOf { state.size() } } - val targetHeight by remember(count, cardHeight, peekHeight, state.expanded) { - mutableStateOf( - if (state.expanded) { - cardHeight * count + (if (count > 0) (count - 1) * peekHeight else 0.dp) + val targetHeight by remember { + derivedStateOf { + if (count == 0) { + 0.dp + } else if (state.expanded) { + cardHeight * count + (count - 1) * peekHeight } else { - cardHeight + (if (count > 0) (count - 1) * peekHeight else 0.dp) + cardHeight + (count - 1) * peekHeight } - ) + } } val animatedStackHeight by animateDpAsState( @@ -299,7 +296,7 @@ fun CardStack( Box( modifier = modifier .width(cardWidth) - .height(if (state.snapshotStateList.size == 0) 0.dp else animatedStackHeight) + .height(if (state.size() == 0) 0.dp else animatedStackHeight) .wrapContentHeight( align = if (stackAbove) { Alignment.Bottom @@ -314,15 +311,15 @@ fun CardStack( tooltipText = "Notification Stack", ) ) { - state.snapshotStateList.forEachIndexed { index, cardModel -> - val logicalIndex = state.snapshotStateList.size - 1 - index + state.snapshotStateList.forEachIndexed { index, snackbarModel -> + val logicalIndex = state.size() - 1 - index val isTop = logicalIndex == 0 - key(cardModel.id) { - CardStackItem( - model = cardModel, - isHidden = cardModel.hidden.value, - isReshown = cardModel.isReshown.value, + key(snackbarModel.id) { + SnackbarStackItem( + model = snackbarModel, + isHidden = snackbarModel.hidden.value, + isReshown = snackbarModel.isReshown.value, expanded = state.expanded, index = logicalIndex, isTop = isTop, @@ -340,7 +337,6 @@ fun CardStack( } } -// CardStackItem and animation functions remain the same as in your original implementation... @Composable private fun CardAdjustAnimation( expanded: Boolean, @@ -375,7 +371,7 @@ private fun CardWidthAnimation( @Composable private fun SlideInAnimation( - model: CardModel, + model: SnackbarItemModel, isReshown: Boolean = false, isTop: Boolean = true, slideInProgress: Animatable @@ -428,8 +424,8 @@ private fun HideAnimation( } @Composable -private fun CardStackItem( - model: CardModel, +private fun SnackbarStackItem( + model: SnackbarItemModel, isHidden: Boolean, isReshown: Boolean = false, expanded: Boolean, From eaa4c71d4f9844c17969618ad31331b91c95cc3e Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Tue, 26 Aug 2025 15:57:06 +0530 Subject: [PATCH 21/22] Added KDocs --- .../notification/StackableSnackbar.kt | 100 +++++++++++++++--- 1 file changed, 85 insertions(+), 15 deletions(-) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index bc24eca4d..a9321e9c2 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -42,6 +42,14 @@ import kotlin.math.roundToInt private const val FADE_OUT_DURATION = 350 private const val STACKED_WIDTH_SCALE_FACTOR = 0.95f +/** + * Represents a single item within the Snackbar stack. + * + * @param id A unique identifier for this snackbar item. + * @param hidden A mutable state to control the visibility (hidden/shown) of the card. `true` if hidden, `false` otherwise. + * @param isReshown A mutable state to indicate if the card is being reshown after being hidden. This helps in triggering specific animations. + * @param content The composable content to be displayed inside the snackbar card. + */ @Stable data class SnackbarItemModel( val id: String, @@ -50,7 +58,15 @@ data class SnackbarItemModel( val content: @Composable () -> Unit ) -/** Public state object to control the stack. */ +/** + * A state object that can be hoisted to control and observe the [SnackbarStack]. + * It provides methods to add, remove, and manage the state of snackbar items. + * + * @param cards The initial list of [SnackbarItemModel] to populate the stack. + * @param scope The [CoroutineScope] to launch operations like adding, removing, and animating cards. + * @param maxCollapsedSize The maximum number of visible cards when the stack is collapsed. + * @param maxExpandedSize The maximum number of visible cards when the stack is expanded. + */ class SnackbarStackState( internal val cards: MutableList, internal val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main), @@ -66,6 +82,11 @@ class SnackbarStackState( private val expandMutex = Mutex() + /** + * Adds a new snackbar card to the top of the stack. + * If the stack exceeds the maximum size, the oldest card will be hidden. + * @param card The [SnackbarItemModel] to add. + */ fun addCard(card: SnackbarItemModel) { scope.launch { listOperationMutex.withLock { @@ -81,6 +102,10 @@ class SnackbarStackState( } } + /** + * Removes a snackbar card from the stack by its unique [id]. + * @param id The id of the card to remove. + */ fun removeCardById(id: String) { scope.launch { listOperationMutex.withLock { @@ -92,6 +117,10 @@ class SnackbarStackState( } } + /** + * Toggles the stack between its collapsed and expanded states. + * It automatically handles showing or hiding cards to match the respective size limits. + */ fun toggleExpanded() { scope.launch { expandMutex.withLock { @@ -112,6 +141,10 @@ class SnackbarStackState( } } + /** + * Hides the oldest visible card from the stack (the one at the bottom). + * @param remove If `true`, the card is permanently removed. If `false`, it's moved to a hidden list and can be reshown later. + */ fun popBack(remove: Boolean = true) { scope.launch { val index = snapshotStateList.indexOfFirst { !it.hidden.value } @@ -121,6 +154,10 @@ class SnackbarStackState( } } + /** + * Hides the newest visible card from the stack (the one at the top). + * @param remove If `true`, the card is permanently removed. If `false`, it's moved to a hidden list and can be reshown later. + */ fun popFront(remove: Boolean = true) { scope.launch { val index = snapshotStateList.indexOfLast { !it.hidden.value } @@ -131,7 +168,8 @@ class SnackbarStackState( } /** - * Shows cards at the specified indices in parallel. + * Reveals previously hidden cards at the specified indices in the hidden list. + * @param indices A list of indices corresponding to the cards in the hidden list to show. */ fun showAt(indices: List) { scope.launch { @@ -139,6 +177,9 @@ class SnackbarStackState( } } + /** + * Reveals all previously hidden cards. + */ fun showAll() { scope.launch { val indicesToShow = (0 until hiddenIndicesList.size).toList() @@ -147,7 +188,7 @@ class SnackbarStackState( } /** - * Shows cards in parallel for smooth animation + * Shows cards in parallel for smooth animation. */ private suspend fun showAtParallel(indices: List) { val cardsToShow = mutableListOf>() @@ -189,7 +230,7 @@ class SnackbarStackState( } /** - * Hides a single card (sequential operation) + * Hides a single card (sequential operation). */ private suspend fun hideAtSingle(index: Int, remove: Boolean) { if (index in snapshotStateList.indices) { @@ -212,12 +253,11 @@ class SnackbarStackState( } /** - * Hides cards in parallel for smooth animation + * Hides cards in parallel for smooth animation. */ private suspend fun hideAtParallel(indices: List, remove: Boolean) { val cardsToHide = mutableListOf>() - // Collect cards and mark them as hidden immediately listOperationMutex.withLock { indices.forEach { idx -> if (idx in snapshotStateList.indices) { @@ -248,21 +288,36 @@ class SnackbarStackState( } } + /** + * Returns the current number of visible cards in the stack. + */ fun size(): Int = snapshotStateList.size } +/** + * Creates and remembers a [SnackbarStackState] in the current composition. + * This is the recommended way to create a state object for the [SnackbarStack]. + * + * @param initial An optional initial list of [SnackbarItemModel]s to populate the stack. + * @return A remembered [SnackbarStackState] instance. + */ @Composable fun rememberSnackbarStackState(initial: List = emptyList()): SnackbarStackState { return remember { SnackbarStackState(initial.toMutableList()) } } /** - * SnackbarStack composable. - * @param state state controlling cards - * @param cardWidth fixed width of the stack - * @param cardHeight base height for each card - * @param peekHeight how much of the previous card is visible under the top card - * @param contentModifier modifier applied to each card slot + * A composable that displays a stack of snackbar notifications. + * It animates the cards based on the provided [SnackbarStackState]. + * The stack can be expanded or collapsed by clicking on it. + * + * @param state The [SnackbarStackState] that controls the content and behavior of the stack. + * @param modifier The [Modifier] to be applied to the stack container. + * @param cardWidth The fixed width for each card in the stack. + * @param cardHeight The base height for each card in the stack. + * @param peekHeight The height of the portion of the underlying cards that is visible when the stack is collapsed. + * @param stackAbove If `true`, the stack builds upwards from the bottom. If `false`, it builds downwards from the top. + * @param contentModifier A modifier to be applied to each individual card slot within the stack. */ @Composable fun SnackbarStack( @@ -293,6 +348,7 @@ fun SnackbarStack( animationSpec = spring(stiffness = Spring.StiffnessMedium) ) + val toggleExpanded = remember<() -> Unit> { { state.toggleExpanded() } } Box( modifier = modifier .width(cardWidth) @@ -305,9 +361,7 @@ fun SnackbarStack( } ) .clickableWithTooltip( - onClick = { - state.toggleExpanded() - }, + onClick = toggleExpanded, tooltipText = "Notification Stack", ) ) { @@ -337,6 +391,9 @@ fun SnackbarStack( } } +/** + * Manages the vertical offset animation of a card when the stack's state changes. + */ @Composable private fun CardAdjustAnimation( expanded: Boolean, @@ -354,6 +411,9 @@ private fun CardAdjustAnimation( } } +/** + * Manages the width animation of a card when the stack's state changes. + */ @Composable private fun CardWidthAnimation( expanded: Boolean, @@ -369,6 +429,9 @@ private fun CardWidthAnimation( } } +/** + * Manages the initial slide-in animation for a new card. + */ @Composable private fun SlideInAnimation( model: SnackbarItemModel, @@ -393,6 +456,9 @@ private fun SlideInAnimation( } } +/** + * Manages the fade-in/fade-out animation when a card is hidden or reshown. + */ @Composable private fun HideAnimation( isHidden: Boolean = false, @@ -423,6 +489,10 @@ private fun HideAnimation( } } +/** + * A private composable that represents a single, animatable card within the [SnackbarStack]. + * It handles its own animations for position, width, opacity, and swipe gestures. + */ @Composable private fun SnackbarStackItem( model: SnackbarItemModel, From ada9d9c6fd119f8a602903bd7b55def4467ce2f5 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Wed, 27 Aug 2025 20:47:30 +0530 Subject: [PATCH 22/22] Added tokens --- .../fluentuidemo/demos/V2SnackbarActivity.kt | 545 +++++++++--------- .../notification/StackableSnackbar.kt | 25 +- 2 files changed, 298 insertions(+), 272 deletions(-) diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt index 36628d8c5..aa2871d02 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SnackbarActivity.kt @@ -72,275 +72,290 @@ class V2SnackbarActivity : V2DemoActivity() { val context = this setActivityContent { - DemoCardStack() -// Box(modifier = Modifier.fillMaxSize()){ -// StackableSnackbar() -// } -// val snackbarState = remember { SnackbarState() } -// -// val scope = rememberCoroutineScope() -// Column( -// Modifier.fillMaxSize(), -// horizontalAlignment = Alignment.CenterHorizontally -// ) { -// var icon: Boolean by rememberSaveable { mutableStateOf(false) } -// var actionLabel: Boolean by rememberSaveable { mutableStateOf(false) } -// var subtitle: String? by rememberSaveable { mutableStateOf(null) } -// var style: SnackbarStyle by rememberSaveable { mutableStateOf(SnackbarStyle.Neutral) } -// var duration: NotificationDuration by rememberSaveable { -// mutableStateOf( -// NotificationDuration.SHORT -// ) -// } -// var dismissEnabled by rememberSaveable { mutableStateOf(false) } -// -// ListItem.SectionHeader( -// title = LocalContext.current.resources.getString(R.string.app_modifiable_parameters), -// enableChevron = true, -// enableContentOpenCloseTransition = true, -// chevronOrientation = ChevronOrientation(90f, 0f), -// modifier = Modifier.testTag(SNACK_BAR_MODIFIABLE_PARAMETER_SECTION) -// ) { -// LazyColumn(Modifier.fillMaxHeight(0.5F)) { -// item { -// PillBar( -// mutableListOf( -// PillMetaData( -// text = LocalContext.current.resources.getString(R.string.fluentui_indefinite), -// onClick = { -// duration = NotificationDuration.INDEFINITE -// }, -// selected = duration == NotificationDuration.INDEFINITE -// ), -// PillMetaData( -// text = LocalContext.current.resources.getString(R.string.fluentui_long), -// onClick = { -// duration = NotificationDuration.LONG -// }, -// selected = duration == NotificationDuration.LONG -// ), -// PillMetaData( -// text = LocalContext.current.resources.getString(R.string.fluentui_short), -// onClick = { -// duration = NotificationDuration.SHORT -// }, -// selected = duration == NotificationDuration.SHORT -// ) -// ), style = FluentStyle.Neutral, -// showBackground = true -// ) -// } -// -// item { -// Spacer( -// Modifier -// .height(8.dp) -// .fillMaxWidth() -// .background(aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value()) -// ) -// } -// -// item { -// PillBar( -// mutableListOf( -// PillMetaData( -// text = LocalContext.current.resources.getString(R.string.fluentui_neutral), -// onClick = { -// style = SnackbarStyle.Neutral -// }, -// selected = style == SnackbarStyle.Neutral -// ), -// PillMetaData( -// text = LocalContext.current.resources.getString(R.string.fluentui_contrast), -// onClick = { -// style = SnackbarStyle.Contrast -// }, -// selected = style == SnackbarStyle.Contrast -// ), -// PillMetaData( -// text = LocalContext.current.resources.getString(R.string.fluentui_accent), -// onClick = { -// style = SnackbarStyle.Accent -// }, -// selected = style == SnackbarStyle.Accent -// ), -// PillMetaData( -// text = LocalContext.current.resources.getString(R.string.fluentui_warning), -// onClick = { -// style = SnackbarStyle.Warning -// }, -// selected = style == SnackbarStyle.Warning -// ), -// PillMetaData( -// text = LocalContext.current.resources.getString(R.string.fluentui_danger), -// onClick = { -// style = SnackbarStyle.Danger -// }, -// selected = style == SnackbarStyle.Danger -// ) -// ), style = FluentStyle.Neutral, -// showBackground = true -// ) -// } -// -// item { -// ListItem.Item( -// text = LocalContext.current.resources.getString(R.string.fluentui_icon), -// subText = if (!icon) -// LocalContext.current.resources.getString(R.string.fluentui_disabled) -// else -// LocalContext.current.resources.getString(R.string.fluentui_enabled), -// trailingAccessoryContent = { -// ToggleSwitch( -// onValueChange = { -// icon = it -// }, -// checkedState = icon, -// modifier = Modifier.testTag(SNACK_BAR_ICON_PARAM) -// ) -// } -// ) -// } -// -// item { -// val subTitleText = -// LocalContext.current.resources.getString(R.string.fluentui_subtitle) -// ListItem.Item( -// text = subTitleText, -// subText = if (subtitle.isNullOrBlank()) -// LocalContext.current.resources.getString(R.string.fluentui_disabled) -// else -// LocalContext.current.resources.getString(R.string.fluentui_enabled), -// trailingAccessoryContent = { -// ToggleSwitch( -// onValueChange = { -// if (subtitle.isNullOrBlank()) { -// subtitle = subTitleText -// } else { -// subtitle = null -// } -// }, -// checkedState = !subtitle.isNullOrBlank(), -// modifier = Modifier.testTag(SNACK_BAR_SUBTITLE_PARAM) -// ) -// } -// ) -// } -// -// item { -// ListItem.Item( -// text = LocalContext.current.resources.getString(R.string.fluentui_action_button), -// subText = if (actionLabel) -// LocalContext.current.resources.getString(R.string.fluentui_disabled) -// else -// LocalContext.current.resources.getString(R.string.fluentui_enabled), -// trailingAccessoryContent = { -// ToggleSwitch( -// onValueChange = { -// actionLabel = it -// }, -// checkedState = actionLabel, -// modifier = Modifier.testTag(SNACK_BAR_ACTION_BUTTON_PARAM) -// ) -// } -// ) -// } -// -// item { -// ListItem.Item( -// text = LocalContext.current.resources.getString(R.string.fluentui_dismiss_button), -// subText = if (!dismissEnabled) -// LocalContext.current.resources.getString(R.string.fluentui_disabled) -// else -// LocalContext.current.resources.getString(R.string.fluentui_enabled), -// trailingAccessoryContent = { -// ToggleSwitch( -// onValueChange = { -// dismissEnabled = it -// }, -// checkedState = dismissEnabled, -// modifier = Modifier.testTag(SNACK_BAR_DISMISS_BUTTON_PARAM) -// ) -// } -// ) -// } -// } -// } -// -// Row( -// Modifier.fillMaxWidth(), -// horizontalArrangement = Arrangement.SpaceEvenly, -// verticalAlignment = Alignment.CenterVertically -// ) { -// val actionButtonString = -// LocalContext.current.resources.getString(R.string.fluentui_action_button) -// val dismissedString = -// LocalContext.current.resources.getString(R.string.fluentui_dismissed) -// val pressedString = -// LocalContext.current.resources.getString(R.string.fluentui_button_pressed) -// val timeoutString = -// LocalContext.current.resources.getString(R.string.fluentui_timeout) -// Button( -// onClick = { -// scope.launch { -// val result: NotificationResult = snackbarState.showSnackbar( -// "Hello from Fluent", -// style = style, -// icon = if (icon) FluentIcon(Icons.Outlined.ShoppingCart) else null, -// actionText = if (actionLabel) actionButtonString else null, -// subTitle = subtitle, -// duration = duration, -// enableDismiss = dismissEnabled, -// animationBehavior = customizedAnimationBehavior -// ) -// -// when (result) { -// NotificationResult.TIMEOUT -> Toast.makeText( -// context, -// timeoutString, -// Toast.LENGTH_SHORT -// ).show() -// -// NotificationResult.CLICKED -> Toast.makeText( -// context, -// pressedString, -// Toast.LENGTH_SHORT -// ).show() -// -// NotificationResult.DISMISSED -> Toast.makeText( -// context, -// dismissedString, -// Toast.LENGTH_SHORT -// ).show() -// } -// } -// }, -// text = LocalContext.current.resources.getString(R.string.fluentui_show_snackbar), -// size = ButtonSize.Small, -// style = ButtonStyle.OutlinedButton, -// modifier = Modifier.testTag(SNACK_BAR_SHOW_SNACKBAR) -// ) -// -// Button( -// onClick = { -// snackbarState.currentSnackbar?.dismiss(scope) -// }, -// text = LocalContext.current.resources.getString(R.string.fluentui_dismiss_snackbar), -// size = ButtonSize.Small, -// style = ButtonStyle.OutlinedButton, -// modifier = Modifier.testTag(SNACK_BAR_DISMISS_SNACKBAR) -// ) -// } -// Box(Modifier.fillMaxHeight(), contentAlignment = Alignment.Center) { -// Snackbar(snackbarState, Modifier.padding(bottom = 12.dp), null, true) -// } -// } + val snackbarState = remember { SnackbarState() } + + val scope = rememberCoroutineScope() + Column( + Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + var icon: Boolean by rememberSaveable { mutableStateOf(false) } + var actionLabel: Boolean by rememberSaveable { mutableStateOf(false) } + var subtitle: String? by rememberSaveable { mutableStateOf(null) } + var style: SnackbarStyle by rememberSaveable { mutableStateOf(SnackbarStyle.Neutral) } + var duration: NotificationDuration by rememberSaveable { + mutableStateOf( + NotificationDuration.SHORT + ) + } + var dismissEnabled by rememberSaveable { mutableStateOf(false) } + + ListItem.SectionHeader( + title = LocalContext.current.resources.getString(R.string.app_modifiable_parameters), + enableChevron = true, + enableContentOpenCloseTransition = true, + chevronOrientation = ChevronOrientation(90f, 0f), + modifier = Modifier.testTag(SNACK_BAR_MODIFIABLE_PARAMETER_SECTION) + ) { + LazyColumn(Modifier.fillMaxHeight(0.5F)) { + item { + PillBar( + mutableListOf( + PillMetaData( + text = LocalContext.current.resources.getString(R.string.fluentui_indefinite), + onClick = { + duration = NotificationDuration.INDEFINITE + }, + selected = duration == NotificationDuration.INDEFINITE + ), + PillMetaData( + text = LocalContext.current.resources.getString(R.string.fluentui_long), + onClick = { + duration = NotificationDuration.LONG + }, + selected = duration == NotificationDuration.LONG + ) + ), style = FluentStyle.Neutral, + showBackground = true + ) + } + item { + PillBar( + mutableListOf( + PillMetaData( + text = LocalContext.current.resources.getString(R.string.fluentui_indefinite), + onClick = { + duration = NotificationDuration.INDEFINITE + }, + selected = duration == NotificationDuration.INDEFINITE + ), + PillMetaData( + text = LocalContext.current.resources.getString(R.string.fluentui_long), + onClick = { + duration = NotificationDuration.LONG + }, + selected = duration == NotificationDuration.LONG + ), + PillMetaData( + text = LocalContext.current.resources.getString(R.string.fluentui_short), + onClick = { + duration = NotificationDuration.SHORT + }, + selected = duration == NotificationDuration.SHORT + ) + ), style = FluentStyle.Neutral, + showBackground = true + ) + } + + item { + Spacer( + Modifier + .height(8.dp) + .fillMaxWidth() + .background(aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value()) + ) + } + + item { + PillBar( + mutableListOf( + PillMetaData( + text = LocalContext.current.resources.getString(R.string.fluentui_neutral), + onClick = { + style = SnackbarStyle.Neutral + }, + selected = style == SnackbarStyle.Neutral + ), + PillMetaData( + text = LocalContext.current.resources.getString(R.string.fluentui_contrast), + onClick = { + style = SnackbarStyle.Contrast + }, + selected = style == SnackbarStyle.Contrast + ), + PillMetaData( + text = LocalContext.current.resources.getString(R.string.fluentui_accent), + onClick = { + style = SnackbarStyle.Accent + }, + selected = style == SnackbarStyle.Accent + ), + PillMetaData( + text = LocalContext.current.resources.getString(R.string.fluentui_warning), + onClick = { + style = SnackbarStyle.Warning + }, + selected = style == SnackbarStyle.Warning + ), + PillMetaData( + text = LocalContext.current.resources.getString(R.string.fluentui_danger), + onClick = { + style = SnackbarStyle.Danger + }, + selected = style == SnackbarStyle.Danger + ) + ), style = FluentStyle.Neutral, + showBackground = true + ) + } + + item { + ListItem.Item( + text = LocalContext.current.resources.getString(R.string.fluentui_icon), + subText = if (!icon) + LocalContext.current.resources.getString(R.string.fluentui_disabled) + else + LocalContext.current.resources.getString(R.string.fluentui_enabled), + trailingAccessoryContent = { + ToggleSwitch( + onValueChange = { + icon = it + }, + checkedState = icon, + modifier = Modifier.testTag(SNACK_BAR_ICON_PARAM) + ) + } + ) + } + + item { + val subTitleText = + LocalContext.current.resources.getString(R.string.fluentui_subtitle) + ListItem.Item( + text = subTitleText, + subText = if (subtitle.isNullOrBlank()) + LocalContext.current.resources.getString(R.string.fluentui_disabled) + else + LocalContext.current.resources.getString(R.string.fluentui_enabled), + trailingAccessoryContent = { + ToggleSwitch( + onValueChange = { + if (subtitle.isNullOrBlank()) { + subtitle = subTitleText + } else { + subtitle = null + } + }, + checkedState = !subtitle.isNullOrBlank(), + modifier = Modifier.testTag(SNACK_BAR_SUBTITLE_PARAM) + ) + } + ) + } + + item { + ListItem.Item( + text = LocalContext.current.resources.getString(R.string.fluentui_action_button), + subText = if (actionLabel) + LocalContext.current.resources.getString(R.string.fluentui_disabled) + else + LocalContext.current.resources.getString(R.string.fluentui_enabled), + trailingAccessoryContent = { + ToggleSwitch( + onValueChange = { + actionLabel = it + }, + checkedState = actionLabel, + modifier = Modifier.testTag(SNACK_BAR_ACTION_BUTTON_PARAM) + ) + } + ) + } + + item { + ListItem.Item( + text = LocalContext.current.resources.getString(R.string.fluentui_dismiss_button), + subText = if (!dismissEnabled) + LocalContext.current.resources.getString(R.string.fluentui_disabled) + else + LocalContext.current.resources.getString(R.string.fluentui_enabled), + trailingAccessoryContent = { + ToggleSwitch( + onValueChange = { + dismissEnabled = it + }, + checkedState = dismissEnabled, + modifier = Modifier.testTag(SNACK_BAR_DISMISS_BUTTON_PARAM) + ) + } + ) + } + } + } + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + val actionButtonString = + LocalContext.current.resources.getString(R.string.fluentui_action_button) + val dismissedString = + LocalContext.current.resources.getString(R.string.fluentui_dismissed) + val pressedString = + LocalContext.current.resources.getString(R.string.fluentui_button_pressed) + val timeoutString = + LocalContext.current.resources.getString(R.string.fluentui_timeout) + Button( + onClick = { + scope.launch { + val result: NotificationResult = snackbarState.showSnackbar( + "Hello from Fluent", + style = style, + icon = if (icon) FluentIcon(Icons.Outlined.ShoppingCart) else null, + actionText = if (actionLabel) actionButtonString else null, + subTitle = subtitle, + duration = duration, + enableDismiss = dismissEnabled, + animationBehavior = customizedAnimationBehavior + ) + + when (result) { + NotificationResult.TIMEOUT -> Toast.makeText( + context, + timeoutString, + Toast.LENGTH_SHORT + ).show() + + NotificationResult.CLICKED -> Toast.makeText( + context, + pressedString, + Toast.LENGTH_SHORT + ).show() + + NotificationResult.DISMISSED -> Toast.makeText( + context, + dismissedString, + Toast.LENGTH_SHORT + ).show() + } + } + }, + text = LocalContext.current.resources.getString(R.string.fluentui_show_snackbar), + size = ButtonSize.Small, + style = ButtonStyle.OutlinedButton, + modifier = Modifier.testTag(SNACK_BAR_SHOW_SNACKBAR) + ) + + Button( + onClick = { + snackbarState.currentSnackbar?.dismiss(scope) + }, + text = LocalContext.current.resources.getString(R.string.fluentui_dismiss_snackbar), + size = ButtonSize.Small, + style = ButtonStyle.OutlinedButton, + modifier = Modifier.testTag(SNACK_BAR_DISMISS_SNACKBAR) + ) + } + Box(Modifier.fillMaxHeight(), contentAlignment = Alignment.Center) { + Snackbar(snackbarState, Modifier.padding(bottom = 12.dp), null, true) + } + } } } } - -@RequiresApi(Build.VERSION_CODES.N) @Composable fun DemoCardStack() { val stackState = rememberSnackbarStackState() diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index a9321e9c2..3b50f101c 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -31,6 +31,11 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.times +import com.microsoft.fluentui.theme.FluentTheme +import com.microsoft.fluentui.theme.token.ControlTokens +import com.microsoft.fluentui.theme.token.controlTokens.SnackBarInfo +import com.microsoft.fluentui.theme.token.controlTokens.SnackBarTokens +import com.microsoft.fluentui.theme.token.controlTokens.SnackbarStyle import com.microsoft.fluentui.tokenized.controls.BasicCard import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex @@ -42,6 +47,10 @@ import kotlin.math.roundToInt private const val FADE_OUT_DURATION = 350 private const val STACKED_WIDTH_SCALE_FACTOR = 0.95f +//TODO: Make stack scrollable in expanded state +//TODO: Add accessibility support for the stack and individual cards +//TODO: Make dynamically sized cards based on content +//TODO: Make card owner of the hide/show animation states /** * Represents a single item within the Snackbar stack. * @@ -55,7 +64,9 @@ data class SnackbarItemModel( val id: String, val hidden: MutableState = mutableStateOf(false), val isReshown: MutableState = mutableStateOf(false), - val content: @Composable () -> Unit + val snackbarToken: SnackBarTokens = SnackBarTokens(), + val snackbarStyle: SnackbarStyle = SnackbarStyle.Neutral, + val content: @Composable () -> Unit, ) /** @@ -367,7 +378,6 @@ fun SnackbarStack( ) { state.snapshotStateList.forEachIndexed { index, snackbarModel -> val logicalIndex = state.size() - 1 - index - val isTop = logicalIndex == 0 key(snackbarModel.id) { SnackbarStackItem( @@ -376,7 +386,6 @@ fun SnackbarStack( isReshown = snackbarModel.isReshown.value, expanded = state.expanded, index = logicalIndex, - isTop = isTop, cardHeight = cardHeight, peekHeight = peekHeight, cardWidth = cardWidth, @@ -500,7 +509,6 @@ private fun SnackbarStackItem( isReshown: Boolean = false, expanded: Boolean, index: Int, - isTop: Boolean, cardWidth: Dp, cardHeight: Dp, peekHeight: Dp, @@ -510,7 +518,7 @@ private fun SnackbarStackItem( ) { val scope = rememberCoroutineScope() val localDensity = LocalDensity.current - + val isTop = index == 0 val targetYOffset = with(localDensity) { if (expanded) (index * (peekHeight + cardHeight)).toPx() else (index * peekHeight).toPx() } val animatedYOffset = remember { @@ -559,6 +567,9 @@ private fun SnackbarStackItem( val swipeX = remember { Animatable(0f) } val offsetX: Float = if (isTop || expanded) swipeX.value else 0f + val token = model.snackbarToken + val snackBarInfo = SnackBarInfo(model.snackbarStyle, false) + Box( modifier = Modifier .offset { @@ -623,9 +634,9 @@ private fun SnackbarStackItem( .fillMaxSize() .clip(RoundedCornerShape(12.dp)) .shadow( - elevation = 12.dp + elevation = token.shadowElevationValue(snackBarInfo) ) - .background(Color.Gray) + .background(token.backgroundBrush(snackBarInfo)) ) { model.content()