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 2a3cf11e3..fb91622c3 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 android.util.Log import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutLinearInEasing @@ -11,6 +12,7 @@ import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -44,10 +46,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow 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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput @@ -55,12 +61,12 @@ 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.LocalFocusManager +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription -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 @@ -81,6 +87,7 @@ 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.flow.drop import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.pow @@ -179,6 +186,10 @@ class SnackBarStackState( var expanded by mutableStateOf(false) private set + var selectedItemId: String? = null + private set + var focusRequestToken by mutableIntStateOf(0) + private set internal var maxCurrentSize = maxCollapsedSize internal var combinedStackHeight by mutableIntStateOf(0) @@ -236,6 +247,10 @@ class SnackBarStackState( return false } + fun updateSelectedItem(id: String) { + selectedItemId = id + } + /** * Removes a snackbar with an exit animation. The actual removal from the state list is * delayed to allow the animation to complete. @@ -267,6 +282,12 @@ class SnackBarStackState( expanded = !expanded maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize onVisibleSizeChange() + if (expanded) { + selectedItemId = snapshotStateList.firstOrNull { it.visibility.value == ItemVisibility.Visible }?.model?.id + focusRequestToken++ + } else { + selectedItemId = null + } } /** @@ -443,6 +464,8 @@ fun SnackBarStack( snackBarStackConfig: SnackBarStackConfig = SnackBarStackConfig() ) { val localDensity = LocalDensity.current + val view = LocalView.current + val focusManager = LocalFocusManager.current val totalVisibleSnackbars by remember { derivedStateOf { state.sizeVisible() } } val targetHeight = if (totalVisibleSnackbars == 0) { @@ -458,11 +481,25 @@ fun SnackBarStack( label = SnackBarLabels.STACK_HEIGHT_ANIMATION ) + val expandedAnnouncement = stringResource(R.string.expanded_announcement) + val collapsedAnnouncement = stringResource(R.string.collapsed_announcement) + 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 + + LaunchedEffect(expandedAnnouncement, collapsedAnnouncement) { + snapshotFlow { state.expanded } + .drop(1) // dropping the first emission since it's not a result of user interaction and can cause unwanted announcements on initial load. + .collect { isExpanded -> + if (!isExpanded) { + focusManager.clearFocus() + } + view.announceForAccessibility(if (isExpanded) expandedAnnouncement else collapsedAnnouncement) + } + } Column( modifier = Modifier .fillMaxWidth() @@ -534,6 +571,19 @@ private fun SnackBarStackItem( val entryAnimationType = token.entryAnimationType(snackBarInfo) val exitAnimationType = token.exitAnimationType(snackBarInfo) + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(state.focusRequestToken) { + if (state.selectedItemId == model.id) { + delay(ANIMATION_DURATION_MS.toLong()) + try { + focusRequester.requestFocus() + } catch (e: Exception) { + Log.e("SnackBarStackItem", "Failed to request focus for snackbar with id ${model.id}", e) + } + } + } + // Vertical Offset Animation: Related to Stack Expansion/Collapse and Item Position in Stack val initialYOffset = when (entryAnimationType) { StackableSnackbarEntryAnimationType.SlideInFromAbove -> -with(localDensity) { cardHeight.toPx() } @@ -708,9 +758,22 @@ private fun SnackBarStackItem( ) .clip(RoundedCornerShape(8.dp)) .background(token.backgroundBrush(snackBarInfo)) - .semantics { - liveRegion = LiveRegionMode.Polite + .focusRequester(focusRequester) + .onFocusChanged{ + if (it.isFocused) { + state.updateSelectedItem(model.id) + } } + .focusable() + .then( + if (state.expanded) { + Modifier.semantics { + contentDescription = "${trueIndex+1} of ${state.snapshotStateList.size} ${model.message}" + } + } else { + Modifier + } + ) .testTag(SnackBarTestTags.SNACK_BAR), verticalAlignment = Alignment.CenterVertically ) { diff --git a/fluentui_notification/src/main/res/values/strings.xml b/fluentui_notification/src/main/res/values/strings.xml index e5104fdf8..f439234a3 100644 --- a/fluentui_notification/src/main/res/values/strings.xml +++ b/fluentui_notification/src/main/res/values/strings.xml @@ -1,4 +1,6 @@ Dismiss Expanded notifications + List Expanded + List Collapsed \ No newline at end of file