11package com.microsoft.fluentui.tokenized.notification
22
3+ import android.util.Log
34import androidx.compose.animation.animateColorAsState
45import androidx.compose.animation.core.Animatable
56import androidx.compose.animation.core.FastOutLinearInEasing
@@ -11,6 +12,7 @@ import androidx.compose.animation.core.spring
1112import androidx.compose.animation.core.tween
1213import androidx.compose.foundation.background
1314import androidx.compose.foundation.clickable
15+ import androidx.compose.foundation.focusable
1416import androidx.compose.foundation.gestures.detectHorizontalDragGestures
1517import androidx.compose.foundation.interaction.MutableInteractionSource
1618import androidx.compose.foundation.layout.Arrangement
@@ -44,23 +46,27 @@ import androidx.compose.runtime.mutableStateOf
4446import androidx.compose.runtime.remember
4547import androidx.compose.runtime.rememberCoroutineScope
4648import androidx.compose.runtime.setValue
49+ import androidx.compose.runtime.snapshotFlow
4750import androidx.compose.ui.Alignment
4851import androidx.compose.ui.Modifier
4952import androidx.compose.ui.draw.clip
5053import androidx.compose.ui.draw.shadow
54+ import androidx.compose.ui.focus.FocusRequester
55+ import androidx.compose.ui.focus.focusRequester
56+ import androidx.compose.ui.focus.onFocusChanged
5157import androidx.compose.ui.graphics.Color
5258import androidx.compose.ui.graphics.graphicsLayer
5359import androidx.compose.ui.input.pointer.pointerInput
5460import androidx.compose.ui.layout.LayoutCoordinates
5561import androidx.compose.ui.layout.onGloballyPositioned
5662import androidx.compose.ui.platform.LocalConfiguration
5763import androidx.compose.ui.platform.LocalDensity
64+ import androidx.compose.ui.platform.LocalFocusManager
65+ import androidx.compose.ui.platform.LocalView
5866import androidx.compose.ui.platform.testTag
5967import androidx.compose.ui.res.stringResource
60- import androidx.compose.ui.semantics.LiveRegionMode
6168import androidx.compose.ui.semantics.Role
6269import androidx.compose.ui.semantics.contentDescription
63- import androidx.compose.ui.semantics.liveRegion
6470import androidx.compose.ui.semantics.semantics
6571import androidx.compose.ui.text.style.TextOverflow
6672import androidx.compose.ui.unit.Dp
@@ -81,6 +87,7 @@ import com.microsoft.fluentui.theme.token.controlTokens.StackableSnackbarEntryAn
8187import com.microsoft.fluentui.theme.token.controlTokens.StackableSnackbarExitAnimationType
8288import com.microsoft.fluentui.tokenized.controls.Button
8389import kotlinx.coroutines.delay
90+ import kotlinx.coroutines.flow.drop
8491import kotlinx.coroutines.launch
8592import kotlin.math.abs
8693import kotlin.math.pow
@@ -179,6 +186,10 @@ class SnackBarStackState(
179186
180187 var expanded by mutableStateOf(false )
181188 private set
189+ var selectedItemId: String? = null
190+ private set
191+ var focusRequestToken by mutableIntStateOf(0 )
192+ private set
182193 internal var maxCurrentSize = maxCollapsedSize
183194
184195 internal var combinedStackHeight by mutableIntStateOf(0 )
@@ -217,6 +228,8 @@ class SnackBarStackState(
217228 }
218229 maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize
219230 snapshotStateList.add(SnackbarItemInternal (snackbar))
231+ selectedItemId = snackbar.id
232+ focusRequestToken++
220233 if (sizeVisible() > maxCurrentSize) {
221234 hideOldest()
222235 }
@@ -229,11 +242,26 @@ class SnackBarStackState(
229242 * @return `true` if the snackbar was found and removed, `false` otherwise.
230243 */
231244 fun removeSnackbarById (id : String ): Boolean {
232- snapshotStateList.firstOrNull { it.model.id == id }?.let {
233- snapshotStateList.remove(it)
245+ val index = snapshotStateList.indexOfFirst { id == it.model.id }
246+ if (index == - 1 ) {
247+ return false
248+ }
249+ if (id != selectedItemId) {
250+ snapshotStateList.removeAt(index)
234251 return true
235252 }
236- return false
253+ selectedItemId = if (index == snapshotStateList.size - 1 ){
254+ snapshotStateList.firstOrNull{ it.visibility.value == ItemVisibility .Visible && it.model.id != selectedItemId }?.model?.id
255+ } else {
256+ snapshotStateList.getOrNull(index+ 1 )?.model?.id
257+ }
258+ snapshotStateList.removeAt(index)
259+ focusRequestToken++
260+ return true
261+ }
262+
263+ fun updateSelectedItem (id : String ) {
264+ selectedItemId = id
237265 }
238266
239267 /* *
@@ -250,9 +278,22 @@ class SnackBarStackState(
250278 onRemoveCompleteCallback : () -> Unit = {}
251279 ) {
252280 val snackbar = snapshotStateList.firstOrNull { it.model.id == id } ? : return
281+ if (id == selectedItemId) {
282+ val index = snapshotStateList.indexOf(snackbar)
283+ selectedItemId = if (index == snapshotStateList.size - 1 ) {
284+ snapshotStateList.firstOrNull {
285+ it.visibility.value == ItemVisibility .Visible && it.model.id != id
286+ }?.model?.id
287+ } else {
288+ snapshotStateList.getOrNull(index + 1 )?.model?.id
289+ }
290+ }
253291 snackbar.visibility.value = ItemVisibility .BeingRemoved
254292 delay(ANIMATION_DURATION_MS .toLong())
255293 snapshotStateList.remove(snackbar)
294+ if (id != selectedItemId) {
295+ focusRequestToken++
296+ }
256297 if (showLastHiddenSnackbarOnRemove) {
257298 onVisibleSizeChange()
258299 }
@@ -267,6 +308,12 @@ class SnackBarStackState(
267308 expanded = ! expanded
268309 maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize
269310 onVisibleSizeChange()
311+ selectedItemId = if (expanded) {
312+ snapshotStateList.firstOrNull { it.visibility.value == ItemVisibility .Visible }?.model?.id
313+ } else {
314+ snapshotStateList.lastOrNull()?.model?.id
315+ }
316+ focusRequestToken++
270317 }
271318
272319 /* *
@@ -443,6 +490,8 @@ fun SnackBarStack(
443490 snackBarStackConfig : SnackBarStackConfig = SnackBarStackConfig ()
444491) {
445492 val localDensity = LocalDensity .current
493+ val view = LocalView .current
494+ val focusManager = LocalFocusManager .current
446495
447496 val totalVisibleSnackbars by remember { derivedStateOf { state.sizeVisible() } }
448497 val targetHeight = if (totalVisibleSnackbars == 0 ) {
@@ -458,11 +507,25 @@ fun SnackBarStack(
458507 label = SnackBarLabels .STACK_HEIGHT_ANIMATION
459508 )
460509
510+ val expandedAnnouncement = stringResource(R .string.expanded_announcement)
511+ val collapsedAnnouncement = stringResource(R .string.collapsed_announcement)
512+
461513 val screenWidth = LocalConfiguration .current.screenWidthDp.dp
462514 val screenWidthPx = with (localDensity) { screenWidth.toPx() }
463515
464516 val scrollState =
465517 rememberScrollState() // TODO: Keep Focus Anchored To the Bottom when expanded and new snackbar added
518+
519+ LaunchedEffect (expandedAnnouncement, collapsedAnnouncement) {
520+ snapshotFlow { state.expanded }
521+ .drop(1 ) // dropping the first emission since it's not a result of user interaction and can cause unwanted announcements on initial load.
522+ .collect { isExpanded ->
523+ if (! isExpanded) {
524+ focusManager.clearFocus()
525+ }
526+ view.announceForAccessibility(if (isExpanded) expandedAnnouncement else collapsedAnnouncement)
527+ }
528+ }
466529 Column (
467530 modifier = Modifier
468531 .fillMaxWidth()
@@ -534,6 +597,19 @@ private fun SnackBarStackItem(
534597 val entryAnimationType = token.entryAnimationType(snackBarInfo)
535598 val exitAnimationType = token.exitAnimationType(snackBarInfo)
536599
600+ val focusRequester = remember { FocusRequester () }
601+
602+ LaunchedEffect (state.focusRequestToken) {
603+ if (state.selectedItemId == model.id) {
604+ delay(ANIMATION_DURATION_MS .toLong())
605+ try {
606+ focusRequester.requestFocus()
607+ } catch (e: Exception ) {
608+ Log .e(" SnackBarStackItem" , " Failed to request focus for snackbar with id ${model.id} " , e)
609+ }
610+ }
611+ }
612+
537613 // Vertical Offset Animation: Related to Stack Expansion/Collapse and Item Position in Stack
538614 val initialYOffset = when (entryAnimationType) {
539615 StackableSnackbarEntryAnimationType .SlideInFromAbove -> - with (localDensity) { cardHeight.toPx() }
@@ -708,9 +784,23 @@ private fun SnackBarStackItem(
708784 )
709785 .clip(RoundedCornerShape (8 .dp))
710786 .background(token.backgroundBrush(snackBarInfo))
711- .semantics {
712- liveRegion = LiveRegionMode .Polite
787+ .focusRequester(focusRequester)
788+ .onFocusChanged{
789+ if (it.isFocused) {
790+ state.updateSelectedItem(model.id)
791+ }
713792 }
793+ .focusable()
794+ .then(
795+ Modifier .semantics {
796+ val resultString = buildString {
797+ append(" ${trueIndex+ 1 } of ${state.snapshotStateList.size} ${model.message} " )
798+ model.actionText?.let { append(" , $it " ) }
799+ model.trailingIcon?.contentDescription?.let { append(" , $it " ) }
800+ }
801+ contentDescription = resultString
802+ }
803+ )
714804 .testTag(SnackBarTestTags .SNACK_BAR ),
715805 verticalAlignment = Alignment .CenterVertically
716806 ) {
0 commit comments