diff --git a/FluentUI.Demo/build.gradle b/FluentUI.Demo/build.gradle
index a9db08acf..1bc67b1f1 100644
--- a/FluentUI.Demo/build.gradle
+++ b/FluentUI.Demo/build.gradle
@@ -13,8 +13,8 @@ android {
applicationId 'com.microsoft.fluentuidemo'
minSdkVersion 23
targetSdkVersion 34
- versionCode 2009
- versionName '0.3.9'
+ versionCode 2010
+ versionName '0.3.10'
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
kotlinOptions {
diff --git a/FluentUI.Demo/src/main/AndroidManifest.xml b/FluentUI.Demo/src/main/AndroidManifest.xml
index 49a6cf3aa..e3b7486dd 100644
--- a/FluentUI.Demo/src/main/AndroidManifest.xml
+++ b/FluentUI.Demo/src/main/AndroidManifest.xml
@@ -65,6 +65,7 @@
+
+ Button(
+ onClick = {
+ Toast.makeText(context, "Button #$index pressed", Toast.LENGTH_SHORT).show()
+ },
+ text = "Button #$index",
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ repeat(20) { index ->
+ BasicText(
+ text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp)
+ )
+ }
+ Spacer(modifier = Modifier.height(50.dp))
+ }
+}
+
+@Composable
+fun SnackBarStackDemoLayout(context: V2StackableSnackbarActivity) {
+ Box() {
+ val stackState = rememberSnackBarStackState(
+ maxExpandedSize = 10,
+ maxCollapsedSize = 5
+ )
+ var counter by rememberSaveable { mutableIntStateOf(0) }
+ val scope = rememberCoroutineScope()
+ BackgroundContent(context)
+ Scrim(
+ isActivated = stackState.expanded && stackState.sizeVisible() > 0,
+ onDismiss = {}
+ )
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Bottom
+ ) {
+
+ SnackBarStack(
+ state = stackState,
+ snackBarStackConfig = SnackBarStackConfig(
+ snackbarGapWhenExpanded = 10.dp
+ )
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ Row() {
+ Button(onClick = {
+ val id = counter++
+
+ stackState.addSnackbar(
+ SnackBarItemModel(
+ id = id.toString(),
+ message = "Snackbar #$id",
+ actionText = "Expand",
+ trailingIcon = FluentIcon(
+ Icons.Default.Close,
+ Icons.Default.Close,
+ contentDescription = "Close",
+ onClick = {
+ scope.launch {
+ stackState.removeSnackbarByIdWithAnimation(
+ id.toString(),
+ showLastHiddenSnackbarOnRemove = true
+ )
+ }
+ }
+ ),
+ onActionTextClicked = {
+ stackState.toggleExpandedState()
+ })
+ )
+ }, text = "Add Snackbar")
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Button(onClick = {
+ scope.launch {
+ stackState.clearAllSnackBars(animateRemoval = true)
+ }
+ }, text = "Clear All")
+
+ Spacer(modifier = Modifier.width(12.dp))
+ Button(onClick = {
+ scope.launch {
+ val id = counter++
+ for (i in 0..15) {
+ stackState.addSnackbar(
+ SnackBarItemModel(
+ id = "$id-$i",
+ message = "Snackbar #$id-$i".repeat(i + 4),
+ actionText = "Expand",
+ trailingIcon = FluentIcon(
+ Icons.Default.Close,
+ Icons.Default.Close,
+ contentDescription = "Close",
+ onClick = {
+ scope.launch {
+ stackState.removeSnackbarByIdWithAnimation(
+ "$id-$i",
+ showLastHiddenSnackbarOnRemove = true
+ )
+ }
+ }
+ ),
+ onActionTextClicked = {
+ stackState.toggleExpandedState()
+ })
+ )
+ delay(2000)
+ }
+ }
+ }, text = "Keep Adding")
+ }
+ Spacer(modifier = Modifier.height(10.dp))
+ }
+ }
+}
\ No newline at end of file
diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2BottomSheetActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2BottomSheetActivity.kt
index 69b629590..5abe980cb 100644
--- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2BottomSheetActivity.kt
+++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2BottomSheetActivity.kt
@@ -58,6 +58,8 @@ import com.microsoft.fluentui.persona.PersonaListView
import com.microsoft.fluentui.theme.FluentTheme
import com.microsoft.fluentui.theme.ThemeMode
import com.microsoft.fluentui.theme.token.FluentAliasTokens
+import com.microsoft.fluentui.theme.token.controlTokens.BottomSheetInfo
+import com.microsoft.fluentui.theme.token.controlTokens.BottomSheetTokens
import com.microsoft.fluentui.theme.token.controlTokens.ButtonSize
import com.microsoft.fluentui.theme.token.controlTokens.ButtonStyle
import com.microsoft.fluentui.tokenized.bottomsheet.BottomSheet
@@ -150,6 +152,12 @@ private fun CreateActivityUI() {
val content = listOf(0, 1, 2)
val selectedOption = remember { mutableStateOf(content[0]) }
+ val customSheetTokens: BottomSheetTokens = object: BottomSheetTokens(){
+ override fun additionalOffset(bottomSheetInfo: BottomSheetInfo): Int {
+ return 0
+ }
+ }
+
BottomSheet(
sheetContent = sheetContentState,
expandable = expandableState,
@@ -161,7 +169,8 @@ private fun CreateActivityUI() {
enableSwipeDismiss = enableSwipeDismiss,
preventDismissalOnScrimClick = preventDismissalOnScrimClick,
stickyThresholdUpward = stickyThresholdUpwardDrag,
- stickyThresholdDownward = stickyThresholdDownwardDrag
+ stickyThresholdDownward = stickyThresholdDownwardDrag,
+ bottomSheetTokens = customSheetTokens
) {
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
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..16624620b 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
@@ -1,7 +1,9 @@
package com.microsoft.fluentuidemo.demos
+import android.os.Build
import android.os.Bundle
import android.widget.Toast
+import androidx.annotation.RequiresApi
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
@@ -59,6 +61,7 @@ class V2SnackbarActivity : V2DemoActivity() {
override val controlTokensUrl =
"https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-34"
+ @RequiresApi(Build.VERSION_CODES.N)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val context = this
diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2TabBarActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2TabBarActivity.kt
index 4e1a0d767..81aeca383 100644
--- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2TabBarActivity.kt
+++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2TabBarActivity.kt
@@ -48,9 +48,9 @@ class V2TabBarActivity : V2DemoActivity() {
setupActivity(this)
}
- override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-37"
+ override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-38"
override val controlTokensUrl =
- "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-35"
+ "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-36"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2TextFieldActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2TextFieldActivity.kt
index d1a304984..ad17b8386 100644
--- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2TextFieldActivity.kt
+++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2TextFieldActivity.kt
@@ -59,9 +59,9 @@ class V2TextFieldActivity : V2DemoActivity() {
setupActivity(this)
}
- override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-38"
+ override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-39"
override val controlTokensUrl =
- "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-36"
+ "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-37"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ToolTipActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ToolTipActivity.kt
index 6516d6a7b..640439c91 100644
--- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ToolTipActivity.kt
+++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ToolTipActivity.kt
@@ -59,8 +59,8 @@ class V2ToolTipActivity : V2DemoActivity() {
setupActivity(this)
}
- override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-39"
- override val controlTokensUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-37"
+ override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-40"
+ override val controlTokensUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-38"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
diff --git a/build.gradle b/build.gradle
index 437c8a3b8..af02c2e57 100644
--- a/build.gradle
+++ b/build.gradle
@@ -47,6 +47,8 @@ allprojects {
composeBomVersion = '2023.09.00'
composeTestVersion = '1.4.3'
composeCompilerVersion = '1.4.7'
+ composeAnimationVersion = '1.6.7'
+ composeRuntimeSaveableVersion = '1.6.7'
constraintLayoutVersion = '2.1.4'
constraintLayoutComposeVersion = '1.0.1'
espressoVersion = '3.5.1'
diff --git a/config.gradle b/config.gradle
index ce9f2c7e6..7cf432112 100644
--- a/config.gradle
+++ b/config.gradle
@@ -12,39 +12,39 @@
* fluentui_drawer and FluentUI should increment their respective version ids
*/
project.ext.fluentui_calendar_versionid = '0.3.3'
-project.ext.fluentui_controls_versionid = '0.3.2'
-project.ext.fluentui_core_versionid = '0.3.9'
-project.ext.fluentui_listitem_versionid = '0.3.6'
-project.ext.fluentui_tablayout_versionid = '0.3.3'
-project.ext.fluentui_drawer_versionid = '0.3.7'
+project.ext.fluentui_controls_versionid = '0.3.3'
+project.ext.fluentui_core_versionid = '0.3.10'
+project.ext.fluentui_listitem_versionid = '0.3.7'
+project.ext.fluentui_tablayout_versionid = '0.3.4'
+project.ext.fluentui_drawer_versionid = '0.3.9'
project.ext.fluentui_ccb_versionid = '0.3.3'
-project.ext.fluentui_others_versionid = '0.3.7'
+project.ext.fluentui_others_versionid = '0.3.8'
project.ext.fluentui_transients_versionid = '0.3.4'
-project.ext.fluentui_topappbars_versionid = '0.3.7'
-project.ext.fluentui_menus_versionid = '0.3.4'
-project.ext.fluentui_peoplepicker_versionid = '0.3.5'
+project.ext.fluentui_topappbars_versionid = '0.3.8'
+project.ext.fluentui_menus_versionid = '0.3.5'
+project.ext.fluentui_peoplepicker_versionid = '0.3.6'
project.ext.fluentui_persona_versionid = '0.3.4'
-project.ext.fluentui_progress_versionid = '0.3.6'
+project.ext.fluentui_progress_versionid = '0.3.7'
project.ext.fluentui_icons_versionid = '0.3.2'
-project.ext.fluentui_notification_versionid = '0.3.5'
-project.ext.FluentUI_versionid = '0.3.9'
+project.ext.fluentui_notification_versionid = '0.3.6'
+project.ext.FluentUI_versionid = '0.3.10'
project.ext.fluentui_calendar_version_code = 2003
-project.ext.fluentui_controls_version_code = 2002
-project.ext.fluentui_core_version_code = 2009
-project.ext.fluentui_listitem_version_code = 2006
-project.ext.fluentui_tablayout_version_code = 2003
-project.ext.fluentui_drawer_version_code = 2007
+project.ext.fluentui_controls_version_code = 2003
+project.ext.fluentui_core_version_code = 2010
+project.ext.fluentui_listitem_version_code = 2007
+project.ext.fluentui_tablayout_version_code = 2004
+project.ext.fluentui_drawer_version_code = 2009
project.ext.fluentui_ccb_version_code = 2003
-project.ext.fluentui_others_version_code = 2007
+project.ext.fluentui_others_version_code = 2008
project.ext.fluentui_transients_version_code = 2004
-project.ext.fluentui_topappbars_version_code = 2007
-project.ext.fluentui_menus_version_code = 2004
-project.ext.fluentui_peoplepicker_version_code = 2005
+project.ext.fluentui_topappbars_version_code = 2008
+project.ext.fluentui_menus_version_code = 2005
+project.ext.fluentui_peoplepicker_version_code = 2006
project.ext.fluentui_persona_version_code = 2004
-project.ext.fluentui_progress_version_code = 2006
+project.ext.fluentui_progress_version_code = 2007
project.ext.fluentui_icons_version_code = 2002
-project.ext.fluentui_notification_version_code = 2005
-project.ext.FluentUI_version_code = 2009
+project.ext.fluentui_notification_version_code = 2006
+project.ext.FluentUI_version_code = 2010
project.ext.license_type = 'MIT License'
project.ext.license_url = 'https://github.com/microsoft/fluentui-android/blob/master/LICENSE'
project.ext.github_url = 'https://github.com/microsoft/fluentui-android'
diff --git a/fluentui-maven-central-publish-1espt.yml b/fluentui-maven-central-publish-1espt.yml
index f8a62286b..bb2245b1b 100644
--- a/fluentui-maven-central-publish-1espt.yml
+++ b/fluentui-maven-central-publish-1espt.yml
@@ -107,4 +107,4 @@ extends:
displayName: 'Publish release notes to pipeline'
targetPath: "$(build.sourcesdirectory)/FluentUI.Demo/src/main/assets"
artifactName: "notes"
- publishLocation: "pipeline"
\ No newline at end of file
+ publishLocation: "pipeline"
diff --git a/fluentui-office-build-universal-publish-1espt.yml b/fluentui-office-build-universal-publish-1espt.yml
index 3a3a49ad7..956b433ac 100644
--- a/fluentui-office-build-universal-publish-1espt.yml
+++ b/fluentui-office-build-universal-publish-1espt.yml
@@ -62,6 +62,6 @@ extends:
vstsFeedPublish: 'Office'
vstsFeedPackagePublish: 'fluentuiandroid'
versionOption: 'custom'
- versionPublish: '0.3.9'
+ versionPublish: '$(releaseVersion)'
packagePublishDescription: 'Fluent Universal Package'
publishedPackageVar: 'fluent package'
diff --git a/fluentui_controls/build.gradle b/fluentui_controls/build.gradle
index 41d94ea5d..9fb6c6adf 100644
--- a/fluentui_controls/build.gradle
+++ b/fluentui_controls/build.gradle
@@ -52,6 +52,9 @@ dependencies {
implementation "androidx.core:core-ktx:$androidxCoreKt"
+ // Override corrupted versions from OfficeFeed
+ implementation "androidx.compose.animation:animation:$composeAnimationVersion"
+
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material:material")
implementation("androidx.compose.runtime:runtime")
diff --git a/fluentui_core/build.gradle b/fluentui_core/build.gradle
index 89293d399..f44006191 100644
--- a/fluentui_core/build.gradle
+++ b/fluentui_core/build.gradle
@@ -84,6 +84,12 @@ dependencies {
//Compose BOM
implementation platform("androidx.compose:compose-bom:$composeBomVersion")
+
+ // Override corrupted versions from OfficeFeed
+ implementation "androidx.compose.animation:animation:$composeAnimationVersion"
+ implementation "androidx.compose.animation:animation-core:$composeAnimationVersion"
+ implementation "androidx.compose.runtime:runtime-saveable:$composeRuntimeSaveableVersion"
+
implementation "androidx.compose.ui:ui-text"
implementation "androidx.compose.ui:ui-graphics"
implementation "androidx.compose.ui:ui-unit"
diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/ControlTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/ControlTokens.kt
index e3bf894aa..cdee9964e 100644
--- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/ControlTokens.kt
+++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/ControlTokens.kt
@@ -73,6 +73,7 @@ open class ControlTokens : IControlTokens {
ShimmerControlType,
SideRailControlType,
SnackbarControlType,
+ StackableSnackbarControlType,
TabBarControlType,
TabItemControlType,
TextFieldControlType,
@@ -124,6 +125,7 @@ open class ControlTokens : IControlTokens {
ControlType.ShimmerControlType -> ShimmerTokens()
ControlType.SideRailControlType -> SideRailTokens()
ControlType.SnackbarControlType -> SnackBarTokens()
+ ControlType.StackableSnackbarControlType -> StackableSnackBarTokens()
ControlType.TabBarControlType -> TabBarTokens()
ControlType.TabItemControlType -> TabItemTokens()
ControlType.TextFieldControlType -> TextFieldTokens()
diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/BottomSheetTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/BottomSheetTokens.kt
index efae62687..bfff91164 100644
--- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/BottomSheetTokens.kt
+++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/BottomSheetTokens.kt
@@ -57,4 +57,6 @@ open class BottomSheetTokens : IControlToken, Parcelable {
@Composable
open fun maxLandscapeWidth (bottomSheetInfo: BottomSheetInfo): Float = 1F
+
+ open fun additionalOffset (bottomSheetInfo: BottomSheetInfo): Int = 0
}
\ No newline at end of file
diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/StackableSnackbarTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/StackableSnackbarTokens.kt
new file mode 100644
index 000000000..b7e1f6b2c
--- /dev/null
+++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/StackableSnackbarTokens.kt
@@ -0,0 +1,50 @@
+package com.microsoft.fluentui.theme.token.controlTokens
+
+import androidx.annotation.FloatRange
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.microsoft.fluentui.theme.token.FluentGlobalTokens
+import kotlinx.parcelize.Parcelize
+
+
+enum class StackableSnackbarEntryAnimationType {
+ SlideInFromAbove,
+ SlideInFromBelow,
+ FadeIn,
+ SlideInFromLeft,
+ SlideInFromRight
+}
+
+enum class StackableSnackbarExitAnimationType {
+ FadeOut,
+ SlideOutToLeft,
+ SlideOutToRight
+}
+
+@Parcelize
+open class StackableSnackBarTokens : SnackBarTokens() {
+ @Composable
+ override fun shadowElevationValue(snackBarInfo: SnackBarInfo): Dp {
+ return FluentGlobalTokens.ShadowTokens.Shadow08.value
+ }
+
+ @FloatRange(from = 0.0, to = 2.0, fromInclusive = false, toInclusive = true)
+ @Composable
+ fun snackbarWidthScalingFactor(snackBarInfo: SnackBarInfo): Float {
+ return 0.95f
+ }
+
+ @Composable
+ fun entryAnimationType(snackBarInfo: SnackBarInfo): StackableSnackbarEntryAnimationType {
+ return StackableSnackbarEntryAnimationType.SlideInFromBelow
+ }
+
+ @Composable
+ fun exitAnimationType(snackBarInfo: SnackBarInfo): StackableSnackbarExitAnimationType {
+ return StackableSnackbarExitAnimationType.SlideOutToLeft
+ }
+}
\ No newline at end of file
diff --git a/fluentui_drawer/build.gradle b/fluentui_drawer/build.gradle
index abe58f744..4ba239bf2 100644
--- a/fluentui_drawer/build.gradle
+++ b/fluentui_drawer/build.gradle
@@ -57,6 +57,10 @@ dependencies {
androidTestImplementation "androidx.test.ext:junit:$extJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
+ // Override corrupted versions from OfficeFeed
+ implementation "androidx.compose.animation:animation-core:$composeAnimationVersion"
+ implementation "androidx.compose.runtime:runtime-saveable:$composeRuntimeSaveableVersion"
+
implementation "androidx.compose.ui:ui"
implementation "androidx.compose.ui:ui-util"
implementation "androidx.compose.material:material"
diff --git a/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/bottomsheet/BottomSheet.kt b/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/bottomsheet/BottomSheet.kt
index 8701fdfae..ec749e24e 100644
--- a/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/bottomsheet/BottomSheet.kt
+++ b/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/bottomsheet/BottomSheet.kt
@@ -387,7 +387,7 @@ fun BottomSheet(
// if we do know our anchors, respect them
sheetState.offset.value.roundToInt()
}
- IntOffset(0, y)
+ IntOffset(0, y + tokens.additionalOffset(bottomSheetInfo))
}
.bottomSheetSwipeable(
sheetState,
diff --git a/fluentui_listitem/build.gradle b/fluentui_listitem/build.gradle
index 89b9693fd..80fc62e40 100644
--- a/fluentui_listitem/build.gradle
+++ b/fluentui_listitem/build.gradle
@@ -52,6 +52,11 @@ dependencies {
implementation "androidx.appcompat:appcompat:$appCompatVersion"
implementation "androidx.recyclerview:recyclerview:$recyclerViewVersion"
+ // Override corrupted versions from OfficeFeed
+ implementation "androidx.compose.animation:animation:$composeAnimationVersion"
+ implementation "androidx.compose.animation:animation-core:$composeAnimationVersion"
+ implementation "androidx.compose.runtime:runtime-saveable:$composeRuntimeSaveableVersion"
+
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material:material")
implementation("androidx.compose.runtime:runtime")
diff --git a/fluentui_menus/build.gradle b/fluentui_menus/build.gradle
index cf6f0b9d8..fa02e212e 100644
--- a/fluentui_menus/build.gradle
+++ b/fluentui_menus/build.gradle
@@ -45,6 +45,10 @@ dependencies {
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$extJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
+
+ // Override corrupted versions from OfficeFeed
+ implementation "androidx.compose.animation:animation-core:$composeAnimationVersion"
+
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.runtime:runtime")
implementation("androidx.compose.ui:ui")
diff --git a/fluentui_notification/build.gradle b/fluentui_notification/build.gradle
index e86d09a21..e246126ba 100644
--- a/fluentui_notification/build.gradle
+++ b/fluentui_notification/build.gradle
@@ -53,6 +53,11 @@ dependencies {
implementation "androidx.core:core-ktx:$androidxCoreKt"
+ // Override corrupted versions from OfficeFeed
+ implementation "androidx.compose.animation:animation:$composeAnimationVersion"
+ implementation "androidx.compose.animation:animation-core:$composeAnimationVersion"
+ implementation "androidx.compose.runtime:runtime-saveable:$composeRuntimeSaveableVersion"
+
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material:material")
implementation("androidx.compose.runtime:runtime")
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..a2d18243c
--- /dev/null
+++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt
@@ -0,0 +1,853 @@
+package com.microsoft.fluentui.tokenized.notification
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FastOutLinearInEasing
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
+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.gestures.detectHorizontalDragGestures
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.Text
+import androidx.compose.material.ripple.rememberRipple
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableIntState
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+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.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.testTag
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.Role
+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
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.times
+import com.microsoft.fluentui.theme.token.FluentIcon
+import com.microsoft.fluentui.theme.token.Icon
+import com.microsoft.fluentui.theme.token.StateColor
+import com.microsoft.fluentui.theme.token.controlTokens.ButtonInfo
+import com.microsoft.fluentui.theme.token.controlTokens.ButtonSize
+import com.microsoft.fluentui.theme.token.controlTokens.ButtonStyle
+import com.microsoft.fluentui.theme.token.controlTokens.ButtonTokens
+import com.microsoft.fluentui.theme.token.controlTokens.SnackBarInfo
+import com.microsoft.fluentui.theme.token.controlTokens.SnackbarStyle
+import com.microsoft.fluentui.theme.token.controlTokens.StackableSnackBarTokens
+import com.microsoft.fluentui.theme.token.controlTokens.StackableSnackbarEntryAnimationType
+import com.microsoft.fluentui.theme.token.controlTokens.StackableSnackbarExitAnimationType
+import com.microsoft.fluentui.tokenized.controls.Button
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlin.math.abs
+import kotlin.math.pow
+
+private const val ANIMATION_DURATION_MS = 250
+private const val SWIPE_AWAY_ANIMATION_TARGET_FACTOR = 1.2f
+private const val SWIPE_TO_DISMISS_THRESHOLD_DIVISOR = 4
+private const val DEFAULT_NUMBER_OF_SNACKBARS_EXPANDED = 10
+private const val DEFAULT_NUMBER_OF_SNACKBARS_COLLAPSED = 5
+
+//TODO: Add accessibility support for the stack and individual snackbars
+//TODO: Perf, reduce recompositions, make stable, minimize launch effect tracked variables
+
+private object SnackBarTestTags {
+ const val SNACK_BAR = "snack_bar"
+ const val SNACK_BAR_ICON = "snack_bar_icon"
+ const val SNACK_BAR_SUBTITLE = "snack_bar_subtitle"
+ const val SNACK_BAR_ACTION_BUTTON = "snack_bar_action_button"
+}
+
+/**
+ * Enum class to define the visibility state of a snackbar item within the stack.
+ */
+enum class ItemVisibility {
+ /** The snackbar is currently visible. */
+ Visible,
+
+ /** The snackbar is currently hidden. */
+ Hidden,
+
+ /** The snackbar is being removed with an animation. */
+ BeingRemoved
+}
+
+private val DEFAULT_SNACKBAR_TOKENS = StackableSnackBarTokens()
+
+/**
+ * Data model for an individual snackbar item.
+ *
+ * @property message The main text message to be displayed in the snackbar.
+ * @property id A unique identifier for the snackbar item. Defaults to a random UUID.
+ * @property style The visual style of the snackbar, e.g., Neutral, Danger, Warning.
+ * @property leadingIcon An optional leading icon to display.
+ * @property trailingIcon An optional trailing icon to display.
+ * @property subTitle An optional subtitle text.
+ * @property actionText Optional text for the action button. If null, no action button is shown.
+ * @property snackBarToken The tokens for customizing the snackbar's appearance.
+ * @property onActionTextClicked The callback to be invoked when the action button is clicked.
+ */
+@Stable
+data class SnackBarItemModel(
+ val message: String,
+ val id: String = java.util.UUID.randomUUID().toString(),
+ val style: SnackbarStyle = SnackbarStyle.Neutral,
+ val leadingIcon: FluentIcon? = null,
+ val trailingIcon: FluentIcon? = null,
+ val subTitle: String? = null,
+ val actionText: String? = null,
+ val snackBarToken: StackableSnackBarTokens = DEFAULT_SNACKBAR_TOKENS,
+ val onActionTextClicked: () -> Unit = {}
+)
+
+internal data class SnackbarItemInternal(
+ val model: SnackBarItemModel,
+ var visibility: MutableState = mutableStateOf(ItemVisibility.Visible),
+ var contentHeight: MutableIntState = mutableIntStateOf(0)
+)
+
+/**
+ * State holder for a stack of snackbars. It manages the list of items, their visibility, and height,
+ * providing methods to add, remove, and toggle the stack's expanded state.
+ *
+ * @param initialSnackbars The initial list of snackbars to be displayed.
+ * @param maxCollapsedSize The maximum number of snackbars to show when the stack is collapsed.
+ * @param maxExpandedSize The maximum number of snackbars to show when the stack is expanded.
+ */
+class SnackBarStackState(
+ internal val initialSnackbars: MutableList = mutableListOf(),
+ internal var maxCollapsedSize: Int = DEFAULT_NUMBER_OF_SNACKBARS_COLLAPSED,
+ internal var maxExpandedSize: Int = DEFAULT_NUMBER_OF_SNACKBARS_EXPANDED
+) {
+ internal val snapshotStateList: MutableList =
+ mutableStateListOf().apply {
+ addAll(initialSnackbars.map { SnackbarItemInternal(it) })
+ }
+
+ var expanded by mutableStateOf(false)
+ private set
+ internal var maxCurrentSize = maxCollapsedSize
+
+ internal var combinedStackHeight by mutableIntStateOf(0)
+
+ fun getSnackBarItemById(id: String): SnackBarItemModel? =
+ snapshotStateList.firstOrNull { it.model.id == id }?.model
+
+ fun getSnackbarItemByIndex(index: Int): SnackBarItemModel? =
+ snapshotStateList.getOrNull(index)?.model
+
+ fun getSnackBarItemIndexById(id: String): Int? =
+ snapshotStateList.indexOfFirst { it.model.id == id }.let { if (it == -1) null else it }
+
+ fun getAllSnackBarItems(): List = snapshotStateList.map { it.model }
+
+ suspend fun clearAllSnackBars(animateRemoval: Boolean = false) {
+ if (animateRemoval) {
+ snapshotStateList.reversed().forEach {
+ it.visibility.value = ItemVisibility.BeingRemoved
+ delay(40)
+ }
+ delay(ANIMATION_DURATION_MS.toLong())
+ }
+ snapshotStateList.clear()
+ }
+
+ /**
+ * Adds a new snackbar to the stack. If the stack exceeds its maximum current size, the
+ * oldest visible snackbar is automatically hidden.
+ *
+ * @param snackbar The [SnackBarItemModel] to add.
+ */
+ fun addSnackbar(snackbar: SnackBarItemModel) {
+ if (snapshotStateList.any { it.model.id == snackbar.id }) {
+ return
+ }
+ maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize
+ snapshotStateList.add(SnackbarItemInternal(snackbar))
+ if (sizeVisible() > maxCurrentSize) {
+ hideOldest()
+ }
+ }
+
+ /**
+ * Removes a snackbar from the stack by its unique ID.
+ *
+ * @param id The ID of the snackbar to remove.
+ * @return `true` if the snackbar was found and removed, `false` otherwise.
+ */
+ fun removeSnackbarById(id: String): Boolean {
+ snapshotStateList.firstOrNull { it.model.id == id }?.let {
+ snapshotStateList.remove(it)
+ return true
+ }
+ return false
+ }
+
+ /**
+ * Removes a snackbar with an exit animation. The actual removal from the state list is
+ * delayed to allow the animation to complete.
+ *
+ * @param id The ID of the snackbar to remove.
+ * @param showLastHiddenSnackbarOnRemove If `true`, the oldest hidden snackbar will become visible after removal.
+ * @param onRemoveCompleteCallback A callback invoked after the removal process is complete.
+ */
+ suspend fun removeSnackbarByIdWithAnimation(
+ id: String,
+ showLastHiddenSnackbarOnRemove: Boolean = true,
+ onRemoveCompleteCallback: () -> Unit = {}
+ ) {
+ val snackbar = snapshotStateList.firstOrNull { it.model.id == id } ?: return
+ snackbar.visibility.value = ItemVisibility.BeingRemoved
+ delay(ANIMATION_DURATION_MS.toLong())
+ snapshotStateList.remove(snackbar)
+ if (showLastHiddenSnackbarOnRemove) {
+ onVisibleSizeChange()
+ }
+ onRemoveCompleteCallback()
+ }
+
+ /**
+ * Toggles the expanded state of the snackbar stack. This changes the layout and the
+ * maximum number of visible snackbars.
+ */
+ fun toggleExpandedState() {
+ expanded = !expanded
+ maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize
+ onVisibleSizeChange()
+ }
+
+ /**
+ * Adjusts the visibility of snackbars based on the current expanded/collapsed state and
+ * the configured maximum size. This function ensures the number of visible snackbars
+ * does not exceed the allowed maximum.
+ */
+ private fun onVisibleSizeChange() {
+ val numVisibleSnackbars =
+ snapshotStateList.count { it.visibility.value == ItemVisibility.Visible }
+ var (numUpdatesRequired, sequenceIterationOrder, targetVisibilityAfterUpdate) =
+ if (numVisibleSnackbars > maxCurrentSize) {
+ Triple(
+ numVisibleSnackbars - maxCurrentSize,
+ snapshotStateList,
+ ItemVisibility.Hidden
+ )
+ } else {
+ Triple(
+ maxCurrentSize - numVisibleSnackbars,
+ snapshotStateList.asReversed(),
+ ItemVisibility.Visible
+ )
+ }
+
+ sequenceIterationOrder.forEach {
+ if (numUpdatesRequired <= 0) return@forEach
+ if (it.visibility.value != targetVisibilityAfterUpdate) {
+ it.visibility.value = targetVisibilityAfterUpdate
+ numUpdatesRequired--
+ }
+ }
+ }
+
+ /**
+ * Hides the oldest visible snackbar.
+ *
+ * @return `true` if a snackbar was hidden, `false` otherwise.
+ */
+ fun hideOldest(): Boolean {
+ snapshotStateList.firstOrNull { it.visibility.value == ItemVisibility.Visible }?.let {
+ it.visibility.value = ItemVisibility.Hidden
+ return true
+ }
+ return false
+ }
+
+ /**
+ * Hides the newest visible snackbar.
+ *
+ * @return `true` if a snackbar was hidden, `false` otherwise.
+ */
+ fun hideLatest(): Boolean {
+ snapshotStateList.lastOrNull { it.visibility.value == ItemVisibility.Visible }?.let {
+ it.visibility.value = ItemVisibility.Hidden
+ return true
+ }
+ return false
+ }
+
+ /**
+ * Removes the newest snackbar from the stack.
+ *
+ * @param skipHidden If `true`, only visible snackbars are considered for removal.
+ * @return `true` if a snackbar was removed, `false` otherwise.
+ */
+ fun removeLatest(skipHidden: Boolean = false): Boolean {
+ snapshotStateList.lastOrNull { (skipHidden && it.visibility.value == ItemVisibility.Visible) || !skipHidden }
+ ?.let {
+ snapshotStateList.remove(it)
+ return true
+ }
+ return false
+ }
+
+ /**
+ * Shows the oldest hidden snackbar.
+ *
+ * @return `true` if a snackbar was shown, `false` otherwise.
+ */
+ fun showLastHidden(): Boolean {
+ snapshotStateList.lastOrNull { it.visibility.value == ItemVisibility.Hidden }?.let {
+ it.visibility.value = ItemVisibility.Visible
+ return true
+ }
+ return false
+ }
+
+ /**
+ * Calculates the combined height of all visible snackbars that appear after a given index.
+ *
+ * @param index The starting index.
+ * @return The combined height in pixels.
+ */
+ internal fun heightAfterIndex(index: Int): Int {
+ var ans = 0
+ snapshotStateList.drop(index + 1).forEach {
+ if (it.visibility.value == ItemVisibility.Visible) {
+ ans += it.contentHeight.intValue
+ }
+ }
+ return ans
+ }
+
+ /**
+ * Returns the total number of snackbars (visible and hidden) in the stack.
+ *
+ * @return The total size of the stack.
+ */
+ fun size(): Int = snapshotStateList.size
+
+ /**
+ * Returns the number of currently visible snackbars in the stack.
+ *
+ * @return The number of visible snackbars.
+ */
+ fun sizeVisible(): Int =
+ snapshotStateList.count { it.visibility.value == ItemVisibility.Visible }
+}
+
+/**
+ * Creates and remembers a [SnackBarStackState] for managing a stack of snackbars.
+ *
+ * @param initial The initial list of snackbar models. Defaults to an empty list.
+ * @param maxExpandedSize The maximum number of snackbars to show when the stack is expanded.
+ * @param maxCollapsedSize The maximum number of snackbars to show when the stack is collapsed.
+ * @return A [SnackBarStackState] instance.
+ */
+@Composable
+fun rememberSnackBarStackState(
+ initial: List = emptyList(),
+ maxExpandedSize: Int = DEFAULT_NUMBER_OF_SNACKBARS_EXPANDED,
+ maxCollapsedSize: Int = DEFAULT_NUMBER_OF_SNACKBARS_COLLAPSED
+): SnackBarStackState {
+ return remember {
+ SnackBarStackState(
+ initialSnackbars = initial.toMutableList(),
+ maxExpandedSize = maxExpandedSize,
+ maxCollapsedSize = maxCollapsedSize
+ )
+ }
+}
+
+/**
+ * Configuration data for the [SnackBarStack] composable.
+ *
+ * @property snackbarGapWhenExpanded The vertical gap between snackbars when the stack is expanded.
+ * @property snackbarHeightWhenCollapsed The fixed height of a snackbar when the stack is collapsed.
+ * @property maximumTextLinesWhenCollapsed The maximum number of text lines allowed for a snackbar's message and subtitle when collapsed.
+ * @property snackbarPeekHeightWhenCollapsed The vertical distance one snackbar peeks from the one below it when collapsed.
+ * @property snackbarStackBottomPadding The padding at the bottom of the entire stack.
+ * @property snackbarStackExpandedTopPadding The padding at the top of the stack when it's expanded.
+ */
+data class SnackBarStackConfig(
+ val snackbarGapWhenExpanded: Dp = 10.dp,
+ val maxSnackbarHeightWhenCollapsed: Dp = 80.dp,
+ val maximumTextLinesWhenCollapsed: Int = 2,
+ val snackbarPeekHeightWhenCollapsed: Dp = 8.dp,
+ val snackbarStackBottomPadding: Dp = 20.dp,
+ val snackbarStackExpandedTopPadding: Dp = 200.dp
+)
+
+/**
+ * A composable that displays a stack of snackbars. It supports a collapsed state (showing a
+ * limited number of items) and an expanded state (showing all items). The stack is
+ * managed by the provided [SnackBarStackState].
+ *
+ * @param state The state object that manages the snackbar stack.
+ * @param snackBarStackConfig The configuration for the stack's appearance and behavior.
+ * @param enableSwipeToDismiss If `true`, the top visible snackbar can be swiped to dismiss it.
+ */
+@Composable
+fun SnackBarStack(
+ state: SnackBarStackState,
+ snackBarStackConfig: SnackBarStackConfig = SnackBarStackConfig(),
+ enableSwipeToDismiss: Boolean = true,
+) {
+ val localDensity = LocalDensity.current
+
+ val totalVisibleSnackbars by remember { derivedStateOf { state.sizeVisible() } }
+ val targetHeight = if (totalVisibleSnackbars == 0) {
+ 0.dp
+ } else if (state.expanded) {
+ with(localDensity) { state.combinedStackHeight.toDp() + snackBarStackConfig.snackbarStackExpandedTopPadding }
+ } else {
+ snackBarStackConfig.maxSnackbarHeightWhenCollapsed + (totalVisibleSnackbars - 1) * snackBarStackConfig.snackbarPeekHeightWhenCollapsed
+ }
+ val animatedStackHeight by animateDpAsState(
+ targetValue = targetHeight,
+ animationSpec = tween(durationMillis = ANIMATION_DURATION_MS, easing = FastOutSlowInEasing),
+ label = "StackHeightAnimation"
+ )
+
+ 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
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .verticalScroll(scrollState, enabled = state.expanded)
+ .padding(bottom = snackBarStackConfig.snackbarStackBottomPadding),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Bottom
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(animatedStackHeight),
+ contentAlignment = Alignment.BottomCenter
+ ) {
+ var visibleSnackbarsEncountered = 0
+ state.snapshotStateList.forEachIndexed { index, snackBarModel ->
+ val visibleIndex = totalVisibleSnackbars - 1 - visibleSnackbarsEncountered
+ visibleSnackbarsEncountered += if (snackBarModel.visibility.value == ItemVisibility.Visible) 1 else 0
+ key(snackBarModel.model.id) {
+ SnackBarStackItem(
+ state = state,
+ visibleIndex = visibleIndex,
+ trueIndex = index,
+ onSwipedAway = { idToRemove ->
+ state.removeSnackbarById(idToRemove)
+ state.showLastHidden()
+ },
+ snackBarStackConfig = snackBarStackConfig,
+ enableSwipeToDismiss = enableSwipeToDismiss,
+ screenWidthPx = screenWidthPx
+ )
+ }
+ }
+ }
+ }
+}
+
+/**
+ * A private composable for a single snackbar item in the stack. It handles individual
+ * animations, styling, and swipe-to-dismiss gestures.
+ *
+ * @param state The state object managing the snackbar stack.
+ * @param visibleIndex The logical index of the snackbar among the visible items.
+ * @param trueIndex The actual index of the snackbar in the full list.
+ * @param onSwipedAway A callback to be invoked when the snackbar is swiped off-screen.
+ * @param snackBarStackConfig The configuration for the stack's appearance.
+ * @param enableSwipeToDismiss If `true`, swiping the snackbar horizontally will dismiss it.
+ * @param screenWidthPx The width of the screen in pixels, used for swipe animation.
+ */
+@Composable
+private fun SnackBarStackItem(
+ state: SnackBarStackState,
+ visibleIndex: Int,
+ trueIndex: Int,
+ onSwipedAway: (String) -> Unit,
+ snackBarStackConfig: SnackBarStackConfig,
+ enableSwipeToDismiss: Boolean = true,
+ screenWidthPx: Float
+) {
+ val modelWrapper = state.snapshotStateList[trueIndex]
+ val model = modelWrapper.model
+ val cardHeight = snackBarStackConfig.maxSnackbarHeightWhenCollapsed
+ val peekHeight = snackBarStackConfig.snackbarPeekHeightWhenCollapsed
+
+ val scope = rememberCoroutineScope()
+ val localDensity = LocalDensity.current
+ val isTop = visibleIndex == 0
+
+ val token = model.snackBarToken
+ val snackBarInfo = SnackBarInfo(model.style, false)
+ val entryAnimationType = token.entryAnimationType(snackBarInfo)
+ val exitAnimationType = token.exitAnimationType(snackBarInfo)
+
+ // Vertical Offset Animation: Related to Stack Expansion/Collapse and Item Position in Stack
+ val initialYOffset = when (entryAnimationType) {
+ StackableSnackbarEntryAnimationType.SlideInFromAbove -> -with(localDensity) { cardHeight.toPx() }
+ StackableSnackbarEntryAnimationType.SlideInFromBelow -> with(localDensity) { cardHeight.toPx() }
+ StackableSnackbarEntryAnimationType.FadeIn -> 0f
+ StackableSnackbarEntryAnimationType.SlideInFromLeft -> 0f
+ StackableSnackbarEntryAnimationType.SlideInFromRight -> 0f
+ }
+ val animatedYOffset = remember { Animatable(initialYOffset) }
+ LaunchedEffect(
+ trueIndex,
+ state.expanded,
+ state.snapshotStateList.size,
+ state.heightAfterIndex(trueIndex),
+ modelWrapper.visibility.value
+ ) {
+ if (modelWrapper.visibility.value == ItemVisibility.BeingRemoved) {
+ return@LaunchedEffect
+ }
+ animatedYOffset.animateTo(
+ with(localDensity) {
+ if (state.expanded) -state.heightAfterIndex(trueIndex)
+ .toFloat() else (visibleIndex * -peekHeight).toPx()
+ },
+ animationSpec = spring(stiffness = Spring.StiffnessLow)
+ )
+ }
+
+ // Width Scale Animation: Related to Cards Shrinking when stacked
+ val stackedWidthScaleFactor =
+ token.snackbarWidthScalingFactor(snackBarInfo).coerceIn(0.01f, 2.0f)
+ val targetWidthScale = if (state.expanded) 1f else stackedWidthScaleFactor.pow(visibleIndex)
+ val animatedWidthScale = animateFloatAsState(
+ targetValue = targetWidthScale,
+ animationSpec = tween(durationMillis = ANIMATION_DURATION_MS, easing = FastOutSlowInEasing),
+ label = "WidthScaleAnimation"
+ )
+
+ // Opacity Animation: Related to Entry/Exit Fade Animations
+ val opacityProgress = remember { Animatable(0f) }
+ LaunchedEffect(modelWrapper.visibility.value) {
+ val visibility = modelWrapper.visibility.value
+ opacityProgress.animateTo(
+ if (visibility != ItemVisibility.Visible) 0f else 1f,
+ tween(ANIMATION_DURATION_MS)
+ )
+ }
+
+ // Horizontal Animations: Related to Swipe to Dismiss and Entry/Exit
+ val initialXOffset = when (entryAnimationType) {
+ StackableSnackbarEntryAnimationType.SlideInFromLeft -> -screenWidthPx
+ StackableSnackbarEntryAnimationType.SlideInFromRight -> screenWidthPx
+ StackableSnackbarEntryAnimationType.FadeIn -> 0f
+ StackableSnackbarEntryAnimationType.SlideInFromAbove -> 0f
+ StackableSnackbarEntryAnimationType.SlideInFromBelow -> 0f
+ }
+ val swipeX = remember { Animatable(initialXOffset) }
+ LaunchedEffect(modelWrapper.visibility.value, state.expanded, visibleIndex) {
+ if (modelWrapper.visibility.value == ItemVisibility.BeingRemoved) {
+ val target = when (exitAnimationType) {
+ StackableSnackbarExitAnimationType.SlideOutToLeft -> screenWidthPx * -SWIPE_AWAY_ANIMATION_TARGET_FACTOR
+ StackableSnackbarExitAnimationType.SlideOutToRight -> screenWidthPx * SWIPE_AWAY_ANIMATION_TARGET_FACTOR
+ StackableSnackbarExitAnimationType.FadeOut -> 0f
+ }
+ swipeX.animateTo(
+ with(localDensity) { target },
+ animationSpec = tween(
+ durationMillis = ANIMATION_DURATION_MS,
+ easing = FastOutLinearInEasing
+ )
+ )
+ } else {
+ if (isTop) {
+ swipeX.animateTo(0f)
+ } else {
+ swipeX.snapTo(0f)
+ }
+ }
+ }
+
+ val textPaddingValues =
+ if (model.actionText == null && model.trailingIcon != null) PaddingValues(
+ start = 16.dp,
+ top = 12.dp,
+ bottom = 12.dp,
+ end = 16.dp
+ ) else PaddingValues(start = 16.dp, top = 12.dp, bottom = 12.dp)
+ Box(
+ modifier = Modifier
+ .then(
+ if (state.expanded) {
+ Modifier.onGloballyPositioned(
+ onGloballyPositioned = { coordinates: LayoutCoordinates ->
+ val contentHeight = coordinates.size.height
+ if (modelWrapper.contentHeight.intValue == contentHeight) {
+ return@onGloballyPositioned
+ }
+ modelWrapper.contentHeight.intValue =
+ contentHeight + with(localDensity) {
+ snackBarStackConfig.snackbarGapWhenExpanded.toPx().toInt()
+ }
+ state.combinedStackHeight = state.heightAfterIndex(0)
+ return@onGloballyPositioned
+ }
+ )
+ } else {
+ Modifier
+ }
+ )
+ .graphicsLayer(
+ alpha = opacityProgress.value,
+ translationX = swipeX.value,
+ translationY = animatedYOffset.value,
+ scaleX = animatedWidthScale.value,
+ scaleY = animatedWidthScale.value
+ )
+ .wrapContentHeight()
+ .then(
+ if (enableSwipeToDismiss && (isTop || state.expanded)) Modifier.pointerInput(model.id) {
+ detectHorizontalDragGestures(
+ onDragStart = {},
+ onDragEnd = {
+ val threshold = screenWidthPx / SWIPE_TO_DISMISS_THRESHOLD_DIVISOR
+ scope.launch {
+ if (abs(swipeX.value) > threshold) {
+ val target = if (swipeX.value > 0)
+ screenWidthPx * SWIPE_AWAY_ANIMATION_TARGET_FACTOR
+ else
+ screenWidthPx * -SWIPE_AWAY_ANIMATION_TARGET_FACTOR
+
+ swipeX.animateTo(
+ target,
+ animationSpec = tween(
+ durationMillis = ANIMATION_DURATION_MS,
+ 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)
+ )
+ }
+ }
+ ) { change, dragAmountX ->
+ change.consume()
+ scope.launch {
+ swipeX.snapTo(swipeX.value + dragAmountX)
+ }
+ }
+ } else Modifier
+ )
+ ) {
+ Row(
+ Modifier
+ .padding(horizontal = 16.dp)
+ .defaultMinSize(minHeight = 52.dp)
+ .fillMaxWidth()
+ .shadow(
+ elevation = token.shadowElevationValue(snackBarInfo),
+ shape = RoundedCornerShape(8.dp)
+ )
+ .clip(RoundedCornerShape(8.dp))
+ .background(token.backgroundBrush(snackBarInfo))
+ .semantics {
+ liveRegion = LiveRegionMode.Polite
+ }
+ .testTag(SnackBarTestTags.SNACK_BAR),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (model.leadingIcon != null && model.leadingIcon.isIconAvailable()) {
+ Box(
+ modifier = Modifier
+ .testTag(SnackBarTestTags.SNACK_BAR_ICON)
+ .then(
+ if (model.leadingIcon.onClick != null) {
+ Modifier.clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = rememberRipple(),
+ enabled = true,
+ role = Role.Image,
+ onClick = model.leadingIcon.onClick!!
+ )
+ } else Modifier
+ )
+ ) {
+ Icon(
+ model.leadingIcon,
+ modifier = Modifier
+ .padding(start = 16.dp, top = 12.dp, bottom = 12.dp)
+ .size(token.leftIconSize(snackBarInfo)),
+ tint = token.iconColor(snackBarInfo)
+ )
+ }
+ }
+ Column(
+ Modifier
+ .weight(1F)
+ .padding(textPaddingValues)
+ ) {
+ val messageMaxLines =
+ if (state.expanded) Int.MAX_VALUE else snackBarStackConfig.maximumTextLinesWhenCollapsed
+
+ Text(
+ text = model.message,
+ style = token.titleTypography(snackBarInfo),
+ maxLines = messageMaxLines,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (!model.subTitle.isNullOrBlank()) {
+ Text(
+ text = model.subTitle,
+ style = token.subtitleTypography(snackBarInfo),
+ maxLines = messageMaxLines,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.testTag(SnackBarTestTags.SNACK_BAR_SUBTITLE)
+ )
+ }
+
+ }
+
+ if (model.actionText != null) {
+ Button(
+ onClick = {
+ model.onActionTextClicked()
+ },
+ modifier = Modifier
+ .testTag(SnackBarTestTags.SNACK_BAR_ACTION_BUTTON)
+ .then(
+ if (model.trailingIcon != null)
+ Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
+ else
+ Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp)
+ ),
+ text = model.actionText,
+ style = ButtonStyle.TextButton,
+ size = ButtonSize.Small,
+ buttonTokens = object : ButtonTokens() {
+ @Composable
+ override fun textColor(buttonInfo: ButtonInfo): StateColor {
+ return StateColor(
+ rest = token.iconColor(snackBarInfo),
+ pressed = token.iconColor(snackBarInfo),
+ focused = token.iconColor(snackBarInfo),
+ )
+ }
+ }
+ )
+ }
+
+ if (model.trailingIcon != null && model.trailingIcon.isIconAvailable()) {
+ Box(
+ modifier = Modifier
+ .testTag(SnackBarTestTags.SNACK_BAR_ICON)
+ .then(
+ if (model.trailingIcon.onClick != null) {
+ Modifier.clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = rememberRipple(),
+ enabled = true,
+ role = Role.Image,
+ onClick = model.trailingIcon.onClick!!
+ )
+ } else Modifier
+ )
+ ) {
+ Icon(
+ model.trailingIcon,
+ modifier = Modifier
+ .padding(top = 12.dp, bottom = 12.dp, end = 16.dp)
+ .size(token.leftIconSize(snackBarInfo)),
+ tint = token.iconColor(snackBarInfo)
+ )
+ }
+ }
+ }
+ }
+}
+
+private const val SCRIM_DEFAULT_OPACITY = 0.6f
+
+/**
+ * A composable that provides a semi-transparent overlay (scrim) that can be used to block user
+ * interaction with content behind it. The scrim animates its color and alpha.
+ *
+ * @param isActivated `true` if the scrim should be visible and opaque, `false` otherwise.
+ * @param onDismiss A callback to be invoked when the scrim is clicked.
+ * @param modifier The modifier to be applied to the scrim.
+ */
+@Composable
+fun Scrim(
+ isActivated: Boolean,
+ onDismiss: () -> Unit,
+ activatedColor: Color = Color.Black.copy(alpha = SCRIM_DEFAULT_OPACITY),
+ modifier: Modifier = Modifier
+) {
+ val scrimColor by animateColorAsState(
+ targetValue = if (isActivated) activatedColor else Color.Transparent,
+ animationSpec = tween(durationMillis = ANIMATION_DURATION_MS),
+ label = "ScrimColorAnimation"
+ )
+
+ if (scrimColor.alpha > 0f) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(scrimColor)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = onDismiss
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/fluentui_others/build.gradle b/fluentui_others/build.gradle
index cac516032..2ce37ff6a 100644
--- a/fluentui_others/build.gradle
+++ b/fluentui_others/build.gradle
@@ -61,6 +61,10 @@ dependencies {
implementation "androidx.cardview:cardview:1.0.0"
implementation "com.google.android.material:material:$materialVersion"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
+
+ // Override corrupted versions from OfficeFeed
+ implementation "androidx.compose.runtime:runtime-saveable:$composeRuntimeSaveableVersion"
+
implementation "androidx.compose.foundation:foundation"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$extJunitVersion"
diff --git a/fluentui_peoplepicker/build.gradle b/fluentui_peoplepicker/build.gradle
index 52dbd1117..39248567a 100644
--- a/fluentui_peoplepicker/build.gradle
+++ b/fluentui_peoplepicker/build.gradle
@@ -74,6 +74,9 @@ dependencies {
implementation "com.google.android.material:material:$materialVersion"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
+ // Override corrupted versions from OfficeFeed
+ implementation "androidx.compose.runtime:runtime-saveable:$composeRuntimeSaveableVersion"
+
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.runtime:runtime")
implementation("androidx.compose.ui:ui")
diff --git a/fluentui_progress/build.gradle b/fluentui_progress/build.gradle
index 9bdb0b224..4c47ebdd1 100644
--- a/fluentui_progress/build.gradle
+++ b/fluentui_progress/build.gradle
@@ -52,6 +52,9 @@ dependencies {
androidTestImplementation "androidx.test.ext:junit:$extJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
+ // Override corrupted versions from OfficeFeed
+ implementation "androidx.compose.animation:animation-core:$composeAnimationVersion"
+
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material:material")
implementation("androidx.compose.runtime:runtime")
diff --git a/fluentui_tablayout/build.gradle b/fluentui_tablayout/build.gradle
index b62186ccb..caa24b449 100644
--- a/fluentui_tablayout/build.gradle
+++ b/fluentui_tablayout/build.gradle
@@ -47,6 +47,9 @@ dependencies {
implementation "androidx.appcompat:appcompat:$appCompatVersion"
implementation "com.google.android.material:material:$materialVersion"
+ // Override corrupted versions from OfficeFeed
+ implementation "androidx.compose.animation:animation-core:$composeAnimationVersion"
+
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material:material")
implementation("androidx.compose.runtime:runtime")
diff --git a/fluentui_topappbars/build.gradle b/fluentui_topappbars/build.gradle
index 0a2fdef00..f97c20fe6 100644
--- a/fluentui_topappbars/build.gradle
+++ b/fluentui_topappbars/build.gradle
@@ -55,6 +55,10 @@ dependencies {
implementation "com.google.android.material:material:$materialVersion"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleComposeVersion"
+ // Override corrupted versions from OfficeFeed
+ implementation "androidx.compose.animation:animation:$composeAnimationVersion"
+ implementation "androidx.compose.runtime:runtime-saveable:$composeRuntimeSaveableVersion"
+
implementation "androidx.compose.ui:ui"
implementation "androidx.compose.ui:ui-util"
implementation "androidx.compose.material:material"