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..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 @@ -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 @@ -64,265 +65,269 @@ 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) - } - } + 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) +// } +// } } } } 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..3bc495519 --- /dev/null +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -0,0 +1,641 @@ +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 +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 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 +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.times +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), + 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.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 var expanded by mutableStateOf(false) + + private val listOperationMutex = Mutex() + + private val expandMutex = Mutex() + + fun addCard(card: CardModel) { + scope.launch { + 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() + } + } + } + + fun removeCardById(id: String) { + scope.launch { + listOperationMutex.withLock { + withContext(Dispatchers.Main) { + val index = snapshotStateList.indexOfFirst { it.id == id } + if (index != -1) { + snapshotStateList.removeAt(index) + } + } + } + } + } + + @RequiresApi(Build.VERSION_CODES.N) + 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 + + withContext(Dispatchers.Main) { + expanded = !currentExpanded + } + + 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() { + scope.launch { + val index = snapshotStateList.indexOfFirst { !it.hidden.value } + if (index != -1) { + hideAtSingle(index, remove = true) + } + } + } + + fun popFront() { + scope.launch { + val index = snapshotStateList.indexOfLast { !it.hidden.value } + if (index != -1) { + hideAtSingle(index, remove = true) + } + } + } + + /** + * Shows cards at the specified indices in parallel. + */ + @RequiresApi(Build.VERSION_CODES.N) + fun showAt(indices: List) { + scope.launch { + showAtParallel(indices) + } + } + + /** + * Shows cards in parallel for smooth animation + */ + @RequiresApi(Build.VERSION_CODES.N) + 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 + } + listOperationMutex.withLock { + withContext(Dispatchers.Main) { + hiddenIndicesList.removeIf { it.second.id == card.id } + } + } + } + } + } + } + + /** + * 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) { + 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) + } + } + } + } + } + } + + /** + * 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 + } + } + } + } + } + + // Animate all cards in parallel + if (cardsToHide.isNotEmpty()) { + delay(FADE_OUT_DURATION.toLong()) + + // 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()) } +} + +/** + * 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 + */ +@RequiresApi(Build.VERSION_CODES.N) +@Composable +fun CardStack( + state: CardStackState, + modifier: Modifier = Modifier, + cardWidth: Dp = 320.dp, + cardHeight: Dp = 160.dp, + peekHeight: Dp = 10.dp, + stackOffset: Offset = Offset(0f, 0f), + stackAbove: Boolean = true, + contentModifier: Modifier = Modifier +) { + 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) + } else { + cardHeight + (if (count > 0) (count - 1) * peekHeight else 0.dp) + } + ) + } + + val animatedStackHeight by animateDpAsState( + targetValue = targetHeight, + animationSpec = spring(stiffness = Spring.StiffnessMedium) + ) + + 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.toggleExpanded() + }, + tooltipText = "Notification Stack", + ) + ) { + val visibleCards = state.snapshotStateList.toList() + + visibleCards.forEachIndexed { index, cardModel -> + val logicalIndex = visibleCards.size - 1 - index + val isTop = logicalIndex == 0 + + key(cardModel.id) { + CardStackItem( + model = cardModel, + isHidden = cardModel.hidden.value, + isReshown = cardModel.isReshown.value, + expanded = state.expanded, + index = logicalIndex, + isTop = isTop, + cardHeight = cardHeight, + peekHeight = peekHeight, + cardWidth = cardWidth, + 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, + index: Int, + stackAbove: Boolean = true, + targetYOffset: MutableState, + animatedYOffset: Animatable +) { + LaunchedEffect(index, expanded, isReshown) { + animatedYOffset.animateTo( + targetYOffset.value * (if (stackAbove) -1f else 1f), + animationSpec = spring(stiffness = Spring.StiffnessLow) + ) + } +} + +@Composable +private fun CardWidthAnimation( + expanded: Boolean, + index: Int, + animatedWidth: Animatable, + targetWidth: MutableState +) { + LaunchedEffect(index, expanded) { + animatedWidth.animateTo( + targetWidth.value, + animationSpec = spring(stiffness = Spring.StiffnessLow) + ) + } +} + +@Composable +private fun SlideInAnimation( + model: CardModel, + 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) + } + } + } +} + +@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 + ) + ) + } + } +} + +@Composable +private fun CardStackItem( + model: CardModel, + isHidden: Boolean, + isReshown: Boolean = false, + expanded: Boolean, + index: Int, + isTop: Boolean, + cardWidth: Dp, + cardHeight: Dp, + peekHeight: Dp, + stackedWidthScaleFactor: Float = 0.95f, + onSwipedAway: (String) -> Unit, + stackAbove: Boolean = false +) { + 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(0f) + } + CardAdjustAnimation( + expanded = expanded, + isReshown = isReshown, + index = index, + stackAbove = stackAbove, + 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) } + 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 + + 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() }, + ) + .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 + ) + ) + onSwipedAway(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 -> + change.consume() + scope.launch { + swipeX.snapTo(swipeX.value + dragAmount.x) + } + } + ) + } else Modifier) + ) { + BasicCard( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(12.dp)) + .shadow( + elevation = 12.dp + ) + .background(Color.Gray) + ) + { + 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