Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -44,23 +46,27 @@ 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
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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
}

/**
Expand Down Expand Up @@ -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) {
Expand All @@ -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()
Expand Down Expand Up @@ -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() }
Expand Down Expand Up @@ -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
) {
Expand Down
2 changes: 2 additions & 0 deletions fluentui_notification/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="scrim_content_description">Dismiss Expanded notifications</string>
<string name="expanded_announcement">List Expanded</string>
<string name="collapsed_announcement">List Collapsed</string>
</resources>