diff --git a/FluentUI.Demo/build.gradle b/FluentUI.Demo/build.gradle index 694d47eb7..83b2efb15 100644 --- a/FluentUI.Demo/build.gradle +++ b/FluentUI.Demo/build.gradle @@ -103,6 +103,8 @@ dependencies { debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation "androidx.test:monitor:$androidTestMonitor" implementation "androidx.compose.runtime:runtime-livedata" + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleComposeVersion" + implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycleComposeVersion" // App Center dogfoodImplementation "com.microsoft.appcenter:appcenter-analytics:$appCenterSdkVersion" diff --git a/FluentUI.Demo/src/main/AndroidManifest.xml b/FluentUI.Demo/src/main/AndroidManifest.xml index fe4af3bed..d937e187a 100644 --- a/FluentUI.Demo/src/main/AndroidManifest.xml +++ b/FluentUI.Demo/src/main/AndroidManifest.xml @@ -34,6 +34,8 @@ diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2AcrylicActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2AcrylicActivity.kt index eb134fbcc..a3a23b5c0 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2AcrylicActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2AcrylicActivity.kt @@ -42,6 +42,9 @@ import com.microsoft.fluentui.theme.token.FluentAliasTokens import com.microsoft.fluentui.theme.token.FluentAliasTokens.NeutralForegroundColorTokens.Foreground2 import com.microsoft.fluentui.theme.token.FluentIcon import com.microsoft.fluentui.theme.token.FluentStyle +import com.microsoft.fluentui.theme.token.controlTokens.AcrylicPaneInfo +import com.microsoft.fluentui.theme.token.controlTokens.AcrylicPaneOrientation +import com.microsoft.fluentui.theme.token.controlTokens.AcrylicPaneTokens import com.microsoft.fluentui.tokenized.SearchBar import com.microsoft.fluentui.tokenized.controls.RadioButton import com.microsoft.fluentui.tokenized.drawer.DrawerValue @@ -60,8 +63,10 @@ class V2AcrylicPaneActivity : V2DemoActivity() { setupActivity(this) } - override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-18" //TODO: Update this URL - override val controlTokensUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-18" + override val paramsUrl = + "https://github.com/microsoft/fluentui-android/wiki/Controls#params-18" //TODO: Update this URL + override val controlTokensUrl = + "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-18" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -74,32 +79,81 @@ class V2AcrylicPaneActivity : V2DemoActivity() { @Composable fun CreateAcrylicPaneActivityUI( context: Context -){ - var acrylicPaneSizeFraction by rememberSaveable { mutableFloatStateOf(0.5F) } - var acrylicPaneStyle by rememberSaveable { mutableStateOf(FluentStyle.Neutral) } +) { + var acrylicPaneSize by rememberSaveable { mutableFloatStateOf(250.0f) } + var acrylicPaneOrientation by rememberSaveable { mutableStateOf(AcrylicPaneOrientation.BOTTOM) } + var acrylicPaneBlurRadius by rememberSaveable { mutableStateOf(0.0f) } + val acrylicPaneTokens: AcrylicPaneTokens = object : AcrylicPaneTokens() { + @Composable + override fun acrylicPaneBlurRadius(acrylicPaneInfo: AcrylicPaneInfo): Int { + return acrylicPaneBlurRadius.toInt() + } + } AcrylicPane( - paneHeight = (acrylicPaneSizeFraction * 500).toInt().dp, - acrylicPaneStyle = acrylicPaneStyle, - component = { acrylicPaneContent(context = context) }, + paneHeight = acrylicPaneSize.toInt().dp, + orientation = acrylicPaneOrientation, + component = { AcrylicPaneContent(context = context) }, backgroundContent = { Column( - modifier = Modifier.verticalScroll(rememberScrollState()).fillMaxWidth().padding(10.dp), + modifier = Modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .padding(10.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(Modifier.height(300.dp)) ListItem.Header( - title = "Acrylic Pane Size", + title = "Acrylic Pane Orientation", titleMaxLines = 2, modifier = Modifier .clearAndSetSemantics { - this.contentDescription = "Acrylic Pane Size" + this.contentDescription = "Acrylic Pane Orientation" + }, + ) + val checkBoxSelectedValues = List(3) { rememberSaveable { mutableStateOf(false) } } + when (acrylicPaneOrientation) { + AcrylicPaneOrientation.TOP -> checkBoxSelectedValues[0].value = true + AcrylicPaneOrientation.CENTER -> checkBoxSelectedValues[1].value = true + AcrylicPaneOrientation.BOTTOM -> checkBoxSelectedValues[2].value = true + } + val acrylicPaneOrientations = listOf( + AcrylicPaneOrientation.TOP, + AcrylicPaneOrientation.CENTER, + AcrylicPaneOrientation.BOTTOM, + ) + val orientations = listOf("Top", "Center", "Bottom") + for (i in 0..2) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 3.dp) + ) { + Text(text = "Orientation ${orientations[i]}") + Spacer(modifier = Modifier.width(320.dp)) + RadioButton( + onClick = { + selectRadioGroupButton(i, checkBoxSelectedValues) + acrylicPaneOrientation = acrylicPaneOrientations[i] + }, + selected = checkBoxSelectedValues[i].value + ) + } + } + ListItem.Header( + title = "Blur Radius", + titleMaxLines = 2, + modifier = Modifier + .clearAndSetSemantics { + this.contentDescription = "Acrylic Pane Blur Radius" }, ) Slider( - value = acrylicPaneSizeFraction, - onValueChange = { acrylicPaneSizeFraction = it }, - valueRange = 0F..1F, + value = acrylicPaneBlurRadius, + onValueChange = { acrylicPaneBlurRadius = it }, + valueRange = 0F..200F, colors = SliderDefaults.colors( thumbColor = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground1].value( FluentTheme.themeMode @@ -121,36 +175,37 @@ fun CreateAcrylicPaneActivityUI( steps = 9 ) ListItem.Header( - title = "Acrylic Pane Theme", + title = "Acrylic Pane Size", titleMaxLines = 2, modifier = Modifier .clearAndSetSemantics { - this.contentDescription = "Acrylic Pane Theme" + this.contentDescription = "Acrylic Pane Size" }, ) - var checkBoxSelectedValues = List(2) { rememberSaveable { mutableStateOf(false) } } - var acrylicPaneStyles = listOf( - FluentStyle.Neutral, - FluentStyle.Brand - ) - for (i in 0..1) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, - modifier = Modifier.fillMaxWidth() - .padding(horizontal = 10.dp, vertical = 3.dp) - ) { - Text(text = "Theme $i") - Spacer(modifier = Modifier.width(320.dp)) - RadioButton( - onClick = { - selectRadioGroupButton(i, checkBoxSelectedValues) - acrylicPaneStyle = acrylicPaneStyles[i] - }, - selected = checkBoxSelectedValues[i].value + Slider( + value = acrylicPaneSize, + onValueChange = { acrylicPaneSize = it }, + valueRange = 0F..500F, + colors = SliderDefaults.colors( + thumbColor = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground1].value( + FluentTheme.themeMode + ), + activeTrackColor = FluentTheme.aliasTokens.brandColor[FluentAliasTokens.BrandColorTokens.Color80], + inactiveTrackColor = FluentTheme.aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background3].value( + FluentTheme.themeMode + ), + disabledThumbColor = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.ForegroundDisable1].value( + FluentTheme.themeMode + ), + disabledActiveTrackColor = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.ForegroundDisable1].value( + FluentTheme.themeMode + ), + disabledInactiveTrackColor = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.ForegroundDisable1].value( + FluentTheme.themeMode ) - } - } + ), + steps = 9 + ) ListItem.Header( title = "Test Bottom Drawer", titleMaxLines = 2, @@ -159,7 +214,7 @@ fun CreateAcrylicPaneActivityUI( this.contentDescription = "Test Bottom Drawer" }, ) - showBottomDrawer() + ShowBottomDrawer() ListItem.Header( title = "Scroll Test", titleMaxLines = 2, @@ -172,24 +227,38 @@ fun CreateAcrylicPaneActivityUI( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 5.dp) + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 5.dp) ) { - Text(text = "Text $it", fontSize = 14.sp, + Text( + text = "Text $it", fontSize = 14.sp, style = FluentTheme.aliasTokens.typography[FluentAliasTokens.TypographyTokens.Body1] - .merge(TextStyle(color = FluentTheme.aliasTokens.neutralForegroundColor[Foreground2].value(themeMode = FluentTheme.themeMode))) + .merge( + TextStyle( + color = FluentTheme.aliasTokens.neutralForegroundColor[Foreground2].value( + themeMode = FluentTheme.themeMode + ) + ) + ) ) } } } - } + }, + acrylicPaneTokens = acrylicPaneTokens ) } @Composable -fun showBottomDrawer(){ +fun ShowBottomDrawer() { val scope = rememberCoroutineScope() - val drawerState = rememberBottomDrawerState(initialValue = DrawerValue.Closed, expandable = true, skipOpenState = false) + val drawerState = rememberBottomDrawerState( + initialValue = DrawerValue.Closed, + expandable = true, + skipOpenState = false + ) val open: () -> Unit = { scope.launch { drawerState.open() } @@ -228,7 +297,7 @@ fun showBottomDrawer(){ } @Composable -fun acrylicPaneContent(context: Context){ +fun AcrylicPaneContent(context: Context) { val scope = rememberCoroutineScope() val microphonePressedString = getDemoAppString(DemoAppStrings.MicrophonePressed) @@ -244,7 +313,11 @@ fun acrylicPaneContent(context: Context){ val showCustomizedAppBar = false Column { Spacer(modifier = Modifier.height(80.dp)) - Row(Modifier.height(5.dp).padding(20.dp)) { + Row( + Modifier + .height(5.dp) + .padding(20.dp) + ) { SearchBar( onValueChange = { query, selectedPerson -> scope.launch { diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2BottomDrawerActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2BottomDrawerActivity.kt index b69817619..c4b45f361 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2BottomDrawerActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2BottomDrawerActivity.kt @@ -1,46 +1,87 @@ package com.microsoft.fluentuidemo.demos +import SearchViewModel +import SearchViewModelFactory +import Searchable import android.content.res.Configuration import android.os.Bundle import androidx.activity.OnBackPressedCallback import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.text.BasicText import androidx.compose.material.Slider import androidx.compose.material.SliderDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import com.microsoft.fluentui.theme.FluentTheme import com.microsoft.fluentui.theme.token.FluentAliasTokens +import com.microsoft.fluentui.theme.token.FluentAliasTokens.NeutralBackgroundColorTokens.Background1 +import com.microsoft.fluentui.theme.token.FluentAliasTokens.NeutralBackgroundColorTokens.Background1Pressed +import com.microsoft.fluentui.theme.token.FluentAliasTokens.NeutralBackgroundColorTokens.Background1Selected +import com.microsoft.fluentui.theme.token.StateBrush +import com.microsoft.fluentui.theme.token.controlTokens.AvatarStatus +import com.microsoft.fluentui.theme.token.controlTokens.BorderType +import com.microsoft.fluentui.theme.token.controlTokens.ListItemInfo +import com.microsoft.fluentui.theme.token.controlTokens.ListItemTokens +import com.microsoft.fluentui.tokenized.SearchBar import com.microsoft.fluentui.tokenized.controls.RadioButton import com.microsoft.fluentui.tokenized.controls.ToggleSwitch import com.microsoft.fluentui.tokenized.drawer.BottomDrawer import com.microsoft.fluentui.tokenized.drawer.DrawerValue import com.microsoft.fluentui.tokenized.drawer.rememberBottomDrawerState import com.microsoft.fluentui.tokenized.listitem.ListItem +import com.microsoft.fluentui.util.KeyboardVisibilityObserver +import com.microsoft.fluentui.util.getStringResource import com.microsoft.fluentuidemo.R import com.microsoft.fluentuidemo.V2DemoActivity import com.microsoft.fluentuidemo.util.PrimarySurfaceContent import com.microsoft.fluentuidemo.util.getAndroidViewAsContent import com.microsoft.fluentuidemo.util.getDrawerAsContent import com.microsoft.fluentuidemo.util.getDynamicListGeneratorAsContent +import generateUniqueId import kotlinx.coroutines.launch class V2BottomDrawerActivity : V2DemoActivity() { @@ -49,18 +90,24 @@ class V2BottomDrawerActivity : V2DemoActivity() { } override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-9" - override val controlTokensUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-9" - private val onBackCallback = object: OnBackPressedCallback(true) { //callback to end the activity - override fun handleOnBackPressed() { - finish() + override val controlTokensUrl = + "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-9" + private val onBackCallback = + object : OnBackPressedCallback(true) { //callback to end the activity + override fun handleOnBackPressed() { + finish() + } + } - } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setActivityContent { CreateActivityUI() - LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher?.addCallback(this, onBackCallback) //registering the callback to end the activity when back button is pressed + LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher?.addCallback( + this, + onBackCallback + ) //registering the callback to end the activity when back button is pressed } } } @@ -71,6 +118,7 @@ private fun CreateActivityUI() { var dynamicSizeContent by rememberSaveable { mutableStateOf(false) } var nestedDrawerContent by rememberSaveable { mutableStateOf(false) } var listContent by rememberSaveable { mutableStateOf(true) } + var searchableDrawerContent by rememberSaveable { mutableStateOf(false) } var expandable by rememberSaveable { mutableStateOf(true) } var skipOpenState by rememberSaveable { mutableStateOf(false) } var selectedContent by rememberSaveable { mutableStateOf(ContentType.FULL_SCREEN_SCROLLABLE_CONTENT) } @@ -79,31 +127,46 @@ private fun CreateActivityUI() { var enableSwipeDismiss by rememberSaveable { mutableStateOf(true) } var maxLandscapeWidthFraction by rememberSaveable { mutableFloatStateOf(1F) } var preventDismissalOnScrimClick by rememberSaveable { mutableStateOf(false) } - var isLandscapeOrientation: Boolean = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + var isLandscapeOrientation: Boolean = + LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE Column(horizontalAlignment = Alignment.CenterHorizontally) { - CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt( - slideOver = slideOver, - scrimVisible = scrimVisible, - skipOpenState = skipOpenState, - expandable = expandable, - showHandle = showHandle, - preventDismissalOnScrimClick = preventDismissalOnScrimClick, - enableSwipeDismiss = enableSwipeDismiss, - maxLandscapeWidthFraction = maxLandscapeWidthFraction, - drawerContent = - if (listContent) - getAndroidViewAsContent(selectedContent) - else if (nestedDrawerContent) { - getDrawerAsContent() - } else { - getDynamicListGeneratorAsContent() - } - ) + if (searchableDrawerContent) { + CreateSearchableDrawerWithButtonOnPrimarySurfaceToInvokeIt( + slideOver = slideOver, + expandable = expandable, + skipOpenState = skipOpenState, + scrimVisible = scrimVisible, + showHandle = showHandle, + preventDismissalOnScrimClick = preventDismissalOnScrimClick, + enableSwipeDismiss = enableSwipeDismiss, + maxLandscapeWidthFraction = maxLandscapeWidthFraction + ) + } else { + CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt( + slideOver = slideOver, + scrimVisible = scrimVisible, + skipOpenState = skipOpenState, + expandable = expandable, + showHandle = showHandle, + preventDismissalOnScrimClick = preventDismissalOnScrimClick, + enableSwipeDismiss = enableSwipeDismiss, + maxLandscapeWidthFraction = maxLandscapeWidthFraction, + drawerContent = + if (listContent) + getAndroidViewAsContent(selectedContent) + else if (nestedDrawerContent) { + getDrawerAsContent() + } else { + getDynamicListGeneratorAsContent() + } + ) + } //Other content on Primary surface LazyColumn(horizontalAlignment = Alignment.CenterHorizontally) { item { ListItem.Header(title = stringResource(id = R.string.drawer_select_drawer_type)) - ListItem.Item(text = stringResource(id = R.string.drawer_bottom), + ListItem.Item( + text = stringResource(id = R.string.drawer_bottom), subText = stringResource(id = R.string.drawer_bottom_description), subTextMaxLines = Int.MAX_VALUE, onClick = { slideOver = false }, @@ -116,7 +179,8 @@ private fun CreateActivityUI() { ) } ) - ListItem.Item(text = stringResource(id = R.string.drawer_bottom_slide_over), + ListItem.Item( + text = stringResource(id = R.string.drawer_bottom_slide_over), subText = stringResource(id = R.string.drawer_bottom_slide_over_description), subTextMaxLines = Int.MAX_VALUE, onClick = { slideOver = true }, @@ -132,25 +196,27 @@ private fun CreateActivityUI() { } item { val scrimVisibleText = stringResource(id = R.string.drawer_scrim_visible) - ListItem.Header(title = scrimVisibleText, modifier = Modifier - .toggleable( - value = scrimVisible, - role = Role.Switch, - onValueChange = { scrimVisible = !scrimVisible } - ) - .clearAndSetSemantics { - this.contentDescription = scrimVisibleText - }, trailingAccessoryContent = { - ToggleSwitch( - onValueChange = { scrimVisible = !scrimVisible }, - checkedState = scrimVisible - ) - } + ListItem.Header( + title = scrimVisibleText, modifier = Modifier + .toggleable( + value = scrimVisible, + role = Role.Switch, + onValueChange = { scrimVisible = !scrimVisible } + ) + .clearAndSetSemantics { + this.contentDescription = scrimVisibleText + }, trailingAccessoryContent = { + ToggleSwitch( + onValueChange = { scrimVisible = !scrimVisible }, + checkedState = scrimVisible + ) + } ) } item { val expandableText = stringResource(id = R.string.drawer_expandable) - ListItem.Header(title = expandableText, + ListItem.Header( + title = expandableText, modifier = Modifier .toggleable( value = expandable, @@ -170,10 +236,10 @@ private fun CreateActivityUI() { ToggleSwitch( onValueChange = { expandable = it - if(!it) { + if (!it) { skipOpenState = false } - }, + }, checkedState = expandable, enabledSwitch = !skipOpenState ) @@ -182,7 +248,8 @@ private fun CreateActivityUI() { } item { val skipOpenStateText = stringResource(id = R.string.skip_open_state) - ListItem.Header(title = skipOpenStateText, + ListItem.Header( + title = skipOpenStateText, modifier = Modifier .toggleable( value = skipOpenState, @@ -213,20 +280,26 @@ private fun CreateActivityUI() { ) } item { - val preventDismissalOnScrimClickText = stringResource(id = R.string.prevent_scrim_click_dismissal) - ListItem.Header(title = preventDismissalOnScrimClickText, + val preventDismissalOnScrimClickText = + stringResource(id = R.string.prevent_scrim_click_dismissal) + ListItem.Header( + title = preventDismissalOnScrimClickText, modifier = Modifier .toggleable( value = preventDismissalOnScrimClick, role = Role.Switch, - onValueChange = { preventDismissalOnScrimClick = !preventDismissalOnScrimClick } + onValueChange = { + preventDismissalOnScrimClick = !preventDismissalOnScrimClick + } ) .clearAndSetSemantics { this.contentDescription = preventDismissalOnScrimClickText }, trailingAccessoryContent = { ToggleSwitch( - onValueChange = { preventDismissalOnScrimClick = !preventDismissalOnScrimClick }, + onValueChange = { + preventDismissalOnScrimClick = !preventDismissalOnScrimClick + }, checkedState = preventDismissalOnScrimClick ) } @@ -234,7 +307,8 @@ private fun CreateActivityUI() { } item { val showHandleText = stringResource(id = R.string.drawer_show_handle) - ListItem.Header(title = showHandleText, + ListItem.Header( + title = showHandleText, modifier = Modifier .toggleable( value = showHandle, @@ -254,7 +328,8 @@ private fun CreateActivityUI() { } item { val showDismissText = stringResource(id = R.string.drawer_enable_swipe_dismiss) - ListItem.Header(title = showDismissText, + ListItem.Header( + title = showDismissText, modifier = Modifier .toggleable( value = enableSwipeDismiss, @@ -274,8 +349,10 @@ private fun CreateActivityUI() { } item { - val maxLandscapeWidthFractionText = stringResource(id = R.string.bottom_drawer_max_width_landscape) - ListItem.Header(title = maxLandscapeWidthFractionText + if(!isLandscapeOrientation) " (Rotate to landscape Mode to use this)" else "", + val maxLandscapeWidthFractionText = + stringResource(id = R.string.bottom_drawer_max_width_landscape) + ListItem.Header( + title = maxLandscapeWidthFractionText + if (!isLandscapeOrientation) " (Rotate to landscape Mode to use this)" else "", titleMaxLines = 2, enabled = isLandscapeOrientation, modifier = Modifier @@ -311,12 +388,14 @@ private fun CreateActivityUI() { } item { ListItem.Header(title = stringResource(id = R.string.drawer_select_drawer_content)) - ListItem.Item(text = stringResource(id = R.string.drawer_full_screen_size_scrollable_content), + ListItem.Item( + text = stringResource(id = R.string.drawer_full_screen_size_scrollable_content), onClick = { selectedContent = ContentType.FULL_SCREEN_SCROLLABLE_CONTENT listContent = true nestedDrawerContent = false dynamicSizeContent = false + searchableDrawerContent = false }, trailingAccessoryContent = { RadioButton( @@ -325,17 +404,20 @@ private fun CreateActivityUI() { listContent = true nestedDrawerContent = false dynamicSizeContent = false + searchableDrawerContent = false }, selected = selectedContent == ContentType.FULL_SCREEN_SCROLLABLE_CONTENT && listContent ) } ) - ListItem.Item(text = stringResource(id = R.string.drawer_more_than_half_screen_content), + ListItem.Item( + text = stringResource(id = R.string.drawer_more_than_half_screen_content), onClick = { selectedContent = ContentType.EXPANDABLE_SIZE_CONTENT listContent = true nestedDrawerContent = false dynamicSizeContent = false + searchableDrawerContent = false }, trailingAccessoryContent = { RadioButton( @@ -344,17 +426,20 @@ private fun CreateActivityUI() { listContent = true nestedDrawerContent = false dynamicSizeContent = false + searchableDrawerContent = false }, selected = selectedContent == ContentType.EXPANDABLE_SIZE_CONTENT && listContent ) } ) - ListItem.Item(text = stringResource(id = R.string.drawer_less_than_half_screen_content), + ListItem.Item( + text = stringResource(id = R.string.drawer_less_than_half_screen_content), onClick = { selectedContent = ContentType.WRAPPED_SIZE_CONTENT listContent = true dynamicSizeContent = false nestedDrawerContent = false + searchableDrawerContent = false }, trailingAccessoryContent = { RadioButton( @@ -363,16 +448,19 @@ private fun CreateActivityUI() { listContent = true dynamicSizeContent = false nestedDrawerContent = false + searchableDrawerContent = false }, selected = selectedContent == ContentType.WRAPPED_SIZE_CONTENT && listContent ) } ) - ListItem.Item(text = stringResource(id = R.string.drawer_dynamic_size_content), + ListItem.Item( + text = stringResource(id = R.string.drawer_dynamic_size_content), onClick = { dynamicSizeContent = true nestedDrawerContent = false listContent = false + searchableDrawerContent = false }, trailingAccessoryContent = { RadioButton( @@ -380,16 +468,19 @@ private fun CreateActivityUI() { dynamicSizeContent = true nestedDrawerContent = false listContent = false + searchableDrawerContent = false }, selected = dynamicSizeContent ) } ) - ListItem.Item(text = stringResource(id = R.string.drawer_nested_drawer_content), + ListItem.Item( + text = stringResource(id = R.string.drawer_nested_drawer_content), onClick = { nestedDrawerContent = true dynamicSizeContent = false listContent = false + searchableDrawerContent = false }, trailingAccessoryContent = { RadioButton( @@ -397,16 +488,363 @@ private fun CreateActivityUI() { nestedDrawerContent = true dynamicSizeContent = false listContent = false + searchableDrawerContent = false }, selected = nestedDrawerContent ) } ) + ListItem.Item( + text = stringResource(id = R.string.searchable_drawer_content), + onClick = { + dynamicSizeContent = false + nestedDrawerContent = false + listContent = false + searchableDrawerContent = true + }, + trailingAccessoryContent = { + RadioButton( + onClick = { + dynamicSizeContent = false + nestedDrawerContent = false + listContent = false + searchableDrawerContent = true + }, + selected = searchableDrawerContent + ) + } + ) } } } } +data class SearchableItem( + val title: String, + val subTitle: String? = null, + val description: String? = null, + val footer: String? = null, + val leftAccessory: @Composable (() -> Unit)? = null, + val rightAccessory: @Composable (() -> Unit)? = null, + val status: AvatarStatus? = null, + val onClick: () -> Unit = {}, + val onLongClick: () -> Unit = {}, + val enabled: Boolean = true, + val id: Any = generateUniqueId() +) : Searchable { + override fun getSearchKey(): String = title + + override fun getUniqueId(): Any = id +} + +@Composable +private fun CreateSearchableDrawerWithButtonOnPrimarySurfaceToInvokeIt( + slideOver: Boolean, + expandable: Boolean, + skipOpenState: Boolean, + scrimVisible: Boolean, + showHandle: Boolean, + preventDismissalOnScrimClick: Boolean, + enableSwipeDismiss: Boolean, + maxLandscapeWidthFraction: Float +) { + val scope = rememberCoroutineScope() + val viewModel: SearchViewModel = viewModel( + factory = SearchViewModelFactory(initialItems = List(100) { index -> + SearchableItem( + title = "Item $index", + subTitle = "Subtitle for item $index", + description = "Description for item $index", + id = index + ) + }) + ) + val drawerState = rememberBottomDrawerState( + initialValue = DrawerValue.Closed, + expandable = expandable, + skipOpenState = skipOpenState + ) + val open: () -> Unit = { + scope.launch { + if(drawerState.currentValue == DrawerValue.Closed) { + viewModel.clearSelection() + } + drawerState.open() + } + } + val expand: () -> Unit = { + scope.launch { + drawerState.expand() + } + } + val close: () -> Unit = { + scope.launch { + drawerState.close() + viewModel.clearSelection() + } + } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val toggleItemSelection = { item: Searchable -> + viewModel.toggleSelection(item as SearchableItem) + } + Row { + PrimarySurfaceContent( + open, + text = stringResource(id = R.string.drawer_open) + ) + Spacer(modifier = Modifier.width(10.dp)) + PrimarySurfaceContent( + expand, + text = stringResource(id = R.string.drawer_expand) + ) + } + BottomDrawer( + drawerState = drawerState, + drawerContent = { + KeyboardVisibilityObserver( + onKeyboardVisible = { + if (drawerState.currentValue == DrawerValue.Open) { + expand() + } + }, + onKeyboardHidden = { + if (drawerState.currentValue == DrawerValue.Expanded) { + open() + } + } + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + SearchableDrawerHeader( + onLeftTextClick = { + scope.launch { + viewModel.clearSelection() + } + }, + onRightTextClick = close, + onCenterTextClick = { + if (drawerState.currentValue == DrawerValue.Open) { + expand() + } else { + open() + } + } + ) + + if (uiState.selectionSize <= 0) { + SearchBar( + onValueChange = { query, selectedPerson -> + scope.launch { + viewModel.onQueryChanged(query) + } + } + ) + } else { + MultiSelectScreen(uiState.selectionSize) + } + + LazyItemsList( + filteredSearchItems = uiState.filteredItems, + selectedSearchItems = uiState.selectedItems, + inSelectionMode = uiState.selectionSize > 0, + toggleItemSelection = toggleItemSelection, + border = BorderType.NoBorder, + modifier = Modifier, + ) + } + } + }, + scrimVisible = scrimVisible, + slideOver = slideOver, + showHandle = showHandle, + enableSwipeDismiss = enableSwipeDismiss, + maxLandscapeWidthFraction = maxLandscapeWidthFraction, + preventDismissalOnScrimClick = preventDismissalOnScrimClick + ) +} + +@Composable +private fun ClickableTextHeader( + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + textStyle: TextStyle = TextStyle.Default +) { + val interactionSource = remember { MutableInteractionSource() } + val animatedFontSizeStart by animateDpAsState( + targetValue = if (interactionSource.collectIsPressedAsState().value) 18.5.dp else 17.dp, + label = "FontSizeAnimation" + ) + Box( + modifier = Modifier + .fillMaxWidth() + .then(modifier) + ) { + BasicText( + text = text, + modifier = Modifier + .fillMaxWidth() + .clickable( + enabled = true, + indication = null, + interactionSource = interactionSource + ) { + onClick() + }, + style = textStyle.merge(fontSize = animatedFontSizeStart.value.sp) + ) + } +} + +@Composable +fun SearchableDrawerHeader( + onLeftTextClick: () -> Unit = {}, + onCenterTextClick: () -> Unit = {}, + onRightTextClick: () -> Unit = {}, +) { + val textColours = listOf(Color(0xFF616161), Color(0xFF242424), Color(0xFF464FEB)) + val textHeaders = listOf( + getStringResource(id = R.string.fluentui_back), + getStringResource(id = R.string.fluentui_title), + getStringResource(id = R.string.popup_menu_item_share) + ) + val textOnClicks = listOf(onLeftTextClick, onCenterTextClick, onRightTextClick) + val textFontStyles = listOf( + FluentTheme.aliasTokens.typography[FluentAliasTokens.TypographyTokens.Body1], + FluentTheme.aliasTokens.typography[FluentAliasTokens.TypographyTokens.Title2], + FluentTheme.aliasTokens.typography[FluentAliasTokens.TypographyTokens.Body1] + ) + val textAlignments = listOf(TextAlign.Start, TextAlign.Center, TextAlign.End) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + for (i in 0..2) { + ClickableTextHeader( + text = textHeaders[i], + modifier = Modifier + .fillMaxWidth() + .weight(1f), + onClick = textOnClicks.get(i), + textStyle = textFontStyles.get(i).copy( + color = textColours.get(i), + textAlign = textAlignments.get(i) + ) + ) + } + } +} + +@Composable +private fun MultiSelectScreen(numSelected: Int) { + BasicText( + "Selected Items: ${numSelected}", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 20.dp), + style = TextStyle( + color = Color(0xFF242424), + fontSize = 17.sp, + lineHeight = 22.sp, + letterSpacing = -0.43.sp, + textAlign = TextAlign.Start, + fontWeight = FontWeight(400) + ) + ) +} + +@Composable +fun LazyItemsList( + filteredSearchItems: List, + selectedSearchItems: Set = setOf(), + inSelectionMode: Boolean = false, + toggleItemSelection: (SearchableItem) -> Unit = {}, + border: BorderType = BorderType.NoBorder, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + var enableStatus by rememberSaveable { mutableStateOf(false) } + val lazyListState = rememberLazyListState() + val positionString: String = + getStringResource(com.microsoft.fluentui.topappbars.R.string.position_string) + val statusString: String = + getStringResource(com.microsoft.fluentui.topappbars.R.string.status_string) + val listItemTokens: ListItemTokens = object : ListItemTokens() { + @Composable + override fun backgroundBrush(listItemInfo: ListItemInfo): StateBrush { + return StateBrush( + rest = SolidColor( + FluentTheme.aliasTokens.neutralBackgroundColor[Background1].value( + themeMode = FluentTheme.themeMode + ) + ), + pressed = SolidColor( + FluentTheme.aliasTokens.neutralBackgroundColor[Background1Pressed].value( + themeMode = FluentTheme.themeMode + ) + ), + selected = SolidColor( + FluentTheme.aliasTokens.neutralBackgroundColor[Background1Selected].value( + themeMode = FluentTheme.themeMode + ) + ) + ) + } + } + LazyColumn( + state = lazyListState, modifier = modifier.draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + scope.launch { + lazyListState.scrollBy(-delta) + } + }, + ) + ) { + itemsIndexed( + items = filteredSearchItems, + key = { index, item -> item.getUniqueId() }) { index, item -> // ensures stable render updates, will prevent recomps + val isSelected = selectedSearchItems.contains(item) + ListItem.Item( + text = item.title, + modifier = Modifier + .clearAndSetSemantics { + contentDescription = + "${item.title}, ${item.subTitle}" + if (enableStatus) statusString.format( + item.status + ) else "" + stateDescription = if (filteredSearchItems.size > 1) positionString.format( + index + 1, + filteredSearchItems.size + ) else "" + role = Role.Button + }, + subText = item.subTitle, + secondarySubText = item.footer, + onClick = { + if (inSelectionMode) { + toggleItemSelection(item) + } else { + item.onClick() + } + }, + onLongClick = { + item.onLongClick() + toggleItemSelection(item) + }, + border = border, + listItemTokens = listItemTokens, + enabled = item.enabled, + selected = isSelected, + leadingAccessoryContent = item.leftAccessory, + trailingAccessoryContent = item.rightAccessory, + ) + } + } +} + @Composable private fun CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt( slideOver: Boolean, @@ -421,7 +859,11 @@ private fun CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt( ) { val scope = rememberCoroutineScope() - val drawerState = rememberBottomDrawerState(initialValue = DrawerValue.Closed, expandable = expandable, skipOpenState = skipOpenState) + val drawerState = rememberBottomDrawerState( + initialValue = DrawerValue.Closed, + expandable = expandable, + skipOpenState = skipOpenState + ) val open: () -> Unit = { scope.launch { drawerState.open() } diff --git a/FluentUI.Demo/src/main/res/values-en-rGB/strings.xml b/FluentUI.Demo/src/main/res/values-en-rGB/strings.xml index fc63f9ef0..d9329d2af 100644 --- a/FluentUI.Demo/src/main/res/values-en-rGB/strings.xml +++ b/FluentUI.Demo/src/main/res/values-en-rGB/strings.xml @@ -850,6 +850,8 @@ Less than half screen content Dynamic size content + + Searchable Drawer Content Nested Drawer Content diff --git a/FluentUI.Demo/src/main/res/values/strings.xml b/FluentUI.Demo/src/main/res/values/strings.xml index 7b64625a3..6e9158ca8 100644 --- a/FluentUI.Demo/src/main/res/values/strings.xml +++ b/FluentUI.Demo/src/main/res/values/strings.xml @@ -902,6 +902,8 @@ Less than half screen content Dynamic size content + + Searchable Drawer Content Nested Drawer Content diff --git a/FluentUI/src/main/res/values-night/themes.xml b/FluentUI/src/main/res/values-night/themes.xml index 480d74b5f..6d8e5a60d 100644 --- a/FluentUI/src/main/res/values-night/themes.xml +++ b/FluentUI/src/main/res/values-night/themes.xml @@ -14,6 +14,8 @@ + true + @android:color/transparent @color/fluentui_black @color/fluentui_gray_900 diff --git a/FluentUI/src/main/res/values/themes.xml b/FluentUI/src/main/res/values/themes.xml index 6671b21df..8640747c6 100644 --- a/FluentUI/src/main/res/values/themes.xml +++ b/FluentUI/src/main/res/values/themes.xml @@ -121,6 +121,8 @@ ?attr/fluentuiBackgroundColor ?attr/colorPrimary ?attr/fluentuiDialogBackgroundColor + true + @android:color/transparent ?attr/fluentuiBackgroundColor diff --git a/build.gradle b/build.gradle index 5ee4b18f3..437c8a3b8 100644 --- a/build.gradle +++ b/build.gradle @@ -57,6 +57,7 @@ allprojects { junitKtxVersion = '1.1.5' kotlin_version = '1.8.21' lifecycleVersion = '2.4.1' + lifecycleComposeVersion = '2.6.1' materialVersion = '1.9.0' mockitoVersion = '1.10.19' recyclerViewVersion = '1.3.0' diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/AcrylicPaneTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/AcrylicPaneTokens.kt index 3b46f27a9..fb7e33d34 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/AcrylicPaneTokens.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/AcrylicPaneTokens.kt @@ -6,45 +6,74 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TileMode +import com.microsoft.fluentui.theme.FluentTheme +import com.microsoft.fluentui.theme.ThemeMode import com.microsoft.fluentui.theme.token.ControlInfo +import com.microsoft.fluentui.theme.token.FluentAliasTokens +import com.microsoft.fluentui.theme.token.FluentColor import com.microsoft.fluentui.theme.token.FluentStyle import com.microsoft.fluentui.theme.token.IControlToken import kotlinx.parcelize.Parcelize +enum class AcrylicPaneOrientation { + TOP, + BOTTOM, + CENTER +} + open class AcrylicPaneInfo( - val style: FluentStyle = FluentStyle.Neutral + val style: FluentStyle = FluentStyle.Neutral, + val orientation: AcrylicPaneOrientation = AcrylicPaneOrientation.BOTTOM ) : ControlInfo @Parcelize open class AcrylicPaneTokens : IControlToken, Parcelable { + companion object { + const val DEFAULT_BLUR_RADIUS = 60 // Default value, can be overridden by theme + } @Composable open fun acrylicPaneGradient(acrylicPaneInfo: AcrylicPaneInfo): Brush { - if(acrylicPaneInfo.style == FluentStyle.Neutral) { - val startColor: Color = Color(red = 0xF7, green = 0xF8 , blue = 0xFB, alpha = 0xFF) - return Brush.verticalGradient( + val startColor: Color = FluentColor( + light = FluentTheme.aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background5].value( + ThemeMode.Light + ), + dark = FluentTheme.aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background5].value( + ThemeMode.Dark + ) + ).value(FluentTheme.themeMode) + when (acrylicPaneInfo.orientation) { + AcrylicPaneOrientation.TOP -> return Brush.verticalGradient( colors = listOf( - startColor, - startColor, startColor, startColor.copy(alpha = 0.5f), startColor.copy(alpha = 0.0f), ), - tileMode = TileMode.Decal + tileMode = TileMode.Decal ) - } - else{ - val startColor: Color = Color(0xFE106cbc) - return Brush.verticalGradient( + + AcrylicPaneOrientation.CENTER -> return Brush.verticalGradient( colors = listOf( + startColor.copy(alpha = 0.0f), startColor, - startColor, - startColor.copy(alpha = 0.8f), - startColor.copy(alpha = 0.5f), startColor.copy(alpha = 0.0f), ), tileMode = TileMode.Decal ) + + AcrylicPaneOrientation.BOTTOM -> return Brush.verticalGradient( + colors = listOf( + startColor.copy(alpha = 0.0f), + startColor.copy(alpha = 0.5f), + startColor + ), + tileMode = TileMode.Decal + ) } } + + @Composable + open fun acrylicPaneBlurRadius(acrylicPaneInfo: AcrylicPaneInfo): Int { + return DEFAULT_BLUR_RADIUS // Need blur tokens + } } \ No newline at end of file diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/util/Utils.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/util/Utils.kt index f04e82b6b..fb739cd88 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/util/Utils.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/util/Utils.kt @@ -1,8 +1,15 @@ package com.microsoft.fluentui.util import android.content.res.Resources +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -18,7 +25,42 @@ fun dpToPx(value: Dp) = (value * Resources fun getStringResource(id: Int): String { return LocalContext.current.resources.getString(id) } + @Composable fun getStringResource(id: Int, vararg formatArgs: Any): String { return LocalContext.current.resources.getString(id, *formatArgs) +} + +/** + * A composable function that observes the visibility of the software keyboard and triggers + * callbacks when the keyboard becomes visible or hidden. + * + * @param onKeyboardVisible A lambda function to be executed when the keyboard becomes visible. + * Defaults to an empty lambda. + * @param onKeyboardHidden A lambda function to be executed when the keyboard becomes hidden. + * Defaults to an empty lambda. + * @param content A composable content block to be displayed within this observer. + */ +@Composable +fun KeyboardVisibilityObserver( + onKeyboardVisible: () -> Unit = {}, + onKeyboardHidden: () -> Unit = {}, + content: @Composable () -> Unit +) { + val imeInsets = WindowInsets.ime + val density = LocalDensity.current + val isKeyboardVisible by remember { + derivedStateOf { + imeInsets.getBottom(density) > 0 + } + } + + LaunchedEffect(isKeyboardVisible) { + if (isKeyboardVisible) { + onKeyboardVisible() + } else { + onKeyboardHidden() + } + } + content() } \ No newline at end of file diff --git a/fluentui_listitem/src/main/java/com/microsoft/fluentui/tokenized/listitem/ListItem.kt b/fluentui_listitem/src/main/java/com/microsoft/fluentui/tokenized/listitem/ListItem.kt index 75b072608..701903067 100644 --- a/fluentui_listitem/src/main/java/com/microsoft/fluentui/tokenized/listitem/ListItem.kt +++ b/fluentui_listitem/src/main/java/com/microsoft/fluentui/tokenized/listitem/ListItem.kt @@ -35,7 +35,6 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.* import androidx.compose.ui.text.* -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import com.microsoft.fluentui.listitem.R import com.microsoft.fluentui.icons.ListItemIcons @@ -212,6 +211,7 @@ object ListItem { textAlignment: ListItemTextAlignment = ListItemTextAlignment.Regular, unreadDot: Boolean = false, enabled: Boolean = true, + selected: Boolean = false, textMaxLines: Int = 1, subTextMaxLines: Int = 1, secondarySubTextMaxLines: Int = 1, @@ -252,7 +252,7 @@ object ListItem { ) val backgroundColor = token.backgroundBrush(listItemInfo).getBrushByState( - enabled = true, selected = false, interactionSource = interactionSource + enabled = true, selected = selected, interactionSource = interactionSource ) val primaryTextTypography = token.primaryTextTypography(listItemInfo) val subTextTypography = token.subTextTypography(listItemInfo) @@ -261,17 +261,17 @@ object ListItem { val primaryTextColor = token.primaryTextColor( listItemInfo ).getColorByState( - enabled = enabled, selected = false, interactionSource = interactionSource + enabled = enabled, selected = selected, interactionSource = interactionSource ) val subTextColor = token.subTextColor( listItemInfo ).getColorByState( - enabled = enabled, selected = false, interactionSource = interactionSource + enabled = enabled, selected = selected, interactionSource = interactionSource ) val secondarySubTextColor = token.secondarySubTextColor( listItemInfo ).getColorByState( - enabled = enabled, selected = false, interactionSource = interactionSource + enabled = enabled, selected = selected, interactionSource = interactionSource ) val rippleColor = token.rippleColor(listItemInfo) val unreadDotColor = token.unreadDotColor(listItemInfo) @@ -281,7 +281,7 @@ object ListItem { token.borderInset(listItemInfo).toPx() } val borderColor = token.borderColor(listItemInfo).getColorByState( - enabled = enabled, selected = false, interactionSource = interactionSource + enabled = enabled, selected = selected, interactionSource = interactionSource ) val textAccessoryContentTextSpacing = token.textAccessoryContentTextSpacing(listItemInfo) val leadingAccessoryAlignment = when (leadingAccessoryContentAlignment) { @@ -446,6 +446,7 @@ object ListItem { * @param textAlignment Optional [ListItemTextAlignment] to align text in the center or start at the lead. * @param unreadDot Option boolean value that display a dot on leading edge of the accessory Content and makes the primary text bold on true * @param enabled Optional enable/disable List item + * @param selected Optional selected state for List item. * @param textMaxLines Optional max visible lines for primary text. * @param subTextMaxLines Optional max visible lines for secondary text. * @param secondarySubTextMaxLines Optional max visible lines for tertiary text. @@ -475,6 +476,7 @@ object ListItem { textAlignment: ListItemTextAlignment = ListItemTextAlignment.Regular, unreadDot: Boolean = false, enabled: Boolean = true, + selected: Boolean = false, textMaxLines: Int = 1, subTextMaxLines: Int = 1, secondarySubTextMaxLines: Int = 1, @@ -503,6 +505,7 @@ object ListItem { textAlignment = textAlignment, unreadDot = unreadDot, enabled = enabled, + selected = selected, textMaxLines = textMaxLines, subTextMaxLines = subTextMaxLines, secondarySubTextMaxLines = secondarySubTextMaxLines, @@ -536,6 +539,7 @@ object ListItem { * @param textAlignment Optional [ListItemTextAlignment] to align text in the center or start at the lead. * @param unreadDot Option boolean value that display a dot on leading edge of the accessory Content and makes the primary text bold on true * @param enabled Optional enable/disable List item + * @param selected Optional selected state for the list item. * @param textMaxLines Optional max visible lines for primary text. * @param subTextMaxLines Optional max visible lines for secondary text. * @param secondarySubTextMaxLines Optional max visible lines for tertiary text. @@ -566,6 +570,7 @@ object ListItem { textAlignment: ListItemTextAlignment = ListItemTextAlignment.Regular, unreadDot: Boolean = false, enabled: Boolean = true, + selected: Boolean = false, textMaxLines: Int = 1, subTextMaxLines: Int = 1, secondarySubTextMaxLines: Int = 1, @@ -595,6 +600,7 @@ object ListItem { textAlignment = textAlignment, unreadDot = unreadDot, enabled = enabled, + selected = selected, textMaxLines = textMaxLines, subTextMaxLines = subTextMaxLines, secondarySubTextMaxLines = secondarySubTextMaxLines, diff --git a/fluentui_others/src/main/java/com/microsoft/fluentui/tokenized/acrylicpane/AcrylicPane.kt b/fluentui_others/src/main/java/com/microsoft/fluentui/tokenized/acrylicpane/AcrylicPane.kt index 7b4a08a84..f97d9630a 100644 --- a/fluentui_others/src/main/java/com/microsoft/fluentui/tokenized/acrylicpane/AcrylicPane.kt +++ b/fluentui_others/src/main/java/com/microsoft/fluentui/tokenized/acrylicpane/AcrylicPane.kt @@ -1,5 +1,8 @@ package com.microsoft.fluentui.tokenized.acrylicpane +import android.os.Build +import android.view.Gravity +import android.view.WindowManager import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -8,41 +11,87 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider import com.microsoft.fluentui.theme.FluentTheme import com.microsoft.fluentui.theme.token.ControlTokens import com.microsoft.fluentui.theme.token.FluentStyle import com.microsoft.fluentui.theme.token.controlTokens.AcrylicPaneInfo +import com.microsoft.fluentui.theme.token.controlTokens.AcrylicPaneOrientation import com.microsoft.fluentui.theme.token.controlTokens.AcrylicPaneTokens - @Composable -private fun AcrylicPane( - modifier: Modifier = Modifier, - component: @Composable BoxScope.() -> Unit, - backgroundContent: @Composable () -> Unit, - triggerRecomposition: Boolean = false +private fun BlurBehindDialog( + orientation: AcrylicPaneOrientation = AcrylicPaneOrientation.BOTTOM, + blurRadius: Int = 60, + offset: IntOffset = IntOffset(0, 0), + content: @Composable () -> Unit ) { - Box( - modifier = Modifier.fillMaxSize() + val dialogProperties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + dismissOnBackPress = false, + dismissOnClickOutside = false + ) + + Dialog( + onDismissRequest = {}, + properties = dialogProperties ) { - backgroundContent() + val window = (LocalView.current.parent as? DialogWindowProvider)?.window - Box( - modifier = modifier - ) { - component() + SideEffect { + if (window != null) { + window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL) + window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + window.addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND) + window.setBackgroundBlurRadius(blurRadius) + } + window.setDimAmount(0f) + window.setGravity( + when (orientation) { + AcrylicPaneOrientation.TOP -> Gravity.TOP + AcrylicPaneOrientation.BOTTOM -> Gravity.BOTTOM + AcrylicPaneOrientation.CENTER -> Gravity.CENTER + } + ) + window.attributes.x = offset.x + window.attributes.y = offset.y + window.decorView.setBackgroundColor(android.graphics.Color.TRANSPARENT) + } } + content() } } fun roundToNearestTen(value: Int): Int { // Added for anti-aliasing return ((value + 5) / 10) * 10 } + /** * A composable function that creates an AcrylicPane with specified properties and content. + * This component leverages a real-time, window-level blur to create its acrylic effect. + * This behavior is subject to specific system conditions. + * + * Platform-Specific Behavior & Requirements: + * API Level: The background blur is only supported on Android 12 (API 31) and newer. + * Device Setting: For the blur to be visible, the "Allow window-level blurs" option must be enabled in the device's Developer Options. + * + * Fallback Mechanism: + * On devices running older Android versions (below API 31) or when the necessary developer + * option is disabled, the AcrylicPane will gracefully fall back to a semi-transparent gradient effect. + * This ensures the UI remains functional and aesthetically pleasing even when the blur effect is not available. * * @param modifier The modifier to be applied to the AcrylicPane. + * @param orientation The orientation of the AcrylicPane, default is AcrylicPaneOrientation.BOTTOM. + * @param offset The offset of the pane from the top-left corner of the screen, default is IntOffset(0, 0). * @param paneHeight The height of the pane, default is 300.dp. * @param acrylicPaneStyle The style of the pane, default is FluentStyle.Neutral. * @param component The main composable content to be displayed within the pane. @@ -51,26 +100,45 @@ fun roundToNearestTen(value: Int): Int { // Added for anti-aliasing */ @Composable -public fun AcrylicPane(modifier: Modifier = Modifier, paneHeight: Dp = 300.dp, acrylicPaneStyle:FluentStyle = FluentStyle.Neutral, component: @Composable () -> Unit, backgroundContent: @Composable () -> Unit, acrylicPaneTokens: AcrylicPaneTokens? = null) { - val paneInfo: AcrylicPaneInfo = AcrylicPaneInfo(style = acrylicPaneStyle) +fun AcrylicPane( + modifier: Modifier = Modifier, + orientation: AcrylicPaneOrientation = AcrylicPaneOrientation.BOTTOM, + offset: IntOffset = IntOffset(0, 0), + paneHeight: Dp = 300.dp, + acrylicPaneStyle: FluentStyle = FluentStyle.Neutral, + component: @Composable () -> Unit, + backgroundContent: @Composable () -> Unit, + acrylicPaneTokens: AcrylicPaneTokens? = null +) { + val paneInfo: AcrylicPaneInfo = + AcrylicPaneInfo(style = acrylicPaneStyle, orientation = orientation) val newPaneHeight = roundToNearestTen(paneHeight.value.toInt()).dp val themeID = FluentTheme.themeID //Adding This only for recomposition in case of Token Updates. Unused otherwise. val token = acrylicPaneTokens ?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.AcrylicPaneControlType] as AcrylicPaneTokens - AcrylicPane( - modifier = modifier - .fillMaxWidth() - .height(newPaneHeight) - .background( - token.acrylicPaneGradient(acrylicPaneInfo = paneInfo) - ), - component = { - component() - }, - backgroundContent = { - backgroundContent() - }, - triggerRecomposition = false - ) -} + val backgroundColor: Brush = token.acrylicPaneGradient(acrylicPaneInfo = paneInfo) + val blurRadius: Int = token.acrylicPaneBlurRadius(acrylicPaneInfo = paneInfo) + Box( + modifier = Modifier.fillMaxSize() + ) { + backgroundContent() + + BlurBehindDialog( + orientation = orientation, + blurRadius = blurRadius, + offset = offset + ) { + Box( + modifier = modifier + .fillMaxWidth() + .height(newPaneHeight) + .background( + backgroundColor + ) + ) { + component() + } + } + } +} \ No newline at end of file diff --git a/fluentui_topappbars/build.gradle b/fluentui_topappbars/build.gradle index e7f8a6514..0a2fdef00 100644 --- a/fluentui_topappbars/build.gradle +++ b/fluentui_topappbars/build.gradle @@ -53,6 +53,7 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "androidx.appcompat:appcompat:$appCompatVersion" implementation "com.google.android.material:material:$materialVersion" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleComposeVersion" implementation "androidx.compose.ui:ui" implementation "androidx.compose.ui:ui-util" diff --git a/fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/SearchBar.kt b/fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/SearchBar.kt index 37353e033..f8ecafd89 100644 --- a/fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/SearchBar.kt +++ b/fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/SearchBar.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.semantics.Role diff --git a/fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/SearchViewModel.kt b/fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/SearchViewModel.kt new file mode 100644 index 000000000..3a199ec8c --- /dev/null +++ b/fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/SearchViewModel.kt @@ -0,0 +1,159 @@ +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.* +import java.util.UUID + +/** + * Remembers a unique identifier for a composable. + * + * If a non-null [id] is provided, it will be returned. + * If [id] is null, a new unique ID will be generated using UUID and + * remembered across recompositions and process death. + * + * @param id An optional existing ID of type [Any]. + * @return The provided [id] or a newly generated unique ID. + */ +fun generateUniqueId(id: Any? = null): Any { + return id ?: { UUID.randomUUID().toString() } +} + +/** + * An interface for objects that can be searched. + * Any class implementing this interface must provide a string key to be used for filtering. + */ +interface Searchable { + /** + * @return The string value that will be checked against the search query. + */ + fun getSearchKey(): String + /** + * @return A unique identifier for this item, used for stable list updates in Compose. + */ + fun getUniqueId(): Any +} + +/** + * A generic UI state holder for the search screen. + * @param T The type of item being searched. + */ +data class SearchUiState( + val searchQuery: String = "", + val filteredItems: List = emptyList(), + val selectedItems: Set = emptySet(), + val selectionSize: Int = 0 +) + + +/** + * A generic ViewModel to handle search logic for any list of 'Searchable' items. + * + * @param T The type of item being searched, constrained to implement [Searchable]. + * @param initialItems The initial list of items to be displayed and searched. + */ +class SearchViewModel( + initialItems: List +) : ViewModel() { + private val _searchQuery = MutableStateFlow("") + private val _allItems = MutableStateFlow(initialItems) + private val _selectedItems = MutableStateFlow>(emptySet()) + + val uiState: StateFlow> = + combine(_searchQuery, _allItems, _selectedItems) { query, items, selected -> + val itemsToShow = if (query.isBlank()) { + items + } else { + items.filter { item -> + item.getSearchKey().contains(query, ignoreCase = true) + } + } + SearchUiState( + searchQuery = query, + filteredItems = itemsToShow, + selectedItems = selected, + selectionSize = selected.size + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = SearchUiState(filteredItems = initialItems) + ) + + /** Handles the query change event from the UI. */ + fun onQueryChanged(query: String) { + _searchQuery.value = query + } + + /** Adds a new item to the master list. */ + fun addItem(item: T) { + _allItems.update { currentList -> currentList + item } + } + + /** Removes an item from the master list using its unique ID. */ + fun removeItem(item: T) { + _selectedItems.update { it - item } + _allItems.update { currentList -> + currentList.filter { it.getUniqueId() != item.getUniqueId() } + } + } + + /** Clears all items from the master list. */ + fun clearItems() { + _selectedItems.value = emptySet() + _allItems.value = emptyList() + } + + /** + * Adds a single item to the selection set. + */ + fun selectItem(item: T) { + _selectedItems.update { currentSet -> + // The '+' operator on a set creates a new set with the item added + currentSet + item + } + } + + /** + * Removes a single item from the selection set. + */ + fun deselectItem(item: T) { + _selectedItems.update { currentSet -> + // The '-' operator on a set creates a new set with the item removed + currentSet - item + } + } + + /** + * A more convenient function for UI toggles. Selects an item if it's not + * selected, and deselects it if it is already selected. + */ + fun toggleSelection(item: T) { + _selectedItems.update { currentSet -> + if (item in currentSet) { + currentSet - item + } else { + currentSet + item + } + } + } + + /** Clears all selected items. */ + fun clearSelection() { + _selectedItems.value = emptySet() + } +} + +/** + * A factory for creating instances of [SearchViewModel] with parameters. + */ +class SearchViewModelFactory( + private val initialItems: List +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(SearchViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return SearchViewModel(initialItems) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} \ No newline at end of file