From 1547a08d9abdb5828ca0f23051bcf14349f03306 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Fri, 8 Aug 2025 11:57:40 +0530 Subject: [PATCH 01/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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() From 31d9750d8faf3b2b5e8aac4c738af70907be499b Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Sun, 14 Sep 2025 15:18:52 +0530 Subject: [PATCH 23/52] Added scrollable behavior in expanded state --- .../notification/StackableSnackbar.kt | 175 +++++++++++------- 1 file changed, 103 insertions(+), 72 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 3b50f101c..2bf5ea972 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 @@ -15,29 +15,30 @@ 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.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.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll 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.graphics.Color 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.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlin.math.abs @@ -82,7 +83,7 @@ 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 maxExpandedSize: Int = 20 ) { internal val snapshotStateList: MutableList = mutableStateListOf().apply { addAll(cards) } @@ -360,46 +361,67 @@ fun SnackbarStack( ) val toggleExpanded = remember<() -> Unit> { { state.toggleExpanded() } } - Box( - modifier = modifier - .width(cardWidth) - .height(if (state.size() == 0) 0.dp else animatedStackHeight) - .wrapContentHeight( - align = if (stackAbove) { - Alignment.Bottom - } else { - Alignment.Top + val scrollState = rememberScrollState() + + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val parentMaxHeight = this.maxHeight + val contentHeight = if (state.size() == 0) 0.dp else animatedStackHeight + + val visibleHeight = + if (state.expanded) minOf(contentHeight, parentMaxHeight) else contentHeight + + Box( + modifier = Modifier + .fillMaxWidth() + .height(visibleHeight) + .then(if (state.expanded) Modifier.verticalScroll(scrollState) else Modifier), + contentAlignment = Alignment.BottomCenter + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(contentHeight), + contentAlignment = Alignment.BottomCenter + ) { + state.snapshotStateList.forEachIndexed { index, snackbarModel -> + val logicalIndex = state.size() - 1 - index + + key(snackbarModel.id) { + SnackbarStackItem( + model = snackbarModel, + isHidden = snackbarModel.hidden.value, + isReshown = snackbarModel.isReshown.value, + expanded = state.expanded, + index = logicalIndex, + cardHeight = cardHeight, + peekHeight = peekHeight, + cardWidth = cardWidth, + onSwipedAway = { idToRemove -> + state.removeCardById(idToRemove) + state.showAt(listOf(0)) + }, + stackAbove = stackAbove, + onClick = { + toggleExpanded() + } + ) + } } - ) - .clickableWithTooltip( - onClick = toggleExpanded, - tooltipText = "Notification Stack", - ) - ) { - state.snapshotStateList.forEachIndexed { index, snackbarModel -> - val logicalIndex = state.size() - 1 - index - - key(snackbarModel.id) { - SnackbarStackItem( - model = snackbarModel, - isHidden = snackbarModel.hidden.value, - isReshown = snackbarModel.isReshown.value, - expanded = state.expanded, - index = logicalIndex, - cardHeight = cardHeight, - peekHeight = peekHeight, - cardWidth = cardWidth, - onSwipedAway = { idToRemove -> - state.removeCardById(idToRemove) - state.showAt(listOf(0)) - }, - stackAbove = stackAbove - ) + } + } + + LaunchedEffect(state.expanded, contentHeight, count) { + if (state.expanded) { + snapshotFlow { scrollState.maxValue } + .filter { it > 0 } + .first() + scrollState.animateScrollTo(scrollState.maxValue) } } } } + /** * Manages the vertical offset animation of a card when the stack's state changes. */ @@ -514,6 +536,7 @@ private fun SnackbarStackItem( peekHeight: Dp, stackedWidthScaleFactor: Float = STACKED_WIDTH_SCALE_FACTOR, onSwipedAway: (String) -> Unit, + onClick: () -> Unit = {}, stackAbove: Boolean = false ) { val scope = rememberCoroutineScope() @@ -585,49 +608,52 @@ private fun SnackbarStackItem( .width(cardWidth) .height(cardHeight) .padding(horizontal = 0.dp) - .then(if (isTop || expanded) Modifier.pointerInput(model.id) { - detectDragGestures( - onDragStart = {}, - onDragEnd = { - 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 + .then( + if (isTop || expanded) Modifier.pointerInput(model.id) { + detectHorizontalDragGestures( + onDragStart = {}, + onDragEnd = { + 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(model.id) - } else { + onSwipedAway(model.id) + } else { + swipeX.animateTo( + 0f, + animationSpec = spring(stiffness = Spring.StiffnessMedium) + ) + } + } + }, + onDragCancel = { + scope.launch { swipeX.animateTo( 0f, animationSpec = spring(stiffness = Spring.StiffnessMedium) ) } } - }, - onDragCancel = { - scope.launch { - swipeX.animateTo( - 0f, - animationSpec = spring(stiffness = Spring.StiffnessMedium) - ) - } - }, - onDrag = { change, dragAmount -> + ) { change, dragAmountX -> change.consume() scope.launch { - swipeX.snapTo(swipeX.value + dragAmount.x) + swipeX.snapTo(swipeX.value + dragAmountX) } } - ) - } else Modifier) + } else Modifier + ) + ) { BasicCard( modifier = Modifier @@ -637,6 +663,11 @@ private fun SnackbarStackItem( elevation = token.shadowElevationValue(snackBarInfo) ) .background(token.backgroundBrush(snackBarInfo)) + .clickable( + onClick = { + onClick() + } + ) ) { model.content() From ec6c97d4d502a95ce36ac1ab3299d8723e6ef4bb Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Sun, 14 Sep 2025 21:08:51 +0530 Subject: [PATCH 24/52] Using only single list as source of truth --- .../fluentuidemo/demos/V2SnackbarActivity.kt | 10 +- .../notification/StackableSnackbar.kt | 146 ++++++------------ 2 files changed, 57 insertions(+), 99 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 aa2871d02..b2b3fa7e4 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,6 +72,7 @@ class V2SnackbarActivity : V2DemoActivity() { val context = this setActivityContent { + DemoCardStack() val snackbarState = remember { SnackbarState() } val scope = rememberCoroutineScope() @@ -359,6 +360,7 @@ class V2SnackbarActivity : V2DemoActivity() { @Composable fun DemoCardStack() { val stackState = rememberSnackbarStackState() + var counter by rememberSaveable { mutableIntStateOf(0) } Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize(), @@ -377,8 +379,10 @@ fun DemoCardStack() { Row { Button(onClick = { - val id = UUID.randomUUID().toString() - stackState.addCard(SnackbarItemModel(id = id) { + // generate an id once per composition + val id = counter++ + + stackState.addCard(SnackbarItemModel(id = id.toString()) { Column(modifier = Modifier.padding(12.dp)) { BasicText("Card: $id") BasicText("Some detail here") @@ -390,7 +394,7 @@ fun DemoCardStack() { Button(onClick = { stackState.popFront() - stackState.showAt(listOf(0)) + stackState.showBack() }, text = "Remove top card") Spacer(modifier = Modifier.width(12.dp)) 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 2bf5ea972..17ada9368 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 @@ -87,7 +87,6 @@ class SnackbarStackState( ) { internal val snapshotStateList: MutableList = mutableStateListOf().apply { addAll(cards) } - internal val hiddenIndicesList: MutableList> = mutableListOf() internal var expanded by mutableStateOf(false) private val listOperationMutex = Mutex() @@ -145,9 +144,15 @@ class SnackbarStackState( 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) + val cardsToShow = mutableListOf() + var leftoverSlots = maxSize - currentSize + snapshotStateList.reversed().forEach { card -> + if (card.hidden.value && leftoverSlots > 0) { + cardsToShow.add(card) + leftoverSlots-- + } + } + showAtParallel(cardsToShow = cardsToShow) } } } @@ -158,10 +163,10 @@ class SnackbarStackState( * @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 } - if (index != -1) { - hideAtSingle(index, remove = remove) + snapshotStateList.firstOrNull { !it.hidden.value }?.let { + it.hidden.value = true + if(remove){ + snapshotStateList.remove(it) } } } @@ -171,21 +176,18 @@ class SnackbarStackState( * @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 } - if (index != -1) { - hideAtSingle(index, remove = remove) + snapshotStateList.lastOrNull { !it.hidden.value }?.let { + it.hidden.value = true + if(remove){ + snapshotStateList.remove(it) } } } - /** - * 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 { - showAtParallel(indices) + fun showBack() { + snapshotStateList.lastOrNull { it.hidden.value }?.let { + it.isReshown.value = true + it.hidden.value = false } } @@ -194,88 +196,37 @@ class SnackbarStackState( */ fun showAll() { scope.launch { - val indicesToShow = (0 until hiddenIndicesList.size).toList() - showAtParallel(indices = indicesToShow) + snapshotStateList.forEach { + it.isReshown.value = true + it.hidden.value = false + } } } /** * Shows cards in parallel for smooth animation. */ - private suspend fun showAtParallel(indices: List) { - val cardsToShow = mutableListOf>() - + private suspend fun showAtParallel(cardsToShow: List) { 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) - } - } - } - - cardsToShow.forEach { (_, card) -> + cardsToShow.forEach { card -> card.isReshown.value = true - snapshotStateList.add(0, card) card.hidden.value = false } } - - coroutineScope { - cardsToShow.map { (idx, card) -> - launch { - delay(FADE_OUT_DURATION.toLong()) - card.isReshown.value = false - listOperationMutex.withLock { - val iterator = hiddenIndicesList.iterator() - while (iterator.hasNext()) { - val item = iterator.next() - if (item.second.id == card.id) { - iterator.remove() - } - } - } - } - } - } - } - - /** - * Hides a single card (sequential operation). - */ - private suspend fun hideAtSingle(index: Int, remove: Boolean) { - if (index in snapshotStateList.indices) { - val card = snapshotStateList[index] - if (!card.hidden.value) { - card.hidden.value = true - - delay(FADE_OUT_DURATION.toLong()) - - listOperationMutex.withLock { - if (remove) { - snapshotStateList.remove(card) - } else { - hiddenIndicesList.add(index to card) - snapshotStateList.remove(card) - } - } - } - } } /** * Hides cards in parallel for smooth animation. */ private suspend fun hideAtParallel(indices: List, remove: Boolean) { - val cardsToHide = mutableListOf>() + val cardsToHide = mutableListOf() listOperationMutex.withLock { indices.forEach { idx -> if (idx in snapshotStateList.indices) { val card = snapshotStateList[idx] if (!card.hidden.value) { - cardsToHide.add(idx to card) + cardsToHide.add(card) card.hidden.value = true } } @@ -286,14 +237,9 @@ class SnackbarStackState( delay(FADE_OUT_DURATION.toLong()) listOperationMutex.withLock { - cardsToHide.forEach { (idx, card) -> + cardsToHide.forEach { card -> if (remove) { snapshotStateList.remove(card) - } else { - if (!hiddenIndicesList.any { it.second.id == card.id }) { - hiddenIndicesList.add(idx to card) - } - snapshotStateList.remove(card) } } } @@ -356,7 +302,7 @@ fun SnackbarStack( } val animatedStackHeight by animateDpAsState( - targetValue = targetHeight, + targetValue = targetHeight + cardHeight, animationSpec = spring(stiffness = Spring.StiffnessMedium) ) @@ -383,9 +329,13 @@ fun SnackbarStack( .height(contentHeight), contentAlignment = Alignment.BottomCenter ) { - state.snapshotStateList.forEachIndexed { index, snackbarModel -> - val logicalIndex = state.size() - 1 - index - + val totalVisibleCards = state.snapshotStateList.count { !it.hidden.value } + var visibleIndex = 0; + state.snapshotStateList.forEach { snackbarModel -> + var logicalIndex = 0; + if (!snackbarModel.hidden.value) { + logicalIndex = totalVisibleCards - 1 - visibleIndex++ + } key(snackbarModel.id) { SnackbarStackItem( model = snackbarModel, @@ -398,7 +348,7 @@ fun SnackbarStack( cardWidth = cardWidth, onSwipedAway = { idToRemove -> state.removeCardById(idToRemove) - state.showAt(listOf(0)) + state.showBack() }, stackAbove = stackAbove, onClick = { @@ -580,12 +530,16 @@ private fun SnackbarStackItem( slideInProgress = slideInProgress ) - val opacityProgress = remember { Animatable(1f) } - HideAnimation( - isHidden = isHidden, - isReshown = isReshown, - opacityProgress = opacityProgress - ) +// val opacityProgress = remember { Animatable(1f) } +// HideAnimation( +// isHidden = isHidden, +// isReshown = isReshown, +// opacityProgress = opacityProgress +// ) + val opacityProgress = animateFloatAsState( + targetValue = if (isHidden) 0f else 1f, + animationSpec = tween(durationMillis = FADE_OUT_DURATION) + ).value val swipeX = remember { Animatable(0f) } val offsetX: Float = if (isTop || expanded) swipeX.value else 0f @@ -602,7 +556,7 @@ private fun SnackbarStackItem( ) } .graphicsLayer( - alpha = opacityProgress.value, + alpha = opacityProgress, scaleX = animatedWidth.value / with(localDensity) { cardWidth.toPx() }, ) .width(cardWidth) From bfca4be1f5964a6f0ff0dd5cf9c588890dab4119 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Sun, 14 Sep 2025 21:24:31 +0530 Subject: [PATCH 25/52] Minor improvements --- .../notification/StackableSnackbar.kt | 31 ++++++------------- 1 file changed, 10 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 17ada9368..f09749de2 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 @@ -88,7 +88,7 @@ class SnackbarStackState( internal val snapshotStateList: MutableList = mutableStateListOf().apply { addAll(cards) } internal var expanded by mutableStateOf(false) - + private val maxSize = if (expanded) maxExpandedSize else maxCollapsedSize private val listOperationMutex = Mutex() private val expandMutex = Mutex() @@ -99,17 +99,9 @@ class SnackbarStackState( * @param card The [SnackbarItemModel] to add. */ fun addCard(card: SnackbarItemModel) { - scope.launch { - listOperationMutex.withLock { - snapshotStateList.add(card) - } - - val maxSize = if (expanded) maxExpandedSize else maxCollapsedSize - val visibleCount = snapshotStateList.count { !it.hidden.value } - - if (visibleCount > maxSize) { - popBack(remove = false) - } + snapshotStateList.add(card) + if (sizeVisible() > maxSize) { + popBack(remove = false) } } @@ -118,13 +110,8 @@ class SnackbarStackState( * @param id The id of the card to remove. */ fun removeCardById(id: String) { - scope.launch { - listOperationMutex.withLock { - val index = snapshotStateList.indexOfFirst { it.id == id } - if (index != -1) { - snapshotStateList.removeAt(index) - } - } + snapshotStateList.firstOrNull { it.id == id }?.let { + snapshotStateList.remove(it) } } @@ -165,7 +152,7 @@ class SnackbarStackState( fun popBack(remove: Boolean = true) { snapshotStateList.firstOrNull { !it.hidden.value }?.let { it.hidden.value = true - if(remove){ + if (remove) { snapshotStateList.remove(it) } } @@ -178,7 +165,7 @@ class SnackbarStackState( fun popFront(remove: Boolean = true) { snapshotStateList.lastOrNull { !it.hidden.value }?.let { it.hidden.value = true - if(remove){ + if (remove) { snapshotStateList.remove(it) } } @@ -250,6 +237,8 @@ class SnackbarStackState( * Returns the current number of visible cards in the stack. */ fun size(): Int = snapshotStateList.size + + fun sizeVisible(): Int = snapshotStateList.count { !it.hidden.value } } /** From 1993efb5fc084bc36e8a5897be8251d02a031526 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Sun, 14 Sep 2025 23:25:05 +0530 Subject: [PATCH 26/52] More improvements --- .../notification/StackableSnackbar.kt | 241 +++++------------- 1 file changed, 69 insertions(+), 172 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 f09749de2..5a289c69a 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 @@ -64,7 +64,6 @@ private const val STACKED_WIDTH_SCALE_FACTOR = 0.95f data class SnackbarItemModel( val id: String, val hidden: MutableState = mutableStateOf(false), - val isReshown: MutableState = mutableStateOf(false), val snackbarToken: SnackBarTokens = SnackBarTokens(), val snackbarStyle: SnackbarStyle = SnackbarStyle.Neutral, val content: @Composable () -> Unit, @@ -87,11 +86,11 @@ class SnackbarStackState( ) { internal val snapshotStateList: MutableList = mutableStateListOf().apply { addAll(cards) } + internal var expanded by mutableStateOf(false) - private val maxSize = if (expanded) maxExpandedSize else maxCollapsedSize - private val listOperationMutex = Mutex() + private set - private val expandMutex = Mutex() + internal var maxCurrentSize = maxCollapsedSize /** * Adds a new snackbar card to the top of the stack. @@ -100,7 +99,7 @@ class SnackbarStackState( */ fun addCard(card: SnackbarItemModel) { snapshotStateList.add(card) - if (sizeVisible() > maxSize) { + if (sizeVisible() > maxCurrentSize) { popBack(remove = false) } } @@ -119,24 +118,35 @@ class SnackbarStackState( * Toggles the stack between its collapsed and expanded states. * It automatically handles showing or hiding cards to match the respective size limits. */ + private val expandMutex = Mutex() fun toggleExpanded() { 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 - expanded = !currentExpanded - if (currentSize > maxSize) { - val indicesToHide = (0 until (currentSize - maxSize)).toList() - hideAtParallel(indices = indicesToHide, remove = false) + val currentSize = snapshotStateList.count { !it.hidden.value } + expanded = !expanded + maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize + + if (currentSize > maxCurrentSize) { + val cardsToHide = mutableListOf() + val extraCards = currentSize - maxCurrentSize + snapshotStateList.forEach { + if (cardsToHide.size >= extraCards) { + return@forEach + } + if (!it.hidden.value && cardsToHide.size < extraCards) { + cardsToHide.add(it) + } + } + hideAtParallel(cardsToHide = cardsToHide, remove = false) } else { val cardsToShow = mutableListOf() - var leftoverSlots = maxSize - currentSize + var leftoverSlots = maxCurrentSize - currentSize snapshotStateList.reversed().forEach { card -> - if (card.hidden.value && leftoverSlots > 0) { + if (cardsToShow.size >= leftoverSlots) { + return@forEach + } + if (card.hidden.value && cardsToShow.size < leftoverSlots) { cardsToShow.add(card) - leftoverSlots-- } } showAtParallel(cardsToShow = cardsToShow) @@ -173,7 +183,6 @@ class SnackbarStackState( fun showBack() { snapshotStateList.lastOrNull { it.hidden.value }?.let { - it.isReshown.value = true it.hidden.value = false } } @@ -184,7 +193,6 @@ class SnackbarStackState( fun showAll() { scope.launch { snapshotStateList.forEach { - it.isReshown.value = true it.hidden.value = false } } @@ -193,41 +201,25 @@ class SnackbarStackState( /** * Shows cards in parallel for smooth animation. */ - private suspend fun showAtParallel(cardsToShow: List) { - listOperationMutex.withLock { - cardsToShow.forEach { card -> - card.isReshown.value = true - card.hidden.value = false - } + private fun showAtParallel(cardsToShow: List) { + cardsToShow.forEach { card -> + card.hidden.value = false } } /** * Hides cards in parallel for smooth animation. */ - private suspend fun hideAtParallel(indices: List, remove: Boolean) { - val cardsToHide = mutableListOf() - - listOperationMutex.withLock { - indices.forEach { idx -> - if (idx in snapshotStateList.indices) { - val card = snapshotStateList[idx] - if (!card.hidden.value) { - cardsToHide.add(card) - card.hidden.value = true - } - } - } + private suspend fun hideAtParallel(cardsToHide: List, remove: Boolean) { + cardsToHide.forEach { + it.hidden.value = true } if (cardsToHide.isNotEmpty()) { delay(FADE_OUT_DURATION.toLong()) - - listOperationMutex.withLock { - cardsToHide.forEach { card -> - if (remove) { - snapshotStateList.remove(card) - } + cardsToHide.forEach { card -> + if (remove) { + snapshotStateList.remove(card) } } } @@ -276,14 +268,14 @@ fun SnackbarStack( stackAbove: Boolean = true, contentModifier: Modifier = Modifier ) { - val count by remember { derivedStateOf { state.size() } } + val count by remember { derivedStateOf { state.sizeVisible() } } val targetHeight by remember { derivedStateOf { if (count == 0) { 0.dp } else if (state.expanded) { - cardHeight * count + (count - 1) * peekHeight + cardHeight * (count + 1) + (count - 1) * peekHeight } else { cardHeight + (count - 1) * peekHeight } @@ -291,11 +283,11 @@ fun SnackbarStack( } val animatedStackHeight by animateDpAsState( - targetValue = targetHeight + cardHeight, + targetValue = targetHeight, animationSpec = spring(stiffness = Spring.StiffnessMedium) ) - val toggleExpanded = remember<() -> Unit> { { state.toggleExpanded() } } + val toggleExpanded = remember { { state.toggleExpanded() } } val scrollState = rememberScrollState() BoxWithConstraints(modifier = modifier.fillMaxWidth()) { @@ -318,18 +310,17 @@ fun SnackbarStack( .height(contentHeight), contentAlignment = Alignment.BottomCenter ) { - val totalVisibleCards = state.snapshotStateList.count { !it.hidden.value } - var visibleIndex = 0; - state.snapshotStateList.forEach { snackbarModel -> - var logicalIndex = 0; - if (!snackbarModel.hidden.value) { + val totalVisibleCards = state.sizeVisible() + var visibleIndex = 0 + state.snapshotStateList.forEach { snackBarModel -> + var logicalIndex = state.maxCurrentSize + if (!snackBarModel.hidden.value) { logicalIndex = totalVisibleCards - 1 - visibleIndex++ } - key(snackbarModel.id) { + key(snackBarModel.id) { SnackbarStackItem( - model = snackbarModel, - isHidden = snackbarModel.hidden.value, - isReshown = snackbarModel.isReshown.value, + model = snackBarModel, + isHidden = snackBarModel.hidden.value, expanded = state.expanded, index = logicalIndex, cardHeight = cardHeight, @@ -348,15 +339,6 @@ fun SnackbarStack( } } } - - LaunchedEffect(state.expanded, contentHeight, count) { - if (state.expanded) { - snapshotFlow { scrollState.maxValue } - .filter { it > 0 } - .first() - scrollState.animateScrollTo(scrollState.maxValue) - } - } } } @@ -367,13 +349,12 @@ fun SnackbarStack( @Composable private fun CardAdjustAnimation( expanded: Boolean, - isReshown: Boolean = false, index: Int, stackAbove: Boolean = true, targetYOffset: Float, animatedYOffset: Animatable ) { - LaunchedEffect(index, expanded, isReshown) { + LaunchedEffect(index, expanded) { animatedYOffset.animateTo( targetYOffset * (if (stackAbove) -1f else 1f), animationSpec = spring(stiffness = Spring.StiffnessLow) @@ -381,84 +362,6 @@ private fun CardAdjustAnimation( } } -/** - * Manages the width animation of a card when the stack's state changes. - */ -@Composable -private fun CardWidthAnimation( - expanded: Boolean, - index: Int, - animatedWidth: Animatable, - targetWidth: Float -) { - LaunchedEffect(index, expanded) { - animatedWidth.animateTo( - targetWidth, - animationSpec = spring(stiffness = Spring.StiffnessLow) - ) - } -} - -/** - * Manages the initial slide-in animation for a new card. - */ -@Composable -private fun SlideInAnimation( - model: SnackbarItemModel, - isReshown: Boolean = false, - isTop: Boolean = true, - slideInProgress: Animatable -) { - LaunchedEffect(model.id) { - if (isReshown) { - slideInProgress.snapTo(0f) - } else { - if (isTop) { - slideInProgress.snapTo(1f) - slideInProgress.animateTo( - 0f, - animationSpec = tween(durationMillis = 350, easing = FastOutSlowInEasing) - ) - } else { - slideInProgress.snapTo(0f) - } - } - } -} - -/** - * Manages the fade-in/fade-out animation when a card is hidden or reshown. - */ -@Composable -private fun HideAnimation( - isHidden: Boolean = false, - isReshown: Boolean = false, - opacityProgress: Animatable -) { - LaunchedEffect(isHidden, isReshown) { - if (isHidden) { - opacityProgress.snapTo(1f) - opacityProgress.animateTo( - 0f, - animationSpec = tween( - durationMillis = FADE_OUT_DURATION, - easing = FastOutSlowInEasing - ) - ) - } - if (isReshown) { - opacityProgress.snapTo(0f) - opacityProgress.animateTo( - 1f, - animationSpec = tween( - durationMillis = FADE_OUT_DURATION, - easing = LinearOutSlowInEasing - ) - ) - } - } -} - /** * A private composable that represents a single, animatable card within the [SnackbarStack]. * It handles its own animations for position, width, opacity, and swipe gestures. @@ -467,7 +370,6 @@ private fun HideAnimation( private fun SnackbarStackItem( model: SnackbarItemModel, isHidden: Boolean, - isReshown: Boolean = false, expanded: Boolean, index: Int, cardWidth: Dp, @@ -488,7 +390,6 @@ private fun SnackbarStackItem( } CardAdjustAnimation( expanded = expanded, - isReshown = isReshown, index = index, stackAbove = stackAbove, targetYOffset = targetYOffset, @@ -498,37 +399,33 @@ private fun SnackbarStackItem( val targetWidth = with(localDensity) { if (expanded) { cardWidth.toPx() + } else if (isHidden) { + cardWidth.toPx() * stackedWidthScaleFactor.pow(index) } else { cardWidth.toPx() * stackedWidthScaleFactor.pow(index) } } - val animatedWidth = remember { Animatable(targetWidth) } - CardWidthAnimation( - expanded = expanded, - index = index, - animatedWidth = animatedWidth, - targetWidth = targetWidth + val animatedWidth = animateFloatAsState( + targetValue = targetWidth, + animationSpec = tween(durationMillis = 100, easing = FastOutSlowInEasing) ) - val slideInProgress = - remember { Animatable(if (isReshown) 0f else 1f) } - SlideInAnimation( - model = model, - isReshown = isReshown, - isTop = isTop, - slideInProgress = slideInProgress - ) + val slideInProgress = remember { Animatable(1f) } + LaunchedEffect(Unit) { + slideInProgress.animateTo( + 0f, + animationSpec = tween(durationMillis = 350, easing = FastOutSlowInEasing) + ) + } -// val opacityProgress = remember { Animatable(1f) } -// HideAnimation( -// isHidden = isHidden, -// isReshown = isReshown, -// opacityProgress = opacityProgress -// ) - val opacityProgress = animateFloatAsState( - targetValue = if (isHidden) 0f else 1f, - animationSpec = tween(durationMillis = FADE_OUT_DURATION) - ).value + val opacityProgress = remember { Animatable(0f) } + LaunchedEffect(isHidden) { + if (isHidden) { + opacityProgress.animateTo(0f, tween(FADE_OUT_DURATION)) + } else { + opacityProgress.animateTo(1f, tween(FADE_OUT_DURATION)) + } + } val swipeX = remember { Animatable(0f) } val offsetX: Float = if (isTop || expanded) swipeX.value else 0f @@ -545,7 +442,7 @@ private fun SnackbarStackItem( ) } .graphicsLayer( - alpha = opacityProgress, + alpha = opacityProgress.value, scaleX = animatedWidth.value / with(localDensity) { cardWidth.toPx() }, ) .width(cardWidth) From e8951f071e49bec0e836e3aa1fbed0e2c942f90e Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Sun, 14 Sep 2025 23:35:23 +0530 Subject: [PATCH 27/52] Added Pop in Animation, Changed Card Order --- .../notification/StackableSnackbar.kt | 44 ++++++------------- 1 file changed, 13 insertions(+), 31 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 5a289c69a..77e5a4e7c 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 @@ -88,7 +88,7 @@ class SnackbarStackState( mutableStateListOf().apply { addAll(cards) } internal var expanded by mutableStateOf(false) - private set + private set internal var maxCurrentSize = maxCollapsedSize @@ -314,6 +314,7 @@ fun SnackbarStack( var visibleIndex = 0 state.snapshotStateList.forEach { snackBarModel -> var logicalIndex = state.maxCurrentSize + val invertedLogicalIndex = visibleIndex if (!snackBarModel.hidden.value) { logicalIndex = totalVisibleCards - 1 - visibleIndex++ } @@ -323,6 +324,7 @@ fun SnackbarStack( isHidden = snackBarModel.hidden.value, expanded = state.expanded, index = logicalIndex, + invertedIndex = invertedLogicalIndex, cardHeight = cardHeight, peekHeight = peekHeight, cardWidth = cardWidth, @@ -342,26 +344,6 @@ fun SnackbarStack( } } - -/** - * Manages the vertical offset animation of a card when the stack's state changes. - */ -@Composable -private fun CardAdjustAnimation( - expanded: Boolean, - index: Int, - stackAbove: Boolean = true, - targetYOffset: Float, - animatedYOffset: Animatable -) { - LaunchedEffect(index, expanded) { - animatedYOffset.animateTo( - targetYOffset * (if (stackAbove) -1f else 1f), - animationSpec = spring(stiffness = Spring.StiffnessLow) - ) - } -} - /** * A private composable that represents a single, animatable card within the [SnackbarStack]. * It handles its own animations for position, width, opacity, and swipe gestures. @@ -372,6 +354,7 @@ private fun SnackbarStackItem( isHidden: Boolean, expanded: Boolean, index: Int, + invertedIndex: Int, cardWidth: Dp, cardHeight: Dp, peekHeight: Dp, @@ -384,17 +367,16 @@ private fun SnackbarStackItem( val localDensity = LocalDensity.current val isTop = index == 0 val targetYOffset = - with(localDensity) { if (expanded) (index * (peekHeight + cardHeight)).toPx() else (index * peekHeight).toPx() } + with(localDensity) { if (expanded) (invertedIndex * (peekHeight + cardHeight)).toPx() else (index * peekHeight).toPx() } val animatedYOffset = remember { - Animatable(0f) + Animatable(with(localDensity) { cardHeight.toPx() * if (stackAbove) 1f else -1f }) + } + LaunchedEffect(index, expanded) { + animatedYOffset.animateTo( + targetYOffset * (if (stackAbove) -1f else 1f), + animationSpec = spring(stiffness = Spring.StiffnessLow) + ) } - CardAdjustAnimation( - expanded = expanded, - index = index, - stackAbove = stackAbove, - targetYOffset = targetYOffset, - animatedYOffset = animatedYOffset - ) val targetWidth = with(localDensity) { if (expanded) { @@ -437,7 +419,7 @@ private fun SnackbarStackItem( modifier = Modifier .offset { IntOffset( - offsetX.roundToInt() + (slideInProgress.value * with(localDensity) { 200.dp.toPx() }).roundToInt(), + offsetX.roundToInt() ,//+ (slideInProgress.value * with(localDensity) { 200.dp.toPx() }).roundToInt(), animatedYOffset.value.roundToInt() ) } From 26719514c294e2ec50bba542c8fecfe06405ed6b Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Sun, 14 Sep 2025 23:40:00 +0530 Subject: [PATCH 28/52] Minor fix --- .../fluentui/tokenized/notification/StackableSnackbar.kt | 2 +- 1 file changed, 1 insertion(+), 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 77e5a4e7c..ea8534198 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 @@ -371,7 +371,7 @@ private fun SnackbarStackItem( val animatedYOffset = remember { Animatable(with(localDensity) { cardHeight.toPx() * if (stackAbove) 1f else -1f }) } - LaunchedEffect(index, expanded) { + LaunchedEffect(index, expanded, invertedIndex) { animatedYOffset.animateTo( targetYOffset * (if (stackAbove) -1f else 1f), animationSpec = spring(stiffness = Spring.StiffnessLow) From c3d1395d0fdff9e9ccbed0352aa15ccc36c39ca7 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Mon, 15 Sep 2025 14:26:27 +0530 Subject: [PATCH 29/52] Added configuration --- .../fluentuidemo/demos/V2SnackbarActivity.kt | 20 +- .../notification/StackableSnackbar.kt | 254 +++++++++++------- 2 files changed, 165 insertions(+), 109 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 b2b3fa7e4..a10ff0296 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 @@ -39,12 +39,14 @@ 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.SnackbarStackConfig import com.microsoft.fluentui.tokenized.notification.SnackbarState 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 import com.microsoft.fluentuidemo.V2DemoActivity +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.UUID @@ -361,6 +363,7 @@ class V2SnackbarActivity : V2DemoActivity() { fun DemoCardStack() { val stackState = rememberSnackbarStackState() var counter by rememberSaveable { mutableIntStateOf(0) } + val scope = rememberCoroutineScope() Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize(), @@ -369,17 +372,12 @@ fun DemoCardStack() { SnackbarStack( 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 = { - // generate an id once per composition val id = counter++ stackState.addCard(SnackbarItemModel(id = id.toString()) { @@ -393,15 +391,19 @@ fun DemoCardStack() { Spacer(modifier = Modifier.width(12.dp)) Button(onClick = { - stackState.popFront() - stackState.showBack() + scope.launch { + stackState.hideFront() + delay(300) + stackState.removeFront() + stackState.showBack() + } }, text = "Remove top card") Spacer(modifier = Modifier.width(12.dp)) Button(onClick = { - stackState.showAll() - }, text = "Show hidden cards") + stackState.showBack() + }, text = "Show last hidden card") } } } 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 ea8534198..b0f960470 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,6 @@ package com.microsoft.fluentui.tokenized.notification +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box @@ -37,10 +38,6 @@ 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.flow.filter -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlin.math.abs import kotlin.math.pow import kotlin.math.roundToInt @@ -48,10 +45,8 @@ 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. * @@ -80,9 +75,8 @@ data class SnackbarItemModel( */ class SnackbarStackState( internal val cards: MutableList, - internal val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main), - internal val maxCollapsedSize: Int = 3, - internal val maxExpandedSize: Int = 20 + internal var maxCollapsedSize: Int = 5, + internal var maxExpandedSize: Int = 10 ) { internal val snapshotStateList: MutableList = mutableStateListOf().apply { addAll(cards) } @@ -98,138 +92,167 @@ class SnackbarStackState( * @param card The [SnackbarItemModel] to add. */ fun addCard(card: SnackbarItemModel) { + maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize snapshotStateList.add(card) if (sizeVisible() > maxCurrentSize) { - popBack(remove = false) + hideBack() } } /** * Removes a snackbar card from the stack by its unique [id]. * @param id The id of the card to remove. + * @return `true` if the card was found and removed, `false` otherwise. + * DOES NOT ANIMATE THE REMOVAL, JUST REMOVES IT IMMEDIATELY */ - fun removeCardById(id: String) { + fun removeCardById(id: String): Boolean { snapshotStateList.firstOrNull { it.id == id }?.let { snapshotStateList.remove(it) + return true } + return false + } + + /** + * Hides a specific card in the stack by its unique [id]. + * @param id The id of the card to hide. + * @return `true` if the card was found and hidden, `false` otherwise. + */ + fun hideCardById(id: String): Boolean { + snapshotStateList.firstOrNull { it.id == id }?.let { + it.hidden.value = true + return true + } + return false + } + + /** + * Shows a specific card in the stack by its unique [id]. + * @param id The id of the card to show. + * @return `true` if the card was found and shown, `false` otherwise. + */ + fun showCardById(id: String): Boolean { + snapshotStateList.firstOrNull { it.id == id }?.let { + it.hidden.value = false + return true + } + return false } /** * Toggles the stack between its collapsed and expanded states. * It automatically handles showing or hiding cards to match the respective size limits. */ - private val expandMutex = Mutex() fun toggleExpanded() { - scope.launch { - expandMutex.withLock { - val currentSize = snapshotStateList.count { !it.hidden.value } - expanded = !expanded - maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize - - if (currentSize > maxCurrentSize) { - val cardsToHide = mutableListOf() - val extraCards = currentSize - maxCurrentSize - snapshotStateList.forEach { - if (cardsToHide.size >= extraCards) { - return@forEach - } - if (!it.hidden.value && cardsToHide.size < extraCards) { - cardsToHide.add(it) - } - } - hideAtParallel(cardsToHide = cardsToHide, remove = false) - } else { - val cardsToShow = mutableListOf() - var leftoverSlots = maxCurrentSize - currentSize - snapshotStateList.reversed().forEach { card -> - if (cardsToShow.size >= leftoverSlots) { - return@forEach - } - if (card.hidden.value && cardsToShow.size < leftoverSlots) { - cardsToShow.add(card) - } - } - showAtParallel(cardsToShow = cardsToShow) - } + val currentSize = snapshotStateList.count { !it.hidden.value } + expanded = !expanded + maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize + val (count, sequence, targetHidden) = + if (currentSize > maxCurrentSize) { + Triple(currentSize - maxCurrentSize, snapshotStateList, true) + } else { + Triple(maxCurrentSize - currentSize, snapshotStateList.asReversed(), false) + } + + var slots = count + sequence.forEach { + if (slots <= 0) return@forEach + if (it.hidden.value != targetHidden) { + it.hidden.value = targetHidden + slots-- } } } /** * 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. + * @return `true` if a card was hidden, `false` if there were no visible cards. */ - fun popBack(remove: Boolean = true) { + fun hideBack(): Boolean { snapshotStateList.firstOrNull { !it.hidden.value }?.let { it.hidden.value = true - if (remove) { - snapshotStateList.remove(it) - } + return true } + return false } /** - * 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. + * Hides the newest visible card in the stack (the one at the top). + * @return `true` if a card was hidden, `false` if there were no visible cards. */ - fun popFront(remove: Boolean = true) { + fun hideFront(): Boolean { snapshotStateList.lastOrNull { !it.hidden.value }?.let { it.hidden.value = true - if (remove) { - snapshotStateList.remove(it) - } + return true } + return false } - fun showBack() { - snapshotStateList.lastOrNull { it.hidden.value }?.let { - it.hidden.value = false + /** + * Removes the oldest visible card from the stack (the one at the bottom). + * @return `true` if a card was removed, `false` if there were no visible cards. + */ + fun removeBack(skipHidden: Boolean = false): Boolean { + snapshotStateList.firstOrNull { skipHidden && !it.hidden.value || !skipHidden }?.let { + snapshotStateList.remove(it) + return true } + return false } /** - * Reveals all previously hidden cards. + * Removes the newest visible card from the stack (the one at the top). + * @return `true` if a card was removed, `false` if there were no visible cards. */ - fun showAll() { - scope.launch { - snapshotStateList.forEach { - it.hidden.value = false - } + fun removeFront(skipHidden: Boolean = false): Boolean { + snapshotStateList.lastOrNull { skipHidden && !it.hidden.value || !skipHidden }?.let { + snapshotStateList.remove(it) + return true } + return false } /** - * Shows cards in parallel for smooth animation. + * Reveals the oldest hidden card in the stack (the one at the bottom). + * @return `true` if a card was revealed, `false` if there were no hidden cards. */ - private fun showAtParallel(cardsToShow: List) { - cardsToShow.forEach { card -> - card.hidden.value = false + fun showBack(): Boolean { + snapshotStateList.lastOrNull { it.hidden.value }?.let { + it.hidden.value = false + return true } + return false } /** - * Hides cards in parallel for smooth animation. + * Reveals the newest hidden card in the stack (the one at the top). + * @return `true` if a card was revealed, `false` if there were no hidden cards. */ - private suspend fun hideAtParallel(cardsToHide: List, remove: Boolean) { - cardsToHide.forEach { - it.hidden.value = true + fun showFront(): Boolean { + snapshotStateList.firstOrNull { it.hidden.value }?.let { + it.hidden.value = false + return true } + return false + } - if (cardsToHide.isNotEmpty()) { - delay(FADE_OUT_DURATION.toLong()) - cardsToHide.forEach { card -> - if (remove) { - snapshotStateList.remove(card) - } - } + /** + * Reveals all previously hidden cards. + */ + fun showAll() { + snapshotStateList.forEach { + it.hidden.value = false } } /** - * Returns the current number of visible cards in the stack. + * Returns the current number of cards in the stack. */ fun size(): Int = snapshotStateList.size + /** + * Returns the current number of visible cards in the stack. + */ fun sizeVisible(): Int = snapshotStateList.count { !it.hidden.value } } @@ -241,10 +264,30 @@ class SnackbarStackState( * @return A remembered [SnackbarStackState] instance. */ @Composable -fun rememberSnackbarStackState(initial: List = emptyList()): SnackbarStackState { - return remember { SnackbarStackState(initial.toMutableList()) } +fun rememberSnackbarStackState( + initial: List = emptyList(), + maxExpandedSize: Int = 10, + maxCollapsedSize: Int = 5 +): SnackbarStackState { + return remember { + SnackbarStackState( + cards = initial.toMutableList(), + maxExpandedSize = maxExpandedSize, + maxCollapsedSize = maxCollapsedSize + ) + } } +data class SnackbarStackConfig( + val cardWidthExpanded: Dp = 320.dp, + val cardHeightExpanded: Dp = 160.dp, + val cardGapExpanded: Dp = 10.dp, + val cardWidthCollapsed: Dp = 280.dp, + val cardHeightCollapsed: Dp = 80.dp, + val cardGapCollapsed: Dp = 8.dp, + internal val stackAbove: Boolean = true, //TODO: Fix Stack Above option, disabling for now +) + /** * A composable that displays a stack of snackbar notifications. * It animates the cards based on the provided [SnackbarStackState]. @@ -262,22 +305,27 @@ fun rememberSnackbarStackState(initial: List = emptyList()): fun SnackbarStack( state: SnackbarStackState, modifier: Modifier = Modifier, - cardWidth: Dp = 320.dp, - cardHeight: Dp = 160.dp, - peekHeight: Dp = 10.dp, - stackAbove: Boolean = true, - contentModifier: Modifier = Modifier + contentModifier: Modifier = Modifier, + snackBarStackConfig: SnackbarStackConfig = SnackbarStackConfig(), ) { val count by remember { derivedStateOf { state.sizeVisible() } } + val cardHeight = animateDpAsState( + targetValue = if (state.expanded) snackBarStackConfig.cardHeightExpanded else snackBarStackConfig.cardHeightCollapsed, + animationSpec = spring(stiffness = Spring.StiffnessMedium) + ) + val peekHeight = animateDpAsState( + targetValue = if (state.expanded) snackBarStackConfig.cardGapExpanded else snackBarStackConfig.cardGapCollapsed, + animationSpec = spring(stiffness = Spring.StiffnessMedium) + ) val targetHeight by remember { derivedStateOf { if (count == 0) { 0.dp } else if (state.expanded) { - cardHeight * (count + 1) + (count - 1) * peekHeight + cardHeight.value * (count + 1) + (count - 1) * peekHeight.value } else { - cardHeight + (count - 1) * peekHeight + cardHeight.value + (count - 1) * peekHeight.value } } } @@ -286,8 +334,6 @@ fun SnackbarStack( targetValue = targetHeight, animationSpec = spring(stiffness = Spring.StiffnessMedium) ) - - val toggleExpanded = remember { { state.toggleExpanded() } } val scrollState = rememberScrollState() BoxWithConstraints(modifier = modifier.fillMaxWidth()) { @@ -325,16 +371,12 @@ fun SnackbarStack( expanded = state.expanded, index = logicalIndex, invertedIndex = invertedLogicalIndex, - cardHeight = cardHeight, - peekHeight = peekHeight, - cardWidth = cardWidth, onSwipedAway = { idToRemove -> state.removeCardById(idToRemove) state.showBack() }, - stackAbove = stackAbove, onClick = { - toggleExpanded() + state.toggleExpanded() } ) } @@ -355,14 +397,23 @@ private fun SnackbarStackItem( expanded: Boolean, index: Int, invertedIndex: Int, - cardWidth: Dp, - cardHeight: Dp, - peekHeight: Dp, stackedWidthScaleFactor: Float = STACKED_WIDTH_SCALE_FACTOR, onSwipedAway: (String) -> Unit, onClick: () -> Unit = {}, - stackAbove: Boolean = false + snackBarStackConfig: SnackbarStackConfig = SnackbarStackConfig(), ) { + + val cardWidth = + if (expanded) snackBarStackConfig.cardWidthExpanded else snackBarStackConfig.cardWidthCollapsed + + val cardHeight = + if (expanded) snackBarStackConfig.cardHeightExpanded else snackBarStackConfig.cardHeightCollapsed + + val peekHeight = + if (expanded) snackBarStackConfig.cardGapExpanded else snackBarStackConfig.cardGapCollapsed + + val stackAbove = snackBarStackConfig.stackAbove + val scope = rememberCoroutineScope() val localDensity = LocalDensity.current val isTop = index == 0 @@ -387,6 +438,7 @@ private fun SnackbarStackItem( cardWidth.toPx() * stackedWidthScaleFactor.pow(index) } } + val animatedWidth = animateFloatAsState( targetValue = targetWidth, animationSpec = tween(durationMillis = 100, easing = FastOutSlowInEasing) @@ -419,7 +471,7 @@ private fun SnackbarStackItem( modifier = Modifier .offset { IntOffset( - offsetX.roundToInt() ,//+ (slideInProgress.value * with(localDensity) { 200.dp.toPx() }).roundToInt(), + offsetX.roundToInt(),//+ (slideInProgress.value * with(localDensity) { 200.dp.toPx() }).roundToInt(), animatedYOffset.value.roundToInt() ) } @@ -486,10 +538,12 @@ private fun SnackbarStackItem( ) .background(token.backgroundBrush(snackBarInfo)) .clickable( + enabled = isTop || expanded, onClick = { onClick() } ) + .animateContentSize() ) { model.content() From 115cb9d56e964ad8391cd35bc692c282520bb792 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Mon, 15 Sep 2025 15:30:31 +0530 Subject: [PATCH 30/52] KDocs updated --- .../fluentuidemo/demos/V2SnackbarActivity.kt | 14 +- .../controlTokens/StackableSnackbarTokens.kt | 21 ++ .../notification/StackableSnackbar.kt | 205 ++++++++++-------- 3 files changed, 146 insertions(+), 94 deletions(-) create mode 100644 fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/StackableSnackbarTokens.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 a10ff0296..d0351833b 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,19 +36,17 @@ import com.microsoft.fluentui.tokenized.notification.AnimationBehavior import com.microsoft.fluentui.tokenized.notification.AnimationVariables import com.microsoft.fluentui.tokenized.notification.NotificationDuration import com.microsoft.fluentui.tokenized.notification.NotificationResult +import com.microsoft.fluentui.tokenized.notification.SnackBarItemModel +import com.microsoft.fluentui.tokenized.notification.SnackBarStack 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.SnackbarStackConfig import com.microsoft.fluentui.tokenized.notification.SnackbarState -import com.microsoft.fluentui.tokenized.notification.rememberSnackbarStackState +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 import com.microsoft.fluentuidemo.V2DemoActivity import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import java.util.UUID // Tags used for testing const val SNACK_BAR_MODIFIABLE_PARAMETER_SECTION = "Snack bar Modifiable Parameters" @@ -361,7 +359,7 @@ class V2SnackbarActivity : V2DemoActivity() { @Composable fun DemoCardStack() { - val stackState = rememberSnackbarStackState() + val stackState = rememberSnackBarStackState() var counter by rememberSaveable { mutableIntStateOf(0) } val scope = rememberCoroutineScope() Column( @@ -369,7 +367,7 @@ fun DemoCardStack() { modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom ) { - SnackbarStack( + SnackBarStack( state = stackState, modifier = Modifier.padding(16.dp), ) @@ -380,7 +378,7 @@ fun DemoCardStack() { Button(onClick = { val id = counter++ - stackState.addCard(SnackbarItemModel(id = id.toString()) { + stackState.addCard(SnackBarItemModel(id = id.toString()) { Column(modifier = Modifier.padding(12.dp)) { BasicText("Card: $id") BasicText("Some detail here") diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/StackableSnackbarTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/StackableSnackbarTokens.kt new file mode 100644 index 000000000..22139f652 --- /dev/null +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/StackableSnackbarTokens.kt @@ -0,0 +1,21 @@ +package com.microsoft.fluentui.theme.token.controlTokens + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.parcelize.Parcelize + +@Parcelize +open class StackableSnackBarTokens : SnackBarTokens() { + @Composable + fun cardShape(snackBarInfo: SnackBarInfo): Shape { + return RoundedCornerShape(12.dp) + } + + @Composable + fun contentPadding(snackBarInfo: SnackBarInfo): Dp { + return 0.dp + } +} \ No newline at end of file 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 b0f960470..e37b3c33e 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,7 +23,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -34,10 +33,9 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.times 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.theme.token.controlTokens.StackableSnackBarTokens import com.microsoft.fluentui.tokenized.controls.BasicCard -import kotlinx.coroutines.* import kotlin.math.abs import kotlin.math.pow import kotlin.math.roundToInt @@ -47,51 +45,54 @@ private const val STACKED_WIDTH_SCALE_FACTOR = 0.95f //TODO: Add accessibility support for the stack and individual cards //TODO: Make dynamically sized cards based on content + /** - * Represents a single item within the Snackbar stack. + * Represents a single item in 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. + * @param snackBarToken The tokens to customize the appearance of the snackBar. + * @param snackBarStyle The style of the snackBar, e.g., Neutral, Success, Error. + * @param id A unique identifier for this snackBar item. Defaults to a random UUID. + * @param hidden A mutable state to control the visibility of the snackBar item. + * @param content The composable content to be displayed inside the snackBar. */ @Stable -data class SnackbarItemModel( - val id: String, - val hidden: MutableState = mutableStateOf(false), - val snackbarToken: SnackBarTokens = SnackBarTokens(), - val snackbarStyle: SnackbarStyle = SnackbarStyle.Neutral, +data class SnackBarItemModel( + val snackBarToken: StackableSnackBarTokens = StackableSnackBarTokens(), + val snackBarStyle: SnackbarStyle = SnackbarStyle.Neutral, + val id: String = java.util.UUID.randomUUID().toString(), + internal val hidden: MutableState = mutableStateOf(false), val content: @Composable () -> Unit, ) /** - * 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. + * Manages the state of a [SnackbarStack]. It allows for adding, removing, hiding, and showing 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. + * @param cards The initial list of [SnackbarItemModel] to be displayed. + * @param maxCollapsedSize The maximum number of visible snackBars when the stack is collapsed. + * @param maxExpandedSize The maximum number of visible snackBars when the stack is expanded. */ -class SnackbarStackState( - internal val cards: MutableList, +class SnackBarStackState( + internal val cards: MutableList, internal var maxCollapsedSize: Int = 5, internal var maxExpandedSize: Int = 10 ) { - internal val snapshotStateList: MutableList = - mutableStateListOf().apply { addAll(cards) } + internal val snapshotStateList: MutableList = + mutableStateListOf().apply { addAll(cards) } + /** + * Whether the snackBar stack is currently expanded. + */ internal var expanded by mutableStateOf(false) private set internal var maxCurrentSize = maxCollapsedSize /** - * Adds a new snackbar card to the top of the stack. - * If the stack exceeds the maximum size, the oldest card will be hidden. + * Adds a new snackBar card to the top of the stack. + * If the number of visible cards exceeds the current maximum, the card at the back is hidden. * @param card The [SnackbarItemModel] to add. */ - fun addCard(card: SnackbarItemModel) { + fun addCard(card: SnackBarItemModel) { maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize snapshotStateList.add(card) if (sizeVisible() > maxCurrentSize) { @@ -100,10 +101,9 @@ class SnackbarStackState( } /** - * Removes a snackbar card from the stack by its unique [id]. - * @param id The id of the card to remove. - * @return `true` if the card was found and removed, `false` otherwise. - * DOES NOT ANIMATE THE REMOVAL, JUST REMOVES IT IMMEDIATELY + * Removes a snackBar card from the stack by its ID. + * @param id The unique identifier of the card to remove. + * @return `true` if a card was removed, `false` otherwise. */ fun removeCardById(id: String): Boolean { snapshotStateList.firstOrNull { it.id == id }?.let { @@ -114,8 +114,8 @@ class SnackbarStackState( } /** - * Hides a specific card in the stack by its unique [id]. - * @param id The id of the card to hide. + * Hides a snackBar card by its ID without removing it from the stack. + * @param id The unique identifier of the card to hide. * @return `true` if the card was found and hidden, `false` otherwise. */ fun hideCardById(id: String): Boolean { @@ -127,8 +127,8 @@ class SnackbarStackState( } /** - * Shows a specific card in the stack by its unique [id]. - * @param id The id of the card to show. + * Shows a previously hidden snackBar card by its ID. + * @param id The unique identifier of the card to show. * @return `true` if the card was found and shown, `false` otherwise. */ fun showCardById(id: String): Boolean { @@ -140,8 +140,8 @@ class SnackbarStackState( } /** - * Toggles the stack between its collapsed and expanded states. - * It automatically handles showing or hiding cards to match the respective size limits. + * Toggles the expanded/collapsed state of the snackBar stack. + * This adjusts the visibility of cards based on [maxCollapsedSize] and [maxExpandedSize]. */ fun toggleExpanded() { val currentSize = snapshotStateList.count { !it.hidden.value } @@ -165,8 +165,8 @@ class SnackbarStackState( } /** - * Hides the oldest visible card from the stack (the one at the bottom). - * @return `true` if a card was hidden, `false` if there were no visible cards. + * Hides the card at the back of the visible stack (the one added earliest). + * @return `true` if a card was hidden, `false` otherwise. */ fun hideBack(): Boolean { snapshotStateList.firstOrNull { !it.hidden.value }?.let { @@ -177,8 +177,8 @@ class SnackbarStackState( } /** - * Hides the newest visible card in the stack (the one at the top). - * @return `true` if a card was hidden, `false` if there were no visible cards. + * Hides the card at the front of the visible stack (the one added most recently). + * @return `true` if a card was hidden, `false` otherwise. */ fun hideFront(): Boolean { snapshotStateList.lastOrNull { !it.hidden.value }?.let { @@ -189,8 +189,9 @@ class SnackbarStackState( } /** - * Removes the oldest visible card from the stack (the one at the bottom). - * @return `true` if a card was removed, `false` if there were no visible cards. + * Removes the card at the back of the stack. + * @param skipHidden If `true`, removes the oldest *visible* card. If `false`, removes the oldest card regardless of visibility. + * @return `true` if a card was removed, `false` otherwise. */ fun removeBack(skipHidden: Boolean = false): Boolean { snapshotStateList.firstOrNull { skipHidden && !it.hidden.value || !skipHidden }?.let { @@ -201,8 +202,9 @@ class SnackbarStackState( } /** - * Removes the newest visible card from the stack (the one at the top). - * @return `true` if a card was removed, `false` if there were no visible cards. + * Removes the card at the front of the stack. + * @param skipHidden If `true`, removes the newest *visible* card. If `false`, removes the newest card regardless of visibility. + * @return `true` if a card was removed, `false` otherwise. */ fun removeFront(skipHidden: Boolean = false): Boolean { snapshotStateList.lastOrNull { skipHidden && !it.hidden.value || !skipHidden }?.let { @@ -213,8 +215,8 @@ class SnackbarStackState( } /** - * Reveals the oldest hidden card in the stack (the one at the bottom). - * @return `true` if a card was revealed, `false` if there were no hidden cards. + * Shows the newest hidden card from the back of the stack. + * @return `true` if a hidden card was shown, `false` otherwise. */ fun showBack(): Boolean { snapshotStateList.lastOrNull { it.hidden.value }?.let { @@ -225,8 +227,8 @@ class SnackbarStackState( } /** - * Reveals the newest hidden card in the stack (the one at the top). - * @return `true` if a card was revealed, `false` if there were no hidden cards. + * Shows the oldest hidden card from the front of the stack. + * @return `true` if a hidden card was shown, `false` otherwise. */ fun showFront(): Boolean { snapshotStateList.firstOrNull { it.hidden.value }?.let { @@ -237,7 +239,7 @@ class SnackbarStackState( } /** - * Reveals all previously hidden cards. + * Makes all snackBar cards in the stack visible. */ fun showAll() { snapshotStateList.forEach { @@ -246,31 +248,32 @@ class SnackbarStackState( } /** - * Returns the current number of cards in the stack. + * @return The total number of cards in the stack, including hidden ones. */ fun size(): Int = snapshotStateList.size /** - * Returns the current number of visible cards in the stack. + * @return The number of currently visible cards in the stack. */ fun sizeVisible(): Int = snapshotStateList.count { !it.hidden.value } } /** - * Creates and remembers a [SnackbarStackState] in the current composition. - * This is the recommended way to create a state object for the [SnackbarStack]. + * Creates and remembers a [SnackbarStackState] instance. * - * @param initial An optional initial list of [SnackbarItemModel]s to populate the stack. + * @param initial The initial list of [SnackbarItemModel]s to populate the stack. + * @param maxExpandedSize The maximum number of visible snackBars when the stack is expanded. + * @param maxCollapsedSize The maximum number of visible snackBars when the stack is collapsed. * @return A remembered [SnackbarStackState] instance. */ @Composable -fun rememberSnackbarStackState( - initial: List = emptyList(), +fun rememberSnackBarStackState( + initial: List = emptyList(), maxExpandedSize: Int = 10, maxCollapsedSize: Int = 5 -): SnackbarStackState { +): SnackBarStackState { return remember { - SnackbarStackState( + SnackBarStackState( cards = initial.toMutableList(), maxExpandedSize = maxExpandedSize, maxCollapsedSize = maxCollapsedSize @@ -278,7 +281,18 @@ fun rememberSnackbarStackState( } } -data class SnackbarStackConfig( +/** + * Configuration for the visual properties of the [SnackBarStack]. + * + * @param cardWidthExpanded The width of a snackBar card when the stack is expanded. + * @param cardHeightExpanded The height of a snackBar card when the stack is expanded. + * @param cardGapExpanded The vertical spacing between cards when the stack is expanded. + * @param cardWidthCollapsed The width of a snackBar card when the stack is collapsed. + * @param cardHeightCollapsed The height of a snackBar card when the stack is collapsed. + * @param cardGapCollapsed The vertical spacing (peek height) between cards when the stack is collapsed. + * @param stackAbove Internal flag to control stacking direction. Currently not implemented. + */ +data class SnackBarStackConfig( val cardWidthExpanded: Dp = 320.dp, val cardHeightExpanded: Dp = 160.dp, val cardGapExpanded: Dp = 10.dp, @@ -289,24 +303,26 @@ data class SnackbarStackConfig( ) /** - * 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. + * A composable that displays a stack of snackBar notifications. + * + * The stack can be in a collapsed state, showing a condensed view of notifications, + * or an expanded state, showing a scrollable list. * - * @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. + * @param state The [SnackBarStackState] that manages the content and state of the stack. + * @param modifier The modifier to be applied to the stack container. + * @param contentModifier The modifier to be applied to the content within each snackBar card. + * @param snackBarStackConfig The configuration for the visual properties of the stack. + * @param enableSwipeToDismiss If `true`, allows users to swipe away the top card (in collapsed mode) or any card (in expanded mode). + * @param expandOnCardClick If `true`, toggles the expanded state of the stack when the top card is clicked. */ @Composable -fun SnackbarStack( - state: SnackbarStackState, +fun SnackBarStack( + state: SnackBarStackState, modifier: Modifier = Modifier, contentModifier: Modifier = Modifier, - snackBarStackConfig: SnackbarStackConfig = SnackbarStackConfig(), + snackBarStackConfig: SnackBarStackConfig = SnackBarStackConfig(), + enableSwipeToDismiss: Boolean = true, + expandOnCardClick: Boolean = true, ) { val count by remember { derivedStateOf { state.sizeVisible() } } @@ -365,7 +381,7 @@ fun SnackbarStack( logicalIndex = totalVisibleCards - 1 - visibleIndex++ } key(snackBarModel.id) { - SnackbarStackItem( + SnackBarStackItem( model = snackBarModel, isHidden = snackBarModel.hidden.value, expanded = state.expanded, @@ -376,8 +392,12 @@ fun SnackbarStack( state.showBack() }, onClick = { - state.toggleExpanded() - } + if(expandOnCardClick) { + state.toggleExpanded() + } + }, + snackBarStackConfig = snackBarStackConfig, + enableSwipeToDismiss = enableSwipeToDismiss ) } } @@ -387,12 +407,23 @@ fun SnackbarStack( } /** - * A private composable that represents a single, animatable card within the [SnackbarStack]. - * It handles its own animations for position, width, opacity, and swipe gestures. + * Represents a single animated item within the [SnackBarStack]. + * This composable manages its own position, scale, opacity, and swipe-to-dismiss behavior. + * + * @param model The data model for this snackBar item. + * @param isHidden Whether the item is currently hidden and should be faded out. + * @param expanded Whether the parent stack is in an expanded state. + * @param index The logical index of the card from the top of the stack (0 is the topmost). + * @param invertedIndex The logical index from the bottom of the stack (0 is the bottommost). + * @param stackedWidthScaleFactor The factor by which to scale the width of cards underneath the top card in collapsed mode. + * @param onSwipedAway Callback invoked when the card has been successfully swiped away. + * @param onClick Callback invoked when the card is clicked. + * @param snackBarStackConfig Configuration for the visual properties. + * @param enableSwipeToDismiss Whether swipe-to-dismiss is enabled for this item. */ @Composable -private fun SnackbarStackItem( - model: SnackbarItemModel, +private fun SnackBarStackItem( + model: SnackBarItemModel, isHidden: Boolean, expanded: Boolean, index: Int, @@ -400,7 +431,8 @@ private fun SnackbarStackItem( stackedWidthScaleFactor: Float = STACKED_WIDTH_SCALE_FACTOR, onSwipedAway: (String) -> Unit, onClick: () -> Unit = {}, - snackBarStackConfig: SnackbarStackConfig = SnackbarStackConfig(), + snackBarStackConfig: SnackBarStackConfig, + enableSwipeToDismiss: Boolean = true, ) { val cardWidth = @@ -417,6 +449,7 @@ private fun SnackbarStackItem( val scope = rememberCoroutineScope() val localDensity = LocalDensity.current val isTop = index == 0 + val targetYOffset = with(localDensity) { if (expanded) (invertedIndex * (peekHeight + cardHeight)).toPx() else (index * peekHeight).toPx() } val animatedYOffset = remember { @@ -438,7 +471,6 @@ private fun SnackbarStackItem( cardWidth.toPx() * stackedWidthScaleFactor.pow(index) } } - val animatedWidth = animateFloatAsState( targetValue = targetWidth, animationSpec = tween(durationMillis = 100, easing = FastOutSlowInEasing) @@ -464,14 +496,14 @@ 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) + val token = model.snackBarToken + val snackBarInfo = SnackBarInfo(model.snackBarStyle, false) Box( modifier = Modifier .offset { IntOffset( - offsetX.roundToInt(),//+ (slideInProgress.value * with(localDensity) { 200.dp.toPx() }).roundToInt(), + offsetX.roundToInt() + (slideInProgress.value * with(localDensity) { 200.dp.toPx() }).roundToInt(), animatedYOffset.value.roundToInt() ) } @@ -483,7 +515,7 @@ private fun SnackbarStackItem( .height(cardHeight) .padding(horizontal = 0.dp) .then( - if (isTop || expanded) Modifier.pointerInput(model.id) { + if (enableSwipeToDismiss && (isTop || expanded)) Modifier.pointerInput(model.id) { detectHorizontalDragGestures( onDragStart = {}, onDragEnd = { @@ -532,7 +564,7 @@ private fun SnackbarStackItem( BasicCard( modifier = Modifier .fillMaxSize() - .clip(RoundedCornerShape(12.dp)) + .clip(token.cardShape(snackBarInfo)) .shadow( elevation = token.shadowElevationValue(snackBarInfo) ) @@ -543,6 +575,7 @@ private fun SnackbarStackItem( onClick() } ) + .padding(token.contentPadding(snackBarInfo)) .animateContentSize() ) { From 9a2e4ad59af34611549c0309871c8403702a60e0 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Tue, 16 Sep 2025 15:05:45 +0530 Subject: [PATCH 31/52] Simplified Layout, moved translations to graphics layer --- .../fluentuidemo/demos/V2SnackbarActivity.kt | 37 ++---- .../notification/StackableSnackbar.kt | 110 ++++++++---------- 2 files changed, 56 insertions(+), 91 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 d0351833b..b84129809 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,7 +72,7 @@ class V2SnackbarActivity : V2DemoActivity() { val context = this setActivityContent { - DemoCardStack() + SnackBarStackDemo() val snackbarState = remember { SnackbarState() } val scope = rememberCoroutineScope() @@ -99,27 +99,6 @@ class V2SnackbarActivity : V2DemoActivity() { 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( @@ -357,9 +336,13 @@ class V2SnackbarActivity : V2DemoActivity() { } } +// Demo for SnackBarStack @Composable -fun DemoCardStack() { - val stackState = rememberSnackBarStackState() +fun SnackBarStackDemo() { + val stackState = rememberSnackBarStackState( + maxExpandedSize = 10, + maxCollapsedSize = 5 + ) var counter by rememberSaveable { mutableIntStateOf(0) } val scope = rememberCoroutineScope() Column( @@ -384,7 +367,7 @@ fun DemoCardStack() { BasicText("Some detail here") } }) - }, text = "Add card") + }, text = "Add Snackbar") Spacer(modifier = Modifier.width(12.dp)) @@ -395,13 +378,13 @@ fun DemoCardStack() { stackState.removeFront() stackState.showBack() } - }, text = "Remove top card") + }, text = "Remove latest") Spacer(modifier = Modifier.width(12.dp)) Button(onClick = { stackState.showBack() - }, text = "Show last hidden card") + }, text = "Show last hidden") } } } 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 e37b3c33e..495992f8a 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 @@ -351,55 +351,46 @@ fun SnackBarStack( animationSpec = spring(stiffness = Spring.StiffnessMedium) ) val scrollState = rememberScrollState() - - BoxWithConstraints(modifier = modifier.fillMaxWidth()) { - val parentMaxHeight = this.maxHeight - val contentHeight = if (state.size() == 0) 0.dp else animatedStackHeight - - val visibleHeight = - if (state.expanded) minOf(contentHeight, parentMaxHeight) else contentHeight - + Box( + modifier = Modifier + .fillMaxWidth() + .height(animatedStackHeight) + .then(if (state.expanded) Modifier.verticalScroll(scrollState) else Modifier), + contentAlignment = Alignment.BottomCenter + ) { Box( modifier = Modifier .fillMaxWidth() - .height(visibleHeight) - .then(if (state.expanded) Modifier.verticalScroll(scrollState) else Modifier), + .height(animatedStackHeight), contentAlignment = Alignment.BottomCenter ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(contentHeight), - contentAlignment = Alignment.BottomCenter - ) { - val totalVisibleCards = state.sizeVisible() - var visibleIndex = 0 - state.snapshotStateList.forEach { snackBarModel -> - var logicalIndex = state.maxCurrentSize - val invertedLogicalIndex = visibleIndex - if (!snackBarModel.hidden.value) { - logicalIndex = totalVisibleCards - 1 - visibleIndex++ - } - key(snackBarModel.id) { - SnackBarStackItem( - model = snackBarModel, - isHidden = snackBarModel.hidden.value, - expanded = state.expanded, - index = logicalIndex, - invertedIndex = invertedLogicalIndex, - onSwipedAway = { idToRemove -> - state.removeCardById(idToRemove) - state.showBack() - }, - onClick = { - if(expandOnCardClick) { - state.toggleExpanded() - } - }, - snackBarStackConfig = snackBarStackConfig, - enableSwipeToDismiss = enableSwipeToDismiss - ) - } + val totalVisibleCards = state.sizeVisible() + var visibleIndex = 0 + state.snapshotStateList.forEach { snackBarModel -> + var logicalIndex = state.maxCurrentSize + val invertedLogicalIndex = visibleIndex + if (!snackBarModel.hidden.value) { + logicalIndex = totalVisibleCards - 1 - visibleIndex++ + } + key(snackBarModel.id) { + SnackBarStackItem( + model = snackBarModel, + isHidden = snackBarModel.hidden.value, + expanded = state.expanded, + index = logicalIndex, + invertedIndex = invertedLogicalIndex, + onSwipedAway = { idToRemove -> + state.removeCardById(idToRemove) + state.showBack() + }, + onClick = { + if (expandOnCardClick) { + state.toggleExpanded() + } + }, + snackBarStackConfig = snackBarStackConfig, + enableSwipeToDismiss = enableSwipeToDismiss + ) } } } @@ -462,17 +453,9 @@ private fun SnackBarStackItem( ) } - val targetWidth = with(localDensity) { - if (expanded) { - cardWidth.toPx() - } else if (isHidden) { - cardWidth.toPx() * stackedWidthScaleFactor.pow(index) - } else { - cardWidth.toPx() * stackedWidthScaleFactor.pow(index) - } - } - val animatedWidth = animateFloatAsState( - targetValue = targetWidth, + val targetWidthScale = if (expanded) 1f else stackedWidthScaleFactor.pow(index) + val animatedWidthScale = animateFloatAsState( + targetValue = targetWidthScale, animationSpec = tween(durationMillis = 100, easing = FastOutSlowInEasing) ) @@ -501,15 +484,12 @@ private fun SnackBarStackItem( Box( modifier = Modifier - .offset { - IntOffset( - offsetX.roundToInt() + (slideInProgress.value * with(localDensity) { 200.dp.toPx() }).roundToInt(), - animatedYOffset.value.roundToInt() - ) - } .graphicsLayer( alpha = opacityProgress.value, - scaleX = animatedWidth.value / with(localDensity) { cardWidth.toPx() }, + translationX = offsetX + (slideInProgress.value * with(localDensity) { 200.dp.toPx() }), + translationY = animatedYOffset.value, + scaleX = animatedWidthScale.value, + scaleY = animatedWidthScale.value ) .width(cardWidth) .height(cardHeight) @@ -564,10 +544,12 @@ private fun SnackBarStackItem( BasicCard( modifier = Modifier .fillMaxSize() - .clip(token.cardShape(snackBarInfo)) .shadow( - elevation = token.shadowElevationValue(snackBarInfo) + elevation = token.shadowElevationValue(snackBarInfo), + shape = token.cardShape(snackBarInfo), + clip = false ) + .clip(token.cardShape(snackBarInfo)) .background(token.backgroundBrush(snackBarInfo)) .clickable( enabled = isTop || expanded, From 313b95e928016d7823516ba7824264becc146962 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Fri, 19 Sep 2025 11:37:52 +0530 Subject: [PATCH 32/52] Added Scrim --- .../notification/StackableSnackbar.kt | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 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 495992f8a..a5ee13c8f 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,6 @@ package com.microsoft.fluentui.tokenized.notification +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween @@ -21,6 +22,7 @@ import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -28,9 +30,9 @@ 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.graphics.Color 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.token.controlTokens.SnackBarInfo import com.microsoft.fluentui.theme.token.controlTokens.SnackbarStyle @@ -38,7 +40,6 @@ import com.microsoft.fluentui.theme.token.controlTokens.StackableSnackBarTokens import com.microsoft.fluentui.tokenized.controls.BasicCard import kotlin.math.abs import kotlin.math.pow -import kotlin.math.roundToInt private const val FADE_OUT_DURATION = 350 private const val STACKED_WIDTH_SCALE_FACTOR = 0.95f @@ -446,7 +447,7 @@ private fun SnackBarStackItem( val animatedYOffset = remember { Animatable(with(localDensity) { cardHeight.toPx() * if (stackAbove) 1f else -1f }) } - LaunchedEffect(index, expanded, invertedIndex) { + LaunchedEffect(targetYOffset) { animatedYOffset.animateTo( targetYOffset * (if (stackAbove) -1f else 1f), animationSpec = spring(stiffness = Spring.StiffnessLow) @@ -564,4 +565,37 @@ private fun SnackBarStackItem( model.content() } } +} + +/** + * A composable that displays a scrim over its background. It dims the background + * and intercepts all clicks, preventing them from reaching the content behind it. + * + * @param isActivated Whether the scrim is currently active. + * @param onDismiss A lambda to be invoked when the scrim area is clicked. + */ +@Composable +fun Scrim( + isActivated: Boolean, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val scrimColor by animateColorAsState( + targetValue = if (isActivated) Color.Black.copy(alpha = 0.6f) else Color.Transparent, + animationSpec = tween(durationMillis = 300), + label = "ScrimColorAnimation" + ) + + if (scrimColor.alpha > 0f) { + Box( + modifier = modifier + .fillMaxSize() + .background(scrimColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss + ) + ) + } } \ No newline at end of file From 9b03ff47114933b510dcedf4cd2658edd9d5fda9 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Fri, 19 Sep 2025 16:07:55 +0530 Subject: [PATCH 33/52] Adding Scrim Demo --- .../fluentuidemo/demos/V2SnackbarActivity.kt | 83 ++++++++++--------- .../notification/StackableSnackbar.kt | 2 +- 2 files changed, 46 insertions(+), 39 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 b84129809..813938b3b 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,6 +36,7 @@ import com.microsoft.fluentui.tokenized.notification.AnimationBehavior import com.microsoft.fluentui.tokenized.notification.AnimationVariables import com.microsoft.fluentui.tokenized.notification.NotificationDuration import com.microsoft.fluentui.tokenized.notification.NotificationResult +import com.microsoft.fluentui.tokenized.notification.Scrim import com.microsoft.fluentui.tokenized.notification.SnackBarItemModel import com.microsoft.fluentui.tokenized.notification.SnackBarStack import com.microsoft.fluentui.tokenized.notification.Snackbar @@ -339,52 +340,58 @@ class V2SnackbarActivity : V2DemoActivity() { // Demo for SnackBarStack @Composable fun SnackBarStackDemo() { - val stackState = rememberSnackBarStackState( - maxExpandedSize = 10, - maxCollapsedSize = 5 - ) - var counter by rememberSaveable { mutableIntStateOf(0) } - val scope = rememberCoroutineScope() - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Bottom - ) { - SnackBarStack( - state = stackState, - modifier = Modifier.padding(16.dp), + Box() { + val stackState = rememberSnackBarStackState( + maxExpandedSize = 10, + maxCollapsedSize = 5 ) + var counter by rememberSaveable { mutableIntStateOf(0) } + val scope = rememberCoroutineScope() + Scrim( + isActivated = stackState.expanded, + onDismiss = {} + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Bottom + ) { + SnackBarStack( + state = stackState, + modifier = Modifier.padding(16.dp), + ) - Spacer(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.height(20.dp)) - Row { - Button(onClick = { - val id = counter++ + Row { + Button(onClick = { + val id = counter++ - stackState.addCard(SnackBarItemModel(id = id.toString()) { - Column(modifier = Modifier.padding(12.dp)) { - BasicText("Card: $id") - BasicText("Some detail here") - } - }) - }, text = "Add Snackbar") + stackState.addCard(SnackBarItemModel(id = id.toString()) { + Column(modifier = Modifier.padding(12.dp)) { + BasicText("Card: $id") + BasicText("Some detail here") + } + }) + }, text = "Add Snackbar") - Spacer(modifier = Modifier.width(12.dp)) + Spacer(modifier = Modifier.width(12.dp)) - Button(onClick = { - scope.launch { - stackState.hideFront() - delay(300) - stackState.removeFront() - stackState.showBack() - } - }, text = "Remove latest") + Button(onClick = { + scope.launch { + stackState.hideFront() + delay(300) + stackState.removeFront() + stackState.showBack() + } + }, text = "Remove latest") - Spacer(modifier = Modifier.width(12.dp)) + Spacer(modifier = Modifier.width(12.dp)) - Button(onClick = { - stackState.showBack() - }, text = "Show last hidden") + Button(onClick = { + stackState.showBack() + }, text = "Show last hidden") + } } } } 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 a5ee13c8f..4119c80fc 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 @@ -83,7 +83,7 @@ class SnackBarStackState( /** * Whether the snackBar stack is currently expanded. */ - internal var expanded by mutableStateOf(false) + var expanded by mutableStateOf(false) private set internal var maxCurrentSize = maxCollapsedSize From 9bed818481c4e4236d2b9a2f292ba885151eb75d Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Tue, 23 Sep 2025 16:07:57 +0530 Subject: [PATCH 34/52] Added Dynamic Sized Cards In Expanded Mode --- .../fluentuidemo/demos/V2SnackbarActivity.kt | 2 +- .../notification/StackableSnackbar.kt | 137 +++++++++++------- 2 files changed, 89 insertions(+), 50 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 813938b3b..43d6e93cb 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 @@ -370,7 +370,7 @@ fun SnackBarStackDemo() { stackState.addCard(SnackBarItemModel(id = id.toString()) { Column(modifier = Modifier.padding(12.dp)) { BasicText("Card: $id") - BasicText("Some detail here") + BasicText("Some detail here Some detail here Some detail here \n".repeat(id+3)) } }) }, text = "Add Snackbar") 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 4119c80fc..c7e7c42d4 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,6 @@ package com.microsoft.fluentui.tokenized.notification import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box @@ -31,6 +30,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.times @@ -38,6 +39,7 @@ import com.microsoft.fluentui.theme.token.controlTokens.SnackBarInfo import com.microsoft.fluentui.theme.token.controlTokens.SnackbarStyle import com.microsoft.fluentui.theme.token.controlTokens.StackableSnackBarTokens import com.microsoft.fluentui.tokenized.controls.BasicCard +import kotlinx.coroutines.delay import kotlin.math.abs import kotlin.math.pow @@ -62,7 +64,7 @@ data class SnackBarItemModel( val snackBarStyle: SnackbarStyle = SnackbarStyle.Neutral, val id: String = java.util.UUID.randomUUID().toString(), internal val hidden: MutableState = mutableStateOf(false), - val content: @Composable () -> Unit, + val content: @Composable () -> Unit ) /** @@ -77,7 +79,8 @@ class SnackBarStackState( internal var maxCollapsedSize: Int = 5, internal var maxExpandedSize: Int = 10 ) { - internal val snapshotStateList: MutableList = + val contentHeightList: MutableList = MutableList(cards.size) { 0 } + val snapshotStateList: MutableList = mutableStateListOf().apply { addAll(cards) } /** @@ -94,8 +97,12 @@ class SnackBarStackState( * @param card The [SnackbarItemModel] to add. */ fun addCard(card: SnackBarItemModel) { + if (snapshotStateList.any { it.id == card.id }) { + return + } maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize snapshotStateList.add(card) + contentHeightList.add(0) if (sizeVisible() > maxCurrentSize) { hideBack() } @@ -108,12 +115,22 @@ class SnackBarStackState( */ fun removeCardById(id: String): Boolean { snapshotStateList.firstOrNull { it.id == id }?.let { + contentHeightList.removeAt(snapshotStateList.indexOf(it)) snapshotStateList.remove(it) return true } return false } + suspend fun removeCardByIdWithAnimation(id: String, onRemoveCompleteCallback: () -> Unit = {}) { + val card = snapshotStateList.firstOrNull { it.id == id } ?: return + card.hidden.value = true + delay(FADE_OUT_DURATION.toLong()) + contentHeightList.removeAt(snapshotStateList.indexOf(card)) + snapshotStateList.remove(card) + onRemoveCompleteCallback() + } + /** * Hides a snackBar card by its ID without removing it from the stack. * @param id The unique identifier of the card to hide. @@ -209,6 +226,7 @@ class SnackBarStackState( */ fun removeFront(skipHidden: Boolean = false): Boolean { snapshotStateList.lastOrNull { skipHidden && !it.hidden.value || !skipHidden }?.let { + contentHeightList.removeAt(snapshotStateList.indexOf(it)) snapshotStateList.remove(it) return true } @@ -248,6 +266,26 @@ class SnackBarStackState( } } + /** + * Internal Functions to get offset heights when expanded + */ + internal fun heightBeforeIndex(index: Int): Int { + return contentHeightList + .take(index) + .filterIndexed { i, _ -> !snapshotStateList[i].hidden.value } + .sum() + } + + internal fun heightAfterIndex(index: Int): Int { + return contentHeightList + .drop(index + 1) + .filterIndexed { i, _ -> + // i here starts from 0 because of drop, so offset by index+1 + !snapshotStateList[i + index + 1].hidden.value + } + .sum() + } + /** * @return The total number of cards in the stack, including hidden ones. */ @@ -325,33 +363,18 @@ fun SnackBarStack( enableSwipeToDismiss: Boolean = true, expandOnCardClick: Boolean = true, ) { - val count by remember { derivedStateOf { state.sizeVisible() } } - - val cardHeight = animateDpAsState( - targetValue = if (state.expanded) snackBarStackConfig.cardHeightExpanded else snackBarStackConfig.cardHeightCollapsed, - animationSpec = spring(stiffness = Spring.StiffnessMedium) - ) - val peekHeight = animateDpAsState( - targetValue = if (state.expanded) snackBarStackConfig.cardGapExpanded else snackBarStackConfig.cardGapCollapsed, - animationSpec = spring(stiffness = Spring.StiffnessMedium) - ) - val targetHeight by remember { - derivedStateOf { - if (count == 0) { - 0.dp - } else if (state.expanded) { - cardHeight.value * (count + 1) + (count - 1) * peekHeight.value - } else { - cardHeight.value + (count - 1) * peekHeight.value - } - } - } - + val visibleCardsCount by remember { derivedStateOf { state.sizeVisible() } } + val localDensity = LocalDensity.current + val cardHeight = snackBarStackConfig.cardHeightCollapsed + val peekHeight = snackBarStackConfig.cardGapCollapsed + val targetHeight = if (visibleCardsCount == 0) { 0.dp } + else if (state.expanded) { with(localDensity) { state.heightAfterIndex(0).toDp() + 200.dp } } + else { cardHeight + (visibleCardsCount - 1) * peekHeight } val animatedStackHeight by animateDpAsState( targetValue = targetHeight, animationSpec = spring(stiffness = Spring.StiffnessMedium) ) - val scrollState = rememberScrollState() + val scrollState = rememberScrollState() //TODO: Keep Focus Anchored To the Bottom when expanded and new card added Box( modifier = Modifier .fillMaxWidth() @@ -367,7 +390,7 @@ fun SnackBarStack( ) { val totalVisibleCards = state.sizeVisible() var visibleIndex = 0 - state.snapshotStateList.forEach { snackBarModel -> + state.snapshotStateList.forEachIndexed { index, snackBarModel -> var logicalIndex = state.maxCurrentSize val invertedLogicalIndex = visibleIndex if (!snackBarModel.hidden.value) { @@ -376,10 +399,11 @@ fun SnackBarStack( key(snackBarModel.id) { SnackBarStackItem( model = snackBarModel, + state = state, isHidden = snackBarModel.hidden.value, expanded = state.expanded, index = logicalIndex, - invertedIndex = invertedLogicalIndex, + trueIndex = index, onSwipedAway = { idToRemove -> state.removeCardById(idToRemove) state.showBack() @@ -416,10 +440,11 @@ fun SnackBarStack( @Composable private fun SnackBarStackItem( model: SnackBarItemModel, + state: SnackBarStackState, isHidden: Boolean, expanded: Boolean, index: Int, - invertedIndex: Int, + trueIndex: Int, stackedWidthScaleFactor: Float = STACKED_WIDTH_SCALE_FACTOR, onSwipedAway: (String) -> Unit, onClick: () -> Unit = {}, @@ -427,14 +452,11 @@ private fun SnackBarStackItem( enableSwipeToDismiss: Boolean = true, ) { - val cardWidth = - if (expanded) snackBarStackConfig.cardWidthExpanded else snackBarStackConfig.cardWidthCollapsed + val cardWidth = snackBarStackConfig.cardWidthCollapsed - val cardHeight = - if (expanded) snackBarStackConfig.cardHeightExpanded else snackBarStackConfig.cardHeightCollapsed + val cardHeight = snackBarStackConfig.cardHeightCollapsed - val peekHeight = - if (expanded) snackBarStackConfig.cardGapExpanded else snackBarStackConfig.cardGapCollapsed + val peekHeight = snackBarStackConfig.cardGapCollapsed val stackAbove = snackBarStackConfig.stackAbove @@ -442,8 +464,10 @@ private fun SnackBarStackItem( val localDensity = LocalDensity.current val isTop = index == 0 + val heightSumAfterIndex = if (isHidden) 0 else state.heightAfterIndex(trueIndex) + val targetYOffset = - with(localDensity) { if (expanded) (invertedIndex * (peekHeight + cardHeight)).toPx() else (index * peekHeight).toPx() } + with(localDensity) { if (expanded) heightSumAfterIndex.toFloat() else (index * peekHeight).toPx() } val animatedYOffset = remember { Animatable(with(localDensity) { cardHeight.toPx() * if (stackAbove) 1f else -1f }) } @@ -460,14 +484,6 @@ private fun SnackBarStackItem( animationSpec = tween(durationMillis = 100, easing = FastOutSlowInEasing) ) - val slideInProgress = remember { Animatable(1f) } - LaunchedEffect(Unit) { - slideInProgress.animateTo( - 0f, - animationSpec = tween(durationMillis = 350, easing = FastOutSlowInEasing) - ) - } - val opacityProgress = remember { Animatable(0f) } LaunchedEffect(isHidden) { if (isHidden) { @@ -485,15 +501,31 @@ private fun SnackBarStackItem( Box( modifier = Modifier + .onGloballyPositioned( + onGloballyPositioned = { coordinates: LayoutCoordinates -> + val contentHeight = coordinates.size.height + state.contentHeightList[trueIndex] = contentHeight + with(localDensity) { + 20.dp.toPx().toInt() + } + return@onGloballyPositioned + } + ) .graphicsLayer( alpha = opacityProgress.value, - translationX = offsetX + (slideInProgress.value * with(localDensity) { 200.dp.toPx() }), + translationX = offsetX, //+ (slideInProgress.value * with(localDensity) { 200.dp.toPx() }), translationY = animatedYOffset.value, scaleX = animatedWidthScale.value, scaleY = animatedWidthScale.value ) - .width(cardWidth) - .height(cardHeight) + .then( + if (state.expanded) { + Modifier.wrapContentSize() + } else { + Modifier + .width(cardWidth) + .height(cardHeight) + } + ) .padding(horizontal = 0.dp) .then( if (enableSwipeToDismiss && (isTop || expanded)) Modifier.pointerInput(model.id) { @@ -544,7 +576,15 @@ private fun SnackBarStackItem( ) { BasicCard( modifier = Modifier - .fillMaxSize() + .then( + if (!state.expanded) { + Modifier + .height(cardHeight) + .width(cardWidth) + } else { + Modifier + } + ) .shadow( elevation = token.shadowElevationValue(snackBarInfo), shape = token.cardShape(snackBarInfo), @@ -559,7 +599,6 @@ private fun SnackBarStackItem( } ) .padding(token.contentPadding(snackBarInfo)) - .animateContentSize() ) { model.content() From cf5b762d7cd3ebd7a67408404cef4986b950abb2 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Tue, 23 Sep 2025 23:37:11 +0530 Subject: [PATCH 35/52] Added Snackbar Attributes in Model, removed content --- .../fluentuidemo/demos/V2SnackbarActivity.kt | 29 ++- .../notification/StackableSnackbar.kt | 232 ++++++++++++++---- 2 files changed, 206 insertions(+), 55 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 43d6e93cb..7f67c9911 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 @@ -367,12 +367,13 @@ fun SnackBarStackDemo() { Button(onClick = { val id = counter++ - stackState.addCard(SnackBarItemModel(id = id.toString()) { - Column(modifier = Modifier.padding(12.dp)) { - BasicText("Card: $id") - BasicText("Some detail here Some detail here Some detail here \n".repeat(id+3)) + stackState.addCard(SnackBarItemModel(id = id.toString(), message = "Snackbar #$id", actionText = "Expand", onDismissClicked = { + scope.launch { + stackState.removeCardByIdWithAnimation(id.toString(), showLastHiddenCardOnRemove = true) } - }) + }, onActionTextClicked = { + stackState.toggleExpanded() + })) }, text = "Add Snackbar") Spacer(modifier = Modifier.width(12.dp)) @@ -386,11 +387,27 @@ fun SnackBarStackDemo() { } }, text = "Remove latest") + Button(onClick = { + scope.launch { + val id = counter++ + for(i in 0..15) { + stackState.addCard(SnackBarItemModel(id = "$id-$i", message = "Snackbar #$id-$i".repeat(i + 4), actionText = "Expand", onDismissClicked = { + scope.launch { + stackState.removeCardByIdWithAnimation("$id-$i", showLastHiddenCardOnRemove = true) + } + }, onActionTextClicked = { + stackState.toggleExpanded() + })) + delay(2000) + } + } + }, text = "Keep Adding") + Spacer(modifier = Modifier.width(12.dp)) Button(onClick = { stackState.showBack() - }, text = "Show last hidden") + }, text = "Show hidden") } } } 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 c7e7c42d4..95096a97e 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 @@ -24,7 +24,12 @@ import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.draw.clip @@ -33,12 +38,26 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.times +import com.microsoft.fluentui.theme.token.FluentIcon +import com.microsoft.fluentui.theme.token.Icon +import com.microsoft.fluentui.theme.token.StateColor +import com.microsoft.fluentui.theme.token.controlTokens.ButtonInfo +import com.microsoft.fluentui.theme.token.controlTokens.ButtonSize +import com.microsoft.fluentui.theme.token.controlTokens.ButtonStyle +import com.microsoft.fluentui.theme.token.controlTokens.ButtonTokens import com.microsoft.fluentui.theme.token.controlTokens.SnackBarInfo import com.microsoft.fluentui.theme.token.controlTokens.SnackbarStyle import com.microsoft.fluentui.theme.token.controlTokens.StackableSnackBarTokens import com.microsoft.fluentui.tokenized.controls.BasicCard +import com.microsoft.fluentui.tokenized.controls.Button +import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.delay import kotlin.math.abs import kotlin.math.pow @@ -47,7 +66,7 @@ private const val FADE_OUT_DURATION = 350 private const val STACKED_WIDTH_SCALE_FACTOR = 0.95f //TODO: Add accessibility support for the stack and individual cards -//TODO: Make dynamically sized cards based on content +//TODO: Perf, reduce recompositions, make stable, minimize launch effect tracked variables /** * Represents a single item in the snackBar stack. @@ -58,13 +77,21 @@ private const val STACKED_WIDTH_SCALE_FACTOR = 0.95f * @param hidden A mutable state to control the visibility of the snackBar item. * @param content The composable content to be displayed inside the snackBar. */ +private val DEFAULT_SNACKBAR_TOKENS = StackableSnackBarTokens() + @Stable data class SnackBarItemModel( - val snackBarToken: StackableSnackBarTokens = StackableSnackBarTokens(), - val snackBarStyle: SnackbarStyle = SnackbarStyle.Neutral, + val message: String, val id: String = java.util.UUID.randomUUID().toString(), + val style: SnackbarStyle = SnackbarStyle.Neutral, + val enableDismiss: Boolean = true, + val icon: FluentIcon? = null, + val subTitle: String? = null, + val actionText: String? = null, + val snackBarToken: StackableSnackBarTokens = DEFAULT_SNACKBAR_TOKENS, + val onActionTextClicked: () -> Unit = {}, + val onDismissClicked: () -> Unit = {}, internal val hidden: MutableState = mutableStateOf(false), - val content: @Composable () -> Unit ) /** @@ -122,12 +149,19 @@ class SnackBarStackState( return false } - suspend fun removeCardByIdWithAnimation(id: String, onRemoveCompleteCallback: () -> Unit = {}) { + suspend fun removeCardByIdWithAnimation( + id: String, + showLastHiddenCardOnRemove: Boolean = true, + onRemoveCompleteCallback: () -> Unit = {} + ) { val card = snapshotStateList.firstOrNull { it.id == id } ?: return card.hidden.value = true delay(FADE_OUT_DURATION.toLong()) contentHeightList.removeAt(snapshotStateList.indexOf(card)) snapshotStateList.remove(card) + if (showLastHiddenCardOnRemove) { + onVisibleSizeChange() + } onRemoveCompleteCallback() } @@ -162,9 +196,13 @@ class SnackBarStackState( * This adjusts the visibility of cards based on [maxCollapsedSize] and [maxExpandedSize]. */ fun toggleExpanded() { - val currentSize = snapshotStateList.count { !it.hidden.value } expanded = !expanded maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize + onVisibleSizeChange() + } + + private fun onVisibleSizeChange() { + val currentSize = snapshotStateList.count { !it.hidden.value } val (count, sequence, targetHidden) = if (currentSize > maxCurrentSize) { Triple(currentSize - maxCurrentSize, snapshotStateList, true) @@ -286,6 +324,14 @@ class SnackBarStackState( .sum() } + internal fun visibleCountAfterIndex(index: Int): Int { + return contentHeightList + .drop(index + 1) + .filterIndexed { i, _ -> + !snapshotStateList[i + index + 1].hidden.value + }.size + } + /** * @return The total number of cards in the stack, including hidden ones. */ @@ -338,7 +384,7 @@ data class SnackBarStackConfig( val cardWidthCollapsed: Dp = 280.dp, val cardHeightCollapsed: Dp = 80.dp, val cardGapCollapsed: Dp = 8.dp, - internal val stackAbove: Boolean = true, //TODO: Fix Stack Above option, disabling for now + internal val stackAbove: Boolean = true, //TODO: Fix Stack Above option, working for true, disabling for now ) /** @@ -367,14 +413,19 @@ fun SnackBarStack( val localDensity = LocalDensity.current val cardHeight = snackBarStackConfig.cardHeightCollapsed val peekHeight = snackBarStackConfig.cardGapCollapsed - val targetHeight = if (visibleCardsCount == 0) { 0.dp } - else if (state.expanded) { with(localDensity) { state.heightAfterIndex(0).toDp() + 200.dp } } - else { cardHeight + (visibleCardsCount - 1) * peekHeight } - val animatedStackHeight by animateDpAsState( + val targetHeight = if (visibleCardsCount == 0) { + 0.dp + } else if (state.expanded) { + with(localDensity) { state.heightAfterIndex(0).toDp() + 200.dp } + } else { + cardHeight + (visibleCardsCount - 1) * peekHeight + } + val animatedStackHeight by animateDpAsState( //TODO: CHANGE TO LAUNCHED EFFECT WITH ONLY A TRIGGER ON VISIBLE CARDS TOTAL HEIGHT CHANGE targetValue = targetHeight, animationSpec = spring(stiffness = Spring.StiffnessMedium) ) - val scrollState = rememberScrollState() //TODO: Keep Focus Anchored To the Bottom when expanded and new card added + val scrollState = + rememberScrollState() //TODO: Keep Focus Anchored To the Bottom when expanded and new card added Box( modifier = Modifier .fillMaxWidth() @@ -392,7 +443,6 @@ fun SnackBarStack( var visibleIndex = 0 state.snapshotStateList.forEachIndexed { index, snackBarModel -> var logicalIndex = state.maxCurrentSize - val invertedLogicalIndex = visibleIndex if (!snackBarModel.hidden.value) { logicalIndex = totalVisibleCards - 1 - visibleIndex++ } @@ -458,22 +508,14 @@ private fun SnackBarStackItem( val peekHeight = snackBarStackConfig.cardGapCollapsed - val stackAbove = snackBarStackConfig.stackAbove - val scope = rememberCoroutineScope() val localDensity = LocalDensity.current val isTop = index == 0 - val heightSumAfterIndex = if (isHidden) 0 else state.heightAfterIndex(trueIndex) - - val targetYOffset = - with(localDensity) { if (expanded) heightSumAfterIndex.toFloat() else (index * peekHeight).toPx() } - val animatedYOffset = remember { - Animatable(with(localDensity) { cardHeight.toPx() * if (stackAbove) 1f else -1f }) - } - LaunchedEffect(targetYOffset) { + val animatedYOffset = remember { Animatable(with(localDensity) { cardHeight.toPx() }) } + LaunchedEffect(trueIndex, expanded, state.snapshotStateList.size, state.heightAfterIndex(trueIndex)) { animatedYOffset.animateTo( - targetYOffset * (if (stackAbove) -1f else 1f), + with(localDensity) { if (expanded) -state.heightAfterIndex(trueIndex).toFloat() else (state.visibleCountAfterIndex(trueIndex) * -peekHeight).toPx() }, animationSpec = spring(stiffness = Spring.StiffnessLow) ) } @@ -497,8 +539,14 @@ private fun SnackBarStackItem( val offsetX: Float = if (isTop || expanded) swipeX.value else 0f val token = model.snackBarToken - val snackBarInfo = SnackBarInfo(model.snackBarStyle, false) - + val snackBarInfo = SnackBarInfo(model.style, false) + var textPaddingValues = + if (model.actionText == null && !model.enableDismiss) PaddingValues( + start = 16.dp, + top = 12.dp, + bottom = 12.dp, + end = 16.dp + ) else PaddingValues(start = 16.dp, top = 12.dp, bottom = 12.dp) Box( modifier = Modifier .onGloballyPositioned( @@ -512,7 +560,7 @@ private fun SnackBarStackItem( ) .graphicsLayer( alpha = opacityProgress.value, - translationX = offsetX, //+ (slideInProgress.value * with(localDensity) { 200.dp.toPx() }), + translationX = offsetX, translationY = animatedYOffset.value, scaleX = animatedWidthScale.value, scaleY = animatedWidthScale.value @@ -572,36 +620,122 @@ private fun SnackBarStackItem( } } else Modifier ) - ) { - BasicCard( - modifier = Modifier - .then( - if (!state.expanded) { - Modifier - .height(cardHeight) - .width(cardWidth) - } else { - Modifier - } - ) + Row( + Modifier + .padding(horizontal = 16.dp) + .defaultMinSize(minHeight = 52.dp) + .fillMaxWidth() .shadow( elevation = token.shadowElevationValue(snackBarInfo), - shape = token.cardShape(snackBarInfo), - clip = false + shape = RoundedCornerShape(8.dp) ) - .clip(token.cardShape(snackBarInfo)) + .clip(RoundedCornerShape(8.dp)) .background(token.backgroundBrush(snackBarInfo)) - .clickable( - enabled = isTop || expanded, + .semantics { + liveRegion = LiveRegionMode.Polite + } + .testTag(SNACK_BAR), + verticalAlignment = Alignment.CenterVertically + ) { + if (model.icon != null && model.icon.isIconAvailable()) { + Box( + modifier = Modifier + .testTag(SNACK_BAR_ICON) + .then( + if (model.icon.onClick != null) { + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(), + enabled = true, + role = Role.Image, + onClick = model.icon.onClick!! + ) + } else Modifier + ) + ) { + Icon( + model.icon, + modifier = Modifier + .padding(start = 16.dp, top = 12.dp, bottom = 12.dp) + .size(token.leftIconSize(snackBarInfo)), + tint = token.iconColor(snackBarInfo) + ) + } + } + Column( + Modifier + .weight(1F) + .padding(textPaddingValues) + ) { + BasicText( + text = model.message, + style = token.titleTypography(snackBarInfo) + ) + if (!model.subTitle.isNullOrBlank()) { + BasicText( + text = model.subTitle, + style = token.subtitleTypography(snackBarInfo), + modifier = Modifier.testTag(SNACK_BAR_SUBTITLE) + ) + } + + } + + if (model.actionText != null) { + Button( onClick = { - onClick() + model.onActionTextClicked() + }, + modifier = Modifier + .testTag(SNACK_BAR_ACTION_BUTTON) + .then( + if (!model.enableDismiss) + Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + else + Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp) + ), + text = model.actionText, + style = ButtonStyle.TextButton, + size = ButtonSize.Small, + buttonTokens = object : ButtonTokens() { + @Composable + override fun textColor(buttonInfo: ButtonInfo): StateColor { + return StateColor( + rest = token.iconColor(snackBarInfo), + pressed = token.iconColor(snackBarInfo), + focused = token.iconColor(snackBarInfo), + ) + } } ) - .padding(token.contentPadding(snackBarInfo)) - ) - { - model.content() + } + + if (model.enableDismiss) { + Box( + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(), + enabled = true, + role = Role.Image, + onClickLabel = "Dismiss", + onClick = { + model.onDismissClicked() + } + ) + .testTag(SNACK_BAR_DISMISS_BUTTON) + ) { + Icon( + Icons.Filled.Close, + "Dismiss", + modifier = Modifier + .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 16.dp) + .size(token.dismissIconSize(snackBarInfo)), + tint = token.iconColor(snackBarInfo) + ) + } + } } } } From bfc71dca4b054edc93e57f894b3d493293f1671c Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Thu, 25 Sep 2025 16:01:43 +0530 Subject: [PATCH 36/52] Modified Removal Animation, Added Trailing Fluent Icon --- .../fluentuidemo/demos/V2SnackbarActivity.kt | 63 ++++-- .../notification/StackableSnackbar.kt | 210 +++++++++--------- 2 files changed, 156 insertions(+), 117 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 7f67c9911..f5ac128ae 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 @@ -13,6 +13,7 @@ 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.filled.Close import androidx.compose.material.icons.outlined.ShoppingCart import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -348,7 +349,7 @@ fun SnackBarStackDemo() { var counter by rememberSaveable { mutableIntStateOf(0) } val scope = rememberCoroutineScope() Scrim( - isActivated = stackState.expanded, + isActivated = stackState.expanded && stackState.sizeVisible() > 0, onDismiss = {} ) Column( @@ -367,13 +368,28 @@ fun SnackBarStackDemo() { Button(onClick = { val id = counter++ - stackState.addCard(SnackBarItemModel(id = id.toString(), message = "Snackbar #$id", actionText = "Expand", onDismissClicked = { - scope.launch { - stackState.removeCardByIdWithAnimation(id.toString(), showLastHiddenCardOnRemove = true) - } - }, onActionTextClicked = { - stackState.toggleExpanded() - })) + stackState.addCard( + SnackBarItemModel( + id = id.toString(), + message = "Snackbar #$id", + actionText = "Expand", + trailingIcon = FluentIcon( + Icons.Default.Close, + Icons.Default.Close, + contentDescription = "Close", + onClick = { + scope.launch { + stackState.removeCardByIdWithAnimation( + id.toString(), + showLastHiddenCardOnRemove = true + ) + } + } + ), + onActionTextClicked = { + stackState.toggleExpanded() + }) + ) }, text = "Add Snackbar") Spacer(modifier = Modifier.width(12.dp)) @@ -390,14 +406,29 @@ fun SnackBarStackDemo() { Button(onClick = { scope.launch { val id = counter++ - for(i in 0..15) { - stackState.addCard(SnackBarItemModel(id = "$id-$i", message = "Snackbar #$id-$i".repeat(i + 4), actionText = "Expand", onDismissClicked = { - scope.launch { - stackState.removeCardByIdWithAnimation("$id-$i", showLastHiddenCardOnRemove = true) - } - }, onActionTextClicked = { - stackState.toggleExpanded() - })) + for (i in 0..15) { + stackState.addCard( + SnackBarItemModel( + id = "$id-$i", + message = "Snackbar #$id-$i".repeat(i + 4), + actionText = "Expand", + trailingIcon = FluentIcon( + Icons.Default.Close, + Icons.Default.Close, + contentDescription = "Close", + onClick = { + scope.launch { + stackState.removeCardByIdWithAnimation( + "$id-$i", + showLastHiddenCardOnRemove = true + ) + } + } + ), + onActionTextClicked = { + stackState.toggleExpanded() + }) + ) delay(2000) } } 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 95096a97e..8515fb38f 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 @@ -37,6 +37,7 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.LiveRegionMode @@ -84,14 +85,13 @@ data class SnackBarItemModel( val message: String, val id: String = java.util.UUID.randomUUID().toString(), val style: SnackbarStyle = SnackbarStyle.Neutral, - val enableDismiss: Boolean = true, - val icon: FluentIcon? = null, + val leadingIcon: FluentIcon? = null, + val trailingIcon: FluentIcon? = null, val subTitle: String? = null, val actionText: String? = null, val snackBarToken: StackableSnackBarTokens = DEFAULT_SNACKBAR_TOKENS, val onActionTextClicked: () -> Unit = {}, - val onDismissClicked: () -> Unit = {}, - internal val hidden: MutableState = mutableStateOf(false), + internal val hidden: MutableState = mutableIntStateOf(0), // 0 = visible, 1 = hidden, won't be removed, 2 = being removed ) /** @@ -106,7 +106,8 @@ class SnackBarStackState( internal var maxCollapsedSize: Int = 5, internal var maxExpandedSize: Int = 10 ) { - val contentHeightList: MutableList = MutableList(cards.size) { 0 } + val contentHeightList = mutableStateListOf().apply { addAll(List(cards.size) { 0 }) } + val snapshotStateList: MutableList = mutableStateListOf().apply { addAll(cards) } @@ -155,7 +156,7 @@ class SnackBarStackState( onRemoveCompleteCallback: () -> Unit = {} ) { val card = snapshotStateList.firstOrNull { it.id == id } ?: return - card.hidden.value = true + card.hidden.value = 2 delay(FADE_OUT_DURATION.toLong()) contentHeightList.removeAt(snapshotStateList.indexOf(card)) snapshotStateList.remove(card) @@ -172,7 +173,7 @@ class SnackBarStackState( */ fun hideCardById(id: String): Boolean { snapshotStateList.firstOrNull { it.id == id }?.let { - it.hidden.value = true + it.hidden.value = 1 return true } return false @@ -185,7 +186,7 @@ class SnackBarStackState( */ fun showCardById(id: String): Boolean { snapshotStateList.firstOrNull { it.id == id }?.let { - it.hidden.value = false + it.hidden.value = 0 return true } return false @@ -202,12 +203,12 @@ class SnackBarStackState( } private fun onVisibleSizeChange() { - val currentSize = snapshotStateList.count { !it.hidden.value } + val currentSize = snapshotStateList.count { it.hidden.value == 0 } val (count, sequence, targetHidden) = if (currentSize > maxCurrentSize) { - Triple(currentSize - maxCurrentSize, snapshotStateList, true) + Triple(currentSize - maxCurrentSize, snapshotStateList, 1) } else { - Triple(maxCurrentSize - currentSize, snapshotStateList.asReversed(), false) + Triple(maxCurrentSize - currentSize, snapshotStateList.asReversed(), 0) } var slots = count @@ -225,8 +226,8 @@ class SnackBarStackState( * @return `true` if a card was hidden, `false` otherwise. */ fun hideBack(): Boolean { - snapshotStateList.firstOrNull { !it.hidden.value }?.let { - it.hidden.value = true + snapshotStateList.firstOrNull { it.hidden.value == 0 }?.let { + it.hidden.value = 1 return true } return false @@ -237,8 +238,8 @@ class SnackBarStackState( * @return `true` if a card was hidden, `false` otherwise. */ fun hideFront(): Boolean { - snapshotStateList.lastOrNull { !it.hidden.value }?.let { - it.hidden.value = true + snapshotStateList.lastOrNull { it.hidden.value == 0 }?.let { + it.hidden.value = 1 return true } return false @@ -250,7 +251,7 @@ class SnackBarStackState( * @return `true` if a card was removed, `false` otherwise. */ fun removeBack(skipHidden: Boolean = false): Boolean { - snapshotStateList.firstOrNull { skipHidden && !it.hidden.value || !skipHidden }?.let { + snapshotStateList.firstOrNull { skipHidden && it.hidden.value == 0 || !skipHidden }?.let { snapshotStateList.remove(it) return true } @@ -263,7 +264,7 @@ class SnackBarStackState( * @return `true` if a card was removed, `false` otherwise. */ fun removeFront(skipHidden: Boolean = false): Boolean { - snapshotStateList.lastOrNull { skipHidden && !it.hidden.value || !skipHidden }?.let { + snapshotStateList.lastOrNull { skipHidden && it.hidden.value == 0 || !skipHidden }?.let { contentHeightList.removeAt(snapshotStateList.indexOf(it)) snapshotStateList.remove(it) return true @@ -276,8 +277,8 @@ class SnackBarStackState( * @return `true` if a hidden card was shown, `false` otherwise. */ fun showBack(): Boolean { - snapshotStateList.lastOrNull { it.hidden.value }?.let { - it.hidden.value = false + snapshotStateList.lastOrNull { it.hidden.value == 1 }?.let { + it.hidden.value = 0 return true } return false @@ -288,8 +289,8 @@ class SnackBarStackState( * @return `true` if a hidden card was shown, `false` otherwise. */ fun showFront(): Boolean { - snapshotStateList.firstOrNull { it.hidden.value }?.let { - it.hidden.value = false + snapshotStateList.firstOrNull { it.hidden.value == 1 }?.let { + it.hidden.value = 0 return true } return false @@ -300,7 +301,7 @@ class SnackBarStackState( */ fun showAll() { snapshotStateList.forEach { - it.hidden.value = false + it.hidden.value = 0 } } @@ -310,7 +311,7 @@ class SnackBarStackState( internal fun heightBeforeIndex(index: Int): Int { return contentHeightList .take(index) - .filterIndexed { i, _ -> !snapshotStateList[i].hidden.value } + .filterIndexed { i, _ -> snapshotStateList[i].hidden.value == 0 } .sum() } @@ -319,7 +320,7 @@ class SnackBarStackState( .drop(index + 1) .filterIndexed { i, _ -> // i here starts from 0 because of drop, so offset by index+1 - !snapshotStateList[i + index + 1].hidden.value + snapshotStateList[i + index + 1].hidden.value == 0 } .sum() } @@ -328,7 +329,7 @@ class SnackBarStackState( return contentHeightList .drop(index + 1) .filterIndexed { i, _ -> - !snapshotStateList[i + index + 1].hidden.value + snapshotStateList[i + index + 1].hidden.value == 0 }.size } @@ -340,7 +341,7 @@ class SnackBarStackState( /** * @return The number of currently visible cards in the stack. */ - fun sizeVisible(): Int = snapshotStateList.count { !it.hidden.value } + fun sizeVisible(): Int = snapshotStateList.count { it.hidden.value == 0 } } /** @@ -409,27 +410,27 @@ fun SnackBarStack( enableSwipeToDismiss: Boolean = true, expandOnCardClick: Boolean = true, ) { - val visibleCardsCount by remember { derivedStateOf { state.sizeVisible() } } val localDensity = LocalDensity.current - val cardHeight = snackBarStackConfig.cardHeightCollapsed - val peekHeight = snackBarStackConfig.cardGapCollapsed + + val visibleCardsCount by remember { derivedStateOf { state.sizeVisible() } } val targetHeight = if (visibleCardsCount == 0) { - 0.dp + snackBarStackConfig.cardHeightCollapsed } else if (state.expanded) { with(localDensity) { state.heightAfterIndex(0).toDp() + 200.dp } } else { - cardHeight + (visibleCardsCount - 1) * peekHeight + snackBarStackConfig.cardHeightCollapsed + (visibleCardsCount - 1) * snackBarStackConfig.cardGapCollapsed } val animatedStackHeight by animateDpAsState( //TODO: CHANGE TO LAUNCHED EFFECT WITH ONLY A TRIGGER ON VISIBLE CARDS TOTAL HEIGHT CHANGE targetValue = targetHeight, - animationSpec = spring(stiffness = Spring.StiffnessMedium) + animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing) ) + val scrollState = rememberScrollState() //TODO: Keep Focus Anchored To the Bottom when expanded and new card added Box( modifier = Modifier .fillMaxWidth() - .height(animatedStackHeight) + .wrapContentHeight() .then(if (state.expanded) Modifier.verticalScroll(scrollState) else Modifier), contentAlignment = Alignment.BottomCenter ) { @@ -442,27 +443,17 @@ fun SnackBarStack( val totalVisibleCards = state.sizeVisible() var visibleIndex = 0 state.snapshotStateList.forEachIndexed { index, snackBarModel -> - var logicalIndex = state.maxCurrentSize - if (!snackBarModel.hidden.value) { - logicalIndex = totalVisibleCards - 1 - visibleIndex++ - } + val logicalIndex = totalVisibleCards - 1 - visibleIndex + visibleIndex += if (snackBarModel.hidden.value == 0) 1 else 0 key(snackBarModel.id) { SnackBarStackItem( - model = snackBarModel, state = state, - isHidden = snackBarModel.hidden.value, - expanded = state.expanded, - index = logicalIndex, + visibleIndex = logicalIndex, trueIndex = index, onSwipedAway = { idToRemove -> state.removeCardById(idToRemove) state.showBack() }, - onClick = { - if (expandOnCardClick) { - state.toggleExpanded() - } - }, snackBarStackConfig = snackBarStackConfig, enableSwipeToDismiss = enableSwipeToDismiss ) @@ -489,59 +480,70 @@ fun SnackBarStack( */ @Composable private fun SnackBarStackItem( - model: SnackBarItemModel, state: SnackBarStackState, - isHidden: Boolean, - expanded: Boolean, - index: Int, + visibleIndex: Int, trueIndex: Int, stackedWidthScaleFactor: Float = STACKED_WIDTH_SCALE_FACTOR, onSwipedAway: (String) -> Unit, - onClick: () -> Unit = {}, snackBarStackConfig: SnackBarStackConfig, enableSwipeToDismiss: Boolean = true, ) { - - val cardWidth = snackBarStackConfig.cardWidthCollapsed - + val model = state.snapshotStateList[trueIndex] val cardHeight = snackBarStackConfig.cardHeightCollapsed - val peekHeight = snackBarStackConfig.cardGapCollapsed + val screenWidth = LocalConfiguration.current.screenWidthDp.dp val scope = rememberCoroutineScope() val localDensity = LocalDensity.current - val isTop = index == 0 + val isTop = visibleIndex == 0 val animatedYOffset = remember { Animatable(with(localDensity) { cardHeight.toPx() }) } - LaunchedEffect(trueIndex, expanded, state.snapshotStateList.size, state.heightAfterIndex(trueIndex)) { + LaunchedEffect( + trueIndex, + state.expanded, + state.snapshotStateList.size, + state.heightAfterIndex(trueIndex), + model.hidden.value + ) { + if (model.hidden.value == 2) { + return@LaunchedEffect + } animatedYOffset.animateTo( - with(localDensity) { if (expanded) -state.heightAfterIndex(trueIndex).toFloat() else (state.visibleCountAfterIndex(trueIndex) * -peekHeight).toPx() }, + with(localDensity) { + if (state.expanded) -state.heightAfterIndex(trueIndex) + .toFloat() else (state.visibleCountAfterIndex(trueIndex) * -peekHeight).toPx() + }, animationSpec = spring(stiffness = Spring.StiffnessLow) ) } - val targetWidthScale = if (expanded) 1f else stackedWidthScaleFactor.pow(index) + val targetWidthScale = if (state.expanded) 1f else stackedWidthScaleFactor.pow(visibleIndex) val animatedWidthScale = animateFloatAsState( targetValue = targetWidthScale, animationSpec = tween(durationMillis = 100, easing = FastOutSlowInEasing) ) val opacityProgress = remember { Animatable(0f) } - LaunchedEffect(isHidden) { - if (isHidden) { - opacityProgress.animateTo(0f, tween(FADE_OUT_DURATION)) - } else { - opacityProgress.animateTo(1f, tween(FADE_OUT_DURATION)) - } + LaunchedEffect(model.hidden.value) { + opacityProgress.animateTo(if (model.hidden.value > 0) 0f else 1f, tween(FADE_OUT_DURATION)) } val swipeX = remember { Animatable(0f) } - val offsetX: Float = if (isTop || expanded) swipeX.value else 0f + LaunchedEffect(model.hidden.value, state.expanded) { + if (model.hidden.value == 2) { + swipeX.animateTo( + with(localDensity) { -screenWidth.toPx() * 1.2f }, + animationSpec = tween(durationMillis = 240, easing = FastOutLinearInEasing) + ) + } else { + swipeX.snapTo(0f) + } + } val token = model.snackBarToken val snackBarInfo = SnackBarInfo(model.style, false) - var textPaddingValues = - if (model.actionText == null && !model.enableDismiss) PaddingValues( + val textPaddingValues = + if (model.actionText == null && model.trailingIcon != null) PaddingValues( start = 16.dp, top = 12.dp, bottom = 12.dp, @@ -549,18 +551,25 @@ private fun SnackBarStackItem( ) else PaddingValues(start = 16.dp, top = 12.dp, bottom = 12.dp) Box( modifier = Modifier - .onGloballyPositioned( - onGloballyPositioned = { coordinates: LayoutCoordinates -> - val contentHeight = coordinates.size.height - state.contentHeightList[trueIndex] = contentHeight + with(localDensity) { - 20.dp.toPx().toInt() - } - return@onGloballyPositioned + .then( + if (state.expanded) { + Modifier.onGloballyPositioned( + onGloballyPositioned = { coordinates: LayoutCoordinates -> + val contentHeight = coordinates.size.height + state.contentHeightList[trueIndex] = + contentHeight + with(localDensity) { + 20.dp.toPx().toInt() + } + return@onGloballyPositioned + } + ) + } else { + Modifier } ) .graphicsLayer( alpha = opacityProgress.value, - translationX = offsetX, + translationX = swipeX.value, translationY = animatedYOffset.value, scaleX = animatedWidthScale.value, scaleY = animatedWidthScale.value @@ -570,23 +579,22 @@ private fun SnackBarStackItem( Modifier.wrapContentSize() } else { Modifier - .width(cardWidth) .height(cardHeight) } ) .padding(horizontal = 0.dp) .then( - if (enableSwipeToDismiss && (isTop || expanded)) Modifier.pointerInput(model.id) { + if (enableSwipeToDismiss && (isTop || state.expanded)) Modifier.pointerInput(model.id) { detectHorizontalDragGestures( onDragStart = {}, onDragEnd = { - val threshold = with(localDensity) { (cardWidth / 4).toPx() } + val threshold = with(localDensity) { (screenWidth / 4).toPx() } scope.launch { if (abs(swipeX.value) > threshold) { val target = if (swipeX.value > 0) - with(localDensity) { cardWidth.toPx() * 1.2f } + with(localDensity) { screenWidth.toPx() * 1.2f } else - -with(localDensity) { cardWidth.toPx() * 1.2f } + -with(localDensity) { screenWidth.toPx() * 1.2f } swipeX.animateTo( target, @@ -638,24 +646,24 @@ private fun SnackBarStackItem( .testTag(SNACK_BAR), verticalAlignment = Alignment.CenterVertically ) { - if (model.icon != null && model.icon.isIconAvailable()) { + if (model.leadingIcon != null && model.leadingIcon.isIconAvailable()) { Box( modifier = Modifier .testTag(SNACK_BAR_ICON) .then( - if (model.icon.onClick != null) { + if (model.leadingIcon.onClick != null) { Modifier.clickable( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(), enabled = true, role = Role.Image, - onClick = model.icon.onClick!! + onClick = model.leadingIcon.onClick!! ) } else Modifier ) ) { Icon( - model.icon, + model.leadingIcon, modifier = Modifier .padding(start = 16.dp, top = 12.dp, bottom = 12.dp) .size(token.leftIconSize(snackBarInfo)), @@ -690,7 +698,7 @@ private fun SnackBarStackItem( modifier = Modifier .testTag(SNACK_BAR_ACTION_BUTTON) .then( - if (!model.enableDismiss) + if (model.trailingIcon != null) Modifier.padding(horizontal = 16.dp, vertical = 12.dp) else Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp) @@ -711,27 +719,27 @@ private fun SnackBarStackItem( ) } - if (model.enableDismiss) { + if (model.trailingIcon != null && model.trailingIcon.isIconAvailable()) { Box( modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(), - enabled = true, - role = Role.Image, - onClickLabel = "Dismiss", - onClick = { - model.onDismissClicked() - } + .testTag(SNACK_BAR_ICON) + .then( + if (model.trailingIcon.onClick != null) { + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(), + enabled = true, + role = Role.Image, + onClick = model.trailingIcon.onClick!! + ) + } else Modifier ) - .testTag(SNACK_BAR_DISMISS_BUTTON) ) { Icon( - Icons.Filled.Close, - "Dismiss", + model.trailingIcon, modifier = Modifier - .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 16.dp) - .size(token.dismissIconSize(snackBarInfo)), + .padding(top = 12.dp, bottom = 12.dp, end = 16.dp) + .size(token.leftIconSize(snackBarInfo)), tint = token.iconColor(snackBarInfo) ) } From a81e334fd013c21175a50e2bf3336bdafc01db3c Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Thu, 25 Sep 2025 18:07:22 +0530 Subject: [PATCH 37/52] Moved visibility to map and out of model --- .../notification/StackableSnackbar.kt | 107 ++++++++++-------- 1 file changed, 59 insertions(+), 48 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 8515fb38f..630f0477a 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 @@ -27,8 +27,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -56,9 +54,7 @@ import com.microsoft.fluentui.theme.token.controlTokens.ButtonTokens import com.microsoft.fluentui.theme.token.controlTokens.SnackBarInfo import com.microsoft.fluentui.theme.token.controlTokens.SnackbarStyle import com.microsoft.fluentui.theme.token.controlTokens.StackableSnackBarTokens -import com.microsoft.fluentui.tokenized.controls.BasicCard import com.microsoft.fluentui.tokenized.controls.Button -import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.delay import kotlin.math.abs import kotlin.math.pow @@ -70,14 +66,14 @@ private const val STACKED_WIDTH_SCALE_FACTOR = 0.95f //TODO: Perf, reduce recompositions, make stable, minimize launch effect tracked variables /** - * Represents a single item in the snackBar stack. - * - * @param snackBarToken The tokens to customize the appearance of the snackBar. - * @param snackBarStyle The style of the snackBar, e.g., Neutral, Success, Error. - * @param id A unique identifier for this snackBar item. Defaults to a random UUID. - * @param hidden A mutable state to control the visibility of the snackBar item. - * @param content The composable content to be displayed inside the snackBar. + * Represents possible visibility states for snackBar items. */ +enum class ItemVisibility { + Visible, + Hidden, + BeingRemoved +} + private val DEFAULT_SNACKBAR_TOKENS = StackableSnackBarTokens() @Stable @@ -90,8 +86,7 @@ data class SnackBarItemModel( val subTitle: String? = null, val actionText: String? = null, val snackBarToken: StackableSnackBarTokens = DEFAULT_SNACKBAR_TOKENS, - val onActionTextClicked: () -> Unit = {}, - internal val hidden: MutableState = mutableIntStateOf(0), // 0 = visible, 1 = hidden, won't be removed, 2 = being removed + val onActionTextClicked: () -> Unit = {} ) /** @@ -107,6 +102,7 @@ class SnackBarStackState( internal var maxExpandedSize: Int = 10 ) { val contentHeightList = mutableStateListOf().apply { addAll(List(cards.size) { 0 }) } + val itemVisibilityMap = mutableStateMapOf().apply { putAll(cards.associate { it.id to ItemVisibility.Visible }) } val snapshotStateList: MutableList = mutableStateListOf().apply { addAll(cards) } @@ -131,6 +127,7 @@ class SnackBarStackState( maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize snapshotStateList.add(card) contentHeightList.add(0) + itemVisibilityMap[card.id] = ItemVisibility.Visible if (sizeVisible() > maxCurrentSize) { hideBack() } @@ -143,8 +140,12 @@ class SnackBarStackState( */ fun removeCardById(id: String): Boolean { snapshotStateList.firstOrNull { it.id == id }?.let { - contentHeightList.removeAt(snapshotStateList.indexOf(it)) + val idx = snapshotStateList.indexOf(it) + if (idx in contentHeightList.indices) { + contentHeightList.removeAt(idx) + } snapshotStateList.remove(it) + itemVisibilityMap.remove(it.id) return true } return false @@ -156,10 +157,14 @@ class SnackBarStackState( onRemoveCompleteCallback: () -> Unit = {} ) { val card = snapshotStateList.firstOrNull { it.id == id } ?: return - card.hidden.value = 2 + itemVisibilityMap[card.id] = ItemVisibility.BeingRemoved delay(FADE_OUT_DURATION.toLong()) - contentHeightList.removeAt(snapshotStateList.indexOf(card)) + val idx = snapshotStateList.indexOf(card) + if (idx in contentHeightList.indices) { + contentHeightList.removeAt(idx) + } snapshotStateList.remove(card) + itemVisibilityMap.remove(card.id) if (showLastHiddenCardOnRemove) { onVisibleSizeChange() } @@ -173,7 +178,7 @@ class SnackBarStackState( */ fun hideCardById(id: String): Boolean { snapshotStateList.firstOrNull { it.id == id }?.let { - it.hidden.value = 1 + itemVisibilityMap[it.id] = ItemVisibility.Hidden return true } return false @@ -186,7 +191,7 @@ class SnackBarStackState( */ fun showCardById(id: String): Boolean { snapshotStateList.firstOrNull { it.id == id }?.let { - it.hidden.value = 0 + itemVisibilityMap[it.id] = ItemVisibility.Visible return true } return false @@ -203,19 +208,19 @@ class SnackBarStackState( } private fun onVisibleSizeChange() { - val currentSize = snapshotStateList.count { it.hidden.value == 0 } + val currentSize = snapshotStateList.count { itemVisibilityMap[it.id] == ItemVisibility.Visible } val (count, sequence, targetHidden) = if (currentSize > maxCurrentSize) { - Triple(currentSize - maxCurrentSize, snapshotStateList, 1) + Triple(currentSize - maxCurrentSize, snapshotStateList, ItemVisibility.Hidden) } else { - Triple(maxCurrentSize - currentSize, snapshotStateList.asReversed(), 0) + Triple(maxCurrentSize - currentSize, snapshotStateList.asReversed(), ItemVisibility.Visible) } var slots = count sequence.forEach { if (slots <= 0) return@forEach - if (it.hidden.value != targetHidden) { - it.hidden.value = targetHidden + if (itemVisibilityMap[it.id] != targetHidden) { + itemVisibilityMap[it.id] = targetHidden slots-- } } @@ -226,8 +231,8 @@ class SnackBarStackState( * @return `true` if a card was hidden, `false` otherwise. */ fun hideBack(): Boolean { - snapshotStateList.firstOrNull { it.hidden.value == 0 }?.let { - it.hidden.value = 1 + snapshotStateList.firstOrNull { itemVisibilityMap[it.id] == ItemVisibility.Visible }?.let { + itemVisibilityMap[it.id] = ItemVisibility.Hidden return true } return false @@ -238,8 +243,8 @@ class SnackBarStackState( * @return `true` if a card was hidden, `false` otherwise. */ fun hideFront(): Boolean { - snapshotStateList.lastOrNull { it.hidden.value == 0 }?.let { - it.hidden.value = 1 + snapshotStateList.lastOrNull { itemVisibilityMap[it.id] == ItemVisibility.Visible }?.let { + itemVisibilityMap[it.id] = ItemVisibility.Hidden return true } return false @@ -251,8 +256,9 @@ class SnackBarStackState( * @return `true` if a card was removed, `false` otherwise. */ fun removeBack(skipHidden: Boolean = false): Boolean { - snapshotStateList.firstOrNull { skipHidden && it.hidden.value == 0 || !skipHidden }?.let { + snapshotStateList.firstOrNull { (skipHidden && itemVisibilityMap[it.id] == ItemVisibility.Visible) || !skipHidden }?.let { snapshotStateList.remove(it) + itemVisibilityMap.remove(it.id) return true } return false @@ -264,9 +270,13 @@ class SnackBarStackState( * @return `true` if a card was removed, `false` otherwise. */ fun removeFront(skipHidden: Boolean = false): Boolean { - snapshotStateList.lastOrNull { skipHidden && it.hidden.value == 0 || !skipHidden }?.let { - contentHeightList.removeAt(snapshotStateList.indexOf(it)) + snapshotStateList.lastOrNull { (skipHidden && itemVisibilityMap[it.id] == ItemVisibility.Visible) || !skipHidden }?.let { + val idx = snapshotStateList.indexOf(it) + if (idx in contentHeightList.indices) { + contentHeightList.removeAt(idx) + } snapshotStateList.remove(it) + itemVisibilityMap.remove(it.id) return true } return false @@ -277,8 +287,8 @@ class SnackBarStackState( * @return `true` if a hidden card was shown, `false` otherwise. */ fun showBack(): Boolean { - snapshotStateList.lastOrNull { it.hidden.value == 1 }?.let { - it.hidden.value = 0 + snapshotStateList.lastOrNull { itemVisibilityMap[it.id] == ItemVisibility.Hidden }?.let { + itemVisibilityMap[it.id] = ItemVisibility.Visible return true } return false @@ -289,8 +299,8 @@ class SnackBarStackState( * @return `true` if a hidden card was shown, `false` otherwise. */ fun showFront(): Boolean { - snapshotStateList.firstOrNull { it.hidden.value == 1 }?.let { - it.hidden.value = 0 + snapshotStateList.firstOrNull { itemVisibilityMap[it.id] == ItemVisibility.Hidden }?.let { + itemVisibilityMap[it.id] = ItemVisibility.Visible return true } return false @@ -301,7 +311,7 @@ class SnackBarStackState( */ fun showAll() { snapshotStateList.forEach { - it.hidden.value = 0 + itemVisibilityMap[it.id] = ItemVisibility.Visible } } @@ -311,7 +321,7 @@ class SnackBarStackState( internal fun heightBeforeIndex(index: Int): Int { return contentHeightList .take(index) - .filterIndexed { i, _ -> snapshotStateList[i].hidden.value == 0 } + .filterIndexed { i, _ -> itemVisibilityMap[snapshotStateList[i].id] == ItemVisibility.Visible } .sum() } @@ -320,7 +330,7 @@ class SnackBarStackState( .drop(index + 1) .filterIndexed { i, _ -> // i here starts from 0 because of drop, so offset by index+1 - snapshotStateList[i + index + 1].hidden.value == 0 + itemVisibilityMap[snapshotStateList[i + index + 1].id] == ItemVisibility.Visible } .sum() } @@ -329,7 +339,7 @@ class SnackBarStackState( return contentHeightList .drop(index + 1) .filterIndexed { i, _ -> - snapshotStateList[i + index + 1].hidden.value == 0 + itemVisibilityMap[snapshotStateList[i + index + 1].id] == ItemVisibility.Visible }.size } @@ -341,13 +351,13 @@ class SnackBarStackState( /** * @return The number of currently visible cards in the stack. */ - fun sizeVisible(): Int = snapshotStateList.count { it.hidden.value == 0 } + fun sizeVisible(): Int = snapshotStateList.count { itemVisibilityMap[it.id] == ItemVisibility.Visible } } /** * Creates and remembers a [SnackbarStackState] instance. * - * @param initial The initial list of [SnackbarItemModel]s to populate the stack. + * @param initial The initial list of [SnackBarItemModel]s to populate the stack. * @param maxExpandedSize The maximum number of visible snackBars when the stack is expanded. * @param maxCollapsedSize The maximum number of visible snackBars when the stack is collapsed. * @return A remembered [SnackbarStackState] instance. @@ -444,7 +454,7 @@ fun SnackBarStack( var visibleIndex = 0 state.snapshotStateList.forEachIndexed { index, snackBarModel -> val logicalIndex = totalVisibleCards - 1 - visibleIndex - visibleIndex += if (snackBarModel.hidden.value == 0) 1 else 0 + visibleIndex += if (state.itemVisibilityMap[snackBarModel.id] == ItemVisibility.Visible) 1 else 0 key(snackBarModel.id) { SnackBarStackItem( state = state, @@ -503,9 +513,9 @@ private fun SnackBarStackItem( state.expanded, state.snapshotStateList.size, state.heightAfterIndex(trueIndex), - model.hidden.value + state.itemVisibilityMap[model.id] ) { - if (model.hidden.value == 2) { + if (state.itemVisibilityMap[model.id] == ItemVisibility.BeingRemoved) { return@LaunchedEffect } animatedYOffset.animateTo( @@ -524,13 +534,14 @@ private fun SnackBarStackItem( ) val opacityProgress = remember { Animatable(0f) } - LaunchedEffect(model.hidden.value) { - opacityProgress.animateTo(if (model.hidden.value > 0) 0f else 1f, tween(FADE_OUT_DURATION)) + LaunchedEffect(state.itemVisibilityMap[model.id]) { + val visibility = state.itemVisibilityMap[model.id] ?: ItemVisibility.Visible + opacityProgress.animateTo(if (visibility != ItemVisibility.Visible) 0f else 1f, tween(FADE_OUT_DURATION)) } val swipeX = remember { Animatable(0f) } - LaunchedEffect(model.hidden.value, state.expanded) { - if (model.hidden.value == 2) { + LaunchedEffect(state.itemVisibilityMap[model.id], state.expanded) { + if (state.itemVisibilityMap[model.id] == ItemVisibility.BeingRemoved) { swipeX.animateTo( with(localDensity) { -screenWidth.toPx() * 1.2f }, animationSpec = tween(durationMillis = 240, easing = FastOutLinearInEasing) @@ -779,4 +790,4 @@ fun Scrim( ) ) } -} \ No newline at end of file +} From 87ef9ee7080440b3305ac0cd1f8038212ec9d42d Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Fri, 26 Sep 2025 16:02:39 +0530 Subject: [PATCH 38/52] Updated Heights To Use Map --- .../notification/StackableSnackbar.kt | 226 ++++-------------- 1 file changed, 45 insertions(+), 181 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 630f0477a..4f8a48a9b 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 @@ -44,6 +44,7 @@ import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.times +import com.microsoft.fluentui.theme.token.FluentGlobalTokens import com.microsoft.fluentui.theme.token.FluentIcon import com.microsoft.fluentui.theme.token.Icon import com.microsoft.fluentui.theme.token.StateColor @@ -65,16 +66,18 @@ private const val STACKED_WIDTH_SCALE_FACTOR = 0.95f //TODO: Add accessibility support for the stack and individual cards //TODO: Perf, reduce recompositions, make stable, minimize launch effect tracked variables -/** - * Represents possible visibility states for snackBar items. - */ enum class ItemVisibility { Visible, Hidden, BeingRemoved } -private val DEFAULT_SNACKBAR_TOKENS = StackableSnackBarTokens() +private val DEFAULT_SNACKBAR_TOKENS = object : StackableSnackBarTokens() { + @Composable + override fun shadowElevationValue(snackBarInfo: SnackBarInfo): Dp { + return FluentGlobalTokens.ShadowTokens.Shadow08.value + } +} @Stable data class SnackBarItemModel( @@ -89,63 +92,42 @@ data class SnackBarItemModel( val onActionTextClicked: () -> Unit = {} ) -/** - * Manages the state of a [SnackbarStack]. It allows for adding, removing, hiding, and showing snackBar items. - * - * @param cards The initial list of [SnackbarItemModel] to be displayed. - * @param maxCollapsedSize The maximum number of visible snackBars when the stack is collapsed. - * @param maxExpandedSize The maximum number of visible snackBars when the stack is expanded. - */ class SnackBarStackState( internal val cards: MutableList, internal var maxCollapsedSize: Int = 5, internal var maxExpandedSize: Int = 10 ) { - val contentHeightList = mutableStateListOf().apply { addAll(List(cards.size) { 0 }) } - val itemVisibilityMap = mutableStateMapOf().apply { putAll(cards.associate { it.id to ItemVisibility.Visible }) } + val contentHeightMap = + mutableStateMapOf().apply { putAll(cards.associate { it.id to 0 }) } + val itemVisibilityMap = + mutableStateMapOf().apply { putAll(cards.associate { it.id to ItemVisibility.Visible }) } val snapshotStateList: MutableList = mutableStateListOf().apply { addAll(cards) } - /** - * Whether the snackBar stack is currently expanded. - */ var expanded by mutableStateOf(false) private set internal var maxCurrentSize = maxCollapsedSize - /** - * Adds a new snackBar card to the top of the stack. - * If the number of visible cards exceeds the current maximum, the card at the back is hidden. - * @param card The [SnackbarItemModel] to add. - */ fun addCard(card: SnackBarItemModel) { if (snapshotStateList.any { it.id == card.id }) { return } maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize snapshotStateList.add(card) - contentHeightList.add(0) + contentHeightMap[card.id] = 0 itemVisibilityMap[card.id] = ItemVisibility.Visible if (sizeVisible() > maxCurrentSize) { hideBack() } } - /** - * Removes a snackBar card from the stack by its ID. - * @param id The unique identifier of the card to remove. - * @return `true` if a card was removed, `false` otherwise. - */ fun removeCardById(id: String): Boolean { snapshotStateList.firstOrNull { it.id == id }?.let { - val idx = snapshotStateList.indexOf(it) - if (idx in contentHeightList.indices) { - contentHeightList.removeAt(idx) - } - snapshotStateList.remove(it) + contentHeightMap.remove(it.id) itemVisibilityMap.remove(it.id) + snapshotStateList.remove(it) return true } return false @@ -159,10 +141,7 @@ class SnackBarStackState( val card = snapshotStateList.firstOrNull { it.id == id } ?: return itemVisibilityMap[card.id] = ItemVisibility.BeingRemoved delay(FADE_OUT_DURATION.toLong()) - val idx = snapshotStateList.indexOf(card) - if (idx in contentHeightList.indices) { - contentHeightList.removeAt(idx) - } + contentHeightMap.remove(card.id) snapshotStateList.remove(card) itemVisibilityMap.remove(card.id) if (showLastHiddenCardOnRemove) { @@ -171,36 +150,6 @@ class SnackBarStackState( onRemoveCompleteCallback() } - /** - * Hides a snackBar card by its ID without removing it from the stack. - * @param id The unique identifier of the card to hide. - * @return `true` if the card was found and hidden, `false` otherwise. - */ - fun hideCardById(id: String): Boolean { - snapshotStateList.firstOrNull { it.id == id }?.let { - itemVisibilityMap[it.id] = ItemVisibility.Hidden - return true - } - return false - } - - /** - * Shows a previously hidden snackBar card by its ID. - * @param id The unique identifier of the card to show. - * @return `true` if the card was found and shown, `false` otherwise. - */ - fun showCardById(id: String): Boolean { - snapshotStateList.firstOrNull { it.id == id }?.let { - itemVisibilityMap[it.id] = ItemVisibility.Visible - return true - } - return false - } - - /** - * Toggles the expanded/collapsed state of the snackBar stack. - * This adjusts the visibility of cards based on [maxCollapsedSize] and [maxExpandedSize]. - */ fun toggleExpanded() { expanded = !expanded maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize @@ -208,12 +157,17 @@ class SnackBarStackState( } private fun onVisibleSizeChange() { - val currentSize = snapshotStateList.count { itemVisibilityMap[it.id] == ItemVisibility.Visible } + val currentSize = + snapshotStateList.count { itemVisibilityMap[it.id] == ItemVisibility.Visible } val (count, sequence, targetHidden) = if (currentSize > maxCurrentSize) { Triple(currentSize - maxCurrentSize, snapshotStateList, ItemVisibility.Hidden) } else { - Triple(maxCurrentSize - currentSize, snapshotStateList.asReversed(), ItemVisibility.Visible) + Triple( + maxCurrentSize - currentSize, + snapshotStateList.asReversed(), + ItemVisibility.Visible + ) } var slots = count @@ -226,10 +180,6 @@ class SnackBarStackState( } } - /** - * Hides the card at the back of the visible stack (the one added earliest). - * @return `true` if a card was hidden, `false` otherwise. - */ fun hideBack(): Boolean { snapshotStateList.firstOrNull { itemVisibilityMap[it.id] == ItemVisibility.Visible }?.let { itemVisibilityMap[it.id] = ItemVisibility.Hidden @@ -238,10 +188,6 @@ class SnackBarStackState( return false } - /** - * Hides the card at the front of the visible stack (the one added most recently). - * @return `true` if a card was hidden, `false` otherwise. - */ fun hideFront(): Boolean { snapshotStateList.lastOrNull { itemVisibilityMap[it.id] == ItemVisibility.Visible }?.let { itemVisibilityMap[it.id] = ItemVisibility.Hidden @@ -250,42 +196,17 @@ class SnackBarStackState( return false } - /** - * Removes the card at the back of the stack. - * @param skipHidden If `true`, removes the oldest *visible* card. If `false`, removes the oldest card regardless of visibility. - * @return `true` if a card was removed, `false` otherwise. - */ - fun removeBack(skipHidden: Boolean = false): Boolean { - snapshotStateList.firstOrNull { (skipHidden && itemVisibilityMap[it.id] == ItemVisibility.Visible) || !skipHidden }?.let { - snapshotStateList.remove(it) - itemVisibilityMap.remove(it.id) - return true - } - return false - } - - /** - * Removes the card at the front of the stack. - * @param skipHidden If `true`, removes the newest *visible* card. If `false`, removes the newest card regardless of visibility. - * @return `true` if a card was removed, `false` otherwise. - */ fun removeFront(skipHidden: Boolean = false): Boolean { - snapshotStateList.lastOrNull { (skipHidden && itemVisibilityMap[it.id] == ItemVisibility.Visible) || !skipHidden }?.let { - val idx = snapshotStateList.indexOf(it) - if (idx in contentHeightList.indices) { - contentHeightList.removeAt(idx) + snapshotStateList.lastOrNull { (skipHidden && itemVisibilityMap[it.id] == ItemVisibility.Visible) || !skipHidden } + ?.let { + contentHeightMap.remove(it.id) + snapshotStateList.remove(it) + itemVisibilityMap.remove(it.id) + return true } - snapshotStateList.remove(it) - itemVisibilityMap.remove(it.id) - return true - } return false } - /** - * Shows the newest hidden card from the back of the stack. - * @return `true` if a hidden card was shown, `false` otherwise. - */ fun showBack(): Boolean { snapshotStateList.lastOrNull { itemVisibilityMap[it.id] == ItemVisibility.Hidden }?.let { itemVisibilityMap[it.id] = ItemVisibility.Visible @@ -294,64 +215,26 @@ class SnackBarStackState( return false } - /** - * Shows the oldest hidden card from the front of the stack. - * @return `true` if a hidden card was shown, `false` otherwise. - */ - fun showFront(): Boolean { - snapshotStateList.firstOrNull { itemVisibilityMap[it.id] == ItemVisibility.Hidden }?.let { - itemVisibilityMap[it.id] = ItemVisibility.Visible - return true - } - return false - } - - /** - * Makes all snackBar cards in the stack visible. - */ - fun showAll() { - snapshotStateList.forEach { - itemVisibilityMap[it.id] = ItemVisibility.Visible - } - } - - /** - * Internal Functions to get offset heights when expanded - */ - internal fun heightBeforeIndex(index: Int): Int { - return contentHeightList - .take(index) - .filterIndexed { i, _ -> itemVisibilityMap[snapshotStateList[i].id] == ItemVisibility.Visible } - .sum() - } - internal fun heightAfterIndex(index: Int): Int { - return contentHeightList - .drop(index + 1) - .filterIndexed { i, _ -> - // i here starts from 0 because of drop, so offset by index+1 - itemVisibilityMap[snapshotStateList[i + index + 1].id] == ItemVisibility.Visible + var ans = 0 + snapshotStateList.drop(index + 1).forEach { + if (itemVisibilityMap[it.id] == ItemVisibility.Visible) { + ans += (contentHeightMap[it.id] ?: 0) } - .sum() + } + return ans } internal fun visibleCountAfterIndex(index: Int): Int { - return contentHeightList - .drop(index + 1) - .filterIndexed { i, _ -> - itemVisibilityMap[snapshotStateList[i + index + 1].id] == ItemVisibility.Visible - }.size + return snapshotStateList.drop(index + 1).filterIndexed { i, _ -> + itemVisibilityMap[snapshotStateList[i + index + 1].id] == ItemVisibility.Visible + }.size } - /** - * @return The total number of cards in the stack, including hidden ones. - */ fun size(): Int = snapshotStateList.size - /** - * @return The number of currently visible cards in the stack. - */ - fun sizeVisible(): Int = snapshotStateList.count { itemVisibilityMap[it.id] == ItemVisibility.Visible } + fun sizeVisible(): Int = + snapshotStateList.count { itemVisibilityMap[it.id] == ItemVisibility.Visible } } /** @@ -473,21 +356,6 @@ fun SnackBarStack( } } -/** - * Represents a single animated item within the [SnackBarStack]. - * This composable manages its own position, scale, opacity, and swipe-to-dismiss behavior. - * - * @param model The data model for this snackBar item. - * @param isHidden Whether the item is currently hidden and should be faded out. - * @param expanded Whether the parent stack is in an expanded state. - * @param index The logical index of the card from the top of the stack (0 is the topmost). - * @param invertedIndex The logical index from the bottom of the stack (0 is the bottommost). - * @param stackedWidthScaleFactor The factor by which to scale the width of cards underneath the top card in collapsed mode. - * @param onSwipedAway Callback invoked when the card has been successfully swiped away. - * @param onClick Callback invoked when the card is clicked. - * @param snackBarStackConfig Configuration for the visual properties. - * @param enableSwipeToDismiss Whether swipe-to-dismiss is enabled for this item. - */ @Composable private fun SnackBarStackItem( state: SnackBarStackState, @@ -521,7 +389,7 @@ private fun SnackBarStackItem( animatedYOffset.animateTo( with(localDensity) { if (state.expanded) -state.heightAfterIndex(trueIndex) - .toFloat() else (state.visibleCountAfterIndex(trueIndex) * -peekHeight).toPx() + .toFloat() else (visibleIndex * -peekHeight).toPx() }, animationSpec = spring(stiffness = Spring.StiffnessLow) ) @@ -536,7 +404,10 @@ private fun SnackBarStackItem( val opacityProgress = remember { Animatable(0f) } LaunchedEffect(state.itemVisibilityMap[model.id]) { val visibility = state.itemVisibilityMap[model.id] ?: ItemVisibility.Visible - opacityProgress.animateTo(if (visibility != ItemVisibility.Visible) 0f else 1f, tween(FADE_OUT_DURATION)) + opacityProgress.animateTo( + if (visibility != ItemVisibility.Visible) 0f else 1f, + tween(FADE_OUT_DURATION) + ) } val swipeX = remember { Animatable(0f) } @@ -567,7 +438,7 @@ private fun SnackBarStackItem( Modifier.onGloballyPositioned( onGloballyPositioned = { coordinates: LayoutCoordinates -> val contentHeight = coordinates.size.height - state.contentHeightList[trueIndex] = + state.contentHeightMap[model.id] = contentHeight + with(localDensity) { 20.dp.toPx().toInt() } @@ -759,13 +630,6 @@ private fun SnackBarStackItem( } } -/** - * A composable that displays a scrim over its background. It dims the background - * and intercepts all clicks, preventing them from reaching the content behind it. - * - * @param isActivated Whether the scrim is currently active. - * @param onDismiss A lambda to be invoked when the scrim area is clicked. - */ @Composable fun Scrim( isActivated: Boolean, From 80957ea1dc45c553e572203f00f3244373c38415 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Fri, 26 Sep 2025 16:28:07 +0530 Subject: [PATCH 39/52] Minor changes --- .../notification/StackableSnackbar.kt | 49 ++++++++++--------- 1 file changed, 25 insertions(+), 24 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 4f8a48a9b..bb2faace4 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,7 @@ import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.times import com.microsoft.fluentui.theme.token.FluentGlobalTokens @@ -93,21 +94,16 @@ data class SnackBarItemModel( ) class SnackBarStackState( - internal val cards: MutableList, + internal val cards: MutableList = mutableListOf(), internal var maxCollapsedSize: Int = 5, internal var maxExpandedSize: Int = 10 ) { - val contentHeightMap = - mutableStateMapOf().apply { putAll(cards.associate { it.id to 0 }) } - val itemVisibilityMap = - mutableStateMapOf().apply { putAll(cards.associate { it.id to ItemVisibility.Visible }) } - - val snapshotStateList: MutableList = - mutableStateListOf().apply { addAll(cards) } + val snapshotStateList: MutableList = mutableStateListOf().apply { addAll(cards) } + val contentHeightMap = mutableStateMapOf().apply { putAll(cards.associate { it.id to 0 }) } + val itemVisibilityMap = mutableStateMapOf().apply { putAll(cards.associate { it.id to ItemVisibility.Visible }) } var expanded by mutableStateOf(false) private set - internal var maxCurrentSize = maxCollapsedSize fun addCard(card: SnackBarItemModel) { @@ -272,11 +268,9 @@ fun rememberSnackBarStackState( * @param stackAbove Internal flag to control stacking direction. Currently not implemented. */ data class SnackBarStackConfig( - val cardWidthExpanded: Dp = 320.dp, - val cardHeightExpanded: Dp = 160.dp, val cardGapExpanded: Dp = 10.dp, - val cardWidthCollapsed: Dp = 280.dp, val cardHeightCollapsed: Dp = 80.dp, + val cardCollapsedMaxLines: Int = 2, val cardGapCollapsed: Dp = 8.dp, internal val stackAbove: Boolean = true, //TODO: Fix Stack Above option, working for true, disabling for now ) @@ -318,13 +312,13 @@ fun SnackBarStack( animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing) ) - val scrollState = - rememberScrollState() //TODO: Keep Focus Anchored To the Bottom when expanded and new card added + val scrollState = rememberScrollState() //TODO: Keep Focus Anchored To the Bottom when expanded and new card added Box( modifier = Modifier .fillMaxWidth() .wrapContentHeight() - .then(if (state.expanded) Modifier.verticalScroll(scrollState) else Modifier), + .verticalScroll(scrollState, enabled = state.expanded) + .padding(bottom = 20.dp), //TODO: Make this customizable, contentAlignment = Alignment.BottomCenter ) { Box( @@ -456,14 +450,15 @@ private fun SnackBarStackItem( scaleX = animatedWidthScale.value, scaleY = animatedWidthScale.value ) - .then( - if (state.expanded) { - Modifier.wrapContentSize() - } else { - Modifier - .height(cardHeight) - } - ) + .wrapContentHeight() +// .then( +// if (state.expanded) { +// Modifier.wrapContentSize() +// } else { +// Modifier +// .height(cardHeight) +// } +// ) .padding(horizontal = 0.dp) .then( if (enableSwipeToDismiss && (isTop || state.expanded)) Modifier.pointerInput(model.id) { @@ -558,14 +553,20 @@ private fun SnackBarStackItem( .weight(1F) .padding(textPaddingValues) ) { + val messageMaxLines = if (state.expanded) Int.MAX_VALUE else snackBarStackConfig.cardCollapsedMaxLines + BasicText( text = model.message, - style = token.titleTypography(snackBarInfo) + style = token.titleTypography(snackBarInfo), + maxLines = messageMaxLines, + overflow = TextOverflow.Ellipsis ) if (!model.subTitle.isNullOrBlank()) { BasicText( text = model.subTitle, style = token.subtitleTypography(snackBarInfo), + maxLines = messageMaxLines, + overflow = TextOverflow.Ellipsis, modifier = Modifier.testTag(SNACK_BAR_SUBTITLE) ) } From bf79e08e9d94a8a93237d8f068b25dd478a6b361 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Mon, 29 Sep 2025 16:03:02 +0530 Subject: [PATCH 40/52] Added new activity --- FluentUI.Demo/src/main/AndroidManifest.xml | 1 + .../java/com/microsoft/fluentuidemo/Demos.kt | 5 +- .../V2StackableSnackbarActivity.kt | 182 +++++++++++++++ .../fluentuidemo/demos/V2SnackbarActivity.kt | 114 --------- .../fluentui/theme/token/ControlTokens.kt | 2 + .../controlTokens/StackableSnackbarTokens.kt | 37 ++- .../notification/StackableSnackbar.kt | 217 ++++++++---------- 7 files changed, 320 insertions(+), 238 deletions(-) create mode 100644 FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/V2StackableSnackbarActivity.kt diff --git a/FluentUI.Demo/src/main/AndroidManifest.xml b/FluentUI.Demo/src/main/AndroidManifest.xml index d937e187a..f870c79a5 100644 --- a/FluentUI.Demo/src/main/AndroidManifest.xml +++ b/FluentUI.Demo/src/main/AndroidManifest.xml @@ -66,6 +66,7 @@ + + Button( + onClick = { + Toast.makeText(context, "Button #$index pressed", Toast.LENGTH_SHORT).show() + }, + text = "Button #$index", + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) + Spacer(modifier = Modifier.height(10.dp)) + } + + Spacer(modifier = Modifier.height(24.dp)) + + repeat(20) { index -> + BasicText( + text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) + } + Spacer(modifier = Modifier.height(50.dp)) + } +} + +@Composable +fun SnackBarStackDemoLayout(context: V2StackableSnackbarActivity) { + Box() { + val stackState = rememberSnackBarStackState( + maxExpandedSize = 10, + maxCollapsedSize = 5 + ) + var counter by rememberSaveable { mutableIntStateOf(0) } + val scope = rememberCoroutineScope() + BackgroundContent(context) + Scrim( + isActivated = stackState.expanded && stackState.sizeVisible() > 0, + onDismiss = {} + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Bottom + ) { + + SnackBarStack( + state = stackState + ) + + Row() { + Button(onClick = { + val id = counter++ + + stackState.addSnackbar( + SnackBarItemModel( + id = id.toString(), + message = "Snackbar #$id", + actionText = "Expand", + trailingIcon = FluentIcon( + Icons.Default.Close, + Icons.Default.Close, + contentDescription = "Close", + onClick = { + scope.launch { + stackState.removeSnackbarByIdWithAnimation( + id.toString(), + showLastHiddenSnackbarOnRemove = true + ) + } + } + ), + onActionTextClicked = { + stackState.toggleExpandedState() + }) + ) + }, text = "Add Snackbar") + + Spacer(modifier = Modifier.width(12.dp)) + + Button(onClick = { + scope.launch { + stackState.hideFront() + delay(300) + stackState.removeFront() + stackState.showBack() + } + }, text = "Remove latest") + + Spacer(modifier = Modifier.width(12.dp)) + Button(onClick = { + scope.launch { + val id = counter++ + for (i in 0..15) { + stackState.addSnackbar( + SnackBarItemModel( + id = "$id-$i", + message = "Snackbar #$id-$i".repeat(i + 4), + actionText = "Expand", + trailingIcon = FluentIcon( + Icons.Default.Close, + Icons.Default.Close, + contentDescription = "Close", + onClick = { + scope.launch { + stackState.removeSnackbarByIdWithAnimation( + "$id-$i", + showLastHiddenSnackbarOnRemove = true + ) + } + } + ), + onActionTextClicked = { + stackState.toggleExpandedState() + }) + ) + delay(2000) + } + } + }, text = "Keep Adding") + } + } + } +} \ No newline at end of file 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 f5ac128ae..16624620b 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 @@ -11,9 +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.filled.Close import androidx.compose.material.icons.outlined.ShoppingCart import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -37,17 +35,12 @@ import com.microsoft.fluentui.tokenized.notification.AnimationBehavior import com.microsoft.fluentui.tokenized.notification.AnimationVariables import com.microsoft.fluentui.tokenized.notification.NotificationDuration import com.microsoft.fluentui.tokenized.notification.NotificationResult -import com.microsoft.fluentui.tokenized.notification.Scrim -import com.microsoft.fluentui.tokenized.notification.SnackBarItemModel -import com.microsoft.fluentui.tokenized.notification.SnackBarStack import com.microsoft.fluentui.tokenized.notification.Snackbar import com.microsoft.fluentui.tokenized.notification.SnackbarState -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 import com.microsoft.fluentuidemo.V2DemoActivity -import kotlinx.coroutines.delay import kotlinx.coroutines.launch // Tags used for testing @@ -74,7 +67,6 @@ class V2SnackbarActivity : V2DemoActivity() { val context = this setActivityContent { - SnackBarStackDemo() val snackbarState = remember { SnackbarState() } val scope = rememberCoroutineScope() @@ -338,112 +330,6 @@ class V2SnackbarActivity : V2DemoActivity() { } } -// Demo for SnackBarStack -@Composable -fun SnackBarStackDemo() { - Box() { - val stackState = rememberSnackBarStackState( - maxExpandedSize = 10, - maxCollapsedSize = 5 - ) - var counter by rememberSaveable { mutableIntStateOf(0) } - val scope = rememberCoroutineScope() - Scrim( - isActivated = stackState.expanded && stackState.sizeVisible() > 0, - onDismiss = {} - ) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Bottom - ) { - SnackBarStack( - state = stackState, - modifier = Modifier.padding(16.dp), - ) - - Spacer(modifier = Modifier.height(20.dp)) - - Row { - Button(onClick = { - val id = counter++ - - stackState.addCard( - SnackBarItemModel( - id = id.toString(), - message = "Snackbar #$id", - actionText = "Expand", - trailingIcon = FluentIcon( - Icons.Default.Close, - Icons.Default.Close, - contentDescription = "Close", - onClick = { - scope.launch { - stackState.removeCardByIdWithAnimation( - id.toString(), - showLastHiddenCardOnRemove = true - ) - } - } - ), - onActionTextClicked = { - stackState.toggleExpanded() - }) - ) - }, text = "Add Snackbar") - - Spacer(modifier = Modifier.width(12.dp)) - - Button(onClick = { - scope.launch { - stackState.hideFront() - delay(300) - stackState.removeFront() - stackState.showBack() - } - }, text = "Remove latest") - - Button(onClick = { - scope.launch { - val id = counter++ - for (i in 0..15) { - stackState.addCard( - SnackBarItemModel( - id = "$id-$i", - message = "Snackbar #$id-$i".repeat(i + 4), - actionText = "Expand", - trailingIcon = FluentIcon( - Icons.Default.Close, - Icons.Default.Close, - contentDescription = "Close", - onClick = { - scope.launch { - stackState.removeCardByIdWithAnimation( - "$id-$i", - showLastHiddenCardOnRemove = true - ) - } - } - ), - onActionTextClicked = { - stackState.toggleExpanded() - }) - ) - delay(2000) - } - } - }, text = "Keep Adding") - - Spacer(modifier = Modifier.width(12.dp)) - - Button(onClick = { - stackState.showBack() - }, text = "Show hidden") - } - } - } -} - // Customized animation behavior for Snackbar val customizedAnimationBehavior: AnimationBehavior = object : AnimationBehavior() { override var animationVariables: AnimationVariables = object : AnimationVariables() { diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/ControlTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/ControlTokens.kt index e3bf894aa..cdee9964e 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/ControlTokens.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/ControlTokens.kt @@ -73,6 +73,7 @@ open class ControlTokens : IControlTokens { ShimmerControlType, SideRailControlType, SnackbarControlType, + StackableSnackbarControlType, TabBarControlType, TabItemControlType, TextFieldControlType, @@ -124,6 +125,7 @@ open class ControlTokens : IControlTokens { ControlType.ShimmerControlType -> ShimmerTokens() ControlType.SideRailControlType -> SideRailTokens() ControlType.SnackbarControlType -> SnackBarTokens() + ControlType.StackableSnackbarControlType -> StackableSnackBarTokens() ControlType.TabBarControlType -> TabBarTokens() ControlType.TabItemControlType -> TabItemTokens() ControlType.TextFieldControlType -> TextFieldTokens() diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/StackableSnackbarTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/StackableSnackbarTokens.kt index 22139f652..b7e1f6b2c 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/StackableSnackbarTokens.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/StackableSnackbarTokens.kt @@ -1,21 +1,50 @@ package com.microsoft.fluentui.theme.token.controlTokens +import androidx.annotation.FloatRange import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.microsoft.fluentui.theme.token.FluentGlobalTokens import kotlinx.parcelize.Parcelize + +enum class StackableSnackbarEntryAnimationType { + SlideInFromAbove, + SlideInFromBelow, + FadeIn, + SlideInFromLeft, + SlideInFromRight +} + +enum class StackableSnackbarExitAnimationType { + FadeOut, + SlideOutToLeft, + SlideOutToRight +} + @Parcelize open class StackableSnackBarTokens : SnackBarTokens() { @Composable - fun cardShape(snackBarInfo: SnackBarInfo): Shape { - return RoundedCornerShape(12.dp) + override fun shadowElevationValue(snackBarInfo: SnackBarInfo): Dp { + return FluentGlobalTokens.ShadowTokens.Shadow08.value + } + + @FloatRange(from = 0.0, to = 2.0, fromInclusive = false, toInclusive = true) + @Composable + fun snackbarWidthScalingFactor(snackBarInfo: SnackBarInfo): Float { + return 0.95f + } + + @Composable + fun entryAnimationType(snackBarInfo: SnackBarInfo): StackableSnackbarEntryAnimationType { + return StackableSnackbarEntryAnimationType.SlideInFromBelow } @Composable - fun contentPadding(snackBarInfo: SnackBarInfo): Dp { - return 0.dp + fun exitAnimationType(snackBarInfo: SnackBarInfo): StackableSnackbarExitAnimationType { + return StackableSnackbarExitAnimationType.SlideOutToLeft } } \ No newline at end of file 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 bb2faace4..11557f69f 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,6 +23,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText @@ -45,7 +46,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.times -import com.microsoft.fluentui.theme.token.FluentGlobalTokens import com.microsoft.fluentui.theme.token.FluentIcon import com.microsoft.fluentui.theme.token.Icon import com.microsoft.fluentui.theme.token.StateColor @@ -56,15 +56,15 @@ import com.microsoft.fluentui.theme.token.controlTokens.ButtonTokens import com.microsoft.fluentui.theme.token.controlTokens.SnackBarInfo import com.microsoft.fluentui.theme.token.controlTokens.SnackbarStyle import com.microsoft.fluentui.theme.token.controlTokens.StackableSnackBarTokens +import com.microsoft.fluentui.theme.token.controlTokens.StackableSnackbarEntryAnimationType +import com.microsoft.fluentui.theme.token.controlTokens.StackableSnackbarExitAnimationType import com.microsoft.fluentui.tokenized.controls.Button import kotlinx.coroutines.delay import kotlin.math.abs import kotlin.math.pow -private const val FADE_OUT_DURATION = 350 -private const val STACKED_WIDTH_SCALE_FACTOR = 0.95f - -//TODO: Add accessibility support for the stack and individual cards +private const val ANIMATION_DURATION_MS = 250 +//TODO: Add accessibility support for the stack and individual snackbars //TODO: Perf, reduce recompositions, make stable, minimize launch effect tracked variables enum class ItemVisibility { @@ -73,12 +73,7 @@ enum class ItemVisibility { BeingRemoved } -private val DEFAULT_SNACKBAR_TOKENS = object : StackableSnackBarTokens() { - @Composable - override fun shadowElevationValue(snackBarInfo: SnackBarInfo): Dp { - return FluentGlobalTokens.ShadowTokens.Shadow08.value - } -} +private val DEFAULT_SNACKBAR_TOKENS = StackableSnackBarTokens() @Stable data class SnackBarItemModel( @@ -94,32 +89,34 @@ data class SnackBarItemModel( ) class SnackBarStackState( - internal val cards: MutableList = mutableListOf(), + internal val initialSnackbars: MutableList = mutableListOf(), internal var maxCollapsedSize: Int = 5, internal var maxExpandedSize: Int = 10 ) { - val snapshotStateList: MutableList = mutableStateListOf().apply { addAll(cards) } - val contentHeightMap = mutableStateMapOf().apply { putAll(cards.associate { it.id to 0 }) } - val itemVisibilityMap = mutableStateMapOf().apply { putAll(cards.associate { it.id to ItemVisibility.Visible }) } + val snapshotStateList: MutableList = mutableStateListOf().apply { addAll(initialSnackbars) } + val contentHeightMap = mutableStateMapOf().apply { putAll(initialSnackbars.associate { it.id to 0 }) } + val itemVisibilityMap = mutableStateMapOf().apply { putAll(initialSnackbars.associate { it.id to ItemVisibility.Visible }) } var expanded by mutableStateOf(false) private set internal var maxCurrentSize = maxCollapsedSize - fun addCard(card: SnackBarItemModel) { - if (snapshotStateList.any { it.id == card.id }) { + internal var combinedStackHeight by mutableStateOf(0) + + fun addSnackbar(snackbar: SnackBarItemModel) { + if (snapshotStateList.any { it.id == snackbar.id }) { return } maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize - snapshotStateList.add(card) - contentHeightMap[card.id] = 0 - itemVisibilityMap[card.id] = ItemVisibility.Visible + snapshotStateList.add(snackbar) + contentHeightMap[snackbar.id] = 0 + itemVisibilityMap[snackbar.id] = ItemVisibility.Visible if (sizeVisible() > maxCurrentSize) { hideBack() } } - fun removeCardById(id: String): Boolean { + fun removeSnackbarById(id: String): Boolean { snapshotStateList.firstOrNull { it.id == id }?.let { contentHeightMap.remove(it.id) itemVisibilityMap.remove(it.id) @@ -129,24 +126,24 @@ class SnackBarStackState( return false } - suspend fun removeCardByIdWithAnimation( + suspend fun removeSnackbarByIdWithAnimation( id: String, - showLastHiddenCardOnRemove: Boolean = true, + showLastHiddenSnackbarOnRemove: Boolean = true, onRemoveCompleteCallback: () -> Unit = {} ) { - val card = snapshotStateList.firstOrNull { it.id == id } ?: return - itemVisibilityMap[card.id] = ItemVisibility.BeingRemoved - delay(FADE_OUT_DURATION.toLong()) - contentHeightMap.remove(card.id) - snapshotStateList.remove(card) - itemVisibilityMap.remove(card.id) - if (showLastHiddenCardOnRemove) { + val snackbar = snapshotStateList.firstOrNull { it.id == id } ?: return + itemVisibilityMap[snackbar.id] = ItemVisibility.BeingRemoved + delay(ANIMATION_DURATION_MS.toLong()) + contentHeightMap.remove(snackbar.id) + snapshotStateList.remove(snackbar) + itemVisibilityMap.remove(snackbar.id) + if (showLastHiddenSnackbarOnRemove) { onVisibleSizeChange() } onRemoveCompleteCallback() } - fun toggleExpanded() { + fun toggleExpandedState() { expanded = !expanded maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize onVisibleSizeChange() @@ -221,26 +218,12 @@ class SnackBarStackState( return ans } - internal fun visibleCountAfterIndex(index: Int): Int { - return snapshotStateList.drop(index + 1).filterIndexed { i, _ -> - itemVisibilityMap[snapshotStateList[i + index + 1].id] == ItemVisibility.Visible - }.size - } - fun size(): Int = snapshotStateList.size fun sizeVisible(): Int = snapshotStateList.count { itemVisibilityMap[it.id] == ItemVisibility.Visible } } -/** - * Creates and remembers a [SnackbarStackState] instance. - * - * @param initial The initial list of [SnackBarItemModel]s to populate the stack. - * @param maxExpandedSize The maximum number of visible snackBars when the stack is expanded. - * @param maxCollapsedSize The maximum number of visible snackBars when the stack is collapsed. - * @return A remembered [SnackbarStackState] instance. - */ @Composable fun rememberSnackBarStackState( initial: List = emptyList(), @@ -249,77 +232,52 @@ fun rememberSnackBarStackState( ): SnackBarStackState { return remember { SnackBarStackState( - cards = initial.toMutableList(), + initialSnackbars = initial.toMutableList(), maxExpandedSize = maxExpandedSize, maxCollapsedSize = maxCollapsedSize ) } } -/** - * Configuration for the visual properties of the [SnackBarStack]. - * - * @param cardWidthExpanded The width of a snackBar card when the stack is expanded. - * @param cardHeightExpanded The height of a snackBar card when the stack is expanded. - * @param cardGapExpanded The vertical spacing between cards when the stack is expanded. - * @param cardWidthCollapsed The width of a snackBar card when the stack is collapsed. - * @param cardHeightCollapsed The height of a snackBar card when the stack is collapsed. - * @param cardGapCollapsed The vertical spacing (peek height) between cards when the stack is collapsed. - * @param stackAbove Internal flag to control stacking direction. Currently not implemented. - */ data class SnackBarStackConfig( - val cardGapExpanded: Dp = 10.dp, - val cardHeightCollapsed: Dp = 80.dp, - val cardCollapsedMaxLines: Int = 2, - val cardGapCollapsed: Dp = 8.dp, - internal val stackAbove: Boolean = true, //TODO: Fix Stack Above option, working for true, disabling for now + val snackbarGapWhenExpanded: Dp = 10.dp, + val snackbarHeightWhenCollapsed: Dp = 80.dp, + val maximumTextLinesWhenCollapsed: Int = 2, + val snackbarPeekHeightWhenCollapsed: Dp = 8.dp, + val snackbarStackBottomPadding: Dp = 20.dp, + val snackbarStackExpandedTopPadding: Dp = 200.dp ) -/** - * A composable that displays a stack of snackBar notifications. - * - * The stack can be in a collapsed state, showing a condensed view of notifications, - * or an expanded state, showing a scrollable list. - * - * @param state The [SnackBarStackState] that manages the content and state of the stack. - * @param modifier The modifier to be applied to the stack container. - * @param contentModifier The modifier to be applied to the content within each snackBar card. - * @param snackBarStackConfig The configuration for the visual properties of the stack. - * @param enableSwipeToDismiss If `true`, allows users to swipe away the top card (in collapsed mode) or any card (in expanded mode). - * @param expandOnCardClick If `true`, toggles the expanded state of the stack when the top card is clicked. - */ @Composable fun SnackBarStack( state: SnackBarStackState, - modifier: Modifier = Modifier, - contentModifier: Modifier = Modifier, snackBarStackConfig: SnackBarStackConfig = SnackBarStackConfig(), enableSwipeToDismiss: Boolean = true, - expandOnCardClick: Boolean = true, ) { val localDensity = LocalDensity.current - val visibleCardsCount by remember { derivedStateOf { state.sizeVisible() } } - val targetHeight = if (visibleCardsCount == 0) { - snackBarStackConfig.cardHeightCollapsed + val visibleSnackbarsCount by remember { derivedStateOf { state.sizeVisible() } } + val targetHeight = if (visibleSnackbarsCount == 0) { + 0.dp } else if (state.expanded) { - with(localDensity) { state.heightAfterIndex(0).toDp() + 200.dp } + with(localDensity) { state.combinedStackHeight.toDp() + snackBarStackConfig.snackbarStackExpandedTopPadding } } else { - snackBarStackConfig.cardHeightCollapsed + (visibleCardsCount - 1) * snackBarStackConfig.cardGapCollapsed + snackBarStackConfig.snackbarHeightWhenCollapsed + (visibleSnackbarsCount - 1) * snackBarStackConfig.snackbarPeekHeightWhenCollapsed } - val animatedStackHeight by animateDpAsState( //TODO: CHANGE TO LAUNCHED EFFECT WITH ONLY A TRIGGER ON VISIBLE CARDS TOTAL HEIGHT CHANGE + val animatedStackHeight by animateDpAsState( targetValue = targetHeight, - animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing) + animationSpec = tween(durationMillis = ANIMATION_DURATION_MS, easing = FastOutSlowInEasing) ) - val scrollState = rememberScrollState() //TODO: Keep Focus Anchored To the Bottom when expanded and new card added - Box( + val scrollState = rememberScrollState() //TODO: Keep Focus Anchored To the Bottom when expanded and new snackbar added + Column( modifier = Modifier .fillMaxWidth() .wrapContentHeight() .verticalScroll(scrollState, enabled = state.expanded) - .padding(bottom = 20.dp), //TODO: Make this customizable, - contentAlignment = Alignment.BottomCenter + .padding(bottom = snackBarStackConfig.snackbarStackBottomPadding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Bottom ) { Box( modifier = Modifier @@ -327,10 +285,9 @@ fun SnackBarStack( .height(animatedStackHeight), contentAlignment = Alignment.BottomCenter ) { - val totalVisibleCards = state.sizeVisible() var visibleIndex = 0 state.snapshotStateList.forEachIndexed { index, snackBarModel -> - val logicalIndex = totalVisibleCards - 1 - visibleIndex + val logicalIndex = visibleSnackbarsCount - 1 - visibleIndex visibleIndex += if (state.itemVisibilityMap[snackBarModel.id] == ItemVisibility.Visible) 1 else 0 key(snackBarModel.id) { SnackBarStackItem( @@ -338,7 +295,7 @@ fun SnackBarStack( visibleIndex = logicalIndex, trueIndex = index, onSwipedAway = { idToRemove -> - state.removeCardById(idToRemove) + state.removeSnackbarById(idToRemove) state.showBack() }, snackBarStackConfig = snackBarStackConfig, @@ -355,21 +312,32 @@ private fun SnackBarStackItem( state: SnackBarStackState, visibleIndex: Int, trueIndex: Int, - stackedWidthScaleFactor: Float = STACKED_WIDTH_SCALE_FACTOR, onSwipedAway: (String) -> Unit, snackBarStackConfig: SnackBarStackConfig, - enableSwipeToDismiss: Boolean = true, + enableSwipeToDismiss: Boolean = true ) { val model = state.snapshotStateList[trueIndex] - val cardHeight = snackBarStackConfig.cardHeightCollapsed - val peekHeight = snackBarStackConfig.cardGapCollapsed + val cardHeight = snackBarStackConfig.snackbarHeightWhenCollapsed + val peekHeight = snackBarStackConfig.snackbarPeekHeightWhenCollapsed val screenWidth = LocalConfiguration.current.screenWidthDp.dp val scope = rememberCoroutineScope() val localDensity = LocalDensity.current val isTop = visibleIndex == 0 - val animatedYOffset = remember { Animatable(with(localDensity) { cardHeight.toPx() }) } + val token = model.snackBarToken + val snackBarInfo = SnackBarInfo(model.style, false) + val entryAnimationType = token.entryAnimationType(snackBarInfo) + val exitAnimationType = token.exitAnimationType(snackBarInfo) + + val initialYOffset = when (entryAnimationType) { + StackableSnackbarEntryAnimationType.SlideInFromAbove -> -with(localDensity) { cardHeight.toPx() } + StackableSnackbarEntryAnimationType.SlideInFromBelow -> with(localDensity) { cardHeight.toPx() } + StackableSnackbarEntryAnimationType.FadeIn -> 0f + StackableSnackbarEntryAnimationType.SlideInFromLeft -> 0f + StackableSnackbarEntryAnimationType.SlideInFromRight -> 0f + } + val animatedYOffset = remember { Animatable(initialYOffset) } LaunchedEffect( trueIndex, state.expanded, @@ -389,10 +357,11 @@ private fun SnackBarStackItem( ) } + val stackedWidthScaleFactor = token.snackbarWidthScalingFactor(snackBarInfo).coerceIn(0.01f, 2.0f) val targetWidthScale = if (state.expanded) 1f else stackedWidthScaleFactor.pow(visibleIndex) val animatedWidthScale = animateFloatAsState( targetValue = targetWidthScale, - animationSpec = tween(durationMillis = 100, easing = FastOutSlowInEasing) + animationSpec = tween(durationMillis = ANIMATION_DURATION_MS, easing = FastOutSlowInEasing) ) val opacityProgress = remember { Animatable(0f) } @@ -400,24 +369,39 @@ private fun SnackBarStackItem( val visibility = state.itemVisibilityMap[model.id] ?: ItemVisibility.Visible opacityProgress.animateTo( if (visibility != ItemVisibility.Visible) 0f else 1f, - tween(FADE_OUT_DURATION) + tween(ANIMATION_DURATION_MS) ) } - val swipeX = remember { Animatable(0f) } - LaunchedEffect(state.itemVisibilityMap[model.id], state.expanded) { + val initialXOffset = when (entryAnimationType) { + StackableSnackbarEntryAnimationType.SlideInFromLeft -> -with(localDensity) { screenWidth.toPx() } + StackableSnackbarEntryAnimationType.SlideInFromRight -> with(localDensity) { screenWidth.toPx() } + StackableSnackbarEntryAnimationType.FadeIn -> 0f + StackableSnackbarEntryAnimationType.SlideInFromAbove -> 0f + StackableSnackbarEntryAnimationType.SlideInFromBelow -> 0f + } + val swipeX = remember { Animatable(initialXOffset) } + LaunchedEffect(state.itemVisibilityMap[model.id], state.expanded, visibleIndex) { if (state.itemVisibilityMap[model.id] == ItemVisibility.BeingRemoved) { + val target = when (exitAnimationType) { + StackableSnackbarExitAnimationType.SlideOutToLeft -> -with(localDensity) { screenWidth.toPx() * 1.2f } + StackableSnackbarExitAnimationType.SlideOutToRight -> with(localDensity) { screenWidth.toPx() * 1.2f } + StackableSnackbarExitAnimationType.FadeOut -> 0f + } swipeX.animateTo( - with(localDensity) { -screenWidth.toPx() * 1.2f }, - animationSpec = tween(durationMillis = 240, easing = FastOutLinearInEasing) + with(localDensity) { target }, + animationSpec = tween(durationMillis = ANIMATION_DURATION_MS, easing = FastOutLinearInEasing) ) } else { - swipeX.snapTo(0f) + if(isTop) { + swipeX.animateTo(0f) + } + else { + swipeX.snapTo(0f) + } } } - val token = model.snackBarToken - val snackBarInfo = SnackBarInfo(model.style, false) val textPaddingValues = if (model.actionText == null && model.trailingIcon != null) PaddingValues( start = 16.dp, @@ -432,10 +416,14 @@ private fun SnackBarStackItem( Modifier.onGloballyPositioned( onGloballyPositioned = { coordinates: LayoutCoordinates -> val contentHeight = coordinates.size.height + if( model.id in state.contentHeightMap && state.contentHeightMap[model.id] == contentHeight) { + return@onGloballyPositioned + } state.contentHeightMap[model.id] = contentHeight + with(localDensity) { - 20.dp.toPx().toInt() + snackBarStackConfig.snackbarGapWhenExpanded.toPx().toInt() } + state.combinedStackHeight = state.heightAfterIndex(0) return@onGloballyPositioned } ) @@ -451,15 +439,6 @@ private fun SnackBarStackItem( scaleY = animatedWidthScale.value ) .wrapContentHeight() -// .then( -// if (state.expanded) { -// Modifier.wrapContentSize() -// } else { -// Modifier -// .height(cardHeight) -// } -// ) - .padding(horizontal = 0.dp) .then( if (enableSwipeToDismiss && (isTop || state.expanded)) Modifier.pointerInput(model.id) { detectHorizontalDragGestures( @@ -476,7 +455,7 @@ private fun SnackBarStackItem( swipeX.animateTo( target, animationSpec = tween( - durationMillis = 240, + durationMillis = ANIMATION_DURATION_MS, easing = FastOutLinearInEasing ) ) @@ -553,7 +532,7 @@ private fun SnackBarStackItem( .weight(1F) .padding(textPaddingValues) ) { - val messageMaxLines = if (state.expanded) Int.MAX_VALUE else snackBarStackConfig.cardCollapsedMaxLines + val messageMaxLines = if (state.expanded) Int.MAX_VALUE else snackBarStackConfig.maximumTextLinesWhenCollapsed BasicText( text = model.message, @@ -639,7 +618,7 @@ fun Scrim( ) { val scrimColor by animateColorAsState( targetValue = if (isActivated) Color.Black.copy(alpha = 0.6f) else Color.Transparent, - animationSpec = tween(durationMillis = 300), + animationSpec = tween(durationMillis = ANIMATION_DURATION_MS), label = "ScrimColorAnimation" ) From fc6857066790698604d56327dd236d662b20e4c3 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Mon, 29 Sep 2025 17:21:41 +0530 Subject: [PATCH 41/52] Screen Width Calculation Update --- .../V2StackableSnackbarActivity.kt | 3 +- .../notification/StackableSnackbar.kt | 32 +++++++++++-------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/V2StackableSnackbarActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/V2StackableSnackbarActivity.kt index f35afb821..18006356b 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/V2StackableSnackbarActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/V2StackableSnackbarActivity.kt @@ -105,7 +105,7 @@ fun SnackBarStackDemoLayout(context: V2StackableSnackbarActivity) { SnackBarStack( state = stackState ) - + Spacer(modifier = Modifier.height(10.dp)) Row() { Button(onClick = { val id = counter++ @@ -177,6 +177,7 @@ fun SnackBarStackDemoLayout(context: V2StackableSnackbarActivity) { } }, text = "Keep Adding") } + Spacer(modifier = Modifier.height(10.dp)) } } } \ No newline at end of file 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 11557f69f..4d1fc6b45 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 @@ -26,8 +26,8 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -101,7 +101,7 @@ class SnackBarStackState( private set internal var maxCurrentSize = maxCollapsedSize - internal var combinedStackHeight by mutableStateOf(0) + internal var combinedStackHeight by mutableIntStateOf(0) fun addSnackbar(snackbar: SnackBarItemModel) { if (snapshotStateList.any { it.id == snackbar.id }) { @@ -269,6 +269,9 @@ fun SnackBarStack( animationSpec = tween(durationMillis = ANIMATION_DURATION_MS, easing = FastOutSlowInEasing) ) + val screenWidth = LocalConfiguration.current.screenWidthDp.dp + val screenWidthPx = with(localDensity) { screenWidth.toPx() } + val scrollState = rememberScrollState() //TODO: Keep Focus Anchored To the Bottom when expanded and new snackbar added Column( modifier = Modifier @@ -299,7 +302,8 @@ fun SnackBarStack( state.showBack() }, snackBarStackConfig = snackBarStackConfig, - enableSwipeToDismiss = enableSwipeToDismiss + enableSwipeToDismiss = enableSwipeToDismiss, + screenWidthPx = screenWidthPx ) } } @@ -314,12 +318,12 @@ private fun SnackBarStackItem( trueIndex: Int, onSwipedAway: (String) -> Unit, snackBarStackConfig: SnackBarStackConfig, - enableSwipeToDismiss: Boolean = true + enableSwipeToDismiss: Boolean = true, + screenWidthPx: Float ) { val model = state.snapshotStateList[trueIndex] val cardHeight = snackBarStackConfig.snackbarHeightWhenCollapsed val peekHeight = snackBarStackConfig.snackbarPeekHeightWhenCollapsed - val screenWidth = LocalConfiguration.current.screenWidthDp.dp val scope = rememberCoroutineScope() val localDensity = LocalDensity.current @@ -374,8 +378,8 @@ private fun SnackBarStackItem( } val initialXOffset = when (entryAnimationType) { - StackableSnackbarEntryAnimationType.SlideInFromLeft -> -with(localDensity) { screenWidth.toPx() } - StackableSnackbarEntryAnimationType.SlideInFromRight -> with(localDensity) { screenWidth.toPx() } + StackableSnackbarEntryAnimationType.SlideInFromLeft -> -screenWidthPx + StackableSnackbarEntryAnimationType.SlideInFromRight -> screenWidthPx StackableSnackbarEntryAnimationType.FadeIn -> 0f StackableSnackbarEntryAnimationType.SlideInFromAbove -> 0f StackableSnackbarEntryAnimationType.SlideInFromBelow -> 0f @@ -384,8 +388,8 @@ private fun SnackBarStackItem( LaunchedEffect(state.itemVisibilityMap[model.id], state.expanded, visibleIndex) { if (state.itemVisibilityMap[model.id] == ItemVisibility.BeingRemoved) { val target = when (exitAnimationType) { - StackableSnackbarExitAnimationType.SlideOutToLeft -> -with(localDensity) { screenWidth.toPx() * 1.2f } - StackableSnackbarExitAnimationType.SlideOutToRight -> with(localDensity) { screenWidth.toPx() * 1.2f } + StackableSnackbarExitAnimationType.SlideOutToLeft -> screenWidthPx * -1.2f + StackableSnackbarExitAnimationType.SlideOutToRight -> screenWidthPx * 1.2f StackableSnackbarExitAnimationType.FadeOut -> 0f } swipeX.animateTo( @@ -444,13 +448,13 @@ private fun SnackBarStackItem( detectHorizontalDragGestures( onDragStart = {}, onDragEnd = { - val threshold = with(localDensity) { (screenWidth / 4).toPx() } + val threshold = screenWidthPx / 4 scope.launch { if (abs(swipeX.value) > threshold) { val target = if (swipeX.value > 0) - with(localDensity) { screenWidth.toPx() * 1.2f } + screenWidthPx * 1.2f else - -with(localDensity) { screenWidth.toPx() * 1.2f } + screenWidthPx * -1.2f swipeX.animateTo( target, @@ -534,14 +538,14 @@ private fun SnackBarStackItem( ) { val messageMaxLines = if (state.expanded) Int.MAX_VALUE else snackBarStackConfig.maximumTextLinesWhenCollapsed - BasicText( + Text( text = model.message, style = token.titleTypography(snackBarInfo), maxLines = messageMaxLines, overflow = TextOverflow.Ellipsis ) if (!model.subTitle.isNullOrBlank()) { - BasicText( + Text( text = model.subTitle, style = token.subtitleTypography(snackBarInfo), maxLines = messageMaxLines, From 5b8d53563e653360ac34a23f239f8fe7b2b7ffdb Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Mon, 29 Sep 2025 17:31:41 +0530 Subject: [PATCH 42/52] Added KDocs and labels --- .../V2StackableSnackbarActivity.kt | 7 +- .../notification/StackableSnackbar.kt | 239 +++++++++++++++--- 2 files changed, 211 insertions(+), 35 deletions(-) diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/V2StackableSnackbarActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/V2StackableSnackbarActivity.kt index 18006356b..2bda85e18 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/V2StackableSnackbarActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/V2StackableSnackbarActivity.kt @@ -14,12 +14,14 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.microsoft.fluentui.theme.token.FluentIcon import com.microsoft.fluentui.tokenized.controls.Button import com.microsoft.fluentui.tokenized.notification.Scrim import com.microsoft.fluentui.tokenized.notification.SnackBarItemModel import com.microsoft.fluentui.tokenized.notification.SnackBarStack +import com.microsoft.fluentui.tokenized.notification.SnackBarStackConfig import com.microsoft.fluentui.tokenized.notification.rememberSnackBarStackState import com.microsoft.fluentuidemo.V2DemoActivity import kotlinx.coroutines.delay @@ -103,7 +105,10 @@ fun SnackBarStackDemoLayout(context: V2StackableSnackbarActivity) { ) { SnackBarStack( - state = stackState + state = stackState, + snackBarStackConfig = SnackBarStackConfig( + snackbarGapWhenExpanded = 10.dp + ) ) Spacer(modifier = Modifier.height(10.dp)) Row() { 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 4d1fc6b45..11d948685 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 @@ -2,38 +2,54 @@ package com.microsoft.fluentui.tokenized.notification import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -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.unit.dp -import kotlinx.coroutines.launch -import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.Text import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration @@ -45,6 +61,7 @@ import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times import com.microsoft.fluentui.theme.token.FluentIcon import com.microsoft.fluentui.theme.token.Icon @@ -60,21 +77,51 @@ import com.microsoft.fluentui.theme.token.controlTokens.StackableSnackbarEntryAn import com.microsoft.fluentui.theme.token.controlTokens.StackableSnackbarExitAnimationType import com.microsoft.fluentui.tokenized.controls.Button import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.pow private const val ANIMATION_DURATION_MS = 250 +private const val SWIPE_AWAY_ANIMATION_TARGET_FACTOR = 1.2f +private const val SWIPE_TO_DISMISS_THRESHOLD_DIVISOR = 4 + //TODO: Add accessibility support for the stack and individual snackbars //TODO: Perf, reduce recompositions, make stable, minimize launch effect tracked variables +private object SnackBarTestTags { + const val SNACK_BAR = "snack_bar" + const val SNACK_BAR_ICON = "snack_bar_icon" + const val SNACK_BAR_SUBTITLE = "snack_bar_subtitle" + const val SNACK_BAR_ACTION_BUTTON = "snack_bar_action_button" +} + +/** + * Enum class to define the visibility state of a snackbar item within the stack. + */ enum class ItemVisibility { + /** The snackbar is currently visible. */ Visible, + /** The snackbar is currently hidden. */ Hidden, + /** The snackbar is being removed with an animation. */ BeingRemoved } private val DEFAULT_SNACKBAR_TOKENS = StackableSnackBarTokens() +/** + * Data model for an individual snackbar item. + * + * @property message The main text message to be displayed in the snackbar. + * @property id A unique identifier for the snackbar item. Defaults to a random UUID. + * @property style The visual style of the snackbar, e.g., Neutral, Danger, Warning. + * @property leadingIcon An optional leading icon to display. + * @property trailingIcon An optional trailing icon to display. + * @property subTitle An optional subtitle text. + * @property actionText Optional text for the action button. If null, no action button is shown. + * @property snackBarToken The tokens for customizing the snackbar's appearance. + * @property onActionTextClicked The callback to be invoked when the action button is clicked. + */ @Stable data class SnackBarItemModel( val message: String, @@ -88,6 +135,14 @@ data class SnackBarItemModel( val onActionTextClicked: () -> Unit = {} ) +/** + * State holder for a stack of snackbars. It manages the list of items, their visibility, and height, + * providing methods to add, remove, and toggle the stack's expanded state. + * + * @param initialSnackbars The initial list of snackbars to be displayed. + * @param maxCollapsedSize The maximum number of snackbars to show when the stack is collapsed. + * @param maxExpandedSize The maximum number of snackbars to show when the stack is expanded. + */ class SnackBarStackState( internal val initialSnackbars: MutableList = mutableListOf(), internal var maxCollapsedSize: Int = 5, @@ -103,6 +158,12 @@ class SnackBarStackState( internal var combinedStackHeight by mutableIntStateOf(0) + /** + * Adds a new snackbar to the stack. If the stack exceeds its maximum current size, the + * oldest visible snackbar is automatically hidden. + * + * @param snackbar The [SnackBarItemModel] to add. + */ fun addSnackbar(snackbar: SnackBarItemModel) { if (snapshotStateList.any { it.id == snackbar.id }) { return @@ -116,6 +177,12 @@ class SnackBarStackState( } } + /** + * Removes a snackbar from the stack by its unique ID. + * + * @param id The ID of the snackbar to remove. + * @return `true` if the snackbar was found and removed, `false` otherwise. + */ fun removeSnackbarById(id: String): Boolean { snapshotStateList.firstOrNull { it.id == id }?.let { contentHeightMap.remove(it.id) @@ -126,6 +193,14 @@ class SnackBarStackState( return false } + /** + * Removes a snackbar with an exit animation. The actual removal from the state list is + * delayed to allow the animation to complete. + * + * @param id The ID of the snackbar to remove. + * @param showLastHiddenSnackbarOnRemove If `true`, the oldest hidden snackbar will become visible after removal. + * @param onRemoveCompleteCallback A callback invoked after the removal process is complete. + */ suspend fun removeSnackbarByIdWithAnimation( id: String, showLastHiddenSnackbarOnRemove: Boolean = true, @@ -143,12 +218,21 @@ class SnackBarStackState( onRemoveCompleteCallback() } + /** + * Toggles the expanded state of the snackbar stack. This changes the layout and the + * maximum number of visible snackbars. + */ fun toggleExpandedState() { expanded = !expanded maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize onVisibleSizeChange() } + /** + * Adjusts the visibility of snackbars based on the current expanded/collapsed state and + * the configured maximum size. This function ensures the number of visible snackbars + * does not exceed the allowed maximum. + */ private fun onVisibleSizeChange() { val currentSize = snapshotStateList.count { itemVisibilityMap[it.id] == ItemVisibility.Visible } @@ -173,6 +257,11 @@ class SnackBarStackState( } } + /** + * Hides the oldest visible snackbar. + * + * @return `true` if a snackbar was hidden, `false` otherwise. + */ fun hideBack(): Boolean { snapshotStateList.firstOrNull { itemVisibilityMap[it.id] == ItemVisibility.Visible }?.let { itemVisibilityMap[it.id] = ItemVisibility.Hidden @@ -181,6 +270,11 @@ class SnackBarStackState( return false } + /** + * Hides the newest visible snackbar. + * + * @return `true` if a snackbar was hidden, `false` otherwise. + */ fun hideFront(): Boolean { snapshotStateList.lastOrNull { itemVisibilityMap[it.id] == ItemVisibility.Visible }?.let { itemVisibilityMap[it.id] = ItemVisibility.Hidden @@ -189,6 +283,12 @@ class SnackBarStackState( return false } + /** + * Removes the newest snackbar from the stack. + * + * @param skipHidden If `true`, only visible snackbars are considered for removal. + * @return `true` if a snackbar was removed, `false` otherwise. + */ fun removeFront(skipHidden: Boolean = false): Boolean { snapshotStateList.lastOrNull { (skipHidden && itemVisibilityMap[it.id] == ItemVisibility.Visible) || !skipHidden } ?.let { @@ -200,6 +300,11 @@ class SnackBarStackState( return false } + /** + * Shows the oldest hidden snackbar. + * + * @return `true` if a snackbar was shown, `false` otherwise. + */ fun showBack(): Boolean { snapshotStateList.lastOrNull { itemVisibilityMap[it.id] == ItemVisibility.Hidden }?.let { itemVisibilityMap[it.id] = ItemVisibility.Visible @@ -208,6 +313,12 @@ class SnackBarStackState( return false } + /** + * Calculates the combined height of all visible snackbars that appear after a given index. + * + * @param index The starting index. + * @return The combined height in pixels. + */ internal fun heightAfterIndex(index: Int): Int { var ans = 0 snapshotStateList.drop(index + 1).forEach { @@ -218,12 +329,30 @@ class SnackBarStackState( return ans } + /** + * Returns the total number of snackbars (visible and hidden) in the stack. + * + * @return The total size of the stack. + */ fun size(): Int = snapshotStateList.size + /** + * Returns the number of currently visible snackbars in the stack. + * + * @return The number of visible snackbars. + */ fun sizeVisible(): Int = snapshotStateList.count { itemVisibilityMap[it.id] == ItemVisibility.Visible } } +/** + * Creates and remembers a [SnackBarStackState] for managing a stack of snackbars. + * + * @param initial The initial list of snackbar models. Defaults to an empty list. + * @param maxExpandedSize The maximum number of snackbars to show when the stack is expanded. + * @param maxCollapsedSize The maximum number of snackbars to show when the stack is collapsed. + * @return A [SnackBarStackState] instance. + */ @Composable fun rememberSnackBarStackState( initial: List = emptyList(), @@ -239,15 +368,34 @@ fun rememberSnackBarStackState( } } +/** + * Configuration data for the [SnackBarStack] composable. + * + * @property snackbarGapWhenExpanded The vertical gap between snackbars when the stack is expanded. + * @property snackbarHeightWhenCollapsed The fixed height of a snackbar when the stack is collapsed. + * @property maximumTextLinesWhenCollapsed The maximum number of text lines allowed for a snackbar's message and subtitle when collapsed. + * @property snackbarPeekHeightWhenCollapsed The vertical distance one snackbar peeks from the one below it when collapsed. + * @property snackbarStackBottomPadding The padding at the bottom of the entire stack. + * @property snackbarStackExpandedTopPadding The padding at the top of the stack when it's expanded. + */ data class SnackBarStackConfig( val snackbarGapWhenExpanded: Dp = 10.dp, - val snackbarHeightWhenCollapsed: Dp = 80.dp, + val maxSnackbarHeightWhenCollapsed: Dp = 80.dp, val maximumTextLinesWhenCollapsed: Int = 2, val snackbarPeekHeightWhenCollapsed: Dp = 8.dp, val snackbarStackBottomPadding: Dp = 20.dp, val snackbarStackExpandedTopPadding: Dp = 200.dp ) +/** + * A composable that displays a stack of snackbars. It supports a collapsed state (showing a + * limited number of items) and an expanded state (showing all items). The stack is + * managed by the provided [SnackBarStackState]. + * + * @param state The state object that manages the snackbar stack. + * @param snackBarStackConfig The configuration for the stack's appearance and behavior. + * @param enableSwipeToDismiss If `true`, the top visible snackbar can be swiped to dismiss it. + */ @Composable fun SnackBarStack( state: SnackBarStackState, @@ -262,11 +410,12 @@ fun SnackBarStack( } else if (state.expanded) { with(localDensity) { state.combinedStackHeight.toDp() + snackBarStackConfig.snackbarStackExpandedTopPadding } } else { - snackBarStackConfig.snackbarHeightWhenCollapsed + (visibleSnackbarsCount - 1) * snackBarStackConfig.snackbarPeekHeightWhenCollapsed + snackBarStackConfig.maxSnackbarHeightWhenCollapsed + (visibleSnackbarsCount - 1) * snackBarStackConfig.snackbarPeekHeightWhenCollapsed } val animatedStackHeight by animateDpAsState( targetValue = targetHeight, - animationSpec = tween(durationMillis = ANIMATION_DURATION_MS, easing = FastOutSlowInEasing) + animationSpec = tween(durationMillis = ANIMATION_DURATION_MS, easing = FastOutSlowInEasing), + label = "StackHeightAnimation" ) val screenWidth = LocalConfiguration.current.screenWidthDp.dp @@ -311,6 +460,18 @@ fun SnackBarStack( } } +/** + * A private composable for a single snackbar item in the stack. It handles individual + * animations, styling, and swipe-to-dismiss gestures. + * + * @param state The state object managing the snackbar stack. + * @param visibleIndex The logical index of the snackbar among the visible items. + * @param trueIndex The actual index of the snackbar in the full list. + * @param onSwipedAway A callback to be invoked when the snackbar is swiped off-screen. + * @param snackBarStackConfig The configuration for the stack's appearance. + * @param enableSwipeToDismiss If `true`, swiping the snackbar horizontally will dismiss it. + * @param screenWidthPx The width of the screen in pixels, used for swipe animation. + */ @Composable private fun SnackBarStackItem( state: SnackBarStackState, @@ -322,7 +483,7 @@ private fun SnackBarStackItem( screenWidthPx: Float ) { val model = state.snapshotStateList[trueIndex] - val cardHeight = snackBarStackConfig.snackbarHeightWhenCollapsed + val cardHeight = snackBarStackConfig.maxSnackbarHeightWhenCollapsed val peekHeight = snackBarStackConfig.snackbarPeekHeightWhenCollapsed val scope = rememberCoroutineScope() @@ -365,7 +526,8 @@ private fun SnackBarStackItem( val targetWidthScale = if (state.expanded) 1f else stackedWidthScaleFactor.pow(visibleIndex) val animatedWidthScale = animateFloatAsState( targetValue = targetWidthScale, - animationSpec = tween(durationMillis = ANIMATION_DURATION_MS, easing = FastOutSlowInEasing) + animationSpec = tween(durationMillis = ANIMATION_DURATION_MS, easing = FastOutSlowInEasing), + label = "WidthScaleAnimation" ) val opacityProgress = remember { Animatable(0f) } @@ -388,8 +550,8 @@ private fun SnackBarStackItem( LaunchedEffect(state.itemVisibilityMap[model.id], state.expanded, visibleIndex) { if (state.itemVisibilityMap[model.id] == ItemVisibility.BeingRemoved) { val target = when (exitAnimationType) { - StackableSnackbarExitAnimationType.SlideOutToLeft -> screenWidthPx * -1.2f - StackableSnackbarExitAnimationType.SlideOutToRight -> screenWidthPx * 1.2f + StackableSnackbarExitAnimationType.SlideOutToLeft -> screenWidthPx * -SWIPE_AWAY_ANIMATION_TARGET_FACTOR + StackableSnackbarExitAnimationType.SlideOutToRight -> screenWidthPx * SWIPE_AWAY_ANIMATION_TARGET_FACTOR StackableSnackbarExitAnimationType.FadeOut -> 0f } swipeX.animateTo( @@ -448,13 +610,13 @@ private fun SnackBarStackItem( detectHorizontalDragGestures( onDragStart = {}, onDragEnd = { - val threshold = screenWidthPx / 4 + val threshold = screenWidthPx / SWIPE_TO_DISMISS_THRESHOLD_DIVISOR scope.launch { if (abs(swipeX.value) > threshold) { val target = if (swipeX.value > 0) - screenWidthPx * 1.2f + screenWidthPx * SWIPE_AWAY_ANIMATION_TARGET_FACTOR else - screenWidthPx * -1.2f + screenWidthPx * -SWIPE_AWAY_ANIMATION_TARGET_FACTOR swipeX.animateTo( target, @@ -503,13 +665,13 @@ private fun SnackBarStackItem( .semantics { liveRegion = LiveRegionMode.Polite } - .testTag(SNACK_BAR), + .testTag(SnackBarTestTags.SNACK_BAR), verticalAlignment = Alignment.CenterVertically ) { if (model.leadingIcon != null && model.leadingIcon.isIconAvailable()) { Box( modifier = Modifier - .testTag(SNACK_BAR_ICON) + .testTag(SnackBarTestTags.SNACK_BAR_ICON) .then( if (model.leadingIcon.onClick != null) { Modifier.clickable( @@ -550,7 +712,7 @@ private fun SnackBarStackItem( style = token.subtitleTypography(snackBarInfo), maxLines = messageMaxLines, overflow = TextOverflow.Ellipsis, - modifier = Modifier.testTag(SNACK_BAR_SUBTITLE) + modifier = Modifier.testTag(SnackBarTestTags.SNACK_BAR_SUBTITLE) ) } @@ -562,7 +724,7 @@ private fun SnackBarStackItem( model.onActionTextClicked() }, modifier = Modifier - .testTag(SNACK_BAR_ACTION_BUTTON) + .testTag(SnackBarTestTags.SNACK_BAR_ACTION_BUTTON) .then( if (model.trailingIcon != null) Modifier.padding(horizontal = 16.dp, vertical = 12.dp) @@ -588,7 +750,7 @@ private fun SnackBarStackItem( if (model.trailingIcon != null && model.trailingIcon.isIconAvailable()) { Box( modifier = Modifier - .testTag(SNACK_BAR_ICON) + .testTag(SnackBarTestTags.SNACK_BAR_ICON) .then( if (model.trailingIcon.onClick != null) { Modifier.clickable( @@ -614,14 +776,23 @@ private fun SnackBarStackItem( } } +/** + * A composable that provides a semi-transparent overlay (scrim) that can be used to block user + * interaction with content behind it. The scrim animates its color and alpha. + * + * @param isActivated `true` if the scrim should be visible and opaque, `false` otherwise. + * @param onDismiss A callback to be invoked when the scrim is clicked. + * @param modifier The modifier to be applied to the scrim. + */ @Composable fun Scrim( isActivated: Boolean, onDismiss: () -> Unit, + activatedColor: Color = Color.Black.copy(alpha = 0.6f), modifier: Modifier = Modifier ) { val scrimColor by animateColorAsState( - targetValue = if (isActivated) Color.Black.copy(alpha = 0.6f) else Color.Transparent, + targetValue = if (isActivated) activatedColor else Color.Transparent, animationSpec = tween(durationMillis = ANIMATION_DURATION_MS), label = "ScrimColorAnimation" ) @@ -638,4 +809,4 @@ fun Scrim( ) ) } -} +} \ No newline at end of file From ddc3de33f8b15314b67a63c62360fba64fdb6595 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Mon, 29 Sep 2025 17:48:15 +0530 Subject: [PATCH 43/52] Added Offset Tokens For Providing Additional Offset --- .../fluentui/theme/token/controlTokens/BottomSheetTokens.kt | 3 +++ .../microsoft/fluentui/tokenized/bottomsheet/BottomSheet.kt | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/BottomSheetTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/BottomSheetTokens.kt index efae62687..6402254d1 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/BottomSheetTokens.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/BottomSheetTokens.kt @@ -57,4 +57,7 @@ open class BottomSheetTokens : IControlToken, Parcelable { @Composable open fun maxLandscapeWidth (bottomSheetInfo: BottomSheetInfo): Float = 1F + + @Composable + open fun additionalOffset (bottomSheetInfo: BottomSheetInfo): Int = 0 } \ No newline at end of file diff --git a/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/bottomsheet/BottomSheet.kt b/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/bottomsheet/BottomSheet.kt index 8701fdfae..ec749e24e 100644 --- a/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/bottomsheet/BottomSheet.kt +++ b/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/bottomsheet/BottomSheet.kt @@ -387,7 +387,7 @@ fun BottomSheet( // if we do know our anchors, respect them sheetState.offset.value.roundToInt() } - IntOffset(0, y) + IntOffset(0, y + tokens.additionalOffset(bottomSheetInfo)) } .bottomSheetSwipeable( sheetState, From 4692c13f7cb8ff34bd6b2e296691d6995c480991 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Mon, 29 Sep 2025 17:52:13 +0530 Subject: [PATCH 44/52] Added Token in Demo --- .../fluentuidemo/demos/V2BottomSheetActivity.kt | 11 ++++++++++- .../theme/token/controlTokens/BottomSheetTokens.kt | 1 - 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2BottomSheetActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2BottomSheetActivity.kt index 69b629590..5abe980cb 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2BottomSheetActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2BottomSheetActivity.kt @@ -58,6 +58,8 @@ import com.microsoft.fluentui.persona.PersonaListView import com.microsoft.fluentui.theme.FluentTheme import com.microsoft.fluentui.theme.ThemeMode import com.microsoft.fluentui.theme.token.FluentAliasTokens +import com.microsoft.fluentui.theme.token.controlTokens.BottomSheetInfo +import com.microsoft.fluentui.theme.token.controlTokens.BottomSheetTokens import com.microsoft.fluentui.theme.token.controlTokens.ButtonSize import com.microsoft.fluentui.theme.token.controlTokens.ButtonStyle import com.microsoft.fluentui.tokenized.bottomsheet.BottomSheet @@ -150,6 +152,12 @@ private fun CreateActivityUI() { val content = listOf(0, 1, 2) val selectedOption = remember { mutableStateOf(content[0]) } + val customSheetTokens: BottomSheetTokens = object: BottomSheetTokens(){ + override fun additionalOffset(bottomSheetInfo: BottomSheetInfo): Int { + return 0 + } + } + BottomSheet( sheetContent = sheetContentState, expandable = expandableState, @@ -161,7 +169,8 @@ private fun CreateActivityUI() { enableSwipeDismiss = enableSwipeDismiss, preventDismissalOnScrimClick = preventDismissalOnScrimClick, stickyThresholdUpward = stickyThresholdUpwardDrag, - stickyThresholdDownward = stickyThresholdDownwardDrag + stickyThresholdDownward = stickyThresholdDownwardDrag, + bottomSheetTokens = customSheetTokens ) { Column( verticalArrangement = Arrangement.spacedBy(10.dp), diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/BottomSheetTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/BottomSheetTokens.kt index 6402254d1..bfff91164 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/BottomSheetTokens.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/BottomSheetTokens.kt @@ -58,6 +58,5 @@ open class BottomSheetTokens : IControlToken, Parcelable { @Composable open fun maxLandscapeWidth (bottomSheetInfo: BottomSheetInfo): Float = 1F - @Composable open fun additionalOffset (bottomSheetInfo: BottomSheetInfo): Int = 0 } \ No newline at end of file From 0d891629e784a262b95735e9ac0d345dd95b1e88 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Mon, 29 Sep 2025 18:56:16 +0530 Subject: [PATCH 45/52] Updated Controls and Params URLs --- .../com/microsoft/fluentuidemo/V2StackableSnackbarActivity.kt | 4 ++-- .../java/com/microsoft/fluentuidemo/demos/V2TabBarActivity.kt | 4 ++-- .../com/microsoft/fluentuidemo/demos/V2TextFieldActivity.kt | 4 ++-- .../com/microsoft/fluentuidemo/demos/V2ToolTipActivity.kt | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/V2StackableSnackbarActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/V2StackableSnackbarActivity.kt index 2bda85e18..9cbea4cb7 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/V2StackableSnackbarActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/V2StackableSnackbarActivity.kt @@ -33,9 +33,9 @@ class V2StackableSnackbarActivity : V2DemoActivity() { setupActivity(this) } - override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-36" + override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-37" override val controlTokensUrl = - "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-34" + "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-35" @RequiresApi(Build.VERSION_CODES.N) override fun onCreate(savedInstanceState: Bundle?) { diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2TabBarActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2TabBarActivity.kt index 4e1a0d767..81aeca383 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2TabBarActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2TabBarActivity.kt @@ -48,9 +48,9 @@ class V2TabBarActivity : V2DemoActivity() { setupActivity(this) } - override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-37" + override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-38" override val controlTokensUrl = - "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-35" + "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-36" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2TextFieldActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2TextFieldActivity.kt index d1a304984..ad17b8386 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2TextFieldActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2TextFieldActivity.kt @@ -59,9 +59,9 @@ class V2TextFieldActivity : V2DemoActivity() { setupActivity(this) } - override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-38" + override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-39" override val controlTokensUrl = - "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-36" + "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-37" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ToolTipActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ToolTipActivity.kt index 6516d6a7b..640439c91 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ToolTipActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ToolTipActivity.kt @@ -59,8 +59,8 @@ class V2ToolTipActivity : V2DemoActivity() { setupActivity(this) } - override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-39" - override val controlTokensUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-37" + override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-40" + override val controlTokensUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-38" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) From 7ff9b1aa1bfc1bbd76e78dee6cecb1560398c5a0 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Tue, 30 Sep 2025 12:11:56 +0530 Subject: [PATCH 46/52] Created new release PR --- FluentUI.Demo/build.gradle | 4 ++-- .../src/main/assets/dogfood-release-notes.txt | 12 ++++------- config.gradle | 20 +++++++++---------- ...i-office-build-universal-publish-1espt.yml | 2 +- 4 files changed, 17 insertions(+), 21 deletions(-) diff --git a/FluentUI.Demo/build.gradle b/FluentUI.Demo/build.gradle index a9db08acf..1bc67b1f1 100644 --- a/FluentUI.Demo/build.gradle +++ b/FluentUI.Demo/build.gradle @@ -13,8 +13,8 @@ android { applicationId 'com.microsoft.fluentuidemo' minSdkVersion 23 targetSdkVersion 34 - versionCode 2009 - versionName '0.3.9' + versionCode 2010 + versionName '0.3.10' testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } kotlinOptions { diff --git a/FluentUI.Demo/src/main/assets/dogfood-release-notes.txt b/FluentUI.Demo/src/main/assets/dogfood-release-notes.txt index 1c1c5ff44..0de3e59cb 100644 --- a/FluentUI.Demo/src/main/assets/dogfood-release-notes.txt +++ b/FluentUI.Demo/src/main/assets/dogfood-release-notes.txt @@ -1,14 +1,10 @@ -Release version 0.3.9 +Release version 0.3.10 What's new Fluent v2: Fix/enhancement: - 1. Added elevation tokens for V2 Snackbar - 2. Fluent V2 Native Controls Accessibility Fixes - 3. Added Tooltip support for V2 AppBar, along with a new modifier to add tooltips to any component - 4. Added TextOverflow Tokens for V2 ListItem - 5. Fixed V2 AppBar Layout Focus Navigation - 6. V2 Acrylic Pane now supports blur behind effect - 7. Added a new default SearchViewModel for V2 Searchbar + 1. Added new Stackable Snackbar Component which allows showing multiple notifications at once. + 2. Fixed V2 BottomDrawer not dismissing on back button press when in focus + 3. Added Offset Tokens for V2 BottomSheet to Offset the Anchors Note: The Demo App for Fluent Controls will now also be published on Github starting from v0.3.8 \ No newline at end of file diff --git a/config.gradle b/config.gradle index ce9f2c7e6..4f96c763a 100644 --- a/config.gradle +++ b/config.gradle @@ -12,11 +12,11 @@ * fluentui_drawer and FluentUI should increment their respective version ids */ project.ext.fluentui_calendar_versionid = '0.3.3' -project.ext.fluentui_controls_versionid = '0.3.2' -project.ext.fluentui_core_versionid = '0.3.9' +project.ext.fluentui_controls_versionid = '0.3.3' +project.ext.fluentui_core_versionid = '0.3.10' project.ext.fluentui_listitem_versionid = '0.3.6' project.ext.fluentui_tablayout_versionid = '0.3.3' -project.ext.fluentui_drawer_versionid = '0.3.7' +project.ext.fluentui_drawer_versionid = '0.3.8' project.ext.fluentui_ccb_versionid = '0.3.3' project.ext.fluentui_others_versionid = '0.3.7' project.ext.fluentui_transients_versionid = '0.3.4' @@ -26,14 +26,14 @@ project.ext.fluentui_peoplepicker_versionid = '0.3.5' project.ext.fluentui_persona_versionid = '0.3.4' project.ext.fluentui_progress_versionid = '0.3.6' project.ext.fluentui_icons_versionid = '0.3.2' -project.ext.fluentui_notification_versionid = '0.3.5' -project.ext.FluentUI_versionid = '0.3.9' +project.ext.fluentui_notification_versionid = '0.3.6' +project.ext.FluentUI_versionid = '0.3.10' project.ext.fluentui_calendar_version_code = 2003 -project.ext.fluentui_controls_version_code = 2002 -project.ext.fluentui_core_version_code = 2009 +project.ext.fluentui_controls_version_code = 2003 +project.ext.fluentui_core_version_code = 2010 project.ext.fluentui_listitem_version_code = 2006 project.ext.fluentui_tablayout_version_code = 2003 -project.ext.fluentui_drawer_version_code = 2007 +project.ext.fluentui_drawer_version_code = 2008 project.ext.fluentui_ccb_version_code = 2003 project.ext.fluentui_others_version_code = 2007 project.ext.fluentui_transients_version_code = 2004 @@ -43,8 +43,8 @@ project.ext.fluentui_peoplepicker_version_code = 2005 project.ext.fluentui_persona_version_code = 2004 project.ext.fluentui_progress_version_code = 2006 project.ext.fluentui_icons_version_code = 2002 -project.ext.fluentui_notification_version_code = 2005 -project.ext.FluentUI_version_code = 2009 +project.ext.fluentui_notification_version_code = 2006 +project.ext.FluentUI_version_code = 2010 project.ext.license_type = 'MIT License' project.ext.license_url = 'https://github.com/microsoft/fluentui-android/blob/master/LICENSE' project.ext.github_url = 'https://github.com/microsoft/fluentui-android' diff --git a/fluentui-office-build-universal-publish-1espt.yml b/fluentui-office-build-universal-publish-1espt.yml index 3a3a49ad7..956b433ac 100644 --- a/fluentui-office-build-universal-publish-1espt.yml +++ b/fluentui-office-build-universal-publish-1espt.yml @@ -62,6 +62,6 @@ extends: vstsFeedPublish: 'Office' vstsFeedPackagePublish: 'fluentuiandroid' versionOption: 'custom' - versionPublish: '0.3.9' + versionPublish: '$(releaseVersion)' packagePublishDescription: 'Fluent Universal Package' publishedPackageVar: 'fluent package' From f5a0a66c2e031eff9eeb9113e510f8444a162474 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Wed, 1 Oct 2025 15:03:45 +0530 Subject: [PATCH 47/52] Incorporating Comment Suggestions --- .../V2StackableSnackbarActivity.kt | 7 +- .../notification/StackableSnackbar.kt | 189 +++++++++++------- 2 files changed, 117 insertions(+), 79 deletions(-) diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/V2StackableSnackbarActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/V2StackableSnackbarActivity.kt index 9cbea4cb7..bba170f9e 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/V2StackableSnackbarActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/V2StackableSnackbarActivity.kt @@ -143,12 +143,9 @@ fun SnackBarStackDemoLayout(context: V2StackableSnackbarActivity) { Button(onClick = { scope.launch { - stackState.hideFront() - delay(300) - stackState.removeFront() - stackState.showBack() + stackState.clearAllSnackBars(animateRemoval = true) } - }, text = "Remove latest") + }, text = "Clear All") Spacer(modifier = Modifier.width(12.dp)) Button(onClick = { 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 11d948685..a2d18243c 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 @@ -32,13 +32,14 @@ import androidx.compose.material.Text import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableIntState +import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -84,6 +85,8 @@ import kotlin.math.pow private const val ANIMATION_DURATION_MS = 250 private const val SWIPE_AWAY_ANIMATION_TARGET_FACTOR = 1.2f private const val SWIPE_TO_DISMISS_THRESHOLD_DIVISOR = 4 +private const val DEFAULT_NUMBER_OF_SNACKBARS_EXPANDED = 10 +private const val DEFAULT_NUMBER_OF_SNACKBARS_COLLAPSED = 5 //TODO: Add accessibility support for the stack and individual snackbars //TODO: Perf, reduce recompositions, make stable, minimize launch effect tracked variables @@ -101,8 +104,10 @@ private object SnackBarTestTags { enum class ItemVisibility { /** The snackbar is currently visible. */ Visible, + /** The snackbar is currently hidden. */ Hidden, + /** The snackbar is being removed with an animation. */ BeingRemoved } @@ -135,6 +140,12 @@ data class SnackBarItemModel( val onActionTextClicked: () -> Unit = {} ) +internal data class SnackbarItemInternal( + val model: SnackBarItemModel, + var visibility: MutableState = mutableStateOf(ItemVisibility.Visible), + var contentHeight: MutableIntState = mutableIntStateOf(0) +) + /** * State holder for a stack of snackbars. It manages the list of items, their visibility, and height, * providing methods to add, remove, and toggle the stack's expanded state. @@ -145,12 +156,13 @@ data class SnackBarItemModel( */ class SnackBarStackState( internal val initialSnackbars: MutableList = mutableListOf(), - internal var maxCollapsedSize: Int = 5, - internal var maxExpandedSize: Int = 10 + internal var maxCollapsedSize: Int = DEFAULT_NUMBER_OF_SNACKBARS_COLLAPSED, + internal var maxExpandedSize: Int = DEFAULT_NUMBER_OF_SNACKBARS_EXPANDED ) { - val snapshotStateList: MutableList = mutableStateListOf().apply { addAll(initialSnackbars) } - val contentHeightMap = mutableStateMapOf().apply { putAll(initialSnackbars.associate { it.id to 0 }) } - val itemVisibilityMap = mutableStateMapOf().apply { putAll(initialSnackbars.associate { it.id to ItemVisibility.Visible }) } + internal val snapshotStateList: MutableList = + mutableStateListOf().apply { + addAll(initialSnackbars.map { SnackbarItemInternal(it) }) + } var expanded by mutableStateOf(false) private set @@ -158,6 +170,28 @@ class SnackBarStackState( internal var combinedStackHeight by mutableIntStateOf(0) + fun getSnackBarItemById(id: String): SnackBarItemModel? = + snapshotStateList.firstOrNull { it.model.id == id }?.model + + fun getSnackbarItemByIndex(index: Int): SnackBarItemModel? = + snapshotStateList.getOrNull(index)?.model + + fun getSnackBarItemIndexById(id: String): Int? = + snapshotStateList.indexOfFirst { it.model.id == id }.let { if (it == -1) null else it } + + fun getAllSnackBarItems(): List = snapshotStateList.map { it.model } + + suspend fun clearAllSnackBars(animateRemoval: Boolean = false) { + if (animateRemoval) { + snapshotStateList.reversed().forEach { + it.visibility.value = ItemVisibility.BeingRemoved + delay(40) + } + delay(ANIMATION_DURATION_MS.toLong()) + } + snapshotStateList.clear() + } + /** * Adds a new snackbar to the stack. If the stack exceeds its maximum current size, the * oldest visible snackbar is automatically hidden. @@ -165,15 +199,13 @@ class SnackBarStackState( * @param snackbar The [SnackBarItemModel] to add. */ fun addSnackbar(snackbar: SnackBarItemModel) { - if (snapshotStateList.any { it.id == snackbar.id }) { + if (snapshotStateList.any { it.model.id == snackbar.id }) { return } maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize - snapshotStateList.add(snackbar) - contentHeightMap[snackbar.id] = 0 - itemVisibilityMap[snackbar.id] = ItemVisibility.Visible + snapshotStateList.add(SnackbarItemInternal(snackbar)) if (sizeVisible() > maxCurrentSize) { - hideBack() + hideOldest() } } @@ -184,9 +216,7 @@ class SnackBarStackState( * @return `true` if the snackbar was found and removed, `false` otherwise. */ fun removeSnackbarById(id: String): Boolean { - snapshotStateList.firstOrNull { it.id == id }?.let { - contentHeightMap.remove(it.id) - itemVisibilityMap.remove(it.id) + snapshotStateList.firstOrNull { it.model.id == id }?.let { snapshotStateList.remove(it) return true } @@ -206,12 +236,10 @@ class SnackBarStackState( showLastHiddenSnackbarOnRemove: Boolean = true, onRemoveCompleteCallback: () -> Unit = {} ) { - val snackbar = snapshotStateList.firstOrNull { it.id == id } ?: return - itemVisibilityMap[snackbar.id] = ItemVisibility.BeingRemoved + val snackbar = snapshotStateList.firstOrNull { it.model.id == id } ?: return + snackbar.visibility.value = ItemVisibility.BeingRemoved delay(ANIMATION_DURATION_MS.toLong()) - contentHeightMap.remove(snackbar.id) snapshotStateList.remove(snackbar) - itemVisibilityMap.remove(snackbar.id) if (showLastHiddenSnackbarOnRemove) { onVisibleSizeChange() } @@ -234,25 +262,28 @@ class SnackBarStackState( * does not exceed the allowed maximum. */ private fun onVisibleSizeChange() { - val currentSize = - snapshotStateList.count { itemVisibilityMap[it.id] == ItemVisibility.Visible } - val (count, sequence, targetHidden) = - if (currentSize > maxCurrentSize) { - Triple(currentSize - maxCurrentSize, snapshotStateList, ItemVisibility.Hidden) + val numVisibleSnackbars = + snapshotStateList.count { it.visibility.value == ItemVisibility.Visible } + var (numUpdatesRequired, sequenceIterationOrder, targetVisibilityAfterUpdate) = + if (numVisibleSnackbars > maxCurrentSize) { + Triple( + numVisibleSnackbars - maxCurrentSize, + snapshotStateList, + ItemVisibility.Hidden + ) } else { Triple( - maxCurrentSize - currentSize, + maxCurrentSize - numVisibleSnackbars, snapshotStateList.asReversed(), ItemVisibility.Visible ) } - var slots = count - sequence.forEach { - if (slots <= 0) return@forEach - if (itemVisibilityMap[it.id] != targetHidden) { - itemVisibilityMap[it.id] = targetHidden - slots-- + sequenceIterationOrder.forEach { + if (numUpdatesRequired <= 0) return@forEach + if (it.visibility.value != targetVisibilityAfterUpdate) { + it.visibility.value = targetVisibilityAfterUpdate + numUpdatesRequired-- } } } @@ -262,9 +293,9 @@ class SnackBarStackState( * * @return `true` if a snackbar was hidden, `false` otherwise. */ - fun hideBack(): Boolean { - snapshotStateList.firstOrNull { itemVisibilityMap[it.id] == ItemVisibility.Visible }?.let { - itemVisibilityMap[it.id] = ItemVisibility.Hidden + fun hideOldest(): Boolean { + snapshotStateList.firstOrNull { it.visibility.value == ItemVisibility.Visible }?.let { + it.visibility.value = ItemVisibility.Hidden return true } return false @@ -275,9 +306,9 @@ class SnackBarStackState( * * @return `true` if a snackbar was hidden, `false` otherwise. */ - fun hideFront(): Boolean { - snapshotStateList.lastOrNull { itemVisibilityMap[it.id] == ItemVisibility.Visible }?.let { - itemVisibilityMap[it.id] = ItemVisibility.Hidden + fun hideLatest(): Boolean { + snapshotStateList.lastOrNull { it.visibility.value == ItemVisibility.Visible }?.let { + it.visibility.value = ItemVisibility.Hidden return true } return false @@ -289,12 +320,10 @@ class SnackBarStackState( * @param skipHidden If `true`, only visible snackbars are considered for removal. * @return `true` if a snackbar was removed, `false` otherwise. */ - fun removeFront(skipHidden: Boolean = false): Boolean { - snapshotStateList.lastOrNull { (skipHidden && itemVisibilityMap[it.id] == ItemVisibility.Visible) || !skipHidden } + fun removeLatest(skipHidden: Boolean = false): Boolean { + snapshotStateList.lastOrNull { (skipHidden && it.visibility.value == ItemVisibility.Visible) || !skipHidden } ?.let { - contentHeightMap.remove(it.id) snapshotStateList.remove(it) - itemVisibilityMap.remove(it.id) return true } return false @@ -305,9 +334,9 @@ class SnackBarStackState( * * @return `true` if a snackbar was shown, `false` otherwise. */ - fun showBack(): Boolean { - snapshotStateList.lastOrNull { itemVisibilityMap[it.id] == ItemVisibility.Hidden }?.let { - itemVisibilityMap[it.id] = ItemVisibility.Visible + fun showLastHidden(): Boolean { + snapshotStateList.lastOrNull { it.visibility.value == ItemVisibility.Hidden }?.let { + it.visibility.value = ItemVisibility.Visible return true } return false @@ -322,8 +351,8 @@ class SnackBarStackState( internal fun heightAfterIndex(index: Int): Int { var ans = 0 snapshotStateList.drop(index + 1).forEach { - if (itemVisibilityMap[it.id] == ItemVisibility.Visible) { - ans += (contentHeightMap[it.id] ?: 0) + if (it.visibility.value == ItemVisibility.Visible) { + ans += it.contentHeight.intValue } } return ans @@ -342,7 +371,7 @@ class SnackBarStackState( * @return The number of visible snackbars. */ fun sizeVisible(): Int = - snapshotStateList.count { itemVisibilityMap[it.id] == ItemVisibility.Visible } + snapshotStateList.count { it.visibility.value == ItemVisibility.Visible } } /** @@ -356,8 +385,8 @@ class SnackBarStackState( @Composable fun rememberSnackBarStackState( initial: List = emptyList(), - maxExpandedSize: Int = 10, - maxCollapsedSize: Int = 5 + maxExpandedSize: Int = DEFAULT_NUMBER_OF_SNACKBARS_EXPANDED, + maxCollapsedSize: Int = DEFAULT_NUMBER_OF_SNACKBARS_COLLAPSED ): SnackBarStackState { return remember { SnackBarStackState( @@ -404,13 +433,13 @@ fun SnackBarStack( ) { val localDensity = LocalDensity.current - val visibleSnackbarsCount by remember { derivedStateOf { state.sizeVisible() } } - val targetHeight = if (visibleSnackbarsCount == 0) { + val totalVisibleSnackbars by remember { derivedStateOf { state.sizeVisible() } } + val targetHeight = if (totalVisibleSnackbars == 0) { 0.dp } else if (state.expanded) { with(localDensity) { state.combinedStackHeight.toDp() + snackBarStackConfig.snackbarStackExpandedTopPadding } } else { - snackBarStackConfig.maxSnackbarHeightWhenCollapsed + (visibleSnackbarsCount - 1) * snackBarStackConfig.snackbarPeekHeightWhenCollapsed + snackBarStackConfig.maxSnackbarHeightWhenCollapsed + (totalVisibleSnackbars - 1) * snackBarStackConfig.snackbarPeekHeightWhenCollapsed } val animatedStackHeight by animateDpAsState( targetValue = targetHeight, @@ -421,7 +450,8 @@ fun SnackBarStack( val screenWidth = LocalConfiguration.current.screenWidthDp.dp val screenWidthPx = with(localDensity) { screenWidth.toPx() } - val scrollState = rememberScrollState() //TODO: Keep Focus Anchored To the Bottom when expanded and new snackbar added + val scrollState = + rememberScrollState() //TODO: Keep Focus Anchored To the Bottom when expanded and new snackbar added Column( modifier = Modifier .fillMaxWidth() @@ -437,18 +467,18 @@ fun SnackBarStack( .height(animatedStackHeight), contentAlignment = Alignment.BottomCenter ) { - var visibleIndex = 0 + var visibleSnackbarsEncountered = 0 state.snapshotStateList.forEachIndexed { index, snackBarModel -> - val logicalIndex = visibleSnackbarsCount - 1 - visibleIndex - visibleIndex += if (state.itemVisibilityMap[snackBarModel.id] == ItemVisibility.Visible) 1 else 0 - key(snackBarModel.id) { + val visibleIndex = totalVisibleSnackbars - 1 - visibleSnackbarsEncountered + visibleSnackbarsEncountered += if (snackBarModel.visibility.value == ItemVisibility.Visible) 1 else 0 + key(snackBarModel.model.id) { SnackBarStackItem( state = state, - visibleIndex = logicalIndex, + visibleIndex = visibleIndex, trueIndex = index, onSwipedAway = { idToRemove -> state.removeSnackbarById(idToRemove) - state.showBack() + state.showLastHidden() }, snackBarStackConfig = snackBarStackConfig, enableSwipeToDismiss = enableSwipeToDismiss, @@ -482,7 +512,8 @@ private fun SnackBarStackItem( enableSwipeToDismiss: Boolean = true, screenWidthPx: Float ) { - val model = state.snapshotStateList[trueIndex] + val modelWrapper = state.snapshotStateList[trueIndex] + val model = modelWrapper.model val cardHeight = snackBarStackConfig.maxSnackbarHeightWhenCollapsed val peekHeight = snackBarStackConfig.snackbarPeekHeightWhenCollapsed @@ -495,6 +526,7 @@ private fun SnackBarStackItem( val entryAnimationType = token.entryAnimationType(snackBarInfo) val exitAnimationType = token.exitAnimationType(snackBarInfo) + // Vertical Offset Animation: Related to Stack Expansion/Collapse and Item Position in Stack val initialYOffset = when (entryAnimationType) { StackableSnackbarEntryAnimationType.SlideInFromAbove -> -with(localDensity) { cardHeight.toPx() } StackableSnackbarEntryAnimationType.SlideInFromBelow -> with(localDensity) { cardHeight.toPx() } @@ -508,9 +540,9 @@ private fun SnackBarStackItem( state.expanded, state.snapshotStateList.size, state.heightAfterIndex(trueIndex), - state.itemVisibilityMap[model.id] + modelWrapper.visibility.value ) { - if (state.itemVisibilityMap[model.id] == ItemVisibility.BeingRemoved) { + if (modelWrapper.visibility.value == ItemVisibility.BeingRemoved) { return@LaunchedEffect } animatedYOffset.animateTo( @@ -522,7 +554,9 @@ private fun SnackBarStackItem( ) } - val stackedWidthScaleFactor = token.snackbarWidthScalingFactor(snackBarInfo).coerceIn(0.01f, 2.0f) + // Width Scale Animation: Related to Cards Shrinking when stacked + val stackedWidthScaleFactor = + token.snackbarWidthScalingFactor(snackBarInfo).coerceIn(0.01f, 2.0f) val targetWidthScale = if (state.expanded) 1f else stackedWidthScaleFactor.pow(visibleIndex) val animatedWidthScale = animateFloatAsState( targetValue = targetWidthScale, @@ -530,15 +564,17 @@ private fun SnackBarStackItem( label = "WidthScaleAnimation" ) + // Opacity Animation: Related to Entry/Exit Fade Animations val opacityProgress = remember { Animatable(0f) } - LaunchedEffect(state.itemVisibilityMap[model.id]) { - val visibility = state.itemVisibilityMap[model.id] ?: ItemVisibility.Visible + LaunchedEffect(modelWrapper.visibility.value) { + val visibility = modelWrapper.visibility.value opacityProgress.animateTo( if (visibility != ItemVisibility.Visible) 0f else 1f, tween(ANIMATION_DURATION_MS) ) } + // Horizontal Animations: Related to Swipe to Dismiss and Entry/Exit val initialXOffset = when (entryAnimationType) { StackableSnackbarEntryAnimationType.SlideInFromLeft -> -screenWidthPx StackableSnackbarEntryAnimationType.SlideInFromRight -> screenWidthPx @@ -547,8 +583,8 @@ private fun SnackBarStackItem( StackableSnackbarEntryAnimationType.SlideInFromBelow -> 0f } val swipeX = remember { Animatable(initialXOffset) } - LaunchedEffect(state.itemVisibilityMap[model.id], state.expanded, visibleIndex) { - if (state.itemVisibilityMap[model.id] == ItemVisibility.BeingRemoved) { + LaunchedEffect(modelWrapper.visibility.value, state.expanded, visibleIndex) { + if (modelWrapper.visibility.value == ItemVisibility.BeingRemoved) { val target = when (exitAnimationType) { StackableSnackbarExitAnimationType.SlideOutToLeft -> screenWidthPx * -SWIPE_AWAY_ANIMATION_TARGET_FACTOR StackableSnackbarExitAnimationType.SlideOutToRight -> screenWidthPx * SWIPE_AWAY_ANIMATION_TARGET_FACTOR @@ -556,13 +592,15 @@ private fun SnackBarStackItem( } swipeX.animateTo( with(localDensity) { target }, - animationSpec = tween(durationMillis = ANIMATION_DURATION_MS, easing = FastOutLinearInEasing) + animationSpec = tween( + durationMillis = ANIMATION_DURATION_MS, + easing = FastOutLinearInEasing + ) ) } else { - if(isTop) { + if (isTop) { swipeX.animateTo(0f) - } - else { + } else { swipeX.snapTo(0f) } } @@ -582,10 +620,10 @@ private fun SnackBarStackItem( Modifier.onGloballyPositioned( onGloballyPositioned = { coordinates: LayoutCoordinates -> val contentHeight = coordinates.size.height - if( model.id in state.contentHeightMap && state.contentHeightMap[model.id] == contentHeight) { + if (modelWrapper.contentHeight.intValue == contentHeight) { return@onGloballyPositioned } - state.contentHeightMap[model.id] = + modelWrapper.contentHeight.intValue = contentHeight + with(localDensity) { snackBarStackConfig.snackbarGapWhenExpanded.toPx().toInt() } @@ -698,7 +736,8 @@ private fun SnackBarStackItem( .weight(1F) .padding(textPaddingValues) ) { - val messageMaxLines = if (state.expanded) Int.MAX_VALUE else snackBarStackConfig.maximumTextLinesWhenCollapsed + val messageMaxLines = + if (state.expanded) Int.MAX_VALUE else snackBarStackConfig.maximumTextLinesWhenCollapsed Text( text = model.message, @@ -776,6 +815,8 @@ private fun SnackBarStackItem( } } +private const val SCRIM_DEFAULT_OPACITY = 0.6f + /** * A composable that provides a semi-transparent overlay (scrim) that can be used to block user * interaction with content behind it. The scrim animates its color and alpha. @@ -788,7 +829,7 @@ private fun SnackBarStackItem( fun Scrim( isActivated: Boolean, onDismiss: () -> Unit, - activatedColor: Color = Color.Black.copy(alpha = 0.6f), + activatedColor: Color = Color.Black.copy(alpha = SCRIM_DEFAULT_OPACITY), modifier: Modifier = Modifier ) { val scrimColor by animateColorAsState( From 78cf990d0bcb67c2f2c32061a24e8ebeb9119805 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Fri, 3 Oct 2025 10:18:43 +0530 Subject: [PATCH 48/52] Specified Explicit version for Compose Animation Module --- build.gradle | 2 ++ fluentui_controls/build.gradle | 3 +++ fluentui_core/build.gradle | 6 ++++++ fluentui_drawer/build.gradle | 4 ++++ fluentui_listitem/build.gradle | 5 +++++ fluentui_menus/build.gradle | 4 ++++ fluentui_notification/build.gradle | 5 +++++ fluentui_others/build.gradle | 4 ++++ fluentui_peoplepicker/build.gradle | 3 +++ fluentui_progress/build.gradle | 3 +++ fluentui_tablayout/build.gradle | 3 +++ fluentui_topappbars/build.gradle | 4 ++++ 12 files changed, 46 insertions(+) diff --git a/build.gradle b/build.gradle index 437c8a3b8..ba98767b3 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,8 @@ allprojects { composeBomVersion = '2023.09.00' composeTestVersion = '1.4.3' composeCompilerVersion = '1.4.7' + composeAnimationVersion = '1.7.8' + composeRuntimeSaveableVersion = '1.7.8' constraintLayoutVersion = '2.1.4' constraintLayoutComposeVersion = '1.0.1' espressoVersion = '3.5.1' diff --git a/fluentui_controls/build.gradle b/fluentui_controls/build.gradle index 41d94ea5d..9fb6c6adf 100644 --- a/fluentui_controls/build.gradle +++ b/fluentui_controls/build.gradle @@ -52,6 +52,9 @@ dependencies { implementation "androidx.core:core-ktx:$androidxCoreKt" + // Override corrupted versions from OfficeFeed + implementation "androidx.compose.animation:animation:$composeAnimationVersion" + implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.material:material") implementation("androidx.compose.runtime:runtime") diff --git a/fluentui_core/build.gradle b/fluentui_core/build.gradle index 89293d399..f44006191 100644 --- a/fluentui_core/build.gradle +++ b/fluentui_core/build.gradle @@ -84,6 +84,12 @@ dependencies { //Compose BOM implementation platform("androidx.compose:compose-bom:$composeBomVersion") + + // Override corrupted versions from OfficeFeed + implementation "androidx.compose.animation:animation:$composeAnimationVersion" + implementation "androidx.compose.animation:animation-core:$composeAnimationVersion" + implementation "androidx.compose.runtime:runtime-saveable:$composeRuntimeSaveableVersion" + implementation "androidx.compose.ui:ui-text" implementation "androidx.compose.ui:ui-graphics" implementation "androidx.compose.ui:ui-unit" diff --git a/fluentui_drawer/build.gradle b/fluentui_drawer/build.gradle index abe58f744..4ba239bf2 100644 --- a/fluentui_drawer/build.gradle +++ b/fluentui_drawer/build.gradle @@ -57,6 +57,10 @@ dependencies { androidTestImplementation "androidx.test.ext:junit:$extJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion" + // Override corrupted versions from OfficeFeed + implementation "androidx.compose.animation:animation-core:$composeAnimationVersion" + implementation "androidx.compose.runtime:runtime-saveable:$composeRuntimeSaveableVersion" + implementation "androidx.compose.ui:ui" implementation "androidx.compose.ui:ui-util" implementation "androidx.compose.material:material" diff --git a/fluentui_listitem/build.gradle b/fluentui_listitem/build.gradle index 89b9693fd..80fc62e40 100644 --- a/fluentui_listitem/build.gradle +++ b/fluentui_listitem/build.gradle @@ -52,6 +52,11 @@ dependencies { implementation "androidx.appcompat:appcompat:$appCompatVersion" implementation "androidx.recyclerview:recyclerview:$recyclerViewVersion" + // Override corrupted versions from OfficeFeed + implementation "androidx.compose.animation:animation:$composeAnimationVersion" + implementation "androidx.compose.animation:animation-core:$composeAnimationVersion" + implementation "androidx.compose.runtime:runtime-saveable:$composeRuntimeSaveableVersion" + implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.material:material") implementation("androidx.compose.runtime:runtime") diff --git a/fluentui_menus/build.gradle b/fluentui_menus/build.gradle index cf6f0b9d8..fa02e212e 100644 --- a/fluentui_menus/build.gradle +++ b/fluentui_menus/build.gradle @@ -45,6 +45,10 @@ dependencies { testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$extJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion" + + // Override corrupted versions from OfficeFeed + implementation "androidx.compose.animation:animation-core:$composeAnimationVersion" + implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.runtime:runtime") implementation("androidx.compose.ui:ui") diff --git a/fluentui_notification/build.gradle b/fluentui_notification/build.gradle index e86d09a21..e246126ba 100644 --- a/fluentui_notification/build.gradle +++ b/fluentui_notification/build.gradle @@ -53,6 +53,11 @@ dependencies { implementation "androidx.core:core-ktx:$androidxCoreKt" + // Override corrupted versions from OfficeFeed + implementation "androidx.compose.animation:animation:$composeAnimationVersion" + implementation "androidx.compose.animation:animation-core:$composeAnimationVersion" + implementation "androidx.compose.runtime:runtime-saveable:$composeRuntimeSaveableVersion" + implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.material:material") implementation("androidx.compose.runtime:runtime") diff --git a/fluentui_others/build.gradle b/fluentui_others/build.gradle index cac516032..2ce37ff6a 100644 --- a/fluentui_others/build.gradle +++ b/fluentui_others/build.gradle @@ -61,6 +61,10 @@ dependencies { implementation "androidx.cardview:cardview:1.0.0" implementation "com.google.android.material:material:$materialVersion" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + + // Override corrupted versions from OfficeFeed + implementation "androidx.compose.runtime:runtime-saveable:$composeRuntimeSaveableVersion" + implementation "androidx.compose.foundation:foundation" testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$extJunitVersion" diff --git a/fluentui_peoplepicker/build.gradle b/fluentui_peoplepicker/build.gradle index 52dbd1117..39248567a 100644 --- a/fluentui_peoplepicker/build.gradle +++ b/fluentui_peoplepicker/build.gradle @@ -74,6 +74,9 @@ dependencies { implementation "com.google.android.material:material:$materialVersion" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + // Override corrupted versions from OfficeFeed + implementation "androidx.compose.runtime:runtime-saveable:$composeRuntimeSaveableVersion" + implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.runtime:runtime") implementation("androidx.compose.ui:ui") diff --git a/fluentui_progress/build.gradle b/fluentui_progress/build.gradle index 9bdb0b224..4c47ebdd1 100644 --- a/fluentui_progress/build.gradle +++ b/fluentui_progress/build.gradle @@ -52,6 +52,9 @@ dependencies { androidTestImplementation "androidx.test.ext:junit:$extJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion" + // Override corrupted versions from OfficeFeed + implementation "androidx.compose.animation:animation-core:$composeAnimationVersion" + implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.material:material") implementation("androidx.compose.runtime:runtime") diff --git a/fluentui_tablayout/build.gradle b/fluentui_tablayout/build.gradle index b62186ccb..caa24b449 100644 --- a/fluentui_tablayout/build.gradle +++ b/fluentui_tablayout/build.gradle @@ -47,6 +47,9 @@ dependencies { implementation "androidx.appcompat:appcompat:$appCompatVersion" implementation "com.google.android.material:material:$materialVersion" + // Override corrupted versions from OfficeFeed + implementation "androidx.compose.animation:animation-core:$composeAnimationVersion" + implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.material:material") implementation("androidx.compose.runtime:runtime") diff --git a/fluentui_topappbars/build.gradle b/fluentui_topappbars/build.gradle index 0a2fdef00..f97c20fe6 100644 --- a/fluentui_topappbars/build.gradle +++ b/fluentui_topappbars/build.gradle @@ -55,6 +55,10 @@ dependencies { implementation "com.google.android.material:material:$materialVersion" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleComposeVersion" + // Override corrupted versions from OfficeFeed + implementation "androidx.compose.animation:animation:$composeAnimationVersion" + implementation "androidx.compose.runtime:runtime-saveable:$composeRuntimeSaveableVersion" + implementation "androidx.compose.ui:ui" implementation "androidx.compose.ui:ui-util" implementation "androidx.compose.material:material" From a91b34c9c38c0a4abddd68aa16e724a320f51a21 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Fri, 3 Oct 2025 12:40:07 +0530 Subject: [PATCH 49/52] Downgraded to a working verison --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index ba98767b3..9c78920e8 100644 --- a/build.gradle +++ b/build.gradle @@ -47,8 +47,8 @@ allprojects { composeBomVersion = '2023.09.00' composeTestVersion = '1.4.3' composeCompilerVersion = '1.4.7' - composeAnimationVersion = '1.7.8' - composeRuntimeSaveableVersion = '1.7.8' + composeAnimationVersion = '1.6.0' + composeRuntimeSaveableVersion = '1.6.0' constraintLayoutVersion = '2.1.4' constraintLayoutComposeVersion = '1.0.1' espressoVersion = '3.5.1' From 9ad9cbaa60812b9fd3a039113bda6442b7273c7e Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Mon, 6 Oct 2025 11:14:06 +0530 Subject: [PATCH 50/52] Updated to maximum compatible version --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 9c78920e8..af02c2e57 100644 --- a/build.gradle +++ b/build.gradle @@ -47,8 +47,8 @@ allprojects { composeBomVersion = '2023.09.00' composeTestVersion = '1.4.3' composeCompilerVersion = '1.4.7' - composeAnimationVersion = '1.6.0' - composeRuntimeSaveableVersion = '1.6.0' + composeAnimationVersion = '1.6.7' + composeRuntimeSaveableVersion = '1.6.7' constraintLayoutVersion = '2.1.4' constraintLayoutComposeVersion = '1.0.1' espressoVersion = '3.5.1' From 17846d39eab3d46dc5cde789c51b1a886872fdfa Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Mon, 6 Oct 2025 11:25:57 +0530 Subject: [PATCH 51/52] Package version upgrades --- config.gradle | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/config.gradle b/config.gradle index 4f96c763a..7cf432112 100644 --- a/config.gradle +++ b/config.gradle @@ -14,34 +14,34 @@ project.ext.fluentui_calendar_versionid = '0.3.3' project.ext.fluentui_controls_versionid = '0.3.3' project.ext.fluentui_core_versionid = '0.3.10' -project.ext.fluentui_listitem_versionid = '0.3.6' -project.ext.fluentui_tablayout_versionid = '0.3.3' -project.ext.fluentui_drawer_versionid = '0.3.8' +project.ext.fluentui_listitem_versionid = '0.3.7' +project.ext.fluentui_tablayout_versionid = '0.3.4' +project.ext.fluentui_drawer_versionid = '0.3.9' project.ext.fluentui_ccb_versionid = '0.3.3' -project.ext.fluentui_others_versionid = '0.3.7' +project.ext.fluentui_others_versionid = '0.3.8' project.ext.fluentui_transients_versionid = '0.3.4' -project.ext.fluentui_topappbars_versionid = '0.3.7' -project.ext.fluentui_menus_versionid = '0.3.4' -project.ext.fluentui_peoplepicker_versionid = '0.3.5' +project.ext.fluentui_topappbars_versionid = '0.3.8' +project.ext.fluentui_menus_versionid = '0.3.5' +project.ext.fluentui_peoplepicker_versionid = '0.3.6' project.ext.fluentui_persona_versionid = '0.3.4' -project.ext.fluentui_progress_versionid = '0.3.6' +project.ext.fluentui_progress_versionid = '0.3.7' project.ext.fluentui_icons_versionid = '0.3.2' project.ext.fluentui_notification_versionid = '0.3.6' project.ext.FluentUI_versionid = '0.3.10' project.ext.fluentui_calendar_version_code = 2003 project.ext.fluentui_controls_version_code = 2003 project.ext.fluentui_core_version_code = 2010 -project.ext.fluentui_listitem_version_code = 2006 -project.ext.fluentui_tablayout_version_code = 2003 -project.ext.fluentui_drawer_version_code = 2008 +project.ext.fluentui_listitem_version_code = 2007 +project.ext.fluentui_tablayout_version_code = 2004 +project.ext.fluentui_drawer_version_code = 2009 project.ext.fluentui_ccb_version_code = 2003 -project.ext.fluentui_others_version_code = 2007 +project.ext.fluentui_others_version_code = 2008 project.ext.fluentui_transients_version_code = 2004 -project.ext.fluentui_topappbars_version_code = 2007 -project.ext.fluentui_menus_version_code = 2004 -project.ext.fluentui_peoplepicker_version_code = 2005 +project.ext.fluentui_topappbars_version_code = 2008 +project.ext.fluentui_menus_version_code = 2005 +project.ext.fluentui_peoplepicker_version_code = 2006 project.ext.fluentui_persona_version_code = 2004 -project.ext.fluentui_progress_version_code = 2006 +project.ext.fluentui_progress_version_code = 2007 project.ext.fluentui_icons_version_code = 2002 project.ext.fluentui_notification_version_code = 2006 project.ext.FluentUI_version_code = 2010 From 20a83f5c651f4626d0f38bd7d529e912d9864e05 Mon Sep 17 00:00:00 2001 From: Dhruv-Mishra Date: Wed, 8 Oct 2025 15:16:25 +0530 Subject: [PATCH 52/52] Trigger CI Rerun --- fluentui-maven-central-publish-1espt.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fluentui-maven-central-publish-1espt.yml b/fluentui-maven-central-publish-1espt.yml index fd4c5681f..cdd52f668 100644 --- a/fluentui-maven-central-publish-1espt.yml +++ b/fluentui-maven-central-publish-1espt.yml @@ -106,4 +106,4 @@ extends: displayName: 'Publish release notes to pipeline' targetPath: "$(build.sourcesdirectory)/FluentUI.Demo/src/main/assets" artifactName: "notes" - publishLocation: "pipeline" \ No newline at end of file + publishLocation: "pipeline"