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"