Skip to content

Commit a25b030

Browse files
Merge pull request microsoft#823 from pranjal-glowingstar/pranjal/strings
Adding strings for expand and collapsed for stackable snackbar
2 parents 221ef29 + c16bc6a commit a25b030

2 files changed

Lines changed: 99 additions & 7 deletions

File tree

fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.microsoft.fluentui.tokenized.notification
22

3+
import android.util.Log
34
import androidx.compose.animation.animateColorAsState
45
import androidx.compose.animation.core.Animatable
56
import androidx.compose.animation.core.FastOutLinearInEasing
@@ -11,6 +12,7 @@ import androidx.compose.animation.core.spring
1112
import androidx.compose.animation.core.tween
1213
import androidx.compose.foundation.background
1314
import androidx.compose.foundation.clickable
15+
import androidx.compose.foundation.focusable
1416
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
1517
import androidx.compose.foundation.interaction.MutableInteractionSource
1618
import androidx.compose.foundation.layout.Arrangement
@@ -44,23 +46,27 @@ import androidx.compose.runtime.mutableStateOf
4446
import androidx.compose.runtime.remember
4547
import androidx.compose.runtime.rememberCoroutineScope
4648
import androidx.compose.runtime.setValue
49+
import androidx.compose.runtime.snapshotFlow
4750
import androidx.compose.ui.Alignment
4851
import androidx.compose.ui.Modifier
4952
import androidx.compose.ui.draw.clip
5053
import 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
5157
import androidx.compose.ui.graphics.Color
5258
import androidx.compose.ui.graphics.graphicsLayer
5359
import androidx.compose.ui.input.pointer.pointerInput
5460
import androidx.compose.ui.layout.LayoutCoordinates
5561
import androidx.compose.ui.layout.onGloballyPositioned
5662
import androidx.compose.ui.platform.LocalConfiguration
5763
import androidx.compose.ui.platform.LocalDensity
64+
import androidx.compose.ui.platform.LocalFocusManager
65+
import androidx.compose.ui.platform.LocalView
5866
import androidx.compose.ui.platform.testTag
5967
import androidx.compose.ui.res.stringResource
60-
import androidx.compose.ui.semantics.LiveRegionMode
6168
import androidx.compose.ui.semantics.Role
6269
import androidx.compose.ui.semantics.contentDescription
63-
import androidx.compose.ui.semantics.liveRegion
6470
import androidx.compose.ui.semantics.semantics
6571
import androidx.compose.ui.text.style.TextOverflow
6672
import androidx.compose.ui.unit.Dp
@@ -81,6 +87,7 @@ import com.microsoft.fluentui.theme.token.controlTokens.StackableSnackbarEntryAn
8187
import com.microsoft.fluentui.theme.token.controlTokens.StackableSnackbarExitAnimationType
8288
import com.microsoft.fluentui.tokenized.controls.Button
8389
import kotlinx.coroutines.delay
90+
import kotlinx.coroutines.flow.drop
8491
import kotlinx.coroutines.launch
8592
import kotlin.math.abs
8693
import 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
) {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<resources>
33
<string name="scrim_content_description">Dismiss Expanded notifications</string>
4+
<string name="expanded_announcement">List Expanded</string>
5+
<string name="collapsed_announcement">List Collapsed</string>
46
</resources>

0 commit comments

Comments
 (0)