diff --git a/FluentUI.Demo/build.gradle b/FluentUI.Demo/build.gradle index f089ecacd..694d47eb7 100644 --- a/FluentUI.Demo/build.gradle +++ b/FluentUI.Demo/build.gradle @@ -11,10 +11,10 @@ android { compileSdkVersion constants.compileSdkVersion defaultConfig { applicationId 'com.microsoft.fluentuidemo' - minSdkVersion 21 + minSdkVersion 23 targetSdkVersion 34 - versionCode 1012 - versionName '0.2.12' + versionCode 2008 + versionName '0.3.8' testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } kotlinOptions { @@ -33,6 +33,7 @@ android { } lintOptions { lintConfig = file("lint.xml") + abortOnError false } buildTypes { release { @@ -89,7 +90,6 @@ dependencies { implementation "androidx.constraintlayout:constraintlayout:$constraintLayoutVersion" implementation 'com.squareup.picasso:picasso:2.71828' implementation 'com.github.bumptech.glide:glide:4.8.0' - implementation 'com.jakewharton.threetenabp:threetenabp:1.1.0' //Compose BOM implementation platform("androidx.compose:compose-bom:$composeBomVersion") diff --git a/FluentUI.Demo/src/main/AndroidManifest.xml b/FluentUI.Demo/src/main/AndroidManifest.xml index 93c6f9cfb..fe4af3bed 100644 --- a/FluentUI.Demo/src/main/AndroidManifest.xml +++ b/FluentUI.Demo/src/main/AndroidManifest.xml @@ -22,7 +22,8 @@ - + + @@ -33,7 +34,7 @@ + android:enableOnBackInvokedCallback="true"/> @@ -44,8 +45,7 @@ android:windowSoftInputMode="adjustResize" /> - + + + FluentTheme.aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background3].value( + themeMode = FluentTheme.themeMode + ) + + FluentStyle.Brand -> + FluentColor( + light = FluentTheme.aliasTokens.brandBackgroundColor[FluentAliasTokens.BrandBackgroundColorTokens.BrandBackground2].value( + ThemeMode.Light + ), + dark = FluentTheme.aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background5].value( + ThemeMode.Dark + ) + ).value(themeMode = FluentTheme.themeMode) + } + ) + } } \ No newline at end of file diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/DemoActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/DemoActivity.kt index d07bc1d4f..80ae0c305 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/DemoActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/DemoActivity.kt @@ -10,7 +10,6 @@ import android.os.Bundle import android.view.MenuItem import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity -import com.microsoft.fluentui.util.DuoSupportUtils import com.microsoft.fluentuidemo.databinding.ActivityDemoDetailBinding import java.util.UUID @@ -37,11 +36,7 @@ abstract class DemoActivity : AppCompatActivity() { // Set demo title val demoID = intent.getSerializableExtra(DEMO_ID) as UUID - val demo: Demo? = if (DuoSupportUtils.isDualScreenMode(this)) { - DUO_DEMOS.find { it.id == demoID } - } else { - V1DEMO.find { it.id == demoID } - } + val demo: Demo? = V1DEMO.find { it.id == demoID } if (demo != null) title = demo.title diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/Demos.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/Demos.kt index fc2695227..c42e63be5 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/Demos.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/Demos.kt @@ -30,6 +30,8 @@ import com.microsoft.fluentuidemo.demos.TabLayoutActivity import com.microsoft.fluentuidemo.demos.TemplateViewActivity import com.microsoft.fluentuidemo.demos.TooltipActivity import com.microsoft.fluentuidemo.demos.TypographyActivity +import com.microsoft.fluentuidemo.demos.V2AcrylicPaneActivity +import com.microsoft.fluentuidemo.demos.V2ActionBarActivity import com.microsoft.fluentuidemo.demos.V2AppBarActivity import com.microsoft.fluentuidemo.demos.V2AvatarActivity import com.microsoft.fluentuidemo.demos.V2AvatarCarouselActivity @@ -74,6 +76,8 @@ enum class Badge { APIBreak } +const val V2ACRYLICPANE = "V2 Acrylic Pane" +const val V2ACTION_BAR = "V2 ActionBar" const val V2AVATAR = "V2 Avatar" const val V2AVATAR_CAROUSEL = "V2 Avatar Carousel" const val V2AVATAR_GROUP = "V2 Avatar Group" @@ -146,7 +150,7 @@ val V1DEMO = arrayListOf( Demo(DATE_TIME_PICKER, DateTimePickerActivity::class), Demo(DRAWER, DrawerActivity::class), Demo(LIST_ITEM_VIEW, ListItemViewActivity::class), - Demo(PEOPLE_PICKER_VIEW, PeoplePickerViewActivity::class), + Demo(PEOPLE_PICKER_VIEW, PeoplePickerViewActivity::class, Badge.Modified), Demo(PERSISTENT_BOTTOM_SHEET, PersistentBottomSheetActivity::class), Demo(PERSONA_CHIP_VIEW, PersonaChipViewActivity::class), Demo(PERSONA_LIST_VIEW, PersonaListViewActivity::class), @@ -161,7 +165,9 @@ val V1DEMO = arrayListOf( ) val V2DEMO = arrayListOf( - Demo(V2APP_BAR_LAYOUT, V2AppBarActivity::class), + Demo(V2ACRYLICPANE, V2AcrylicPaneActivity::class, Badge.Modified), + Demo(V2ACTION_BAR, V2ActionBarActivity::class), + Demo(V2APP_BAR_LAYOUT, V2AppBarActivity::class, Badge.Modified), Demo(V2AVATAR, V2AvatarActivity::class), Demo(V2AVATAR_CAROUSEL, V2AvatarCarouselActivity::class), Demo(V2AVATAR_GROUP, V2AvatarGroupActivity::class), @@ -170,16 +176,16 @@ val V2DEMO = arrayListOf( Demo(V2BASIC_CHIP, V2BasicChipActivity::class), Demo(V2BASIC_CONTROLS, V2BasicControlsActivity::class), Demo(V2BOTTOM_DRAWER, V2BottomDrawerActivity::class), - Demo(V2BOTTOM_SHEET, V2BottomSheetActivity::class), - Demo(V2BUTTON, V2ButtonsActivity::class), + Demo(V2BOTTOM_SHEET, V2BottomSheetActivity::class, Badge.Modified), + Demo(V2BUTTON, V2ButtonsActivity::class, Badge.Modified), Demo(V2CARD, V2CardActivity::class), Demo(V2CARD_NUDGE, V2CardNudgeActivity::class), Demo(V2CITATION, V2CitationActivity::class), Demo(V2CONTEXTUAL_COMMAND_BAR, V2ContextualCommandBarActivity::class), Demo(V2DIALOG, V2DialogActivity::class), - Demo(V2DRAWER, V2DrawerActivity::class), - Demo(V2LABEL, V2LabelActivity::class, Badge.Modified), - Demo(V2LIST_ITEM, V2ListItemActivity::class), + Demo(V2DRAWER, V2DrawerActivity::class, Badge.Modified), + Demo(V2LABEL, V2LabelActivity::class), + Demo(V2LIST_ITEM, V2ListItemActivity::class, Badge.Modified), Demo(V2MENU, V2MenuActivity::class), Demo(V2PEOPLE_PICKER, V2PeoplePickerActivity::class), Demo(V2PERSONA, V2PersonaActivity::class), @@ -187,11 +193,11 @@ val V2DEMO = arrayListOf( Demo(V2PERSONA_LIST, V2PersonaListActivity::class), Demo(V2PROGRESS, V2ProgressActivity::class), Demo(V2SCAFFOLD, V2ScaffoldActivity::class), - Demo(V2SEARCHBAR, V2SearchBarActivity::class), - Demo(V2SEGMENTED_CONTROL, V2SegmentedControlActivity::class, Badge.Modified), - Demo(V2SHIMMER, V2ShimmerActivity::class), + Demo(V2SEARCHBAR, V2SearchBarActivity::class, Badge.Modified), + Demo(V2SEGMENTED_CONTROL, V2SegmentedControlActivity::class), + Demo(V2SHIMMER, V2ShimmerActivity::class, Badge.Modified), Demo(V2SIDE_RAIL, V2SideRailActivity::class), - Demo(V2SNACKBAR, V2SnackbarActivity::class), + Demo(V2SNACKBAR, V2SnackbarActivity::class, Badge.Modified), Demo(V2TAB_BAR, V2TabBarActivity::class), Demo(V2TEXT_FIELD, V2TextFieldActivity::class), Demo(V2TOOL_TIP, V2ToolTipActivity::class), diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/BottomSheetActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/BottomSheetActivity.kt index 7f6f6e288..1c12cea35 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/BottomSheetActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/BottomSheetActivity.kt @@ -7,6 +7,7 @@ package com.microsoft.fluentuidemo.demos import android.os.Bundle import android.view.LayoutInflater +import android.widget.Switch import android.widget.TextView import com.microsoft.fluentui.bottomsheet.BottomSheet import com.microsoft.fluentui.bottomsheet.BottomSheetDialog @@ -67,6 +68,12 @@ class BottomSheetActivity : DemoActivity(), BottomSheetItem.OnClickListener { R.id.bottom_sheet_item_delete, R.drawable.ic_delete_24_regular, getString(R.string.bottom_sheet_item_delete_title) + ), + BottomSheetItem( + R.id.bottom_sheet_item_toggle, + R.drawable.ic_fluent_toggle_multiple_24_regular, + getString(R.string.bottom_sheet_item_toggle_title), + customAccessoryView = Switch(this) ) ) ) @@ -218,6 +225,7 @@ class BottomSheetActivity : DemoActivity(), BottomSheetItem.OnClickListener { R.id.bottom_sheet_item_reply -> showSnackbar(resources.getString(R.string.bottom_sheet_item_reply_toast)) R.id.bottom_sheet_item_forward -> showSnackbar(resources.getString(R.string.bottom_sheet_item_forward_toast)) R.id.bottom_sheet_item_delete -> showSnackbar(resources.getString(R.string.bottom_sheet_item_delete_toast)) + R.id.bottom_sheet_item_toggle -> showSnackbar(resources.getString(R.string.bottom_sheet_item_toggle_toast)) // Double line items R.id.bottom_sheet_item_camera -> showSnackbar(resources.getString(R.string.bottom_sheet_item_camera_toast)) diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/CalendarViewActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/CalendarViewActivity.kt index 23ad5835e..48baa3bd7 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/CalendarViewActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/CalendarViewActivity.kt @@ -8,16 +8,13 @@ package com.microsoft.fluentuidemo.demos import android.os.Bundle import android.view.KeyEvent import android.view.LayoutInflater -import android.view.View import android.view.View.TEXT_ALIGNMENT_TEXT_START import com.microsoft.fluentui.calendar.OnDateSelectedListener import com.microsoft.fluentui.util.DateStringUtils -import com.microsoft.fluentui.util.DuoSupportUtils import com.microsoft.fluentuidemo.DemoActivity -import com.microsoft.fluentuidemo.R import com.microsoft.fluentuidemo.databinding.ActivityCalendarViewBinding -import org.threeten.bp.Duration -import org.threeten.bp.ZonedDateTime +import java.time.Duration +import java.time.ZonedDateTime class CalendarViewActivity : DemoActivity() { companion object { @@ -41,9 +38,6 @@ class CalendarViewActivity : DemoActivity() { true ) - if (DuoSupportUtils.isDualScreenMode(this)) { - calenderBinding.exampleDateTitle.textAlignment = TEXT_ALIGNMENT_TEXT_START - } calenderBinding.calendarView.onDateSelectedListener = object : OnDateSelectedListener { override fun onDateSelected(date: ZonedDateTime) { setExampleDate(date) diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/DateTimePickerActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/DateTimePickerActivity.kt index 5bb897ec9..054b5c3fc 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/DateTimePickerActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/DateTimePickerActivity.kt @@ -9,7 +9,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.widget.Button -import com.jakewharton.threetenabp.AndroidThreeTen import com.microsoft.fluentui.datetimepicker.DateTimePicker import com.microsoft.fluentui.datetimepicker.DateTimePickerDialog import com.microsoft.fluentui.datetimepicker.DateTimePickerDialog.DateRangeMode @@ -20,8 +19,8 @@ import com.microsoft.fluentui.util.isAccessibilityEnabled import com.microsoft.fluentuidemo.DemoActivity import com.microsoft.fluentuidemo.R import com.microsoft.fluentuidemo.databinding.ActivityDateTimePickerBinding -import org.threeten.bp.Duration -import org.threeten.bp.ZonedDateTime +import java.time.Duration +import java.time.ZonedDateTime class DateTimePickerActivity : DemoActivity(), DateTimePickerDialog.OnDateTimePickedListener { companion object { @@ -161,11 +160,6 @@ class DateTimePickerActivity : DemoActivity(), DateTimePickerDialog.OnDateTimePi private var dialogMode: Mode? = null - init { - // Initialization of ThreeTenABP required for ZoneDateTime - AndroidThreeTen.init(this) - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/PersistentBottomSheetActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/PersistentBottomSheetActivity.kt index 708c4b9d3..7b85dd24b 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/PersistentBottomSheetActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/PersistentBottomSheetActivity.kt @@ -240,7 +240,7 @@ class PersistentBottomSheetActivity : DemoActivity(), SheetItem.OnClickListener, ContextCompat.getColor(this, R.color.bottomsheet_horizontal_icon_tint), disabled = true ) - ), 0, marginBetweenView + ), 0, marginBetweenView, drawerTint = ContextCompat.getColor(this, R.color.bottomsheet_horizontal_icon_tint).toInt() ) horizontalListAdapter.mOnSheetItemClickListener = this persistentSheetContentBinding.sheetHorizontalItemList3.layoutManager = 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 new file mode 100644 index 000000000..eb134fbcc --- /dev/null +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2AcrylicActivity.kt @@ -0,0 +1,302 @@ +package com.microsoft.fluentuidemo.demos + +import android.content.Context +import android.os.Bundle +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.microsoft.fluentui.tokenized.listitem.ListItem +import com.microsoft.fluentuidemo.R +import com.microsoft.fluentuidemo.V2DemoActivity +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Slider +import androidx.compose.material.SliderDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.sp +import com.microsoft.fluentui.tokenized.acrylicpane.AcrylicPane +import com.microsoft.fluentui.icons.SearchBarIcons +import com.microsoft.fluentui.icons.searchbaricons.Office +import com.microsoft.fluentui.theme.FluentTheme +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.tokenized.SearchBar +import com.microsoft.fluentui.tokenized.controls.RadioButton +import com.microsoft.fluentui.tokenized.drawer.DrawerValue +import com.microsoft.fluentui.tokenized.drawer.rememberBottomDrawerState +import com.microsoft.fluentui.tokenized.persona.Person +import com.microsoft.fluentuidemo.CustomizedSearchBarTokens +import com.microsoft.fluentuidemo.util.DemoAppStrings +import com.microsoft.fluentuidemo.util.PrimarySurfaceContent +import com.microsoft.fluentuidemo.util.getAndroidViewAsContent +import com.microsoft.fluentuidemo.util.getDemoAppString +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class V2AcrylicPaneActivity : V2DemoActivity() { + init { + 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 fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setActivityContent { + CreateAcrylicPaneActivityUI(this) + } + } +} + +@Composable +fun CreateAcrylicPaneActivityUI( + context: Context +){ + var acrylicPaneSizeFraction by rememberSaveable { mutableFloatStateOf(0.5F) } + var acrylicPaneStyle by rememberSaveable { mutableStateOf(FluentStyle.Neutral) } + + AcrylicPane( + paneHeight = (acrylicPaneSizeFraction * 500).toInt().dp, + acrylicPaneStyle = acrylicPaneStyle, + component = { acrylicPaneContent(context = context) }, + backgroundContent = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()).fillMaxWidth().padding(10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(300.dp)) + ListItem.Header( + title = "Acrylic Pane Size", + titleMaxLines = 2, + modifier = Modifier + .clearAndSetSemantics { + this.contentDescription = "Acrylic Pane Size" + }, + ) + Slider( + value = acrylicPaneSizeFraction, + onValueChange = { acrylicPaneSizeFraction = it }, + valueRange = 0F..1F, + 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 = "Acrylic Pane Theme", + titleMaxLines = 2, + modifier = Modifier + .clearAndSetSemantics { + this.contentDescription = "Acrylic Pane Theme" + }, + ) + 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 + ) + } + } + ListItem.Header( + title = "Test Bottom Drawer", + titleMaxLines = 2, + modifier = Modifier + .clearAndSetSemantics { + this.contentDescription = "Test Bottom Drawer" + }, + ) + showBottomDrawer() + ListItem.Header( + title = "Scroll Test", + titleMaxLines = 2, + modifier = Modifier + .clearAndSetSemantics { + this.contentDescription = "Test Bottom Drawer" + }, + ) + repeat(40) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 5.dp) + ) { + 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))) + ) + } + } + } + } + ) +} + +@Composable +fun showBottomDrawer(){ + val scope = rememberCoroutineScope() + + val drawerState = rememberBottomDrawerState(initialValue = DrawerValue.Closed, expandable = true, skipOpenState = false) + + val open: () -> Unit = { + scope.launch { drawerState.open() } + } + val expand: () -> Unit = { + scope.launch { drawerState.expand() } + } + val close: () -> Unit = { + scope.launch { drawerState.close() } + } + 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) + ) + } + var selectedContent by rememberSaveable { mutableStateOf(ContentType.FULL_SCREEN_SCROLLABLE_CONTENT) } + val drawerContent = getAndroidViewAsContent(selectedContent) + var maxLandscapeWidthFraction by rememberSaveable { mutableFloatStateOf(1F) } + var preventDismissalOnScrimClick by rememberSaveable { mutableStateOf(false) } + com.microsoft.fluentui.tokenized.drawer.BottomDrawer( + drawerState = drawerState, + drawerContent = { drawerContent(close) }, + scrimVisible = true, + slideOver = true, + showHandle = true, + enableSwipeDismiss = true, + maxLandscapeWidthFraction = maxLandscapeWidthFraction, + preventDismissalOnScrimClick = preventDismissalOnScrimClick + ) +} + +@Composable +fun acrylicPaneContent(context: Context){ + val scope = rememberCoroutineScope() + + val microphonePressedString = getDemoAppString(DemoAppStrings.MicrophonePressed) + val rightViewPressedString = getDemoAppString(DemoAppStrings.RightViewPressed) + val keyboardSearchPressedString = getDemoAppString(DemoAppStrings.KeyboardSearchPressed) + var loading by rememberSaveable { mutableStateOf(false) } + val keyboardController = LocalSoftwareKeyboardController.current + var autoCorrectEnabled: Boolean by rememberSaveable { mutableStateOf(false) } + var enableMicrophoneCallback: Boolean by rememberSaveable { mutableStateOf(true) } + var searchBarStyle: FluentStyle by rememberSaveable { mutableStateOf(FluentStyle.Brand) } + var displayRightAccessory: Boolean by rememberSaveable { mutableStateOf(true) } + var selectedPeople: Person? by rememberSaveable { mutableStateOf(null) } + val showCustomizedAppBar = false + Column { + Spacer(modifier = Modifier.height(80.dp)) + Row(Modifier.height(5.dp).padding(20.dp)) { + SearchBar( + onValueChange = { query, selectedPerson -> + scope.launch { + loading = true + delay(2000) + loading = false + } + }, + style = searchBarStyle, + loading = loading, + selectedPerson = selectedPeople, + microphoneCallback = if (enableMicrophoneCallback) { + { + Toast.makeText(context, microphonePressedString, Toast.LENGTH_SHORT) + .show() + } + } else null, + keyboardOptions = KeyboardOptions( + autoCorrect = autoCorrectEnabled, + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Search + ), + keyboardActions = KeyboardActions( + onSearch = { + Toast.makeText( + context, + keyboardSearchPressedString, + Toast.LENGTH_SHORT + ) + .show() + keyboardController?.hide() + } + ), + rightAccessoryIcon = if (displayRightAccessory) { + FluentIcon( + SearchBarIcons.Office, + contentDescription = "Office", + onClick = { + Toast.makeText( + context, + rightViewPressedString, + Toast.LENGTH_SHORT + ) + .show() + } + ) + } else null, + searchBarTokens = if (showCustomizedAppBar) { + CustomizedSearchBarTokens + } else null, + modifier = if (showCustomizedAppBar) Modifier.requiredHeight(60.dp) else Modifier + ) + } + } +} diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ActionBarActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ActionBarActivity.kt new file mode 100644 index 000000000..83f04aaf4 --- /dev/null +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ActionBarActivity.kt @@ -0,0 +1,147 @@ +package com.microsoft.fluentuidemo.demos + +import android.os.Bundle +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.platform.testTag +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import com.microsoft.fluentui.theme.FluentTheme +import com.microsoft.fluentui.theme.ThemeMode +import com.microsoft.fluentui.theme.token.FluentAliasTokens +import com.microsoft.fluentui.tokenized.controls.Button +import com.microsoft.fluentui.tokenized.controls.RadioButton +import com.microsoft.fluentui.tokenized.listitem.ListItem +import com.microsoft.fluentuidemo.Demo +import com.microsoft.fluentuidemo.DemoActivity.Companion.DEMO_ID +import com.microsoft.fluentuidemo.Navigation +import com.microsoft.fluentuidemo.R +import com.microsoft.fluentuidemo.V2DemoActivity +import com.microsoft.fluentuidemo.demos.actionbar.V2ActionBarDemoActivity + +const val ACTION_BAR_TOP_RADIO = "actionBarTopRadio" +const val ACTION_BAR_BOTTOM_RADIO = "actionBarBottomRadio" +const val ACTION_BAR_BASIC_TYPE_RADIO = "actionBarBasicTypeRadio" +const val ACTION_BAR_ICON_TYPE_RADIO = "actionBarIconTypeRadio" +const val ACTION_BAR_CAROUSEL_TYPE_RADIO = "actionBarCarouselTypeRadio" + +class V2ActionBarActivity : V2DemoActivity() { + init { + setupActivity(this) + } + + override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-37" + override val controlTokensUrl = + "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-35" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val context = this + + setActivityContent { + val actionBarPos = listOf(0, 1) + val actionBarType = listOf(0, 1, 2) + var selectedActionBarPos by rememberSaveable { mutableStateOf(actionBarPos[0]) } + var selectedActionBarType by rememberSaveable { mutableStateOf(actionBarType[0]) } + + Column { + ListItem.Header(title = resources.getString(R.string.actionbar_position_heading)) + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + ActionBarRow( + text = R.string.actionbar_position_top_radio_label, + testTag = ACTION_BAR_TOP_RADIO, + selected = selectedActionBarPos == actionBarPos[0], + onClick = { selectedActionBarPos = actionBarPos[0] } + ) + ActionBarRow( + text = R.string.actionbar_position_bottom_radio_label, + testTag = ACTION_BAR_BOTTOM_RADIO, + selected = selectedActionBarPos == actionBarPos[1], + onClick = { selectedActionBarPos = actionBarPos[1] } + ) + } + ListItem.Header(title = resources.getString(R.string.actionbar_type_heading)) + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + ActionBarRow( + text = R.string.actionbar_basic_radio_label, + testTag = ACTION_BAR_BASIC_TYPE_RADIO, + selected = selectedActionBarType == actionBarType[0], + onClick = { selectedActionBarType = actionBarType[0] } + ) + ActionBarRow( + text = R.string.actionbar_icon_radio_label, + testTag = ACTION_BAR_ICON_TYPE_RADIO, + selected = selectedActionBarType == actionBarType[1], + onClick = { selectedActionBarType = actionBarType[1] } + ) + + ActionBarRow( + text = R.string.actionbar_carousel_radio_label, + testTag = ACTION_BAR_CAROUSEL_TYPE_RADIO, + selected = selectedActionBarType == actionBarType[2], + onClick = { selectedActionBarType = actionBarType[2] } + ) + } + + Button( + text = resources.getString(R.string.actionbar_start_button), + onClick = { + val demo = Demo("DEMOACTIONBAR", V2ActionBarDemoActivity::class) + val packageContext = this@V2ActionBarActivity + Navigation.forwardNavigation( + packageContext, + demo.demoClass.java, + Pair(DEMO_ID, demo.id), + Pair(DEMO_TITLE, demo.title), + Pair("ACTION_BAR_TYPE", selectedActionBarType), + Pair("ACTION_BAR_POSITION", selectedActionBarPos) + ) + }, + modifier = Modifier.padding(16.dp) + ) + } + } + } + + @Composable + fun ActionBarRow( + text: Int, + testTag: String, + selected: Boolean, + onClick: () -> Unit + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + BasicText( + text = resources.getString(text), + modifier = Modifier.weight(1F), + style = TextStyle( + color = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground3].value( + themeMode = ThemeMode.Auto + ) + ) + ) + RadioButton( + modifier = Modifier.testTag(testTag), + selected = selected, + onClick = onClick + ) + } + } +} \ No newline at end of file diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2AppBarActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2AppBarActivity.kt index ad8a1b2a2..54fea5696 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2AppBarActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2AppBarActivity.kt @@ -12,10 +12,13 @@ import androidx.compose.foundation.layout.requiredSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Email +import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberUpdatedState 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.focus.onFocusChanged import androidx.compose.ui.graphics.Color @@ -34,9 +37,12 @@ import com.microsoft.fluentui.theme.token.FluentColor import com.microsoft.fluentui.theme.token.FluentIcon import com.microsoft.fluentui.theme.token.FluentStyle import com.microsoft.fluentui.theme.token.Icon +import com.microsoft.fluentui.theme.token.controlTokens.AppBarInfo import com.microsoft.fluentui.theme.token.controlTokens.AppBarSize +import com.microsoft.fluentui.theme.token.controlTokens.AppBarTokens import com.microsoft.fluentui.theme.token.controlTokens.AvatarSize import com.microsoft.fluentui.theme.token.controlTokens.AvatarStatus +import com.microsoft.fluentui.theme.token.controlTokens.TooltipControls import com.microsoft.fluentui.tokenized.AppBar import com.microsoft.fluentui.tokenized.SearchBar import com.microsoft.fluentui.tokenized.controls.ToggleSwitch @@ -56,6 +62,10 @@ const val APP_BAR_SUBTITLE_PARAM = "App Bar Subtitle Param" const val APP_BAR_STYLE_PARAM = "App Bar AppBar Style Param" const val APP_BAR_BUTTONBAR_PARAM = "App Bar ButtonBar Param" const val APP_BAR_SEARCHBAR_PARAM = "App Bar SearchBar Param" +const val APP_BAR_LOGO_PARAM = "App Bar Logo Param" +const val APP_BAR_CENTER_ALIGN_PARAM = "App Bar Center Align Param" +const val APP_BAR_ENABLE_TOOLTIPS_PARAM = "App Bar Enable Tooltips Param" +const val APP_BAR_NAVIGATION_ICON_PARAM = "App Bar Navigation Icon Param" class V2AppBarActivity : V2DemoActivity() { init { @@ -79,7 +89,11 @@ class V2AppBarActivity : V2DemoActivity() { var enableSearchBar: Boolean by rememberSaveable { mutableStateOf(false) } var enableButtonBar: Boolean by rememberSaveable { mutableStateOf(false) } var enableBottomBorder: Boolean by rememberSaveable { mutableStateOf(true) } + var centerAlignAppBar: Boolean by rememberSaveable { mutableStateOf(false) } + var enableTooltips: Boolean by rememberSaveable { mutableStateOf(false) } + var showNavigationIcon: Boolean by rememberSaveable { mutableStateOf(true) } var yAxisDelta: Float by rememberSaveable { mutableStateOf(1.0F) } + var enableLogo: Boolean by rememberSaveable { mutableStateOf(true) } Column(modifier = Modifier.pointerInput(Unit) { detectDragGestures { _, distance -> @@ -97,6 +111,7 @@ class V2AppBarActivity : V2DemoActivity() { chevronOrientation = ChevronOrientation(90f, 0f), ) { Column { + ListItem.Header(LocalContext.current.resources.getString(R.string.app_bar_size)) PillBar( mutableListOf( PillMetaData( @@ -218,6 +233,72 @@ class V2AppBarActivity : V2DemoActivity() { ) } ) + + ListItem.Item( + text = LocalContext.current.resources.getString(R.string.left_logo), + subText = if (enableLogo) + LocalContext.current.resources.getString(R.string.fluentui_enabled) + else + LocalContext.current.resources.getString(R.string.fluentui_disabled), + trailingAccessoryContent = { + ToggleSwitch( + onValueChange = { + enableLogo = !enableLogo + }, + modifier = Modifier.testTag(APP_BAR_LOGO_PARAM), + checkedState = enableLogo + ) + } + ) + + ListItem.Item( + text = LocalContext.current.resources.getString(R.string.navigation_icon), + subText = if (showNavigationIcon) + LocalContext.current.resources.getString(R.string.fluentui_enabled) + else + LocalContext.current.resources.getString(R.string.fluentui_disabled), + trailingAccessoryContent = { + ToggleSwitch( + onValueChange = { + showNavigationIcon = !showNavigationIcon + }, + modifier = Modifier.testTag(APP_BAR_NAVIGATION_ICON_PARAM), + checkedState = showNavigationIcon + ) + } + ) + ListItem.Item( + text = LocalContext.current.resources.getString(R.string.center_title_alignment), + subText = if (centerAlignAppBar) + LocalContext.current.resources.getString(R.string.fluentui_enabled) + else + LocalContext.current.resources.getString(R.string.fluentui_disabled), + trailingAccessoryContent = { + ToggleSwitch( + onValueChange = { + centerAlignAppBar = !centerAlignAppBar + }, + modifier = Modifier.testTag(APP_BAR_CENTER_ALIGN_PARAM), + checkedState = centerAlignAppBar + ) + } + ) + ListItem.Item( + text = LocalContext.current.resources.getString(R.string.enable_tooltips), + subText = if (enableTooltips) + LocalContext.current.resources.getString(R.string.fluentui_enabled) + else + LocalContext.current.resources.getString(R.string.fluentui_disabled), + trailingAccessoryContent = { + ToggleSwitch( + onValueChange = { + enableTooltips = !enableTooltips + }, + modifier = Modifier.testTag(APP_BAR_ENABLE_TOOLTIPS_PARAM), + checkedState = enableTooltips + ) + } + ) } } @@ -258,34 +339,58 @@ class V2AppBarActivity : V2DemoActivity() { ThemeMode.Dark ) ).value(FluentTheme.themeMode) - + val appBarTokens = object : AppBarTokens() { + @Composable + override fun tooltipVisibilityControls(info: AppBarInfo): TooltipControls { + return TooltipControls( + enableTitleTooltip = enableTooltips, + enableSubtitleTooltip = enableTooltips, + enableNavigationIconTooltip = enableTooltips, + ) + } + } AppBar( title = "Fluent UI Demo", - navigationIcon = FluentIcon( - SearchBarIcons.Arrowback, - contentDescription = "Navigate Back", - onClick = { - Toast.makeText( - context, - "Navigation Icon pressed", - Toast.LENGTH_SHORT - ).show() - }, - flipOnRtl = true - ), - subTitle = subtitle, - logo = { - Avatar( - Person( - "Allan", - "Munger", - status = AvatarStatus.DND, - isActive = true - ), - enablePresence = true, - size = AvatarSize.Size32 + navigationIcon = if (showNavigationIcon) { + FluentIcon( + SearchBarIcons.Arrowback, + contentDescription = "Navigate Back", + onLongClick = { + Toast.makeText( + context, + "Navigation Icon long pressed", + Toast.LENGTH_SHORT + ).show() + }, + onClick = { + Toast.makeText( + context, + "Navigation Icon pressed", + Toast.LENGTH_SHORT + ).show() + }, + flipOnRtl = true ) - }, + } else null, + subTitle = subtitle, + centerAlignAppBar = centerAlignAppBar, + logo = if (enableLogo) { + { + Avatar( + Person( + "Allan", + "Munger", + status = AvatarStatus.DND, + isActive = true + ), + enablePresence = true, + size = AvatarSize.Size32, + modifier = if (!showNavigationIcon) { + Modifier.padding(start = 16.dp) + } else Modifier + ) + } + } else null, postTitleIcon = FluentIcon( ListItemIcons.Chevron, contentDescription = LocalContext.current.resources.getString(R.string.fluentui_chevron), @@ -327,6 +432,7 @@ class V2AppBarActivity : V2DemoActivity() { } else null, appTitleDelta = appTitleDelta, accessoryDelta = accessoryDelta, + appBarTokens = appBarTokens, rightAccessoryView = { Icon( Icons.Filled.Add, diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2AvatarActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2AvatarActivity.kt index 117dae880..6728bb883 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2AvatarActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2AvatarActivity.kt @@ -52,6 +52,7 @@ class V2AvatarActivity : V2DemoActivity() { ) { var isActive by rememberSaveable { mutableStateOf(true) } var isOOO by rememberSaveable { mutableStateOf(false) } + var isActivityDotPresent by rememberSaveable { mutableStateOf(false) } BasicText( modifier = Modifier.padding(start = 16.dp), @@ -127,8 +128,8 @@ class V2AvatarActivity : V2DemoActivity() { ) { Button( onClick = { isActive = !isActive }, - text = "Toggle Activity", - contentDescription = "Activity Indicator ${if (isActive) "enabled" else "disabled"}" + text = "Toggle Activity Ring", + contentDescription = "Activity Ring ${if (isActive) "enabled" else "disabled"}" ) Button( onClick = { isOOO = !isOOO }, @@ -136,6 +137,19 @@ class V2AvatarActivity : V2DemoActivity() { contentDescription = "OOO status ${if (isOOO) "enabled" else "disabled"}" ) } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + 10.dp, + Alignment.CenterHorizontally + ) + ) { + Button( + onClick = { isActivityDotPresent = !isActivityDotPresent }, + text = "Toggle Activity Dot", + contentDescription = "Activity Dot ${if (isActivityDotPresent) "enabled" else "disabled"}" + ) + } Divider() @@ -158,29 +172,32 @@ class V2AvatarActivity : V2DemoActivity() { status = AvatarStatus.Available, isOOO = isOOO ) - Avatar(person, size = AvatarSize.Size16, enableActivityRings = true) - Avatar(person, size = AvatarSize.Size20, enableActivityRings = true) - Avatar(person, size = AvatarSize.Size24, enableActivityRings = true) + Avatar(person, size = AvatarSize.Size16, enableActivityRings = true, enableActivityDot = isActivityDotPresent) + Avatar(person, size = AvatarSize.Size20, enableActivityRings = true, enableActivityDot = isActivityDotPresent) + Avatar(person, size = AvatarSize.Size24, enableActivityRings = true, enableActivityDot = isActivityDotPresent) Avatar( personNoImage, size = AvatarSize.Size32, enableActivityRings = true, - avatarToken = AnonymousAvatarTokens() + avatarToken = AnonymousAvatarTokens(), + enableActivityDot = isActivityDotPresent ) Avatar( person, size = AvatarSize.Size40, enableActivityRings = true, - avatarToken = AnonymousAvatarTokens() + avatarToken = AnonymousAvatarTokens(), + enableActivityDot = isActivityDotPresent ) Avatar( personNoImage, size = AvatarSize.Size56, enableActivityRings = true, - avatarToken = AnonymousAvatarTokens() + avatarToken = AnonymousAvatarTokens(), + enableActivityDot = isActivityDotPresent ) - Avatar(personNoImage, size = AvatarSize.Size72, enableActivityRings = true) + Avatar(personNoImage, size = AvatarSize.Size72, enableActivityRings = true, enableActivityDot = isActivityDotPresent) } Row( @@ -197,13 +214,13 @@ class V2AvatarActivity : V2DemoActivity() { status = AvatarStatus.Away, isOOO = isOOO ) - Avatar(person, size = AvatarSize.Size16, enableActivityRings = true) - Avatar(person, size = AvatarSize.Size20, enableActivityRings = true) - Avatar(person, size = AvatarSize.Size24, enableActivityRings = true) - Avatar(person, size = AvatarSize.Size32, enableActivityRings = true) - Avatar(person, size = AvatarSize.Size40, enableActivityRings = true) - Avatar(person, size = AvatarSize.Size56, enableActivityRings = true) - Avatar(person, size = AvatarSize.Size72, enableActivityRings = true) + Avatar(person, size = AvatarSize.Size16, enableActivityRings = true, enableActivityDot = isActivityDotPresent) + Avatar(person, size = AvatarSize.Size20, enableActivityRings = true, enableActivityDot = isActivityDotPresent) + Avatar(person, size = AvatarSize.Size24, enableActivityRings = true, enableActivityDot = isActivityDotPresent) + Avatar(person, size = AvatarSize.Size32, enableActivityRings = true, enableActivityDot = isActivityDotPresent) + Avatar(person, size = AvatarSize.Size40, enableActivityRings = true, enableActivityDot = isActivityDotPresent) + Avatar(person, size = AvatarSize.Size56, enableActivityRings = true, enableActivityDot = isActivityDotPresent) + Avatar(person, size = AvatarSize.Size72, enableActivityRings = true, enableActivityDot = isActivityDotPresent) } Row( @@ -223,43 +240,50 @@ class V2AvatarActivity : V2DemoActivity() { person, size = AvatarSize.Size16, enableActivityRings = false, - avatarToken = AnonymousAccentAvatarTokens() + avatarToken = AnonymousAccentAvatarTokens(), + enableActivityDot = isActivityDotPresent ) Avatar( person, size = AvatarSize.Size20, enableActivityRings = false, - avatarToken = AnonymousAccentAvatarTokens() + avatarToken = AnonymousAccentAvatarTokens(), + enableActivityDot = isActivityDotPresent ) Avatar( person, size = AvatarSize.Size24, enableActivityRings = false, - avatarToken = AnonymousAccentAvatarTokens() + avatarToken = AnonymousAccentAvatarTokens(), + enableActivityDot = isActivityDotPresent ) Avatar( person, size = AvatarSize.Size32, enableActivityRings = false, - avatarToken = AnonymousAccentAvatarTokens() + avatarToken = AnonymousAccentAvatarTokens(), + enableActivityDot = isActivityDotPresent ) Avatar( person, size = AvatarSize.Size40, enableActivityRings = false, - avatarToken = AnonymousAccentAvatarTokens() + avatarToken = AnonymousAccentAvatarTokens(), + enableActivityDot = isActivityDotPresent ) Avatar( person, size = AvatarSize.Size56, enableActivityRings = false, - avatarToken = AnonymousAccentAvatarTokens() + avatarToken = AnonymousAccentAvatarTokens(), + enableActivityDot = isActivityDotPresent ) Avatar( person, size = AvatarSize.Size72, enableActivityRings = false, - avatarToken = AnonymousAccentAvatarTokens() + avatarToken = AnonymousAccentAvatarTokens(), + enableActivityDot = isActivityDotPresent ) } @@ -282,28 +306,31 @@ class V2AvatarActivity : V2DemoActivity() { ) - Avatar(person, size = AvatarSize.Size16, enableActivityRings = true) - Avatar(person, size = AvatarSize.Size20, enableActivityRings = true) - Avatar(person, size = AvatarSize.Size24, enableActivityRings = true) - Avatar(person, size = AvatarSize.Size32, enableActivityRings = true) + Avatar(person, size = AvatarSize.Size16, enableActivityRings = true, enableActivityDot = isActivityDotPresent) + Avatar(person, size = AvatarSize.Size20, enableActivityRings = true, enableActivityDot = isActivityDotPresent) + Avatar(person, size = AvatarSize.Size24, enableActivityRings = true, enableActivityDot = isActivityDotPresent) + Avatar(person, size = AvatarSize.Size32, enableActivityRings = true, enableActivityDot = isActivityDotPresent) Avatar( personNoInitial, size = AvatarSize.Size40, enableActivityRings = true, - avatarToken = StandardInvertedAvatarTokens() + avatarToken = StandardInvertedAvatarTokens(), + enableActivityDot = isActivityDotPresent ) Avatar( personNoInitial, size = AvatarSize.Size56, enableActivityRings = true, - avatarToken = StandardInvertedAvatarTokens() + avatarToken = StandardInvertedAvatarTokens(), + enableActivityDot = isActivityDotPresent ) Avatar( personNoInitial, size = AvatarSize.Size72, enableActivityRings = true, - avatarToken = StandardInvertedAvatarTokens() + avatarToken = StandardInvertedAvatarTokens(), + enableActivityDot = isActivityDotPresent ) } @@ -328,44 +355,51 @@ class V2AvatarActivity : V2DemoActivity() { person, size = AvatarSize.Size16, enableActivityRings = false, - avatarToken = StandardInvertedAvatarTokens() + avatarToken = StandardInvertedAvatarTokens(), + enableActivityDot = isActivityDotPresent ) Avatar( person, size = AvatarSize.Size20, enableActivityRings = true, - avatarToken = StandardInvertedAvatarTokens() + avatarToken = StandardInvertedAvatarTokens(), + enableActivityDot = isActivityDotPresent ) Avatar( person, size = AvatarSize.Size24, enableActivityRings = true, - avatarToken = StandardInvertedAvatarTokens() + avatarToken = StandardInvertedAvatarTokens(), + enableActivityDot = isActivityDotPresent ) Avatar( person, size = AvatarSize.Size32, enableActivityRings = true, - avatarToken = StandardInvertedAvatarTokens() + avatarToken = StandardInvertedAvatarTokens(), + enableActivityDot = isActivityDotPresent ) Avatar( personNoName, size = AvatarSize.Size40, enableActivityRings = false, - avatarToken = AnonymousAvatarTokens() + avatarToken = AnonymousAvatarTokens(), + enableActivityDot = isActivityDotPresent ) Avatar( personNoName, size = AvatarSize.Size56, enableActivityRings = true, - avatarToken = AnonymousAvatarTokens() + avatarToken = AnonymousAvatarTokens(), + enableActivityDot = isActivityDotPresent ) Avatar( personNoName, size = AvatarSize.Size72, enableActivityRings = true, - avatarToken = AnonymousAvatarTokens() + avatarToken = AnonymousAvatarTokens(), + enableActivityDot = isActivityDotPresent ) } diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2AvatarGroupActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2AvatarGroupActivity.kt index 8433ceb3b..d58cd5dd5 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2AvatarGroupActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2AvatarGroupActivity.kt @@ -41,7 +41,8 @@ class V2AvatarGroupActivity : V2DemoActivity() { } override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-3" - override val controlTokensUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-3" + override val controlTokensUrl = + "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-3" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -54,6 +55,7 @@ class V2AvatarGroupActivity : V2DemoActivity() { var isActive by rememberSaveable { mutableStateOf(false) } var enablePresence by rememberSaveable { mutableStateOf(true) } var maxVisibleAvatar by rememberSaveable { mutableStateOf(1) } + var enableActivityDot by rememberSaveable { mutableStateOf(false) } val group = Group( listOf( @@ -110,6 +112,19 @@ class V2AvatarGroupActivity : V2DemoActivity() { text = "+", contentDescription = "Max Visible Avatar $maxVisibleAvatar" ) + Button( + onClick = { enableActivityDot = !enableActivityDot }, + text = "Show Activity Dot", + contentDescription = "Activity Dot ${if (enableActivityDot) "Enabled" else "Disabled"}" + ) + } + + Row( + Modifier + .fillMaxWidth() + .padding(5.dp), horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { Button( onClick = { isActive = !isActive }, text = "Swap Active State", @@ -146,7 +161,8 @@ class V2AvatarGroupActivity : V2DemoActivity() { group, size = AvatarSize.Size16, maxVisibleAvatar = maxVisibleAvatar, - enablePresence = enablePresence + enablePresence = enablePresence, + enableActivityDot = enableActivityDot ) } } @@ -165,7 +181,8 @@ class V2AvatarGroupActivity : V2DemoActivity() { size = AvatarSize.Size20, maxVisibleAvatar = maxVisibleAvatar, enablePresence = enablePresence, - avatarToken = AnonymousAvatarTokens() + avatarToken = AnonymousAvatarTokens(), + enableActivityDot = enableActivityDot ) } } @@ -184,7 +201,8 @@ class V2AvatarGroupActivity : V2DemoActivity() { size = AvatarSize.Size24, maxVisibleAvatar = maxVisibleAvatar, enablePresence = enablePresence, - avatarToken = AnonymousAvatarTokens() + avatarToken = AnonymousAvatarTokens(), + enableActivityDot = enableActivityDot ) } } @@ -202,7 +220,8 @@ class V2AvatarGroupActivity : V2DemoActivity() { group, size = AvatarSize.Size32, maxVisibleAvatar = maxVisibleAvatar, - enablePresence = enablePresence + enablePresence = enablePresence, + enableActivityDot = enableActivityDot ) } } @@ -221,7 +240,8 @@ class V2AvatarGroupActivity : V2DemoActivity() { size = AvatarSize.Size40, maxVisibleAvatar = maxVisibleAvatar, enablePresence = enablePresence, - avatarToken = AnonymousAccentAvatarTokens() + avatarToken = AnonymousAccentAvatarTokens(), + enableActivityDot = enableActivityDot ) } } @@ -239,7 +259,8 @@ class V2AvatarGroupActivity : V2DemoActivity() { group, size = AvatarSize.Size56, maxVisibleAvatar = maxVisibleAvatar, - enablePresence = enablePresence + enablePresence = enablePresence, + enableActivityDot = enableActivityDot ) } } @@ -258,7 +279,8 @@ class V2AvatarGroupActivity : V2DemoActivity() { size = AvatarSize.Size72, maxVisibleAvatar = maxVisibleAvatar, enablePresence = enablePresence, - avatarToken = StandardInvertedAvatarTokens() + avatarToken = StandardInvertedAvatarTokens(), + enableActivityDot = enableActivityDot ) } } @@ -286,7 +308,8 @@ class V2AvatarGroupActivity : V2DemoActivity() { size = AvatarSize.Size16, style = AvatarGroupStyle.Pile, maxVisibleAvatar = maxVisibleAvatar, - enablePresence = enablePresence + enablePresence = enablePresence, + enableActivityDot = enableActivityDot ) } } @@ -306,7 +329,8 @@ class V2AvatarGroupActivity : V2DemoActivity() { style = AvatarGroupStyle.Pile, maxVisibleAvatar = maxVisibleAvatar, enablePresence = enablePresence, - avatarToken = AnonymousAvatarTokens() + avatarToken = AnonymousAvatarTokens(), + enableActivityDot = enableActivityDot ) } } @@ -326,7 +350,8 @@ class V2AvatarGroupActivity : V2DemoActivity() { style = AvatarGroupStyle.Pile, maxVisibleAvatar = maxVisibleAvatar, enablePresence = enablePresence, - avatarToken = AnonymousAvatarTokens() + avatarToken = AnonymousAvatarTokens(), + enableActivityDot = enableActivityDot ) } } @@ -345,7 +370,8 @@ class V2AvatarGroupActivity : V2DemoActivity() { size = AvatarSize.Size32, style = AvatarGroupStyle.Pile, maxVisibleAvatar = maxVisibleAvatar, - enablePresence = enablePresence + enablePresence = enablePresence, + enableActivityDot = enableActivityDot ) } } @@ -365,7 +391,8 @@ class V2AvatarGroupActivity : V2DemoActivity() { style = AvatarGroupStyle.Pile, maxVisibleAvatar = maxVisibleAvatar, enablePresence = enablePresence, - avatarToken = AnonymousAccentAvatarTokens() + avatarToken = AnonymousAccentAvatarTokens(), + enableActivityDot = enableActivityDot ) } } @@ -384,7 +411,8 @@ class V2AvatarGroupActivity : V2DemoActivity() { size = AvatarSize.Size56, style = AvatarGroupStyle.Pile, maxVisibleAvatar = maxVisibleAvatar, - enablePresence = enablePresence + enablePresence = enablePresence, + enableActivityDot = enableActivityDot ) } } @@ -402,7 +430,152 @@ class V2AvatarGroupActivity : V2DemoActivity() { style = AvatarGroupStyle.Pile, maxVisibleAvatar = maxVisibleAvatar, enablePresence = enablePresence, - avatarToken = StandardInvertedAvatarTokens() + avatarToken = StandardInvertedAvatarTokens(), + enableActivityDot = enableActivityDot + ) + } + } + } + + item { + Row(horizontalArrangement = Arrangement.Center) { + BasicText( + "Pie Group Style", + style = aliasTokens.typography[FluentAliasTokens.TypographyTokens.Title2] + ) + } + } + item { + LazyRow( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize() + ) { + item { + BasicText("Size 16: ") + } + item { + AvatarGroup( + group, + size = AvatarSize.Size16, + style = AvatarGroupStyle.Pie, + maxVisibleAvatar = maxVisibleAvatar, + enableActivityDot = enableActivityDot + ) + } + } + } + item { + LazyRow( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize() + ) { + item { + BasicText("Size 20: ") + } + item { + AvatarGroup( + group, + size = AvatarSize.Size20, + style = AvatarGroupStyle.Pie, + maxVisibleAvatar = maxVisibleAvatar, + avatarToken = AnonymousAvatarTokens(), + enableActivityDot = enableActivityDot + ) + } + } + } + item { + LazyRow( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize() + ) { + item { + BasicText("Size 24: ") + } + item { + AvatarGroup( + group, + size = AvatarSize.Size24, + style = AvatarGroupStyle.Pie, + maxVisibleAvatar = maxVisibleAvatar, + avatarToken = AnonymousAvatarTokens(), + enableActivityDot = enableActivityDot + ) + } + } + } + item { + LazyRow( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize() + ) { + item { + BasicText("Size 32: ") + } + item { + AvatarGroup( + group, + size = AvatarSize.Size32, + style = AvatarGroupStyle.Pie, + maxVisibleAvatar = maxVisibleAvatar, + enableActivityDot = enableActivityDot + ) + } + } + } + item { + LazyRow( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize() + ) { + item { + BasicText("Size 40: ") + } + item { + AvatarGroup( + group, + size = AvatarSize.Size40, + style = AvatarGroupStyle.Pie, + maxVisibleAvatar = maxVisibleAvatar, + avatarToken = AnonymousAccentAvatarTokens(), + enableActivityDot = enableActivityDot + ) + } + } + } + item { + LazyRow( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize() + ) { + item { + BasicText("Size 56: ") + } + item { + AvatarGroup( + group, + size = AvatarSize.Size56, + style = AvatarGroupStyle.Pie, + maxVisibleAvatar = maxVisibleAvatar, + enableActivityDot = enableActivityDot + ) + } + } + } + item { + LazyRow( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize() + ) { + item { BasicText("Size 72: ") } + item { + AvatarGroup( + group, + size = AvatarSize.Size72, + style = AvatarGroupStyle.Pie, + maxVisibleAvatar = maxVisibleAvatar, + avatarToken = StandardInvertedAvatarTokens(), + enableActivityDot = enableActivityDot ) } } diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2BasicControlsActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2BasicControlsActivity.kt index c090b5823..9e8e437e6 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2BasicControlsActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2BasicControlsActivity.kt @@ -170,7 +170,7 @@ class V2BasicControlsActivity : V2DemoActivity() { selected = (theme == selectedOption.value), onClick = { }, role = Role.RadioButton, - interactionSource = MutableInteractionSource(), + interactionSource = remember { MutableInteractionSource() }, indication = null ) ) { 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 93a3b241b..b69817619 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 @@ -2,6 +2,8 @@ package com.microsoft.fluentuidemo.demos import android.content.res.Configuration import android.os.Bundle +import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -14,8 +16,8 @@ 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 @@ -30,6 +32,7 @@ import com.microsoft.fluentui.theme.token.FluentAliasTokens 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.fluentuidemo.R @@ -47,29 +50,35 @@ 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 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 } } } @Composable private fun CreateActivityUI() { - var scrimVisible by remember { mutableStateOf(true) } - var dynamicSizeContent by remember { mutableStateOf(false) } - var nestedDrawerContent by remember { mutableStateOf(false) } - var listContent by remember { mutableStateOf(true) } - var expandable by remember { mutableStateOf(true) } - var skipOpenState by remember { mutableStateOf(false) } - var selectedContent by remember { mutableStateOf(ContentType.FULL_SCREEN_SCROLLABLE_CONTENT) } - var slideOver by remember { mutableStateOf(false) } - var showHandle by remember { mutableStateOf(true) } - var enableSwipeDismiss by remember { mutableStateOf(true) } - var maxLandscapeWidthFraction by remember { mutableFloatStateOf(1F) } - var preventDismissalOnScrimClick by remember { mutableStateOf(false) } + var scrimVisible by rememberSaveable { mutableStateOf(true) } + var dynamicSizeContent by rememberSaveable { mutableStateOf(false) } + var nestedDrawerContent by rememberSaveable { mutableStateOf(false) } + var listContent by rememberSaveable { mutableStateOf(true) } + var expandable by rememberSaveable { mutableStateOf(true) } + var skipOpenState by rememberSaveable { mutableStateOf(false) } + var selectedContent by rememberSaveable { mutableStateOf(ContentType.FULL_SCREEN_SCROLLABLE_CONTENT) } + var slideOver by rememberSaveable { mutableStateOf(false) } + var showHandle by rememberSaveable { mutableStateOf(true) } + 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 Column(horizontalAlignment = Alignment.CenterHorizontally) { CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt( @@ -412,7 +421,7 @@ private fun CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt( ) { val scope = rememberCoroutineScope() - val drawerState = rememberBottomDrawerState(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/java/com/microsoft/fluentuidemo/demos/V2BottomSheetActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2BottomSheetActivity.kt index 27fe3f7a1..69b629590 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 @@ -39,6 +39,7 @@ 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 @@ -75,13 +76,15 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch const val BOTTOM_SHEET_ENABLE_SWIPE_DISMISS_TEST_TAG = "enableSwipeDismiss" + class V2BottomSheetActivity : V2DemoActivity() { init { setupActivity(this) } override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-10" - override val controlTokensUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-10" + override val controlTokensUrl = + "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-10" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -94,6 +97,8 @@ class V2BottomSheetActivity : V2DemoActivity() { @Composable private fun CreateActivityUI() { + var scrimVisible by rememberSaveable { mutableStateOf(false) } + var enableSwipeDismiss by remember { mutableStateOf(true) } var showHandleState by remember { mutableStateOf(true) } @@ -104,12 +109,14 @@ private fun CreateActivityUI() { var peekHeightState by remember { mutableStateOf(110.dp) } + var preventDismissalOnScrimClick by rememberSaveable { mutableStateOf(false) } + var stickyThresholdUpwardDrag: Float by remember { mutableStateOf(56f) } var stickyThresholdDownwardDrag: Float by remember { mutableStateOf(56f) } var hidden by remember { mutableStateOf(true) } - val bottomSheetState = rememberBottomSheetState(BottomSheetValue.Shown) + val bottomSheetState = rememberBottomSheetState(BottomSheetValue.Hidden) val scope = rememberCoroutineScope() @@ -147,10 +154,12 @@ private fun CreateActivityUI() { sheetContent = sheetContentState, expandable = expandableState, peekHeight = peekHeightState, + scrimVisible = scrimVisible, showHandle = showHandleState, sheetState = bottomSheetState, slideOver = slideOverState, enableSwipeDismiss = enableSwipeDismiss, + preventDismissalOnScrimClick = preventDismissalOnScrimClick, stickyThresholdUpward = stickyThresholdUpwardDrag, stickyThresholdDownward = stickyThresholdDownwardDrag ) { @@ -196,28 +205,12 @@ private fun CreateActivityUI() { } } ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Button( - style = ButtonStyle.OutlinedButton, - size = ButtonSize.Medium, - text = "Hide", - enabled = !hidden, - onClick = { - hidden = true - scope.launch { bottomSheetState.hide() } - } - ) Button( style = ButtonStyle.OutlinedButton, size = ButtonSize.Medium, text = "Expand", - enabled = !hidden && expandableState, + enabled = expandableState, onClick = { scope.launch { bottomSheetState.expand() } } @@ -286,7 +279,7 @@ private fun CreateActivityUI() { modifier = Modifier.fillMaxWidth() ) { BasicText( - text = stringResource(id =R.string.bottom_sheet_text_enable_swipe_dismiss), + text = stringResource(id = R.string.bottom_sheet_text_enable_swipe_dismiss), modifier = Modifier.weight(1F), style = TextStyle( color = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground1].value( @@ -300,6 +293,44 @@ private fun CreateActivityUI() { onValueChange = { enableSwipeDismiss = it } ) } + Row( + horizontalArrangement = Arrangement.spacedBy(30.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + BasicText( + text = "Scrim Visible", + modifier = Modifier.weight(1F), + style = TextStyle( + color = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground1].value( + themeMode = ThemeMode.Auto + ) + ) + ) + ToggleSwitch(checkedState = scrimVisible, + onValueChange = { scrimVisible = it } + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(30.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + BasicText( + text = "Prevent Dismissal On Scrim Click", + modifier = Modifier.weight(1F), + style = TextStyle( + color = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground1].value( + themeMode = ThemeMode.Auto + ) + ) + ) + ToggleSwitch(checkedState = preventDismissalOnScrimClick, + onValueChange = { preventDismissalOnScrimClick = it } + ) + } + // New Row for Sticky Threshold Downward Drag Row( horizontalArrangement = Arrangement.spacedBy(30.dp), @@ -316,11 +347,15 @@ private fun CreateActivityUI() { ) ) Slider( - modifier = Modifier.width(100.dp).height(50.dp).padding(0.dp, 0.dp, 0.dp, 0.dp), + modifier = Modifier + .width(100.dp) + .height(50.dp) + .padding(0.dp, 0.dp, 0.dp, 0.dp), value = stickyThresholdUpwardDrag, - onValueChange = { stickyThresholdUpwardDrag = it - peekHeightState+=0.0001.dp - }, + onValueChange = { + stickyThresholdUpwardDrag = it + peekHeightState += 0.0001.dp + }, valueRange = 0f..500f, colors = SliderDefaults.colors( thumbColor = FluentTheme.aliasTokens.brandColor[FluentAliasTokens.BrandColorTokens.Color100], @@ -352,11 +387,15 @@ private fun CreateActivityUI() { ) ) Slider( - modifier = Modifier.width(100.dp).height(50.dp).padding(0.dp, 0.dp, 0.dp, 0.dp), + modifier = Modifier + .width(100.dp) + .height(50.dp) + .padding(0.dp, 0.dp, 0.dp, 0.dp), value = stickyThresholdDownwardDrag, - onValueChange = { stickyThresholdDownwardDrag = it - peekHeightState+=0.0001.dp - }, + onValueChange = { + stickyThresholdDownwardDrag = it + peekHeightState += 0.0001.dp + }, valueRange = 0f..500f, colors = SliderDefaults.colors( thumbColor = FluentTheme.aliasTokens.brandColor[FluentAliasTokens.BrandColorTokens.Color100], @@ -390,14 +429,13 @@ private fun CreateActivityUI() { style = ButtonStyle.Button, size = ButtonSize.Medium, text = "+ 8 dp", - enabled = !hidden, onClick = { peekHeightState += 8.dp }) Button( style = ButtonStyle.Button, size = ButtonSize.Medium, text = "- 8 dp", - enabled = !hidden && (peekHeightState > 0.dp), + enabled = peekHeightState > 0.dp, onClick = { peekHeightState -= 8.dp }) } diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ButtonsActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ButtonsActivity.kt index 3686f7e91..16451ba95 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ButtonsActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ButtonsActivity.kt @@ -12,14 +12,17 @@ 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.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.BasicText import androidx.compose.material.Divider +import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.outlined.Favorite import androidx.compose.material.icons.outlined.ThumbUp import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -46,7 +49,9 @@ import com.microsoft.fluentui.theme.token.controlTokens.FABSize import com.microsoft.fluentui.theme.token.controlTokens.FABState import com.microsoft.fluentui.tokenized.controls.Button import com.microsoft.fluentui.tokenized.controls.FloatingActionButton +import com.microsoft.fluentui.tokenized.controls.RadioButton import com.microsoft.fluentuidemo.V2DemoActivity +import kotlinx.coroutines.selects.select class V2ButtonsActivity : V2DemoActivity() { init { @@ -181,8 +186,8 @@ class V2ButtonsActivity : V2DemoActivity() { } } } - item { + Divider() FluentTheme { BasicText( "Button with selected theme, auto mode and overridden control token", @@ -195,6 +200,39 @@ class V2ButtonsActivity : V2DemoActivity() { CreateButtons(MyButtonTokens()) } } + item { + Divider() + var checkBoxSelectedValues = List(4) { rememberSaveable { mutableStateOf(false) } } + FluentTheme { + BasicText( + "Radio Button Group with selected theme", + style = TextStyle( + color = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground1].value( + themeMode + ) + ) + ) + for(i in 0..3) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 10.dp, vertical = 3.dp)) { + BasicText( + "Text", + style = TextStyle( + color = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground1].value( + themeMode + ) + ) + ) + Spacer(Modifier.width(20.dp)) + RadioButton( + onClick = { + selectRadioGroupButton(i, checkBoxSelectedValues) + }, + selected = checkBoxSelectedValues[i].value + ) + } + } + } + } } } FluentTheme { @@ -311,3 +349,9 @@ class V2ButtonsActivity : V2DemoActivity() { } } } + +fun selectRadioGroupButton(buttonNumber: Int, saveableCheckbox: List>){ + saveableCheckbox.forEachIndexed { index, mutableState -> + mutableState.value = index == buttonNumber + } +} diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2CardActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2CardActivity.kt index ab995d96f..f4822d8fe 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2CardActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2CardActivity.kt @@ -184,7 +184,7 @@ class V2CardActivity : V2DemoActivity() { Box( modifier = Modifier .clickable( - interactionSource = MutableInteractionSource(), + interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(), enabled = true, onClick = { }, diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ContextualCommandBarActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ContextualCommandBarActivity.kt index b00d6b826..f59db9b22 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ContextualCommandBarActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ContextualCommandBarActivity.kt @@ -5,6 +5,7 @@ import android.os.Bundle import android.view.KeyEvent.KEYCODE_DPAD_DOWN import android.view.KeyEvent.KEYCODE_DPAD_RIGHT import android.widget.Toast +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -47,9 +48,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import com.microsoft.fluentui.theme.FluentTheme 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.controlTokens.ButtonStyle import com.microsoft.fluentui.tokenized.contextualcommandbar.ActionButtonPosition @@ -255,7 +258,14 @@ class V2ContextualCommandBarActivity : V2DemoActivity() { CenterHorizontally ), verticalAlignment = CenterVertically ) { - BasicText(text = "Action Button") + BasicText( + text = "Action Button", + style = FluentTheme.aliasTokens.typography[FluentAliasTokens.TypographyTokens.Body1].merge( + TextStyle(color = FluentTheme.aliasTokens.neutralForegroundColor[Foreground2].value( + themeMode = FluentTheme.themeMode + )) + ), + ) ToggleSwitch( onValueChange = { @@ -321,6 +331,7 @@ class V2ContextualCommandBarActivity : V2DemoActivity() { } } .padding(start = 8.dp) + .background(Color.White), ) } diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2DrawerActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2DrawerActivity.kt index 6338e16d3..b6f0ec5c4 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2DrawerActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2DrawerActivity.kt @@ -15,8 +15,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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 @@ -73,20 +73,20 @@ enum class ContentType { @Composable private fun CreateActivityUI() { - var scrimVisible by remember { mutableStateOf(true) } - var dynamicSizeContent by remember { mutableStateOf(false) } - var nestedDrawerContent by remember { mutableStateOf(false) } - var listContent by remember { mutableStateOf(true) } - var preventDismissalOnScrimClick by remember { mutableStateOf(false) } - var selectedContent by remember { mutableStateOf(ContentType.FULL_SCREEN_SCROLLABLE_CONTENT) } - var selectedBehaviorType by remember { mutableStateOf(BehaviorType.BOTTOM_SLIDE_OVER) } - var relativeToParentAnchor by remember { + var scrimVisible by rememberSaveable { mutableStateOf(true) } + var dynamicSizeContent by rememberSaveable { mutableStateOf(false) } + var nestedDrawerContent by rememberSaveable { mutableStateOf(false) } + var listContent by rememberSaveable { mutableStateOf(true) } + var preventDismissalOnScrimClick by rememberSaveable { mutableStateOf(false) } + var selectedContent by rememberSaveable { mutableStateOf(ContentType.FULL_SCREEN_SCROLLABLE_CONTENT) } + var selectedBehaviorType by rememberSaveable { mutableStateOf(BehaviorType.BOTTOM_SLIDE_OVER) } + var relativeToParentAnchor by rememberSaveable { mutableStateOf( false ) } - var offsetX by remember { mutableIntStateOf(0) } - var offsetY by remember { mutableIntStateOf(0) } + var offsetX by rememberSaveable { mutableIntStateOf(0) } + var offsetY by rememberSaveable { mutableIntStateOf(0) } Column { if (relativeToParentAnchor) { Row( diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ListItemActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ListItemActivity.kt index f00359e51..dd69d23cf 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ListItemActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ListItemActivity.kt @@ -8,11 +8,15 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.KeyboardArrowRight import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -22,6 +26,7 @@ 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.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.SpanStyle @@ -33,6 +38,7 @@ import com.microsoft.fluentui.theme.FluentTheme.aliasTokens import com.microsoft.fluentui.theme.FluentTheme.themeMode import com.microsoft.fluentui.theme.token.FluentAliasTokens import com.microsoft.fluentui.theme.token.FluentGlobalTokens +import com.microsoft.fluentui.theme.token.FluentIcon import com.microsoft.fluentui.theme.token.Icon import com.microsoft.fluentui.theme.token.controlTokens.AvatarSize import com.microsoft.fluentui.theme.token.controlTokens.AvatarSize.Size24 @@ -108,6 +114,7 @@ private fun CreateListActivityUI(context: Context) { ListItem.Item( text = primaryText, onClick = {}, + onLongClick = { invokeToast("ListItem Long", context) }, border = BorderType.Bottom, borderInset = XXLarge, primaryTextTrailingContent = { Icon20() } @@ -115,6 +122,7 @@ private fun CreateListActivityUI(context: Context) { ListItem.Item( text = primaryText, onClick = {}, + onLongClick = { invokeToast("ListItem Long", context) }, subText = secondaryText, border = BorderType.Bottom, borderInset = XXLarge, @@ -123,6 +131,7 @@ private fun CreateListActivityUI(context: Context) { ListItem.Item( text = primaryText, onClick = {}, + onLongClick = { invokeToast("ListItem Long", context) }, subText = secondaryText, secondarySubText = tertiaryText, border = BorderType.Bottom, @@ -275,10 +284,30 @@ private fun CreateListActivityUI(context: Context) { border = BorderType.Bottom ) ListItem.SectionDescription(description = "Centered action text only supports primary text and ignores any given trailing or leading accessory Contents") + GroupedList() } } } +@Composable +private fun GroupedList() { + ListItem.Header("Grouped List") + ListItem.SectionDescription(description = "Grouped List", modifier = Modifier.height(25.dp)) + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp).clip( + RoundedCornerShape(10.dp))) { + for(i in 0..3) { + ListItem.Item( + text = "Text", + onClick = {}, + textAlignment = ListItemTextAlignment.Regular, + border = BorderType.Bottom, + trailingAccessoryContent = { Icon(icon = FluentIcon(Icons.Outlined.KeyboardArrowRight))}, + ) + } + } + ListItem.SectionDescription(description = "Grouped list containing multiple similar elements", modifier = Modifier.wrapContentHeight().padding(0.dp)) +} + @Composable private fun OneLineSimpleList() { return Column { @@ -393,11 +422,13 @@ private fun OneLineListAccessoryContentContent(context: Context) { }, border = BorderType.Bottom, borderInset = XXLarge, - onClick = { checked = !checked } + onClick = { checked = !checked }, + onLongClick = { invokeToast("ListItem Long", context) } ) ListItem.Item( text = primaryText, onClick = {}, + onLongClick = { invokeToast("ListItem Long", context) }, leadingAccessoryContent = { RightContentButton( size = ButtonSize.Small, @@ -417,6 +448,7 @@ private fun OneLineListAccessoryContentContent(context: Context) { ListItem.Item( text = primaryText, onClick = {}, + onLongClick = { invokeToast("ListItem Long", context) }, leadingAccessoryContent = { LeftContentAvatar(size = Size24) }, border = BorderType.Bottom, borderInset = XXLarge @@ -430,6 +462,7 @@ private fun OneLineListAccessoryContentContent(context: Context) { ListItem.Item( text = "", onClick = {}, + onLongClick = { invokeToast("ListItem Long", context) }, leadingAccessoryContent = { LeftContentThreeIcon() }, border = BorderType.Bottom, borderInset = XXLarge @@ -437,6 +470,7 @@ private fun OneLineListAccessoryContentContent(context: Context) { ListItem.Item( text = primaryText, onClick = {}, + onLongClick = { invokeToast("ListItem Long", context) }, leadingAccessoryContent = { LeftContentRadioButton() }, trailingAccessoryContent = { RightContentAvatarStack(Size24) }, border = BorderType.Bottom, @@ -445,6 +479,7 @@ private fun OneLineListAccessoryContentContent(context: Context) { ListItem.Item( text = primaryText, onClick = {}, + onLongClick = { invokeToast("ListItem Long", context) }, leadingAccessoryContent = { LeftContentThreeButton() }, trailingAccessoryContent = { RightContentToggle() }, border = BorderType.Bottom, @@ -462,6 +497,7 @@ private fun TwoLineListAccessoryContentContent(context: Context) { text = primaryText, secondarySubText = tertiaryText, onClick = {}, + onLongClick = { invokeToast("ListItem Long", context) }, leadingAccessoryContent = { LeftContentAvatar(size = Size40) }, trailingAccessoryContent = { LeftContentAvatar(size = Size40) }, border = BorderType.Bottom, @@ -477,6 +513,7 @@ private fun TwoLineListAccessoryContentContent(context: Context) { borderInset = XXLarge, unreadDot = unreadDot1, onClick = { unreadDot1 = !unreadDot1 }, + onLongClick = { invokeToast("ListItem Long", context) }, primaryTextTrailingContent = { Icon20() }, secondarySubTextTrailingContent = { Icon16() } ) @@ -488,6 +525,7 @@ private fun TwoLineListAccessoryContentContent(context: Context) { borderInset = XXLarge, unreadDot = unreadDot2, onClick = { unreadDot2 = !unreadDot2 }, + onLongClick = { invokeToast("ListItem Long", context) }, primaryTextTrailingContent = { Icon20() }, secondarySubTextTrailingContent = { Icon16() } ) @@ -495,6 +533,7 @@ private fun TwoLineListAccessoryContentContent(context: Context) { text = primaryText, secondarySubText = tertiaryText, onClick = {}, + onLongClick = { invokeToast("ListItem Long", context) }, leadingAccessoryContent = { LeftContentFolderIcon40() }, primaryTextLeadingContent = { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -516,6 +555,7 @@ private fun TwoLineListAccessoryContentContent(context: Context) { text = primaryText, subText = secondaryText, onClick = {}, + onLongClick = { invokeToast("ListItem Long", context) }, leadingAccessoryContent = { LeftContentFolderIcon40() }, trailingAccessoryContent = { RightContentAvatarStack(Size40) }, border = BorderType.Bottom, @@ -526,6 +566,7 @@ private fun TwoLineListAccessoryContentContent(context: Context) { secondarySubText = tertiaryText, leadingAccessoryContent = { LeftContentFolderIcon40() }, onClick = {}, + onLongClick = { invokeToast("ListItem Long", context) }, secondarySubTextLeadingContent = { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon16() @@ -551,6 +592,7 @@ private fun TwoLineListAccessoryContentContent(context: Context) { text = primaryText, secondarySubText = tertiaryText, onClick = {}, + onLongClick = { invokeToast("ListItem Long", context) }, leadingAccessoryContent = { LeftContentFolderIcon40() }, secondarySubTextTrailingContent = { Icon16() }, trailingAccessoryContent = { RightContentToggle() }, @@ -561,6 +603,7 @@ private fun TwoLineListAccessoryContentContent(context: Context) { text = primaryText, subText = secondaryText, onClick = {}, + onLongClick = { invokeToast("ListItem Long", context) }, leadingAccessoryContent = { LeftContentThreeButton() }, trailingAccessoryContent = { RightContentText("Value") }, border = BorderType.Bottom, @@ -587,6 +630,7 @@ private fun ThreeLineListAccessoryContentContent( subText = "Wanda can you please update the file with comments", secondarySubTextAnnotated = footer, onClick = {}, + onLongClick = { invokeToast("ListItem Long", context) }, leadingAccessoryContent = { LeftContentAvatar(size = Size56) }, trailingAccessoryContent = { rightContentIconButton() }, primaryTextTrailingContent = { Badge(text = "2") }, @@ -602,6 +646,7 @@ private fun ThreeLineListAccessoryContentContent( subText = secondaryText, secondarySubText = tertiaryText, onClick = {}, + onLongClick = { invokeToast("ListItem Long", context) }, leadingAccessoryContent = { LeftContentFolderIcon40() }, primaryTextTrailingContent = { Badge(text = "Suggested") }, trailingAccessoryContent = { @@ -618,6 +663,7 @@ private fun ThreeLineListAccessoryContentContent( subText = secondaryText, bottomContent = { LinearProgressIndicator() }, onClick = {}, + onLongClick = { invokeToast("ListItem Long", context) }, primaryTextLeadingContent = { Icon20() }, secondarySubTextTrailingContent = { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2MenuActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2MenuActivity.kt index 3f8f32426..705b3ccc2 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2MenuActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2MenuActivity.kt @@ -3,6 +3,7 @@ package com.microsoft.fluentuidemo.demos import android.content.Context import android.content.res.Configuration import android.os.Bundle +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,6 +22,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardType @@ -35,6 +37,7 @@ import com.microsoft.fluentui.tokenized.menu.Menu import com.microsoft.fluentuidemo.R import com.microsoft.fluentuidemo.V2DemoActivity +val DefaultMenuInputWidthFraction = 0.35f class V2MenuActivity : V2DemoActivity() { init { @@ -64,29 +67,37 @@ fun CreateMenuActivityUI(context: Context) { Column { Column { ListItem.Header(title = context.getString(R.string.menu_xOffset), + titleMaxLines = 2, trailingAccessoryContent = { BasicTextField(value = xOffsetState.value, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - onValueChange = { xOffsetState.value = it.trim() }) + onValueChange = { xOffsetState.value = it.trim() }, + modifier = Modifier.background(Color.White).fillMaxWidth(fraction = DefaultMenuInputWidthFraction)) } ) ListItem.Header(title = context.getString(R.string.menu_yOffset), + titleMaxLines = 2, trailingAccessoryContent = { BasicTextField(value = yOffsetState.value, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - onValueChange = { yOffsetState.value = it.trim() }) + onValueChange = { yOffsetState.value = it.trim() }, + modifier = Modifier.background(Color.White).fillMaxWidth(fraction = DefaultMenuInputWidthFraction)) }) ListItem.Header(title = context.getString(R.string.menu_content_text), + titleMaxLines = 2, trailingAccessoryContent = { BasicTextField( value = contentTextState.value, - onValueChange = { contentTextState.value = it }) + onValueChange = { contentTextState.value = it }, + modifier = Modifier.background(Color.White).fillMaxWidth(fraction = DefaultMenuInputWidthFraction)) }) ListItem.Header(title = context.getString(R.string.menu_repeat_content_text), + titleMaxLines = 2, trailingAccessoryContent = { BasicTextField(value = repeatContentTextCountState.value, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - onValueChange = { repeatContentTextCountState.value = it.trim() }) + onValueChange = { repeatContentTextCountState.value = it.trim() }, + modifier = Modifier.background(Color.White).fillMaxWidth(fraction = DefaultMenuInputWidthFraction)) }) ListItem.SectionDescription(description = context.getString(R.string.menu_description)) } diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ScaffoldActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ScaffoldActivity.kt index 2c37386fd..a4ac69dab 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ScaffoldActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ScaffoldActivity.kt @@ -160,6 +160,7 @@ class V2ScaffoldActivity : V2DemoActivity() { } } + @OptIn(ExperimentalLayoutApi::class) @Composable private fun GetContent(context: Context, snackbarState: SnackbarState? = null) { val size = remember { mutableStateOf(5) } @@ -171,7 +172,7 @@ class V2ScaffoldActivity : V2DemoActivity() { drawerState = drawerState, drawerContent = { CreateList(size = 20, context = context) } ) - Row( + FlowRow( modifier = Modifier.padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SearchBarActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SearchBarActivity.kt index 1ca65c256..be34ebb5e 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SearchBarActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SearchBarActivity.kt @@ -4,6 +4,7 @@ import android.content.Context import android.os.Bundle import android.widget.Toast import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.getValue @@ -12,11 +13,12 @@ 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.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp import com.microsoft.fluentui.icons.SearchBarIcons import com.microsoft.fluentui.icons.searchbaricons.Office import com.microsoft.fluentui.theme.token.FluentIcon @@ -30,6 +32,7 @@ import com.microsoft.fluentui.tokenized.listitem.ListItem import com.microsoft.fluentui.tokenized.persona.Person import com.microsoft.fluentui.tokenized.persona.Persona import com.microsoft.fluentui.tokenized.persona.PersonaList +import com.microsoft.fluentuidemo.CustomizedSearchBarTokens import com.microsoft.fluentuidemo.R import com.microsoft.fluentuidemo.V2DemoActivity import com.microsoft.fluentuidemo.util.DemoAppStrings @@ -45,7 +48,6 @@ class V2SearchBarActivity : V2DemoActivity() { override val paramsUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#params-29" override val controlTokensUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-27" - @OptIn(ExperimentalComposeUiApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -56,8 +58,8 @@ class V2SearchBarActivity : V2DemoActivity() { var searchBarStyle: FluentStyle by rememberSaveable { mutableStateOf(FluentStyle.Neutral) } var displayRightAccessory: Boolean by rememberSaveable { mutableStateOf(true) } var induceDelay: Boolean by rememberSaveable { mutableStateOf(false) } - var selectedPeople: Person? by rememberSaveable { mutableStateOf(null) } + var customizedSearchBar: Boolean by rememberSaveable { mutableStateOf(false) } val listofPeople = listOf( Person( @@ -184,6 +186,22 @@ class V2SearchBarActivity : V2DemoActivity() { ) } ) + + ListItem.Item( + text = "Customized Search Bar", + subText = if (customizedSearchBar) + LocalContext.current.resources.getString(R.string.fluentui_enabled) + else + LocalContext.current.resources.getString(R.string.fluentui_disabled), + trailingAccessoryContent = { + ToggleSwitch( + onValueChange = { + customizedSearchBar = it + }, + checkedState = customizedSearchBar + ) + } + ) } } @@ -195,6 +213,7 @@ class V2SearchBarActivity : V2DemoActivity() { val scope = rememberCoroutineScope() var loading by rememberSaveable { mutableStateOf(false) } val keyboardController = LocalSoftwareKeyboardController.current + val showCustomizedAppBar = searchBarStyle == FluentStyle.Neutral && customizedSearchBar SearchBar( onValueChange = { query, selectedPerson -> @@ -251,7 +270,11 @@ class V2SearchBarActivity : V2DemoActivity() { .show() } ) - } else null + } else null, + searchBarTokens = if (showCustomizedAppBar) { + CustomizedSearchBarTokens + } else null, + modifier = if (showCustomizedAppBar) Modifier.requiredHeight(60.dp) else Modifier ) val filteredPersona = mutableListOf() diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SegmentedControlActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SegmentedControlActivity.kt index f1e00772d..3940c548c 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SegmentedControlActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2SegmentedControlActivity.kt @@ -2,14 +2,19 @@ package com.microsoft.fluentuidemo.demos import android.os.Bundle import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.* import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.microsoft.fluentui.icons.AvatarIcons @@ -25,20 +30,25 @@ import com.microsoft.fluentui.tokenized.listitem.ChevronOrientation import com.microsoft.fluentui.tokenized.listitem.ListItem import com.microsoft.fluentui.tokenized.segmentedcontrols.* import com.microsoft.fluentuidemo.V2DemoActivity +import kotlinx.coroutines.launch +import com.microsoft.fluentui.tokenized.navigation.ViewPager // Tags used for testing const val SEGMENTED_CONTROL_PILL_BUTTON = "Segmented Control Pill Button" const val SEGMENTED_CONTROL_PILL_BAR = "Segmented Control Pill Bar" const val SEGMENTED_CONTROL_TABS = "Segmented Control Tabs" const val SEGMENTED_CONTROL_SWITCH = "Segmented Control Switch" +const val SEGMENTED_CONTROL_VIEW_PAGER = "Segmented Control View Pager" const val SEGMENTED_CONTROL_PILL_BUTTON_TOGGLE = "Segmented Control Pill Button Toggle" const val SEGMENTED_CONTROL_PILL_BAR_TOGGLE = "Segmented Control Pill Bar Toggle" const val SEGMENTED_CONTROL_TABS_TOGGLE = "Segmented Control Tabs Toggle" const val SEGMENTED_CONTROL_SWITCH_TOGGLE = "Segmented Control Switch Toggle" +const val SEGMENTED_CONTROL_VIEW_PAGER_TOGGLE = "Segmented Control View Pager Toggle" const val SEGMENTED_CONTROL_PILL_BUTTON_COMPONENT = "Segmented Control Pill Button Component" const val SEGMENTED_CONTROL_PILL_BAR_COMPONENT = "Segmented Control Pill Bar Component" const val SEGMENTED_CONTROL_TABS_COMPONENT = "Segmented Control Tabs Component" const val SEGMENTED_CONTROL_SWITCH_COMPONENT = "Segmented Control Switch Component" +const val SEGMENTED_CONTROL_VIEW_PAGER_COMPONENT = "Segmented Control View pager Component" class V2SegmentedControlActivity : V2DemoActivity() { @@ -50,6 +60,7 @@ class V2SegmentedControlActivity : V2DemoActivity() { override val controlTokensUrl = "https://github.com/microsoft/fluentui-android/wiki/Controls#control-tokens-28" + @OptIn(ExperimentalFoundationApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val context = this @@ -229,8 +240,10 @@ class V2SegmentedControlActivity : V2DemoActivity() { item { var enableTabs by rememberSaveable { mutableStateOf(true) } var selectedTab by rememberSaveable { mutableStateOf(0) } + val pagerState = rememberPagerState(pageCount = { 6 }) + val coroutineScope = rememberCoroutineScope() - var tabsList: MutableList = mutableListOf() + val tabsList: MutableList = mutableListOf() for (idx in 0..5) { val label = "Neutral ${idx + 1}" @@ -245,6 +258,10 @@ class V2SegmentedControlActivity : V2DemoActivity() { Toast.LENGTH_SHORT ).show() selectedTab = idx + coroutineScope.launch { + // Call scroll to on pagerState + pagerState.animateScrollToPage(idx) + } }, enabled = enableTabs, notificationDot = selectedTab != idx @@ -296,6 +313,66 @@ class V2SegmentedControlActivity : V2DemoActivity() { } } ) + + template( + "View Pager", + testTag = SEGMENTED_CONTROL_VIEW_PAGER, + enableSwitch = { + ToggleSwitch( + Modifier + .padding(vertical = 3.dp) + .testTag(SEGMENTED_CONTROL_VIEW_PAGER_TOGGLE), + onValueChange = { enableTabs = it }, + checkedState = enableTabs + ) + }, + neutralContent = { + Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { + PillTabs( + modifier = Modifier.testTag(SEGMENTED_CONTROL_VIEW_PAGER_COMPONENT), + metadataList = tabsList.subList(0, 4), + selectedIndex = selectedTab, + scrollable = true + ) + PillTabs( + tabsList.subList(0, 4), + style = FluentStyle.Brand, + selectedIndex = selectedTab, + scrollable = false + ) + } + }, + brandContent = { + Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { + PillTabs( + tabsList, + selectedIndex = selectedTab, + scrollable = true + ) + PillTabs( + tabsList, + style = FluentStyle.Brand, + selectedIndex = selectedTab, + scrollable = false + ) + ViewPager(pagerState, pageContent = { + Box( + Modifier + .fillMaxSize() + .background( + color = if (selectedTab % 2 == 0) Color.Cyan else Color.LightGray + ) + ) { + BasicText( + text = "Page $selectedTab", + modifier = Modifier.align(Alignment.Center) + ) + } + }, modifier = Modifier.height(200.dp), userScrollEnabled = true) + } + + } + ) } item { @@ -369,6 +446,7 @@ class V2SegmentedControlActivity : V2DemoActivity() { } ) } + } } } diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ShimmerActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ShimmerActivity.kt index a2d2d8278..fb08e9e4e 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ShimmerActivity.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2ShimmerActivity.kt @@ -2,32 +2,52 @@ package com.microsoft.fluentuidemo.demos import android.os.Bundle import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.material.ListItem import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp +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.FluentStyle import com.microsoft.fluentui.theme.token.controlTokens.AvatarSize import com.microsoft.fluentui.theme.token.controlTokens.AvatarStatus import com.microsoft.fluentui.theme.token.controlTokens.BadgeInfo import com.microsoft.fluentui.theme.token.controlTokens.BadgeTokens +import com.microsoft.fluentui.theme.token.controlTokens.ButtonStyle import com.microsoft.fluentui.theme.token.controlTokens.ColorStyle import com.microsoft.fluentui.theme.token.controlTokens.PersonaChipSize import com.microsoft.fluentui.theme.token.controlTokens.PersonaChipStyle import com.microsoft.fluentui.theme.token.controlTokens.ShimmerInfo +import com.microsoft.fluentui.theme.token.controlTokens.ShimmerOrientation import com.microsoft.fluentui.theme.token.controlTokens.ShimmerTokens +import com.microsoft.fluentui.tokenized.controls.Button import com.microsoft.fluentui.tokenized.controls.Label +import com.microsoft.fluentui.tokenized.controls.RadioButton +import com.microsoft.fluentui.tokenized.controls.ToggleSwitch import com.microsoft.fluentui.tokenized.notification.Badge import com.microsoft.fluentui.tokenized.persona.Avatar import com.microsoft.fluentui.tokenized.persona.Person @@ -51,9 +71,31 @@ class V2ShimmerActivity : V2DemoActivity() { CreateShimmerActivityUI() } } + private fun getShimmerOrientation(shimmerOrientation: Int): ShimmerOrientation { + return when (shimmerOrientation) { + 0 -> ShimmerOrientation.LEFT_TO_RIGHT + 1 -> ShimmerOrientation.RIGHT_TO_LEFT + 2 -> ShimmerOrientation.TOPLEFT_TO_BOTTOMRIGHT + 3 -> ShimmerOrientation.BOTTOMRIGHT_TO_TOPLEFT + else -> ShimmerOrientation.LEFT_TO_RIGHT + } + } @Composable private fun CreateShimmerActivityUI() { + var shimmerOrientation by rememberSaveable { mutableStateOf(0) } + var isShimmering by rememberSaveable { mutableStateOf(true) } + val shimmerTokens = object: ShimmerTokens(){ + @Composable + override fun delay(shimmerInfo: ShimmerInfo): Int { + return 1000 + } + + @Composable + override fun orientation(shimmerInfo: ShimmerInfo): ShimmerOrientation { + return getShimmerOrientation(shimmerOrientation) + } + } Column( Modifier .padding(all = 12.dp) @@ -70,16 +112,16 @@ class V2ShimmerActivity : V2DemoActivity() { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Shimmer(modifier = Modifier.size(120.dp, 80.dp)) + Shimmer(modifier = Modifier.size(120.dp, 80.dp), shimmerTokens = shimmerTokens, isShimmering = isShimmering, shimmerOrientation = getShimmerOrientation(shimmerOrientation)) Column( Modifier .height(80.dp) .padding(top = 10.dp, bottom = 10.dp), verticalArrangement = Arrangement.SpaceBetween ) { - Shimmer(modifier = Modifier.size(140.dp, 12.dp)) - Shimmer(modifier = Modifier.size(180.dp, 12.dp)) - Shimmer(modifier = Modifier.size(200.dp, 12.dp)) + Shimmer(modifier = Modifier.size(140.dp, 12.dp), shimmerTokens = shimmerTokens, isShimmering = isShimmering, shimmerOrientation = getShimmerOrientation(shimmerOrientation)) + Shimmer(modifier = Modifier.size(180.dp, 12.dp), shimmerTokens = shimmerTokens, isShimmering = isShimmering, shimmerOrientation = getShimmerOrientation(shimmerOrientation)) + Shimmer(modifier = Modifier.size(200.dp, 12.dp), shimmerTokens = shimmerTokens, isShimmering = isShimmering, shimmerOrientation = getShimmerOrientation(shimmerOrientation)) } } Label( @@ -94,15 +136,15 @@ class V2ShimmerActivity : V2DemoActivity() { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Shimmer(modifier = Modifier.size(60.dp, 60.dp).clip(RoundedCornerShape(50.dp))) + Shimmer(modifier = Modifier.size(60.dp, 60.dp).clip(RoundedCornerShape(50.dp)), shimmerTokens = shimmerTokens, isShimmering = isShimmering, shimmerOrientation = getShimmerOrientation(shimmerOrientation)) Column( Modifier .height(80.dp) .padding(top = 10.dp, bottom = 10.dp), verticalArrangement = Arrangement.SpaceBetween ) { - Shimmer(modifier = Modifier.size(180.dp, 12.dp)) - Shimmer(modifier = Modifier.size(180.dp, 12.dp)) + Shimmer(modifier = Modifier.size(180.dp, 12.dp), shimmerTokens = shimmerTokens, isShimmering = isShimmering, shimmerOrientation = getShimmerOrientation(shimmerOrientation)) + Shimmer(modifier = Modifier.size(180.dp, 12.dp), shimmerTokens = shimmerTokens, isShimmering = isShimmering, shimmerOrientation = getShimmerOrientation(shimmerOrientation)) } } Row( @@ -112,15 +154,15 @@ class V2ShimmerActivity : V2DemoActivity() { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Shimmer(modifier = Modifier.size(60.dp, 60.dp).clip(RoundedCornerShape(50.dp))) + Shimmer(modifier = Modifier.size(60.dp, 60.dp).clip(RoundedCornerShape(50.dp)), shimmerTokens = shimmerTokens, isShimmering = isShimmering, shimmerOrientation = getShimmerOrientation(shimmerOrientation)) Column( Modifier .height(80.dp) .padding(top = 10.dp, bottom = 10.dp), verticalArrangement = Arrangement.SpaceBetween ) { - Shimmer(modifier = Modifier.size(140.dp, 12.dp)) - Shimmer(modifier = Modifier.size(180.dp, 12.dp)) + Shimmer(modifier = Modifier.size(140.dp, 12.dp), shimmerTokens = shimmerTokens, isShimmering = isShimmering, shimmerOrientation = getShimmerOrientation(shimmerOrientation)) + Shimmer(modifier = Modifier.size(180.dp, 12.dp), shimmerTokens = shimmerTokens, isShimmering = isShimmering, shimmerOrientation = getShimmerOrientation(shimmerOrientation)) } } Row( @@ -130,15 +172,15 @@ class V2ShimmerActivity : V2DemoActivity() { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Shimmer(modifier = Modifier.size(60.dp, 60.dp).clip(RoundedCornerShape(50.dp))) + Shimmer(modifier = Modifier.size(60.dp, 60.dp).clip(RoundedCornerShape(50.dp)), shimmerTokens = shimmerTokens, isShimmering = isShimmering, shimmerOrientation = getShimmerOrientation(shimmerOrientation)) Column( Modifier .height(80.dp) .padding(top = 10.dp, bottom = 10.dp), verticalArrangement = Arrangement.SpaceBetween ) { - Shimmer(modifier = Modifier.size(140.dp, 12.dp)) - Shimmer(modifier = Modifier.size(180.dp, 12.dp)) + Shimmer(modifier = Modifier.size(140.dp, 12.dp), shimmerTokens = shimmerTokens, isShimmering = isShimmering, shimmerOrientation = getShimmerOrientation(shimmerOrientation)) + Shimmer(modifier = Modifier.size(180.dp, 12.dp), shimmerTokens = shimmerTokens, isShimmering = isShimmering, shimmerOrientation = getShimmerOrientation(shimmerOrientation)) } } class ShimmerGoldEffectToken : ShimmerTokens() { @@ -176,7 +218,7 @@ class V2ShimmerActivity : V2DemoActivity() { Label(text = "Badge", textStyle = FluentAliasTokens.TypographyTokens.Body1) Shimmer(content = { Badge(text = "Badge", badgeTokens = BadgeColorToken()) - }, shimmerTokens = ShimmerGoldEffectToken(), cornerRadius = 100.dp) + }, shimmerTokens = ShimmerGoldEffectToken() , cornerRadius = 100.dp, isShimmering = isShimmering, shimmerOrientation = getShimmerOrientation(shimmerOrientation)) } Row( modifier = Modifier @@ -192,7 +234,7 @@ class V2ShimmerActivity : V2DemoActivity() { size = PersonaChipSize.Small, style = PersonaChipStyle.Brand ) - }) + }, shimmerTokens = shimmerTokens, isShimmering = isShimmering, shimmerOrientation = getShimmerOrientation(shimmerOrientation)) } Row( modifier = Modifier @@ -209,10 +251,94 @@ class V2ShimmerActivity : V2DemoActivity() { status = AvatarStatus.Available, ), size = AvatarSize.Size72, enableActivityRings = false ) - }) + }, shimmerTokens = shimmerTokens, isShimmering = isShimmering, shimmerOrientation = getShimmerOrientation(shimmerOrientation)) + } + Label( + text = "Change Orientation", + textStyle = FluentAliasTokens.TypographyTokens.Title2, + colorStyle = ColorStyle.Brand + ) + Spacer( modifier = Modifier.height(10.dp)) + SelectionRow( + text = "Left to Right", + testTag = "Left to Right", + selected = shimmerOrientation == 0, + onClick = { shimmerOrientation = 0 } + ) + SelectionRow( + text = "Right to Left", + testTag = "Right to Left", + selected = shimmerOrientation == 1, + onClick = { shimmerOrientation = 1 } + ) + SelectionRow( + text = "Top Left to Bottom Right", + testTag = "Top Left to Bottom Right", + selected = shimmerOrientation == 2, + onClick = { shimmerOrientation = 2 } + ) + SelectionRow( + text = "Bottom Right to Top Left", + testTag = "Bottom Right to Top Left", + selected = shimmerOrientation == 3, + onClick = { shimmerOrientation = 3 } + ) + Spacer(modifier = Modifier.height(10.dp)) + Label( + text = "Toggle Animation", + textStyle = FluentAliasTokens.TypographyTokens.Title2, + colorStyle = ColorStyle.Brand + ) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable { + isShimmering = !isShimmering + } + ) { + BasicText( + text = "Toggle Shimmer Animation", + modifier = Modifier.weight(1F), + style = TextStyle( + color = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground3].value( + themeMode = ThemeMode.Auto + ) + ) + ) + ToggleSwitch( + onValueChange = { isShimmering = !isShimmering }, + checkedState = isShimmering + ) } - } } + @Composable + private fun SelectionRow( + text: String, + testTag: String, + selected: Boolean, + onClick: () -> Unit + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + BasicText( + text = text, + modifier = Modifier.weight(1F), + style = TextStyle( + color = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground3].value( + themeMode = ThemeMode.Auto + ) + ) + ) + RadioButton( + modifier = Modifier.testTag(testTag), + selected = selected, + onClick = onClick + ) + } + } } \ No newline at end of file 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 9739fac84..6dfef1aa1 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 @@ -2,6 +2,10 @@ package com.microsoft.fluentuidemo.demos import android.os.Bundle import android.widget.Toast +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -25,6 +29,8 @@ import com.microsoft.fluentui.tokenized.controls.Button import com.microsoft.fluentui.tokenized.controls.ToggleSwitch import com.microsoft.fluentui.tokenized.listitem.ChevronOrientation import com.microsoft.fluentui.tokenized.listitem.ListItem +import com.microsoft.fluentui.tokenized.notification.AnimationBehavior +import com.microsoft.fluentui.tokenized.notification.AnimationVariables import com.microsoft.fluentui.tokenized.notification.NotificationDuration import com.microsoft.fluentui.tokenized.notification.NotificationResult import com.microsoft.fluentui.tokenized.notification.Snackbar @@ -272,7 +278,8 @@ class V2SnackbarActivity : V2DemoActivity() { actionText = if (actionLabel) actionButtonString else null, subTitle = subtitle, duration = duration, - enableDismiss = dismissEnabled + enableDismiss = dismissEnabled, + animationBehavior = customizedAnimationBehavior ) when (result) { @@ -304,7 +311,7 @@ class V2SnackbarActivity : V2DemoActivity() { Button( onClick = { - snackbarState.currentSnackbar?.dismiss() + snackbarState.currentSnackbar?.dismiss(scope) }, text = LocalContext.current.resources.getString(R.string.fluentui_dismiss_snackbar), size = ButtonSize.Small, @@ -313,9 +320,42 @@ class V2SnackbarActivity : V2DemoActivity() { ) } Box(Modifier.fillMaxHeight(), contentAlignment = Alignment.Center) { - Snackbar(snackbarState, Modifier.padding(bottom = 12.dp)) + Snackbar(snackbarState, Modifier.padding(bottom = 12.dp), null, true) } } } } +} + +// Customized animation behavior for Snackbar +val customizedAnimationBehavior: AnimationBehavior = object : AnimationBehavior() { + override var animationVariables: AnimationVariables = object : AnimationVariables() { + override var scale = Animatable(1F) + override var offsetY = Animatable(50F) + } + + override suspend fun onShowAnimation() { + // pop from bottom + animationVariables.alpha.snapTo(1F) + animationVariables.offsetX.snapTo(0F) + animationVariables.offsetY.snapTo(50F) + animationVariables.offsetY.animateTo( + 0F, + animationSpec = tween( + easing = LinearOutSlowInEasing, + durationMillis = 500, + ) + ) + } + + override suspend fun onDismissAnimation() { + // slide out from left + animationVariables.offsetX.animateTo( + targetValue = -2000f, + animationSpec = tween( + durationMillis = 500, + easing = FastOutSlowInEasing + ) + ) + } } \ No newline at end of file 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 088dc5d80..4e1a0d767 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 @@ -8,6 +8,7 @@ import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -63,7 +64,7 @@ class V2TabBarActivity : V2DemoActivity() { setActivityContent { val content = listOf(0, 1, 2) - var selectedOption by rememberSaveable { mutableStateOf(content[0]) } + var selectedOption by rememberSaveable { mutableIntStateOf(content[0]) } val tabItemsCount = _tabItemsCount.observeAsState(initial = 5) var showIndicator by rememberSaveable { mutableStateOf(false) @@ -205,7 +206,8 @@ class V2TabBarActivity : V2DemoActivity() { selectedIndex = 0 showHomeBadge = false }, - badge = { if (selectedIndex == 0 && showHomeBadge) Badge() } + badge = { if (selectedIndex == 0 && showHomeBadge) Badge() }, + accessibilityDescription = resources.getString(R.string.tabBar_home) + ": " + if(selectedIndex == 0) {resources.getString(R.string.Active)} else {resources.getString(R.string.Inactive)} ), TabData( title = resources.getString(R.string.tabBar_mail), @@ -215,7 +217,8 @@ class V2TabBarActivity : V2DemoActivity() { invokeToast(resources.getString(R.string.tabBar_mail), context) selectedIndex = 1 }, - badge = { Badge(text = "123+", badgeType = BadgeType.Character) } + badge = { Badge(text = "123+", badgeType = BadgeType.Character) }, + accessibilityDescription = resources.getString(R.string.tabBar_mail) + ": " + if(selectedIndex == 1) {resources.getString(R.string.Active)} else {resources.getString(R.string.Inactive)} ), TabData( title = resources.getString(R.string.tabBar_settings), @@ -224,7 +227,8 @@ class V2TabBarActivity : V2DemoActivity() { onClick = { invokeToast(resources.getString(R.string.tabBar_settings), context) selectedIndex = 2 - } + }, + accessibilityDescription = resources.getString(R.string.tabBar_settings) + ": " + if(selectedIndex == 2) {resources.getString(R.string.Active)} else {resources.getString(R.string.Inactive)} ), TabData( title = resources.getString(R.string.tabBar_notification), @@ -234,7 +238,8 @@ class V2TabBarActivity : V2DemoActivity() { invokeToast(resources.getString(R.string.tabBar_notification), context) selectedIndex = 3 }, - badge = { Badge(text = "10", badgeType = BadgeType.Character) } + badge = { Badge(text = "10", badgeType = BadgeType.Character) }, + accessibilityDescription = resources.getString(R.string.tabBar_notification) + ": " + if(selectedIndex == 3) {resources.getString(R.string.Active)} else {resources.getString(R.string.Inactive)} ), TabData( title = resources.getString(R.string.tabBar_more), @@ -245,6 +250,7 @@ class V2TabBarActivity : V2DemoActivity() { selectedIndex = 4 }, badge = { Badge() }, + accessibilityDescription = resources.getString(R.string.tabBar_more) + ": " + if(selectedIndex == 4) {resources.getString(R.string.Active)} else {resources.getString(R.string.Inactive)} ) ) diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/actionbar/V2ActionBarDemoActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/actionbar/V2ActionBarDemoActivity.kt new file mode 100644 index 000000000..8fa1c3020 --- /dev/null +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/actionbar/V2ActionBarDemoActivity.kt @@ -0,0 +1,93 @@ +package com.microsoft.fluentuidemo.demos.actionbar + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.text.BasicText +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.microsoft.fluentui.compose.Scaffold +import com.microsoft.fluentui.theme.FluentTheme +import com.microsoft.fluentui.theme.token.FluentAliasTokens +import com.microsoft.fluentui.tokenized.actionbar.ActionBar +import com.microsoft.fluentui.tokenized.navigation.ViewPager +import com.microsoft.fluentuidemo.SetStatusBarColor +import com.microsoft.fluentuidemo.V2DemoActivity + +class V2ActionBarDemoActivity : V2DemoActivity() { + init { + setupActivity(this) + } + + @OptIn(ExperimentalFoundationApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val context = this + val selectedActionBarType = intent.getIntExtra("ACTION_BAR_TYPE", 0) + val selectedActionBarPosition = intent.getIntExtra("ACTION_BAR_POSITION", 0) + setContent { + FluentTheme { + SetStatusBarColor() + val noOfPages = 5 + val pagerState = rememberPagerState(pageCount = { noOfPages }) + + val actionBar = @androidx.compose.runtime.Composable { + ActionBar( + pagerState = pagerState, + startCallback = { + this.finish() + }, + type = selectedActionBarType + ) + } + Scaffold( + contentWindowInsets = WindowInsets.statusBars, + topBar = if (selectedActionBarPosition == 0) + actionBar + else { + {} + }, + bottomBar = if (selectedActionBarPosition == 1) { + actionBar + } else { + {} + } + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(FluentTheme.aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value()) + .padding(it) + ) { + ViewPager( + pagerState = pagerState, + modifier = Modifier.fillMaxSize(), + pageContent = { + Box( + Modifier + .fillMaxSize() + .background( + color = if (pagerState.currentPage % 2 == 0) Color.Cyan else Color.LightGray + ) + ) { + BasicText( + text = "Page ${pagerState.currentPage}", + modifier = Modifier.align(Alignment.Center) + ) + } + } + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/FluentUI.Demo/src/main/res/values/ids.xml b/FluentUI.Demo/src/main/res/values/ids.xml index f1ca268fa..0d8fff24a 100644 --- a/FluentUI.Demo/src/main/res/values/ids.xml +++ b/FluentUI.Demo/src/main/res/values/ids.xml @@ -18,6 +18,7 @@ + diff --git a/FluentUI.Demo/src/main/res/values/strings.xml b/FluentUI.Demo/src/main/res/values/strings.xml index 026632a7b..7b64625a3 100644 --- a/FluentUI.Demo/src/main/res/values/strings.xml +++ b/FluentUI.Demo/src/main/res/values/strings.xml @@ -42,6 +42,21 @@ Navigation icon clicked. + + Center align app bar + + + App Bar size + + + Left Logo + + + Navigation Icon + + + Enable Tooltips + Flag Settings @@ -248,6 +263,10 @@ Delete Delete item clicked + + Toggle + + Toggle item clicked Avatar @@ -439,9 +458,9 @@ - Expand Persistent BottomSheet - Hide Persistent BottomSheet - Show Persistent BottomSheet + Expand Persistent Bottom Sheet + Hide Persistent Bottom Sheet + Show Persistent Bottom Sheet This is New View Toggle Bottomsheet Content Switch to custom Content diff --git a/FluentUI/build.gradle b/FluentUI/build.gradle index aefb6fc90..8acde506d 100644 --- a/FluentUI/build.gradle +++ b/FluentUI/build.gradle @@ -30,6 +30,12 @@ android { mavenCentral() } } + lint { + baseline = file("lint-baseline.xml") + } + lintOptions { + abortOnError false + } } dependencies { diff --git a/FluentUI/lint-baseline.xml b/FluentUI/lint-baseline.xml new file mode 100644 index 000000000..ce7fbf551 --- /dev/null +++ b/FluentUI/lint-baseline.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FluentUI/src/main/res/values-night/themes.xml b/FluentUI/src/main/res/values-night/themes.xml index 323b6cee5..480d74b5f 100644 --- a/FluentUI/src/main/res/values-night/themes.xml +++ b/FluentUI/src/main/res/values-night/themes.xml @@ -51,14 +51,6 @@ @color/fluentui_communication_tint_40 @color/fluentui_gray_800 - - ?attr/fluentuiBackgroundSecondaryColor - ?attr/fluentuiBackgroundSecondaryColor - @color/fluentui_gray_700 - ?attr/fluentuiBackgroundSecondaryColor - ?attr/fluentuiForegroundColor - @color/fluentui_gray_600 - @color/fluentui_gray_600 @color/fluentui_gray_900 diff --git a/FluentUI/src/main/res/values/themes.xml b/FluentUI/src/main/res/values/themes.xml index 4579b1086..6671b21df 100644 --- a/FluentUI/src/main/res/values/themes.xml +++ b/FluentUI/src/main/res/values/themes.xml @@ -95,18 +95,6 @@ ?attr/fluentuiForegroundSecondaryIconColor ?attr/fluentuiForegroundSelectedColor - - ?attr/fluentuiBackgroundColor - ?attr/fluentuiBackgroundColor - ?attr/fluentuiForegroundSecondaryColor - @color/fluentui_gray_400 - ?attr/fluentuiBackgroundColor - ?attr/fluentuiForegroundColor - @color/fluentui_gray_25 - ?attr/fluentuiForegroundSelectedColor - ?attr/fluentuiColorPrimaryLighter - @color/fluentui_gray_600 - @color/fluentui_gray_50 @color/fluentui_gray_100 diff --git a/NOTICE b/NOTICE index 66356db6b..7a4736969 100644 --- a/NOTICE +++ b/NOTICE @@ -225,213 +225,6 @@ Copyright (C) 2008 The Android Open Source Project =============================================================================== -ThreeTen Android Backport -Copyright (C) 2015 Jake Wharton - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - -=============================================================================== - TokenAutoComplete Copyright (c) 2013, 2014 splitwise, Wouter Dullaert diff --git a/README.md b/README.md index 60df97774..6d9581ee2 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Fluent UI for Android includes an expanding library of controls written in Kotli - [Tooltip](fluentui_others/src/main/java/com/microsoft/fluentui) ## Compose based Controls (v2) +- [AcrylicPane](fluentui_others/src/main/java/com/microsoft/fluentui/tokenized/acrylicpane/AcrylicPane.kt) - [AnnouncementCard](fluentui_controls/src/main/java/com/microsoft/fluentui/tokenized/controls/AnnouncementCard.kt) - [AppBar](fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/AppBar.kt) - [Avatar](fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/Avatar.kt) @@ -116,7 +117,10 @@ Fluent UI for Android includes an expanding library of controls written in Kotli ### Requirements -API 21+ +API 23+ + + +**Note:** The API requirement has been updated from API 21+ to API 23+. Please ensure your project meets this requirement if you are upgrading from an older version. ### 1. Using Gradle @@ -147,20 +151,6 @@ dependencies { More information about contents of each module can be found in [Modularization](#modularization) section - -#### a) Develop for Surface-Duo: -- Please also add the following lines to your repositories section in your gradle script: -```gradle -maven { - url "https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1" -} -``` -- Also add the SDK dependency to the module-level build.gradle file(current version may be different -from what's shown here): -```gradle -implementation "com.microsoft.device:dualscreen-layout:1.0.0-alpha01" -``` - ### 2. Using Maven - Add the FluentUI library as a dependency: @@ -195,14 +185,7 @@ implementation "com.microsoft.device:dualscreen-layout:1.0.0-alpha01" - Follow [these instructions](https://developer.android.com/studio/projects/android-library) to build and output an AAR files from the FluentUI modules, import the module(s) to your project, and add it as a dependency. If you're having trouble generating an AAR file for the module, make sure you select it and run e.g "Make Module 'fluentui_drawer'" from the Build menu. - Some components have dependencies you will need to manually add to your app if you are using this library as an AAR artifact because these dependencies do not get included in the output. - - If using **PeoplePickerView**, include this dependency in your gradle file: - ```gradle - implementation 'com.splitwise:tokenautocomplete:2.0.8' - ``` - - If using **CalendarView** or **DateTimePickerDialog**, include this dependency in your gradle file: - ```gradle - implementation 'com.jakewharton.threetenabp:threetenabp:1.1.0' - ``` + - None at the moment - Double check that these library versions correspond to the latest versions we implement in the FluentUI [build.gradle](fluentui_others\build.gradle). ### Import and use the library diff --git a/build.gradle b/build.gradle index cdc54f62f..5ee4b18f3 100644 --- a/build.gradle +++ b/build.gradle @@ -32,7 +32,7 @@ apply plugin: "com.microsoft.hydralab.client-util" allprojects { project.ext { constants = [ - minSdkVersion: 21, + minSdkVersion: 23, targetSdkVersion: 34, compileSdkVersion: 34 ] @@ -49,7 +49,6 @@ allprojects { composeCompilerVersion = '1.4.7' constraintLayoutVersion = '2.1.4' constraintLayoutComposeVersion = '1.0.1' - duoVersion = '1.0.0-alpha01' espressoVersion = '3.5.1' exifInterfaceVersion = '1.3.6' extJunitVersion = '1.1.5' @@ -64,16 +63,12 @@ allprojects { robolectric = '4.13' uiautomatorVersion = '2.2.0' supportVersion = '28.0.0' - tokenautocompleteVersion = '2.0.8' - threetenabpVersion = '1.1.0' universalPkgDir = "universal" + composeFoundationVersion = '1.6.0' } repositories { google() jcenter() - maven { - url "https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1" - } } } diff --git a/config.gradle b/config.gradle index a66eb86e4..a0dc71c6e 100644 --- a/config.gradle +++ b/config.gradle @@ -11,40 +11,40 @@ * and fluentui_drawer' current version is 0.0.2, both fluentui_listitem and * fluentui_drawer and FluentUI should increment their respective version ids */ -project.ext.fluentui_calendar_versionid = '0.2.1' -project.ext.fluentui_controls_versionid = '0.2.7' -project.ext.fluentui_core_versionid = '0.2.8' -project.ext.fluentui_listitem_versionid = '0.2.3' -project.ext.fluentui_tablayout_versionid = '0.2.3' -project.ext.fluentui_drawer_versionid = '0.2.8' -project.ext.fluentui_ccb_versionid = '0.2.1' -project.ext.fluentui_others_versionid = '0.2.1' -project.ext.fluentui_transients_versionid = '0.2.1' -project.ext.fluentui_topappbars_versionid = '0.2.1' -project.ext.fluentui_menus_versionid = '0.2.1' -project.ext.fluentui_peoplepicker_versionid = '0.2.2' -project.ext.fluentui_persona_versionid = '0.2.2' -project.ext.fluentui_progress_versionid = '0.2.1' -project.ext.fluentui_icons_versionid = '0.2.1' -project.ext.fluentui_notification_versionid = '0.2.3' -project.ext.FluentUI_versionid = '0.2.12' -project.ext.fluentui_calendar_version_code = 1001 -project.ext.fluentui_controls_version_code = 1007 -project.ext.fluentui_core_version_code = 1008 -project.ext.fluentui_listitem_version_code = 1003 -project.ext.fluentui_tablayout_version_code = 1003 -project.ext.fluentui_drawer_version_code = 1008 -project.ext.fluentui_ccb_version_code = 1001 -project.ext.fluentui_others_version_code = 1001 -project.ext.fluentui_transients_version_code = 1001 -project.ext.fluentui_topappbars_version_code = 1001 -project.ext.fluentui_menus_version_code = 1001 -project.ext.fluentui_peoplepicker_version_code = 1002 -project.ext.fluentui_persona_version_code = 1002 -project.ext.fluentui_progress_version_code = 1001 -project.ext.fluentui_icons_version_code = 1001 -project.ext.fluentui_notification_version_code = 1003 -project.ext.FluentUI_version_code = 1012 +project.ext.fluentui_calendar_versionid = '0.3.3' +project.ext.fluentui_controls_versionid = '0.3.2' +project.ext.fluentui_core_versionid = '0.3.8' +project.ext.fluentui_listitem_versionid = '0.3.5' +project.ext.fluentui_tablayout_versionid = '0.3.3' +project.ext.fluentui_drawer_versionid = '0.3.7' +project.ext.fluentui_ccb_versionid = '0.3.3' +project.ext.fluentui_others_versionid = '0.3.6' +project.ext.fluentui_transients_versionid = '0.3.4' +project.ext.fluentui_topappbars_versionid = '0.3.6' +project.ext.fluentui_menus_versionid = '0.3.4' +project.ext.fluentui_peoplepicker_versionid = '0.3.5' +project.ext.fluentui_persona_versionid = '0.3.4' +project.ext.fluentui_progress_versionid = '0.3.6' +project.ext.fluentui_icons_versionid = '0.3.2' +project.ext.fluentui_notification_versionid = '0.3.4' +project.ext.FluentUI_versionid = '0.3.8' +project.ext.fluentui_calendar_version_code = 2003 +project.ext.fluentui_controls_version_code = 2002 +project.ext.fluentui_core_version_code = 2008 +project.ext.fluentui_listitem_version_code = 2005 +project.ext.fluentui_tablayout_version_code = 2003 +project.ext.fluentui_drawer_version_code = 2007 +project.ext.fluentui_ccb_version_code = 2003 +project.ext.fluentui_others_version_code = 2006 +project.ext.fluentui_transients_version_code = 2004 +project.ext.fluentui_topappbars_version_code = 2006 +project.ext.fluentui_menus_version_code = 2004 +project.ext.fluentui_peoplepicker_version_code = 2005 +project.ext.fluentui_persona_version_code = 2004 +project.ext.fluentui_progress_version_code = 2006 +project.ext.fluentui_icons_version_code = 2002 +project.ext.fluentui_notification_version_code = 2004 +project.ext.FluentUI_version_code = 2008 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-android-release.yml b/fluentui-android-release.yml new file mode 100644 index 000000000..f2179dd70 --- /dev/null +++ b/fluentui-android-release.yml @@ -0,0 +1,55 @@ +trigger: none +name: $(Date:yyyyMMdd).$(Rev:r) +resources: + pipelines: + - pipeline: 'fluentui-android-maven-publish' + project: 'ISS' + source: 'fluentui-maven-central-publish [1es-pt]' + repositories: + - repository: OfficePipelineTemplates + type: git + name: 1ESPipelineTemplates/OfficePipelineTemplates + ref: refs/tags/release +extends: + template: v1/Office.Official.PipelineTemplate.yml@OfficePipelineTemplates + parameters: + pool: + name: Azure-Pipelines-1ESPT-ExDShared + image: windows-2022 + os: windows + customBuildTags: + - ES365AIMigrationTooling-Release + stages: + - stage: Stage_1 + displayName: ESRP Release + jobs: + - job: Job_1 + displayName: Agent job + condition: succeeded() + timeoutInMinutes: 0 + templateContext: + type: releaseJob + isProduction: true + inputs: + - input: pipelineArtifact + buildType: 'specific' + project: '$(projectName)' + definition: '$(pipelineDefinition)' + buildVersionToDownload: 'specific' + pipelineId: '$(buildId)' + artifactName: 'Build' + targetPath: '$(Pipeline.Workspace)/fluentui-android-maven-publish/Build' + steps: + - task: SFP.release-tasks.custom-build-release-task.EsrpRelease@9 + displayName: 'ESRP Release' + inputs: + connectedservicename: 'ESRP-JSHost3' + usemanagedidentity: false + keyvaultname: 'OGX-JSHost-KV' + authcertname: 'OGX-JSHost-Auth4' + signcertname: 'OGX-JSHost-Sign3' + clientid: '0a35e01f-eadf-420a-a2bf-def002ba898d' + domaintenantid: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2' + folderlocation: '$(Pipeline.Workspace)/fluentui-android-maven-publish/Build' + owners: 'ronakdhoot@microsoft.com' + approvers: 'dhruvmishra@microsoft.com' \ No newline at end of file diff --git a/fluentui-github-release.yml b/fluentui-github-release.yml new file mode 100644 index 000000000..7df693472 --- /dev/null +++ b/fluentui-github-release.yml @@ -0,0 +1,76 @@ +trigger: none +name: $(Date:yyyyMMdd).$(Rev:r) + +resources: + pipelines: + - pipeline: 'fluentui-android-maven-publish' + project: 'ISS' + source: 'fluentui-maven-central-publish [1es-pt]' + repositories: + - repository: OfficePipelineTemplates + type: git + name: 1ESPipelineTemplates/OfficePipelineTemplates + ref: refs/tags/release + +extends: + template: v1/Office.Official.PipelineTemplate.yml@OfficePipelineTemplates + parameters: + pool: + name: Azure-Pipelines-1ESPT-ExDShared + image: windows-2022 + os: windows + customBuildTags: + - ES365AIMigrationTooling-Release + stages: + - stage: Stage_1 + displayName: GitHub Release + jobs: + - job: Job_1 + displayName: Agent job + condition: succeeded() + timeoutInMinutes: 0 + templateContext: + type: releaseJob + isProduction: true + inputs: + - input: pipelineArtifact + buildType: 'specific' + project: '$(projectName)' + definition: '$(pipelineDefinition)' + buildVersionToDownload: 'specific' + pipelineId: '$(buildId)' + artifactName: 'dogfood' + targetPath: '$(Pipeline.Workspace)/fluentui-android-maven-publish/dogfood' + - input: pipelineArtifact + buildType: 'specific' + project: '$(projectName)' + definition: '$(pipelineDefinition)' + buildVersionToDownload: 'specific' + pipelineId: '$(buildId)' + artifactName: 'notes' + targetPath: '$(Pipeline.Workspace)/fluentui-android-maven-publish/notes' + steps: + # Read release notes content + - task: PowerShell@2 + displayName: 'Read Release Notes' + inputs: + targetType: 'inline' + script: | + $releaseNotesContent = Get-Content -Path "$(Pipeline.Workspace)/fluentui-android-maven-publish/notes/dogfood-release-notes.txt" -Raw + Write-Host "##vso[task.setvariable variable=ReleaseNotesContent]$releaseNotesContent" + + - task: GitHubRelease@1 + displayName: 'Create GitHub Release' + inputs: + gitHubConnection: 'GitHub-FluentUI-Android' + repositoryName: 'microsoft/fluentui-android' + action: 'create' + target: '$(Build.SourceVersion)' + tagSource: 'userSpecifiedTag' + tag: 'v$(releaseVersion)' + title: 'FluentUI Android v$(releaseVersion)' + releaseNotesSource: 'inline' + releaseNotesInline: '$(ReleaseNotesContent)' + assets: '$(Pipeline.Workspace)/fluentui-android-maven-publish/dogfood/FluentUI.Demo-dogfood-release.apk' + addChangeLog: false + isPreRelease: false diff --git a/fluentui-maven-central-publish-1espt.yml b/fluentui-maven-central-publish-1espt.yml index 7961e9ba8..fd4c5681f 100644 --- a/fluentui-maven-central-publish-1espt.yml +++ b/fluentui-maven-central-publish-1espt.yml @@ -5,12 +5,12 @@ variables: value: production,externalfacing resources: repositories: - - repository: 1ESPipelineTemplates + - repository: OfficePipelineTemplates type: git - name: 1ESPipelineTemplates/1ESPipelineTemplates + name: 1ESPipelineTemplates/OfficePipelineTemplates ref: refs/tags/release extends: - template: v1/1ES.Official.PipelineTemplate.yml@1ESPipelineTemplates + template: v1/Office.Official.PipelineTemplate.yml@OfficePipelineTemplates parameters: sdl: spotBugs: diff --git a/fluentui-office-build-universal-publish-1espt.yml b/fluentui-office-build-universal-publish-1espt.yml index b67cec018..a5f61e792 100644 --- a/fluentui-office-build-universal-publish-1espt.yml +++ b/fluentui-office-build-universal-publish-1espt.yml @@ -4,12 +4,12 @@ variables: value: production,externalfacing resources: repositories: - - repository: 1ESPipelineTemplates + - repository: M365GPT type: git - name: 1ESPipelineTemplates/1ESPipelineTemplates + name: 1ESPipelineTemplates/M365GPT ref: refs/tags/release extends: - template: v1/1ES.Official.PipelineTemplate.yml@1ESPipelineTemplates + template: v1/M365.Official.PipelineTemplate.yml@M365GPT parameters: pool: name: Azure-Pipelines-1ESPT-ExDShared @@ -62,6 +62,6 @@ extends: vstsFeedPublish: 'Office' vstsFeedPackagePublish: 'fluentuiandroid' versionOption: 'custom' - versionPublish: '0.2.12' + versionPublish: '0.3.8' packagePublishDescription: 'Fluent Universal Package' - publishedPackageVar: 'fluent package' \ No newline at end of file + publishedPackageVar: 'fluent package' diff --git a/fluentui_calendar/build.gradle b/fluentui_calendar/build.gradle index a1782c5a4..339947952 100644 --- a/fluentui_calendar/build.gradle +++ b/fluentui_calendar/build.gradle @@ -57,8 +57,6 @@ dependencies { implementation "androidx.recyclerview:recyclerview:$recyclerViewVersion" implementation "com.google.android.material:material:$materialVersion" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation "com.jakewharton.threetenabp:threetenabp:$threetenabpVersion" - implementation "com.microsoft.device:dualscreen-layout:$duoVersion" testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$extJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion" diff --git a/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/CalendarAdapter.kt b/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/CalendarAdapter.kt index 37d2c7b98..ce59231a8 100644 --- a/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/CalendarAdapter.kt +++ b/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/CalendarAdapter.kt @@ -18,9 +18,15 @@ import android.view.ViewGroup import com.microsoft.fluentui.calendar.CalendarDaySelectionDrawable.Mode import com.microsoft.fluentui.managers.PreferencesManager import com.microsoft.fluentui.util.DateTimeUtils -import org.threeten.bp.* -import org.threeten.bp.temporal.ChronoUnit import java.lang.StringBuilder +import java.time.DayOfWeek +import java.time.Duration +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit import java.util.concurrent.TimeUnit /** diff --git a/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/CalendarDayView.kt b/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/CalendarDayView.kt index a3e603f35..83c60c346 100644 --- a/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/CalendarDayView.kt +++ b/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/CalendarDayView.kt @@ -30,10 +30,10 @@ import com.microsoft.fluentui.theming.FluentUIContextThemeWrapper import com.microsoft.fluentui.util.DateStringUtils import com.microsoft.fluentui.util.DateTimeUtils import com.microsoft.fluentui.util.isAccessibilityEnabled -import org.threeten.bp.LocalDate -import org.threeten.bp.ZonedDateTime -import org.threeten.bp.format.DateTimeFormatter -import org.threeten.bp.temporal.ChronoUnit +import java.time.LocalDate +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit import java.util.* /** diff --git a/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/CalendarView.kt b/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/CalendarView.kt index cd8063c4f..380a8fa26 100644 --- a/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/CalendarView.kt +++ b/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/CalendarView.kt @@ -16,10 +16,13 @@ import android.util.AttributeSet import android.util.Property import android.view.View import android.widget.LinearLayout -import com.jakewharton.threetenabp.AndroidThreeTen import com.microsoft.fluentui.theming.FluentUIContextThemeWrapper import com.microsoft.fluentui.util.ThemeUtil -import org.threeten.bp.* +import java.time.Duration +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZonedDateTime // TODO: Convert to TemplateView along with other things that extend LinearLayout // TODO: implement ability to add icon to CalendarDayView @@ -108,10 +111,6 @@ class CalendarView : LinearLayout, OnDateSelectedListener { } } - init { - AndroidThreeTen.init(context) - } - @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : super(FluentUIContextThemeWrapper(context,R.style.Theme_FluentUI_Calendar), attrs, defStyleAttr) { dividerHeight = Math.round(resources.getDimension(R.dimen.fluentui_divider_height)) @@ -192,7 +191,7 @@ class CalendarView : LinearLayout, OnDateSelectedListener { } } - super.onMeasure(widthMeasureSpec, View.MeasureSpec.makeMeasureSpec(computeHeight(displayMode), View.MeasureSpec.EXACTLY)) + super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(computeHeight(displayMode), View.MeasureSpec.EXACTLY)) } override fun onDateSelected(dateTime: ZonedDateTime) { diff --git a/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/WeekHeadingView.kt b/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/WeekHeadingView.kt index 681aa4c7e..425a46c93 100644 --- a/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/WeekHeadingView.kt +++ b/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/WeekHeadingView.kt @@ -15,9 +15,8 @@ import android.widget.LinearLayout import android.widget.TextView import com.microsoft.fluentui.calendar.CalendarView.Companion.WEEK_MID import com.microsoft.fluentui.managers.PreferencesManager -import com.microsoft.fluentui.util.DuoSupportUtils import com.microsoft.fluentui.util.activity -import org.threeten.bp.DayOfWeek +import java.time.DayOfWeek /** * [WeekHeadingView] is a LinearLayout holding the [CalendarView] header with views for @@ -51,23 +50,7 @@ internal class WeekHeadingView : LinearLayout { textView.gravity = Gravity.CENTER post { context.activity?.let { - if (DuoSupportUtils.intersectHinge(it, this)) { - when { - currentDay < WEEK_MID -> { - addView(textView, LayoutParams(0, LayoutParams.MATCH_PARENT, (DuoSupportUtils.getHalfScreenWidth(it) / DuoSupportUtils.COLUMNS_IN_START_DUO_MODE).toFloat())) - } - currentDay == WEEK_MID -> { - addView(textView, LayoutParams(0, LayoutParams.MATCH_PARENT, (DuoSupportUtils.getHalfScreenWidth(it) / DuoSupportUtils.COLUMNS_IN_START_DUO_MODE).toFloat())) - addView(View(context), LayoutParams(0, LayoutParams.MATCH_PARENT, (DuoSupportUtils.getHingeWidth(it).toFloat()))) - } - else -> { - addView(textView, LayoutParams(0, LayoutParams.MATCH_PARENT, (DuoSupportUtils.getHalfScreenWidth(it) / DuoSupportUtils.COLUMNS_IN_END_DUO_MODE).toFloat())) - - } - } - } else { - addView(textView, LayoutParams(0, LayoutParams.MATCH_PARENT, 1.0f)) - } + addView(textView, LayoutParams(0, LayoutParams.MATCH_PARENT, 1.0f)) } } diff --git a/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/WeeksView.kt b/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/WeeksView.kt index 67a549904..f335f2010 100644 --- a/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/WeeksView.kt +++ b/fluentui_calendar/src/main/java/com/microsoft/fluentui/calendar/WeeksView.kt @@ -29,16 +29,13 @@ import android.view.View import com.microsoft.fluentui.util.ColorProperty import com.microsoft.fluentui.util.DateTimeUtils -import com.microsoft.fluentui.util.activity -import com.microsoft.fluentui.util.DuoSupportUtils -import com.microsoft.fluentui.util.displaySize import com.microsoft.fluentui.view.MSRecyclerView -import org.threeten.bp.Duration -import org.threeten.bp.LocalDate -import org.threeten.bp.Month -import org.threeten.bp.ZonedDateTime -import org.threeten.bp.chrono.IsoChronology -import org.threeten.bp.temporal.ChronoUnit +import java.time.Duration +import java.time.LocalDate +import java.time.Month +import java.time.ZonedDateTime +import java.time.chrono.IsoChronology +import java.time.temporal.ChronoUnit import java.util.* /** @@ -118,15 +115,6 @@ internal class WeeksView : MSRecyclerView { setHasFixedSize(true) layoutManager = GridLayoutManager(context, DAYS_IN_WEEK, LinearLayoutManager.VERTICAL, false) layoutManager?.scrollToPosition(pickerAdapter.todayPosition) - post { - context.activity?.let { - if (DuoSupportUtils.intersectHinge(it, this)) { - (layoutManager as GridLayoutManager).spanCount = context.displaySize.x - addItemDecoration(HingeItemDecoration(DuoSupportUtils.getHingeWidth(it))) - (layoutManager as GridLayoutManager).spanSizeLookup = DuoSupportUtils.getSpanSizeLookup(it) - } - } - } itemAnimator = null @@ -197,32 +185,7 @@ internal class WeeksView : MSRecyclerView { paint.getTextBounds(text, 0, text.length, textBounds) paint.color = overlayFontColorProperty.color - - context.activity?.let { - if (DuoSupportUtils.isDualScreenMode(it)) { - // For duo mode we show month name both on left and right screen - // This shows on start 1/4th screen position - canvas.drawText(text, - (measuredWidth/4 - textBounds.width()/2).toFloat(), - (((monthDescriptor.bottom + monthDescriptor.top)- textBounds.height()) / 2).toFloat(), - paint - ) - // This shows on 3/4th screen position - canvas.drawText(text, - ((3*measuredWidth)/4 - textBounds.width()/2).toFloat(), - (((monthDescriptor.bottom + monthDescriptor.top)- textBounds.height()) / 2).toFloat(), - paint - ) - } - else { - // Show on 1/2 screen position - canvas.drawText(text, - ((measuredWidth - textBounds.width()) / 2).toFloat(), - (((monthDescriptor.bottom + monthDescriptor.top)- textBounds.height()) / 2).toFloat(), - paint - ) - } - } ?: canvas.drawText(text, + canvas.drawText(text, ((measuredWidth - textBounds.width()) / 2).toFloat(), (((monthDescriptor.bottom + monthDescriptor.top)- textBounds.height()) / 2).toFloat(), paint diff --git a/fluentui_calendar/src/main/java/com/microsoft/fluentui/datetimepicker/DateTimePicker.kt b/fluentui_calendar/src/main/java/com/microsoft/fluentui/datetimepicker/DateTimePicker.kt index 4df5294e7..95243b9eb 100644 --- a/fluentui_calendar/src/main/java/com/microsoft/fluentui/datetimepicker/DateTimePicker.kt +++ b/fluentui_calendar/src/main/java/com/microsoft/fluentui/datetimepicker/DateTimePicker.kt @@ -9,12 +9,11 @@ import android.app.Dialog import android.content.Context import android.os.Bundle import androidx.appcompat.app.AppCompatDialogFragment -import com.jakewharton.threetenabp.AndroidThreeTen import com.microsoft.fluentui.datetimepicker.DateTimePickerDialog.* import com.microsoft.fluentui.util.DateTimeUtils import com.microsoft.fluentui.util.isAccessibilityEnabled -import org.threeten.bp.Duration -import org.threeten.bp.ZonedDateTime +import java.time.Duration +import java.time.ZonedDateTime /** * [DateTimePicker] houses a [DateTimePickerDialog] and provides state management for the dialog. @@ -56,10 +55,6 @@ class DateTimePicker : AppCompatDialogFragment(), OnDateTimeSelectedListener, On } } - init { - AndroidThreeTen.init(context) - } - private lateinit var displayMode: DisplayMode private lateinit var dateRangeMode: DateRangeMode private lateinit var dateTime: ZonedDateTime diff --git a/fluentui_calendar/src/main/java/com/microsoft/fluentui/datetimepicker/DateTimePickerDialog.kt b/fluentui_calendar/src/main/java/com/microsoft/fluentui/datetimepicker/DateTimePickerDialog.kt index 54b7cbbb1..0f1582426 100644 --- a/fluentui_calendar/src/main/java/com/microsoft/fluentui/datetimepicker/DateTimePickerDialog.kt +++ b/fluentui_calendar/src/main/java/com/microsoft/fluentui/datetimepicker/DateTimePickerDialog.kt @@ -16,16 +16,15 @@ import android.view.* import android.view.accessibility.AccessibilityEvent import androidx.viewpager.widget.PagerAdapter import androidx.viewpager.widget.ViewPager -import com.jakewharton.threetenabp.AndroidThreeTen import com.microsoft.fluentui.calendar.R import com.microsoft.fluentui.calendar.CalendarView import com.microsoft.fluentui.calendar.OnDateSelectedListener import com.microsoft.fluentui.calendar.databinding.DialogDateTimePickerBinding import com.microsoft.fluentui.theming.FluentUIContextThemeWrapper import com.microsoft.fluentui.util.* -import org.threeten.bp.Duration -import org.threeten.bp.ZonedDateTime import com.microsoft.fluentui.calendar.databinding.DialogResizableBinding +import java.time.Duration +import java.time.ZonedDateTime // TODO consider merging PickerMode and DateRangeMode since not all combinations will work /** @@ -118,10 +117,6 @@ class DateTimePickerDialog : AppCompatDialog, Toolbar.OnMenuItemClickListener, O private lateinit var dialogContainerBinding: DialogDateTimePickerBinding private lateinit var pagerAdapter: DateTimePagerAdapter - init { - AndroidThreeTen.init(context) - } - @JvmOverloads constructor( context: Context, @@ -217,14 +212,7 @@ class DateTimePickerDialog : AppCompatDialog, Toolbar.OnMenuItemClickListener, O override fun onStart() { super.onStart() - context.activity?.let { - if (DuoSupportUtils.isDualScreenMode(it)) { - window?.setLayout(DuoSupportUtils.getSingleScreenWidthPixels(it),WindowManager.LayoutParams.MATCH_PARENT) - } - else { - window?.setLayout(context.desiredDialogSize[0], WindowManager.LayoutParams.MATCH_PARENT) - } - } ?: window?.setLayout(context.desiredDialogSize[0], WindowManager.LayoutParams.MATCH_PARENT) + window?.setLayout(context.desiredDialogSize[0], WindowManager.LayoutParams.MATCH_PARENT) } override fun dismiss() { diff --git a/fluentui_calendar/src/main/java/com/microsoft/fluentui/datetimepicker/TimePicker.kt b/fluentui_calendar/src/main/java/com/microsoft/fluentui/datetimepicker/TimePicker.kt index aef6a0e0d..723e0a8ee 100644 --- a/fluentui_calendar/src/main/java/com/microsoft/fluentui/datetimepicker/TimePicker.kt +++ b/fluentui_calendar/src/main/java/com/microsoft/fluentui/datetimepicker/TimePicker.kt @@ -22,9 +22,13 @@ import com.microsoft.fluentui.managers.PreferencesManager import com.microsoft.fluentui.util.DateStringUtils import com.microsoft.fluentui.util.DateTimeUtils import com.microsoft.fluentui.view.NumberPicker -import org.threeten.bp.* -import org.threeten.bp.temporal.ChronoUnit import java.text.DateFormatSymbols +import java.time.Duration +import java.time.LocalDate +import java.time.YearMonth +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit /** * [TimePicker] houses [NumberPicker]s that allow users to pick dates, times and periods (12 hour clocks). diff --git a/fluentui_calendar/src/main/java/com/microsoft/fluentui/datetimepicker/TimeSlot.kt b/fluentui_calendar/src/main/java/com/microsoft/fluentui/datetimepicker/TimeSlot.kt index ed8b7c386..6745b6866 100644 --- a/fluentui_calendar/src/main/java/com/microsoft/fluentui/datetimepicker/TimeSlot.kt +++ b/fluentui_calendar/src/main/java/com/microsoft/fluentui/datetimepicker/TimeSlot.kt @@ -5,9 +5,9 @@ package com.microsoft.fluentui.datetimepicker -import org.threeten.bp.Duration -import org.threeten.bp.ZonedDateTime import java.io.Serializable +import java.time.Duration +import java.time.ZonedDateTime // TODO PBI #668220 investigate whether it's feasible to replace dateTime + duration with this data class data class TimeSlot(val start: ZonedDateTime, val duration: Duration) : Serializable diff --git a/fluentui_calendar/src/main/java/com/microsoft/fluentui/managers/PreferencesManager.kt b/fluentui_calendar/src/main/java/com/microsoft/fluentui/managers/PreferencesManager.kt index 3f65b10e4..4614014cb 100644 --- a/fluentui_calendar/src/main/java/com/microsoft/fluentui/managers/PreferencesManager.kt +++ b/fluentui_calendar/src/main/java/com/microsoft/fluentui/managers/PreferencesManager.kt @@ -6,7 +6,7 @@ package com.microsoft.fluentui.managers import android.content.Context -import org.threeten.bp.DayOfWeek +import java.time.DayOfWeek /** * [PreferencesManager] helper methods dealing with device SharedPreferences diff --git a/fluentui_calendar/src/main/java/com/microsoft/fluentui/util/DateStringUtils.kt b/fluentui_calendar/src/main/java/com/microsoft/fluentui/util/DateStringUtils.kt index 5fed57911..d5c7d997d 100644 --- a/fluentui_calendar/src/main/java/com/microsoft/fluentui/util/DateStringUtils.kt +++ b/fluentui_calendar/src/main/java/com/microsoft/fluentui/util/DateStringUtils.kt @@ -6,15 +6,14 @@ package com.microsoft.fluentui.util import android.content.Context -import android.text.format.DateUtils import android.text.format.DateUtils.* import com.microsoft.fluentui.calendar.R -import org.threeten.bp.LocalDate -import org.threeten.bp.LocalDateTime -import org.threeten.bp.ZoneId -import org.threeten.bp.ZonedDateTime -import org.threeten.bp.temporal.TemporalAccessor import java.text.SimpleDateFormat +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.temporal.TemporalAccessor import java.util.* /** @@ -54,7 +53,7 @@ object DateStringUtils { */ @JvmStatic fun formatDateWithWeekDay(context: Context, date: Long): String = - DateUtils.formatDateTime(context, date,FORMAT_SHOW_DATE or FORMAT_SHOW_WEEKDAY) + formatDateTime(context, date,FORMAT_SHOW_DATE or FORMAT_SHOW_WEEKDAY) /** * Formats a date with the abbreviated weekday + month + day @@ -73,7 +72,7 @@ object DateStringUtils { */ @JvmStatic fun formatDateAbbrevAll(context: Context, time: Long): String = - DateUtils.formatDateTime(context, time, FORMAT_ABBREV_ALL or FORMAT_SHOW_DATE or FORMAT_SHOW_WEEKDAY) + formatDateTime(context, time, FORMAT_ABBREV_ALL or FORMAT_SHOW_DATE or FORMAT_SHOW_WEEKDAY) /** * Formats the month day and year @@ -83,7 +82,7 @@ object DateStringUtils { */ @JvmStatic fun formatMonthDayYear(context: Context, date: TemporalAccessor): String = - DateUtils.formatDateTime(context, date.epochMillis, 0) + formatDateTime(context, date.epochMillis, 0) /** * Formats a date with the weekday + month + day + Time. The year is optionally formatted if it @@ -97,7 +96,7 @@ object DateStringUtils { */ @JvmStatic fun formatFullDateTime(context: Context, time: Long): String = - DateUtils.formatDateTime(context, time, FORMAT_SHOW_DATE or FORMAT_SHOW_WEEKDAY or FORMAT_SHOW_TIME) + formatDateTime(context, time, FORMAT_SHOW_DATE or FORMAT_SHOW_WEEKDAY or FORMAT_SHOW_TIME) /** * @see .formatFullDateTime @@ -131,7 +130,7 @@ object DateStringUtils { */ @JvmStatic fun formatAbbrevTime(context: Context, dateTime: TemporalAccessor): String = - DateUtils.formatDateTime(context, dateTime.epochMillis, FORMAT_SHOW_TIME or FORMAT_ABBREV_TIME) + formatDateTime(context, dateTime.epochMillis, FORMAT_SHOW_TIME or FORMAT_ABBREV_TIME) /** * Formats a date with abbreviated Weekday + Date + Year @@ -143,7 +142,7 @@ object DateStringUtils { */ @JvmStatic fun formatWeekdayDateYearAbbrev(context: Context, date: TemporalAccessor): String = - DateUtils.formatDateTime( + formatDateTime( context, date.epochMillis, FORMAT_ABBREV_WEEKDAY or FORMAT_ABBREV_MONTH or FORMAT_SHOW_WEEKDAY or FORMAT_SHOW_DATE or FORMAT_SHOW_YEAR @@ -159,8 +158,8 @@ object DateStringUtils { if (calendar.get(Calendar.YEAR) != currentYear) flags = flags or FORMAT_SHOW_YEAR - val date = DateUtils.formatDateTime(context, timestamp, flags) - val time = DateUtils.formatDateTime(context, timestamp, FORMAT_SHOW_TIME) + val date = formatDateTime(context, timestamp, flags) + val time = formatDateTime(context, timestamp, FORMAT_SHOW_TIME) return context.getString(stringResource, date, time) } diff --git a/fluentui_calendar/src/main/java/com/microsoft/fluentui/util/DateTimeUtils.kt b/fluentui_calendar/src/main/java/com/microsoft/fluentui/util/DateTimeUtils.kt index 915cff76b..ba1b919e1 100644 --- a/fluentui_calendar/src/main/java/com/microsoft/fluentui/util/DateTimeUtils.kt +++ b/fluentui_calendar/src/main/java/com/microsoft/fluentui/util/DateTimeUtils.kt @@ -5,9 +5,14 @@ package com.microsoft.fluentui.util -import org.threeten.bp.* -import org.threeten.bp.format.DateTimeFormatter -import org.threeten.bp.format.DateTimeParseException +import java.time.DayOfWeek +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException /** * [DateTimeUtils] contains helper methods for manipulating and parsing dates diff --git a/fluentui_calendar/src/main/res/values-night/themes.xml b/fluentui_calendar/src/main/res/values-night/themes.xml new file mode 100644 index 000000000..e2ceb95a5 --- /dev/null +++ b/fluentui_calendar/src/main/res/values-night/themes.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/fluentui_calendar/src/main/res/values/themes.xml b/fluentui_calendar/src/main/res/values/themes.xml index df5643eea..fb7d6bd34 100644 --- a/fluentui_calendar/src/main/res/values/themes.xml +++ b/fluentui_calendar/src/main/res/values/themes.xml @@ -28,6 +28,7 @@ ?attr/fluentuiForegroundOnPrimaryColor ?attr/fluentuiForegroundOnPrimaryColor ?attr/fluentuiForegroundSecondaryColor + @color/fluentui_gray_600 ?attr/colorPrimary diff --git a/fluentui_ccb/build.gradle b/fluentui_ccb/build.gradle index 77889b480..9475fdd3a 100644 --- a/fluentui_ccb/build.gradle +++ b/fluentui_ccb/build.gradle @@ -33,6 +33,9 @@ android { buildFeatures { compose true } + lintOptions { + abortOnError false + } } dependencies { diff --git a/fluentui_core/build.gradle b/fluentui_core/build.gradle index bf28b4255..89293d399 100644 --- a/fluentui_core/build.gradle +++ b/fluentui_core/build.gradle @@ -46,6 +46,12 @@ android { } productFlavors { } + lint { + baseline = file("lint-baseline.xml") + } + lintOptions { + abortOnError false + } } gradle.taskGraph.whenReady { taskGraph -> @@ -66,8 +72,8 @@ dependencies { implementation "androidx.cardview:cardview:1.0.0" implementation "com.google.android.material:material:$materialVersion" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation "com.microsoft.device:dualscreen-layout:$duoVersion" implementation "androidx.constraintlayout:constraintlayout:$constraintLayoutVersion" + implementation "androidx.compose.foundation:foundation:$composeFoundationVersion" implementation "androidx.compose.material:material" testImplementation "junit:junit:$junitVersion" diff --git a/fluentui_core/lint-baseline.xml b/fluentui_core/lint-baseline.xml new file mode 100644 index 000000000..ea9aef2e5 --- /dev/null +++ b/fluentui_core/lint-baseline.xml @@ -0,0 +1,1301 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/compose/AnchoredDraggable.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/compose/AnchoredDraggable.kt new file mode 100644 index 000000000..41513ef48 --- /dev/null +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/compose/AnchoredDraggable.kt @@ -0,0 +1,912 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.microsoft.fluentui.compose + +import android.annotation.SuppressLint +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.animate +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.MutatorMutex +import androidx.compose.foundation.gestures.DragScope +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.structuralEqualityPolicy +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.unit.Velocity +import kotlin.math.abs +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +/** + * Structure that represents the anchors of a [AnchoredDraggableState]. + * + * See the DraggableAnchors factory method to construct drag anchors using a default implementation. + */ +interface DraggableAnchors { + + /** + * Get the anchor position for an associated [value] + * + * @param value The value to look up + * + * @return The position of the anchor, or [Float.NaN] if the anchor does not exist + */ + fun positionOf(value: T): Float + + /** + * Whether there is an anchor position associated with the [value] + * + * @param value The value to look up + * + * @return true if there is an anchor for this value, false if there is no anchor for this value + */ + fun hasAnchorFor(value: T): Boolean + + /** + * Find the closest anchor to the [position]. + * + * @param position The position to start searching from + * + * @return The closest anchor or null if the anchors are empty + */ + fun closestAnchor(position: Float): T? + + /** + * Find the closest anchor to the [position], in the specified direction. + * + * @param position The position to start searching from + * @param searchUpwards Whether to search upwards from the current position or downwards + * + * @return The closest anchor or null if the anchors are empty + */ + fun closestAnchor(position: Float, searchUpwards: Boolean): T? + + /** + * The smallest anchor, or [Float.NEGATIVE_INFINITY] if the anchors are empty. + */ + fun minAnchor(): Float + + /** + * The biggest anchor, or [Float.POSITIVE_INFINITY] if the anchors are empty. + */ + fun maxAnchor(): Float + + /** + * The amount of anchors + */ + val size: Int +} + +/** + * [DraggableAnchorsConfig] stores a mutable configuration anchors, comprised of values of [T] and + * corresponding [Float] positions. This [DraggableAnchorsConfig] is used to construct an immutable + * [DraggableAnchors] instance later on. + */ +class DraggableAnchorsConfig { + + internal val anchors = mutableMapOf() + + /** + * Set the anchor position for [this] anchor. + * + * @param position The anchor position. + */ + @Suppress("BuilderSetStyle") + infix fun T.at(position: Float) { + anchors[this] = position + } +} + +/** + * Create a new [DraggableAnchors] instance using a builder function. + * + * @param builder A function with a [DraggableAnchorsConfig] that offers APIs to configure anchors + * @return A new [DraggableAnchors] instance with the anchor positions set by the `builder` + * function. + */ +fun DraggableAnchors( + builder: DraggableAnchorsConfig.() -> Unit +): DraggableAnchors = MapDraggableAnchors(DraggableAnchorsConfig().apply(builder).anchors) + +/** + * Enable drag gestures between a set of predefined values. + * + * When a drag is detected, the offset of the [AnchoredDraggableState] will be updated with the drag + * delta. You should use this offset to move your content accordingly (see [Modifier.offset]). + * When the drag ends, the offset will be animated to one of the anchors and when that anchor is + * reached, the value of the [AnchoredDraggableState] will also be updated to the value + * corresponding to the new anchor. + * + * Dragging is constrained between the minimum and maximum anchors. + * + * @param state The associated [AnchoredDraggableState]. + * @param orientation The orientation in which the [anchoredDraggable] can be dragged. + * @param enabled Whether this [anchoredDraggable] is enabled and should react to the user's input. + * @param reverseDirection Whether to reverse the direction of the drag, so a top to bottom + * drag will behave like bottom to top, and a left to right drag will behave like right to left. + * @param interactionSource Optional [MutableInteractionSource] that will passed on to + * the internal [Modifier.draggable]. + */ +@Suppress("ModifierFactoryUnreferencedReceiver") +fun Modifier.anchoredDraggable( + state: AnchoredDraggableState, + orientation: Orientation, + enabled: Boolean = true, + reverseDirection: Boolean = false, + interactionSource: MutableInteractionSource? = null +) = draggable( + state = state.draggableState, + orientation = orientation, + enabled = enabled, + interactionSource = interactionSource, + reverseDirection = reverseDirection, + startDragImmediately = state.isAnimationRunning, + onDragStopped = { velocity -> launch { state.settle(velocity) } } +) + +/** + * Scope used for suspending anchored drag blocks. Allows to set [AnchoredDraggableState.offset] to + * a new value. + * + * @see [AnchoredDraggableState.anchoredDrag] to learn how to start the anchored drag and get the + * access to this scope. + */ +interface AnchoredDragScope { + /** + * Assign a new value for an offset value for [AnchoredDraggableState]. + * + * @param newOffset new value for [AnchoredDraggableState.offset]. + * @param lastKnownVelocity last known velocity (if known) + */ + fun dragTo( + newOffset: Float, + lastKnownVelocity: Float = 0f + ) +} + +/** + * State of the [anchoredDraggable] modifier. + * Use the constructor overload with anchors if the anchors are defined in composition, or update + * the anchors using [updateAnchors]. + * + * This contains necessary information about any ongoing drag or animation and provides methods + * to change the state either immediately or by starting an animation. + * + * @param initialValue The initial value of the state. + * @param positionalThreshold The positional threshold, in px, to be used when calculating the + * target state while a drag is in progress and when settling after the drag ends. This is the + * distance from the start of a transition. It will be, depending on the direction of the + * interaction, added or subtracted from/to the origin offset. It should always be a positive value. + * @param velocityThreshold The velocity threshold (in px per second) that the end velocity has to + * exceed in order to animate to the next state, even if the [positionalThreshold] has not been + * reached. + * @param animationSpec The default animation that will be used to animate to a new state. + * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. + */ +@Stable +class AnchoredDraggableState( + initialValue: T, + internal val positionalThreshold: (totalDistance: Float) -> Float, + internal val velocityThreshold: () -> Float, + val animationSpec: AnimationSpec, + internal val confirmValueChange: (newValue: T) -> Boolean = { true } +) { + + /** + * Construct an [AnchoredDraggableState] instance with anchors. + * + * @param initialValue The initial value of the state. + * @param anchors The anchors of the state. Use [updateAnchors] to update the anchors later. + * @param animationSpec The default animation that will be used to animate to a new state. + * @param confirmValueChange Optional callback invoked to confirm or veto a pending state + * change. + * @param positionalThreshold The positional threshold, in px, to be used when calculating the + * target state while a drag is in progress and when settling after the drag ends. This is the + * distance from the start of a transition. It will be, depending on the direction of the + * interaction, added or subtracted from/to the origin offset. It should always be a positive + * value. + * @param velocityThreshold The velocity threshold (in px per second) that the end velocity has + * to exceed in order to animate to the next state, even if the [positionalThreshold] has not + * been reached. + */ + constructor( + initialValue: T, + anchors: DraggableAnchors, + positionalThreshold: (totalDistance: Float) -> Float, + velocityThreshold: () -> Float, + animationSpec: AnimationSpec, + confirmValueChange: (newValue: T) -> Boolean = { true } + ) : this( + initialValue, + positionalThreshold, + velocityThreshold, + animationSpec, + confirmValueChange + ) { + this.anchors = anchors + trySnapTo(initialValue) + } + + private val dragMutex = MutatorMutex() + internal var minBound = Float.NEGATIVE_INFINITY + + internal val draggableState = object : DraggableState { + + private val dragScope = object : DragScope { + override fun dragBy(pixels: Float) { + with(anchoredDragScope) { + dragTo(newOffsetForDelta(pixels)) + } + } + } + + override suspend fun drag( + dragPriority: MutatePriority, + block: suspend DragScope.() -> Unit + ) { + this@AnchoredDraggableState.anchoredDrag(dragPriority) { + with(dragScope) { block() } + } + } + + override fun dispatchRawDelta(delta: Float) { + this@AnchoredDraggableState.dispatchRawDelta(delta) + } + } + + /** + * The current value of the [AnchoredDraggableState]. + */ + var currentValue: T by mutableStateOf(initialValue) + private set + + /** + * The target value. This is the closest value to the current offset, taking into account + * positional thresholds. If no interactions like animations or drags are in progress, this + * will be the current value. + */ + val targetValue: T by derivedStateOf { + dragTarget ?: run { + val currentOffset = offset + if (!currentOffset.isNaN()) { + computeTarget(currentOffset, currentValue, velocity = 0f) + } else currentValue + } + } + + /** + * The closest value in the swipe direction from the current offset, not considering thresholds. + * If an [anchoredDrag] is in progress, this will be the target of that anchoredDrag (if + * specified). + */ + internal val closestValue: T by derivedStateOf { + dragTarget ?: run { + val currentOffset = offset + if (!currentOffset.isNaN()) { + computeTargetWithoutThresholds(currentOffset, currentValue) + } else currentValue + } + } + + /** + * The current offset, or [Float.NaN] if it has not been initialized yet. + * + * The offset will be initialized when the anchors are first set through [updateAnchors]. + * + * Strongly consider using [requireOffset] which will throw if the offset is read before it is + * initialized. This helps catch issues early in your workflow. + */ + var offset: Float by mutableFloatStateOf(Float.NaN) + private set + + /** + * Require the current offset. + * + * @see offset + * + * @throws IllegalStateException If the offset has not been initialized yet + */ + fun requireOffset(): Float { + check(!offset.isNaN()) { + "The offset was read before being initialized. Did you access the offset in a phase " + + "before layout, like effects or composition?" + } + return offset + } + + /* +It's a flag to indicate whether anchors are filled or not. +Useful as a flag to let expand(), open() to get to know whether anchors are filled or not +when launched for the very first time + */ + var anchorsFilled: Boolean by mutableStateOf(false) + + /** + * Whether an animation is currently in progress. + */ + val isAnimationRunning: Boolean get() = dragTarget != null + + /** + * The fraction of the progress going from [currentValue] to [closestValue], within [0f..1f] + * bounds, or 1f if the [AnchoredDraggableState] is in a settled state. + */ + /*@FloatRange(from = 0f, to = 1f)*/ + val progress: Float by derivedStateOf(structuralEqualityPolicy()) { + val a = anchors.positionOf(currentValue) + val b = anchors.positionOf(closestValue) + val distance = abs(b - a) + if (!distance.isNaN() && distance > 1e-6f) { + val progress = (this.requireOffset() - a) / (b - a) + // If we are very close to 0f or 1f, we round to the closest + if (progress < 1e-6f) 0f else if (progress > 1 - 1e-6f) 1f else progress + } else 1f + } + + /** + * The velocity of the last known animation. Gets reset to 0f when an animation completes + * successfully, but does not get reset when an animation gets interrupted. + * You can use this value to provide smooth reconciliation behavior when re-targeting an + * animation. + */ + var lastVelocity: Float by mutableFloatStateOf(0f) + private set + + private var dragTarget: T? by mutableStateOf(null) + + var anchors: DraggableAnchors by mutableStateOf(emptyDraggableAnchors()) + private set + + /** + * Update the anchors. If there is no ongoing [anchoredDrag] operation, snap to the [newTarget], + * otherwise restart the ongoing [anchoredDrag] operation (e.g. an animation) with the new + * anchors. + * + * If your anchors depend on the size of the layout, updateAnchors should be called in the + * layout (placement) phase, e.g. through Modifier.onSizeChanged. This ensures that the + * state is set up within the same frame. + * For static anchors, or anchors with different data dependencies, [updateAnchors] is safe to + * be called from side effects or layout. + * + * @param newAnchors The new anchors. + * @param newTarget The new target, by default the closest anchor or the current target if there + * are no anchors. + */ + fun updateAnchors( + newAnchors: DraggableAnchors, + newTarget: T = if (!offset.isNaN()) { + newAnchors.closestAnchor(offset) ?: targetValue + } else targetValue + ) { + if (anchors != newAnchors) { + anchors = newAnchors + // Attempt to snap. If nobody is holding the lock, we can immediately update the offset. + // If anybody is holding the lock, we send a signal to restart the ongoing work with the + // updated anchors. + val snapSuccessful = trySnapTo(newTarget) + if (!snapSuccessful) { + dragTarget = newTarget + } + } + anchorsFilled = true + } + + /** + * Find the closest anchor, taking into account the [velocityThreshold] and + * [positionalThreshold], and settle at it with an animation. + * + * If the [velocity] is lower than the [velocityThreshold], the closest anchor by distance and + * [positionalThreshold] will be the target. If the [velocity] is higher than the + * [velocityThreshold], the [positionalThreshold] will not be considered and the next + * anchor in the direction indicated by the sign of the [velocity] will be the target. + */ + suspend fun settle(velocity: Float) { + val previousValue = this.currentValue + val targetValue = computeTarget( + offset = requireOffset(), + currentValue = previousValue, + velocity = velocity + ) + if (confirmValueChange(targetValue)) { + animateTo(targetValue, velocity) + } else { + // If the user vetoed the state change, rollback to the previous state. + animateTo(previousValue, velocity) + } + } + + private fun computeTarget( + offset: Float, + currentValue: T, + velocity: Float + ): T { + val currentAnchors = anchors + val currentAnchorPosition = currentAnchors.positionOf(currentValue) + val velocityThresholdPx = velocityThreshold() + return if (currentAnchorPosition == offset || currentAnchorPosition.isNaN()) { + currentValue + } else if (currentAnchorPosition < offset) { + // Swiping from lower to upper (positive). + if (velocity >= velocityThresholdPx) { + currentAnchors.closestAnchor(offset, true)!! + } else { + val upper = currentAnchors.closestAnchor(offset, true)!! + val distance = abs(currentAnchors.positionOf(upper) - currentAnchorPosition) + val relativeThreshold = abs(positionalThreshold(distance)) + val absoluteThreshold = abs(currentAnchorPosition + relativeThreshold) + if (offset < absoluteThreshold) currentValue else upper + } + } else { + // Swiping from upper to lower (negative). + if (velocity <= -velocityThresholdPx) { + currentAnchors.closestAnchor(offset, false)!! + } else { + val lower = currentAnchors.closestAnchor(offset, false)!! + val distance = abs(currentAnchorPosition - currentAnchors.positionOf(lower)) + val relativeThreshold = abs(positionalThreshold(distance)) + val absoluteThreshold = abs(currentAnchorPosition - relativeThreshold) + if (offset < 0) { + // For negative offsets, larger absolute thresholds are closer to lower anchors + // than smaller ones. + if (abs(offset) < absoluteThreshold) currentValue else lower + } else { + if (offset > absoluteThreshold) currentValue else lower + } + } + } + } + + private fun computeTargetWithoutThresholds( + offset: Float, + currentValue: T, + ): T { + val currentAnchors = anchors + val currentAnchor = currentAnchors.positionOf(currentValue) + return if (currentAnchor == offset || currentAnchor.isNaN()) { + currentValue + } else if (currentAnchor < offset) { + currentAnchors.closestAnchor(offset, true) ?: currentValue + } else { + currentAnchors.closestAnchor(offset, false) ?: currentValue + } + } + + private val anchoredDragScope: AnchoredDragScope = object : AnchoredDragScope { + override fun dragTo(newOffset: Float, lastKnownVelocity: Float) { + offset = newOffset + lastVelocity = lastKnownVelocity + } + } + + /** + * Call this function to take control of drag logic and perform anchored drag with the latest + * anchors. + * + * All actions that change the [offset] of this [AnchoredDraggableState] must be performed + * within an [anchoredDrag] block (even if they don't call any other methods on this object) + * in order to guarantee that mutual exclusion is enforced. + * + * If [anchoredDrag] is called from elsewhere with the [dragPriority] higher or equal to ongoing + * drag, the ongoing drag will be cancelled. + * + * If the [anchors] change while the [block] is being executed, it will be cancelled and + * re-executed with the latest anchors and target. This allows you to target the correct + * state. + * + * @param dragPriority of the drag operation + * @param block perform anchored drag given the current anchor provided + */ + suspend fun anchoredDrag( + dragPriority: MutatePriority = MutatePriority.Default, + block: suspend AnchoredDragScope.(anchors: DraggableAnchors) -> Unit + ) { + try { + dragMutex.mutate(dragPriority) { + restartable(inputs = { anchors }) { latestAnchors -> + anchoredDragScope.block(latestAnchors) + } + } + } finally { + val closest = anchors.closestAnchor(offset) + if (closest != null && abs(offset - anchors.positionOf(closest)) <= 0.5f) { + currentValue = closest + } + } + } + + /** + * Call this function to take control of drag logic and perform anchored drag with the latest + * anchors and target. + * + * All actions that change the [offset] of this [AnchoredDraggableState] must be performed + * within an [anchoredDrag] block (even if they don't call any other methods on this object) + * in order to guarantee that mutual exclusion is enforced. + * + * This overload allows the caller to hint the target value that this [anchoredDrag] is intended + * to arrive to. This will set [AnchoredDraggableState.targetValue] to provided value so + * consumers can reflect it in their UIs. + * + * If the [anchors] or [AnchoredDraggableState.targetValue] change while the [block] is being + * executed, it will be cancelled and re-executed with the latest anchors and target. This + * allows you to target the correct state. + * + * If [anchoredDrag] is called from elsewhere with the [dragPriority] higher or equal to ongoing + * drag, the ongoing drag will be cancelled. + * + * @param targetValue hint the target value that this [anchoredDrag] is intended to arrive to + * @param dragPriority of the drag operation + * @param block perform anchored drag given the current anchor provided + */ + suspend fun anchoredDrag( + targetValue: T, + dragPriority: MutatePriority = MutatePriority.Default, + block: suspend AnchoredDragScope.(anchors: DraggableAnchors, targetValue: T) -> Unit + ) { + if (anchors.hasAnchorFor(targetValue)) { + try { + dragMutex.mutate(dragPriority) { + dragTarget = targetValue + restartable( + inputs = { anchors to this@AnchoredDraggableState.targetValue } + ) { (latestAnchors, latestTarget) -> + anchoredDragScope.block(latestAnchors, latestTarget) + } + } + } finally { + dragTarget = null + val closest = anchors.closestAnchor(offset) + if (closest != null && abs(offset - anchors.positionOf(closest)) <= 0.5f) { + currentValue = closest + } + } + } else { + // Todo: b/283467401, revisit this behavior + currentValue = targetValue + } + } + + internal fun newOffsetForDelta(delta: Float) = + ((if (offset.isNaN()) 0f else offset) + delta) + .coerceIn(anchors.minAnchor(), anchors.maxAnchor()) + + /** + * Drag by the [delta], coerce it in the bounds and dispatch it to the [AnchoredDraggableState]. + * + * @return The delta the consumed by the [AnchoredDraggableState] + */ + fun dispatchRawDelta(delta: Float): Float { + val newOffset = newOffsetForDelta(delta) + val oldOffset = if (offset.isNaN()) 0f else offset + offset = newOffset + return newOffset - oldOffset + } + + + /** + * Attempt to snap synchronously. Snapping can happen synchronously when there is no other drag + * transaction like a drag or an animation is progress. If there is another interaction in + * progress, the suspending [snapTo] overload needs to be used. + * + * @return true if the synchronous snap was successful, or false if we couldn't snap synchronous + */ + private fun trySnapTo(targetValue: T): Boolean = dragMutex.tryMutate { + with(anchoredDragScope) { + val targetOffset = anchors.positionOf(targetValue) + if (!targetOffset.isNaN()) { + dragTo(targetOffset) + dragTarget = null + } + currentValue = targetValue + } + } + + companion object { + /** + * The default [Saver] implementation for [AnchoredDraggableState]. + */ + fun Saver( + animationSpec: AnimationSpec, + positionalThreshold: (distance: Float) -> Float, + velocityThreshold: () -> Float, + confirmValueChange: (T) -> Boolean = { true }, + ) = Saver, T>( + save = { it.currentValue }, + restore = { + AnchoredDraggableState( + initialValue = it, + animationSpec = animationSpec, + confirmValueChange = confirmValueChange, + positionalThreshold = positionalThreshold, + velocityThreshold = velocityThreshold + ) + } + ) + } +} + +/** + * Snap to a [targetValue] without any animation. + * If the [targetValue] is not in the set of anchors, the [AnchoredDraggableState.currentValue] will + * be updated to the [targetValue] without updating the offset. + * + * @throws CancellationException if the interaction interrupted by another interaction like a + * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call. + * + * @param targetValue The target value of the animation + */ +suspend fun AnchoredDraggableState.snapTo(targetValue: T) { + anchoredDrag(targetValue = targetValue) { anchors, latestTarget -> + val targetOffset = anchors.positionOf(latestTarget) + if (!targetOffset.isNaN()) dragTo(targetOffset) + } +} + +/** + * Animate to a [targetValue]. + * If the [targetValue] is not in the set of anchors, the [AnchoredDraggableState.currentValue] will + * be updated to the [targetValue] without updating the offset. + * + * @throws CancellationException if the interaction interrupted by another interaction like a + * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call. + * + * @param targetValue The target value of the animation + * @param velocity The velocity the animation should start with + */ +suspend fun AnchoredDraggableState.animateTo( + targetValue: T, + velocity: Float = this.lastVelocity, +) { + anchoredDrag(targetValue = targetValue) { anchors, latestTarget -> + val targetOffset = anchors.positionOf(latestTarget) + if (!targetOffset.isNaN()) { + var prev = if (offset.isNaN()) 0f else offset + animate(prev, targetOffset, velocity, animationSpec) { value, velocity -> + // Our onDrag coerces the value within the bounds, but an animation may + // overshoot, for example a spring animation or an overshooting interpolator + // We respect the user's intention and allow the overshoot, but still use + // DraggableState's drag for its mutex. + dragTo(value, velocity) + prev = value + } + } + } +} + +private class AnchoredDragFinishedSignal : CancellationException() { + override fun fillInStackTrace(): Throwable { + stackTrace = emptyArray() + return this + } +} + +private suspend fun restartable(inputs: () -> I, block: suspend (I) -> Unit) { + try { + coroutineScope { + var previousDrag: Job? = null + snapshotFlow(inputs) + .collect { latestInputs -> + previousDrag?.apply { + cancel(AnchoredDragFinishedSignal()) + join() + } + previousDrag = launch(start = CoroutineStart.UNDISPATCHED) { + block(latestInputs) + this@coroutineScope.cancel(AnchoredDragFinishedSignal()) + } + } + } + } catch (anchoredDragFinished: AnchoredDragFinishedSignal) { + // Ignored + } +} + +val AnchoredDraggableState.PreUpPostDownNestedScrollConnection: NestedScrollConnection + get() = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.toFloat() + return if (delta < 0 && source == NestedScrollSource.Drag) { + dispatchRawDelta(delta).toOffset() + } else { + Offset.Zero + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + return if (source == NestedScrollSource.Drag) { + dispatchRawDelta(available.toFloat()).toOffset() + } else { + Offset.Zero + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val toFling = Offset(available.x, available.y).toFloat() + return if (toFling < 0 && offset > minBound) { + settle(velocity = toFling) + // since we go to the anchor with tween settling, consume all for the best UX + available + } else { + Velocity.Zero + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + settle(velocity = Offset(available.x, available.y).toFloat()) + return available + } + + private fun Float.toOffset(): Offset = Offset(0f, this) + + private fun Offset.toFloat(): Float = this.y + } + +val AnchoredDraggableState.PostDownNestedScrollConnection: NestedScrollConnection + get() = object : NestedScrollConnection { + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + return if (source == NestedScrollSource.Drag && available.toFloat() > 0) { + dispatchRawDelta(available.toFloat()).toOffset() + } else { + Offset.Zero + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + settle(velocity = Offset(available.x, available.y).toFloat()) + return available + } + + private fun Float.toOffset(): Offset = Offset(0f, this) + + private fun Offset.toFloat(): Float = this.y + } +val AnchoredDraggableState.NonDismissiblePostDownNestedScrollConnection: NestedScrollConnection + get() = object : NestedScrollConnection { + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.toFloat() + return if (delta < 0 && source == NestedScrollSource.Drag) { + dispatchRawDelta(delta).toOffset() + } else { + Offset.Zero + } + } + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + return if (source == NestedScrollSource.Drag && available.toFloat() < 0) { + dispatchRawDelta(available.toFloat()).toOffset() + } else { + Offset.Zero + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + settle(velocity = Offset(available.x, available.y).toFloat()) + return available + } + + private fun Float.toOffset(): Offset = Offset(0f, this) + + private fun Offset.toFloat(): Float = this.y + } + +val AnchoredDraggableState.NonDismissiblePreUpPostDownNestedScrollConnection: NestedScrollConnection + get() = object : NestedScrollConnection { + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.toFloat() + return if (delta < 0 && source == NestedScrollSource.Drag) { + dispatchRawDelta(delta).toOffset() + } else { + Offset.Zero + } + } + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + return if (source == NestedScrollSource.Drag && available.toFloat() < 0) { + dispatchRawDelta(available.toFloat()).toOffset() + } else { + Offset.Zero + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val toFling = Offset(available.x, available.y).toFloat() + return if (toFling < 0 && offset > minBound) { + settle(velocity = toFling) + // since we go to the anchor with tween settling, consume all for the best UX + available + } else { + Velocity.Zero + } + } + + private fun Float.toOffset(): Offset = Offset(0f, this) + + private fun Offset.toFloat(): Float = this.y + } + +private fun emptyDraggableAnchors() = MapDraggableAnchors(emptyMap()) + +private class MapDraggableAnchors(private val anchors: Map) : DraggableAnchors { + + override fun positionOf(value: T): Float = anchors[value] ?: Float.NaN + override fun hasAnchorFor(value: T) = anchors.containsKey(value) + + override fun closestAnchor(position: Float): T? = anchors.minByOrNull { + abs(position - it.value) + }?.key + + override fun closestAnchor( + position: Float, + searchUpwards: Boolean + ): T? { + return anchors.minByOrNull { (_, anchor) -> + val delta = if (searchUpwards) anchor - position else position - anchor + if (delta < 0) Float.POSITIVE_INFINITY else delta + }?.key + } + + override fun minAnchor() = anchors.values.minOrNull() ?: Float.NaN + + override fun maxAnchor() = anchors.values.maxOrNull() ?: Float.NaN + + override val size: Int + get() = anchors.size + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MapDraggableAnchors<*>) return false + + return anchors == other.anchors + } + + override fun hashCode() = 31 * anchors.hashCode() + + override fun toString() = "MapDraggableAnchors($anchors)" +} \ No newline at end of file diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/compose/ModalPopup.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/compose/ModalPopup.kt index 043664e91..600618998 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/compose/ModalPopup.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/compose/ModalPopup.kt @@ -1,12 +1,16 @@ package com.microsoft.fluentui.compose import android.content.Context +import android.graphics.Outline import android.graphics.PixelFormat +import android.graphics.Rect +import android.os.Build import android.view.Gravity -import android.view.KeyEvent import android.view.View -import android.view.ViewTreeObserver +import android.view.ViewGroup +import android.view.ViewOutlineProvider import android.view.WindowManager +import androidx.annotation.RequiresApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.captionBar @@ -15,7 +19,6 @@ import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.mandatorySystemGestures import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.safeContent import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.tappableElement @@ -33,15 +36,22 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.AbstractComposeView +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.ViewRootForInspector import androidx.compose.ui.semantics.popup import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.findViewTreeViewModelStoreOwner @@ -56,54 +66,76 @@ import java.util.UUID */ @Composable fun ModalPopup( - onDismissRequest: () -> Unit, + onDismissRequest:(() -> Unit)? = null, windowInsetsType: Int = WindowInsetsCompat.Type.systemBars(), content: @Composable () -> Unit, ) { + val properties = PopupProperties( + focusable = true, + ) val view = LocalView.current - val id = rememberSaveable { UUID.randomUUID() } + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current val parentComposition = rememberCompositionContext() val currentContent by rememberUpdatedState(content) - val layoutDirection = LocalLayoutDirection.current + val id = rememberSaveable { UUID.randomUUID() } val modalWindow = remember { ModalWindow( onDismissRequest = onDismissRequest, + properties = properties, composeView = view, + density = density, saveId = id ).apply { - setCustomContent( - parent = parentComposition, - content = { - Box( - Modifier - .semantics { this.popup() } - // Get the size of the content - .onSizeChanged { - popupContentSize = it - } - // Hide the popup while we can't position it correctly - .alpha(if (canCalculatePosition) 1f else 0f) - .windowInsetsPadding( - convertWindowInsetsCompatTypeToWindowInsets(windowInsetsType) - ) - .imePadding() - ) { - currentContent() - } + setCustomContent(parentComposition) { + Box( + Modifier + .semantics { this.popup() } + // Get the size of the content + .onSizeChanged { + popupContentSize = it + } + // Hide the popup while we can't position it correctly + .alpha(if (canCalculatePosition) 1f else 0f) + .windowInsetsPadding( + convertWindowInsetsCompatTypeToWindowInsets(windowInsetsType) + ) + .imePadding() + ) { + currentContent() } - ) + } } } DisposableEffect(modalWindow) { modalWindow.show() - modalWindow.superSetLayoutDirection(layoutDirection) + modalWindow.updateParameters( + onDismissRequest = onDismissRequest, + properties = properties, + layoutDirection = layoutDirection + ) onDispose { modalWindow.disposeComposition() modalWindow.dismiss() } } + + Layout( + content = {}, + modifier = Modifier + .onGloballyPositioned { childCoordinates -> + val parentCoordinates = childCoordinates.parentLayoutCoordinates + if (parentCoordinates != null) { + modalWindow.updateParentLayoutCoordinates(parentCoordinates) + } + } + ) { _, _ -> + modalWindow.parentLayoutDirection = layoutDirection + layout(0, 0) {} + } } + @Composable fun convertWindowInsetsCompatTypeToWindowInsets(windowInsetsCompatType: Int): WindowInsets { return when (windowInsetsCompatType) { @@ -121,17 +153,32 @@ fun convertWindowInsetsCompatTypeToWindowInsets(windowInsetsCompatType: Int): Wi /** Custom compose view for [BottomDrawer] */ private class ModalWindow( - private var onDismissRequest: () -> Unit, + private var onDismissRequest: (() -> Unit)? = null, + private var properties: PopupProperties, private val composeView: View, + density: Density, saveId: UUID, + private val popupLayoutHelper: PopupLayoutHelperImpl = if (Build.VERSION.SDK_INT >= 29) { + PopupLayoutHelperImpl29() + } else { + PopupLayoutHelperImpl() + } ) : AbstractComposeView(composeView.context), - ViewTreeObserver.OnGlobalLayoutListener, ViewRootForInspector { + private val windowManager = + composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + + private val params: WindowManager.LayoutParams = createLayoutParams() + var parentLayoutDirection: LayoutDirection = LayoutDirection.Ltr var popupContentSize: IntSize? by mutableStateOf(null) + private var parentLayoutCoordinates: LayoutCoordinates? by mutableStateOf(null) val canCalculatePosition by derivedStateOf { - popupContentSize != null + parentLayoutCoordinates != null && popupContentSize != null } + + override val subCompositionView: AbstractComposeView get() = this + init { id = android.R.id.content // Set up view owners @@ -141,104 +188,81 @@ private class ModalWindow( setTag(androidx.compose.ui.R.id.compose_view_saveable_id_tag, "Popup:$saveId") // Enable children to draw their shadow by not clipping them clipChildren = false - } - - private val windowManager = - composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager - - - private val params: WindowManager.LayoutParams = - WindowManager.LayoutParams().apply { - // Position bottom sheet from the bottom of the screen - gravity = Gravity.BOTTOM or Gravity.START - // Application panel window - type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL - // Fill up the entire app view - width = WindowManager.LayoutParams.MATCH_PARENT - height = WindowManager.LayoutParams.MATCH_PARENT - - // Format of screen pixels - format = PixelFormat.TRANSLUCENT - // Title used as fallback for a11y services - // TODO: Provide bottom sheet window resource - title = composeView.context.resources.getString( - androidx.compose.ui.R.string.default_popup_window_title - ) - // Get the Window token from the parent view - token = composeView.applicationWindowToken - - // Flags specific to modal bottom sheet. - flags = flags and ( - WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES or - WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM - ).inv() - - flags = flags or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS + with(density) { elevation = 8.dp.toPx() } + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, result: Outline) { + result.setRect(0, 0, view.width, view.height) + result.alpha = 0f + } } + } private var content: @Composable () -> Unit by mutableStateOf({}) override var shouldCreateCompositionOnAttachedToWindow: Boolean = false private set - @Composable - override fun Content() { - content() + fun show() { + windowManager.addView(this, params) } fun setCustomContent( parent: CompositionContext? = null, content: @Composable () -> Unit ) { - parent?.let { setParentCompositionContext(it) } + setParentCompositionContext(parent) this.content = content shouldCreateCompositionOnAttachedToWindow = true } - fun show() { - windowManager.addView(this, params) + @Composable + override fun Content() { + content() } - fun dismiss() { - this.setViewTreeLifecycleOwner(null) - setViewTreeSavedStateRegistryOwner(null) - composeView.viewTreeObserver.removeOnGlobalLayoutListener(this) - windowManager.removeViewImmediate(this) + private fun focusable(isFocusable: Boolean) = applyNewFlags( + if (!isFocusable) { + params.flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + } else { + params.flags and (WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE.inv()) + } + ) + + private fun applyNewFlags(flags: Int) { + params.flags = flags + popupLayoutHelper.updateViewLayout(windowManager, this, params) } - /** - * Taken from PopupWindow. Calls [onDismissRequest] when back button is pressed. - */ - override fun dispatchKeyEvent(event: KeyEvent): Boolean { - if (event.keyCode == KeyEvent.KEYCODE_BACK) { - if (keyDispatcherState == null) { - return super.dispatchKeyEvent(event) - } - if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { - val state = keyDispatcherState - state?.startTracking(event, this) - return true - } else if (event.action == KeyEvent.ACTION_UP) { - val state = keyDispatcherState - if (state != null && state.isTracking(event) && !event.isCanceled) { - onDismissRequest() - return true - } - } + fun updateParameters( + onDismissRequest: (() -> Unit)?, + properties: PopupProperties, + layoutDirection: LayoutDirection + ) { + this.onDismissRequest = onDismissRequest + if (properties.usePlatformDefaultWidth && !this.properties.usePlatformDefaultWidth) { + params.width = WindowManager.LayoutParams.WRAP_CONTENT + params.height = WindowManager.LayoutParams.WRAP_CONTENT + popupLayoutHelper.updateViewLayout(windowManager, this, params) } - return super.dispatchKeyEvent(event) + this.properties = properties + focusable(properties.focusable) + superSetLayoutDirection(layoutDirection) + } + + fun updateParentLayoutCoordinates(parentLayoutCoordinates: LayoutCoordinates) { + this.parentLayoutCoordinates = parentLayoutCoordinates } - override fun onGlobalLayout() { - // No-op + fun dismiss() { + this.setViewTreeLifecycleOwner(null) + setViewTreeSavedStateRegistryOwner(null) + windowManager.removeViewImmediate(this) } override fun setLayoutDirection(layoutDirection: Int) { - // Do nothing. ViewRootImpl will call this method attempting to set the layout direction - // from the context's locale, but we have one already from the parent composition. + // Do nothing. } - // Sets the "real" layout direction for our content that we obtain from the parent composition. fun superSetLayoutDirection(layoutDirection: LayoutDirection) { val direction = when (layoutDirection) { LayoutDirection.Ltr -> android.util.LayoutDirection.LTR @@ -246,4 +270,70 @@ private class ModalWindow( } super.setLayoutDirection(direction) } -} \ No newline at end of file + + private fun createLayoutParams(): WindowManager.LayoutParams{ + return WindowManager.LayoutParams().apply { + // Position bottom sheet from the bottom of the screen + gravity = Gravity.BOTTOM or Gravity.START + // Application panel window + type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL + // Fill up the entire app view + width = WindowManager.LayoutParams.MATCH_PARENT + // for build versions less than or equal to S_V2, set the height to wrap content + height = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) + WindowManager.LayoutParams.WRAP_CONTENT + else + WindowManager.LayoutParams.MATCH_PARENT + + // Format of screen pixels + format = PixelFormat.TRANSLUCENT + // Title used as fallback for a11y services + // TODO: Provide bottom sheet window resource + title = composeView.context.resources.getString( + androidx.compose.ui.R.string.default_popup_window_title + ) + // Get the Window token from the parent view + token = composeView.applicationWindowToken + + // Flags specific to modal bottom sheet. + flags = flags and ( + WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES or + WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM + ).inv() + + flags = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + flags + } else flags or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS + } + } +} + +private open class PopupLayoutHelperImpl { + + open fun setGestureExclusionRects(composeView: View, width: Int, height: Int) { + //For Android versions below API 29, it’s not necessary to explicitly exclude the entire screen from system gestures. + // The skeleton method is defined to keep consistency in the two objects. + } + + fun updateViewLayout( + windowManager: WindowManager, + popupView: View, + params: ViewGroup.LayoutParams + ) { + windowManager.updateViewLayout(popupView, params) + } +} + +@RequiresApi(29) // android.view.View#setSystemGestureExclusionRects call requires API 29 and above +private class PopupLayoutHelperImpl29 : PopupLayoutHelperImpl() { + override fun setGestureExclusionRects(composeView: View, width: Int, height: Int) { // We need to explicitly specify to exclude the entire screen from system gestures + composeView.systemGestureExclusionRects = mutableListOf( + Rect( + 0, + 0, + width, + height + ) + ) + } +} diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/compose/Swipeable.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/compose/Swipeable.kt index 141b8d949..ad3d97561 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/compose/Swipeable.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/compose/Swipeable.kt @@ -16,6 +16,7 @@ package com.microsoft.fluentui.compose +import android.annotation.SuppressLint import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.SpringSpec @@ -544,6 +545,7 @@ internal fun rememberSwipeableStateFor( * @param velocityThreshold The threshold (in dp per second) that the end velocity has to exceed * in order to animate to the next state, even if the positional [thresholds] have not been reached. */ +@SuppressLint("ModifierFactoryUnreferencedReceiver") fun Modifier.swipeable( state: SwipeableState, anchors: Map, 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 16ba30df7..e3bf894aa 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 @@ -33,6 +33,8 @@ object UndefinedControlToken: IControlToken */ open class ControlTokens : IControlTokens { enum class ControlType : IType { + AcrylicPaneControlType, + ActionBarControlType, AnnouncementCardControlType, AppBarControlType, AvatarControlType, @@ -76,11 +78,14 @@ open class ControlTokens : IControlTokens { TextFieldControlType, ToggleSwitchControlType, TooltipControlType, + ViewPagerControlType } override val tokens: TokenSet by lazy { TokenSet { type -> when (type) { + ControlType.AcrylicPaneControlType -> AcrylicPaneTokens() + ControlType.ActionBarControlType -> ActionBarTokens() ControlType.AnnouncementCardControlType -> AnnouncementCardTokens() ControlType.AppBarControlType -> AppBarTokens() ControlType.AvatarControlType -> AvatarTokens() @@ -124,6 +129,7 @@ open class ControlTokens : IControlTokens { ControlType.TextFieldControlType -> TextFieldTokens() ControlType.ToggleSwitchControlType -> ToggleSwitchTokens() ControlType.TooltipControlType -> TooltipTokens() + ControlType.ViewPagerControlType -> ViewPagerTokens() else -> { UndefinedControlToken } diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/FluentGlobalTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/FluentGlobalTokens.kt index 02fbbcf2c..c8c7cf0f9 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/FluentGlobalTokens.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/FluentGlobalTokens.kt @@ -224,6 +224,7 @@ object FluentGlobalTokens { CornerRadius40(4.dp), CornerRadius80(8.dp), CornerRadius120(12.dp), + CornerRadius160(16.dp), CornerRadiusCircle(9999.dp) } diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/FluentIcon.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/FluentIcon.kt index 13ef17c4d..536d5525f 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/FluentIcon.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/FluentIcon.kt @@ -2,10 +2,14 @@ package com.microsoft.fluentui.theme.token import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.remember @@ -15,11 +19,11 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.toolingGraphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.semantics.Role @@ -37,6 +41,7 @@ data class FluentIcon( val tint: Color? = null, val flipOnRtl: Boolean = false, val enabled: Boolean = true, + val onLongClick: (() -> Unit)? = null, //TODO: Add tokens for ripple val onClick: (() -> Unit)? = null ) { @Composable @@ -75,6 +80,7 @@ fun Icon( flipOnRtl = icon.flipOnRtl, tint = icon.tint ?: tint, enabled = icon.enabled, + onLongClick = icon.onLongClick, onClick = icon.onClick ) } @@ -103,6 +109,7 @@ fun Icon( flipOnRtl: Boolean = false, tint: Color = Color.Unspecified, enabled: Boolean = true, + onLongClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null ) { Icon( @@ -112,6 +119,7 @@ fun Icon( flipOnRtl = flipOnRtl, tint = tint, enabled = enabled, + onLongClick = onLongClick, onClick = onClick ) } @@ -131,6 +139,35 @@ fun Icon( * @param enabled Boolean to define if icon is clickable or not * @param onClick onClick Lambda to be invoked when icon is clicked. */ +@Composable +fun Modifier.clickAndLongClick( + onClick: () -> Unit, + onLongClick: () -> Unit, + rippleColor: Color = Color.Unspecified, +): Modifier { + val interactionSource = remember { MutableInteractionSource() } + + return this + .indication(interactionSource, rememberRipple(color = rippleColor)) + .pointerInput(Unit) { + detectTapGestures( + onPress = { offset -> + val press = PressInteraction.Press(offset) + interactionSource.emit(press) + val released = tryAwaitRelease() // for hold clicks + val endInteraction = if (released) { + PressInteraction.Release(press) + } else { + PressInteraction.Cancel(press) + } + interactionSource.emit(endInteraction) + }, + onTap = { onClick() }, + onLongPress = { onLongClick() } + ) + } +} + @Composable fun Icon( painter: Painter, @@ -139,6 +176,7 @@ fun Icon( flipOnRtl: Boolean = false, tint: Color = Color.Unspecified, enabled: Boolean = true, + onLongClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null ) { val colorFilter = if (tint == Color.Unspecified) null else ColorFilter.tint(tint) @@ -152,11 +190,19 @@ fun Icon( } val clickableModifier = Modifier.then( - if (onClick != null) Modifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = LocalIndication.current, - enabled = enabled, - onClick = onClick + if (onClick != null) + Modifier.then ( + if(onLongClick != null) Modifier.clickAndLongClick( + onClick = onClick, + onLongClick = onLongClick, + rippleColor = Color.Unspecified + ) + else Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = LocalIndication.current, + enabled = enabled, + onClick = onClick + ) ) else Modifier ) 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 new file mode 100644 index 000000000..3b46f27a9 --- /dev/null +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/AcrylicPaneTokens.kt @@ -0,0 +1,50 @@ +package com.microsoft.fluentui.theme.token.controlTokens + + +import android.os.Parcelable +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.token.ControlInfo +import com.microsoft.fluentui.theme.token.FluentStyle +import com.microsoft.fluentui.theme.token.IControlToken +import kotlinx.parcelize.Parcelize + +open class AcrylicPaneInfo( + val style: FluentStyle = FluentStyle.Neutral +) : ControlInfo + +@Parcelize +open class AcrylicPaneTokens : IControlToken, Parcelable { + + @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( + colors = listOf( + startColor, + startColor, + startColor, + startColor.copy(alpha = 0.5f), + startColor.copy(alpha = 0.0f), + ), + tileMode = TileMode.Decal + ) + } + else{ + val startColor: Color = Color(0xFE106cbc) + return Brush.verticalGradient( + colors = listOf( + startColor, + startColor, + startColor.copy(alpha = 0.8f), + startColor.copy(alpha = 0.5f), + startColor.copy(alpha = 0.0f), + ), + tileMode = TileMode.Decal + ) + } + } +} \ No newline at end of file diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/ActionBarTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/ActionBarTokens.kt new file mode 100644 index 000000000..5ae11108f --- /dev/null +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/ActionBarTokens.kt @@ -0,0 +1,38 @@ +package com.microsoft.fluentui.theme.token.controlTokens + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import com.microsoft.fluentui.theme.FluentTheme.aliasTokens +import com.microsoft.fluentui.theme.FluentTheme.themeMode +import com.microsoft.fluentui.theme.token.ControlInfo +import com.microsoft.fluentui.theme.token.FluentAliasTokens +import com.microsoft.fluentui.theme.token.FluentGlobalTokens +import com.microsoft.fluentui.theme.token.IControlToken + +import kotlinx.parcelize.Parcelize + +enum class ACTIONBARTYPE { + BASIC, + ICON, + CAROUSEL +} + +open class ActionBarInfo: ControlInfo + +@Parcelize +open class ActionBarTokens : IControlToken, Parcelable { + + @Composable + open fun actionBarHeight(actionBarInfo: ActionBarInfo): Dp { + return FluentGlobalTokens.SizeTokens.Size480.value + } + + @Composable + open fun actionBarColor(actionBarInfo: ActionBarInfo): Color { + return aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.ForegroundOnColor].value( + themeMode = themeMode + ) + } +} diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/AppBarTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/AppBarTokens.kt index 032f80f43..72da65b80 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/AppBarTokens.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/AppBarTokens.kt @@ -5,11 +5,13 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.microsoft.fluentui.theme.FluentTheme @@ -28,6 +30,12 @@ enum class AppBarSize { Small } +open class TooltipControls( + var enableTitleTooltip: Boolean = false, + var enableSubtitleTooltip: Boolean = false, + var enableNavigationIconTooltip: Boolean = false +) {} + open class AppBarInfo( val style: FluentStyle = FluentStyle.Neutral, val appBarSize: AppBarSize = AppBarSize.Medium @@ -143,6 +151,55 @@ open class AppBarTokens : IControlToken, Parcelable { } } + @Composable + open fun tooltipVisibilityControls(info: AppBarInfo): TooltipControls { + return TooltipControls( + enableTitleTooltip = false, + enableSubtitleTooltip = false, + enableNavigationIconTooltip = false + ) + } + + @Composable + open fun tooltipTextStyle(info: AppBarInfo): TextStyle { + return FluentTheme.aliasTokens.typography[FluentAliasTokens.TypographyTokens.Body2].merge( + TextStyle( + color = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.ForegroundLightStatic].value( + themeMode = FluentTheme.themeMode + ) + ) + ) + } + + @Composable + open fun tooltipBackgroundBrush(info: AppBarInfo): Brush { + return SolidColor( + FluentTheme.aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.BackgroundDarkStatic].value( + themeMode = FluentTheme.themeMode + ) + ) + } + + @Composable + open fun tooltipCornerRadius(info: AppBarInfo): Dp { + return FluentGlobalTokens.CornerRadiusTokens.CornerRadius80.value + } + + @Composable + open fun tooltipRippleColor(info: AppBarInfo): Color { + return Color.Unspecified + } + + @Composable + open fun tooltipOffset(info: AppBarInfo): DpOffset { + return DpOffset(x = 0.dp, y = 0.dp) + } + + @Composable + open fun tooltipTimeout(info: AppBarInfo): Long { + return 2000L // Default timeout for tooltip in milliseconds + } + @Composable open fun subtitleTextColor(info: AppBarInfo): Color { return when (info.style) { @@ -169,7 +226,6 @@ open class AppBarTokens : IControlToken, Parcelable { AppBarSize.Large -> FluentTheme.aliasTokens.typography[FluentAliasTokens.TypographyTokens.Title1] AppBarSize.Medium -> FluentTheme.aliasTokens.typography[FluentAliasTokens.TypographyTokens.Title2] AppBarSize.Small -> FluentTheme.aliasTokens.typography[FluentAliasTokens.TypographyTokens.Body1Strong] - else -> TextStyle(fontSize = 0.sp) } } @@ -203,7 +259,7 @@ open class AppBarTokens : IControlToken, Parcelable { @Composable open fun navigationIconPadding(info: AppBarInfo): PaddingValues { return when (info.appBarSize) { - AppBarSize.Large -> PaddingValues() + AppBarSize.Large -> PaddingValues(16.dp) AppBarSize.Medium -> PaddingValues(16.dp) AppBarSize.Small -> PaddingValues(16.dp) } @@ -213,7 +269,7 @@ open class AppBarTokens : IControlToken, Parcelable { open fun textPadding(info: AppBarInfo): PaddingValues { return when (info.appBarSize) { AppBarSize.Large -> PaddingValues(start = 12.dp) - AppBarSize.Medium -> PaddingValues() + AppBarSize.Medium -> PaddingValues(start = 8.dp) AppBarSize.Small -> PaddingValues(start = 8.dp) } } diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/AvatarGroupTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/AvatarGroupTokens.kt index 7e3fa5f13..b206f65b7 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/AvatarGroupTokens.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/AvatarGroupTokens.kt @@ -13,7 +13,8 @@ import kotlinx.parcelize.Parcelize enum class AvatarGroupStyle { Stack, - Pile + Pile, + Pie } open class AvatarGroupInfo( @@ -107,6 +108,8 @@ open class AvatarGroupTokens : IControlToken, Parcelable { AvatarSize.Size72 -> FluentGlobalTokens.SizeTokens.Size80 .value } + + AvatarGroupStyle.Pie -> 0.dp } } diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/AvatarTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/AvatarTokens.kt index 2932c4dc2..0f52b3dbf 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/AvatarTokens.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/AvatarTokens.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.TextStyle @@ -128,6 +129,7 @@ import com.microsoft.fluentui.icons.avataricons.presence.unknown.medium.Dark import com.microsoft.fluentui.icons.avataricons.presence.unknown.medium.Light import com.microsoft.fluentui.icons.avataricons.presence.unknown.small.Dark import com.microsoft.fluentui.icons.avataricons.presence.unknown.small.Light +import com.microsoft.fluentui.theme.FluentTheme import com.microsoft.fluentui.theme.FluentTheme.aliasTokens import com.microsoft.fluentui.theme.FluentTheme.themeMode import com.microsoft.fluentui.theme.token.* @@ -211,31 +213,37 @@ open class AvatarTokens(private val activityRingToken: ActivityRingsToken = Acti lineHeight = 12.sp, fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value ) + AvatarSize.Size20 -> TextStyle( fontSize = 9.sp, lineHeight = 12.sp, fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value ) + AvatarSize.Size24 -> TextStyle( fontSize = FluentGlobalTokens.FontSizeTokens.Size100.value, lineHeight = FluentGlobalTokens.LineHeightTokens.Size100.value, fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value ) + AvatarSize.Size32 -> TextStyle( fontSize = FluentGlobalTokens.FontSizeTokens.Size200.value, lineHeight = FluentGlobalTokens.LineHeightTokens.Size200.value, fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value ) + AvatarSize.Size40 -> TextStyle( fontSize = FluentGlobalTokens.FontSizeTokens.Size300.value, lineHeight = FluentGlobalTokens.LineHeightTokens.Size300.value, fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value ) + AvatarSize.Size56 -> TextStyle( fontSize = FluentGlobalTokens.FontSizeTokens.Size500.value, lineHeight = FluentGlobalTokens.LineHeightTokens.Size500.value, fontWeight = FluentGlobalTokens.FontWeightTokens.Medium.value ) + AvatarSize.Size72 -> TextStyle( fontSize = FluentGlobalTokens.FontSizeTokens.Size700.value, lineHeight = FluentGlobalTokens.LineHeightTokens.Size700.value, @@ -260,27 +268,25 @@ open class AvatarTokens(private val activityRingToken: ActivityRingsToken = Acti @Composable open fun icon(avatarInfo: AvatarInfo): ImageVector { return when (avatarStyle(avatarInfo)) { - AvatarStyle.Standard, AvatarStyle.StandardInverted -> - when (avatarInfo.size) { - AvatarSize.Size16 -> AvatarIcons.Icon.Standard.Xsmall - AvatarSize.Size20 -> AvatarIcons.Icon.Standard.Small - AvatarSize.Size24 -> AvatarIcons.Icon.Standard.Small - AvatarSize.Size32 -> AvatarIcons.Icon.Standard.Medium - AvatarSize.Size40 -> AvatarIcons.Icon.Standard.Large - AvatarSize.Size56 -> AvatarIcons.Icon.Standard.Xlarge - AvatarSize.Size72 -> AvatarIcons.Icon.Standard.Xxlarge - } + AvatarStyle.Standard, AvatarStyle.StandardInverted -> when (avatarInfo.size) { + AvatarSize.Size16 -> AvatarIcons.Icon.Standard.Xsmall + AvatarSize.Size20 -> AvatarIcons.Icon.Standard.Small + AvatarSize.Size24 -> AvatarIcons.Icon.Standard.Small + AvatarSize.Size32 -> AvatarIcons.Icon.Standard.Medium + AvatarSize.Size40 -> AvatarIcons.Icon.Standard.Large + AvatarSize.Size56 -> AvatarIcons.Icon.Standard.Xlarge + AvatarSize.Size72 -> AvatarIcons.Icon.Standard.Xxlarge + } - AvatarStyle.Anonymous, AvatarStyle.AnonymousAccent -> - when (avatarInfo.size) { - AvatarSize.Size16 -> AvatarIcons.Icon.Anonymous.Xsmall - AvatarSize.Size20 -> AvatarIcons.Icon.Anonymous.Small - AvatarSize.Size24 -> AvatarIcons.Icon.Anonymous.Small - AvatarSize.Size32 -> AvatarIcons.Icon.Anonymous.Medium - AvatarSize.Size40 -> AvatarIcons.Icon.Anonymous.Large - AvatarSize.Size56 -> AvatarIcons.Icon.Anonymous.Xlarge - AvatarSize.Size72 -> AvatarIcons.Icon.Anonymous.Xxlarge - } + AvatarStyle.Anonymous, AvatarStyle.AnonymousAccent -> when (avatarInfo.size) { + AvatarSize.Size16 -> AvatarIcons.Icon.Anonymous.Xsmall + AvatarSize.Size20 -> AvatarIcons.Icon.Anonymous.Small + AvatarSize.Size24 -> AvatarIcons.Icon.Anonymous.Small + AvatarSize.Size32 -> AvatarIcons.Icon.Anonymous.Medium + AvatarSize.Size40 -> AvatarIcons.Icon.Anonymous.Large + AvatarSize.Size56 -> AvatarIcons.Icon.Anonymous.Xlarge + AvatarSize.Size72 -> AvatarIcons.Icon.Anonymous.Xxlarge + } } } @@ -289,12 +295,9 @@ open class AvatarTokens(private val activityRingToken: ActivityRingsToken = Acti return if (avatarInfo.isImageAvailable || avatarInfo.hasValidInitials) { FluentColor( light = calculatedColor( - avatarInfo.calculatedColorKey, - FluentGlobalTokens.SharedColorsTokens.Shade30 - ), - dark = calculatedColor( - avatarInfo.calculatedColorKey, - FluentGlobalTokens.SharedColorsTokens.Tint40 + avatarInfo.calculatedColorKey, FluentGlobalTokens.SharedColorsTokens.Shade30 + ), dark = calculatedColor( + avatarInfo.calculatedColorKey, FluentGlobalTokens.SharedColorsTokens.Tint40 ) ).value( themeMode = themeMode @@ -305,22 +308,21 @@ open class AvatarTokens(private val activityRingToken: ActivityRingsToken = Acti ) } else { when (avatarStyle(avatarInfo)) { - AvatarStyle.Standard -> - aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.ForegroundOnColor].value( - themeMode = themeMode - ) - AvatarStyle.StandardInverted -> - aliasTokens.brandForegroundColor[FluentAliasTokens.BrandForegroundColorTokens.BrandForeground1].value( - themeMode = themeMode - ) - AvatarStyle.Anonymous -> - aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground2].value( - themeMode = themeMode - ) - AvatarStyle.AnonymousAccent -> - aliasTokens.brandForegroundColor[FluentAliasTokens.BrandForegroundColorTokens.BrandForeground1].value( - themeMode = themeMode - ) + AvatarStyle.Standard -> aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.ForegroundOnColor].value( + themeMode = themeMode + ) + + AvatarStyle.StandardInverted -> aliasTokens.brandForegroundColor[FluentAliasTokens.BrandForegroundColorTokens.BrandForeground1].value( + themeMode = themeMode + ) + + AvatarStyle.Anonymous -> aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground2].value( + themeMode = themeMode + ) + + AvatarStyle.AnonymousAccent -> aliasTokens.brandForegroundColor[FluentAliasTokens.BrandForegroundColorTokens.BrandForeground1].value( + themeMode = themeMode + ) } } } @@ -331,12 +333,9 @@ open class AvatarTokens(private val activityRingToken: ActivityRingsToken = Acti if (avatarInfo.isImageAvailable || avatarInfo.hasValidInitials) { FluentColor( light = calculatedColor( - avatarInfo.calculatedColorKey, - FluentGlobalTokens.SharedColorsTokens.Tint40 - ), - dark = calculatedColor( - avatarInfo.calculatedColorKey, - FluentGlobalTokens.SharedColorsTokens.Shade30 + avatarInfo.calculatedColorKey, FluentGlobalTokens.SharedColorsTokens.Tint40 + ), dark = calculatedColor( + avatarInfo.calculatedColorKey, FluentGlobalTokens.SharedColorsTokens.Shade30 ) ).value( themeMode = themeMode @@ -347,22 +346,21 @@ open class AvatarTokens(private val activityRingToken: ActivityRingsToken = Acti ) } else { when (avatarStyle(avatarInfo)) { - AvatarStyle.Standard -> - aliasTokens.brandBackgroundColor[FluentAliasTokens.BrandBackgroundColorTokens.BrandBackground1].value( - themeMode = themeMode - ) - AvatarStyle.StandardInverted -> - aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value( - themeMode = themeMode - ) - AvatarStyle.Anonymous -> - aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background5].value( - themeMode = themeMode - ) - AvatarStyle.AnonymousAccent -> - aliasTokens.brandBackgroundColor[FluentAliasTokens.BrandBackgroundColorTokens.BrandBackgroundTint].value( - themeMode = themeMode - ) + AvatarStyle.Standard -> aliasTokens.brandBackgroundColor[FluentAliasTokens.BrandBackgroundColorTokens.BrandBackground1].value( + themeMode = themeMode + ) + + AvatarStyle.StandardInverted -> aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value( + themeMode = themeMode + ) + + AvatarStyle.Anonymous -> aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background5].value( + themeMode = themeMode + ) + + AvatarStyle.AnonymousAccent -> aliasTokens.brandBackgroundColor[FluentAliasTokens.BrandBackgroundColorTokens.BrandBackgroundTint].value( + themeMode = themeMode + ) } } ) @@ -371,245 +369,284 @@ open class AvatarTokens(private val activityRingToken: ActivityRingsToken = Acti @Composable open fun presenceIcon(avatarInfo: AvatarInfo): FluentIcon { return when (avatarInfo.status) { - AvatarStatus.Available -> - when (avatarInfo.size) { - AvatarSize.Size16 -> FluentIcon() - AvatarSize.Size20 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Small.Light else AvatarIcons.Presence.Available.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Small.Dark else AvatarIcons.Presence.Available.Small.Dark - ) - AvatarSize.Size24 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Small.Light else AvatarIcons.Presence.Available.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Small.Dark else AvatarIcons.Presence.Available.Small.Dark - ) + AvatarStatus.Available -> when (avatarInfo.size) { + AvatarSize.Size16 -> FluentIcon() + AvatarSize.Size20 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Small.Light else AvatarIcons.Presence.Available.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Small.Dark else AvatarIcons.Presence.Available.Small.Dark + ) - AvatarSize.Size32 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Small.Light else AvatarIcons.Presence.Available.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Small.Dark else AvatarIcons.Presence.Available.Small.Dark - ) + AvatarSize.Size24 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Small.Light else AvatarIcons.Presence.Available.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Small.Dark else AvatarIcons.Presence.Available.Small.Dark + ) - AvatarSize.Size40 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Medium.Light else AvatarIcons.Presence.Available.Medium.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Medium.Dark else AvatarIcons.Presence.Available.Medium.Dark - ) + AvatarSize.Size32 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Small.Light else AvatarIcons.Presence.Available.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Small.Dark else AvatarIcons.Presence.Available.Small.Dark + ) - AvatarSize.Size56 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Medium.Light else AvatarIcons.Presence.Available.Medium.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Medium.Dark else AvatarIcons.Presence.Available.Medium.Dark - ) + AvatarSize.Size40 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Medium.Light else AvatarIcons.Presence.Available.Medium.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Medium.Dark else AvatarIcons.Presence.Available.Medium.Dark + ) - AvatarSize.Size72 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Large.Light else AvatarIcons.Presence.Available.Large.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Large.Dark else AvatarIcons.Presence.Available.Large.Dark - ) - } + AvatarSize.Size56 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Medium.Light else AvatarIcons.Presence.Available.Medium.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Medium.Dark else AvatarIcons.Presence.Available.Medium.Dark + ) - AvatarStatus.Busy -> - when (avatarInfo.size) { - AvatarSize.Size16 -> FluentIcon() - AvatarSize.Size20 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Small.Light else AvatarIcons.Presence.Busy.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Small.Dark else AvatarIcons.Presence.Busy.Small.Dark - ) + AvatarSize.Size72 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Large.Light else AvatarIcons.Presence.Available.Large.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Availableoof.Large.Dark else AvatarIcons.Presence.Available.Large.Dark + ) + } - AvatarSize.Size24 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Small.Light else AvatarIcons.Presence.Busy.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Small.Dark else AvatarIcons.Presence.Busy.Small.Dark - ) + AvatarStatus.Busy -> when (avatarInfo.size) { + AvatarSize.Size16 -> FluentIcon() + AvatarSize.Size20 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Small.Light else AvatarIcons.Presence.Busy.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Small.Dark else AvatarIcons.Presence.Busy.Small.Dark + ) - AvatarSize.Size32 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Small.Light else AvatarIcons.Presence.Busy.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Small.Dark else AvatarIcons.Presence.Busy.Small.Dark - ) + AvatarSize.Size24 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Small.Light else AvatarIcons.Presence.Busy.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Small.Dark else AvatarIcons.Presence.Busy.Small.Dark + ) - AvatarSize.Size40 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Medium.Light else AvatarIcons.Presence.Busy.Medium.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Medium.Dark else AvatarIcons.Presence.Busy.Medium.Dark - ) + AvatarSize.Size32 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Small.Light else AvatarIcons.Presence.Busy.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Small.Dark else AvatarIcons.Presence.Busy.Small.Dark + ) - AvatarSize.Size56 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Medium.Light else AvatarIcons.Presence.Busy.Medium.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Medium.Dark else AvatarIcons.Presence.Busy.Medium.Dark - ) + AvatarSize.Size40 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Medium.Light else AvatarIcons.Presence.Busy.Medium.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Medium.Dark else AvatarIcons.Presence.Busy.Medium.Dark + ) - AvatarSize.Size72 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Large.Light else AvatarIcons.Presence.Busy.Large.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Large.Dark else AvatarIcons.Presence.Busy.Large.Dark - ) - } + AvatarSize.Size56 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Medium.Light else AvatarIcons.Presence.Busy.Medium.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Medium.Dark else AvatarIcons.Presence.Busy.Medium.Dark + ) - AvatarStatus.Away -> - when (avatarInfo.size) { - AvatarSize.Size16 -> FluentIcon() - AvatarSize.Size20 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Small.Light else AvatarIcons.Presence.Away.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Small.Dark else AvatarIcons.Presence.Away.Small.Dark - ) + AvatarSize.Size72 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Large.Light else AvatarIcons.Presence.Busy.Large.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Busyoof.Large.Dark else AvatarIcons.Presence.Busy.Large.Dark + ) + } - AvatarSize.Size24 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Small.Light else AvatarIcons.Presence.Away.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Small.Dark else AvatarIcons.Presence.Away.Small.Dark - ) + AvatarStatus.Away -> when (avatarInfo.size) { + AvatarSize.Size16 -> FluentIcon() + AvatarSize.Size20 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Small.Light else AvatarIcons.Presence.Away.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Small.Dark else AvatarIcons.Presence.Away.Small.Dark + ) - AvatarSize.Size32 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Small.Light else AvatarIcons.Presence.Away.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Small.Dark else AvatarIcons.Presence.Away.Small.Dark - ) + AvatarSize.Size24 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Small.Light else AvatarIcons.Presence.Away.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Small.Dark else AvatarIcons.Presence.Away.Small.Dark + ) - AvatarSize.Size40 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Medium.Light else AvatarIcons.Presence.Away.Medium.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Medium.Dark else AvatarIcons.Presence.Away.Medium.Dark - ) + AvatarSize.Size32 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Small.Light else AvatarIcons.Presence.Away.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Small.Dark else AvatarIcons.Presence.Away.Small.Dark + ) - AvatarSize.Size56 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Medium.Light else AvatarIcons.Presence.Away.Medium.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Medium.Dark else AvatarIcons.Presence.Away.Medium.Dark - ) + AvatarSize.Size40 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Medium.Light else AvatarIcons.Presence.Away.Medium.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Medium.Dark else AvatarIcons.Presence.Away.Medium.Dark + ) - AvatarSize.Size72 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Large.Light else AvatarIcons.Presence.Away.Large.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Large.Dark else AvatarIcons.Presence.Away.Large.Dark - ) - } + AvatarSize.Size56 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Medium.Light else AvatarIcons.Presence.Away.Medium.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Medium.Dark else AvatarIcons.Presence.Away.Medium.Dark + ) - AvatarStatus.DND -> - when (avatarInfo.size) { - AvatarSize.Size16 -> FluentIcon() - AvatarSize.Size20 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Small.Light else AvatarIcons.Presence.Dnd.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Small.Dark else AvatarIcons.Presence.Dnd.Small.Dark - ) + AvatarSize.Size72 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Large.Light else AvatarIcons.Presence.Away.Large.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Awayoof.Large.Dark else AvatarIcons.Presence.Away.Large.Dark + ) + } - AvatarSize.Size24 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Small.Light else AvatarIcons.Presence.Dnd.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Small.Dark else AvatarIcons.Presence.Dnd.Small.Dark - ) + AvatarStatus.DND -> when (avatarInfo.size) { + AvatarSize.Size16 -> FluentIcon() + AvatarSize.Size20 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Small.Light else AvatarIcons.Presence.Dnd.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Small.Dark else AvatarIcons.Presence.Dnd.Small.Dark + ) - AvatarSize.Size32 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Small.Light else AvatarIcons.Presence.Dnd.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Small.Dark else AvatarIcons.Presence.Dnd.Small.Dark - ) + AvatarSize.Size24 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Small.Light else AvatarIcons.Presence.Dnd.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Small.Dark else AvatarIcons.Presence.Dnd.Small.Dark + ) - AvatarSize.Size40 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Medium.Light else AvatarIcons.Presence.Dnd.Medium.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Medium.Dark else AvatarIcons.Presence.Dnd.Medium.Dark - ) + AvatarSize.Size32 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Small.Light else AvatarIcons.Presence.Dnd.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Small.Dark else AvatarIcons.Presence.Dnd.Small.Dark + ) - AvatarSize.Size56 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Medium.Light else AvatarIcons.Presence.Dnd.Medium.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Medium.Dark else AvatarIcons.Presence.Dnd.Medium.Dark - ) + AvatarSize.Size40 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Medium.Light else AvatarIcons.Presence.Dnd.Medium.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Medium.Dark else AvatarIcons.Presence.Dnd.Medium.Dark + ) - AvatarSize.Size72 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Large.Light else AvatarIcons.Presence.Dnd.Large.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Large.Dark else AvatarIcons.Presence.Dnd.Large.Dark - ) - } + AvatarSize.Size56 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Medium.Light else AvatarIcons.Presence.Dnd.Medium.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Medium.Dark else AvatarIcons.Presence.Dnd.Medium.Dark + ) - AvatarStatus.Unknown -> - when (avatarInfo.size) { - AvatarSize.Size16 -> FluentIcon() - AvatarSize.Size20 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Light else AvatarIcons.Presence.Unknown.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Dark else AvatarIcons.Presence.Unknown.Small.Dark - ) + AvatarSize.Size72 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Large.Light else AvatarIcons.Presence.Dnd.Large.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Dndoof.Large.Dark else AvatarIcons.Presence.Dnd.Large.Dark + ) + } - AvatarSize.Size24 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Light else AvatarIcons.Presence.Unknown.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Dark else AvatarIcons.Presence.Unknown.Small.Dark - ) + AvatarStatus.Unknown -> when (avatarInfo.size) { + AvatarSize.Size16 -> FluentIcon() + AvatarSize.Size20 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Light else AvatarIcons.Presence.Unknown.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Dark else AvatarIcons.Presence.Unknown.Small.Dark + ) - AvatarSize.Size32 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Light else AvatarIcons.Presence.Unknown.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Dark else AvatarIcons.Presence.Unknown.Small.Dark - ) + AvatarSize.Size24 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Light else AvatarIcons.Presence.Unknown.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Dark else AvatarIcons.Presence.Unknown.Small.Dark + ) - AvatarSize.Size40 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Light else AvatarIcons.Presence.Unknown.Medium.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Dark else AvatarIcons.Presence.Unknown.Medium.Dark - ) + AvatarSize.Size32 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Light else AvatarIcons.Presence.Unknown.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Dark else AvatarIcons.Presence.Unknown.Small.Dark + ) - AvatarSize.Size56 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Light else AvatarIcons.Presence.Unknown.Medium.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Dark else AvatarIcons.Presence.Unknown.Medium.Dark - ) + AvatarSize.Size40 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Light else AvatarIcons.Presence.Unknown.Medium.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Dark else AvatarIcons.Presence.Unknown.Medium.Dark + ) - AvatarSize.Size72 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Large.Light else AvatarIcons.Presence.Unknown.Large.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Large.Dark else AvatarIcons.Presence.Unknown.Large.Dark - ) - } + AvatarSize.Size56 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Light else AvatarIcons.Presence.Unknown.Medium.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Dark else AvatarIcons.Presence.Unknown.Medium.Dark + ) - AvatarStatus.Blocked -> - when (avatarInfo.size) { - AvatarSize.Size16 -> FluentIcon() - AvatarSize.Size20 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Light else AvatarIcons.Presence.Blocked.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Dark else AvatarIcons.Presence.Blocked.Small.Dark - ) + AvatarSize.Size72 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Large.Light else AvatarIcons.Presence.Unknown.Large.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Large.Dark else AvatarIcons.Presence.Unknown.Large.Dark + ) + } - AvatarSize.Size24 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Light else AvatarIcons.Presence.Blocked.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Dark else AvatarIcons.Presence.Blocked.Small.Dark - ) + AvatarStatus.Blocked -> when (avatarInfo.size) { + AvatarSize.Size16 -> FluentIcon() + AvatarSize.Size20 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Light else AvatarIcons.Presence.Blocked.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Dark else AvatarIcons.Presence.Blocked.Small.Dark + ) - AvatarSize.Size32 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Light else AvatarIcons.Presence.Blocked.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Dark else AvatarIcons.Presence.Blocked.Small.Dark - ) + AvatarSize.Size24 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Light else AvatarIcons.Presence.Blocked.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Dark else AvatarIcons.Presence.Blocked.Small.Dark + ) - AvatarSize.Size40 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Light else AvatarIcons.Presence.Blocked.Medium.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Dark else AvatarIcons.Presence.Blocked.Medium.Dark - ) + AvatarSize.Size32 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Light else AvatarIcons.Presence.Blocked.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Dark else AvatarIcons.Presence.Blocked.Small.Dark + ) - AvatarSize.Size56 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Light else AvatarIcons.Presence.Blocked.Medium.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Dark else AvatarIcons.Presence.Blocked.Medium.Dark - ) + AvatarSize.Size40 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Light else AvatarIcons.Presence.Blocked.Medium.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Dark else AvatarIcons.Presence.Blocked.Medium.Dark + ) - AvatarSize.Size72 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Large.Light else AvatarIcons.Presence.Blocked.Large.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Large.Dark else AvatarIcons.Presence.Blocked.Large.Dark - ) - } + AvatarSize.Size56 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Light else AvatarIcons.Presence.Blocked.Medium.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Dark else AvatarIcons.Presence.Blocked.Medium.Dark + ) - AvatarStatus.Offline -> - when (avatarInfo.size) { - AvatarSize.Size16 -> FluentIcon() - AvatarSize.Size20 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Light else AvatarIcons.Presence.Offline.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Dark else AvatarIcons.Presence.Offline.Small.Dark - ) + AvatarSize.Size72 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Large.Light else AvatarIcons.Presence.Blocked.Large.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Large.Dark else AvatarIcons.Presence.Blocked.Large.Dark + ) + } - AvatarSize.Size24 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Light else AvatarIcons.Presence.Offline.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Dark else AvatarIcons.Presence.Offline.Small.Dark - ) + AvatarStatus.Offline -> when (avatarInfo.size) { + AvatarSize.Size16 -> FluentIcon() + AvatarSize.Size20 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Light else AvatarIcons.Presence.Offline.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Dark else AvatarIcons.Presence.Offline.Small.Dark + ) - AvatarSize.Size32 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Light else AvatarIcons.Presence.Offline.Small.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Dark else AvatarIcons.Presence.Offline.Small.Dark - ) + AvatarSize.Size24 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Light else AvatarIcons.Presence.Offline.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Dark else AvatarIcons.Presence.Offline.Small.Dark + ) - AvatarSize.Size40 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Light else AvatarIcons.Presence.Offline.Medium.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Dark else AvatarIcons.Presence.Offline.Medium.Dark - ) + AvatarSize.Size32 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Light else AvatarIcons.Presence.Offline.Small.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Small.Dark else AvatarIcons.Presence.Offline.Small.Dark + ) - AvatarSize.Size56 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Light else AvatarIcons.Presence.Offline.Medium.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Dark else AvatarIcons.Presence.Offline.Medium.Dark - ) + AvatarSize.Size40 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Light else AvatarIcons.Presence.Offline.Medium.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Dark else AvatarIcons.Presence.Offline.Medium.Dark + ) - AvatarSize.Size72 -> FluentIcon( - light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Large.Light else AvatarIcons.Presence.Offline.Large.Light, - dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Large.Dark else AvatarIcons.Presence.Offline.Large.Dark - ) - } + AvatarSize.Size56 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Light else AvatarIcons.Presence.Offline.Medium.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Medium.Dark else AvatarIcons.Presence.Offline.Medium.Dark + ) + + AvatarSize.Size72 -> FluentIcon( + light = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Large.Light else AvatarIcons.Presence.Offline.Large.Light, + dark = if (avatarInfo.isOOO) AvatarIcons.Presence.Oof.Large.Dark else AvatarIcons.Presence.Offline.Large.Dark + ) + } + } + } + + @Composable + open fun unreadDotBorderStroke(avatarInfo: AvatarInfo): BorderStroke { + return BorderStroke( + FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, + aliasTokens.neutralStrokeColor[FluentAliasTokens.NeutralStrokeColorTokens.StrokeFocus1].value( + themeMode = themeMode + ) + ) + } + + @Composable + open fun unreadDotBackgroundBrush(avatarInfo: AvatarInfo): Brush { + return SolidColor( + aliasTokens.brandBackgroundColor[FluentAliasTokens.BrandBackgroundColorTokens.BrandBackground1].value( + themeMode = themeMode + ) + ) + } + + @Composable + open fun unreadDotSize(avatarInfo: AvatarInfo): Dp { + return when (avatarInfo.size) { + AvatarSize.Size16 -> 8.dp + AvatarSize.Size20 -> 8.dp + AvatarSize.Size24 -> 8.dp + AvatarSize.Size32 -> 10.dp + AvatarSize.Size40 -> 12.dp + AvatarSize.Size56 -> 14.dp + AvatarSize.Size72 -> 16.dp } } + @Composable + open fun unreadDotOffset(avatarInfo: AvatarInfo): DpOffset { + return when(avatarInfo.size) { + AvatarSize.Size16 -> DpOffset(4.dp, (0).dp) + AvatarSize.Size20 -> DpOffset(4.dp, (-2).dp) + AvatarSize.Size24 -> DpOffset(4.dp, (-3).dp) + AvatarSize.Size32 -> DpOffset(4.dp, (-3).dp) + AvatarSize.Size40 -> DpOffset(4.dp, (-3).dp) + AvatarSize.Size56 -> DpOffset(4.dp, (-4).dp) + AvatarSize.Size72 -> DpOffset(4.dp, (-5).dp) + } + } + @Composable open fun presenceOffset(avatarInfo: AvatarInfo): DpOffset { return when (avatarInfo.size) { @@ -641,12 +678,9 @@ open class AvatarTokens(private val activityRingToken: ActivityRingsToken = Acti val glowColor: Color = if (avatarInfo.isImageAvailable || avatarInfo.hasValidInitials) { FluentColor( light = calculatedColor( - avatarInfo.calculatedColorKey, - FluentGlobalTokens.SharedColorsTokens.Primary - ), - dark = calculatedColor( - avatarInfo.calculatedColorKey, - FluentGlobalTokens.SharedColorsTokens.Tint30 + avatarInfo.calculatedColorKey, FluentGlobalTokens.SharedColorsTokens.Primary + ), dark = calculatedColor( + avatarInfo.calculatedColorKey, FluentGlobalTokens.SharedColorsTokens.Tint30 ) ).value( themeMode = themeMode @@ -660,55 +694,58 @@ open class AvatarTokens(private val activityRingToken: ActivityRingsToken = Acti AvatarStyle.Standard, AvatarStyle.StandardInverted, AvatarStyle.AnonymousAccent -> aliasTokens.brandStroke[FluentAliasTokens.BrandStrokeColorTokens.BrandStroke1].value( themeMode = themeMode ) + AvatarStyle.Anonymous -> aliasTokens.neutralStrokeColor[FluentAliasTokens.NeutralStrokeColorTokens.Stroke1].value( themeMode = themeMode ) } } - return if (avatarInfo.active) - when (avatarInfo.size) { - AvatarSize.Size16 -> activityRingToken.activeBorderStroke( - ActivityRingSize.Size16, - glowColor - ) - AvatarSize.Size20 -> activityRingToken.activeBorderStroke( - ActivityRingSize.Size20, - glowColor - ) - AvatarSize.Size24 -> activityRingToken.activeBorderStroke( - ActivityRingSize.Size24, - glowColor - ) - AvatarSize.Size32 -> activityRingToken.activeBorderStroke( - ActivityRingSize.Size32, - glowColor - ) - AvatarSize.Size40 -> activityRingToken.activeBorderStroke( - ActivityRingSize.Size40, - glowColor - ) - AvatarSize.Size56 -> activityRingToken.activeBorderStroke( - ActivityRingSize.Size56, - glowColor - ) - AvatarSize.Size72 -> activityRingToken.activeBorderStroke( - ActivityRingSize.Size72, - glowColor - ) - } - else - when (avatarInfo.size) { - AvatarSize.Size16 -> activityRingToken.inactiveBorderStroke(ActivityRingSize.Size16) - AvatarSize.Size20 -> activityRingToken.inactiveBorderStroke(ActivityRingSize.Size20) - AvatarSize.Size24 -> activityRingToken.inactiveBorderStroke(ActivityRingSize.Size24) - AvatarSize.Size32 -> activityRingToken.inactiveBorderStroke(ActivityRingSize.Size32) - AvatarSize.Size40 -> activityRingToken.inactiveBorderStroke(ActivityRingSize.Size40) - AvatarSize.Size56 -> activityRingToken.inactiveBorderStroke(ActivityRingSize.Size56) - AvatarSize.Size72 -> activityRingToken.inactiveBorderStroke(ActivityRingSize.Size72) - } + return if (avatarInfo.active) when (avatarInfo.size) { + AvatarSize.Size16 -> activityRingToken.activeBorderStroke( + ActivityRingSize.Size16, glowColor + ) + + AvatarSize.Size20 -> activityRingToken.activeBorderStroke( + ActivityRingSize.Size20, glowColor + ) + + AvatarSize.Size24 -> activityRingToken.activeBorderStroke( + ActivityRingSize.Size24, glowColor + ) + + AvatarSize.Size32 -> activityRingToken.activeBorderStroke( + ActivityRingSize.Size32, glowColor + ) + + AvatarSize.Size40 -> activityRingToken.activeBorderStroke( + ActivityRingSize.Size40, glowColor + ) + + AvatarSize.Size56 -> activityRingToken.activeBorderStroke( + ActivityRingSize.Size56, glowColor + ) + + AvatarSize.Size72 -> activityRingToken.activeBorderStroke( + ActivityRingSize.Size72, glowColor + ) + } + else when (avatarInfo.size) { + AvatarSize.Size16 -> activityRingToken.inactiveBorderStroke(ActivityRingSize.Size16) + AvatarSize.Size20 -> activityRingToken.inactiveBorderStroke(ActivityRingSize.Size20) + AvatarSize.Size24 -> activityRingToken.inactiveBorderStroke(ActivityRingSize.Size24) + AvatarSize.Size32 -> activityRingToken.inactiveBorderStroke(ActivityRingSize.Size32) + AvatarSize.Size40 -> activityRingToken.inactiveBorderStroke(ActivityRingSize.Size40) + AvatarSize.Size56 -> activityRingToken.inactiveBorderStroke(ActivityRingSize.Size56) + AvatarSize.Size72 -> activityRingToken.inactiveBorderStroke(ActivityRingSize.Size72) + } } + @Composable + open fun cutoutColorFilter(avatarInfo: AvatarInfo): ColorFilter? { + return null + } + @Composable open fun cutoutCornerRadius(avatarInfo: AvatarInfo): Dp { return when (avatarInfo.cutoutStyle) { @@ -742,8 +779,7 @@ open class AvatarTokens(private val activityRingToken: ActivityRingsToken = Acti @Composable private fun calculatedColor( - avatarString: String, - token: FluentGlobalTokens.SharedColorsTokens + avatarString: String, token: FluentGlobalTokens.SharedColorsTokens ): Color { val colors = listOf( FluentGlobalTokens.SharedColorSets.DarkRed, @@ -778,40 +814,51 @@ open class AvatarTokens(private val activityRingToken: ActivityRingsToken = Acti FluentGlobalTokens.SharedColorSets.Anchor ) - when(token){ + when (token) { FluentGlobalTokens.SharedColorsTokens.Primary -> { return colors[abs(avatarString.hashCode()) % colors.size].primary } + FluentGlobalTokens.SharedColorsTokens.Tint10 -> { return colors[abs(avatarString.hashCode()) % colors.size].tint10 } + FluentGlobalTokens.SharedColorsTokens.Tint20 -> { return colors[abs(avatarString.hashCode()) % colors.size].tint20 } + FluentGlobalTokens.SharedColorsTokens.Tint30 -> { return colors[abs(avatarString.hashCode()) % colors.size].tint30 } + FluentGlobalTokens.SharedColorsTokens.Tint40 -> { return colors[abs(avatarString.hashCode()) % colors.size].tint40 } + FluentGlobalTokens.SharedColorsTokens.Tint50 -> { return colors[abs(avatarString.hashCode()) % colors.size].tint50 } + FluentGlobalTokens.SharedColorsTokens.Tint60 -> { return colors[abs(avatarString.hashCode()) % colors.size].tint60 } + FluentGlobalTokens.SharedColorsTokens.Shade10 -> { return colors[abs(avatarString.hashCode()) % colors.size].shade10 } + FluentGlobalTokens.SharedColorsTokens.Shade20 -> { return colors[abs(avatarString.hashCode()) % colors.size].shade20 } + FluentGlobalTokens.SharedColorsTokens.Shade30 -> { return colors[abs(avatarString.hashCode()) % colors.size].shade30 } + FluentGlobalTokens.SharedColorsTokens.Shade40 -> { return colors[abs(avatarString.hashCode()) % colors.size].shade40 } + FluentGlobalTokens.SharedColorsTokens.Shade50 -> { return colors[abs(avatarString.hashCode()) % colors.size].shade50 } @@ -832,6 +879,7 @@ open class ActivityRingsToken : Parcelable { ) ) ) + ActivityRingSize.Size20 -> listOf( BorderStroke( FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, @@ -840,6 +888,7 @@ open class ActivityRingsToken : Parcelable { ) ) ) + ActivityRingSize.Size24 -> listOf( BorderStroke( FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, @@ -848,6 +897,7 @@ open class ActivityRingsToken : Parcelable { ) ) ) + ActivityRingSize.Size32 -> listOf( BorderStroke( FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, @@ -856,6 +906,7 @@ open class ActivityRingsToken : Parcelable { ) ) ) + ActivityRingSize.Size40 -> listOf( BorderStroke( FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, @@ -864,6 +915,7 @@ open class ActivityRingsToken : Parcelable { ) ) ) + ActivityRingSize.Size56 -> listOf( BorderStroke( FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, @@ -872,6 +924,7 @@ open class ActivityRingsToken : Parcelable { ) ) ) + ActivityRingSize.Size72 -> listOf( BorderStroke( FluentGlobalTokens.StrokeWidthTokens.StrokeWidth40.value, @@ -885,8 +938,7 @@ open class ActivityRingsToken : Parcelable { @Composable open fun activeBorderStroke( - activityRingSize: ActivityRingSize, - glowColor: Color + activityRingSize: ActivityRingSize, glowColor: Color ): List { return when (activityRingSize) { ActivityRingSize.Size16 -> listOf( @@ -895,120 +947,105 @@ open class ActivityRingsToken : Parcelable { aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value( themeMode = themeMode ) - ), - BorderStroke( - FluentGlobalTokens.StrokeWidthTokens.StrokeWidth15.value, - glowColor - ), - BorderStroke( + ), BorderStroke( + FluentGlobalTokens.StrokeWidthTokens.StrokeWidth15.value, glowColor + ), BorderStroke( FluentGlobalTokens.StrokeWidthTokens.StrokeWidth15.value, aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value( themeMode = themeMode ) ) ) + ActivityRingSize.Size20 -> listOf( BorderStroke( FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value( themeMode = themeMode ) - ), - BorderStroke( - FluentGlobalTokens.StrokeWidthTokens.StrokeWidth15.value, - glowColor - ), - BorderStroke( + ), BorderStroke( + FluentGlobalTokens.StrokeWidthTokens.StrokeWidth15.value, glowColor + ), BorderStroke( FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value( themeMode = themeMode ) ) ) + ActivityRingSize.Size24 -> listOf( BorderStroke( FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value( themeMode = themeMode ) - ), - BorderStroke( - FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, - glowColor - ), - BorderStroke( + ), BorderStroke( + FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, glowColor + ), BorderStroke( FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value( themeMode = themeMode ) ) ) + ActivityRingSize.Size32 -> listOf( BorderStroke( FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value( themeMode = themeMode ) - ), - BorderStroke( - FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, - glowColor - ), - BorderStroke( + ), BorderStroke( + FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, glowColor + ), BorderStroke( FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value( themeMode = themeMode ) ) ) + ActivityRingSize.Size40 -> listOf( BorderStroke( FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value( themeMode = themeMode ) - ), - BorderStroke( - FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, - glowColor - ), - BorderStroke( + ), BorderStroke( + FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, glowColor + ), BorderStroke( FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value( themeMode = themeMode ) ) ) + ActivityRingSize.Size56 -> listOf( BorderStroke( FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value( themeMode = themeMode ) - ), - BorderStroke( - FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, - glowColor - ), - BorderStroke( + ), BorderStroke( + FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, glowColor + ), BorderStroke( FluentGlobalTokens.StrokeWidthTokens.StrokeWidth20.value, aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value( themeMode = themeMode ) ) ) + ActivityRingSize.Size72 -> listOf( BorderStroke( FluentGlobalTokens.StrokeWidthTokens.StrokeWidth40.value, aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value( themeMode = themeMode ) - ), - BorderStroke( - FluentGlobalTokens.StrokeWidthTokens.StrokeWidth40.value, - glowColor - ), - BorderStroke( + ), BorderStroke( + FluentGlobalTokens.StrokeWidthTokens.StrokeWidth40.value, glowColor + ), BorderStroke( FluentGlobalTokens.StrokeWidthTokens.StrokeWidth40.value, aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value( themeMode = themeMode 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 72d81385f..efae62687 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 @@ -15,9 +15,17 @@ import kotlinx.parcelize.Parcelize open class BottomSheetInfo : ControlInfo +data class SheetAccessibilityAnnouncement( + var expandedToShown: String = "", + var expandedToCollapsed: String = "Bottom Sheet Collapsed", + var shownToExpanded: String = "Bottom Sheet Expanded", + var shownToCollapsed: String = "Bottom Sheet Collapsed", + var collapsedToExpanded: String = "Bottom Sheet Expanded", + var collapsedToShown: String = "Bottom Sheet Opened", +) + @Parcelize open class BottomSheetTokens : IControlToken, Parcelable { - @Composable open fun backgroundBrush(bottomSheetInfo: BottomSheetInfo): Brush = SolidColor( diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/DrawerTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/DrawerTokens.kt index 27d3b9083..cb4cdc7f9 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/DrawerTokens.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/DrawerTokens.kt @@ -22,9 +22,13 @@ enum class BehaviorType { open class DrawerInfo(val type: BehaviorType = BehaviorType.LEFT_SLIDE_OVER) : ControlInfo +data class DrawerAccessibilityAnnouncement( + var opened: String = "Drawer Opened", + var closed: String = "Drawer Closed", +) + @Parcelize open class DrawerTokens : IControlToken, Parcelable { - @Composable open fun backgroundBrush(drawerInfo: DrawerInfo): Brush = SolidColor( diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/ListItemTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/ListItemTokens.kt index c5d6275c0..348cf818a 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/ListItemTokens.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/ListItemTokens.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.microsoft.fluentui.theme.FluentTheme @@ -353,4 +354,9 @@ open class ListItemTokens : IControlToken, Parcelable { open fun textAccessoryContentTextSpacing(listItemInfo: ListItemInfo): Dp { return FluentGlobalTokens.SizeTokens.Size40.value } + + @Composable + open fun textOverflow(listItemInfo: ListItemInfo): TextOverflow { + return TextOverflow.Ellipsis + } } \ No newline at end of file diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/PillButtonTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/PillButtonTokens.kt index c948b40fd..1069324d4 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/PillButtonTokens.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/PillButtonTokens.kt @@ -3,6 +3,7 @@ package com.microsoft.fluentui.theme.token.controlTokens import android.os.Parcelable import androidx.compose.foundation.BorderStroke import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Dp @@ -153,6 +154,15 @@ open class PillButtonTokens : IControlToken, Parcelable { } } + @Composable + open fun borderColor(pillButtonInfo: PillButtonInfo): Color{ + return Color.Transparent + } + + @Composable + open fun borderWidth(pillButtonInfo: PillButtonInfo): Dp { + return 0.dp + } @Composable open fun iconColor(pillButtonInfo: PillButtonInfo): StateColor { when (pillButtonInfo.style) { diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/SearchBarTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/SearchBarTokens.kt index 7c1ef43af..651c17340 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/SearchBarTokens.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/SearchBarTokens.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.DefaultShadowColor import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Dp @@ -29,6 +30,7 @@ open class SearchBarTokens : IControlToken, Parcelable { FluentTheme.aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background5].value( themeMode = FluentTheme.themeMode ) + FluentStyle.Brand -> FluentColor( light = FluentTheme.aliasTokens.brandBackgroundColor[FluentAliasTokens.BrandBackgroundColorTokens.BrandBackground2].value( @@ -50,6 +52,7 @@ open class SearchBarTokens : IControlToken, Parcelable { FluentTheme.aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background3].value( themeMode = FluentTheme.themeMode ) + FluentStyle.Brand -> FluentColor( light = FluentTheme.aliasTokens.brandBackgroundColor[FluentAliasTokens.BrandBackgroundColorTokens.BrandBackground1].value( @@ -89,6 +92,7 @@ open class SearchBarTokens : IControlToken, Parcelable { FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground3].value( themeMode = FluentTheme.themeMode ) + FluentStyle.Brand -> FluentColor( light = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.ForegroundOnColor].value( @@ -107,6 +111,7 @@ open class SearchBarTokens : IControlToken, Parcelable { when (searchBarInfo.style) { FluentStyle.Neutral -> FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground3].value() + FluentStyle.Brand -> FluentColor( light = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.ForegroundOnColor].value( @@ -127,6 +132,7 @@ open class SearchBarTokens : IControlToken, Parcelable { FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground2].value( themeMode = FluentTheme.themeMode ) + FluentStyle.Brand -> FluentColor( light = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.ForegroundOnColor].value( @@ -168,4 +174,21 @@ open class SearchBarTokens : IControlToken, Parcelable { open fun height(searchBarInfo: SearchBarInfo): Dp { return 40.dp } + + @Composable + open fun cornerRadius(searchBarInfo: SearchBarInfo): Dp = + FluentGlobalTokens.CornerRadiusTokens.CornerRadius80.value + + @Composable + open fun elevation(searchBarInfo: SearchBarInfo): Dp = 0.dp + + @Composable + open fun borderWidth(searchBarInfo: SearchBarInfo): Dp = 0.dp + + @Composable + open fun borderColor(searchBarInfo: SearchBarInfo): Color = + FluentTheme.aliasTokens.neutralStrokeColor[FluentAliasTokens.NeutralStrokeColorTokens.Stroke2].value() + + @Composable + open fun shadowColor(searchBarInfo: SearchBarInfo): Color = DefaultShadowColor } \ No newline at end of file diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/ShimmerTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/ShimmerTokens.kt index 281e8c170..88a874f8e 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/ShimmerTokens.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/ShimmerTokens.kt @@ -11,6 +11,14 @@ import kotlinx.parcelize.Parcelize open class ShimmerInfo : ControlInfo +enum class ShimmerOrientation { + LEFT_TO_RIGHT, + RIGHT_TO_LEFT, + TOPLEFT_TO_BOTTOMRIGHT, + BOTTOMRIGHT_TO_TOPLEFT, + _NONE //DO NOT USE +} + @Parcelize open class ShimmerTokens : IControlToken, Parcelable { @Composable @@ -29,6 +37,11 @@ open class ShimmerTokens : IControlToken, Parcelable { @Composable open fun delay(shimmerInfo: ShimmerInfo): Int { - return 1000 + return -1 + } + + @Composable + open fun orientation(shimmerInfo: ShimmerInfo): ShimmerOrientation { + return ShimmerOrientation._NONE //Do not return ShimmerOrientation._NONE if you are overriding this method, it will default to ShimmerOrientation.TOPLEFT_TO_BOTTOMRIGHT in that case } } \ No newline at end of file diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/SnackbarTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/SnackbarTokens.kt index d9237ddfb..9d66d8c81 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/SnackbarTokens.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/SnackbarTokens.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.unit.dp import com.microsoft.fluentui.theme.FluentTheme.aliasTokens import com.microsoft.fluentui.theme.token.ControlInfo import com.microsoft.fluentui.theme.token.FluentAliasTokens +import com.microsoft.fluentui.theme.token.FluentGlobalTokens import com.microsoft.fluentui.theme.token.IControlToken import kotlinx.parcelize.Parcelize @@ -91,6 +92,11 @@ open class SnackBarTokens : IControlToken, Parcelable { ) } + @Composable + open fun shadowElevationValue(snackBarInfo: SnackBarInfo): Dp { + return 0.dp + } + @Composable open fun leftIconSize(snackBarInfo: SnackBarInfo): Dp = 24.dp diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/TabItemTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/TabItemTokens.kt index ab8784965..a429d013e 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/TabItemTokens.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/TabItemTokens.kt @@ -5,7 +5,10 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import com.microsoft.fluentui.theme.FluentTheme import com.microsoft.fluentui.theme.ThemeMode @@ -63,49 +66,53 @@ open class TabItemTokens : IControlToken, Parcelable { } @Composable - open fun iconColor(tabItemInfo: TabItemInfo): StateColor { + open fun iconColor(tabItemInfo: TabItemInfo): StateBrush { return when (tabItemInfo.fluentStyle) { - FluentStyle.Neutral -> StateColor( - rest = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground3].value( + FluentStyle.Neutral -> StateBrush( + rest = SolidColor( FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground3].value( themeMode = FluentTheme.themeMode + ) ), - pressed = FluentTheme.aliasTokens.brandForegroundColor[FluentAliasTokens.BrandForegroundColorTokens.BrandForeground1].value( + pressed = SolidColor( FluentTheme.aliasTokens.brandForegroundColor[FluentAliasTokens.BrandForegroundColorTokens.BrandForeground1].value( themeMode = FluentTheme.themeMode + ) ), - focused= FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground1].value( + focused= SolidColor( FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground1].value( themeMode = FluentTheme.themeMode + ) ), - disabled = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.ForegroundDisable1].value( + disabled = SolidColor( FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.ForegroundDisable1].value( themeMode = FluentTheme.themeMode ) + ) ) - FluentStyle.Brand -> StateColor( - rest = FluentColor( + FluentStyle.Brand -> StateBrush( + rest = SolidColor( FluentColor( light = FluentTheme.aliasTokens.brandForegroundColor[FluentAliasTokens.BrandForegroundColorTokens.BrandForeground1].value( ThemeMode.Light ), dark = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground1].value( ThemeMode.Dark ) - ).value(FluentTheme.themeMode), - pressed = FluentColor( + ).value(FluentTheme.themeMode)), + pressed = SolidColor( FluentColor( light = FluentTheme.aliasTokens.brandForegroundColor[FluentAliasTokens.BrandForegroundColorTokens.BrandForeground1Pressed].value( ThemeMode.Light ), dark = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground1].value( ThemeMode.Dark ) - ).value(FluentTheme.themeMode), - selected = FluentTheme.aliasTokens.brandForegroundColor[FluentAliasTokens.BrandForegroundColorTokens.BrandForeground1].value(), - disabled = FluentColor( + ).value(FluentTheme.themeMode)), + selected = SolidColor( FluentTheme.aliasTokens.brandForegroundColor[FluentAliasTokens.BrandForegroundColorTokens.BrandForeground1].value()), + disabled = SolidColor( FluentColor( light = FluentTheme.aliasTokens.brandForegroundColor[FluentAliasTokens.BrandForegroundColorTokens.BrandForegroundDisabled2].value( ThemeMode.Light ), dark = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.ForegroundDisable1].value( ThemeMode.Dark ) - ).value(FluentTheme.themeMode) + ).value(FluentTheme.themeMode)) ) } } @@ -155,6 +162,51 @@ open class TabItemTokens : IControlToken, Parcelable { } } + @Composable + open fun indicatorColor(tabItemInfo: TabItemInfo): StateBrush { + return when (tabItemInfo.fluentStyle) { + FluentStyle.Neutral -> StateBrush( + rest = SolidColor(FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground2].value( + themeMode = FluentTheme.themeMode + )), + pressed = SolidColor(FluentTheme.aliasTokens.brandForegroundColor[FluentAliasTokens.BrandForegroundColorTokens.BrandForeground1Pressed].value( + themeMode = FluentTheme.themeMode + )), + disabled = SolidColor(FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.ForegroundDisable1].value( + themeMode = FluentTheme.themeMode + )) + ) + + FluentStyle.Brand -> StateBrush( + rest = SolidColor(FluentColor( + light = FluentTheme.aliasTokens.brandForegroundColor[FluentAliasTokens.BrandForegroundColorTokens.BrandForeground1].value( + ThemeMode.Light + ), + dark = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground1].value( + ThemeMode.Dark + ) + ).value(FluentTheme.themeMode)), + pressed = SolidColor(FluentColor( + light = FluentTheme.aliasTokens.brandForegroundColor[FluentAliasTokens.BrandForegroundColorTokens.BrandForeground1Pressed].value( + ThemeMode.Light + ), + dark = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground1].value( + ThemeMode.Dark + ) + ).value(FluentTheme.themeMode)), + selected = SolidColor(FluentTheme.aliasTokens.brandForegroundColor[FluentAliasTokens.BrandForegroundColorTokens.BrandForeground1].value()), + disabled = SolidColor(FluentColor( + light = FluentTheme.aliasTokens.brandForegroundColor[FluentAliasTokens.BrandForegroundColorTokens.BrandForegroundDisabled2].value( + ThemeMode.Light + ), + dark = FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.ForegroundDisable1].value( + ThemeMode.Dark + ) + ).value(FluentTheme.themeMode)) + ) + } + } + @Composable open fun padding(tabItemInfo: TabItemInfo): PaddingValues { return when(tabItemInfo.tabTextAlignment){ @@ -163,4 +215,9 @@ open class TabItemTokens : IControlToken, Parcelable { TabTextAlignment.NO_TEXT -> PaddingValues(top = 8.dp, start = 8.dp, bottom = 4.dp, end = 8.dp) } } + + @Composable + open fun textTypography(tabItemInfo: TabItemInfo): TextStyle { + return FluentTheme.aliasTokens.typography[FluentAliasTokens.TypographyTokens.Caption2] + } } \ No newline at end of file diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/ViewPagerTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/ViewPagerTokens.kt new file mode 100644 index 000000000..a40930b7c --- /dev/null +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/ViewPagerTokens.kt @@ -0,0 +1,32 @@ +package com.microsoft.fluentui.theme.token.controlTokens + +import android.os.Parcelable +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.microsoft.fluentui.theme.FluentTheme +import com.microsoft.fluentui.theme.token.ControlInfo +import com.microsoft.fluentui.theme.token.FluentAliasTokens +import com.microsoft.fluentui.theme.token.FluentGlobalTokens +import com.microsoft.fluentui.theme.token.IControlToken +import kotlinx.parcelize.Parcelize + + +open class ViewPagerInfo: ControlInfo +@Parcelize +open class ViewPagerTokens : IControlToken, Parcelable { + + @Composable + open fun contentPadding(viewPagerInfo: ViewPagerInfo): PaddingValues { + return PaddingValues(0.dp) + } + + @Composable + open fun pageSpacing(viewPagerInfo: ViewPagerInfo): Dp { + return 0.dp + } +} diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/util/AccessibilityUtils.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/util/AccessibilityUtils.kt index 422f9c6c9..a408144ad 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/util/AccessibilityUtils.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/util/AccessibilityUtils.kt @@ -7,6 +7,46 @@ package com.microsoft.fluentui.util import android.content.Context import android.view.accessibility.AccessibilityManager +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import kotlinx.coroutines.delay /** * Utilities for accessibility @@ -16,3 +56,166 @@ val Context.isAccessibilityEnabled: Boolean val Context.accessibilityManager: AccessibilityManager get() = getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager + +/** + * A Modifier that makes a composable clickable, long clickable and displays a tooltip on a long press. + * + * This modifier combines click and long-press gesture detection with a tooltip that appears + * above or below the composable. The tooltip is dismissed after a specified timeout or when + * the user clicks outside of it. + * + * Example usage: + * ``` + * Box( + * modifier = Modifier + * .clickableWithTooltip( + * tooltipText = "This is a tooltip!", + * tooltipEnabled = true, + * onClick = { Log.d("TAG", "Clicked!") }, + * onLongClick = { Log.d("TAG", "Long clicked!") } + * ), + * contentAlignment = Alignment.Center + * ) { + * Text("Hover or Long Press") + * } + * ``` + * + * @param tooltipText The text to be displayed inside the tooltip. + * @param tooltipEnabled A boolean to enable or disable the tooltip functionality. Defaults to `false`. + * @param backgroundColor The background brush for the tooltip. Can be a solid color or a gradient. Defaults to `Color.Unspecified`. + * @param cornerRadius The corner radius for the tooltip's background shape. Defaults to `8.dp`. + * @param textStyle The text style for the tooltip's content. + * @param padding The internal padding around the tooltip's text. Defaults to `12.dp`. + * @param offset The DpOffset to adjust the tooltip's position relative to the composable. `x` adjusts horizontal position, `y` adjusts vertical. Defaults to `DpOffset(0.dp, 0.dp)`. + * @param timeout The duration in milliseconds for which the tooltip remains visible before automatically dismissing. Defaults to `2000L`. + * @param showRippleOnClick If true, a ripple effect will be shown on tap. Defaults to `true`. + * @param clickRippleColor The color of the ripple effect. Defaults to `Color.Unspecified` which uses the theme's default. + * @param onClick A lambda to be executed when the composable is tapped. + * @param onLongClick A lambda to be executed when the composable is long-pressed. This action also triggers the tooltip if `tooltipEnabled` is true. + * @return Returns a [Modifier] that applies the click and tooltip behavior. + */ + +@OptIn(ExperimentalFoundationApi::class) +fun Modifier.clickableWithTooltip( + tooltipText: String, + tooltipEnabled: Boolean = false, + backgroundColor: Brush = SolidColor(Color.Unspecified), + cornerRadius: Dp = 8.dp, + textStyle: TextStyle = TextStyle(color = Color.Black, fontWeight = FontWeight.Normal), + padding: Dp = 12.dp, + offset: DpOffset = DpOffset(0.dp, 0.dp), + timeout: Long = 2000L, + showRippleOnClick: Boolean = true, + clickRippleColor: Color = Color.Unspecified, + onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null +): Modifier = composed { + var isTooltipVisible by remember { mutableStateOf(false) } + var anchorBounds by remember { mutableStateOf(IntRect.Zero) } + val interactionSource = remember { MutableInteractionSource() } + + val currentOnClick by rememberUpdatedState(onClick) + val currentOnLongClick by rememberUpdatedState(onLongClick) + val currentTooltipEnabled by rememberUpdatedState(tooltipEnabled) + + val clickableModifier = Modifier.combinedClickable( + interactionSource = interactionSource, + indication = if (showRippleOnClick) rememberRipple(color = clickRippleColor) else null, + onClick = { currentOnClick?.invoke() }, + onLongClick = { + currentOnLongClick?.invoke() + if (currentTooltipEnabled) { + isTooltipVisible = true + } + } + ) + + val positionModifier = Modifier.onGloballyPositioned { layoutCoordinates -> + anchorBounds = IntRect( + left = layoutCoordinates.positionInWindow().x.toInt(), + top = layoutCoordinates.positionInWindow().y.toInt(), + right = (layoutCoordinates.positionInWindow().x + layoutCoordinates.size.width).toInt(), + bottom = (layoutCoordinates.positionInWindow().y + layoutCoordinates.size.height).toInt() + ) + } + + if (isTooltipVisible) { + Tooltip( + tooltipText = tooltipText, + backgroundColor = backgroundColor, + cornerRadius = cornerRadius, + textStyle = textStyle, + padding = padding, + offset = offset, + onDismissRequest = { isTooltipVisible = false } + ) + + LaunchedEffect(isTooltipVisible, timeout) { + delay(timeout) + isTooltipVisible = false + } + } + + this + .then(if (tooltipEnabled) positionModifier else Modifier) + .then(clickableModifier) +} + +@Composable +private fun Tooltip( + tooltipText: String, + backgroundColor: Brush, + cornerRadius: Dp, + textStyle: TextStyle, + padding: Dp, + offset: DpOffset, + onDismissRequest: () -> Unit +) { + val density = LocalDensity.current + val positionProvider = remember(offset) { + TooltipPositionProvider( + offset = offset, + density = density + ) + } + Popup( + popupPositionProvider = positionProvider, + onDismissRequest = onDismissRequest, + properties = PopupProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + focusable = false, + ) + ) { + Column( + modifier = Modifier.shadow(4.dp, clip = false) + ) { + Box( + modifier = Modifier + .background(backgroundColor, RoundedCornerShape(cornerRadius)) + .padding(padding) + ) { + Text(text = tooltipText, style = textStyle) + } + } + } +} + +private class TooltipPositionProvider( + private val offset: DpOffset, + private val density: Density +) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset { + val offsetX = with(density) { offset.x.roundToPx() } + val offsetY = with(density) { offset.y.roundToPx() } + val y = + if ((anchorBounds.top - popupContentSize.height) > 0) anchorBounds.top - popupContentSize.height + offsetY else anchorBounds.bottom + offsetY + val x = (anchorBounds.left + (anchorBounds.width - popupContentSize.width) / 2) + offsetX + return IntOffset(x, y) + } +} diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/util/DuoSupportUtils.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/util/DuoSupportUtils.kt deleted file mode 100644 index 170e549cb..000000000 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/util/DuoSupportUtils.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.microsoft.fluentui.util - -import android.app.Activity -import android.content.Context -import android.graphics.Rect -import androidx.recyclerview.widget.GridLayoutManager -import android.view.View -import com.microsoft.device.dualscreen.layout.ScreenHelper -import java.lang.Exception - -/** - * [DuoSupportUtils] is helper object for Surface Duo Device Support - */ -object DuoSupportUtils { - const val DUO_HINGE_WIDTH = 84 - const val COLUMNS_IN_START_DUO_MODE = 3 - const val COLUMNS_IN_END_DUO_MODE = 4 - - @JvmStatic - fun isDeviceSurfaceDuo(activity: Activity) = ScreenHelper.isDeviceSurfaceDuo(activity) - - @JvmStatic - fun isDualScreenMode(activity: Activity) = ScreenHelper.isDualMode(activity) - - @JvmStatic - fun getRotation(activity: Activity) = ScreenHelper.getCurrentRotation(activity) - - @JvmStatic - fun getHinge(activity: Activity) = ScreenHelper.getHinge(activity) - - @JvmStatic - fun getScreenRectangles(activity: Activity) = ScreenHelper.getScreenRectangles(activity) - - /** - * Use [isWindowDoublePortrait] to check if the device is in landscape mode and app is spanned. - */ - @JvmStatic - fun isWindowDoublePortrait(activity: Activity): Boolean { - return activity.isLandscape && isDualScreenMode(activity) - } - - /** - * Use [isWindowDoubleLandscape] to check if the device is in portrait mode and app is spanned. - */ - @JvmStatic - fun isWindowDoubleLandscape(activity: Activity): Boolean { - return activity.isPortrait && isDualScreenMode(activity) - } - - private fun getRect(view: View): Rect { - val screenPos = IntArray(2) - view.getLocationOnScreen(screenPos) - return Rect(screenPos[0], screenPos[1], view.width, view.height) - } - - /** - * Use [moreOnLeft] to check if a given [View] or [Rect] is more to the left of the Surface Duo hinge or is more to the right. - * @return true if the View or Rect is more on left of the hinge, false otherwise. (false implies more to the right of the hinge) - */ - @JvmStatic - fun moreOnLeft(activity: Activity, rect: Rect) = isWindowDoublePortrait(activity) && ((getHinge(activity)!!.left - rect.left) >= (rect.right - getHinge(activity)!!.right)) - - @JvmStatic - fun moreOnLeft(activity: Activity, view: View) = moreOnLeft(activity, getRect(view)) - - - /** - * Use [moreOnTop] to check if a given [View] or [Rect] is more on the top of Surface Duo hinge or is more on the bottom. - * @return true if the View or Rect is more on top of the hinge, false otherwise. (false implies more on the bottom of the hinge) - */ - @JvmStatic - fun moreOnTop(activity: Activity, rect: Rect) = isWindowDoubleLandscape(activity) && ((getHinge(activity)!!.top - rect.top) >= (rect.bottom - getHinge(activity)!!.bottom)) - - @JvmStatic - fun moreOnTop(activity: Activity, view: View) = moreOnTop(activity, getRect(view)) - - /** - * Use [intersectHinge] to check if a given [View] or [Rect] intersects with the Surface Duo hinge. - * @return true if the View or Rect intersects with the hinge, false otherwise. - */ - @JvmStatic - fun intersectHinge(activity: Activity, anchorRect: Rect): Boolean { - return isDeviceSurfaceDuo(activity) && getHinge(activity)!!.intersect(anchorRect) - } - - @JvmStatic - fun intersectHinge(activity: Activity, anchor: View) = intersectHinge(activity, getRect(anchor)) - - /** - * Returns the width of hinge/display mask. - */ - @JvmStatic - fun getHingeWidth(activity: Activity): Int { - if (!isDeviceSurfaceDuo(activity)) return 0 - return if (activity.isLandscape) - getHinge(activity)!!.width() - else - getHinge(activity)!!.height() - } - - /** - * Returns the width of hinge/display mask. - */ - @JvmStatic - fun getHalfScreenWidth(activity: Activity): Int { - if (!isDeviceSurfaceDuo(activity)) return activity.baseContext.displaySize.x/2 - return (activity.baseContext.displaySize.x - getHingeWidth(activity))/2 - } - - fun getSpanSizeLookup(activity: Activity): GridLayoutManager.SpanSizeLookup { - val span: Int = getHalfScreenWidth(activity) / COLUMNS_IN_START_DUO_MODE - val spanMid: Int = getHalfScreenWidth(activity) / COLUMNS_IN_START_DUO_MODE + getHingeWidth(activity) - val spanEnd: Int = getHalfScreenWidth(activity) / COLUMNS_IN_END_DUO_MODE - - return object : GridLayoutManager.SpanSizeLookup() { - override fun getSpanSize(position: Int): Int { - if (position % (COLUMNS_IN_START_DUO_MODE+ COLUMNS_IN_END_DUO_MODE) < 2) { - return span - } else if (position % (COLUMNS_IN_START_DUO_MODE+ COLUMNS_IN_END_DUO_MODE) == 2) { - return spanMid - } else { - return spanEnd - } - } - } - } - - /** - * Use [getSingleScreenWidthPixels] to get the pixels of a single screen on Surface Duo device - */ - @JvmStatic - fun getSingleScreenWidthPixels(activity: Activity) = if (isWindowDoublePortrait(activity)) (activity.displaySize.x - getHingeWidth(activity)) / 2 else activity.displaySize.x - - /** - * Exception thrown when the [Context] is not activity context. - */ - class ActivityContextNotFoundException : Exception("Activity Context is required.") -} \ No newline at end of file diff --git a/fluentui_drawer/build.gradle b/fluentui_drawer/build.gradle index 8acaf3ab1..abe58f744 100644 --- a/fluentui_drawer/build.gradle +++ b/fluentui_drawer/build.gradle @@ -38,6 +38,9 @@ android { kotlinOptions { jvmTarget = '1.8' } + lintOptions { + abortOnError false + } } dependencies { @@ -49,6 +52,7 @@ dependencies { implementation "androidx.appcompat:appcompat:$appCompatVersion" implementation "androidx.recyclerview:recyclerview:$recyclerViewVersion" implementation "com.google.android.material:material:$materialVersion" + implementation "androidx.activity:activity-compose:$composeActivityVersion" testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$extJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion" diff --git a/fluentui_drawer/src/main/java/com/microsoft/fluentui/bottomsheet/BottomSheetAdapter.kt b/fluentui_drawer/src/main/java/com/microsoft/fluentui/bottomsheet/BottomSheetAdapter.kt index 3912c7a75..bc0578592 100644 --- a/fluentui_drawer/src/main/java/com/microsoft/fluentui/bottomsheet/BottomSheetAdapter.kt +++ b/fluentui_drawer/src/main/java/com/microsoft/fluentui/bottomsheet/BottomSheetAdapter.kt @@ -103,15 +103,22 @@ class BottomSheetAdapter : RecyclerView.Adapter { image.imageAlpha = ThemeUtil.getThemeAttrColor(FluentUIContextThemeWrapper(context, R.style.Theme_FluentUI_Drawer), R.attr.fluentuiBottomSheetDisabledIconColor) listItemView.customView = image - var accessoryImage: ImageView ?= null - if (item.accessoryBitmap != null) { - accessoryImage = context.createImageView(item.accessoryBitmap) + var accessoryView: View ?= null + var accessoryImageView: ImageView ?= null + if (item.customAccessoryView != null) { + accessoryView = item.customAccessoryView + } else if (item.accessoryBitmap != null) { + accessoryImageView = context.createImageView(item.accessoryBitmap) } else if (item.accessoryImageId != NO_ID) { - accessoryImage = context.createImageView(item.accessoryImageId, item.getImageTint(context)) + accessoryImageView = context.createImageView(item.accessoryImageId, item.getImageTint(context)) } - if (accessoryImage != null && item.disabled) - accessoryImage.imageAlpha = ThemeUtil.getThemeAttrColor(FluentUIContextThemeWrapper(context, R.style.Theme_FluentUI_Drawer), R.attr.fluentuiBottomSheetDisabledIconColor) - listItemView.customAccessoryView = accessoryImage + if (accessoryView != null) { + accessoryView.isEnabled = !item.disabled + } else if (accessoryImageView != null && item.disabled) { + accessoryImageView.imageAlpha = + ThemeUtil.getThemeAttrColor(FluentUIContextThemeWrapper(context, R.style.Theme_FluentUI_Drawer), R.attr.fluentuiBottomSheetDisabledIconColor) + } + listItemView.customAccessoryView = accessoryView ?: accessoryImageView listItemView.setOnClickListener { onBottomSheetItemClickListener?.onBottomSheetItemClick(item) diff --git a/fluentui_drawer/src/main/java/com/microsoft/fluentui/bottomsheet/BottomSheetItem.kt b/fluentui_drawer/src/main/java/com/microsoft/fluentui/bottomsheet/BottomSheetItem.kt index 17bfa6b47..a4cdabb71 100644 --- a/fluentui_drawer/src/main/java/com/microsoft/fluentui/bottomsheet/BottomSheetItem.kt +++ b/fluentui_drawer/src/main/java/com/microsoft/fluentui/bottomsheet/BottomSheetItem.kt @@ -44,6 +44,7 @@ class BottomSheetItem : Parcelable { val accessoryBitmap: Bitmap? val roleDescription: String + val customAccessoryView: View? @JvmOverloads constructor( @@ -59,6 +60,7 @@ class BottomSheetItem : Parcelable { @DrawableRes accessoryImageId: Int = NO_ID, accessoryBitmap: Bitmap? = null, roleDescription: String = "", + customAccessoryView: View? = null ) { this.id = id this.imageId = imageId @@ -72,6 +74,7 @@ class BottomSheetItem : Parcelable { this.accessoryImageId = accessoryImageId this.accessoryBitmap = accessoryBitmap this.roleDescription = roleDescription + this.customAccessoryView = customAccessoryView } private constructor(parcel: Parcel) : this( @@ -123,6 +126,7 @@ class BottomSheetItem : Parcelable { if (accessoryImageId != other.accessoryImageId) return false if (accessoryBitmap != other.accessoryBitmap) return false if (roleDescription != other.roleDescription) return false + if (customAccessoryView != other.customAccessoryView) return false return true } @@ -140,6 +144,7 @@ class BottomSheetItem : Parcelable { result = 31 * result + accessoryImageId.hashCode() result = 31 * result + (accessoryBitmap?.hashCode() ?: 0) result = 31 * result + roleDescription.hashCode() + result = 31 * result + (customAccessoryView?.hashCode() ?: 0) return result } diff --git a/fluentui_drawer/src/main/java/com/microsoft/fluentui/drawer/DrawerDialog.kt b/fluentui_drawer/src/main/java/com/microsoft/fluentui/drawer/DrawerDialog.kt index a8b2c86b2..2c7dc9997 100644 --- a/fluentui_drawer/src/main/java/com/microsoft/fluentui/drawer/DrawerDialog.kt +++ b/fluentui_drawer/src/main/java/com/microsoft/fluentui/drawer/DrawerDialog.kt @@ -194,7 +194,11 @@ open class DrawerDialog @JvmOverloads constructor(context: Context, val behavior val displaySize: Point = context.displaySize val layoutParams: WindowManager.LayoutParams? = window?.attributes - layoutParams?.gravity = Gravity.TOP + if(behaviorType == BehaviorType.TOP) { + layoutParams?.gravity = Gravity.TOP + } else { + layoutParams?.gravity = Gravity.BOTTOM + } layoutParams?.y = topMargin layoutParams?.dimAmount = this.dimValue window?.attributes = layoutParams diff --git a/fluentui_drawer/src/main/java/com/microsoft/fluentui/persistentbottomsheet/SheetHorizontalItemAdapter.kt b/fluentui_drawer/src/main/java/com/microsoft/fluentui/persistentbottomsheet/SheetHorizontalItemAdapter.kt index a1ce00be6..59f13cae0 100644 --- a/fluentui_drawer/src/main/java/com/microsoft/fluentui/persistentbottomsheet/SheetHorizontalItemAdapter.kt +++ b/fluentui_drawer/src/main/java/com/microsoft/fluentui/persistentbottomsheet/SheetHorizontalItemAdapter.kt @@ -11,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView import android.view.ContextThemeWrapper import android.view.LayoutInflater import android.view.ViewGroup +import androidx.annotation.ColorInt import com.microsoft.fluentui.drawer.R import com.microsoft.fluentui.util.createImageView @@ -18,7 +19,7 @@ import com.microsoft.fluentui.util.createImageView /** * [SheetHorizontalItemAdapter] is used for horizontal list in bottomSheet */ -class SheetHorizontalItemAdapter(private val context: Context, items: ArrayList, @StyleRes private val themeId: Int = R.style.Theme_FluentUI_Drawer, private val marginBetweenView: Int = 0) : RecyclerView.Adapter() { +class SheetHorizontalItemAdapter(private val context: Context, items: ArrayList, @StyleRes private val themeId: Int = R.style.Theme_FluentUI_Drawer, private val marginBetweenView: Int = 0, @ColorInt private val drawerTint: Int? = null) : RecyclerView.Adapter() { var mOnSheetItemClickListener: SheetItem.OnClickListener? = null private val mItems: ArrayList = items @@ -49,7 +50,7 @@ class SheetHorizontalItemAdapter(private val context: Context, items: ArrayList< if (it != null) { listItemView.update(item.title, context.createImageView(it), item.disabled) } else { - listItemView.update(item.title, context.createImageView(item.drawable), item.disabled) + listItemView.update(item.title, context.createImageView(item.drawable, imageTint = drawerTint), item.disabled) } } listItemView.setOnClickListener { diff --git a/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/Utils.kt b/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/Utils.kt index 3f4f73980..5352f1198 100644 --- a/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/Utils.kt +++ b/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/Utils.kt @@ -1,4 +1,47 @@ package com.microsoft.fluentui.tokenized +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.testTag + internal fun calculateFraction(a: Float, b: Float, pos: Float) = ((pos - a) / (b - a)).coerceIn(0f, 1f) + +@Composable +internal fun Scrim( + open: Boolean, + color: Color, + onClose: () -> Unit, + fraction: () -> Float, + preventDismissalOnScrimClick: Boolean = false, + onScrimClick: () -> Unit = {}, + tag: String +) { + val dismissDrawer = if (open) { + Modifier.pointerInput(onClose) { + detectTapGestures { + if (!preventDismissalOnScrimClick) { + onClose() + } + onScrimClick() //this function runs post onClose() so that the drawer is closed before the callback is invoked + } + } + } else { + Modifier + } + + Canvas( + Modifier + .fillMaxSize() + .then(dismissDrawer) + .testTag(tag) + + ) { + drawRect(color = color, alpha = fraction()) + } +} \ No newline at end of file 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 d566415a7..8701fdfae 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 @@ -13,7 +13,6 @@ import android.view.accessibility.AccessibilityManager import androidx.compose.animation.core.AnimationSpec import androidx.compose.foundation.* import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.layout.* @@ -31,10 +30,10 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.* import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.* @@ -50,6 +49,7 @@ import com.microsoft.fluentui.theme.FluentTheme import com.microsoft.fluentui.theme.token.ControlTokens import com.microsoft.fluentui.theme.token.controlTokens.BottomSheetInfo import com.microsoft.fluentui.theme.token.controlTokens.BottomSheetTokens +import com.microsoft.fluentui.theme.token.controlTokens.SheetAccessibilityAnnouncement import com.microsoft.fluentui.tokenized.calculateFraction import com.microsoft.fluentui.util.dpToPx import com.microsoft.fluentui.util.pxToDp @@ -58,6 +58,7 @@ import kotlinx.coroutines.launch import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt +import com.microsoft.fluentui.tokenized.Scrim /** * Possible values of [BottomSheetState]. @@ -101,7 +102,7 @@ class BottomSheetState( /** * Whether the bottom sheet is visible. */ - val isVisible: Boolean + var isVisible: Boolean = false get() = currentValue != BottomSheetValue.Hidden internal val hasExpandedState: Boolean @@ -135,25 +136,27 @@ class BottomSheetState( * * @throws [CancellationException] if the animation is interrupted */ - suspend fun hide() = animateTo(BottomSheetValue.Hidden) + suspend fun hide() { + try { + animateTo(BottomSheetValue.Hidden) + } finally { + isVisible = false + } + } companion object { /** * The default [Saver] implementation for [BottomSheetState]. */ fun Saver( - animationSpec: AnimationSpec, - confirmStateChange: (BottomSheetValue) -> Boolean - ): Saver = Saver( - save = { it.currentValue }, - restore = { - BottomSheetState( - initialValue = it, - animationSpec = animationSpec, - confirmStateChange = confirmStateChange - ) - } - ) + animationSpec: AnimationSpec, confirmStateChange: (BottomSheetValue) -> Boolean + ): Saver = Saver(save = { it.currentValue }, restore = { + BottomSheetState( + initialValue = it, + animationSpec = animationSpec, + confirmStateChange = confirmStateChange + ) + }) } } @@ -171,10 +174,8 @@ fun rememberBottomSheetState( confirmStateChange: (BottomSheetValue) -> Boolean = { true } ): BottomSheetState { return rememberSaveable( - initialValue, animationSpec, confirmStateChange, - saver = BottomSheetState.Saver( - animationSpec = animationSpec, - confirmStateChange = confirmStateChange + initialValue, animationSpec, confirmStateChange, saver = BottomSheetState.Saver( + animationSpec = animationSpec, confirmStateChange = confirmStateChange ) ) { BottomSheetState( @@ -192,6 +193,51 @@ const val BOTTOMSHEET_SCRIM_TAG = "Fluent Bottom Sheet Scrim" private const val BottomSheetOpenFraction = 0.5f +@Composable +internal fun AccesibilityBottomsheetAnnouncement(sheetState: BottomSheetState, talkbackAnnouncement: SheetAccessibilityAnnouncement){ + val view = LocalView.current + var previousState by remember { mutableStateOf(sheetState.currentValue) } + + LaunchedEffect(sheetState.currentValue) { + when (sheetState.currentValue) { + BottomSheetValue.Expanded -> { + when (previousState) { + BottomSheetValue.Shown -> { + view.announceForAccessibility(talkbackAnnouncement.shownToExpanded) + } + BottomSheetValue.Hidden -> { + view.announceForAccessibility(talkbackAnnouncement.collapsedToExpanded) + } + BottomSheetValue.Expanded -> {} + } + } + BottomSheetValue.Shown -> { + when (previousState) { + BottomSheetValue.Expanded -> { + view.announceForAccessibility(talkbackAnnouncement.expandedToShown) + } + BottomSheetValue.Hidden -> { + view.announceForAccessibility(talkbackAnnouncement.collapsedToShown) + } + BottomSheetValue.Shown -> {} + } + } + BottomSheetValue.Hidden -> { + when (previousState) { + BottomSheetValue.Expanded -> { + view.announceForAccessibility(talkbackAnnouncement.expandedToCollapsed) + } + BottomSheetValue.Shown -> { + view.announceForAccessibility(talkbackAnnouncement.shownToCollapsed) + } + BottomSheetValue.Hidden -> {} + } + } + } + previousState = sheetState.currentValue // Update previous state + } +} + /** * * Bottom sheets present a set of choices while blocking interaction with the rest of the @@ -229,21 +275,24 @@ fun BottomSheet( sheetState: BottomSheetState = rememberBottomSheetState(BottomSheetValue.Hidden), expandable: Boolean = true, peekHeight: Dp = 110.dp, - scrimVisible: Boolean = true, + scrimVisible: Boolean = false, showHandle: Boolean = true, slideOver: Boolean = true, enableSwipeDismiss: Boolean = false, + preventDismissalOnScrimClick: Boolean = false, // if true, the sheet will not be dismissed when the scrim is clicked stickyThresholdUpward: Float = 56f, stickyThresholdDownward: Float = 56f, + talkbackAnnouncement: SheetAccessibilityAnnouncement = SheetAccessibilityAnnouncement(), bottomSheetTokens: BottomSheetTokens? = null, + onDismiss: () -> Unit = {}, // callback to be invoked after the sheet is closed content: @Composable () -> Unit ) { val themeID = FluentTheme.themeID //Adding This only for recomposition in case of Token Updates. Unused otherwise. val tokens = bottomSheetTokens ?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.BottomSheetControlType] as BottomSheetTokens - val bottomSheetInfo = BottomSheetInfo() + AccesibilityBottomsheetAnnouncement(sheetState, talkbackAnnouncement) val sheetShape: Shape = RoundedCornerShape( topStart = tokens.cornerRadius(bottomSheetInfo), topEnd = tokens.cornerRadius(bottomSheetInfo) @@ -252,16 +301,14 @@ fun BottomSheet( val sheetBackgroundColor: Brush = tokens.backgroundBrush(bottomSheetInfo) val sheetHandleColor: Color = tokens.handleColor(bottomSheetInfo) val scrimOpacity: Float = tokens.scrimOpacity(bottomSheetInfo) - val scrimColor: Color = - tokens.scrimColor(bottomSheetInfo).copy(alpha = scrimOpacity) + val scrimColor: Color = tokens.scrimColor(bottomSheetInfo).copy(alpha = scrimOpacity) val scope = rememberCoroutineScope() - val maxLandscapeWidth :Float= tokens.maxLandscapeWidth(bottomSheetInfo) + val maxLandscapeWidth: Float = tokens.maxLandscapeWidth(bottomSheetInfo) BoxWithConstraints(modifier) { val fullHeight = constraints.maxHeight.toFloat() - val sheetHeightState = - remember(sheetContent.hashCode()) { mutableStateOf(null) } + val sheetHeightState = remember(sheetContent.hashCode()) { mutableStateOf(null) } Box( Modifier @@ -276,35 +323,42 @@ fun BottomSheet( true } } - } - ) { + }) { content() - if (slideOver) { + if (scrimVisible) { Scrim( - color = if (scrimVisible) scrimColor else Color.Transparent, - onDismiss = { + color = scrimColor, + onClose = { if (sheetState.confirmStateChange(BottomSheetValue.Hidden)) { - scope.launch { sheetState.show() } + scope.launch { sheetState.hide() } } }, fraction = { - if (sheetState.anchors.isEmpty() - || !sheetState.anchors.containsValue(BottomSheetValue.Expanded) - || (sheetHeightState.value != null && sheetHeightState.value == 0f) - ) { + if (sheetState.anchors.isEmpty() || (sheetHeightState.value != null && sheetHeightState.value == 0f)) { 0.toFloat() } else { + val targetValue: BottomSheetValue = if (slideOver) { + if (sheetState.anchors.entries.firstOrNull { it.value == BottomSheetValue.Expanded } != null) { + BottomSheetValue.Expanded + } else if (sheetState.anchors.entries.firstOrNull { it.value == BottomSheetValue.Shown } != null) { + BottomSheetValue.Shown + } else { + BottomSheetValue.Hidden + } + } else { + BottomSheetValue.Shown + } calculateFraction( - sheetState.anchors.entries.firstOrNull { it.value == BottomSheetValue.Shown }?.key!!, - sheetState.anchors.entries.firstOrNull { it.value == BottomSheetValue.Expanded }?.key!!, + sheetState.anchors.entries.firstOrNull { it.value == BottomSheetValue.Hidden }?.key!!, + sheetState.anchors.entries.firstOrNull { it.value == targetValue }?.key!!, sheetState.offset.value ) } }, - visible = (sheetState.targetValue == BottomSheetValue.Expanded - || (sheetState.targetValue == BottomSheetValue.Shown - && sheetState.currentValue == BottomSheetValue.Expanded) - ) + open = sheetState.isVisible, + onScrimClick = onDismiss, + preventDismissalOnScrimClick = preventDismissalOnScrimClick, + tag = BOTTOMSHEET_SCRIM_TAG ) } } @@ -314,15 +368,14 @@ fun BottomSheet( Modifier .align(Alignment.TopCenter) .fillMaxWidth( - if(configuration.orientation == Configuration.ORIENTATION_LANDSCAPE)maxLandscapeWidth + if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) maxLandscapeWidth else 1F ) .nestedScroll( if (!enableSwipeDismiss && sheetState.offset.value >= (fullHeight - dpToPx( peekHeight )) - ) - sheetState.NonDismissiblePostDownNestedScrollConnection + ) sheetState.NonDismissiblePostDownNestedScrollConnection else if (slideOver) sheetState.PreUpPostDownNestedScrollConnection else sheetState.PostDownNestedScrollConnection ) @@ -360,11 +413,7 @@ fun BottomSheet( } } .sheetHeight( - expandable, - slideOver, - fullHeight, - peekHeight, - sheetState + expandable, slideOver, fullHeight, peekHeight, sheetState ) .clip(sheetShape) .shadow(sheetElevation) @@ -376,6 +425,7 @@ fun BottomSheet( if (sheetState.confirmStateChange(BottomSheetValue.Hidden)) { scope.launch { sheetState.hide() } } + onDismiss() true } } @@ -423,6 +473,7 @@ fun BottomSheet( if (!sheetState.isVisible) { if (enableSwipeDismiss) { scope.launch { sheetState.hide() } + onDismiss() } else { scope.launch { sheetState.show() } } @@ -434,67 +485,78 @@ fun BottomSheet( ) { val collapsed = LocalContext.current.resources.getString(R.string.collapsed) val expanded = LocalContext.current.resources.getString(R.string.expanded) - val accessibilityManager = LocalContext.current.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager - Icon( - painterResource(id = R.drawable.ic_drawer_handle), - contentDescription = - if (sheetState.currentValue == BottomSheetValue.Expanded || (sheetState.hasExpandedState && sheetState.isVisible)) { + val accessibilityManager = + LocalContext.current.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager + Icon(painterResource(id = R.drawable.ic_drawer_handle), + contentDescription = if (sheetState.currentValue == BottomSheetValue.Expanded || (sheetState.hasExpandedState && sheetState.isVisible)) { LocalContext.current.resources.getString(R.string.drag_handle) } else { null }, tint = sheetHandleColor, - modifier = Modifier - .clickable( - enabled = sheetState.hasExpandedState, - role = Role.Button, - onClickLabel = - if (sheetState.currentValue == BottomSheetValue.Expanded) { - LocalContext.current.resources.getString(R.string.collapse) - } else { - if (sheetState.hasExpandedState && sheetState.isVisible) LocalContext.current.resources.getString( - R.string.expand - ) else null - } - ) { - if (sheetState.currentValue == BottomSheetValue.Expanded) { - if (sheetState.confirmStateChange(BottomSheetValue.Shown)) { - scope.launch { sheetState.show() } - accessibilityManager?.let { manager -> - if(manager.isEnabled){ - val event = AccessibilityEvent.obtain(AccessibilityEvent.TYPE_ANNOUNCEMENT).apply { - text.add(collapsed) - } - manager.sendAccessibilityEvent(event) - } + modifier = Modifier.clickable( + enabled = sheetState.hasExpandedState, + role = Role.Button, + onClickLabel = if (sheetState.currentValue == BottomSheetValue.Expanded) { + LocalContext.current.resources.getString(R.string.collapse) + } else { + if (sheetState.hasExpandedState && sheetState.isVisible) LocalContext.current.resources.getString( + R.string.expand + ) else null + } + ) { + if (sheetState.currentValue == BottomSheetValue.Expanded) { + if (sheetState.confirmStateChange(BottomSheetValue.Shown)) { + scope.launch { sheetState.show() } + accessibilityManager?.let { manager -> + if (manager.isEnabled) { + val event = + AccessibilityEvent.obtain(AccessibilityEvent.TYPE_ANNOUNCEMENT) + .apply { + text.add(collapsed) + } + manager.sendAccessibilityEvent(event) } } - } else if (sheetState.hasExpandedState) { - if (sheetState.confirmStateChange(BottomSheetValue.Expanded)) { - scope.launch { sheetState.expand() } - accessibilityManager?.let { manager -> - if(manager.isEnabled){ - val event = AccessibilityEvent.obtain(AccessibilityEvent.TYPE_ANNOUNCEMENT).apply { - text.add(expanded) - } - manager.sendAccessibilityEvent(event) - } + } + } else if (sheetState.hasExpandedState) { + if (sheetState.confirmStateChange(BottomSheetValue.Expanded)) { + scope.launch { sheetState.expand() } + accessibilityManager?.let { manager -> + if (manager.isEnabled) { + val event = + AccessibilityEvent.obtain(AccessibilityEvent.TYPE_ANNOUNCEMENT) + .apply { + text.add(expanded) + } + manager.sendAccessibilityEvent(event) } } } } - ) + }) } } Column(modifier = Modifier .testTag(BOTTOMSHEET_CONTENT_TAG) - .then(if (slideOver) Modifier - .onFocusChanged { focusState -> - if (focusState.hasFocus && sheetState.currentValue != BottomSheetValue.Expanded) { // this expands the sheet when the content is focused - scope.launch { sheetState.expand() } - } - } else Modifier.fillMaxSize()), - content = { sheetContent() }) + .then(if (slideOver) Modifier.onFocusChanged { focusState -> + if (focusState.hasFocus && sheetState.currentValue != BottomSheetValue.Expanded) { // this expands the sheet when the content is focused + scope.launch { sheetState.expand() } + } + } else Modifier.fillMaxSize()), content = { + sheetContent() + Spacer( + modifier = Modifier + .fillMaxWidth() + .height( + fullHeight.dp + ) + .background(sheetBackgroundColor) + .onGloballyPositioned { + sheetHeightState.value = sheetHeightState.value?.minus(it.size.height.toFloat()) + } + ) + }) } } } @@ -517,27 +579,27 @@ private fun Modifier.bottomSheetSwipeable( if (sheetHeight != null && sheetHeight != 0f) { val anchors = if (!expandable) { mapOf( - fullHeight to BottomSheetValue.Hidden, - (fullHeight - min(sheetHeight, peekHeightPx))+keyCorrection to BottomSheetValue.Shown + fullHeight to BottomSheetValue.Hidden, (fullHeight - min( + sheetHeight, peekHeightPx + )) + keyCorrection to BottomSheetValue.Shown ) } else if (sheetHeight <= peekHeightPx) { mapOf( fullHeight to BottomSheetValue.Hidden, - (fullHeight - sheetHeight)+keyCorrection to BottomSheetValue.Shown + (fullHeight - sheetHeight) + keyCorrection to BottomSheetValue.Shown ) } else { mapOf( fullHeight to BottomSheetValue.Hidden, - (fullHeight - peekHeightPx)+keyCorrection to BottomSheetValue.Shown, - (max(0f, fullHeight - sheetHeight))+(keyCorrection*2) to BottomSheetValue.Expanded + (fullHeight - peekHeightPx) + keyCorrection to BottomSheetValue.Shown, + (max( + 0f, fullHeight - sheetHeight + )) + (keyCorrection * 2) to BottomSheetValue.Expanded ) } - if (sheetState.initialValue == BottomSheetValue.Expanded - && anchors.entries.firstOrNull { it.value == BottomSheetValue.Expanded } == null - ) { + if (sheetState.initialValue == BottomSheetValue.Expanded && anchors.entries.firstOrNull { it.value == BottomSheetValue.Expanded } == null) { throw IllegalArgumentException( - "BottomSheet initial value must not be set to Expanded " + - "if the whole content is visible in Shown state itself" + "BottomSheet initial value must not be set to Expanded " + "if the whole content is visible in Shown state itself" ) } Modifier.swipeable( @@ -549,9 +611,15 @@ private fun Modifier.bottomSheetSwipeable( val fromKey = anchors.entries.firstOrNull { it.value == from }?.key val toKey = anchors.entries.firstOrNull { it.value == to }?.key - if(fromKey == null || toKey == null) { FixedThreshold(56.dp) } //in case of null defaulting to 56.dp threshold - else if (fromKey < toKey) { FixedThreshold(stickyThresholdDownward.dp) } // Threshold for drag down - else{ FixedThreshold(stickyThresholdUpward.dp) } // Threshold for drag up + if (fromKey == null || toKey == null) { + FixedThreshold(56.dp) + } //in case of null defaulting to 56.dp threshold + else if (fromKey < toKey) { + FixedThreshold(stickyThresholdDownward.dp) + } // Threshold for drag down + else { + FixedThreshold(stickyThresholdUpward.dp) + } // Threshold for drag up }, resistance = null ) @@ -582,9 +650,15 @@ private fun Modifier.bottomSheetSwipeable( val fromKey = anchors.entries.firstOrNull { it.value == from }?.key val toKey = anchors.entries.firstOrNull { it.value == to }?.key - if(fromKey == null || toKey == null) { FixedThreshold(56.dp) } //in case of null defaulting to 56 as a fallback - else if (fromKey < toKey) { FixedThreshold(stickyThresholdDownward.dp) } // Threshold for drag down - else{ FixedThreshold(stickyThresholdUpward.dp) } // Threshold for drag up + if (fromKey == null || toKey == null) { + FixedThreshold(56.dp) + } //in case of null defaulting to 56 as a fallback + else if (fromKey < toKey) { + FixedThreshold(stickyThresholdDownward.dp) + } // Threshold for drag down + else { + FixedThreshold(stickyThresholdUpward.dp) + } // Threshold for drag up }, resistance = null ) @@ -601,47 +675,16 @@ private fun Modifier.sheetHeight( peekHeight: Dp, sheetState: BottomSheetState ): Modifier { - val modifier = - if (slideOver) { - if (expandable) { - Modifier - } else { - Modifier.heightIn( - 0.dp, - pxToDp(min(fullHeight * BottomSheetOpenFraction, dpToPx(peekHeight))) - ) - } - } else { - Modifier.heightIn(0.dp, pxToDp(fullHeight - sheetState.offset.value)) - } - return this.then(modifier) -} - -//TODO : Revisit Scrim usage across module to check single scrim implementation across module. -@Composable -private fun Scrim( - color: Color, - onDismiss: () -> Unit, - fraction: () -> Float, - visible: Boolean -) { - if (visible) { - val closeSheet = LocalContext.current.resources.getString(R.string.fluentui_close_sheet) - val dismissModifier = Modifier - .pointerInput(onDismiss) { detectTapGestures { onDismiss() } } - .semantics(mergeDescendants = true) { - contentDescription = closeSheet - onClick { onDismiss(); true } - } - - Canvas( + val modifier = if (slideOver) { + if (expandable) { Modifier - .fillMaxSize() - .then(dismissModifier) - .testTag(BOTTOMSHEET_SCRIM_TAG) - - ) { - drawRect(color = color, alpha = fraction()) + } else { + Modifier.heightIn( + 0.dp, pxToDp(min(fullHeight * BottomSheetOpenFraction, dpToPx(peekHeight))) + ) } + } else { + Modifier.heightIn(0.dp, pxToDp(fullHeight - sheetState.offset.value)) } + return this.then(modifier) } diff --git a/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/drawer/BottomDrawer.kt b/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/drawer/BottomDrawer.kt new file mode 100644 index 000000000..debf37dac --- /dev/null +++ b/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/drawer/BottomDrawer.kt @@ -0,0 +1,426 @@ +package com.microsoft.fluentui.tokenized.drawer + +import android.content.Context +import android.content.res.Configuration +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityManager +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.collapse +import androidx.compose.ui.semantics.dismiss +import androidx.compose.ui.semantics.expand +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.microsoft.fluentui.compose.NonDismissiblePreUpPostDownNestedScrollConnection +import com.microsoft.fluentui.compose.PostDownNestedScrollConnection +import com.microsoft.fluentui.compose.swipeable +import com.microsoft.fluentui.drawer.R +import com.microsoft.fluentui.theme.token.Icon +import com.microsoft.fluentui.tokenized.calculateFraction +import com.microsoft.fluentui.util.pxToDp +import kotlinx.coroutines.launch +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + + +@Composable +internal fun BottomDrawer( + modifier: Modifier, + drawerState: DrawerState, + drawerShape: Shape, + drawerElevation: Dp, + drawerBackground: Brush, + drawerHandleColor: Color, + scrimColor: Color, + scrimVisible: Boolean, + slideOver: Boolean, + enableSwipeDismiss: Boolean = true, + showHandle: Boolean, + onDismiss: () -> Unit, + drawerContent: @Composable () -> Unit, + maxLandscapeWidthFraction : Float = 1F, + preventDismissalOnScrimClick: Boolean = false, + onScrimClick: () -> Unit = {} +) { + BoxWithConstraints(modifier.fillMaxSize()) { + val fullHeight = constraints.maxHeight.toFloat() + val drawerHeight = + remember(drawerContent.hashCode()) { mutableStateOf(null) } + val maxOpenHeight = fullHeight * DrawerOpenFraction + + val drawerConstraints = with(LocalDensity.current) { + Modifier + .sizeIn( + maxWidth = constraints.maxWidth.toDp(), + maxHeight = constraints.maxHeight.toDp() + ) + } + val scope = rememberCoroutineScope() + + Scrim( + open = !drawerState.isClosed || (drawerHeight != null && drawerHeight.value == 0f), + onClose = onDismiss, + fraction = { + if (drawerState.anchors.isEmpty() || (drawerHeight != null && drawerHeight.value == 0f)) { + 0.toFloat() + } else { + var targetValue: DrawerValue = if (slideOver) { + drawerState.anchors.maxBy { it.value }.value + } else if (drawerState.skipOpenState) { + DrawerValue.Expanded + } else { + DrawerValue.Open + } + calculateFraction( + drawerState.anchors.entries.firstOrNull { it.value == DrawerValue.Closed }?.key!!, + drawerState.anchors.entries.firstOrNull { it.value == targetValue }?.key!!, + drawerState.offset.value + ) + } + }, + color = if (scrimVisible) scrimColor else Color.Transparent, + preventDismissalOnScrimClick = preventDismissalOnScrimClick, + onScrimClick = onScrimClick + ) + val configuration = LocalConfiguration.current + Box( + drawerConstraints + .fillMaxWidth( + if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) maxLandscapeWidthFraction + else 1F + ) + .nestedScroll( + if (!enableSwipeDismiss && drawerState.offset.value >= maxOpenHeight) drawerState.NonDismissiblePreUpPostDownNestedScrollConnection else + if (slideOver) drawerState.nestedScrollConnection else drawerState.PostDownNestedScrollConnection + ) + .offset { + val y = if (drawerState.anchors == null) { + fullHeight.roundToInt() + } else { + drawerState.offset.value.roundToInt() + } + IntOffset(x = 0, y = y) + } + .then( + if (maxLandscapeWidthFraction != 1F + && configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + ) Modifier.align(Alignment.TopCenter) + else Modifier + ) + .onGloballyPositioned { layoutCoordinates -> + if (!drawerState.animationInProgress + && drawerState.currentValue == DrawerValue.Closed + && drawerState.targetValue == DrawerValue.Closed + ) { + onDismiss() + } + + if (slideOver) { + val originalSize = layoutCoordinates.size.height.toFloat() + drawerHeight.value = if (drawerState.expandable) { + originalSize + } else { + min( + originalSize, + maxOpenHeight + ) + } + } + } + .bottomDrawerSwipeable( + drawerState, + slideOver, + maxOpenHeight, + fullHeight, + drawerHeight.value + ) + .drawerHeight( + slideOver, + maxOpenHeight, + fullHeight, + drawerState + ) + .shadow(drawerElevation) + .clip(drawerShape) + .background(drawerBackground) + .semantics { + if (!drawerState.isClosed) { + dismiss { + onDismiss() + true + } + if (drawerState.currentValue == DrawerValue.Open && drawerState.hasExpandedState) { + expand { + if (drawerState.confirmStateChange(DrawerValue.Expanded)) { + scope.launch { drawerState.expand() } + } + true + } + } else if (drawerState.hasExpandedState && drawerState.hasOpenedState) { + collapse { + if (drawerState.confirmStateChange(DrawerValue.Open)) { + scope.launch { drawerState.open() } + } + true + } + } + } + } + .focusable(false), + ) { + Column { + if (showHandle) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(vertical = 8.dp) + .fillMaxWidth() + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + if (!enableSwipeDismiss && drawerState.offset.value >= maxOpenHeight) { + if (delta < 0) { + drawerState.performDrag(delta) + } + } else { + drawerState.performDrag(delta) + } + }, + onDragStopped = { velocity -> + launch { + drawerState.performFling( + velocity + ) + if (drawerState.isClosed) { + if (enableSwipeDismiss) + onDismiss() + else + scope.launch { drawerState.open() } + } + } + }, + ) + .testTag(DRAWER_HANDLE_TAG) + ) { + val collapsed = LocalContext.current.resources.getString(R.string.collapsed) + val expanded = LocalContext.current.resources.getString(R.string.expanded) + val accessibilityManager = LocalContext.current.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager + Icon( + painterResource(id = R.drawable.ic_drawer_handle), + contentDescription = LocalContext.current.resources.getString(R.string.drag_handle), + tint = drawerHandleColor, + modifier = Modifier + .clickable( + enabled = drawerState.hasExpandedState, + role = Role.Button, + onClickLabel = + if (drawerState.currentValue == DrawerValue.Expanded) { + LocalContext.current.resources.getString(R.string.collapse) + } else { + if (drawerState.hasExpandedState && !drawerState.isClosed) LocalContext.current.resources.getString( + R.string.expand + ) else null + } + ) { + if (drawerState.currentValue == DrawerValue.Expanded) { + if (drawerState.hasOpenedState && drawerState.confirmStateChange( + DrawerValue.Open + ) + ) { + scope.launch { drawerState.open() } + accessibilityManager?.let { manager -> + if(manager.isEnabled){ + val event = AccessibilityEvent.obtain( + AccessibilityEvent.TYPE_ANNOUNCEMENT).apply { + text.add(collapsed) + } + manager.sendAccessibilityEvent(event) + } + } + } + } else if (drawerState.hasExpandedState) { + if (drawerState.confirmStateChange(DrawerValue.Expanded)) { + scope.launch { drawerState.expand() } + accessibilityManager?.let { manager -> + if(manager.isEnabled){ + val event = AccessibilityEvent.obtain( + AccessibilityEvent.TYPE_ANNOUNCEMENT).apply { + text.add(expanded) + } + manager.sendAccessibilityEvent(event) + } + } + } + } + } + ) + } + } + Column(modifier = Modifier + .testTag(DRAWER_CONTENT_TAG), content = { drawerContent() }) + } + } + } +} + + +private fun Modifier.bottomDrawerSwipeable( + drawerState: DrawerState, + slideOver: Boolean, + maxOpenHeight: Float, + fullHeight: Float, + drawerHeight: Float? +): Modifier { + val modifier = if (slideOver) { + if (drawerHeight != null) { + val minHeight = 0f + val bottomOpenStateY = max(maxOpenHeight, fullHeight - drawerHeight) + val bottomExpandedStateY = max(minHeight, fullHeight - drawerHeight) + val anchors = + if (drawerHeight <= maxOpenHeight) { // when contentHeight is less than maxOpenHeight + if (drawerState.anchors.containsValue(DrawerValue.Expanded)) { + /* + *For dynamic content when drawerHeight was previously greater than maxOpenHeight and now less than maxOpenHEight + *The old anchors won't have Open state, so we need to continue with Expanded state. + */ + mapOf( + bottomOpenStateY to DrawerValue.Expanded, + fullHeight to DrawerValue.Closed, + ) + } else { + mapOf( + bottomOpenStateY to DrawerValue.Open, + fullHeight to DrawerValue.Closed + ) + } + } else { + if (drawerState.expandable) { + if (drawerState.skipOpenState) { + if (drawerState.anchors.containsValue(DrawerValue.Open)) { + /* + *For dynamic content when drawerHeight was previously less than maxOpenHeight and now greater than maxOpenHEight + *The old anchors won't have Expanded state, so we need to continue with Open state. + */ + mapOf( + bottomExpandedStateY to DrawerValue.Open, // when drawerHeight is greater than maxOpenHeight but less than fullHeight, then Expanded state starts from fullHeight-drawerHeight + fullHeight to DrawerValue.Closed + ) + } else { + mapOf( + bottomExpandedStateY to DrawerValue.Expanded, // when drawerHeight is greater than maxOpenHeight but less than fullHeight, then Expanded state starts from fullHeight-drawerHeight + fullHeight to DrawerValue.Closed, + ) + } + } else { + mapOf( + maxOpenHeight to DrawerValue.Open, + bottomExpandedStateY to DrawerValue.Expanded, + fullHeight to DrawerValue.Closed + ) + } + } else { + mapOf( + maxOpenHeight to DrawerValue.Open, + fullHeight to DrawerValue.Closed + ) + } + } + Modifier.swipeable( + state = drawerState, + anchors = anchors, + orientation = Orientation.Vertical, + enabled = false, + resistance = null + ) + } else { + Modifier + } + } else { + val anchors = if (drawerState.expandable) { + if (drawerState.skipOpenState) { + mapOf( + 0F to DrawerValue.Expanded, + fullHeight to DrawerValue.Closed, + ) + } else { + mapOf( + maxOpenHeight to DrawerValue.Open, + 0F to DrawerValue.Expanded, + fullHeight to DrawerValue.Closed + ) + } + } else { + mapOf( + maxOpenHeight to DrawerValue.Open, + fullHeight to DrawerValue.Closed + ) + } + Modifier.swipeable( + state = drawerState, + anchors = anchors, + orientation = Orientation.Vertical, + enabled = false, + resistance = null + ) + } + return this.then(modifier) +} + + +private fun Modifier.drawerHeight( + slideOver: Boolean, + fixedHeight: Float, + fullHeight: Float, + drawerState: DrawerState +): Modifier { + val modifier = if (slideOver) { + if (drawerState.expandable) { + Modifier + } else { + Modifier.heightIn( + 0.dp, + pxToDp(fixedHeight) + ) + } + } else { + Modifier.height(pxToDp(fullHeight - drawerState.offset.value)) + } + + return this.then(modifier) +} diff --git a/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/drawer/Drawer.kt b/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/drawer/Drawer.kt index 496c0c685..c5e1be86a 100644 --- a/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/drawer/Drawer.kt +++ b/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/drawer/Drawer.kt @@ -1,30 +1,12 @@ package com.microsoft.fluentui.tokenized.drawer -import android.content.Context -import android.content.res.Configuration -import android.view.accessibility.AccessibilityEvent -import android.view.accessibility.AccessibilityManager -import androidx.compose.animation.core.TweenSpec +import androidx.activity.compose.BackHandler import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable -import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -32,85 +14,34 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.Saver 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.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.layout -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.collapse -import androidx.compose.ui.semantics.dismiss -import androidx.compose.ui.semantics.expand -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties -import androidx.constraintlayout.compose.ConstraintLayout import androidx.core.view.WindowInsetsCompat -import com.microsoft.fluentui.compose.FixedThreshold import com.microsoft.fluentui.compose.ModalPopup -import com.microsoft.fluentui.compose.NonDismissiblePreUpPostDownNestedScrollConnection -import com.microsoft.fluentui.compose.PostDownNestedScrollConnection import com.microsoft.fluentui.compose.PreUpPostDownNestedScrollConnection import com.microsoft.fluentui.compose.SwipeableState -import com.microsoft.fluentui.compose.swipeable -import com.microsoft.fluentui.drawer.R import com.microsoft.fluentui.theme.FluentTheme import com.microsoft.fluentui.theme.token.ControlTokens -import com.microsoft.fluentui.theme.token.Icon import com.microsoft.fluentui.theme.token.controlTokens.BehaviorType +import com.microsoft.fluentui.theme.token.controlTokens.DrawerAccessibilityAnnouncement import com.microsoft.fluentui.theme.token.controlTokens.DrawerInfo import com.microsoft.fluentui.theme.token.controlTokens.DrawerTokens -import com.microsoft.fluentui.tokenized.calculateFraction -import com.microsoft.fluentui.util.dpToPx -import com.microsoft.fluentui.util.pxToDp import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlin.math.max -import kotlin.math.min -import kotlin.math.roundToInt - - -/** - * Possible values of [DrawerState]. - */ -enum class DrawerValue { - /** - * The state of the drawer when it is closed. - */ - Closed, - - /** - * The state of the drawer when it is open. - */ - Open, - - /** - * The state of the bottom drawer when it is expanded (i.e. at 100% height). - */ - Expanded -} /** * State of the [Drawer] composable. @@ -152,7 +83,7 @@ class DrawerState( } } - var enable: Boolean by mutableStateOf(false) + var enable: Boolean by mutableStateOf(initialValue != DrawerValue.Closed) /** * Whether drawer has Open state. @@ -336,15 +267,16 @@ fun rememberDrawerState(confirmStateChange: (DrawerValue) -> Boolean = { true }) @Composable fun rememberBottomDrawerState( + initialValue: DrawerValue = DrawerValue.Closed, expandable: Boolean = true, skipOpenState: Boolean = false, confirmStateChange: (DrawerValue) -> Boolean = { true } ): DrawerState { return rememberSaveable( - confirmStateChange, expandable, skipOpenState, + initialValue, confirmStateChange, expandable, skipOpenState, saver = DrawerState.Saver(expandable, skipOpenState, confirmStateChange) ) { - DrawerState(DrawerValue.Closed, expandable, skipOpenState, confirmStateChange) + DrawerState(initialValue, expandable, skipOpenState, confirmStateChange) } } @@ -363,7 +295,7 @@ private class DrawerPositionProvider(val offset: IntOffset?) : PopupPositionProv } @Composable -private fun Scrim( +internal fun Scrim( open: Boolean, onClose: () -> Unit, fraction: () -> Float, @@ -394,664 +326,23 @@ private fun Scrim( } } -private val EndDrawerPadding = 56.dp -private val DrawerVelocityThreshold = 400.dp - -private val AnimationSpec = TweenSpec(durationMillis = 256) - -private const val DrawerOpenFraction = 0.5f - -//Tag use for testing -const val DRAWER_HANDLE_TAG = "Fluent Drawer Handle" -const val DRAWER_CONTENT_TAG = "Fluent Drawer Content" -const val DRAWER_SCRIM_TAG = "Fluent Drawer Scrim" - -//Drawer Handle height + padding -private val DrawerHandleHeightOffset = 20.dp - -private fun Modifier.drawerHeight( - slideOver: Boolean, - fixedHeight: Float, - fullHeight: Float, - drawerState: DrawerState -): Modifier { - val modifier = if (slideOver) { - if (drawerState.expandable) { - Modifier - } else { - Modifier.heightIn( - 0.dp, - pxToDp(fixedHeight) - ) - } - } else { - Modifier.height(pxToDp(fullHeight - drawerState.offset.value)) - } - - return this.then(modifier) -} - -private fun Modifier.bottomDrawerSwipeable( - drawerState: DrawerState, - slideOver: Boolean, - maxOpenHeight: Float, - fullHeight: Float, - drawerHeight: Float? -): Modifier { - val modifier = if (slideOver) { - if (drawerHeight != null) { - val minHeight = 0f - val bottomOpenStateY = max(maxOpenHeight, fullHeight - drawerHeight) - val bottomExpandedStateY = max(minHeight, fullHeight - drawerHeight) - val anchors = - if (drawerHeight <= maxOpenHeight) { // when contentHeight is less than maxOpenHeight - if (drawerState.anchors.containsValue(DrawerValue.Expanded)) { - /* - *For dynamic content when drawerHeight was previously greater than maxOpenHeight and now less than maxOpenHEight - *The old anchors won't have Open state, so we need to continue with Expanded state. - */ - mapOf( - bottomOpenStateY to DrawerValue.Expanded, - fullHeight to DrawerValue.Closed, - ) - } else { - mapOf( - bottomOpenStateY to DrawerValue.Open, - fullHeight to DrawerValue.Closed - ) - } - } else { - if (drawerState.expandable) { - if (drawerState.skipOpenState) { - if (drawerState.anchors.containsValue(DrawerValue.Open)) { - /* - *For dynamic content when drawerHeight was previously less than maxOpenHeight and now greater than maxOpenHEight - *The old anchors won't have Expanded state, so we need to continue with Open state. - */ - mapOf( - bottomExpandedStateY to DrawerValue.Open, // when drawerHeight is greater than maxOpenHeight but less than fullHeight, then Expanded state starts from fullHeight-drawerHeight - fullHeight to DrawerValue.Closed - ) - } else { - mapOf( - bottomExpandedStateY to DrawerValue.Expanded, // when drawerHeight is greater than maxOpenHeight but less than fullHeight, then Expanded state starts from fullHeight-drawerHeight - fullHeight to DrawerValue.Closed, - ) - } - } else { - mapOf( - maxOpenHeight to DrawerValue.Open, - bottomExpandedStateY to DrawerValue.Expanded, - fullHeight to DrawerValue.Closed - ) - } - } else { - mapOf( - maxOpenHeight to DrawerValue.Open, - fullHeight to DrawerValue.Closed - ) - } - } - Modifier.swipeable( - state = drawerState, - anchors = anchors, - orientation = Orientation.Vertical, - enabled = false, - resistance = null - ) - } else { - Modifier - } - } else { - val anchors = if (drawerState.expandable) { - if (drawerState.skipOpenState) { - mapOf( - 0F to DrawerValue.Expanded, - fullHeight to DrawerValue.Closed, - ) - } else { - mapOf( - maxOpenHeight to DrawerValue.Open, - 0F to DrawerValue.Expanded, - fullHeight to DrawerValue.Closed - ) - } - } else { - mapOf( - maxOpenHeight to DrawerValue.Open, - fullHeight to DrawerValue.Closed - ) - } - Modifier.swipeable( - state = drawerState, - anchors = anchors, - orientation = Orientation.Vertical, - enabled = false, - resistance = null - ) - } - return this.then(modifier) -} - -/** - * - * - * Side drawers block interaction with the rest of an app’s content with a scrim. - * They are elevated above most of the app’s UI and don’t affect the screen’s layout grid. - * - * @param drawerContent composable that represents content inside the drawer - * @param modifier optional modifier for the drawer - * @param drawerState state of the drawer - * @param drawerShape shape of the drawer sheet - * @param drawerElevation drawer sheet elevation. This controls the size of the shadow below the - * drawer sheet - * @param drawerBackground background color to be used for the drawer sheet - * @param scrimColor color of the scrim that obscures content when the drawer is open - * @param preventDismissalOnScrimClick when true, the drawer will not be dismissed when the scrim is clicked - * @param onScrimClick callback to be invoked when the scrim is clicked - * - * @throws IllegalStateException when parent has [Float.POSITIVE_INFINITY] width - */ @Composable -private fun HorizontalDrawer( - modifier: Modifier, - behaviorType: BehaviorType, - drawerState: DrawerState, - drawerShape: Shape, - drawerElevation: Dp, - drawerBackground: Brush, - scrimColor: Color, - scrimVisible: Boolean, - onDismiss: () -> Unit, - drawerContent: @Composable () -> Unit, - preventDismissalOnScrimClick: Boolean = false, - onScrimClick: () -> Unit = {} -) { - BoxWithConstraints(modifier.fillMaxSize()) { - val modalDrawerConstraints = constraints - - // TODO : think about Infinite max bounds case - if (!modalDrawerConstraints.hasBoundedWidth) { - throw IllegalStateException("Drawer shouldn't have infinite width") - } - - val fullWidth = modalDrawerConstraints.maxWidth.toFloat() - var drawerWidth by remember(fullWidth) { mutableStateOf(fullWidth) } - //Hack to get exact drawerHeight wrt to content. - val visible = remember { mutableStateOf(true) } - if (visible.value) { - Box( - modifier = Modifier - .layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - layout(placeable.width, placeable.height) { - drawerWidth = placeable.width.toFloat() - visible.value = false - } - } - ) { - drawerContent() - } - } else { - val paddingPx = pxToDp(max(dpToPx(EndDrawerPadding), (fullWidth - drawerWidth))) - val leftSlide = behaviorType == BehaviorType.LEFT_SLIDE_OVER - - val minValue = - modalDrawerConstraints.maxWidth.toFloat() * (if (leftSlide) (-1F) else (1F)) - val maxValue = 0f - - val anchors = mapOf(minValue to DrawerValue.Closed, maxValue to DrawerValue.Open) - val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl - Scrim( - open = !drawerState.isClosed, - onClose = onDismiss, - fraction = { - calculateFraction(minValue, maxValue, drawerState.offset.value) - }, - color = if (scrimVisible) scrimColor else Color.Transparent, - preventDismissalOnScrimClick = preventDismissalOnScrimClick, - onScrimClick = onScrimClick - ) - - Box( - modifier = with(LocalDensity.current) { - Modifier - .sizeIn( - minWidth = modalDrawerConstraints.minWidth.toDp(), - minHeight = modalDrawerConstraints.minHeight.toDp(), - maxWidth = modalDrawerConstraints.maxWidth.toDp(), - maxHeight = modalDrawerConstraints.maxHeight.toDp() - ) - } - .offset { IntOffset(drawerState.offset.value.roundToInt(), 0) } - .padding( - start = if (leftSlide) 0.dp else paddingPx, - end = if (leftSlide) paddingPx else 0.dp - ) - .semantics { - if (!drawerState.isClosed) { - dismiss { - onDismiss() - true - } - } - } - .shadow(drawerElevation) - .clip(drawerShape) - .background(drawerBackground) - .swipeable( - state = drawerState, - anchors = anchors, - thresholds = { _, _ -> FixedThreshold(pxToDp(value = drawerWidth / 2)) }, - orientation = Orientation.Horizontal, - enabled = false, - reverseDirection = isRtl, - velocityThreshold = DrawerVelocityThreshold, - resistance = null - ), - ) { - Column( - Modifier - .draggable( - orientation = Orientation.Horizontal, - state = rememberDraggableState { delta -> - drawerState.performDrag(delta) - }, - onDragStopped = { velocity -> - launch { - drawerState.performFling( - velocity - ) - if (drawerState.isClosed) { - onDismiss() - } - } - }, - ) - .testTag(DRAWER_CONTENT_TAG), content = { drawerContent() }) - } - } - } -} - -@Composable -private fun TopDrawer( - modifier: Modifier, - drawerState: DrawerState, - drawerShape: Shape, - drawerElevation: Dp, - drawerBackground: Brush, - drawerHandleColor: Color, - scrimColor: Color, - scrimVisible: Boolean, - onDismiss: () -> Unit, - drawerContent: @Composable () -> Unit, - preventDismissalOnScrimClick: Boolean = false, - onScrimClick: () -> Unit = {} -) { - BoxWithConstraints(modifier.fillMaxSize()) { - val fullHeight = constraints.maxHeight.toFloat() - var drawerHeight by remember(fullHeight) { mutableStateOf(fullHeight) } - - Box( - modifier = Modifier - .alpha(0f) - .layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - layout(placeable.width, placeable.height) { - drawerHeight = - placeable.height.toFloat() + dpToPx(DrawerHandleHeightOffset) - } - } - ) { - drawerContent() - } - val maxOpenHeight = fullHeight * DrawerOpenFraction - val minHeight = 0f - val topCloseHeight = minHeight - val topOpenHeight = min(maxOpenHeight, drawerHeight) - - val minValue: Float = topCloseHeight - val maxValue: Float = topOpenHeight - - val anchors = mapOf( - topCloseHeight to DrawerValue.Closed, - topOpenHeight to DrawerValue.Open - ) - - val drawerConstraints = with(LocalDensity.current) { - Modifier - .sizeIn( - maxWidth = constraints.maxWidth.toDp(), - maxHeight = constraints.maxHeight.toDp() - ) - } - - Scrim( - open = !drawerState.isClosed, - onClose = onDismiss, - fraction = { - calculateFraction(minValue, maxValue, drawerState.offset.value) - }, - color = if (scrimVisible) scrimColor else Color.Transparent, - preventDismissalOnScrimClick = preventDismissalOnScrimClick, - onScrimClick = onScrimClick - ) - - Box( - drawerConstraints - .offset { IntOffset(0, 0) } - .semantics { - if (!drawerState.isClosed) { - dismiss { - onDismiss() - true - } - } - } - .height( - pxToDp(drawerState.offset.value) - ) - .shadow(drawerElevation) - .clip(drawerShape) - .background(drawerBackground) - .swipeable( - state = drawerState, - anchors = anchors, - orientation = Orientation.Vertical, - enabled = false, - resistance = null - ) - .focusable(false), - ) { - ConstraintLayout(modifier = Modifier.padding(bottom = 8.dp)) { - val (drawerContentConstrain, drawerHandleConstrain) = createRefs() - Column(modifier = Modifier - .offset { IntOffset(0, 0) } - .padding(bottom = 8.dp) - .constrainAs(drawerContentConstrain) { - top.linkTo(parent.top) - bottom.linkTo(drawerHandleConstrain.top) - } - .focusTarget() - .testTag(DRAWER_CONTENT_TAG), content = { drawerContent() } - ) - Column(horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .constrainAs(drawerHandleConstrain) { - top.linkTo(drawerContentConstrain.bottom) - bottom.linkTo(parent.bottom) - } - .fillMaxWidth() - .draggable( - orientation = Orientation.Vertical, - state = rememberDraggableState { delta -> - drawerState.performDrag(delta) - }, - onDragStopped = { velocity -> - launch { - drawerState.performFling( - velocity - ) - if (drawerState.isClosed) { - onDismiss() - } - } - }, - ) - .testTag(DRAWER_HANDLE_TAG) - ) { - Icon( - painterResource(id = R.drawable.ic_drawer_handle), - contentDescription = null, - tint = drawerHandleColor - ) - } +internal fun AnnounceDrawerActions(drawerState: DrawerState, talkbackAnnouncement: DrawerAccessibilityAnnouncement){ // Announces actions for drawer through Talkback + val view = LocalView.current + var previousState by remember { mutableStateOf(drawerState.enable) } + + LaunchedEffect(drawerState.enable) { + if (drawerState.enable != previousState) { + if (drawerState.enable) { + view.announceForAccessibility(talkbackAnnouncement.opened) + } else { + view.announceForAccessibility(talkbackAnnouncement.closed) } + previousState = drawerState.enable } } -} - -@Composable -private fun BottomDrawer( - modifier: Modifier, - drawerState: DrawerState, - drawerShape: Shape, - drawerElevation: Dp, - drawerBackground: Brush, - drawerHandleColor: Color, - scrimColor: Color, - scrimVisible: Boolean, - slideOver: Boolean, - enableSwipeDismiss: Boolean = true, - showHandle: Boolean, - onDismiss: () -> Unit, - drawerContent: @Composable () -> Unit, - maxLandscapeWidthFraction : Float = 1F, - preventDismissalOnScrimClick: Boolean = false, - onScrimClick: () -> Unit = {} -) { - BoxWithConstraints(modifier.fillMaxSize()) { - val fullHeight = constraints.maxHeight.toFloat() - val drawerHeight = - remember(drawerContent.hashCode()) { mutableStateOf(null) } - val maxOpenHeight = fullHeight * DrawerOpenFraction - val drawerConstraints = with(LocalDensity.current) { - Modifier - .sizeIn( - maxWidth = constraints.maxWidth.toDp(), - maxHeight = constraints.maxHeight.toDp() - ) - } - val scope = rememberCoroutineScope() - - Scrim( - open = !drawerState.isClosed || (drawerHeight != null && drawerHeight.value == 0f), - onClose = onDismiss, - fraction = { - if (drawerState.anchors.isEmpty() || (drawerHeight != null && drawerHeight.value == 0f)) { - 0.toFloat() - } else { - var targetValue: DrawerValue = if (slideOver) { - drawerState.anchors.maxBy { it.value }.value - } else if (drawerState.skipOpenState) { - DrawerValue.Expanded - } else { - DrawerValue.Open - } - calculateFraction( - drawerState.anchors.entries.firstOrNull { it.value == DrawerValue.Closed }?.key!!, - drawerState.anchors.entries.firstOrNull { it.value == targetValue }?.key!!, - drawerState.offset.value - ) - } - }, - color = if (scrimVisible) scrimColor else Color.Transparent, - preventDismissalOnScrimClick = preventDismissalOnScrimClick, - onScrimClick = onScrimClick - ) - val configuration = LocalConfiguration.current - Box( - drawerConstraints - .fillMaxWidth( - if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) maxLandscapeWidthFraction - else 1F - ) - .nestedScroll( - if (!enableSwipeDismiss && drawerState.offset.value >= maxOpenHeight) drawerState.NonDismissiblePreUpPostDownNestedScrollConnection else - if (slideOver) drawerState.nestedScrollConnection else drawerState.PostDownNestedScrollConnection - ) - .offset { - val y = if (drawerState.anchors == null) { - fullHeight.roundToInt() - } else { - drawerState.offset.value.roundToInt() - } - IntOffset(x = 0, y = y) - } - .then( - if (maxLandscapeWidthFraction != 1F - && configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - ) Modifier.align(Alignment.TopCenter) - else Modifier - ) - .onGloballyPositioned { layoutCoordinates -> - if (!drawerState.animationInProgress - && drawerState.currentValue == DrawerValue.Closed - && drawerState.targetValue == DrawerValue.Closed - ) { - onDismiss() - } - - if (slideOver) { - val originalSize = layoutCoordinates.size.height.toFloat() - drawerHeight.value = if (drawerState.expandable) { - originalSize - } else { - min( - originalSize, - maxOpenHeight - ) - } - } - } - .bottomDrawerSwipeable( - drawerState, - slideOver, - maxOpenHeight, - fullHeight, - drawerHeight.value - ) - .drawerHeight( - slideOver, - maxOpenHeight, - fullHeight, - drawerState - ) - .shadow(drawerElevation) - .clip(drawerShape) - .background(drawerBackground) - .semantics { - if (!drawerState.isClosed) { - dismiss { - onDismiss() - true - } - if (drawerState.currentValue == DrawerValue.Open && drawerState.hasExpandedState) { - expand { - if (drawerState.confirmStateChange(DrawerValue.Expanded)) { - scope.launch { drawerState.expand() } - } - true - } - } else if (drawerState.hasExpandedState && drawerState.hasOpenedState) { - collapse { - if (drawerState.confirmStateChange(DrawerValue.Open)) { - scope.launch { drawerState.open() } - } - true - } - } - } - } - .focusable(false), - ) { - Column { - if (showHandle) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .padding(vertical = 8.dp) - .fillMaxWidth() - .draggable( - orientation = Orientation.Vertical, - state = rememberDraggableState { delta -> - if (!enableSwipeDismiss && drawerState.offset.value >= maxOpenHeight) { - if (delta < 0) { - drawerState.performDrag(delta) - } - } else { - drawerState.performDrag(delta) - } - }, - onDragStopped = { velocity -> - launch { - drawerState.performFling( - velocity - ) - if (drawerState.isClosed) { - if (enableSwipeDismiss) - onDismiss() - else - scope.launch { drawerState.open() } - } - } - }, - ) - .testTag(DRAWER_HANDLE_TAG) - ) { - val collapsed = LocalContext.current.resources.getString(R.string.collapsed) - val expanded = LocalContext.current.resources.getString(R.string.expanded) - val accessibilityManager = LocalContext.current.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager - Icon( - painterResource(id = R.drawable.ic_drawer_handle), - contentDescription = LocalContext.current.resources.getString(R.string.drag_handle), - tint = drawerHandleColor, - modifier = Modifier - .clickable( - enabled = drawerState.hasExpandedState, - role = Role.Button, - onClickLabel = - if (drawerState.currentValue == DrawerValue.Expanded) { - LocalContext.current.resources.getString(R.string.collapse) - } else { - if (drawerState.hasExpandedState && !drawerState.isClosed) LocalContext.current.resources.getString( - R.string.expand - ) else null - } - ) { - if (drawerState.currentValue == DrawerValue.Expanded) { - if (drawerState.hasOpenedState && drawerState.confirmStateChange( - DrawerValue.Open - ) - ) { - scope.launch { drawerState.open() } - accessibilityManager?.let { manager -> - if(manager.isEnabled){ - val event = AccessibilityEvent.obtain( - AccessibilityEvent.TYPE_ANNOUNCEMENT).apply { - text.add(collapsed) - } - manager.sendAccessibilityEvent(event) - } - } - } - } else if (drawerState.hasExpandedState) { - if (drawerState.confirmStateChange(DrawerValue.Expanded)) { - scope.launch { drawerState.expand() } - accessibilityManager?.let { manager -> - if(manager.isEnabled){ - val event = AccessibilityEvent.obtain( - AccessibilityEvent.TYPE_ANNOUNCEMENT).apply { - text.add(expanded) - } - manager.sendAccessibilityEvent(event) - } - } - } - } - } - ) - } - } - Column(modifier = Modifier - .testTag(DRAWER_CONTENT_TAG), content = { drawerContent() }) - } - } - } } - /** * * Drawer block interaction with the rest of an app’s content with a scrim. @@ -1077,17 +368,20 @@ fun Drawer( drawerState: DrawerState = rememberDrawerState(), scrimVisible: Boolean = true, offset: IntOffset? = null, + talkbackAnnouncement: DrawerAccessibilityAnnouncement = DrawerAccessibilityAnnouncement(), drawerTokens: DrawerTokens? = null, drawerContent: @Composable () -> Unit, preventDismissalOnScrimClick: Boolean = false, onScrimClick: () -> Unit = {} ) { + val tokens = drawerTokens + ?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.DrawerControlType] as DrawerTokens + val drawerInfo = DrawerInfo(type = behaviorType) + AnnounceDrawerActions(drawerState, talkbackAnnouncement = talkbackAnnouncement) + if (drawerState.enable) { val themeID = FluentTheme.themeID //Adding This only for recomposition in case of Token Updates. Unused otherwise. - val tokens = drawerTokens - ?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.DrawerControlType] as DrawerTokens - val popupPositionProvider = DrawerPositionProvider(offset) val scope = rememberCoroutineScope() val close: () -> Unit = { @@ -1095,7 +389,6 @@ fun Drawer( scope.launch { drawerState.close() } } } - val drawerInfo = DrawerInfo(type = behaviorType) Popup( onDismissRequest = close, popupPositionProvider = popupPositionProvider, @@ -1207,29 +500,34 @@ fun BottomDrawer( showHandle: Boolean = true, enableSwipeDismiss: Boolean = true, windowInsetsType: Int = WindowInsetsCompat.Type.systemBars(), + talkbackAnnouncement: DrawerAccessibilityAnnouncement = DrawerAccessibilityAnnouncement(), drawerTokens: DrawerTokens? = null, drawerContent: @Composable () -> Unit, maxLandscapeWidthFraction: Float = 1F, preventDismissalOnScrimClick: Boolean = false, onScrimClick: () -> Unit = {}, ) { + val behaviorType = + if (slideOver) BehaviorType.BOTTOM_SLIDE_OVER else BehaviorType.BOTTOM + val drawerInfo = DrawerInfo(type = behaviorType) + val tokens = drawerTokens + ?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.DrawerControlType] as DrawerTokens + AnnounceDrawerActions(drawerState, talkbackAnnouncement = talkbackAnnouncement) if (drawerState.enable) { val themeID = FluentTheme.themeID //Adding This only for recomposition in case of Token Updates. Unused otherwise. - val tokens = drawerTokens - ?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.DrawerControlType] as DrawerTokens val scope = rememberCoroutineScope() val close: () -> Unit = { if (drawerState.confirmStateChange(DrawerValue.Closed)) { scope.launch { drawerState.close() } } } - val behaviorType = - if (slideOver) BehaviorType.BOTTOM_SLIDE_OVER else BehaviorType.BOTTOM - val drawerInfo = DrawerInfo(type = behaviorType) + BackHandler { //TODO: Add pull down animation with predictive back + close() + } ModalPopup( + windowInsetsType = windowInsetsType, onDismissRequest = close, - windowInsetsType = windowInsetsType ) { val drawerShape: Shape = diff --git a/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/drawer/DrawerUtils.kt b/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/drawer/DrawerUtils.kt new file mode 100644 index 000000000..c62d905fd --- /dev/null +++ b/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/drawer/DrawerUtils.kt @@ -0,0 +1,39 @@ +package com.microsoft.fluentui.tokenized.drawer + +import androidx.compose.animation.core.TweenSpec +import androidx.compose.ui.unit.dp + +val EndDrawerPadding = 56.dp +val DrawerVelocityThreshold = 400.dp + +val AnimationSpec = TweenSpec(durationMillis = 256) + +const val DrawerOpenFraction = 0.5f + +//Tag use for testing +const val DRAWER_HANDLE_TAG = "Fluent Drawer Handle" +const val DRAWER_CONTENT_TAG = "Fluent Drawer Content" +const val DRAWER_SCRIM_TAG = "Fluent Drawer Scrim" + +//Drawer Handle height + padding +val DrawerHandleHeightOffset = 20.dp + +/** + * Possible values of [DrawerState]. + */ +enum class DrawerValue { + /** + * The state of the drawer when it is closed. + */ + Closed, + + /** + * The state of the drawer when it is open. + */ + Open, + + /** + * The state of the bottom drawer when it is expanded (i.e. at 100% height). + */ + Expanded +} diff --git a/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/drawer/HorizontalDrawer.kt b/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/drawer/HorizontalDrawer.kt new file mode 100644 index 000000000..c742fd729 --- /dev/null +++ b/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/drawer/HorizontalDrawer.kt @@ -0,0 +1,186 @@ +package com.microsoft.fluentui.tokenized.drawer + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.dismiss +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.microsoft.fluentui.compose.FixedThreshold +import com.microsoft.fluentui.compose.swipeable +import com.microsoft.fluentui.theme.token.controlTokens.BehaviorType +import com.microsoft.fluentui.tokenized.calculateFraction +import com.microsoft.fluentui.util.dpToPx +import com.microsoft.fluentui.util.pxToDp +import kotlinx.coroutines.launch +import kotlin.math.max +import kotlin.math.roundToInt + +/** + * + * + * Side drawers block interaction with the rest of an app’s content with a scrim. + * They are elevated above most of the app’s UI and don’t affect the screen’s layout grid. + * + * @param drawerContent composable that represents content inside the drawer + * @param modifier optional modifier for the drawer + * @param drawerState state of the drawer + * @param drawerShape shape of the drawer sheet + * @param drawerElevation drawer sheet elevation. This controls the size of the shadow below the + * drawer sheet + * @param drawerBackground background color to be used for the drawer sheet + * @param scrimColor color of the scrim that obscures content when the drawer is open + * @param preventDismissalOnScrimClick when true, the drawer will not be dismissed when the scrim is clicked + * @param onScrimClick callback to be invoked when the scrim is clicked + * + * @throws IllegalStateException when parent has [Float.POSITIVE_INFINITY] width + */ + + +@Composable +internal fun HorizontalDrawer( + modifier: Modifier, + behaviorType: BehaviorType, + drawerState: DrawerState, + drawerShape: Shape, + drawerElevation: Dp, + drawerBackground: Brush, + scrimColor: Color, + scrimVisible: Boolean, + onDismiss: () -> Unit, + drawerContent: @Composable () -> Unit, + preventDismissalOnScrimClick: Boolean = false, + onScrimClick: () -> Unit = {} +) { + BoxWithConstraints(modifier.fillMaxSize()) { + val modalDrawerConstraints = constraints + + // TODO : think about Infinite max bounds case + if (!modalDrawerConstraints.hasBoundedWidth) { + throw IllegalStateException("Drawer shouldn't have infinite width") + } + + val fullWidth = modalDrawerConstraints.maxWidth.toFloat() + var drawerWidth by remember(fullWidth) { mutableStateOf(fullWidth) } + //Hack to get exact drawerHeight wrt to content. + val visible = remember { mutableStateOf(true) } + if (visible.value) { + Box( + modifier = Modifier + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + drawerWidth = placeable.width.toFloat() + visible.value = false + } + } + ) { + drawerContent() + } + } else { + val paddingPx = pxToDp(max(dpToPx(EndDrawerPadding), (fullWidth - drawerWidth))) + val leftSlide = behaviorType == BehaviorType.LEFT_SLIDE_OVER + + val minValue = + modalDrawerConstraints.maxWidth.toFloat() * (if (leftSlide) (-1F) else (1F)) + val maxValue = 0f + + val anchors = mapOf(minValue to DrawerValue.Closed, maxValue to DrawerValue.Open) + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + Scrim( + open = !drawerState.isClosed, + onClose = onDismiss, + fraction = { + calculateFraction(minValue, maxValue, drawerState.offset.value) + }, + color = if (scrimVisible) scrimColor else Color.Transparent, + preventDismissalOnScrimClick = preventDismissalOnScrimClick, + onScrimClick = onScrimClick + ) + + Box( + modifier = with(LocalDensity.current) { + Modifier + .sizeIn( + minWidth = modalDrawerConstraints.minWidth.toDp(), + minHeight = modalDrawerConstraints.minHeight.toDp(), + maxWidth = modalDrawerConstraints.maxWidth.toDp(), + maxHeight = modalDrawerConstraints.maxHeight.toDp() + ) + } + .offset { IntOffset(drawerState.offset.value.roundToInt(), 0) } + .padding( + start = if (leftSlide) 0.dp else paddingPx, + end = if (leftSlide) paddingPx else 0.dp + ) + .semantics { + if (!drawerState.isClosed) { + dismiss { + onDismiss() + true + } + } + } + .shadow(drawerElevation) + .clip(drawerShape) + .background(drawerBackground) + .swipeable( + state = drawerState, + anchors = anchors, + thresholds = { _, _ -> FixedThreshold(pxToDp(value = drawerWidth / 2)) }, + orientation = Orientation.Horizontal, + enabled = false, + reverseDirection = isRtl, + velocityThreshold = DrawerVelocityThreshold, + resistance = null + ), + ) { + Column( + Modifier + .draggable( + orientation = Orientation.Horizontal, + state = rememberDraggableState { delta -> + drawerState.performDrag(delta) + }, + onDragStopped = { velocity -> + launch { + drawerState.performFling( + velocity + ) + if (drawerState.isClosed) { + onDismiss() + } + } + }, + ) + .testTag(DRAWER_CONTENT_TAG), content = { drawerContent() }) + } + } + } +} \ No newline at end of file diff --git a/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/drawer/TopDrawer.kt b/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/drawer/TopDrawer.kt new file mode 100644 index 000000000..e3154ecc2 --- /dev/null +++ b/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/drawer/TopDrawer.kt @@ -0,0 +1,187 @@ +package com.microsoft.fluentui.tokenized.drawer + +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.dismiss +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import com.microsoft.fluentui.compose.swipeable +import com.microsoft.fluentui.drawer.R +import com.microsoft.fluentui.theme.token.Icon +import com.microsoft.fluentui.tokenized.calculateFraction +import com.microsoft.fluentui.util.dpToPx +import com.microsoft.fluentui.util.pxToDp +import kotlinx.coroutines.launch +import kotlin.math.min + + +@Composable +internal fun TopDrawer( + modifier: Modifier, + drawerState: DrawerState, + drawerShape: Shape, + drawerElevation: Dp, + drawerBackground: Brush, + drawerHandleColor: Color, + scrimColor: Color, + scrimVisible: Boolean, + onDismiss: () -> Unit, + drawerContent: @Composable () -> Unit, + preventDismissalOnScrimClick: Boolean = false, + onScrimClick: () -> Unit = {} +) { + BoxWithConstraints(modifier.fillMaxSize()) { + val fullHeight = constraints.maxHeight.toFloat() + var drawerHeight by remember(fullHeight) { mutableStateOf(fullHeight) } + + Box( + modifier = Modifier + .alpha(0f) + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + drawerHeight = + placeable.height.toFloat() + dpToPx(DrawerHandleHeightOffset) + } + } + ) { + drawerContent() + } + val maxOpenHeight = fullHeight * DrawerOpenFraction + val minHeight = 0f + val topCloseHeight = minHeight + val topOpenHeight = min(maxOpenHeight, drawerHeight) + + val minValue: Float = topCloseHeight + val maxValue: Float = topOpenHeight + + val anchors = mapOf( + topCloseHeight to DrawerValue.Closed, + topOpenHeight to DrawerValue.Open + ) + + val drawerConstraints = with(LocalDensity.current) { + Modifier + .sizeIn( + maxWidth = constraints.maxWidth.toDp(), + maxHeight = constraints.maxHeight.toDp() + ) + } + + Scrim( + open = !drawerState.isClosed, + onClose = onDismiss, + fraction = { + calculateFraction(minValue, maxValue, drawerState.offset.value) + }, + color = if (scrimVisible) scrimColor else Color.Transparent, + preventDismissalOnScrimClick = preventDismissalOnScrimClick, + onScrimClick = onScrimClick + ) + + Box( + drawerConstraints + .offset { IntOffset(0, 0) } + .semantics { + if (!drawerState.isClosed) { + dismiss { + onDismiss() + true + } + } + } + .height( + pxToDp(drawerState.offset.value) + ) + .shadow(drawerElevation) + .clip(drawerShape) + .background(drawerBackground) + .swipeable( + state = drawerState, + anchors = anchors, + orientation = Orientation.Vertical, + enabled = false, + resistance = null + ) + .focusable(false), + ) { + ConstraintLayout(modifier = Modifier.padding(bottom = 8.dp)) { + val (drawerContentConstrain, drawerHandleConstrain) = createRefs() + Column(modifier = Modifier + .offset { IntOffset(0, 0) } + .padding(bottom = 8.dp) + .constrainAs(drawerContentConstrain) { + top.linkTo(parent.top) + bottom.linkTo(drawerHandleConstrain.top) + } + .focusTarget() + .testTag(DRAWER_CONTENT_TAG), content = { drawerContent() } + ) + Column(horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .constrainAs(drawerHandleConstrain) { + top.linkTo(drawerContentConstrain.bottom) + bottom.linkTo(parent.bottom) + } + .fillMaxWidth() + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + drawerState.performDrag(delta) + }, + onDragStopped = { velocity -> + launch { + drawerState.performFling( + velocity + ) + if (drawerState.isClosed) { + onDismiss() + } + } + }, + ) + .testTag(DRAWER_HANDLE_TAG) + ) { + Icon( + painterResource(id = R.drawable.ic_drawer_handle), + contentDescription = null, + tint = drawerHandleColor + ) + } + } + } + } +} diff --git a/fluentui_icons/src/main/java/com/microsoft/fluentui/icons/__ActionBarIcons.kt b/fluentui_icons/src/main/java/com/microsoft/fluentui/icons/__ActionBarIcons.kt new file mode 100644 index 000000000..9b50d0aad --- /dev/null +++ b/fluentui_icons/src/main/java/com/microsoft/fluentui/icons/__ActionBarIcons.kt @@ -0,0 +1,19 @@ +package com.microsoft.fluentui.icons + +import androidx.compose.ui.graphics.vector.ImageVector +import com.microsoft.fluentui.icons.actionbaricons.Arrowright +import com.microsoft.fluentui.icons.actionbaricons.Chevron +import kotlin.collections.List as ____KtList + +object ActionBarIcons + +private var __AllIcons: ____KtList? = null + +val ActionBarIcons.AllIcons: ____KtList + get() { + if (__AllIcons != null) { + return __AllIcons!! + } + __AllIcons= listOf(Arrowright, Chevron) + return __AllIcons!! + } diff --git a/fluentui_icons/src/main/java/com/microsoft/fluentui/icons/actionbaricons/Arrowright.kt b/fluentui_icons/src/main/java/com/microsoft/fluentui/icons/actionbaricons/Arrowright.kt new file mode 100644 index 000000000..28ecf87cf --- /dev/null +++ b/fluentui_icons/src/main/java/com/microsoft/fluentui/icons/actionbaricons/Arrowright.kt @@ -0,0 +1,48 @@ +package com.microsoft.fluentui.icons.actionbaricons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import com.microsoft.fluentui.icons.ActionBarIcons + +val ActionBarIcons.Arrowright: ImageVector + get() { + if (_arrowright != null) { + return _arrowright!! + } + _arrowright = Builder(name = "Arrowright", defaultWidth = 20.0.dp, defaultHeight = 20.0.dp, + viewportWidth = 20.0f, viewportHeight = 20.0f).apply { + path(fill = SolidColor(Color(0xFF212121)), stroke = null, strokeLineWidth = 0.0f, + strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, + pathFillType = NonZero) { + moveTo(10.8371f, 3.1307f) + curveTo(10.6332f, 2.9446f, 10.3169f, 2.959f, 10.1307f, 3.1629f) + curveTo(9.9446f, 3.3668f, 9.959f, 3.6831f, 10.1629f, 3.8693f) + lineTo(16.3307f, 9.5f) + horizontalLineTo(2.5f) + curveTo(2.2239f, 9.5f, 2.0f, 9.7239f, 2.0f, 10.0f) + curveTo(2.0f, 10.2761f, 2.2239f, 10.5f, 2.5f, 10.5f) + horizontalLineTo(16.3279f) + lineTo(10.1629f, 16.1281f) + curveTo(9.959f, 16.3143f, 9.9446f, 16.6305f, 10.1307f, 16.8345f) + curveTo(10.3169f, 17.0384f, 10.6332f, 17.0528f, 10.8371f, 16.8666f) + lineTo(17.7535f, 10.5526f) + curveTo(17.8934f, 10.4248f, 17.9732f, 10.2573f, 17.993f, 10.0841f) + curveTo(17.9976f, 10.0568f, 18.0f, 10.0287f, 18.0f, 10.0f) + curveTo(18.0f, 9.9731f, 17.9979f, 9.9467f, 17.9938f, 9.921f) + curveTo(17.9756f, 9.7451f, 17.8955f, 9.5745f, 17.7535f, 9.4448f) + lineTo(10.8371f, 3.1307f) + close() + } + } + .build() + return _arrowright!! + } + +private var _arrowright: ImageVector? = null diff --git a/fluentui_icons/src/main/java/com/microsoft/fluentui/icons/actionbaricons/Chevron.kt b/fluentui_icons/src/main/java/com/microsoft/fluentui/icons/actionbaricons/Chevron.kt new file mode 100644 index 000000000..f5e20e70e --- /dev/null +++ b/fluentui_icons/src/main/java/com/microsoft/fluentui/icons/actionbaricons/Chevron.kt @@ -0,0 +1,42 @@ +package com.microsoft.fluentui.icons.actionbaricons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import com.microsoft.fluentui.icons.ActionBarIcons +import com.microsoft.fluentui.icons.ListItemIcons + +val ActionBarIcons.Chevron: ImageVector + get() { + if (_chevron != null) { + return _chevron!! + } + _chevron = Builder(name = "Chevron", defaultWidth = 12.0.dp, defaultHeight = 12.0.dp, + viewportWidth = 12.0f, viewportHeight = 12.0f).apply { + path(fill = SolidColor(Color(0xFF808080)), stroke = null, strokeLineWidth = 0.0f, + strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, + pathFillType = NonZero) { + moveTo(4.6465f, 2.1465f) + curveTo(4.4512f, 2.3417f, 4.4512f, 2.6583f, 4.6465f, 2.8535f) + lineTo(7.7929f, 6.0f) + lineTo(4.6465f, 9.1465f) + curveTo(4.4512f, 9.3417f, 4.4512f, 9.6583f, 4.6465f, 9.8535f) + curveTo(4.8417f, 10.0488f, 5.1583f, 10.0488f, 5.3535f, 9.8535f) + lineTo(8.8535f, 6.3535f) + curveTo(9.0488f, 6.1583f, 9.0488f, 5.8417f, 8.8535f, 5.6465f) + lineTo(5.3535f, 2.1465f) + curveTo(5.1583f, 1.9512f, 4.8417f, 1.9512f, 4.6465f, 2.1465f) + close() + } + } + .build() + return _chevron!! + } + +private var _chevron: ImageVector? = null diff --git a/fluentui_listitem/build.gradle b/fluentui_listitem/build.gradle index c70f5c461..89b9693fd 100644 --- a/fluentui_listitem/build.gradle +++ b/fluentui_listitem/build.gradle @@ -37,6 +37,9 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + lintOptions { + abortOnError false + } } dependencies { diff --git a/fluentui_listitem/src/main/java/com/microsoft/fluentui/tokenized/contentBuilder/ListContentBuilder.kt b/fluentui_listitem/src/main/java/com/microsoft/fluentui/tokenized/contentBuilder/ListContentBuilder.kt index 173d4976e..6cff8d8ff 100644 --- a/fluentui_listitem/src/main/java/com/microsoft/fluentui/tokenized/contentBuilder/ListContentBuilder.kt +++ b/fluentui_listitem/src/main/java/com/microsoft/fluentui/tokenized/contentBuilder/ListContentBuilder.kt @@ -11,6 +11,8 @@ import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.Layout +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.microsoft.fluentui.theme.FluentTheme @@ -100,7 +102,7 @@ class ListContentBuilder { header: String? = null, maxItemInRow: Int = 4, equidistant: Boolean = false, - tabItemTokens: TabItemTokens? = null + tabItemTokens: TabItemTokens? = TabItemTokens() ): ListContentBuilder { add(VerticalGridContentData(itemDataList, header, maxItemInRow, equidistant, tabItemTokens)) return this @@ -157,7 +159,7 @@ class ListContentBuilder { header: String?, maxItemsInRow: Int, equidistant: Boolean, - tabItemTokens: TabItemTokens? = null + tabItemTokens: TabItemTokens? = TabItemTokens() ): LazyListScope.() -> Unit { return { if (header != null) { @@ -185,17 +187,20 @@ class ListContentBuilder { content = { var col = 0 val widthRatio = if ((row + 1) * itemsInRow <= size || !equidistant) - 1.0f / itemsInRow + 1.0f / maxItemsInRow else 1.0f / min(itemsInRow, (size - (row * itemsInRow))) while (col < itemsInRow && (row * itemsInRow + col) < size) { + val titleString = itemDataList[row * itemsInRow + col].title TabItem( - title = itemDataList[row * itemsInRow + col].title, + title = titleString, enabled = itemDataList[row * itemsInRow + col].enabled, onClick = itemDataList[row * itemsInRow + col].onClick, accessory = itemDataList[row * itemsInRow + col].accessory, icon = itemDataList[row * itemsInRow + col].icon, - modifier = Modifier.fillMaxWidth(widthRatio), + modifier = Modifier.fillMaxWidth(widthRatio).semantics { + contentDescription = titleString + }, tabItemTokens = tabItemTokens ) col++ @@ -233,7 +238,7 @@ class ListContentBuilder { itemDataList: List, header: String?, fixedWidth: Boolean = false, - tabItemTokens: TabItemTokens? = null + tabItemTokens: TabItemTokens? = TabItemTokens() ): LazyListScope.() -> Unit { return { if (header != null) { @@ -277,6 +282,9 @@ class ListContentBuilder { ) } } + } + .semantics { + contentDescription = item.title }, tabItemTokens = tabItemTokens ) @@ -289,7 +297,7 @@ class ListContentBuilder { private fun createVerticalList( itemDataList: List, header: String?, - listItemTokens: ListItemTokens? = null + listItemTokens: ListItemTokens? = ListItemTokens() ): LazyListScope.() -> Unit { return { if (header != null) { 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 effd37fa6..75b072608 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 @@ -3,8 +3,10 @@ package com.microsoft.fluentui.tokenized.listitem import androidx.compose.animation.* import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* @@ -59,18 +61,22 @@ object ListItem { } } - private fun Modifier.clickAndSemanticsModifier( + @OptIn(ExperimentalFoundationApi::class) + private fun Modifier.longPressSemanticsModifier( interactionSource: MutableInteractionSource, onClick: () -> Unit, + onLongClick: () -> Unit, enabled: Boolean, rippleColor: Color ): Modifier = composed { - Modifier.clickable( + Modifier.combinedClickable( interactionSource = interactionSource, indication = rememberRipple(color = rippleColor), onClickLabel = null, + onLongClickLabel = null, enabled = enabled, - onClick = onClick + onClick = onClick, + onLongClick = onLongClick ) } @@ -210,6 +216,7 @@ object ListItem { subTextMaxLines: Int = 1, secondarySubTextMaxLines: Int = 1, onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, primaryTextLeadingContent: (@Composable () -> Unit)? = null, primaryTextTrailingContent: (@Composable () -> Unit)? = null, secondarySubTextLeadingContent: (@Composable () -> Unit)? = null, @@ -287,6 +294,7 @@ object ListItem { Alignment.Bottom -> Alignment.BottomEnd else -> Alignment.CenterEnd } + val textOverflow = token.textOverflow(listItemInfo) Row( modifier .background(backgroundColor) @@ -295,9 +303,10 @@ object ListItem { .borderModifier(border, borderColor, borderSize, borderInsetToPx) .then( if (onClick != null) { - Modifier.clickAndSemanticsModifier( + Modifier.longPressSemanticsModifier( interactionSource, onClick = onClick, + onLongClick = onLongClick ?: {}, enabled, rippleColor ) @@ -356,7 +365,7 @@ object ListItem { text = text, style = primaryTextTypography.merge(TextStyle(color = primaryTextColor)), maxLines = textMaxLines, - overflow = TextOverflow.Ellipsis + overflow = textOverflow ) if (primaryTextTrailingContent != null) { primaryTextTrailingContent() @@ -368,7 +377,7 @@ object ListItem { text = subText, style = subTextTypography.merge(TextStyle(color = subTextColor)), maxLines = subTextMaxLines, - overflow = TextOverflow.Ellipsis + overflow = textOverflow ) } } @@ -391,7 +400,7 @@ object ListItem { text = secondarySubText, style = secondarySubTextTypography.merge(TextStyle(color = secondarySubTextColor)), maxLines = secondarySubTextMaxLines, - overflow = TextOverflow.Ellipsis + overflow = textOverflow ) } else if (secondarySubTextAnnotated != null) { BasicText( @@ -399,7 +408,7 @@ object ListItem { text = secondarySubTextAnnotated, inlineContent = secondarySubTextInlineContent, maxLines = secondarySubTextMaxLines, - overflow = TextOverflow.Ellipsis + overflow = textOverflow ) } if (secondarySubTextTrailingContent != null) { @@ -441,6 +450,7 @@ object ListItem { * @param subTextMaxLines Optional max visible lines for secondary text. * @param secondarySubTextMaxLines Optional max visible lines for tertiary text. * @param onClick Optional onClick action for list item. + * @param onLongClick Optional onLongClick action for list item. * @param primaryTextLeadingContent Optional primary text leading Content. * @param primaryTextTrailingContent Optional primary text trailing Content. * @param secondarySubTextLeadingContent Optional secondary text leading Content. @@ -469,6 +479,7 @@ object ListItem { subTextMaxLines: Int = 1, secondarySubTextMaxLines: Int = 1, onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, primaryTextLeadingContent: (@Composable () -> Unit)? = null, primaryTextTrailingContent: (@Composable () -> Unit)? = null, secondarySubTextLeadingContent: (@Composable () -> Unit)? = null, @@ -496,6 +507,7 @@ object ListItem { subTextMaxLines = subTextMaxLines, secondarySubTextMaxLines = secondarySubTextMaxLines, onClick = onClick, + onLongClick = onLongClick, primaryTextLeadingContent = primaryTextLeadingContent, primaryTextTrailingContent = primaryTextTrailingContent, secondarySubTextLeadingContent = secondarySubTextLeadingContent, @@ -528,6 +540,7 @@ object ListItem { * @param subTextMaxLines Optional max visible lines for secondary text. * @param secondarySubTextMaxLines Optional max visible lines for tertiary text. * @param onClick Optional onClick action for list item. + * @param onLongClick Optional onLongClick action for list item. * @param primaryTextLeadingContent Optional primary text leading Content. * @param primaryTextTrailingContent Optional primary text trailing Content. * @param secondarySubTextLeadingContent Optional secondary text leading Content. @@ -557,6 +570,7 @@ object ListItem { subTextMaxLines: Int = 1, secondarySubTextMaxLines: Int = 1, onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, primaryTextLeadingContent: (@Composable () -> Unit)? = null, primaryTextTrailingContent: (@Composable () -> Unit)? = null, secondarySubTextLeadingContent: (@Composable () -> Unit)? = null, @@ -585,6 +599,7 @@ object ListItem { subTextMaxLines = subTextMaxLines, secondarySubTextMaxLines = secondarySubTextMaxLines, onClick = onClick, + onLongClick = onLongClick, primaryTextLeadingContent = primaryTextLeadingContent, primaryTextTrailingContent = primaryTextTrailingContent, secondarySubTextLeadingContent = secondarySubTextLeadingContent, @@ -694,6 +709,7 @@ object ListItem { ) val expandedString = LocalContext.current.resources.getString(R.string.fluentui_expanded) val collapsedString = LocalContext.current.resources.getString(R.string.fluentui_collapsed) + val textOverflow = token.textOverflow(listItemInfo) Box( modifier = modifier .fillMaxWidth() @@ -701,11 +717,14 @@ object ListItem { .background(backgroundColor) .then( if (enableContentOpenCloseTransition && content != null) { - Modifier.clickAndSemanticsModifier( + Modifier.longPressSemanticsModifier( interactionSource, onClick = { expandedState = !expandedState }, + onLongClick = { + expandedState = !expandedState + }, enabled, rippleColor ) @@ -768,7 +787,7 @@ object ListItem { text = title, style = primaryTextTypography.merge(TextStyle(color = primaryTextColor)), maxLines = titleMaxLines, - overflow = TextOverflow.Ellipsis + overflow = textOverflow ) if(titleTrailingContent != null){ titleTrailingContent() @@ -784,7 +803,7 @@ object ListItem { Modifier.clickable( onClick = accessoryTextOnClick ?: {}) .clearAndSetSemantics { contentDescription = accessoryTextTitle - role = Role.Button }, + role = Role.Button }, style = actionTextTypography.merge(TextStyle(color = actionTextColor)) ) } @@ -827,6 +846,7 @@ object ListItem { * @param actionText Option boolean to append "Action" text button to the description text. * @param descriptionPlacement [TextPlacement] Enum value for placing the description text in the list item. * @param onClick Optional onClick action for list item. + * @param onLongClick Optional onLongClick action for list item. * @param onActionClick Optional onClick action for actionText. * @param border [BorderType] Optional border for the list item. * @param borderInset [BorderInset] Optional borderInset for list item. @@ -844,6 +864,7 @@ object ListItem { border: BorderType = NoBorder, borderInset: BorderInset = None, onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, onActionClick: (() -> Unit)? = null, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, leadingAccessoryContent: (@Composable () -> Unit)? = null, @@ -895,8 +916,8 @@ object ListItem { .heightIn(min = cellHeight) .background(backgroundColor) .borderModifier(border, borderColor, borderSize, borderInsetToPx) - .clickAndSemanticsModifier( - interactionSource, onClick = onClick ?: {}, enabled, rippleColor + .longPressSemanticsModifier( + interactionSource, onClick = onClick ?: {}, onLongClick = onLongClick ?: {} ,enabled, rippleColor ), verticalAlignment = descriptionAlignment ) { if (leadingAccessoryContent != null && descriptionPlacement == Top) { @@ -1018,7 +1039,7 @@ object ListItem { val borderColor = token.borderColor(listItemInfo).getColorByState( enabled = enabled, selected = false, interactionSource = interactionSource ) - + val textOverflow = token.textOverflow(listItemInfo) Box( modifier = modifier .fillMaxWidth() @@ -1045,7 +1066,7 @@ object ListItem { .weight(1f), style = primaryTextTypography.merge(TextStyle(color = primaryTextColor)), maxLines = titleMaxLines, - overflow = TextOverflow.Ellipsis + overflow = textOverflow ) if (accessoryTextTitle != null) { @@ -1060,7 +1081,8 @@ object ListItem { onClick = accessoryTextOnClick ?: {}) .clearAndSetSemantics { contentDescription = accessoryTextTitle role = Role.Button }, - style = actionTextTypography.merge(TextStyle(color = actionTextColor)) + style = actionTextTypography.merge(TextStyle(color = actionTextColor)), + overflow = textOverflow, ) } if (trailingAccessoryContent != null) { diff --git a/fluentui_listitem/src/main/java/com/microsoft/fluentui/tokenized/tabItem/TabItem.kt b/fluentui_listitem/src/main/java/com/microsoft/fluentui/tokenized/tabItem/TabItem.kt index 44f6f1b95..9ef610e6b 100644 --- a/fluentui_listitem/src/main/java/com/microsoft/fluentui/tokenized/tabItem/TabItem.kt +++ b/fluentui_listitem/src/main/java/com/microsoft/fluentui/tokenized/tabItem/TabItem.kt @@ -27,12 +27,19 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.layoutId import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.invisibleToUser +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -42,7 +49,6 @@ import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension import com.microsoft.fluentui.theme.FluentTheme import com.microsoft.fluentui.theme.token.ControlTokens -import com.microsoft.fluentui.theme.token.FluentAliasTokens import com.microsoft.fluentui.theme.token.FluentGlobalTokens import com.microsoft.fluentui.theme.token.FluentStyle import com.microsoft.fluentui.theme.token.Icon @@ -50,6 +56,7 @@ import com.microsoft.fluentui.theme.token.controlTokens.TabItemInfo import com.microsoft.fluentui.theme.token.controlTokens.TabItemTokens import com.microsoft.fluentui.theme.token.controlTokens.TabTextAlignment +@OptIn(ExperimentalComposeUiApi::class) @Composable fun TabItem( title: String, @@ -81,14 +88,16 @@ fun TabItem( ), animationSpec = tween(durationMillis = 300) ) - val iconColor by animateColorAsState ( - targetValue = token.iconColor(tabItemInfo = tabItemInfo).getColorByState( - enabled = enabled, - selected = selected, - interactionSource = interactionSource - ), - animationSpec = tween(durationMillis = 300) + val iconColorBrush: Brush = token.iconColor(tabItemInfo = tabItemInfo).getBrushByState( + enabled = enabled, + selected = selected, + interactionSource = interactionSource ) + + val indicatorColor: Brush = token.indicatorColor(tabItemInfo = tabItemInfo).getBrushByState( + enabled = enabled, selected = selected, interactionSource = interactionSource + ) + val padding = token.padding(tabItemInfo = tabItemInfo) val backgroundColor = token.backgroundBrush(tabItemInfo = tabItemInfo).getBrushByState( enabled = enabled, selected = selected, interactionSource = interactionSource @@ -112,9 +121,19 @@ fun TabItem( val iconContent: @Composable () -> Unit = { Icon( imageVector = icon, - modifier = Modifier.size(if (textAlignment == TabTextAlignment.NO_TEXT) 28.dp else 24.dp), + modifier = Modifier + .semantics { + invisibleToUser() + } + .size(if (textAlignment == TabTextAlignment.NO_TEXT) 28.dp else 24.dp) + .graphicsLayer(alpha = 0.99f) + .drawWithCache { + onDrawWithContent { + drawContent() + drawRect(brush = iconColorBrush, blendMode = BlendMode.SrcAtop) + } + }, contentDescription = if (textAlignment == TabTextAlignment.NO_TEXT) title else null, - tint = iconColor ) } @@ -145,6 +164,9 @@ fun TabItem( BasicText( text = title, modifier = Modifier + .semantics { + invisibleToUser() + } .constrainAs(textConstrain) { start.linkTo(iconConstrain.end) end.linkTo(badgeConstrain.start) @@ -214,7 +236,7 @@ fun TabItem( totalWidth, totalHeight ) { - + anchorPlaceable.placeRelative(0, 0) val badgeX = anchorPlaceable.width + badgeHorizontalOffset val badgeY = badgeVerticalOffset.roundToPx() @@ -241,11 +263,11 @@ fun TabItem( ) { badgeWithIcon() - val fontStyle = FluentTheme.aliasTokens.typography[FluentAliasTokens.TypographyTokens.Caption2] - var fontSize = remember { mutableStateOf(fontStyle.fontSize) } + val textTypography = token.textTypography(tabItemInfo = tabItemInfo) + var fontSize = remember { mutableStateOf(textTypography.fontSize) } var textStyle by remember(textColor) { mutableStateOf( - fontStyle.merge(TextStyle(color = textColor, fontSize = fontSize.value)) + textTypography.merge(TextStyle(color = textColor, fontSize = fontSize.value)) ) } @@ -256,12 +278,8 @@ fun TabItem( style = textStyle, maxLines = 1, overflow = TextOverflow.Ellipsis, - onTextLayout = { textLayoutResult -> - if (textLayoutResult.didOverflowHeight) { - textStyle.fontSize - fontSize.value *= 0.9 - textStyle = textStyle.copy(fontSize = fontSize.value) - } + modifier = Modifier.semantics { + invisibleToUser() } ) } @@ -277,7 +295,7 @@ fun TabItem( modifier = Modifier .height(3.dp) .width(indicatorWidth) - .background(shape = RoundedCornerShape(indicatorCornerRadiusSize), color = textColor) + .background(shape = RoundedCornerShape(indicatorCornerRadiusSize), brush = indicatorColor) .clip(RoundedCornerShape(indicatorCornerRadiusSize)) ) } diff --git a/fluentui_menus/build.gradle b/fluentui_menus/build.gradle index 1a71e473d..cf6f0b9d8 100644 --- a/fluentui_menus/build.gradle +++ b/fluentui_menus/build.gradle @@ -30,6 +30,9 @@ android { buildFeatures { compose true } + lintOptions { + abortOnError false + } } dependencies { diff --git a/fluentui_menus/src/main/java/com/microsoft/fluentui/popupmenu/PopupMenu.kt b/fluentui_menus/src/main/java/com/microsoft/fluentui/popupmenu/PopupMenu.kt index 57b03ed00..2e540edfa 100644 --- a/fluentui_menus/src/main/java/com/microsoft/fluentui/popupmenu/PopupMenu.kt +++ b/fluentui_menus/src/main/java/com/microsoft/fluentui/popupmenu/PopupMenu.kt @@ -13,8 +13,6 @@ import android.view.View import com.microsoft.fluentui.menus.R import com.microsoft.fluentui.popupmenu.PopupMenu.ItemCheckableBehavior import com.microsoft.fluentui.theming.FluentUIContextThemeWrapper -import com.microsoft.fluentui.util.DuoSupportUtils -import com.microsoft.fluentui.util.activity /** * [PopupMenu] is a transient UI that displays a list of options. The popup appears from a view that @@ -76,12 +74,6 @@ class PopupMenu : ListPopupWindow, PopupMenuItem.OnClickListener { isModal = true width = adapter.calculateWidth() - context.activity?.let { - if (DuoSupportUtils.isWindowDoublePortrait(it) && anchorView.x < DuoSupportUtils.getSingleScreenWidthPixels(it) && - anchorView.x + width > DuoSupportUtils.getSingleScreenWidthPixels(it)) { - width = DuoSupportUtils.getSingleScreenWidthPixels(it) - anchorView.x.toInt() - } - } } override fun onPopupMenuItemClicked(popupMenuItem: PopupMenuItem) { diff --git a/fluentui_menus/src/main/java/com/microsoft/fluentui/popupmenu/PopupMenuAdapter.kt b/fluentui_menus/src/main/java/com/microsoft/fluentui/popupmenu/PopupMenuAdapter.kt index 133b33658..1244180b1 100644 --- a/fluentui_menus/src/main/java/com/microsoft/fluentui/popupmenu/PopupMenuAdapter.kt +++ b/fluentui_menus/src/main/java/com/microsoft/fluentui/popupmenu/PopupMenuAdapter.kt @@ -11,10 +11,7 @@ import android.view.ViewGroup import android.widget.BaseAdapter import android.widget.ListView import com.microsoft.fluentui.menus.R -import com.microsoft.fluentui.util.DuoSupportUtils -import com.microsoft.fluentui.util.activity import kotlin.math.max -import kotlin.math.min internal class PopupMenuAdapter : BaseAdapter { private val context: Context @@ -68,12 +65,6 @@ internal class PopupMenuAdapter : BaseAdapter { itemView.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) maxWidth = max(maxWidth, itemView.measuredWidth) - context.activity?.let { - if (DuoSupportUtils.isWindowDoublePortrait(it)) { - val singleScreenDisplayPixels = DuoSupportUtils.getSingleScreenWidthPixels(it) - maxWidth = min(maxWidth, singleScreenDisplayPixels - DuoSupportUtils.DUO_HINGE_WIDTH) - } - } } return max(minWidth, maxWidth) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/Badge.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/Badge.kt index 9e90b5f79..c8e617bf5 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/Badge.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/Badge.kt @@ -7,6 +7,10 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -69,7 +73,15 @@ fun Badge( } } else { val textColor = token.textColor(badgeInfo = badgeInfo) - val typography = token.typography(badgeInfo = badgeInfo) + var typography = token.typography(badgeInfo = badgeInfo) + val fontSize = remember { mutableStateOf(typography.fontSize) } + var textStyle by remember(textColor) { + mutableStateOf( + typography.merge(TextStyle(color = textColor, fontSize = fontSize.value, platformStyle = PlatformTextStyle( + includeFontPadding = false + ))) + ) + } val paddingValues = token.padding(badgeInfo = badgeInfo) val shape = RoundedCornerShape(token.cornerRadius(badgeInfo = badgeInfo)) @@ -86,14 +98,14 @@ fun Badge( BasicText( text, modifier = Modifier.padding(paddingValues), - style = typography.merge( - TextStyle( - color = textColor, - platformStyle = PlatformTextStyle( - includeFontPadding = false - ) - ) - ) + style = textStyle, + onTextLayout = { textLayoutResult -> + if (textLayoutResult.didOverflowHeight) { + textStyle.fontSize + fontSize.value *= 0.9 + textStyle = textStyle.copy(fontSize = fontSize.value) + } + } ) } } diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/NotificationCommon.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/NotificationCommon.kt index 45575bb89..09ce30ad5 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/NotificationCommon.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/NotificationCommon.kt @@ -3,17 +3,78 @@ package com.microsoft.fluentui.tokenized.notification import androidx.compose.animation.core.* import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.ui.platform.AccessibilityManager import androidx.compose.ui.platform.LocalAccessibilityManager +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay interface NotificationMetadata { - fun clicked() + fun clicked(scope: CoroutineScope? = null) - fun dismiss() + fun dismiss(scope: CoroutineScope? = null ) - fun timedOut() + fun timedOut(scope: CoroutineScope? = null) +} + +open class AnimationVariables { + open var alpha = Animatable(0F) + open var scale = Animatable(0.8F) + open var offsetX = Animatable(0f) + open var offsetY = Animatable(0f) +} + +open class AnimationBehavior { + open var animationVariables: AnimationVariables = AnimationVariables() + + open suspend fun onShowAnimation() { + animationVariables.alpha.animateTo( + 1F, + animationSpec = tween( + easing = LinearEasing, + durationMillis = 150, + ) + ) + animationVariables.scale.animateTo( + 1F, + animationSpec = tween( + easing = FastOutSlowInEasing, + durationMillis = 150, + ) + ) + } + + open suspend fun onClickAnimation() { + // fade out + animationVariables.alpha.animateTo( + 0F, + animationSpec = tween( + easing = LinearEasing, + durationMillis = 150, + ) + ) + } + + open suspend fun onDismissAnimation() { + // fade out + animationVariables.alpha.animateTo( + 0F, + animationSpec = tween( + easing = LinearEasing, + durationMillis = 150, + ) + ) + } + + open suspend fun onTimeoutAnimation() { + // fade out + animationVariables.alpha.animateTo( + 0F, + animationSpec = tween( + easing = LinearEasing, + durationMillis = 150, + ) + ) + } } enum class NotificationResult { @@ -27,6 +88,7 @@ enum class NotificationDuration { LONG, INDEFINITE; + fun convertToMillis( hasIcon: Boolean, hasAction: Boolean, @@ -56,12 +118,9 @@ internal fun NotificationContainer( hasIcon: Boolean, hasAction: Boolean, duration: NotificationDuration, - content: @Composable ( - ( - Animatable, - Animatable - ) -> Unit - ) + animationBehavior: AnimationBehavior, + scope: CoroutineScope, + content: @Composable ((animationVariables: AnimationVariables) -> Unit) ) { val accessibilityManager = LocalAccessibilityManager.current LaunchedEffect(notificationMetadata) { @@ -72,31 +131,12 @@ internal fun NotificationContainer( accessibilityManager = accessibilityManager ) ) - notificationMetadata.timedOut() + notificationMetadata.timedOut(scope) } - val alpha = remember { Animatable(0F) } - val scale = remember { Animatable(0.8F) } - - LaunchedEffect(notificationMetadata) { - alpha.animateTo( - 1F, - animationSpec = tween( - easing = LinearEasing, - durationMillis = 150, - ) - ) - } LaunchedEffect(notificationMetadata) { - scale.animateTo( - 1F, - animationSpec = tween( - easing = FastOutSlowInEasing, - durationMillis = 150, - ) - ) + animationBehavior.onShowAnimation() } - content(alpha, scale) - + content(animationBehavior.animationVariables) } \ No newline at end of file diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/Snackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/Snackbar.kt index 2639dfcc7..1b5e531d6 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/Snackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/Snackbar.kt @@ -1,7 +1,9 @@ package com.microsoft.fluentui.tokenized.notification +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.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -13,7 +15,10 @@ import androidx.compose.runtime.* 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.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.Role @@ -27,8 +32,11 @@ import com.microsoft.fluentui.theme.token.Icon import com.microsoft.fluentui.theme.token.StateColor import com.microsoft.fluentui.theme.token.controlTokens.* import com.microsoft.fluentui.tokenized.controls.Button +import com.microsoft.fluentui.util.dpToPx import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -41,6 +49,21 @@ const val SNACK_BAR_SUBTITLE = "Fluent Snack bar Subtitle" const val SNACK_BAR_ACTION_BUTTON = "Fluent Snack bar Action Button" const val SNACK_BAR_DISMISS_BUTTON = "Fluent Snack bar Dismiss Button" +/** + * SnackbarMetadata is a data class that holds the metadata for a Snackbar. + * It contains information such as the message, style, icon, action text, and duration. + * + * @param message The message to be displayed in the Snackbar. + * @param style The style of the Snackbar. + * @param enableDismiss Whether the Snackbar can be dismissed by the user. + * @param icon The icon to be displayed in the Snackbar. + * @param subTitle The subtitle to be displayed in the Snackbar. + * @param actionText The text for the action button in the Snackbar. + * @param duration The duration for which the Snackbar will be displayed. + * @param continuation A cancellable continuation for handling user interactions with the Snackbar. + * @param animationBehavior The animation behavior for the Snackbar. + */ + class SnackbarMetadata( val message: String, val style: SnackbarStyle, @@ -49,28 +72,50 @@ class SnackbarMetadata( val subTitle: String?, val actionText: String?, val duration: NotificationDuration, - private val continuation: CancellableContinuation + private val continuation: CancellableContinuation, + val animationBehavior: AnimationBehavior ) : NotificationMetadata { - override fun clicked() { + override fun clicked(scope: CoroutineScope?) { try { - if (continuation.isActive) continuation.resume(NotificationResult.CLICKED) - } catch (e: Exception){ + if(scope == null) { + if (continuation.isActive) continuation.resume(NotificationResult.CLICKED) + return + } + scope.launch { + animationBehavior.onClickAnimation() + if (continuation.isActive) continuation.resume(NotificationResult.CLICKED) + } + } catch (e: Exception) { // This can happen if there is a race condition b/w two events. In that case, we ignore the second event. } } - override fun dismiss() { + override fun dismiss(scope: CoroutineScope?) { try { - if (continuation.isActive) continuation.resume(NotificationResult.DISMISSED) - } catch (e: Exception){ + if(scope == null) { + if (continuation.isActive) continuation.resume(NotificationResult.DISMISSED) + return + } + scope.launch { + animationBehavior.onDismissAnimation() + if (continuation.isActive) continuation.resume(NotificationResult.DISMISSED) + } + } catch (e: Exception) { // This can happen if there is a race condition b/w two events. In that case, we ignore the second event. } } - override fun timedOut() { + override fun timedOut(scope: CoroutineScope?) { try { - if (continuation.isActive) continuation.resume(NotificationResult.TIMEOUT) + if(scope == null) { + if (continuation.isActive) continuation.resume(NotificationResult.TIMEOUT) + return + } + scope.launch { + animationBehavior.onTimeoutAnimation() + if (continuation.isActive) continuation.resume(NotificationResult.TIMEOUT) + } } catch (e: Exception) { // This can happen if there is a race condition b/w two events. In that case, we ignore the second event. } @@ -89,7 +134,8 @@ class SnackbarState { icon: FluentIcon? = null, subTitle: String? = null, actionText: String? = null, - duration: NotificationDuration = NotificationDuration.SHORT + duration: NotificationDuration = NotificationDuration.SHORT, + animationBehavior: AnimationBehavior = AnimationBehavior() ): NotificationResult { mutex.withLock { try { @@ -102,7 +148,8 @@ class SnackbarState { subTitle, actionText, duration, - it + it, + animationBehavior ) } } finally { @@ -113,6 +160,40 @@ class SnackbarState { } } +@Composable +fun Modifier.swipeToDismiss( + animationVariables: AnimationVariables, + scope: CoroutineScope, + metadata: SnackbarMetadata +): Modifier { + val configuration = LocalConfiguration.current + val dismissThreshold = + dpToPx(configuration.screenWidthDp.dp) * 0.33f // One-third of screen width + return this.pointerInput(Unit) { + detectHorizontalDragGestures( + onDragEnd = { + if (animationVariables.offsetX.value < -dismissThreshold) { + scope.launch { + metadata.dismiss() + } + } else { + scope.launch { + animationVariables.offsetX.animateTo( + 0f, + animationSpec = tween(300) + ) + } + } + }, + onHorizontalDrag = { _, dragAmount -> + scope.launch { + animationVariables.offsetX.snapTo(animationVariables.offsetX.value + dragAmount) + } + } + ) + } +} + /** * Snackbar are transient Notification control used to deliver information which can be timedout or * can be cleared by user pressing the CTA or dismiss icon. Snackbar is rendered using [SnackbarMetadata] @@ -122,14 +203,17 @@ class SnackbarState { * @param snackbarState Queue to store all the Notification requests. * @param modifier Optional modifier to be applied to Snackbar. * @param snackbarTokens Optional Tokens to redesign Snackbar. + * @param enableSwipeToDismiss Optional flag to enable swipe to dismiss functionality. */ @Composable fun Snackbar( snackbarState: SnackbarState, modifier: Modifier = Modifier, - snackbarTokens: SnackBarTokens? = null + snackbarTokens: SnackBarTokens? = null, + enableSwipeToDismiss: Boolean = false ) { - val metadata: SnackbarMetadata = snackbarState.currentSnackbar ?: return + val metadata = snackbarState.currentSnackbar ?: return + val scope = rememberCoroutineScope() val themeID = FluentTheme.themeID //Adding This only for recomposition in case of Token Updates. Unused otherwise. @@ -137,20 +221,47 @@ fun Snackbar( ?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.SnackbarControlType] as SnackBarTokens val snackBarInfo = SnackBarInfo(metadata.style, !metadata.subTitle.isNullOrBlank()) - var textPaddingValues = if(metadata.actionText == null && !metadata.enableDismiss ) PaddingValues(start = 16.dp, top = 12.dp, bottom = 12.dp, end = 16.dp) else PaddingValues(start = 16.dp, top = 12.dp, bottom = 12.dp) - + var textPaddingValues = + if (metadata.actionText == null && !metadata.enableDismiss) PaddingValues( + start = 16.dp, + top = 12.dp, + bottom = 12.dp, + end = 16.dp + ) else PaddingValues(start = 16.dp, top = 12.dp, bottom = 12.dp) + val shadowElevationValue = token.shadowElevationValue(snackBarInfo) NotificationContainer( notificationMetadata = metadata, hasIcon = metadata.icon != null, hasAction = metadata.actionText != null, - duration = metadata.duration - ) { alpha, scale -> - Row( + duration = metadata.duration, + scope = scope, + animationBehavior = metadata.animationBehavior, + ) { animationVariables -> + val swipeToDismissModifier = if (enableSwipeToDismiss) { + modifier.swipeToDismiss( + animationVariables, + scope, + metadata + ) + } else { modifier - .graphicsLayer(scaleX = scale.value, scaleY = scale.value, alpha = alpha.value) + } + Row( + swipeToDismissModifier + .graphicsLayer( + scaleX = animationVariables.scale.value, + scaleY = animationVariables.scale.value, + alpha = animationVariables.alpha.value, + translationX = animationVariables.offsetX.value, + translationY = animationVariables.offsetY.value + ) .padding(horizontal = 16.dp) .defaultMinSize(minHeight = 52.dp) .fillMaxWidth() + .shadow( + elevation = shadowElevationValue, + shape = RoundedCornerShape(8.dp) + ) .clip(RoundedCornerShape(8.dp)) .background(token.backgroundBrush(snackBarInfo)) .semantics { @@ -205,7 +316,9 @@ fun Snackbar( if (metadata.actionText != null) { Button( - onClick = { metadata.clicked() }, + onClick = { + metadata.clicked(scope) + }, modifier = Modifier .testTag(SNACK_BAR_ACTION_BUTTON) .then( @@ -239,7 +352,9 @@ fun Snackbar( enabled = true, role = Role.Image, onClickLabel = "Dismiss", - onClick = { metadata.dismiss() } + onClick = { + metadata.dismiss(scope) + } ) .testTag(SNACK_BAR_DISMISS_BUTTON) ) { diff --git a/fluentui_others/build.gradle b/fluentui_others/build.gradle index f8607f7ce..cac516032 100644 --- a/fluentui_others/build.gradle +++ b/fluentui_others/build.gradle @@ -27,6 +27,12 @@ android { proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } + buildFeatures { + compose true + } + composeOptions { + kotlinCompilerExtensionVersion composeCompilerVersion + } testOptions { unitTests { includeAndroidResources = true @@ -47,15 +53,15 @@ gradle.taskGraph.whenReady { taskGraph -> dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') implementation project(':fluentui_core') + implementation project(':fluentui_controls') + implementation project(':fluentui_icons') implementation "androidx.appcompat:appcompat:$appCompatVersion" implementation "androidx.exifinterface:exifinterface:$exifInterfaceVersion" implementation "androidx.recyclerview:recyclerview:$recyclerViewVersion" implementation "androidx.cardview:cardview:1.0.0" implementation "com.google.android.material:material:$materialVersion" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation "com.jakewharton.threetenabp:threetenabp:$threetenabpVersion" - implementation "com.microsoft.device:dualscreen-layout:$duoVersion" - + implementation "androidx.compose.foundation:foundation" testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$extJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion" 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 new file mode 100644 index 000000000..7b4a08a84 --- /dev/null +++ b/fluentui_others/src/main/java/com/microsoft/fluentui/tokenized/acrylicpane/AcrylicPane.kt @@ -0,0 +1,76 @@ +package com.microsoft.fluentui.tokenized.acrylicpane + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.ui.unit.Dp +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.AcrylicPaneTokens + + +@Composable +private fun AcrylicPane( + modifier: Modifier = Modifier, + component: @Composable BoxScope.() -> Unit, + backgroundContent: @Composable () -> Unit, + triggerRecomposition: Boolean = false +) { + Box( + modifier = Modifier.fillMaxSize() + ) { + backgroundContent() + + Box( + modifier = modifier + ) { + component() + } + } +} + +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. + * + * @param modifier The modifier to be applied to the AcrylicPane. + * @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. + * @param backgroundContent The composable content to be displayed as the background of the pane. + * @param acrylicPaneTokens Optional tokens to customize the appearance of the AcrylicPane. + */ + +@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) + 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 + ) +} diff --git a/fluentui_others/src/main/java/com/microsoft/fluentui/tokenized/actionbar/ActionBar.kt b/fluentui_others/src/main/java/com/microsoft/fluentui/tokenized/actionbar/ActionBar.kt new file mode 100644 index 000000000..1d79b0b64 --- /dev/null +++ b/fluentui_others/src/main/java/com/microsoft/fluentui/tokenized/actionbar/ActionBar.kt @@ -0,0 +1,131 @@ +package com.microsoft.fluentui.tokenized.actionbar + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.microsoft.fluentui.icons.ActionBarIcons +import com.microsoft.fluentui.icons.actionbaricons.Arrowright +import com.microsoft.fluentui.icons.actionbaricons.Chevron +import com.microsoft.fluentui.theme.FluentTheme +import com.microsoft.fluentui.theme.token.ControlTokens +import com.microsoft.fluentui.theme.token.controlTokens.ACTIONBARTYPE +import com.microsoft.fluentui.theme.token.controlTokens.ActionBarInfo +import com.microsoft.fluentui.theme.token.controlTokens.ActionBarTokens +import com.microsoft.fluentui.theme.token.controlTokens.ButtonStyle +import com.microsoft.fluentui.tokenized.controls.Button +import kotlinx.coroutines.launch + +/** + * ActionBar is a composable that provides a way to navigate between pages. + * + * @param pagerState: PagerState + * @param modifier: Modifier + * @param type: Int + * @param startCallback: () -> Unit + * @param actionBarTokens: ActionBarTokens? + */ +@Composable +@OptIn(ExperimentalFoundationApi::class) +fun ActionBar( + pagerState: PagerState, + modifier: Modifier = Modifier, + type: Int = ACTIONBARTYPE.BASIC.ordinal, + startCallback: () -> Unit, + actionBarTokens: ActionBarTokens? = null +) { + val token = + actionBarTokens + ?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.ActionBarControlType] as ActionBarTokens + val noOfPages = pagerState.pageCount + val actionBarInfo = ActionBarInfo() + val height = token.actionBarHeight(actionBarInfo) + Box( + modifier = modifier.fillMaxWidth().height(height).background( + token.actionBarColor(actionBarInfo) + ) + ) { + val scope = rememberCoroutineScope() + var selectedPage by rememberSaveable { mutableStateOf(0) } + + // carousel indicator + if (type == ACTIONBARTYPE.CAROUSEL.ordinal) { + Row( + Modifier + .wrapContentHeight() + .fillMaxWidth() + .align(Alignment.Center), + horizontalArrangement = Arrangement.Center + ) { + repeat(pagerState.pageCount) { iteration -> + val color = + if (pagerState.currentPage == iteration) Color.DarkGray else Color.LightGray + Box( + modifier = Modifier + .padding(2.dp) + .clip(CircleShape) + .background(color) + .size(8.dp) + ) + } + } + } + + // left action + if (selectedPage < noOfPages - 1) { + Button( + style = ButtonStyle.TextButton, + onClick = { + scope.launch { + selectedPage = noOfPages - 1 + pagerState.animateScrollToPage(noOfPages - 1) + } + }, + modifier = Modifier.align(Alignment.CenterStart), + text = "Skip" + ) + } + + // right action + val rightActionText = + if (type == ACTIONBARTYPE.CAROUSEL.ordinal) "" else if (selectedPage == noOfPages - 1) "Start" else "Next" + val trailingIcon = + if (type == ACTIONBARTYPE.ICON.ordinal) { + ActionBarIcons.Chevron + } else if (type == ACTIONBARTYPE.CAROUSEL.ordinal) { + ActionBarIcons.Arrowright + } else { + null + } + + Button( + style = ButtonStyle.TextButton, + trailingIcon = trailingIcon, + onClick = { + if (selectedPage < noOfPages - 1) { + selectedPage += 1 + scope.launch { + pagerState.animateScrollToPage(selectedPage) + } + } else { + startCallback() + } + }, + modifier = Modifier.align(Alignment.CenterEnd), + text = rightActionText + ) + + } +} diff --git a/fluentui_peoplepicker/build.gradle b/fluentui_peoplepicker/build.gradle index a2f33355a..52dbd1117 100644 --- a/fluentui_peoplepicker/build.gradle +++ b/fluentui_peoplepicker/build.gradle @@ -73,8 +73,6 @@ dependencies { implementation "androidx.exifinterface:exifinterface:$exifInterfaceVersion" implementation "com.google.android.material:material:$materialVersion" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation "com.jakewharton.threetenabp:threetenabp:$threetenabpVersion" - implementation "com.splitwise:tokenautocomplete:$tokenautocompleteVersion" implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.runtime:runtime") diff --git a/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/peoplepicker/PeoplePickerTextView.kt b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/peoplepicker/PeoplePickerTextView.kt index 743b4f5d3..4fa71a867 100644 --- a/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/peoplepicker/PeoplePickerTextView.kt +++ b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/peoplepicker/PeoplePickerTextView.kt @@ -34,17 +34,15 @@ import android.view.accessibility.AccessibilityNodeInfo import android.view.inputmethod.InputMethodManager import android.widget.LinearLayout import android.widget.MultiAutoCompleteTextView +import androidx.core.content.ContextCompat import com.microsoft.fluentui.persona.IPersona import com.microsoft.fluentui.persona.PersonaChipView import com.microsoft.fluentui.persona.setPersona import com.microsoft.fluentui.util.ThemeUtil import com.microsoft.fluentui.util.getTextSize import com.microsoft.fluentui.util.inputMethodManager -import com.microsoft.fluentui.util.activity -import com.microsoft.fluentui.util.displaySize -import com.microsoft.fluentui.util.DuoSupportUtils -import com.tokenautocomplete.CountSpan -import com.tokenautocomplete.TokenCompleteTextView +import com.microsoft.fluentui.tokenautocomplete.CountSpan +import com.microsoft.fluentui.tokenautocomplete.TokenCompleteTextView import kotlin.math.max enum class PeoplePickerPersonaChipClickStyle(internal val tokenClickStyle: TokenCompleteTextView.TokenClickStyle) { @@ -73,7 +71,8 @@ enum class PeoplePickerPersonaChipClickStyle(internal val tokenClickStyle: Token * - Using backspace to delete a selected token does not work if other text is entered in the input; * [TokenCompleteTextView] overrides [onCreateInputConnection] which blocks our ability to control this functionality. */ -internal class PeoplePickerTextView : TokenCompleteTextView { +internal class PeoplePickerTextView : + TokenCompleteTextView { companion object { // Max number of personas the screen reader will announce on focus. private const val MAX_PERSONAS_TO_READ = 3 @@ -252,9 +251,14 @@ internal class PeoplePickerTextView : TokenCompleteTextView { // Soft keyboard does not always show up when the view first loads without this if (hasFocus) { + // add bottom border + this.background = ContextCompat.getDrawable(context, R.drawable.people_picker_textview_focusable_background) post { context.inputMethodManager.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) } + } else { + // remove bottom border + this.background = null } /** @@ -304,11 +308,6 @@ internal class PeoplePickerTextView : TokenCompleteTextView { return super.replaceText(text) - context.activity?.let { - if (DuoSupportUtils.isDualScreenMode(it) && lastSpan != null) { - checkForIntersectionWithHinge(lastSpan!!) - } - } } override fun canDeleteSelection(beforeLength: Int): Boolean { @@ -475,11 +474,6 @@ internal class PeoplePickerTextView : TokenCompleteTextView { val personaSpan = buildSpanForObject(persona) text.insert(offset, spannableStringBuilder) text.setSpan(personaSpan, offset, offset + spannableStringBuilder.length - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - context.activity?.let { - if (DuoSupportUtils.isDualScreenMode(it)) { - checkForIntersectionWithHinge(personaSpan) - } - } } private fun checkForIntersectionWithHinge(tokenImageSpan: TokenImageSpan) { @@ -495,15 +489,6 @@ internal class PeoplePickerTextView : TokenCompleteTextView { personaBound.right += parentTextViewLocation[0] personaBound.top += parentTextViewLocation[1] personaBound.bottom += parentTextViewLocation[1] - context.activity?.let { - if (DuoSupportUtils.intersectHinge(it, personaBound)) { - text.removeSpan(tokenImageSpan) - val spanWithEmptySpace = getViewForObjectWithSpace(tokenImageSpan.token, (context.displaySize.x + DuoSupportUtils.getHingeWidth((it))) / 2 - personaBound.left + resources.getDimension(R.dimen.fluentui_people_picker_horizontal_margin).toInt()) - val countSpanWidth = resources.getDimension(R.dimen.fluentui_people_picker_count_span_width).toInt() + DuoSupportUtils.getHingeWidth(it) - - text.setSpan(TokenImageSpan(spanWithEmptySpace, tokenImageSpan.token, maxTextWidth().toInt() - countSpanWidth), spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - } - } } // Persona spans don't always fit their new space so we rebuild the spans in available space. @@ -517,11 +502,6 @@ internal class PeoplePickerTextView : TokenCompleteTextView { val spanEnd = text.getSpanEnd(personaSpan) text.removeSpan(personaSpan) text.setSpan(rebuiltSpan, spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - context.activity?.let { - if (DuoSupportUtils.isDualScreenMode(it)) { - checkForIntersectionWithHinge(rebuiltSpan) - } - } } } diff --git a/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/peoplepicker/PeoplePickerTextViewAdapter.kt b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/peoplepicker/PeoplePickerTextViewAdapter.kt index 2cbb54cab..aae3ab510 100644 --- a/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/peoplepicker/PeoplePickerTextViewAdapter.kt +++ b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/peoplepicker/PeoplePickerTextViewAdapter.kt @@ -8,8 +8,6 @@ package com.microsoft.fluentui.peoplepicker import android.content.Context import android.graphics.drawable.InsetDrawable import androidx.core.content.ContextCompat -import android.view.Gravity.CENTER -import android.view.Gravity.START import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -17,8 +15,6 @@ import android.widget.* import com.microsoft.fluentui.listitem.ListItemView import com.microsoft.fluentui.peoplepicker.databinding.PeoplePickerSearchDirectoryBinding import com.microsoft.fluentui.persona.* -import com.microsoft.fluentui.util.DuoSupportUtils -import com.microsoft.fluentui.util.activity import java.util.* /** @@ -123,11 +119,6 @@ internal class PeoplePickerTextViewAdapter : ArrayAdapter, Filterable // Need to use the convertView, otherwise accessibility focus breaks. Also more efficient. val view = convertView ?: LayoutInflater.from(context).inflate(R.layout.people_picker_search_directory, parent, false) searchDirectoryBinding = PeoplePickerSearchDirectoryBinding.bind(view) - convertView?.context?.activity?.let { - if (DuoSupportUtils.isDualScreenMode(it)) { - searchDirectoryBinding?.peoplePickerSearchDirectoryText?.gravity = START or CENTER - } - } return view } diff --git a/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/peoplepicker/PeoplePickerView.kt b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/peoplepicker/PeoplePickerView.kt index 146d40249..0129dd2ee 100644 --- a/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/peoplepicker/PeoplePickerView.kt +++ b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/peoplepicker/PeoplePickerView.kt @@ -9,18 +9,16 @@ import android.content.Context import androidx.core.content.ContextCompat import android.util.AttributeSet import android.view.View.OnClickListener -import android.view.ViewGroup import android.view.accessibility.AccessibilityEvent import android.widget.Filter import android.widget.TextView -import com.microsoft.fluentui.peoplepicker.* import com.microsoft.fluentui.persona.IPersona import com.microsoft.fluentui.persona.Persona import com.microsoft.fluentui.theming.FluentUIContextThemeWrapper import com.microsoft.fluentui.util.ThemeUtil import com.microsoft.fluentui.util.isAccessibilityEnabled import com.microsoft.fluentui.view.TemplateView -import com.tokenautocomplete.TokenCompleteTextView +import com.microsoft.fluentui.tokenautocomplete.TokenCompleteTextView import kotlin.math.max /** diff --git a/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/CharacterTokenizer.java b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/CharacterTokenizer.java new file mode 100644 index 000000000..37680f37c --- /dev/null +++ b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/CharacterTokenizer.java @@ -0,0 +1,75 @@ +package com.microsoft.fluentui.tokenautocomplete; + +import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextUtils; +import android.widget.MultiAutoCompleteTextView; + +import java.util.ArrayList; + +/** + * Tokenizer with configurable array of characters to tokenize on. + * + * Created on 2/3/15. + * @author mgod + */ +public class CharacterTokenizer implements MultiAutoCompleteTextView.Tokenizer { + ArrayList splitChar; + + CharacterTokenizer(char[] splitChar){ + super(); + this.splitChar = new ArrayList<>(splitChar.length); + for(char c : splitChar) this.splitChar.add(c); + } + + public int findTokenStart(CharSequence text, int cursor) { + int i = cursor; + + while (i > 0 && !splitChar.contains(text.charAt(i - 1))) { + i--; + } + while (i < cursor && text.charAt(i) == ' ') { + i++; + } + + return i; + } + + public int findTokenEnd(CharSequence text, int cursor) { + int i = cursor; + int len = text.length(); + + while (i < len) { + if (splitChar.contains(text.charAt(i))) { + return i; + } else { + i++; + } + } + + return len; + } + + public CharSequence terminateToken(CharSequence text) { + int i = text.length(); + + while (i > 0 && text.charAt(i - 1) == ' ') { + i--; + } + + if (i > 0 && splitChar.contains(text.charAt(i - 1))) { + return text; + } else { + // Try not to use a space as a token character + String token = (splitChar.size()>1 && splitChar.get(0)==' ' ? splitChar.get(1) : splitChar.get(0))+" "; + if (text instanceof Spanned) { + SpannableString sp = new SpannableString(text + token); + TextUtils.copySpansFrom((Spanned) text, 0, text.length(), + Object.class, sp, 0); + return sp; + } else { + return text + token; + } + } + } +} diff --git a/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/CountSpan.java b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/CountSpan.java new file mode 100644 index 000000000..5d46eff94 --- /dev/null +++ b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/CountSpan.java @@ -0,0 +1,29 @@ +package com.microsoft.fluentui.tokenautocomplete; + +import android.content.Context; +import android.util.TypedValue; +import android.widget.TextView; + +/** + * Span that displays +[x] + * + * Created on 2/3/15. + * @author mgod + */ + +public class CountSpan extends ViewSpan { + public String text = ""; + + public CountSpan(int count, Context ctx, int textColor, int textSize, int maxWidth) { + super(new TextView(ctx), maxWidth); + TextView v = (TextView)view; + v.setTextColor(textColor); + v.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize); + setCount(count); + } + + public void setCount(int c) { + text = "+" + c; + ((TextView)view).setText(text); + } +} diff --git a/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/FilteredArrayAdapter.java b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/FilteredArrayAdapter.java new file mode 100644 index 000000000..c1109f8bc --- /dev/null +++ b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/FilteredArrayAdapter.java @@ -0,0 +1,164 @@ +package com.microsoft.fluentui.tokenautocomplete; + +import android.content.Context; +import android.widget.ArrayAdapter; +import android.widget.Filter; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +/** + * Simplified custom filtered ArrayAdapter + * override keepObject with your test for filtering + *

+ * Based on gist + * FilteredArrayAdapter by Tobias Schürg + *

+ * Created on 9/17/13. + * @author mgod + */ + +abstract public class FilteredArrayAdapter extends ArrayAdapter { + + private List originalObjects; + private Filter filter; + + /** + * Constructor + * + * @param context The current context. + * @param resource The resource ID for a layout file containing a TextView to use when + * instantiating views. + * @param objects The objects to represent in the ListView. + */ + public FilteredArrayAdapter(Context context, int resource, T[] objects) { + this(context, resource, 0, objects); + } + + /** + * Constructor + * + * @param context The current context. + * @param resource The resource ID for a layout file containing a layout to use when + * instantiating views. + * @param textViewResourceId The id of the TextView within the layout resource to be populated + * @param objects The objects to represent in the ListView. + */ + public FilteredArrayAdapter(Context context, int resource, int textViewResourceId, T[] objects) { + this(context, resource, textViewResourceId, new ArrayList(Arrays.asList(objects))); + } + + /** + * Constructor + * + * @param context The current context. + * @param resource The resource ID for a layout file containing a TextView to use when + * instantiating views. + * @param objects The objects to represent in the ListView. + */ + @SuppressWarnings("unused") + public FilteredArrayAdapter(Context context, int resource, List objects) { + this(context, resource, 0, objects); + } + + /** + * Constructor + * + * @param context The current context. + * @param resource The resource ID for a layout file containing a layout to use when + * instantiating views. + * @param textViewResourceId The id of the TextView within the layout resource to be populated + * @param objects The objects to represent in the ListView. + */ + public FilteredArrayAdapter(Context context, int resource, int textViewResourceId, List objects) { + super(context, resource, textViewResourceId, new ArrayList(objects)); + this.originalObjects = objects; + } + + @SuppressWarnings("unchecked") + @Override + public void notifyDataSetChanged() { + ((AppFilter)getFilter()).setSourceObjects(this.originalObjects); + super.notifyDataSetChanged(); + } + + @SuppressWarnings("unchecked") + @Override + public void notifyDataSetInvalidated(){ + ((AppFilter)getFilter()).setSourceObjects(this.originalObjects); + super.notifyDataSetInvalidated(); + } + + @Override + public Filter getFilter() { + if (filter == null) + filter = new AppFilter(originalObjects); + return filter; + } + + /** + * Filter method used by the adapter. Return true if the object should remain in the list + * + * @param obj object we are checking for inclusion in the adapter + * @param mask current text in the edit text we are completing against + * @return true if we should keep the item in the adapter + */ + abstract protected boolean keepObject(T obj, String mask); + + /** + * Class for filtering Adapter, relies on keepObject in FilteredArrayAdapter + * + * based on gist by Tobias Schürg + * in turn inspired by inspired by Alxandr + * (http://stackoverflow.com/a/2726348/570168) + */ + private class AppFilter extends Filter { + + private ArrayList sourceObjects; + + public AppFilter(List objects) { + setSourceObjects(objects); + } + + public void setSourceObjects(List objects) { + synchronized (this) { + sourceObjects = new ArrayList(objects); + } + } + + @Override + protected FilterResults performFiltering(CharSequence chars) { + FilterResults result = new FilterResults(); + if (chars != null && chars.length() > 0) { + String mask = chars.toString(); + ArrayList keptObjects = new ArrayList(); + + for (T object : sourceObjects) { + if (keepObject(object, mask)) + keptObjects.add(object); + } + result.count = keptObjects.size(); + result.values = keptObjects; + } else { + // add all objects + result.values = sourceObjects; + result.count = sourceObjects.size(); + } + return result; + } + + @SuppressWarnings("unchecked") + @Override + protected void publishResults(CharSequence constraint, FilterResults results) { + clear(); + if (results.count > 0) { + FilteredArrayAdapter.this.addAll((Collection)results.values); + notifyDataSetChanged(); + } else { + notifyDataSetInvalidated(); + } + } + } +} \ No newline at end of file diff --git a/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/HintSpan.java b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/HintSpan.java new file mode 100644 index 000000000..b17834efb --- /dev/null +++ b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/HintSpan.java @@ -0,0 +1,16 @@ +package com.microsoft.fluentui.tokenautocomplete; + +import android.content.res.ColorStateList; +import android.text.style.TextAppearanceSpan; + +/** + * Subclass of TextAppearanceSpan just to work with how Spans get detected + * + * Created on 2/3/15. + * @author mgod + */ +public class HintSpan extends TextAppearanceSpan { + public HintSpan(String family, int style, int size, ColorStateList color, ColorStateList linkColor) { + super(family, style, size, color, linkColor); + } +} diff --git a/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/TokenCompleteTextView.java b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/TokenCompleteTextView.java new file mode 100644 index 000000000..238804d45 --- /dev/null +++ b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/TokenCompleteTextView.java @@ -0,0 +1,1563 @@ +package com.microsoft.fluentui.tokenautocomplete; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.os.Build; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.Editable; +import android.text.InputFilter; +import android.text.InputType; +import android.text.Layout; +import android.text.NoCopySpan; +import android.text.Selection; +import android.text.SpanWatcher; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.text.method.QwertyKeyListener; +import android.util.AttributeSet; +import android.util.Log; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.accessibility.AccessibilityEvent; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.ExtractedText; +import android.view.inputmethod.ExtractedTextRequest; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputConnectionWrapper; +import android.view.inputmethod.InputMethodManager; +import android.widget.Filter; +import android.widget.ListView; +import android.widget.MultiAutoCompleteTextView; +import android.widget.TextView; +import androidx.annotation.NonNull; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Gmail style auto complete view with easy token customization + * override getViewForObject to provide your token view + *
+ * Created by mgod on 9/12/13. + * + * @author mgod + */ +public abstract class TokenCompleteTextView extends androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView implements TextView.OnEditorActionListener { + //Logging + public static final String TAG = "TokenAutoComplete"; + + //When the token is deleted... + public enum TokenDeleteStyle { + _Parent, //...do the parent behavior, not recommended + Clear, //...clear the underlying text + PartialCompletion, //...return the original text used for completion + ToString //...replace the token with toString of the token object + } + + //When the user clicks on a token... + public enum TokenClickStyle { + None(false), //...do nothing, but make sure the cursor is not in the token + Delete(false),//...delete the token + Select(true),//...select the token. A second click will delete it. + SelectDeselect(true); + + private boolean mIsSelectable = false; + + TokenClickStyle(final boolean selectable) { + mIsSelectable = selectable; + } + + public boolean isSelectable() { + return mIsSelectable; + } + } + + private char[] splitChar = {',', ';'}; + private Tokenizer tokenizer; + private T selectedObject; + private TokenListener listener; + private TokenSpanWatcher spanWatcher; + private TokenTextWatcher textWatcher; + private ArrayList objects; + private List.TokenImageSpan> hiddenSpans; + private TokenDeleteStyle deletionStyle = TokenDeleteStyle._Parent; + private TokenClickStyle tokenClickStyle = TokenClickStyle.None; + private CharSequence prefix = ""; + private boolean hintVisible = false; + private Layout lastLayout = null; + private boolean allowDuplicates = true; + private boolean focusChanging = false; + private boolean initialized = false; + private boolean performBestGuess = true; + private boolean savingState = false; + private boolean shouldFocusNext = false; + private boolean allowCollapse = true; + + private int tokenLimit = -1; + + /** + * Add the TextChangedListeners + */ + protected void addListeners() { + Editable text = getText(); + if (text != null) { + text.setSpan(spanWatcher, 0, text.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); + addTextChangedListener(textWatcher); + } + } + + /** + * Remove the TextChangedListeners + */ + protected void removeListeners() { + Editable text = getText(); + if (text != null) { + TokenSpanWatcher[] spanWatchers = text.getSpans(0, text.length(), TokenSpanWatcher.class); + for (TokenSpanWatcher watcher : spanWatchers) { + text.removeSpan(watcher); + } + removeTextChangedListener(textWatcher); + } + } + + /** + * Initialise the variables and various listeners + */ + private void init() { + if (initialized) return; + + // Initialise variables + setTokenizer(new MultiAutoCompleteTextView.CommaTokenizer()); + objects = new ArrayList<>(); + Editable text = getText(); + assert null != text; + spanWatcher = new TokenSpanWatcher(); + textWatcher = new TokenTextWatcher(); + hiddenSpans = new ArrayList<>(); + + // Initialise TextChangedListeners + addListeners(); + + setTextIsSelectable(false); + setLongClickable(false); + + //In theory, get the soft keyboard to not supply suggestions. very unreliable < API 11 + setInputType(getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS | InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE); + setHorizontallyScrolling(false); + + // Listen to IME action keys + setOnEditorActionListener(this); + + // Initialise the textfilter (listens for the splitchars) + setFilters(new InputFilter[]{new InputFilter() { + @Override + public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) { + // Token limit check + if (tokenLimit != -1 && objects.size() == tokenLimit) { + return ""; + } else if (source.length() == 1) {//Detect split characters, remove them and complete the current token instead + if (isSplitChar(source.charAt(0))) { + performCompletion(); + return ""; + } + } + + //We need to not do anything when we would delete the prefix + if (dstart < prefix.length()) { + //when settext is called, which should only be called during + //restoring, dstart and dend are 0. If not checked, it will clear out the prefix. + //this is why we need to return null in this if condition to preserve state. + if (dstart == 0 && dend == 0) { + return null; + } else if (dend <= prefix.length()) { + //Don't do anything + return prefix.subSequence(dstart, dend); + } else { + //Delete everything up to the prefix + return prefix.subSequence(dstart, prefix.length()); + } + } + return null; + } + }}); + + //We had _Parent style during initialization to handle an edge case in the parent + //now we can switch to Clear, usually the best choice + setDeletionStyle(TokenDeleteStyle.Clear); + initialized = true; + } + + public TokenCompleteTextView(Context context) { + super(context); + init(); + } + + public TokenCompleteTextView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public TokenCompleteTextView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(); + } + + @Override + protected void performFiltering(@NonNull CharSequence text, int start, int end, + int keyCode) { + if (start < prefix.length()) { + start = prefix.length(); + } + Filter filter = getFilter(); + if (filter != null) { + if (hintVisible) { + filter.filter(""); + } else { + filter.filter(text.subSequence(start, end), this); + } + } + } + + + @Override + public void setTokenizer(Tokenizer t) { + super.setTokenizer(t); + tokenizer = t; + } + + /** + * Set the action to be taken when a Token is removed + * + * @param dStyle The TokenDeleteStyle + */ + public void setDeletionStyle(TokenDeleteStyle dStyle) { + deletionStyle = dStyle; + } + + /** + * Set the action to be taken when a Token is clicked + * + * @param cStyle The TokenClickStyle + */ + @SuppressWarnings("unused") + public void setTokenClickStyle(TokenClickStyle cStyle) { + tokenClickStyle = cStyle; + } + + /** + * Set the listener that will be notified of changes in the Tokenlist + * + * @param l The TokenListener + */ + public void setTokenListener(TokenListener l) { + listener = l; + } + + /** + * Override if you want to prevent a token from being removed. Defaults to true. + * @param token the token to check + * @return false if the token should not be removed, true if it's ok to remove it. + */ + @SuppressWarnings("unused") + public boolean isTokenRemovable(T token) { + return true; + } + + /** + * A String of text that is shown before all the tokens inside the EditText + * (Think "To: " in an email address field. I would advise against this: use a label and a hint. + * + * @param p String with the hint + */ + public void setPrefix(CharSequence p) { + //Have to clear and set the actual text before saving the prefix to avoid the prefix filter + prefix = ""; + Editable text = getText(); + if (text != null) { + text.insert(0, p); + } + prefix = p; + + updateHint(); + } + + /** + * Get the list of Tokens + * + * @return List of tokens + */ + public List getObjects() { + return objects; + } + + /** + * Set a list of characters that should trigger the token creation + * Because spaces are difficult to handle, we add '§' as an additional splitChar + * + * @param splitChar char[] with a characters that trigger the token creation + */ + public void setSplitChar(char[] splitChar) { + char[] fixed = splitChar; + if (splitChar[0] == ' ') { + fixed = new char[splitChar.length + 1]; + fixed[0] = '§'; + System.arraycopy(splitChar, 0, fixed, 1, splitChar.length); + } + this.splitChar = fixed; + // Keep the tokenizer and splitchars in sync + this.setTokenizer(new CharacterTokenizer(splitChar)); + } + + /** + * Sets a single character to trigger the token creation + * + * @param splitChar char that triggers the token creation + */ + @SuppressWarnings("unused") + public void setSplitChar(char splitChar) { + setSplitChar(new char[]{splitChar}); + } + + /** + * Returns true if the character is currently configured as a splitChar + * + * @param c the char to test + * @return boolean + */ + private boolean isSplitChar(char c) { + for (char split : splitChar) { + if (c == split) return true; + } + return false; + } + + /** + * Sets whether to allow duplicate objects. If false, when the user selects + * an object that's already in the view, the current text is just cleared. + *
+ * Defaults to true. Requires that the objects implement equals() correctly. + * + * @param allow boolean + */ + @SuppressWarnings("unused") + public void allowDuplicates(boolean allow) { + allowDuplicates = allow; + } + + /** + * Set whether we try to guess an entry from the autocomplete spinner or allow any text to be + * entered + * + * @param guess true to enable guessing + */ + @SuppressWarnings("unused") + public void performBestGuess(boolean guess) { + performBestGuess = guess; + } + + /** + * Set whether the view should collapse to a single line when it loses focus. + * + * @param allowCollapse true if it should collapse + */ + @SuppressWarnings("unused") + public void allowCollapse(boolean allowCollapse) { + this.allowCollapse = allowCollapse; + } + + /** + * Set a number of tokens limit. + * + * @param tokenLimit The number of tokens permitted. -1 value disables limit. + */ + @SuppressWarnings("unused") + public void setTokenLimit(int tokenLimit) { + this.tokenLimit = tokenLimit; + } + + /** + * A token view for the object + * + * @param object the object selected by the user from the list + * @return a view to display a token in the text field for the object + */ + abstract protected View getViewForObject(T object); + + /** + * Provides a default completion when the user hits , and there is no item in the completion + * list + * + * @param completionText the current text we are completing against + * @return a best guess for what the user meant to complete + */ + abstract protected T defaultObject(String completionText); + + /** + * Correctly build accessibility string for token contents + * + * This seems to be a hidden API, but there doesn't seem to be another reasonable way + * @return custom string for accessibility + */ + @SuppressWarnings("unused") + public CharSequence getTextForAccessibility() { + if (getObjects().size() == 0) { + return getText(); + } + + SpannableStringBuilder description = new SpannableStringBuilder(); + Editable text = getText(); + int selectionStart = -1; + int selectionEnd = -1; + int i; + //Need to take the existing tet buffer and + // - replace all tokens with a decent string representation of the object + // - set the selection span to the corresponding location in the new CharSequence + for (i = 0; i < text.length(); ++i) { + //See if this is where we should start the selection + int origSelectionStart = Selection.getSelectionStart(text); + if (i == origSelectionStart) { + selectionStart = description.length(); + } + int origSelectionEnd = Selection.getSelectionEnd(text); + if (i == origSelectionEnd) { + selectionEnd = description.length(); + } + + //Replace token spans + TokenImageSpan[] tokens = text.getSpans(i, i, TokenImageSpan.class); + if (tokens.length > 0) { + TokenImageSpan token = tokens[0]; + description = description.append(tokenizer.terminateToken(token.getToken().toString())); + i = text.getSpanEnd(token); + continue; + } + + description = description.append(text.subSequence(i, i + 1)); + } + + int origSelectionStart = Selection.getSelectionStart(text); + if (i == origSelectionStart) { + selectionStart = description.length(); + } + int origSelectionEnd = Selection.getSelectionEnd(text); + if (i == origSelectionEnd) { + selectionEnd = description.length(); + } + + if (selectionStart >= 0 && selectionEnd >= 0) { + Selection.setSelection(description, selectionStart, selectionEnd); + } + + return description; + } + + @Override + public void onInitializeAccessibilityEvent(AccessibilityEvent event) { + super.onInitializeAccessibilityEvent(event); + + if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) { + CharSequence text = getTextForAccessibility(); + event.setFromIndex(Selection.getSelectionStart(text)); + event.setToIndex(Selection.getSelectionEnd(text)); + event.setItemCount(text.length()); + } + } + + private int getCorrectedTokenEnd() { + Editable editable = getText(); + int cursorPosition = getSelectionEnd(); + return tokenizer.findTokenEnd(editable, cursorPosition); + } + + private int getCorrectedTokenBeginning(int end) { + int start = tokenizer.findTokenStart(getText(), end); + if (start < prefix.length()) { + start = prefix.length(); + } + return start; + } + + protected String currentCompletionText() { + if (hintVisible) return ""; //Can't have any text if the hint is visible + + Editable editable = getText(); + int end = getCorrectedTokenEnd(); + int start = getCorrectedTokenBeginning(end); + + //Some keyboards add extra spaces when doing corrections, so + return TextUtils.substring(editable, start, end); + } + + protected float maxTextWidth() { + return getWidth() - getPaddingLeft() - getPaddingRight(); + } + + boolean inInvalidate = false; + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + private void api16Invalidate() { + if (initialized && !inInvalidate) { + inInvalidate = true; + setShadowLayer(getShadowRadius(), getShadowDx(), getShadowDy(), getShadowColor()); + inInvalidate = false; + } + } + + @Override + public void invalidate() { + //Need to force the TextView private mEditor variable to reset as well on API 16 and up + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + api16Invalidate(); + } + + super.invalidate(); + } + + @Override + public boolean enoughToFilter() { + if (tokenizer == null || hintVisible) { + return false; + } + + int cursorPosition = getSelectionEnd(); + + if (cursorPosition < 0) { + return false; + } + + int end = getCorrectedTokenEnd(); + int start = getCorrectedTokenBeginning(end); + + //Don't allow 0 length entries to filter + return end - start >= Math.max(getThreshold(), 1); + } + + @Override + public void performCompletion() { + if ((getAdapter() == null || getListSelection() == ListView.INVALID_POSITION) && enoughToFilter()) { + Object bestGuess; + if (getAdapter() != null && getAdapter().getCount() > 0 && performBestGuess) { + bestGuess = getAdapter().getItem(0); + } else { + bestGuess = defaultObject(currentCompletionText()); + } + replaceText(convertSelectionToString(bestGuess)); + } else { + super.performCompletion(); + } + } + + @Override + public InputConnection onCreateInputConnection(@NonNull EditorInfo outAttrs) { + InputConnection superConn = super.onCreateInputConnection(outAttrs); + if (superConn != null) { + TokenInputConnection conn = new TokenInputConnection(superConn, true); + outAttrs.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION; + outAttrs.imeOptions |= EditorInfo.IME_FLAG_NO_EXTRACT_UI; + return conn; + } else { + return null; + } + } + + /** + * Create a token and hide the keyboard when the user sends the DONE IME action + * Use IME_NEXT if you want to create a token and go to the next field + */ + private void handleDone() { + // Attempt to complete the current token token + performCompletion(); + + // Hide the keyboard + InputMethodManager imm = (InputMethodManager) getContext().getSystemService( + Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(getWindowToken(), 0); + } + + @Override + public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) { + boolean handled = super.onKeyUp(keyCode, event); + if (shouldFocusNext) { + shouldFocusNext = false; + handleDone(); + } + return handled; + } + + @Override + public boolean onKeyDown(int keyCode, @NonNull KeyEvent event) { + boolean handled = false; + switch (keyCode) { + case KeyEvent.KEYCODE_TAB: + case KeyEvent.KEYCODE_ENTER: + case KeyEvent.KEYCODE_DPAD_CENTER: + if (event.hasNoModifiers()) { + shouldFocusNext = true; + handled = true; + } + break; + case KeyEvent.KEYCODE_DEL: + handled = !canDeleteSelection(1) || deleteSelectedObject(false); + break; + } + + return handled || super.onKeyDown(keyCode, event); + } + + private boolean deleteSelectedObject(boolean handled) { + if (tokenClickStyle != null && tokenClickStyle.isSelectable()) { + Editable text = getText(); + if (text == null) return handled; + + TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class); + for (TokenImageSpan span : spans) { + if (span.view.isSelected()) { + removeSpan(span); + handled = true; + break; + } + } + } + return handled; + } + + @Override + public boolean onEditorAction(TextView view, int action, KeyEvent keyEvent) { + if (action == EditorInfo.IME_ACTION_DONE) { + handleDone(); + return true; + } + return false; + } + + @Override + public boolean onTouchEvent(@NonNull MotionEvent event) { + int action = event.getActionMasked(); + Editable text = getText(); + boolean handled = false; + + if (tokenClickStyle == TokenClickStyle.None) { + handled = super.onTouchEvent(event); + } + + if (isFocused() && text != null && lastLayout != null && action == MotionEvent.ACTION_UP) { + + int offset = getOffsetForPosition(event.getX(), event.getY()); + + if (offset != -1) { + TokenImageSpan[] links = text.getSpans(offset, offset, TokenImageSpan.class); + + if (links.length > 0) { + links[0].onClick(); + handled = true; + } else { + //We didn't click on a token, so if any are selected, we should clear that + clearSelections(); + } + } + } + + if (!handled && tokenClickStyle != TokenClickStyle.None) { + handled = super.onTouchEvent(event); + } + return handled; + + } + + @Override + protected void onSelectionChanged(int selStart, int selEnd) { + if (hintVisible) { + //Don't let users select the hint + selStart = 0; + } + //Never let users select text + selEnd = selStart; + + if (tokenClickStyle != null && tokenClickStyle.isSelectable()) { + Editable text = getText(); + if (text != null) { + clearSelections(); + } + } + + + if (prefix != null && (selStart < prefix.length() || selEnd < prefix.length())) { + //Don't let users select the prefix + setSelection(prefix.length()); + } else { + Editable text = getText(); + if (text != null) { + //Make sure if we are in a span, we select the spot 1 space after the span end + TokenImageSpan[] spans = text.getSpans(selStart, selEnd, TokenImageSpan.class); + for (TokenImageSpan span : spans) { + int spanEnd = text.getSpanEnd(span); + if (selStart <= spanEnd && text.getSpanStart(span) < selStart) { + if (spanEnd == text.length()) + setSelection(spanEnd); + else + setSelection(spanEnd + 1); + return; + } + } + + } + + super.onSelectionChanged(selStart, selEnd); + } + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + lastLayout = getLayout(); //Used for checking text positions + } + + /** + * Collapse the view by removing all the tokens not on the first line. Displays a "+x" token. + * Restores the hidden tokens when the view gains focus. + * + * @param hasFocus boolean indicating whether we have the focus or not. + */ + public void performCollapse(boolean hasFocus) { + // Pause the spanwatcher + focusChanging = true; + if (!hasFocus) { + Editable text = getText(); + if (text != null && lastLayout != null) { + // Display +x thingy if appropriate + int lastPosition = lastLayout.getLineVisibleEnd(0); + TokenImageSpan[] tokens = text.getSpans(0, lastPosition, TokenImageSpan.class); + int count = objects.size() - tokens.length; + + // Make sure we don't add more than 1 CountSpan + CountSpan[] countSpans = text.getSpans(0, lastPosition, CountSpan.class); + + if (count > 0 && countSpans.length == 0) { + lastPosition++; + CountSpan cs = new CountSpan(count, getContext(), getCurrentTextColor(), + (int) getTextSize(), (int) maxTextWidth()); + text.insert(lastPosition, cs.text); + + float newWidth = Layout.getDesiredWidth(text, 0, + lastPosition + cs.text.length(), lastLayout.getPaint()); + //If the +x span will be moved off screen, move it one token in + if (newWidth > maxTextWidth()) { + text.delete(lastPosition, lastPosition + cs.text.length()); + + if (tokens.length > 0) { + TokenImageSpan token = tokens[tokens.length - 1]; + lastPosition = text.getSpanStart(token); + cs.setCount(count + 1); + } else { + lastPosition = prefix.length(); + } + + text.insert(lastPosition, cs.text); + } + + text.setSpan(cs, lastPosition, lastPosition + cs.text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + // Remove all spans behind the count span and hold them in the hiddenSpans List + // The generic type information is not captured in TokenImageSpan.class so we have + // to perform a cast for the returned spans to coerce them to the proper generic type. + hiddenSpans = new ArrayList<>(Arrays.asList( + (TokenImageSpan[]) text.getSpans(lastPosition + cs.text.length(), text.length(), TokenImageSpan.class))); + for (TokenImageSpan span : hiddenSpans) { + removeSpan(span); + } + } + } + } else { + final Editable text = getText(); + if (text != null) { + CountSpan[] counts = text.getSpans(0, text.length(), CountSpan.class); + for (CountSpan count : counts) { + text.delete(text.getSpanStart(count), text.getSpanEnd(count)); + text.removeSpan(count); + } + + // Restore the spans we have hidden + for (TokenImageSpan span : hiddenSpans) { + insertSpan(span); + } + hiddenSpans.clear(); + + if (hintVisible) { + setSelection(prefix.length()); + } else { + // Slightly delay moving the cursor to the end. Inserting spans seems to take + // some time. (ugly, but what can you do :( ) + postDelayed(new Runnable() { + @Override + public void run() { + setSelection(text.length()); + } + }, 10); + } + + TokenSpanWatcher[] watchers = getText().getSpans(0, getText().length(), TokenSpanWatcher.class); + if (watchers.length == 0) { + //Someone removes watchers? I'm pretty sure this isn't in this code... -mgod + text.setSpan(spanWatcher, 0, text.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); + } + } + } + // Start the spanwatcher + focusChanging = false; + } + + @Override + public void onFocusChanged(boolean hasFocus, int direction, Rect previous) { + super.onFocusChanged(hasFocus, direction, previous); + + // See if the user left any unfinished tokens and finish them + if (!hasFocus) performCompletion(); + + // Clear sections when focus changes to avoid a token remaining selected + clearSelections(); + + // Collapse the view to a single line + if (allowCollapse) performCollapse(hasFocus); + } + + @SuppressWarnings("unchecked cast") + @Override + protected CharSequence convertSelectionToString(Object object) { + selectedObject = (T) object; + + //if the token gets deleted, this text will get put in the field instead + switch (deletionStyle) { + case Clear: + return ""; + case PartialCompletion: + return currentCompletionText(); + case ToString: + return object != null ? object.toString() : ""; + case _Parent: + default: + return super.convertSelectionToString(object); + + } + } + + private SpannableStringBuilder buildSpannableForText(CharSequence text) { + //Add a sentinel , at the beginning so the user can remove an inner token and keep auto-completing + //This is a hack to work around the fact that the tokenizer cannot directly detect spans + //We don't want a space as the sentinel, and splitChar[0] is guaranteed to be something non-space + char sentinel = splitChar[0]; + return new SpannableStringBuilder(String.valueOf(sentinel) + tokenizer.terminateToken(text)); + } + + protected TokenImageSpan buildSpanForObject(T obj) { + if (obj == null) { + return null; + } + View tokenView = getViewForObject(obj); + return new TokenImageSpan(tokenView, obj, (int) maxTextWidth()); + } + + @Override + protected void replaceText(CharSequence text) { + clearComposingText(); + + // Don't build a token for an empty String + if (selectedObject == null || selectedObject.toString().equals("")) return; + + SpannableStringBuilder ssb = buildSpannableForText(text); + TokenImageSpan tokenSpan = buildSpanForObject(selectedObject); + + Editable editable = getText(); + int cursorPosition = getSelectionEnd(); + int end = cursorPosition; + int start = cursorPosition; + if (!hintVisible) { + //If you force the drop down to show when the hint is visible, you can run a completion + //on the hint. If the hint includes commas, this truncates and inserts the hint in the field + end = getCorrectedTokenEnd(); + start = getCorrectedTokenBeginning(end); + } + + String original = TextUtils.substring(editable, start, end); + + if (editable != null) { + if (tokenSpan == null) { + editable.replace(start, end, ""); + } else if (!allowDuplicates && objects.contains(tokenSpan.getToken())) { + editable.replace(start, end, ""); + } else { + QwertyKeyListener.markAsReplaced(editable, start, end, original); + editable.replace(start, end, ssb); + editable.setSpan(tokenSpan, start, start + ssb.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + } + + @Override + public boolean extractText(@NonNull ExtractedTextRequest request, @NonNull ExtractedText outText) { + try { + return super.extractText(request, outText); + } catch (IndexOutOfBoundsException ignored) { + Log.d(TAG, "extractText hit IndexOutOfBoundsException. This may be normal.", ignored); + return false; + } + } + + /** + * Append a token object to the object list + * + * @param object the object to add to the displayed tokens + * @param sourceText the text used if this object is deleted + */ + public void addObject(final T object, final CharSequence sourceText) { + post(new Runnable() { + @Override + public void run() { + if (object == null) return; + if (!allowDuplicates && objects.contains(object)) return; + if (tokenLimit != -1 && objects.size() == tokenLimit) return; + insertSpan(object, sourceText); + if (getText() != null && isFocused()) setSelection(getText().length()); + } + }); + } + + /** + * Shorthand for addObject(object, "") + * + * @param object the object to add to the displayed token + */ + public void addObject(T object) { + addObject(object, ""); + } + + /** + * Remove an object from the token list. Will remove duplicates or do nothing if no object + * present in the view. + * + * @param object object to remove, may be null or not in the view + */ + public void removeObject(final T object) { + post(new Runnable() { + @Override + public void run() { + //To make sure all the appropriate callbacks happen, we just want to piggyback on the + //existing code that handles deleting spans when the text changes + Editable text = getText(); + if (text == null) return; + + // If the object is currently hidden, remove it + ArrayList toRemove = new ArrayList<>(); + for (TokenImageSpan span : hiddenSpans) { + if (span.getToken().equals(object)) { + toRemove.add(span); + } + } + for (TokenImageSpan span : toRemove) { + hiddenSpans.remove(span); + // Remove it from the state and fire the callback + spanWatcher.onSpanRemoved(text, span, 0, 0); + } + + updateCountSpan(); + + // If the object is currently visible, remove it + TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class); + for (TokenImageSpan span : spans) { + if (span.getToken().equals(object)) { + removeSpan(span); + } + } + } + }); + } + + /** + * Set the count span the current number of hidden objects + */ + private void updateCountSpan() { + Editable text = getText(); + CountSpan[] counts = text.getSpans(0, text.length(), CountSpan.class); + int newCount = hiddenSpans.size(); + for (CountSpan count : counts) { + if (newCount == 0) { + // No more hidden Objects: remove the CountSpan + text.delete(text.getSpanStart(count), text.getSpanEnd(count)); + text.removeSpan(count); + } else { + // Update the CountSpan + count.setCount(hiddenSpans.size()); + text.setSpan(count, text.getSpanStart(count), text.getSpanEnd(count), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + } + + /** + * Remove a span from the current EditText and fire the appropriate callback + * + * @param span TokenImageSpan to be removed + */ + private void removeSpan(TokenImageSpan span) { + Editable text = getText(); + if (text == null) return; + + //If the spanwatcher has been removed, we need to also manually trigger onSpanRemoved + TokenSpanWatcher[] spans = text.getSpans(0, text.length(), TokenSpanWatcher.class); + if (spans.length == 0) { + spanWatcher.onSpanRemoved(text, span, text.getSpanStart(span), text.getSpanEnd(span)); + } + + //Add 1 to the end because we put a " " at the end of the spans when adding them + text.delete(text.getSpanStart(span), text.getSpanEnd(span) + 1); + + if (allowCollapse && !isFocused()) { + updateCountSpan(); + } + } + + /** + * Insert a new span for an Object + * + * @param object Object to create a span for + * @param sourceText CharSequence to show when the span is removed + */ + private void insertSpan(T object, CharSequence sourceText) { + SpannableStringBuilder ssb = buildSpannableForText(sourceText); + TokenImageSpan tokenSpan = buildSpanForObject(object); + + Editable editable = getText(); + if (editable == null) return; + + // If we're focused, or haven't hidden any objects yet, we can try adding it + if (!allowCollapse || isFocused() || hiddenSpans.isEmpty()) { + int offset = editable.length(); + //There might be a hint visible... + if (hintVisible) { + //...so we need to put the object in in front of the hint + offset = prefix.length(); + editable.insert(offset, ssb); + } else { + String completionText = currentCompletionText(); + if (completionText != null && completionText.length() > 0) { + // The user has entered some text that has not yet been tokenized. + // Find the beginning of this text and insert the new token there. + offset = TextUtils.indexOf(editable, completionText); + } + editable.insert(offset, ssb); + } + editable.setSpan(tokenSpan, offset, offset + ssb.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + // If we're not focused: collapse the view if necessary + if (!isFocused() && allowCollapse) performCollapse(false); + + //In some cases, particularly the 1 to nth objects when not focused and restoring + //onSpanAdded doesn't get called + if (!objects.contains(object)) { + spanWatcher.onSpanAdded(editable, tokenSpan, 0, 0); + } + } else { + hiddenSpans.add(tokenSpan); + //Need to manually call onSpanAdded here as we're not putting the span on the text + spanWatcher.onSpanAdded(editable, tokenSpan, 0, 0); + updateCountSpan(); + } + } + + private void insertSpan(T object) { + String spanString; + // The information about the original text is lost here, so other than "toString" we have no data + if (deletionStyle == TokenDeleteStyle.ToString) { + spanString = object != null ? object.toString() : ""; + } else { + spanString = ""; + } + + insertSpan(object, spanString); + } + + private void insertSpan(TokenImageSpan span) { + insertSpan(span.getToken()); + } + + /** + * Remove all objects from the token list. + * We're handling this separately because removeObject doesn't always reliably trigger + * onSpanRemoved when called too fast. + * If removeObject is working for you, you probably shouldn't be using this. + */ + @SuppressWarnings("unused") + public void clear() { + post(new Runnable() { + @Override + public void run() { + // If there's no text, we're already empty + Editable text = getText(); + if (text == null) return; + + // Get all spans in the EditText and remove them + TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class); + for (TokenImageSpan span : spans) { + removeSpan(span); + + // Make sure the callback gets called + spanWatcher.onSpanRemoved(text, span, text.getSpanStart(span), text.getSpanEnd(span)); + } + } + }); + } + + private void updateHint() { + Editable text = getText(); + CharSequence hintText = getHint(); + if (text == null || hintText == null) { + return; + } + + //Show hint if we need to + if (prefix.length() > 0) { + HintSpan[] hints = text.getSpans(0, text.length(), HintSpan.class); + HintSpan hint = null; + int testLength = prefix.length(); + if (hints.length > 0) { + hint = hints[0]; + testLength += text.getSpanEnd(hint) - text.getSpanStart(hint); + } + + if (text.length() == testLength) { + hintVisible = true; + + if (hint != null) { + return;//hint already visible + } + + //We need to display the hint manually + Typeface tf = getTypeface(); + int style = Typeface.NORMAL; + if (tf != null) { + style = tf.getStyle(); + } + ColorStateList colors = getHintTextColors(); + + HintSpan hintSpan = new HintSpan(null, style, (int) getTextSize(), colors, colors); + text.insert(prefix.length(), hintText); + text.setSpan(hintSpan, prefix.length(), prefix.length() + getHint().length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + setSelection(prefix.length()); + } else { + if (hint == null) { + return; //hint already removed + } + + //Remove the hint. There should only ever be one + int sStart = text.getSpanStart(hint); + int sEnd = text.getSpanEnd(hint); + + text.removeSpan(hint); + text.replace(sStart, sEnd, ""); + + hintVisible = false; + } + } + } + + private void clearSelections() { + if (tokenClickStyle == null || !tokenClickStyle.isSelectable()) return; + + Editable text = getText(); + if (text == null) return; + + TokenImageSpan[] tokens = text.getSpans(0, text.length(), TokenImageSpan.class); + for (TokenImageSpan token : tokens) { + token.view.setSelected(false); + } + invalidate(); + } + + protected class TokenImageSpan extends ViewSpan implements NoCopySpan { + private T token; + + public TokenImageSpan(View d, T token, int maxWidth) { + super(d, maxWidth); + this.token = token; + } + + public T getToken() { + return this.token; + } + + public void onClick() { + Editable text = getText(); + if (text == null) return; + + switch (tokenClickStyle) { + case Select: + case SelectDeselect: + + if (!view.isSelected()) { + clearSelections(); + view.setSelected(true); + break; + } + + if (tokenClickStyle == TokenClickStyle.SelectDeselect || !isTokenRemovable(token)) { + view.setSelected(false); + invalidate(); + break; + } + //If the view is already selected, we want to delete it + case Delete: + if (isTokenRemovable(token)) { + removeSpan(this); + } + break; + case None: + default: + if (getSelectionStart() != text.getSpanEnd(this) + 1) { + //Make sure the selection is not in the middle of the span + setSelection(text.getSpanEnd(this) + 1); + } + } + } + } + + public interface TokenListener { + void onTokenAdded(T token); + + void onTokenRemoved(T token); + } + + private class TokenSpanWatcher implements SpanWatcher { + + @SuppressWarnings("unchecked cast") + @Override + public void onSpanAdded(Spannable text, Object what, int start, int end) { + if (what instanceof TokenCompleteTextView.TokenImageSpan && !savingState && !focusChanging) { + TokenImageSpan token = (TokenImageSpan) what; + objects.add(token.getToken()); + + if (listener != null) + listener.onTokenAdded(token.getToken()); + } + } + + @SuppressWarnings("unchecked cast") + @Override + public void onSpanRemoved(Spannable text, Object what, int start, int end) { + if (what instanceof TokenCompleteTextView.TokenImageSpan && !savingState && !focusChanging) { + TokenImageSpan token = (TokenImageSpan) what; + if (objects.contains(token.getToken())) { + objects.remove(token.getToken()); + } + + if (listener != null) + listener.onTokenRemoved(token.getToken()); + } + } + + @Override + public void onSpanChanged(Spannable text, Object what, int ostart, int oend, int nstart, int nend) { + } + } + + /** + * deletes tokens if you delete the space in front of them + * without this, you get the auto-complete dropdown a character early + */ + private class TokenTextWatcher implements TextWatcher { + ArrayList spansToRemove = new ArrayList<>(); + + protected void removeToken(TokenImageSpan token, Editable text) { + text.removeSpan(token); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + // count > 0 means something will be deleted + if (count > 0 && getText() != null) { + Editable text = getText(); + int end = start + count; + + //If we're deleting a space, we want spans from 1 character before this start + if (text.charAt(start) == ' ') { + start -= 1; + } + + TokenImageSpan[] spans = text.getSpans(start, end, TokenImageSpan.class); + + //NOTE: I'm not completely sure this won't cause problems if we get stuck in a text changed loop + //but it appears to work fine. Spans will stop getting removed if this breaks. + ArrayList spansToRemove = new ArrayList<>(); + for (TokenImageSpan token : spans) { + if (text.getSpanStart(token) < end && start < text.getSpanEnd(token)) { + spansToRemove.add(token); + } + } + this.spansToRemove = spansToRemove; + } + } + + @Override + public void afterTextChanged(Editable text) { + ArrayList spansCopy = new ArrayList<>(spansToRemove); + spansToRemove.clear(); + for (TokenImageSpan token : spansCopy) { + int spanStart = text.getSpanStart(token); + int spanEnd = text.getSpanEnd(token); + + removeToken(token, text); + + //The end of the span is the character index after it + spanEnd--; + + //Delete any extra split chars + if (spanEnd >= 0 && isSplitChar(text.charAt(spanEnd))) { + text.delete(spanEnd, spanEnd + 1); + } + + if (spanStart >= 0 && isSplitChar(text.charAt(spanStart))) { + text.delete(spanStart, spanStart + 1); + } + } + + clearSelections(); + updateHint(); + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + } + + protected ArrayList getSerializableObjects() { + ArrayList serializables = new ArrayList<>(); + for (Object obj : getObjects()) { + if (obj instanceof Serializable) { + serializables.add((Serializable) obj); + } else { + Log.e(TAG, "Unable to save '" + obj + "'"); + } + } + if (serializables.size() != objects.size()) { + String message = "You should make your objects Serializable or override\n" + + "getSerializableObjects and convertSerializableArrayToObjectArray"; + Log.e(TAG, message); + } + + return serializables; + } + + @SuppressWarnings("unchecked") + protected ArrayList convertSerializableArrayToObjectArray(ArrayList s) { + return (ArrayList) (ArrayList) s; + } + + @Override + public Parcelable onSaveInstanceState() { + ArrayList baseObjects = getSerializableObjects(); + + //We don't want to save the listeners as part of the parent + //onSaveInstanceState, so remove them first + removeListeners(); + + //ARGH! Apparently, saving the parent state on 2.3 mutates the spannable + //prevent this mutation from triggering add or removes of token objects ~mgod + savingState = true; + Parcelable superState = super.onSaveInstanceState(); + savingState = false; + SavedState state = new SavedState(superState); + + state.prefix = prefix; + state.allowCollapse = allowCollapse; + state.allowDuplicates = allowDuplicates; + state.performBestGuess = performBestGuess; + state.tokenClickStyle = tokenClickStyle; + state.tokenDeleteStyle = deletionStyle; + state.baseObjects = baseObjects; + state.splitChar = splitChar; + + //So, when the screen is locked or some other system event pauses execution, + //onSaveInstanceState gets called, but it won't restore state later because the + //activity is still in memory, so make sure we add the listeners again + //They should not be restored in onInstanceState if the app is actually killed + //as we removed them before the parent saved instance state, so our adding them in + //onRestoreInstanceState is good. + addListeners(); + + return state; + } + + @Override + public void onRestoreInstanceState(Parcelable state) { + if (!(state instanceof SavedState)) { + super.onRestoreInstanceState(state); + return; + } + + SavedState ss = (SavedState) state; + super.onRestoreInstanceState(ss.getSuperState()); + + setText(ss.prefix); + prefix = ss.prefix; + updateHint(); + allowCollapse = ss.allowCollapse; + allowDuplicates = ss.allowDuplicates; + performBestGuess = ss.performBestGuess; + tokenClickStyle = ss.tokenClickStyle; + deletionStyle = ss.tokenDeleteStyle; + splitChar = ss.splitChar; + + addListeners(); + for (T obj : convertSerializableArrayToObjectArray(ss.baseObjects)) { + addObject(obj); + } + + // Collapse the view if necessary + if (!isFocused() && allowCollapse) { + post(new Runnable() { + @Override + public void run() { + //Resize the view and display the +x if appropriate + performCollapse(isFocused()); + } + }); + } + } + + /** + * Handle saving the token state + */ + private static class SavedState extends BaseSavedState { + CharSequence prefix; + boolean allowCollapse; + boolean allowDuplicates; + boolean performBestGuess; + TokenClickStyle tokenClickStyle; + TokenDeleteStyle tokenDeleteStyle; + ArrayList baseObjects; + char[] splitChar; + + @SuppressWarnings("unchecked") + SavedState(Parcel in) { + super(in); + prefix = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in); + allowCollapse = in.readInt() != 0; + allowDuplicates = in.readInt() != 0; + performBestGuess = in.readInt() != 0; + tokenClickStyle = TokenClickStyle.values()[in.readInt()]; + tokenDeleteStyle = TokenDeleteStyle.values()[in.readInt()]; + baseObjects = (ArrayList) in.readSerializable(); + splitChar = in.createCharArray(); + } + + SavedState(Parcelable superState) { + super(superState); + } + + @Override + public void writeToParcel(@NonNull Parcel out, int flags) { + super.writeToParcel(out, flags); + TextUtils.writeToParcel(prefix, out, 0); + out.writeInt(allowCollapse ? 1 : 0); + out.writeInt(allowDuplicates ? 1 : 0); + out.writeInt(performBestGuess ? 1 : 0); + out.writeInt(tokenClickStyle.ordinal()); + out.writeInt(tokenDeleteStyle.ordinal()); + out.writeSerializable(baseObjects); + out.writeCharArray(splitChar); + } + + @Override + public String toString() { + String str = "TokenCompleteTextView.SavedState{" + + Integer.toHexString(System.identityHashCode(this)) + + " tokens=" + baseObjects; + return str + "}"; + } + + @SuppressWarnings("hiding") + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } + + /** + * Checks if selection can be deleted. This method is called from TokenInputConnection . + * @param beforeLength the number of characters before the current selection end to check + * @return true if there are no non-deletable pieces of the section + */ + @SuppressWarnings("unused") + public boolean canDeleteSelection(int beforeLength) { + if (objects.size() < 1) return true; + + // if beforeLength is 1, we either have no selection or the call is coming from OnKey Event. + // In these scenarios, getSelectionStart() will return the correct value. + + int endSelection = getSelectionEnd(); + int startSelection = beforeLength == 1 ? getSelectionStart() : endSelection - beforeLength; + + Editable text = getText(); + TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class); + + // Iterate over all tokens and allow the deletion + // if there are no tokens not removable in the selection + for (TokenImageSpan span : spans) { + int startTokenSelection = text.getSpanStart(span); + int endTokenSelection = text.getSpanEnd(span); + + // moving on, no need to check this token + if (isTokenRemovable(span.token)) continue; + + if (startSelection == endSelection) { + // Delete single + if (endTokenSelection + 1 == endSelection) { + return false; + } + } else { + // Delete range + // Don't delete if a non removable token is in range + if (startSelection <= startTokenSelection + && endTokenSelection + 1 <= endSelection) { + return false; + } + } + } + return true; + } + + private class TokenInputConnection extends InputConnectionWrapper { + + public TokenInputConnection(InputConnection target, boolean mutable) { + super(target, mutable); + } + + // This will fire if the soft keyboard delete key is pressed. + // The onKeyPressed method does not always do this. + @Override + public boolean deleteSurroundingText(int beforeLength, int afterLength) { + // Shouldn't be able to delete any text with tokens that are not removable + if (!canDeleteSelection(beforeLength)) return false; + + //Shouldn't be able to delete prefix, so don't do anything + if (getSelectionStart() <= prefix.length()) { + beforeLength = 0; + return deleteSelectedObject(false) || super.deleteSurroundingText(beforeLength, afterLength); + } + + return super.deleteSurroundingText(beforeLength, afterLength); + } + } +} diff --git a/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/ViewSpan.java b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/ViewSpan.java new file mode 100644 index 000000000..e0dd905d2 --- /dev/null +++ b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/ViewSpan.java @@ -0,0 +1,65 @@ +package com.microsoft.fluentui.tokenautocomplete; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.text.style.ReplacementSpan; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; + +/** + * Span that holds a view it draws when rendering + * + * Created on 2/3/15. + * @author mgod + */ +public class ViewSpan extends ReplacementSpan { + protected View view; + private int maxWidth; + + public ViewSpan(View v, int maxWidth) { + super(); + this.maxWidth = maxWidth; + view = v; + view.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + } + + private void prepView() { + int widthSpec = View.MeasureSpec.makeMeasureSpec(maxWidth, View.MeasureSpec.AT_MOST); + int heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); + + view.measure(widthSpec, heightSpec); + view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight()); + } + + public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) { + prepView(); + + canvas.save(); + //Centering the token looks like a better strategy that aligning the bottom + int padding = (bottom - top - view.getBottom()) / 2; + canvas.translate(x, bottom - view.getBottom() - padding); + view.draw(canvas); + canvas.restore(); + } + + public int getSize(@NonNull Paint paint, CharSequence charSequence, int i, int i2, Paint.FontMetricsInt fm) { + prepView(); + + if (fm != null) { + //We need to make sure the layout allots enough space for the view + int height = view.getMeasuredHeight(); + int need = height - (fm.descent - fm.ascent); + if (need > 0) { + int ascent = need / 2; + //This makes sure the text drawing area will be tall enough for the view + fm.descent += need - ascent; + fm.ascent -= ascent; + fm.bottom += need - ascent; + fm.top -= need / 2; + } + } + + return view.getRight(); + } +} diff --git a/fluentui_peoplepicker/src/main/res/drawable/people_picker_textview_focusable_background.xml b/fluentui_peoplepicker/src/main/res/drawable/people_picker_textview_focusable_background.xml new file mode 100644 index 000000000..1c494cbf9 --- /dev/null +++ b/fluentui_peoplepicker/src/main/res/drawable/people_picker_textview_focusable_background.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/fluentui_persona/build.gradle b/fluentui_persona/build.gradle index dcc0777a9..97a409aaf 100644 --- a/fluentui_persona/build.gradle +++ b/fluentui_persona/build.gradle @@ -59,6 +59,9 @@ android { } productFlavors { } + lintOptions { + abortOnError false + } } diff --git a/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/Avatar.kt b/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/Avatar.kt index 1eac0e067..4aa4253cf 100644 --- a/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/Avatar.kt +++ b/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/Avatar.kt @@ -10,9 +10,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.clearAndSetSemantics @@ -29,6 +32,7 @@ import com.microsoft.fluentui.theme.token.ControlTokens import com.microsoft.fluentui.theme.token.FluentIcon import com.microsoft.fluentui.theme.token.Icon import com.microsoft.fluentui.theme.token.controlTokens.* +import com.microsoft.fluentui.util.dpToPx // Tags used for testing const val AVATAR_IMAGE = "Fluent Avatar Image" @@ -44,6 +48,7 @@ const val AVATAR_ICON = "Fluent Avatar Icon" * @param size Set Size of Avatar. Default: [AvatarSize.Size32] * @param enableActivityRings Enable/Disable Activity Rings on Avatar * @param enablePresence Enable/Disable Presence Indicator on Avatar, if cutout is provided then presence indicator is not displayed + * @param enableActivityDot Enable/Disable Activity Dot on Avatar. * @param cutoutIconDrawable cutout drawable * @param cutoutIconImageVector cutout image vector * @param cutoutStyle shape of the cutout. Default: [CutoutStyle.Circle] @@ -57,6 +62,7 @@ fun Avatar( size: AvatarSize = AvatarSize.Size32, enableActivityRings: Boolean = false, enablePresence: Boolean = true, + enableActivityDot: Boolean = false, @DrawableRes cutoutIconDrawable: Int? = null, cutoutIconImageVector: ImageVector? = null, cutoutStyle: CutoutStyle = CutoutStyle.Circle, @@ -71,10 +77,15 @@ fun Avatar( val personInitials = person.getInitials() val avatarInfo = AvatarInfo( - size, AvatarType.Person, person.isActive, - - person.status, person.isOOO, person.isImageAvailable(), - personInitials.isNotEmpty(), person.getName(), cutoutStyle + size, + AvatarType.Person, + person.isActive, + person.status, + person.isOOO, + person.isImageAvailable(), + personInitials.isNotEmpty(), + person.getName(), + cutoutStyle ) val avatarSize = token.avatarSize(avatarInfo) val backgroundColor = token.backgroundBrush(avatarInfo) @@ -82,23 +93,18 @@ fun Avatar( val borders = token.borderStroke(avatarInfo) val fontTextStyle = token.fontTypography(avatarInfo) val cutoutCornerRadius = token.cutoutCornerRadius(avatarInfo) - val cutoutBackgroundColor = - token.cutoutBackgroundColor(avatarInfo = avatarInfo) + val cutoutBackgroundColor = token.cutoutBackgroundColor(avatarInfo = avatarInfo) val cutoutBorderColor = token.cutoutBorderColor(avatarInfo = avatarInfo) val cutoutIconSize = token.cutoutIconSize(avatarInfo = avatarInfo) val isCutoutEnabled = (cutoutIconDrawable != null || cutoutIconImageVector != null) var isImageOrInitialsAvailable = true - Box(modifier = Modifier - .semantics(mergeDescendants = true) { - contentDescription = "${person.getName()}. " + - "${if (enablePresence) "Status, ${person.status}," else ""} " + - "${if (enablePresence && person.isOOO) "Out Of Office," else ""} " + - if (enableActivityRings) { - if (person.isActive) "Active" else "Inactive" - } else "" - } - ) { + Box(modifier = Modifier.semantics(mergeDescendants = true) { + contentDescription = + "${person.getName()}. " + "${if (enablePresence) "Status, ${person.status}," else ""} " + "${if (enablePresence && person.isOOO) "Out Of Office," else ""} " + if (enableActivityRings) { + if (person.isActive) "Active" else "Inactive" + } else "" + }) { Box( Modifier .then(modifier) @@ -107,35 +113,35 @@ fun Avatar( ) { when { person.image != null -> { - Image( - painter = painterResource(person.image), null, + Image(painter = painterResource(person.image), + null, + contentScale = ContentScale.Crop, modifier = Modifier .size(avatarSize) .clip(CircleShape) .semantics { testTag = AVATAR_IMAGE - } - ) + }) } + person.bitmap != null -> { - Image( - bitmap = person.bitmap.asImageBitmap(), null, + Image(bitmap = person.bitmap.asImageBitmap(), + null, + contentScale = ContentScale.Crop, modifier = Modifier .size(avatarSize) .clip(CircleShape) .semantics { testTag = AVATAR_IMAGE - } - ) + }) } + personInitials.isNotEmpty() -> { - BasicText(personInitials, - style = fontTextStyle.merge( - TextStyle(color = foregroundColor) - ), - modifier = Modifier - .clearAndSetSemantics { }) + BasicText(personInitials, style = fontTextStyle.merge( + TextStyle(color = foregroundColor) + ), modifier = Modifier.clearAndSetSemantics { }) } + else -> { isImageOrInitialsAvailable = false Icon( @@ -151,8 +157,7 @@ fun Avatar( } } - if (enableActivityRings) - ActivityRing(radius = avatarSize / 2, borders) + if (enableActivityRings) ActivityRing(radius = avatarSize / 2, borders) if (isCutoutEnabled && isImageOrInitialsAvailable && cutoutIconSize > 0.dp) { Box( @@ -164,30 +169,30 @@ fun Avatar( if (cutoutIconDrawable != null) { Image( painter = painterResource(cutoutIconDrawable), + contentScale = ContentScale.Crop, modifier = Modifier .background(cutoutBackgroundColor) .border( - 2.dp, - cutoutBorderColor, - RoundedCornerShape(cutoutCornerRadius) + 2.dp, cutoutBorderColor, RoundedCornerShape(cutoutCornerRadius) ) .padding(4.dp) .size(cutoutIconSize), - contentDescription = cutoutContentDescription + contentDescription = cutoutContentDescription, + colorFilter = token.cutoutColorFilter(avatarInfo = avatarInfo) ) } else if (cutoutIconImageVector != null) { Image( imageVector = cutoutIconImageVector, + contentScale = ContentScale.Crop, modifier = Modifier .background(cutoutBackgroundColor) .border( - 2.dp, - cutoutBorderColor, - RoundedCornerShape(cutoutCornerRadius) + 2.dp, cutoutBorderColor, RoundedCornerShape(cutoutCornerRadius) ) .padding(4.dp) .size(cutoutIconSize), - contentDescription = cutoutContentDescription + contentDescription = cutoutContentDescription, + colorFilter = token.cutoutColorFilter(avatarInfo = avatarInfo) ) } } @@ -202,7 +207,89 @@ fun Avatar( Modifier .align(Alignment.BottomEnd) // Adding 2.dp to both side to incorporate border which is an image in Fluent Android. - .offset(presenceOffset.x + 2.dp, -presenceOffset.y + 2.dp) + .offset(presenceOffset.x + 2.dp, -presenceOffset.y + 2.dp), + contentScale = ContentScale.Crop + ) + } + + if (enableActivityDot) { + ActivityDot(token, avatarInfo, modifier.align(Alignment.TopEnd)) + } + } + } +} + +@Composable +internal fun SlicedAvatar( + person: Person, + modifier: Modifier = Modifier, + width: Dp = 32.dp, + avatarToken: AvatarTokens? = null, + slicedAvatarSize: Dp = 32.dp, + size: AvatarSize = AvatarSize.Size32 +) { + val personInitials = person.getInitials() + // if less than 19dp, show only first initial + val personInitialsToDisplay = + if (personInitials.length >= 2 && width < 19.dp) personInitials[0].toString() else personInitials + val token = avatarToken + ?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.AvatarControlType] as AvatarTokens + val avatarInfo = AvatarInfo( + size = size, + type = AvatarType.Person, + isImageAvailable = person.isImageAvailable(), + hasValidInitials = personInitials.isNotEmpty(), + calculatedColorKey = person.getName() + ) + val foregroundColor = token.foregroundColor(avatarInfo) + val fontTextStyle = fontTypographyForSlicedAvatar(slicedAvatarSize) + val backgroundBrush = token.backgroundBrush(avatarInfo) + when { + person.image != null -> { + Image( + painter = painterResource(person.image), + null, + contentScale = ContentScale.Crop, + modifier = modifier + ) + } + + person.bitmap != null -> { + Image( + bitmap = person.bitmap.asImageBitmap(), + null, + contentScale = ContentScale.Crop, + modifier = modifier + ) + } + + personInitialsToDisplay.isNotEmpty() -> { + Box( + modifier = modifier.background( + brush = backgroundBrush + ), contentAlignment = Alignment.Center + ) { + BasicText(personInitialsToDisplay, style = fontTextStyle.merge( + TextStyle(color = foregroundColor) + ), modifier = Modifier.clearAndSetSemantics { }) + } + } + + else -> { + Box( + modifier = modifier.background( + brush = backgroundBrush + ), contentAlignment = Alignment.Center + ) { + Icon( + token.icon(avatarInfo), + null, + modifier = Modifier + .background(backgroundBrush, CircleShape) + .semantics { + testTag = AVATAR_ICON + }, + tint = foregroundColor, ) } } @@ -224,7 +311,7 @@ fun Avatar( group: Group, modifier: Modifier = Modifier, size: AvatarSize = AvatarSize.Size32, - avatarToken: AvatarTokens? = null, + avatarToken: AvatarTokens? = null ) { val themeID = @@ -233,7 +320,8 @@ fun Avatar( ?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.AvatarControlType] as AvatarTokens val avatarInfo = AvatarInfo( - size, AvatarType.Group, + size, + AvatarType.Group, isImageAvailable = group.isImageAvailable(), hasValidInitials = group.getInitials().isNotEmpty(), calculatedColorKey = group.groupName @@ -245,8 +333,7 @@ fun Avatar( val foregroundColor = token.foregroundColor(avatarInfo) var membersList = "" - for (person in group.members) - membersList += (person.firstName + person.lastName + "\n") + for (person in group.members) membersList += (person.firstName + person.lastName + "\n") Box( modifier @@ -260,43 +347,37 @@ fun Avatar( Modifier .clip(RoundedCornerShape(cornerRadius)) .background(backgroundColor) - .fillMaxSize(), - contentAlignment = Alignment.Center + .fillMaxSize(), contentAlignment = Alignment.Center ) { if (group.image != null) { - Image( - painter = painterResource(group.image), + Image(painter = painterResource(group.image), + contentScale = ContentScale.Crop, contentDescription = null, modifier = Modifier .size(avatarSize) .clip(RoundedCornerShape(cornerRadius)) .semantics { testTag = AVATAR_IMAGE - } - ) + }) } else if (group.bitmap != null) { - Image( - bitmap = group.bitmap.asImageBitmap(), + Image(bitmap = group.bitmap.asImageBitmap(), + contentScale = ContentScale.Crop, contentDescription = null, modifier = Modifier .size(avatarSize) .clip(RoundedCornerShape(cornerRadius)) .semantics { testTag = AVATAR_IMAGE - } - ) + }) } else if (group.groupName.isNotEmpty()) { BasicText(group.getInitials(), style = fontTextStyle.merge(TextStyle(color = foregroundColor)), modifier = Modifier.clearAndSetSemantics { }) } else { Icon( - token.icon(avatarInfo), - null, - modifier = Modifier.semantics { + token.icon(avatarInfo), null, modifier = Modifier.semantics { testTag = AVATAR_ICON - }, - tint = foregroundColor + }, tint = foregroundColor ) } } @@ -311,6 +392,7 @@ fun Avatar( * @param size Set Size of Avatar. Default: [AvatarSize. Medium] * @param enableActivityRings Enable/Disable Activity Rings on Avatar * @param avatarToken Token to provide appearance values to Avatar + * @param enableActivityDot Enable/Disable Activity Dot on Avatar. */ @Composable fun Avatar( @@ -318,7 +400,8 @@ fun Avatar( modifier: Modifier = Modifier, size: AvatarSize = AvatarSize.Size32, enableActivityRings: Boolean = false, - avatarToken: AvatarTokens? = null + avatarToken: AvatarTokens? = null, + enableActivityDot: Boolean = false ) { val themeID = FluentTheme.themeID //Adding This only for recomposition in case of Token Updates. Unused otherwise. @@ -349,8 +432,10 @@ fun Avatar( modifier = Modifier.clearAndSetSemantics { }) } - if (enableActivityRings) - ActivityRing(radius = avatarSize / 2, borders) + if (enableActivityRings) ActivityRing(radius = avatarSize / 2, borders) + if (enableActivityDot) { + ActivityDot(token, avatarInfo, modifier.align(Alignment.TopEnd)) + } } } @@ -368,3 +453,29 @@ fun ActivityRing(radius: Dp, borders: List) { } } } + +@Composable +fun ActivityDot(token: AvatarTokens, avatarInfo: AvatarInfo, modifier: Modifier) { + val unreadDotOffset: DpOffset = token.unreadDotOffset(avatarInfo) + val unreadDotSize: Dp = token.unreadDotSize(avatarInfo) + val unreadDotBackground: Brush = token.unreadDotBackgroundBrush(avatarInfo) + val unreadDotBorderStroke = token.unreadDotBorderStroke(avatarInfo) + Box( + modifier = modifier + .size(unreadDotSize) + .offset(unreadDotOffset.x + unreadDotBorderStroke.width , -unreadDotOffset.y + unreadDotBorderStroke.width) + ) { + Canvas(Modifier) { + drawCircle( + brush = unreadDotBorderStroke.brush, + radius = dpToPx(unreadDotBorderStroke.width + unreadDotSize / 2) + ) + drawCircle( + brush = unreadDotBackground, + style = Fill, + radius = dpToPx(unreadDotSize / 2) + ) + } + } + +} diff --git a/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/AvatarGroup.kt b/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/AvatarGroup.kt index c6e1c0368..7f2c6bbb1 100644 --- a/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/AvatarGroup.kt +++ b/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/AvatarGroup.kt @@ -37,6 +37,7 @@ fun AvatarGroup( style: AvatarGroupStyle = AvatarGroupStyle.Stack, maxVisibleAvatar: Int = DEFAULT_MAX_AVATAR, enablePresence: Boolean = false, + enableActivityDot: Boolean = false, avatarToken: AvatarTokens? = null, avatarGroupToken: AvatarGroupTokens? = null ) { @@ -52,6 +53,8 @@ fun AvatarGroup( else maxVisibleAvatar + val showActivityDot: Boolean = enableActivityDot && style == AvatarGroupStyle.Stack + var enablePresence: Boolean = enablePresence if (style == AvatarGroupStyle.Stack) enablePresence = false @@ -81,29 +84,52 @@ fun AvatarGroup( Layout(modifier = modifier .padding(8.dp) .then(semanticModifier), content = { - for (i in 0 until visibleAvatar) { - val person = group.members[i] + if (group.members.size > 0) { + if (style == AvatarGroupStyle.Pie) { + if (visibleAvatar > 1) { + AvatarPie( + group = group, + size = size, + noOfVisibleAvatars = visibleAvatar, + avatarTokens = avatarToken + ) + } else { + Avatar( + group.members[0], + size = size, + enableActivityRings = true, + enablePresence = enablePresence, + avatarToken = avatarToken + ) + } - var paddingModifier: Modifier = Modifier - if (style == AvatarGroupStyle.Pile && person.isActive) { - val padding = token.pilePadding(avatarGroupInfo) - paddingModifier = paddingModifier.padding(start = padding, end = padding) - } + } else { + for (i in 0 until visibleAvatar) { + val person = group.members[i] - Avatar( - person, - modifier = paddingModifier, - size = size, - enableActivityRings = true, - enablePresence = enablePresence, - avatarToken = avatarToken - ) - } - if (group.members.size > visibleAvatar || group.members.isEmpty()) { - Avatar( - group.members.size - visibleAvatar, size = size, - enableActivityRings = true, avatarToken = avatarToken - ) + var paddingModifier: Modifier = Modifier + if (style == AvatarGroupStyle.Pile && person.isActive) { + val padding = token.pilePadding(avatarGroupInfo) + paddingModifier = paddingModifier.padding(start = padding, end = padding) + } + + Avatar( + person, + modifier = paddingModifier, + size = size, + enableActivityRings = true, + enablePresence = enablePresence, + avatarToken = avatarToken, + enableActivityDot = group.members.size == visibleAvatar && i == visibleAvatar - 1 && showActivityDot + ) + } + if (group.members.size > visibleAvatar || group.members.isEmpty()) { + Avatar( + group.members.size - visibleAvatar, size = size, + enableActivityRings = true, avatarToken = avatarToken, enableActivityDot = showActivityDot + ) + } + } } }) { measurables, constraints -> val placeables = measurables.map { measurable -> @@ -127,4 +153,5 @@ fun AvatarGroup( } } } -} \ No newline at end of file +} + diff --git a/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/AvatarPie.kt b/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/AvatarPie.kt new file mode 100644 index 000000000..95a640b8d --- /dev/null +++ b/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/AvatarPie.kt @@ -0,0 +1,155 @@ +package com.microsoft.fluentui.tokenized.persona + +import androidx.compose.foundation.background +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.microsoft.fluentui.theme.FluentTheme +import com.microsoft.fluentui.theme.token.ControlTokens +import com.microsoft.fluentui.theme.token.controlTokens.AvatarInfo +import com.microsoft.fluentui.theme.token.controlTokens.AvatarTokens +import com.microsoft.fluentui.theme.token.controlTokens.AvatarSize + +private val SPACER_SIZE = 2.dp + +@Composable +fun AvatarPie( + group: Group, size: AvatarSize, noOfVisibleAvatars: Int = 2, avatarTokens: AvatarTokens? = null +) { + val avatarInfo = AvatarInfo( + size + ) + val token = avatarTokens + ?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.AvatarControlType] as AvatarTokens + val avatarSize = token.avatarSize(avatarInfo) + + Box( + modifier = Modifier + .requiredSize(avatarSize) + .background( + color = Color.White, shape = CircleShape + ), contentAlignment = Alignment.Center + ) { + val slicedAvatarDimen = avatarSize / 2 - SPACER_SIZE / 2 + if (noOfVisibleAvatars == 2) { + RenderTwoSlices(avatarSize, slicedAvatarDimen, group, size) + } else if (noOfVisibleAvatars >= 3) { + RenderThreeSlices(avatarSize, slicedAvatarDimen, group, size) + } + } +} + +@Composable +private fun RenderTwoSlices( + avatarSize: Dp, slicedAvatarDimen: Dp, group: Group, size: AvatarSize +) { + Row( + modifier = Modifier + .requiredSize(avatarSize) + .clip(CircleShape) + ) { + SlicedAvatar( + group.members[0], + slicedAvatarSize = avatarSize, + width = slicedAvatarDimen, + modifier = Modifier + .height(avatarSize) + .width(slicedAvatarDimen), + size = size + ) + AddVerticalSpacer() + SlicedAvatar( + group.members[1], + slicedAvatarSize = avatarSize, + width = slicedAvatarDimen, + modifier = Modifier + .height(avatarSize) + .width(slicedAvatarDimen), + size = size + ) + } +} + +@Composable +private fun RenderThreeSlices( + avatarSize: Dp, slicedAvatarDimen: Dp, group: Group, size: AvatarSize +) { + Row( + modifier = Modifier + .requiredSize(avatarSize) + .clip(CircleShape) + ) { + SlicedAvatar( + group.members[0], + slicedAvatarSize = avatarSize, + width = slicedAvatarDimen, + modifier = Modifier + .height(avatarSize) + .width(slicedAvatarDimen) + .align(Alignment.CenterVertically), + size = size + ) + AddVerticalSpacer() + Column( + modifier = Modifier + .height(avatarSize) + .width(slicedAvatarDimen), + ) { + SlicedAvatar( + group.members[1], + slicedAvatarSize = slicedAvatarDimen, + width = slicedAvatarDimen, + modifier = Modifier + .height(slicedAvatarDimen) + .width(slicedAvatarDimen), + size = size + ) + AddHorizontalSpacer() + SlicedAvatar( + group.members[2], + slicedAvatarSize = slicedAvatarDimen, + width = slicedAvatarDimen, + modifier = Modifier + .height(slicedAvatarDimen) + .width(slicedAvatarDimen), + size = size + ) + } + + } +} + +@Composable +private fun AddVerticalSpacer() { + Spacer( + modifier = Modifier + .background(color = Color.White) + .fillMaxHeight() + .width(SPACER_SIZE) + ) +} + +@Composable +private fun AddHorizontalSpacer() { + Spacer( + modifier = Modifier + .background(color = Color.White) + .fillMaxWidth() + .height(SPACER_SIZE) + ) +} diff --git a/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/Utils.kt b/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/Utils.kt index ffde47e45..d34510553 100644 --- a/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/Utils.kt +++ b/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/Utils.kt @@ -4,7 +4,11 @@ import android.graphics.Bitmap import android.os.Parcelable import androidx.annotation.DrawableRes import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.microsoft.fluentui.theme.token.FluentGlobalTokens import com.microsoft.fluentui.theme.token.controlTokens.AvatarSize import com.microsoft.fluentui.theme.token.controlTokens.AvatarStatus import kotlinx.parcelize.Parcelize @@ -152,4 +156,89 @@ fun getAvatarSize(secondaryText: String?, tertiaryText: String?): AvatarSize { return AvatarSize.Size40 } return AvatarSize.Size56 +} + + +@Composable +fun fontTypographyForSlicedAvatar(slicedAvatarSize: Dp): TextStyle { + return when (slicedAvatarSize) { + 7.dp -> TextStyle( + fontSize = 4.sp, + lineHeight = 4.69.sp, + fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value + ) + 9.dp -> TextStyle( + fontSize = 5.sp, + lineHeight = 9.sp, + fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value + ) + 11.dp -> TextStyle( + fontSize = 6.sp, + lineHeight = 7.5.sp, + fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value + ) + 15.dp -> TextStyle( + fontSize = 10.sp, + lineHeight = 13.sp, + fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value + ) + 16.dp -> TextStyle( + fontSize = 6.sp, + lineHeight = 7.03.sp, + fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value + ) + 19.dp -> TextStyle( + fontSize = 8.sp, + lineHeight = 15.sp, + fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value + ) + 20.dp -> TextStyle( + fontSize = 8.sp, + lineHeight = 9.sp, + fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value + ) + 24.dp -> TextStyle( + fontSize = 10.sp, + lineHeight = 9.sp, + fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value + ) + 27.dp -> TextStyle( + fontSize = 11.sp, + lineHeight = 12.89.sp, + fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value + ) + 32.dp -> TextStyle( + fontSize = 13.sp, + lineHeight = 13.sp, + fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value + ) + 35.dp-> TextStyle( + fontSize = 13.sp, + lineHeight = 28.sp, + fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value + ) + 40.dp -> TextStyle( + fontSize = 10.sp, + lineHeight = 15.sp, + fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value + ) + 56.dp -> TextStyle( + fontSize = 14.sp, + lineHeight = 18.sp, + fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value + ) + 72.dp -> TextStyle( + fontSize = FluentGlobalTokens.FontSizeTokens.Size400.value, + lineHeight = FluentGlobalTokens.LineHeightTokens.Size700.value, + fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value + ) + + else -> { + TextStyle( + fontSize = 13.sp, + lineHeight = 13.sp, + fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value + ) + } + } } \ No newline at end of file diff --git a/fluentui_progress/build.gradle b/fluentui_progress/build.gradle index 50e9e2eda..9bdb0b224 100644 --- a/fluentui_progress/build.gradle +++ b/fluentui_progress/build.gradle @@ -37,6 +37,9 @@ android { buildFeatures { compose true } + lintOptions { + abortOnError false + } } dependencies { diff --git a/fluentui_progress/src/main/java/com/microsoft/fluentui/tokenized/shimmer/Shimmer.kt b/fluentui_progress/src/main/java/com/microsoft/fluentui/tokenized/shimmer/Shimmer.kt index 395c27e1a..49017b836 100644 --- a/fluentui_progress/src/main/java/com/microsoft/fluentui/tokenized/shimmer/Shimmer.kt +++ b/fluentui_progress/src/main/java/com/microsoft/fluentui/tokenized/shimmer/Shimmer.kt @@ -1,6 +1,7 @@ package com.microsoft.fluentui.tokenized.shimmer import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition @@ -15,6 +16,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset @@ -28,6 +31,7 @@ import androidx.compose.ui.unit.dp import com.microsoft.fluentui.theme.FluentTheme import com.microsoft.fluentui.theme.token.ControlTokens import com.microsoft.fluentui.theme.token.controlTokens.ShimmerInfo +import com.microsoft.fluentui.theme.token.controlTokens.ShimmerOrientation import com.microsoft.fluentui.theme.token.controlTokens.ShimmerTokens import com.microsoft.fluentui.util.dpToPx import kotlin.math.absoluteValue @@ -36,44 +40,42 @@ import kotlin.math.sqrt private const val DEFAULT_CORNER_RADIUS = 4 /** - * Create an empty Shimmer effect - * - * @param modifier Modifier for shimmer - * @param shimmerTokens Token values for shimmer - * - */ -@Composable -fun Shimmer( - modifier: Modifier = Modifier, - shimmerTokens: ShimmerTokens? = null -) { - InternalShimmer( - cornerRadius = DEFAULT_CORNER_RADIUS.dp, - modifier = modifier, - shimmerTokens = shimmerTokens - ) -} - -/** - * Create Shimmer effect on some content + * Create Shimmer effect on some content, creates an empty shimmer if content not provided or left null * * @param cornerRadius Corner radius of the shimmer * @param modifier Modifier for shimmer * @param shimmerTokens Token values for shimmer - * @param content Content to be shimmered + * @param content Content to be shimmered, creates an empty shimmer if content not provided or left null * */ @Composable fun Shimmer( - cornerRadius: Dp, + cornerRadius: Dp = DEFAULT_CORNER_RADIUS.dp, modifier: Modifier = Modifier, shimmerTokens: ShimmerTokens? = null, - content: @Composable () -> Unit, + shimmerDelay: Int = 1000, + isShimmering: Boolean = true, + shimmerOrientation: ShimmerOrientation = ShimmerOrientation.TOPLEFT_TO_BOTTOMRIGHT, + content: (@Composable () -> Unit)? = null, ) { + if(content == null) { + InternalShimmer( + cornerRadius = cornerRadius, + modifier = modifier, + shimmerTokens = shimmerTokens, + shimmerDelay = shimmerDelay, + isShimmering = isShimmering, + shimmerOrientation = shimmerOrientation + ) + return + } InternalShimmer( cornerRadius = cornerRadius, modifier = modifier, - shimmerTokens = shimmerTokens + shimmerTokens = shimmerTokens, + shimmerDelay = shimmerDelay, + isShimmering = isShimmering, + shimmerOrientation = shimmerOrientation, ) { content() } @@ -84,6 +86,9 @@ internal fun InternalShimmer( cornerRadius: Dp, modifier: Modifier = Modifier, shimmerTokens: ShimmerTokens? = null, + shimmerDelay: Int = 1000, + isShimmering: Boolean = true, + shimmerOrientation: ShimmerOrientation = ShimmerOrientation.TOPLEFT_TO_BOTTOMRIGHT, content: (@Composable () -> Unit)? = null, ) { val themeID = @@ -96,6 +101,18 @@ internal fun InternalShimmer( val diagonal = sqrt((screenHeight * screenHeight + screenWidth * screenWidth).toDouble()).toFloat() val shimmerInfo = ShimmerInfo() + val cachedDelay = tokens.delay(shimmerInfo) + val shimmerDelayValue = if (cachedDelay != -1) { + cachedDelay + } else { + shimmerDelay + } + val tokenOrientation = tokens.orientation(shimmerInfo) + val orientation: ShimmerOrientation = if(tokenOrientation != ShimmerOrientation._NONE){ + tokenOrientation + } else { + shimmerOrientation + } val shimmerBackgroundColor = if (content != null) { Color.Transparent } else { @@ -104,27 +121,56 @@ internal fun InternalShimmer( val shimmerKnockoutEffectColor = tokens.knockoutEffectColor(shimmerInfo) val cornerRadius = dpToPx(cornerRadius) - val shimmerDelay = tokens.delay(shimmerInfo) val infiniteTransition = rememberInfiniteTransition() - val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr - val initialValue = if (isLtr) 0f else screenWidth + val isLtr = if (orientation in listOf( + ShimmerOrientation.LEFT_TO_RIGHT, + ShimmerOrientation.TOPLEFT_TO_BOTTOMRIGHT + ) + ) (LocalLayoutDirection.current == LayoutDirection.Ltr) else (LocalLayoutDirection.current == LayoutDirection.Rtl) + + val initialValue = if (isLtr) 0f else diagonal val targetValue = if (isLtr) diagonal else 0f - val shimmerEffect by infiniteTransition.animateFloat( - initialValue, - targetValue, - infiniteRepeatable( - animation = tween( - durationMillis = shimmerDelay, - easing = LinearEasing + val shimmerEffect by if (isShimmering) { + infiniteTransition.animateFloat( + initialValue, + targetValue, + infiniteRepeatable( + animation = tween( + durationMillis = shimmerDelayValue, + easing = LinearEasing + ), + repeatMode = RepeatMode.Restart + ) ) - ) - ) + } + else { + remember { mutableFloatStateOf(0f) } + } + + val startOffset: Offset = when (orientation) { + ShimmerOrientation.LEFT_TO_RIGHT -> Offset.Zero + ShimmerOrientation.RIGHT_TO_LEFT -> Offset.Zero + ShimmerOrientation.TOPLEFT_TO_BOTTOMRIGHT -> Offset.Zero + ShimmerOrientation.BOTTOMRIGHT_TO_TOPLEFT -> Offset.Zero + else -> Offset.Zero + } + val endOffset: Offset = if (isShimmering) { + when (orientation) { + ShimmerOrientation.LEFT_TO_RIGHT -> Offset(shimmerEffect.absoluteValue, 0F) + ShimmerOrientation.RIGHT_TO_LEFT -> Offset(shimmerEffect.absoluteValue, 0F) + ShimmerOrientation.TOPLEFT_TO_BOTTOMRIGHT -> Offset(shimmerEffect.absoluteValue, shimmerEffect.absoluteValue) + ShimmerOrientation.BOTTOMRIGHT_TO_TOPLEFT -> Offset(shimmerEffect.absoluteValue, shimmerEffect.absoluteValue) + else -> Offset(shimmerEffect.absoluteValue, shimmerEffect.absoluteValue) + } + } else { + Offset.Zero + } val gradientColor = Brush.linearGradient( 0f to shimmerBackgroundColor, 0.5f to shimmerKnockoutEffectColor, 1.0f to shimmerBackgroundColor, - start = Offset.Zero, - end = Offset(shimmerEffect.absoluteValue, shimmerEffect.absoluteValue) + start = startOffset, + end = endOffset ) if (content != null) { Box( @@ -133,12 +179,14 @@ internal fun InternalShimmer( .height(IntrinsicSize.Max) ) { content() - Spacer( - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(cornerRadius)) - .background(gradientColor) - ) + if(isShimmering) { + Spacer( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(cornerRadius)) + .background(gradientColor) + ) + } } } else { Spacer( diff --git a/fluentui_tablayout/build.gradle b/fluentui_tablayout/build.gradle index f808986ad..b62186ccb 100644 --- a/fluentui_tablayout/build.gradle +++ b/fluentui_tablayout/build.gradle @@ -33,6 +33,9 @@ android { composeOptions { kotlinCompilerExtensionVersion composeCompilerVersion } + lintOptions { + abortOnError false + } } dependencies { diff --git a/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/navigation/TabBar.kt b/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/navigation/TabBar.kt index 5d7fa7b84..c3fe509ba 100644 --- a/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/navigation/TabBar.kt +++ b/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/navigation/TabBar.kt @@ -5,8 +5,9 @@ import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import com.microsoft.fluentui.theme.FluentTheme import com.microsoft.fluentui.theme.token.ControlTokens import com.microsoft.fluentui.theme.token.FluentStyle @@ -15,7 +16,7 @@ import com.microsoft.fluentui.theme.token.controlTokens.TabBarTokens import com.microsoft.fluentui.theme.token.controlTokens.TabItemTokens import com.microsoft.fluentui.theme.token.controlTokens.TabTextAlignment import com.microsoft.fluentui.tokenized.tabItem.TabItem - +import com.microsoft.fluentui.tablayout.R data class TabData( var title: String, @@ -23,7 +24,8 @@ data class TabData( var selectedIcon: ImageVector = icon, var selected: Boolean = false, var onClick: () -> Unit, - var badge: @Composable (() -> Unit)? = null + var badge: @Composable (() -> Unit)? = null, + var accessibilityDescription: String? = null, //Custom announcement for Talkback ) /** @@ -52,6 +54,7 @@ fun TabBar( FluentTheme.themeID //Adding This only for recomposition in case of Token Updates. Unused otherwise. val token = tabBarTokens ?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.TabBarControlType] as TabBarTokens + val resources = LocalContext.current.resources Column(modifier.fillMaxWidth()) { Box( @@ -65,9 +68,16 @@ fun TabBar( ) { tabDataList.forEachIndexed { index, tabData -> tabData.selected = index == selectedIndex + var accessibilityDescriptionValue = if(tabData.accessibilityDescription != null) { tabData.accessibilityDescription } + else{ tabData.title + if(tabData.selected) resources.getString(R.string.tab_active).prependIndent(": ") else resources.getString(R.string.tab_inactive).prependIndent(": ") } TabItem( title = tabData.title, modifier = Modifier + .semantics { + if (accessibilityDescriptionValue != null) { + contentDescription = accessibilityDescriptionValue + } + } .fillMaxWidth() .weight(1F), icon = if (tabData.selected) tabData.selectedIcon else tabData.icon, diff --git a/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/navigation/ViewPager.kt b/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/navigation/ViewPager.kt new file mode 100644 index 000000000..b44c226f3 --- /dev/null +++ b/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/navigation/ViewPager.kt @@ -0,0 +1,55 @@ +package com.microsoft.fluentui.tokenized.navigation + +import android.graphics.Paint.Align +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.PagerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.microsoft.fluentui.theme.FluentTheme +import com.microsoft.fluentui.theme.token.ControlTokens +import com.microsoft.fluentui.theme.token.controlTokens.ViewPagerInfo +import com.microsoft.fluentui.theme.token.controlTokens.ViewPagerTokens + +/** + * API to create a ViewPager. + * + * @param pagerState PagerState to manage the state of ViewPager + * @param pageContent Content to be displayed in ViewPager + * @param modifier Optional modifier for ViewPager + * @param pageSize Size of the page. Default: [PageSize.Fill] + * @param userScrollEnabled Boolean for enabling/disabling user scroll. Default: [false] + * @param verticalAlignment Alignment of content in ViewPager. Default: [Alignment.CenterVertically] + * @param viewPagerTokens Tokens to customize appearance of ViewPager. Default: [null] + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ViewPager( + pagerState: PagerState, + pageContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + pageSize: PageSize = PageSize.Fill, + userScrollEnabled: Boolean = false, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + viewPagerTokens: ViewPagerTokens? = null +) { + val token = + viewPagerTokens + ?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.ViewPagerControlType] as ViewPagerTokens + + val viewPagerInfo = ViewPagerInfo() + // HorizontalPager is a horizontally scrolling pager using the provided pagerState + HorizontalPager( + state = pagerState, + modifier = modifier, + contentPadding = token.contentPadding(viewPagerInfo), + pageSpacing = token.pageSpacing(viewPagerInfo), + pageSize = pageSize, + userScrollEnabled = userScrollEnabled, + verticalAlignment = verticalAlignment + ) { + pageContent() + } +} diff --git a/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/segmentedcontrols/Pill.kt b/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/segmentedcontrols/Pill.kt index 004bc4d57..7844bc3f5 100644 --- a/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/segmentedcontrols/Pill.kt +++ b/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/segmentedcontrols/Pill.kt @@ -126,6 +126,11 @@ fun PillButton( selected = pillMetaData.selected, interactionSource = interactionSource ) + + val borderColor = token.borderColor(pillButtonInfo = pillButtonInfo) + + val borderWidth = token.borderWidth(pillButtonInfo = pillButtonInfo) + val iconColor = token.iconColor(pillButtonInfo = pillButtonInfo).getColorByState( enabled = pillMetaData.enabled, @@ -174,6 +179,7 @@ fun PillButton( .defaultMinSize(minHeight = token.minHeight(pillButtonInfo)) .clip(shape) .background(backgroundColor, shape) + .border(width = borderWidth, color = borderColor, shape = shape) .then(clickAndSemanticsModifier) .then(if (interactionSource.collectIsFocusedAsState().value || interactionSource.collectIsHoveredAsState().value) focusedBorderModifier else Modifier) .padding(vertical = token.verticalPadding(pillButtonInfo)) diff --git a/fluentui_tablayout/src/main/res/values/strings.xml b/fluentui_tablayout/src/main/res/values/strings.xml index f3d06bb1f..2de3c71d6 100644 --- a/fluentui_tablayout/src/main/res/values/strings.xml +++ b/fluentui_tablayout/src/main/res/values/strings.xml @@ -4,5 +4,11 @@ \u0020 tab %1$d of %2$d - Item %d in list of %d + Item %1$d in list of %2$d + + + Active + + + Inactive \ No newline at end of file diff --git a/fluentui_topappbars/build.gradle b/fluentui_topappbars/build.gradle index a2dc541a8..e7f8a6514 100644 --- a/fluentui_topappbars/build.gradle +++ b/fluentui_topappbars/build.gradle @@ -39,6 +39,9 @@ android { kotlinOptions { jvmTarget = '1.8' } + lintOptions { + abortOnError false + } } dependencies { diff --git a/fluentui_topappbars/src/main/java/com/microsoft/fluentui/search/Searchbar.kt b/fluentui_topappbars/src/main/java/com/microsoft/fluentui/search/Searchbar.kt index 423a5bb37..2fa7e0461 100644 --- a/fluentui_topappbars/src/main/java/com/microsoft/fluentui/search/Searchbar.kt +++ b/fluentui_topappbars/src/main/java/com/microsoft/fluentui/search/Searchbar.kt @@ -14,7 +14,6 @@ import android.util.AttributeSet import android.view.KeyEvent import android.view.View import android.view.inputmethod.InputMethodManager -import android.widget.FrameLayout import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout @@ -22,10 +21,8 @@ import android.widget.RelativeLayout import com.microsoft.fluentui.topappbars.R import com.microsoft.fluentui.appbarlayout.AppBarLayout import com.microsoft.fluentui.theming.FluentUIContextThemeWrapper -import com.microsoft.fluentui.util.DuoSupportUtils import com.microsoft.fluentui.util.inputMethodManager import com.microsoft.fluentui.util.isVisible -import com.microsoft.fluentui.util.activity import com.microsoft.fluentui.util.toggleKeyboardVisibility import com.microsoft.fluentui.view.TemplateView import com.microsoft.fluentui.progress.ProgressBar @@ -156,8 +153,6 @@ open class Searchbar : TemplateView, SearchView.OnQueryTextListener { private var searchView: SearchView? = null private var searchCloseButton: ImageButton? = null private var searchProgress: ProgressBar? = null - private var singleScreenDisplayPixels = 0 - private var screenPos = IntArray(2) override fun onTemplateLoaded() { super.onTemplateLoaded() @@ -169,9 +164,6 @@ open class Searchbar : TemplateView, SearchView.OnQueryTextListener { searchView = findViewInTemplateById(R.id.search_view) searchCloseButton = findViewInTemplateById(R.id.search_close) searchProgress = findViewInTemplateById(R.id.search_progress) - context.activity?.let { - singleScreenDisplayPixels = DuoSupportUtils.getSingleScreenWidthPixels(it) - } // Hide the default search view close button from TalkBack and get rid of the space it takes up. val closeButton = searchView?.findViewById(R.id.search_close_btn) @@ -183,21 +175,6 @@ open class Searchbar : TemplateView, SearchView.OnQueryTextListener { setUnfocusedState() } - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - var widthMeasureSpec = widthMeasureSpec - val viewWidth = MeasureSpec.getSize(widthMeasureSpec) - this.getLocationOnScreen(screenPos) - - // Adjust x coordinate for second screen on Duo - if (screenPos[0] > singleScreenDisplayPixels) - screenPos[0] -= singleScreenDisplayPixels + DuoSupportUtils.DUO_HINGE_WIDTH - - // Adjust for hinge - if (screenPos[0] + viewWidth > singleScreenDisplayPixels) - widthMeasureSpec = MeasureSpec.makeMeasureSpec(singleScreenDisplayPixels - screenPos[0], MeasureSpec.EXACTLY) - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - } - private fun updateViews() { searchView?.queryHint = queryHint searchView?.setSearchableInfo(searchableInfo) diff --git a/fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/AppBar.kt b/fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/AppBar.kt index 679378edf..6d5039b1f 100644 --- a/fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/AppBar.kt +++ b/fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/AppBar.kt @@ -2,13 +2,9 @@ package com.microsoft.fluentui.tokenized import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.BasicText -import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -20,21 +16,20 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.microsoft.fluentui.core.R import com.microsoft.fluentui.icons.ListItemIcons -import com.microsoft.fluentui.icons.SearchBarIcons -import com.microsoft.fluentui.icons.appbaricons.AppBarIcons -import com.microsoft.fluentui.icons.appbaricons.appbaricons.Arrowback import com.microsoft.fluentui.icons.listitemicons.Chevron import com.microsoft.fluentui.theme.FluentTheme import com.microsoft.fluentui.theme.token.* import com.microsoft.fluentui.theme.token.controlTokens.AppBarInfo import com.microsoft.fluentui.theme.token.controlTokens.AppBarSize import com.microsoft.fluentui.theme.token.controlTokens.AppBarTokens +import androidx.compose.runtime.* +import androidx.compose.ui.unit.* +import com.microsoft.fluentui.util.clickableWithTooltip /** * An app bar appears at the top of an app screen, below the status bar, @@ -54,7 +49,6 @@ import com.microsoft.fluentui.theme.token.controlTokens.AppBarTokens * @param subTitle Subtitle to be displayed. Default: [null] * @param logo Composable to be placed at left of Title. Guideline is to not increase a size of 32x32. Default: [null] * @param searchMode Boolean to enable/disable searchMode. Default: [false] - * @param navigationIcon Navigate Back Icon to be placed at extreme left. Default: [SearchBarIcons.Arrowback] * @param postTitleIcon Icon to be placed after title making the title clickable. Default: Empty [FluentIcon] * @param preSubtitleIcon Icon to be placed before subtitle. Default: Empty [FluentIcon] * @param postSubtitleIcon Icon to be placed after subtitle. Default: [ListItemIcons.Chevron] @@ -64,7 +58,10 @@ import com.microsoft.fluentui.theme.token.controlTokens.AppBarTokens * @param bottomBorder Boolean to place a bottom border on AppBar. Applies only when searchBar and bottomBar are empty. Default: [true] * @param appTitleDelta Ratio of opening of appTitle. Used for Shychrome and other animations. Default: [1.0F] * @param accessoryDelta Ratio of opening of accessory View. Used for Shychrome and other animations. Default: [1.0F] + * @param centerAlignAppBar boolean indicating if the app bar should be center aligned. Default: [false] + * @param navigationIcon Navigate Back Icon to be placed at extreme left. Default: [null] * @param appBarTokens Optional Tokens for App Bar to customize it. Default: [null] + * @param secondaryPostTitleIcon Secondary icon to be placed after title. Default: Empty [FluentIcon] */ // TAGS FOR TESTING @@ -72,7 +69,7 @@ const val APP_BAR = "Fluent App bar" const val APP_BAR_SUBTITLE = "Fluent App bar Subtitle" const val APP_BAR_BOTTOM_BAR = "Fluent App bar Bottom bar" const val APP_BAR_SEARCH_BAR = "Fluent App bar Search bar" -@OptIn(ExperimentalTextApi::class) + @Composable fun AppBar( title: String, @@ -82,7 +79,6 @@ fun AppBar( subTitle: String? = null, logo: @Composable (() -> Unit)? = null, searchMode: Boolean = false, - navigationIcon: FluentIcon = FluentIcon(AppBarIcons.Arrowback, flipOnRtl = true), postTitleIcon: FluentIcon = FluentIcon(), preSubtitleIcon: FluentIcon = FluentIcon(), postSubtitleIcon: FluentIcon = FluentIcon( @@ -95,16 +91,18 @@ fun AppBar( bottomBorder: Boolean = true, appTitleDelta: Float = 1.0F, accessoryDelta: Float = 1.0F, - appBarTokens: AppBarTokens? = null + centerAlignAppBar: Boolean = false, + navigationIcon: FluentIcon? = null, + appBarTokens: AppBarTokens? = null, + secondaryPostTitleIcon: FluentIcon = FluentIcon(), ) { val themeID = FluentTheme.themeID //Adding This only for recomposition in case of Token Updates. Unused otherwise. val token = appBarTokens ?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.AppBarControlType] as AppBarTokens - - val appBarInfo = AppBarInfo(style, appBarSize) + val tooltipControls = token.tooltipVisibilityControls(appBarInfo) Box( modifier = modifier .fillMaxWidth() @@ -140,61 +138,69 @@ fun AppBar( .fillMaxWidth() .scale(scaleX = 1.0F, scaleY = appTitleDelta) .alpha(if (appTitleDelta != 1.0F) appTitleDelta / 3 else 1.0F), - horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically ) { - if (appBarSize != AppBarSize.Large && navigationIcon.isIconAvailable()) { + if (navigationIcon !== null && navigationIcon.isIconAvailable()) { Icon( navigationIcon, - modifier = - Modifier.then( - if(navigationIcon.onClick != null) - Modifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(color = token.navigationIconRippleColor()), - enabled = true, - onClick = navigationIcon.onClick ?: {} + modifier = Modifier.clickableWithTooltip( + tooltipText = navigationIcon.contentDescription ?: "", + tooltipEnabled = tooltipControls.enableNavigationIconTooltip, + backgroundColor = token.tooltipBackgroundBrush(appBarInfo), + textStyle = token.tooltipTextStyle(appBarInfo), + cornerRadius = token.tooltipCornerRadius(appBarInfo), + clickRippleColor = token.navigationIconRippleColor(), + onClick = navigationIcon.onClick, + onLongClick = navigationIcon.onLongClick, + offset = token.tooltipOffset(appBarInfo), + timeout = token.tooltipTimeout(appBarInfo), ) - else Modifier - ) .padding(token.navigationIconPadding(appBarInfo)) .size(token.leftIconSize(appBarInfo)), tint = token.navigationIconColor(appBarInfo) ) } - if (appBarSize != AppBarSize.Medium) { - Box( - modifier = Modifier - .then( - if (appBarSize == AppBarSize.Large) - Modifier.padding(start = 16.dp) - else - Modifier - ) - ) { - logo?.invoke() - } - } + logo?.invoke() val titleTextStyle = token.titleTypography(appBarInfo) val subtitleTextStyle = token.subtitleTypography(appBarInfo) + val titleAlignment: Alignment.Horizontal = + if (centerAlignAppBar) Alignment.CenterHorizontally else Alignment.Start - if (appBarSize != AppBarSize.Large && !subTitle.isNullOrBlank()) { + if (appBarSize != AppBarSize.Large) { Column( modifier = Modifier .weight(1F) .padding(token.textPadding(appBarInfo)) - .testTag(APP_BAR_SUBTITLE) + .testTag(APP_BAR_SUBTITLE), + horizontalAlignment = titleAlignment ) { + // title Row( - modifier = Modifier - .then( - if (postTitleIcon.onClick != null && appBarSize == AppBarSize.Small) - Modifier.clickable(onClick = postTitleIcon.onClick!!) - else - Modifier - ), verticalAlignment = Alignment.CenterVertically + modifier = Modifier.clickableWithTooltip( + tooltipText = title, + tooltipEnabled = tooltipControls.enableTitleTooltip, + backgroundColor = token.tooltipBackgroundBrush( + appBarInfo + ), + textStyle = token.tooltipTextStyle(appBarInfo), + cornerRadius = token.tooltipCornerRadius(appBarInfo), + clickRippleColor = token.tooltipRippleColor(appBarInfo), + onClick = { + if (appBarSize == AppBarSize.Small) { + postTitleIcon.onClick?.invoke() + } + }, + onLongClick = { + if (appBarSize == AppBarSize.Small) { + postTitleIcon.onLongClick?.invoke() + } + }, + offset = token.tooltipOffset(appBarInfo), + timeout = token.tooltipTimeout(appBarInfo) + ), + verticalAlignment = Alignment.CenterVertically ) { BasicText( text = title, @@ -204,7 +210,8 @@ fun AppBar( ) ), maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) ) if (postTitleIcon.isIconAvailable() && appBarSize == AppBarSize.Small) Icon( @@ -214,68 +221,116 @@ fun AppBar( .size(token.titleIconSize(appBarInfo)), tint = token.titleIconColor(appBarInfo), ) - } - Row( - modifier = Modifier - .then( - if (postSubtitleIcon.onClick != null) - Modifier.clickable(onClick = postSubtitleIcon.onClick!!) - else - Modifier - ), verticalAlignment = Alignment.CenterVertically - ) { - if (preSubtitleIcon.isIconAvailable()) + + if (secondaryPostTitleIcon.isIconAvailable() && appBarSize == AppBarSize.Small) Icon( - preSubtitleIcon, + secondaryPostTitleIcon.value(), + secondaryPostTitleIcon.contentDescription, modifier = Modifier - .size( - token.subtitleIconSize( - appBarInfo - ) - ), - tint = token.subtitleIconColor(appBarInfo) + .size(token.titleIconSize(appBarInfo)), + tint = token.titleIconColor(appBarInfo), ) - BasicText( - subTitle, - style = subtitleTextStyle.merge( - TextStyle( - color = token.subtitleTextColor( + } + // subtitle + if (!subTitle.isNullOrBlank()) { + Row( + modifier = Modifier.clickableWithTooltip( + tooltipText = subTitle, + tooltipEnabled = tooltipControls.enableSubtitleTooltip, + backgroundColor = token.tooltipBackgroundBrush( appBarInfo - ) + ), + textStyle = token.tooltipTextStyle(appBarInfo), + cornerRadius = token.tooltipCornerRadius(appBarInfo), + clickRippleColor = token.tooltipRippleColor( + appBarInfo + ), + onClick = { + if (appBarSize == AppBarSize.Small) { + preSubtitleIcon.onClick?.invoke() + } + }, + onLongClick = { + if (appBarSize == AppBarSize.Small) { + postSubtitleIcon.onLongClick?.invoke() + } + }, + offset = token.tooltipOffset(appBarInfo), + timeout = token.tooltipTimeout(appBarInfo) + ), + verticalAlignment = Alignment.CenterVertically + ) { + if (preSubtitleIcon.isIconAvailable()) + Icon( + preSubtitleIcon, + modifier = Modifier + .size( + token.subtitleIconSize( + appBarInfo + ) + ), + tint = token.subtitleIconColor(appBarInfo) ) - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (postSubtitleIcon.isIconAvailable()) - Icon( - postSubtitleIcon.value(), - contentDescription = postSubtitleIcon.contentDescription, - modifier = Modifier - .size( - token.subtitleIconSize( + BasicText( + subTitle, + style = subtitleTextStyle.merge( + TextStyle( + color = token.subtitleTextColor( appBarInfo ) - ), - tint = token.subtitleIconColor(appBarInfo) + ) + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis ) + if (postSubtitleIcon.isIconAvailable()) + Icon( + postSubtitleIcon.value(), + contentDescription = postSubtitleIcon.contentDescription, + modifier = Modifier + .size( + token.subtitleIconSize( + appBarInfo + ) + ), + tint = token.subtitleIconColor(appBarInfo) + ) + } } } } else { - BasicText( - text = title, - modifier = Modifier + Column( + modifier = Modifier.clickableWithTooltip( + tooltipText = title, + tooltipEnabled = tooltipControls.enableTitleTooltip, + backgroundColor = token.tooltipBackgroundBrush(appBarInfo), + cornerRadius = token.tooltipCornerRadius(appBarInfo), + textStyle = token.tooltipTextStyle(appBarInfo), + clickRippleColor = token.tooltipRippleColor(appBarInfo), + showRippleOnClick = false, + onClick = {}, + onLongClick = {}, + offset = token.tooltipOffset(appBarInfo), + timeout = token.tooltipTimeout(appBarInfo) + ) .padding(token.textPadding(appBarInfo)) .weight(1F) .semantics { heading() }, - style = titleTextStyle.merge( - TextStyle( - color = token.titleTextColor(appBarInfo) - ) - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + horizontalAlignment = titleAlignment + ) { + + BasicText( + text = title, + style = titleTextStyle.merge( + TextStyle( + color = token.titleTextColor(appBarInfo) + ) + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + } } if (rightAccessoryView != null) { 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 286d30692..37353e033 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 @@ -1,8 +1,8 @@ package com.microsoft.fluentui.tokenized import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* @@ -15,10 +15,10 @@ import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged @@ -73,8 +73,6 @@ import com.microsoft.fluentui.topappbars.R * @param rightAccessoryIcon [FluentIcon] Object which is displayed on the right side of microphone. Default: [null] * @param searchBarTokens Tokens which help in customizing appearance of search bar. Default: [null] */ -// AnimatedContent Backspace Key -@OptIn(ExperimentalComposeUiApi::class, ExperimentalAnimationApi::class) @Composable fun SearchBar( onValueChange: (String, Person?) -> Unit, @@ -90,6 +88,7 @@ fun SearchBar( personaChipOnClick: (() -> Unit)? = null, microphoneCallback: (() -> Unit)? = null, navigationIconCallback: (() -> Unit)? = null, + leftAccessoryIcon: ImageVector? = SearchBarIcons.Search, rightAccessoryIcon: FluentIcon? = null, searchBarTokens: SearchBarTokens? = null ) { @@ -106,8 +105,23 @@ fun SearchBar( var personaChipSelected by rememberSaveable { mutableStateOf(false) } var selectedPerson: Person? = selectedPerson + val borderWidth = token.borderWidth(searchBarInfo) + val elevation = token.elevation(searchBarInfo) + val height = token.height(searchBarInfo) val scope = rememberCoroutineScope() + val borderModifier = if (borderWidth > 0.dp) { + Modifier.border( + width = borderWidth, + color = token.borderColor(searchBarInfo), + shape = RoundedCornerShape(token.cornerRadius(searchBarInfo)) + ) + } else Modifier + val shadowModifier = if (elevation > 0.dp) Modifier.shadow( + elevation = token.elevation(searchBarInfo), + shape = RoundedCornerShape(token.cornerRadius(searchBarInfo)), + spotColor = token.shadowColor(searchBarInfo) + ) else Modifier Row( modifier = modifier @@ -116,12 +130,14 @@ fun SearchBar( ) { Row( Modifier - .requiredHeightIn(min = token.height(searchBarInfo)) + .requiredHeightIn(min = height) + .then(borderModifier) + .then(shadowModifier) .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(token.cornerRadius(searchBarInfo))) .background( token.inputBackgroundBrush(searchBarInfo), - RoundedCornerShape(8.dp) + RoundedCornerShape(token.cornerRadius(searchBarInfo)) ), verticalAlignment = Alignment.CenterVertically ) { @@ -152,11 +168,12 @@ fun SearchBar( if (LocalLayoutDirection.current == LayoutDirection.Rtl) mirrorImage = true } + false -> { onClick = { focusRequester.requestFocus() } - icon = SearchBarIcons.Search + icon = leftAccessoryIcon ?: SearchBarIcons.Search contentDescription = LocalContext.current.resources.getString(R.string.fluentui_search) mirrorImage = false @@ -306,6 +323,7 @@ fun SearchBar( onClick = microphoneCallback ) } + false -> Box( modifier = Modifier diff --git a/fluentui_transients/build.gradle b/fluentui_transients/build.gradle index a5b071dd5..065d18d43 100644 --- a/fluentui_transients/build.gradle +++ b/fluentui_transients/build.gradle @@ -27,6 +27,9 @@ android { proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } + lintOptions { + abortOnError false + } } dependencies { diff --git a/fluentui_transients/src/main/java/com/microsoft/fluentui/snackbar/Snackbar.kt b/fluentui_transients/src/main/java/com/microsoft/fluentui/snackbar/Snackbar.kt index 136752391..8163cf643 100644 --- a/fluentui_transients/src/main/java/com/microsoft/fluentui/snackbar/Snackbar.kt +++ b/fluentui_transients/src/main/java/com/microsoft/fluentui/snackbar/Snackbar.kt @@ -11,7 +11,6 @@ import com.google.android.material.snackbar.BaseTransientBottomBar import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat import androidx.appcompat.widget.AppCompatButton -import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -22,9 +21,7 @@ import com.microsoft.fluentui.transients.R import com.microsoft.fluentui.transients.R.id.* import com.microsoft.fluentui.theming.FluentUIContextThemeWrapper import com.microsoft.fluentui.transients.databinding.ViewSnackbarBinding -import com.microsoft.fluentui.util.DuoSupportUtils import com.microsoft.fluentui.util.ThemeUtil -import com.microsoft.fluentui.util.activity /** * Snackbars provide lightweight feedback about an operation by showing a brief message at the bottom of the screen. @@ -137,55 +134,6 @@ class Snackbar : BaseTransientBottomBar { actionButtonView = binding.snackbarAction updateBackground() - // Set the margin on the FrameLayout (SnackbarLayout) instead of the content because the content's bottom margin is buggy in some APIs. - if (content.parent is FrameLayout) { - context.activity?.let { - if(DuoSupportUtils.isWindowDoublePortrait(it)) { - val singleScreenDisplayPixels = DuoSupportUtils.getSingleScreenWidthPixels(it) - val snackbarLP = getView().layoutParams - snackbarLP.width = singleScreenDisplayPixels - getView().layoutParams = snackbarLP - alignLeft(parent) - } - } - } - } - - /** - * This is adapted from android.support.design.widget.Snackbar - * It ensures we can use Snackbars in complex ViewGroups like RecyclerView. - */ - private fun alignLeft(view: View) { - var currentView: View? = view - var fallbackParent: ViewGroup? = null - - do { - if (currentView is CoordinatorLayout) { - // We've found a CoordinatorLayout, use it - val params = getView().layoutParams as CoordinatorLayout.LayoutParams - params.gravity = Gravity.BOTTOM - getView().layoutParams = params - return - } - - if (currentView is FrameLayout) - if (currentView.id == android.R.id.content) { - // If we've hit the decor content view, then we didn't find a CoL in the - // hierarchy, so use it. - val params = getView().layoutParams as FrameLayout.LayoutParams - params.gravity = Gravity.BOTTOM - view.layoutParams = params - return - } else - // It's not the content view but we'll use it as our fallback - fallbackParent = currentView - - // Else, we will loop and crawl up the view hierarchy and try to find a parent - currentView = currentView?.parent as? View - } while (currentView != null) - - // If we reach here then we didn't find a CoL or a suitable content view so we'll fallback - return } /** @@ -216,7 +164,8 @@ class Snackbar : BaseTransientBottomBar { actionButtonView.visibility = View.VISIBLE actionButtonView.setOnClickListener { view -> listener.onClick(view) - dismiss() + // dismiss the Snackbar + dispatchDismiss(BaseCallback.DISMISS_EVENT_ACTION) } updateStyle() diff --git a/fluentui_transients/src/main/java/com/microsoft/fluentui/tooltip/Tooltip.kt b/fluentui_transients/src/main/java/com/microsoft/fluentui/tooltip/Tooltip.kt index 468b70cb1..639ffb5ed 100644 --- a/fluentui_transients/src/main/java/com/microsoft/fluentui/tooltip/Tooltip.kt +++ b/fluentui_transients/src/main/java/com/microsoft/fluentui/tooltip/Tooltip.kt @@ -162,7 +162,6 @@ class Tooltip { initTooltipArrow(anchorRect, anchor.layoutIsRtl, config.offsetX) checkEdgeCase(anchorRect) - hingeSupport(anchorRect, config.touchDismissLocation) if (requireReinit) initTooltipArrow(anchorRect, anchor.layoutIsRtl, config.offsetX) if (requireReadjustment) readjustTooltip(anchorRect, anchor.layoutIsRtl, config) @@ -226,12 +225,6 @@ class Tooltip { private fun setPositionX(anchorCenter: Int, offsetX: Int) { positionX = anchorCenter - contentWidth / 2 + offsetX - // Duo Second Screen Support - val secondScreen = anchorCenter > displayWidth && context.activity?.let { - DuoSupportUtils.isDeviceSurfaceDuo(it) - } ?: false - if (secondScreen) positionX -= displayWidth + DuoSupportUtils.DUO_HINGE_WIDTH - // Navigation Bar in Nougat+ can appear on the left on phones at 270 rotation and adds // its height to the left of the display creating an offset that needs to be corrected to get // accurate horizontal position. @@ -249,18 +242,11 @@ class Tooltip { private fun setPositionY(anchor: Rect, offsetY: Int, dismissLocation: TouchDismissLocation) { positionY = anchor.bottom - // Duo Second Screen Support - val secondScreen = anchor.bottom > displayHeight && context.activity?.let { - DuoSupportUtils.isDeviceSurfaceDuo(it) - } ?: false - if (secondScreen) positionY -= displayHeight + DuoSupportUtils.DUO_HINGE_WIDTH - isAboveAnchor = context.activity?.let { positionY + contentHeight + margin > displayHeight } ?: false if (isAboveAnchor) { positionY = anchor.top - contentHeight - offsetY - if (secondScreen) positionY -= displayHeight + DuoSupportUtils.DUO_HINGE_WIDTH } } @@ -302,10 +288,7 @@ class Tooltip { val layoutParams = toolTipArrow.layoutParams as LinearLayout.LayoutParams val cornerRadius = context.resources.getDimensionPixelSize(R.dimen.fluentui_tooltip_radius) if (!isSideAnchor) { // Normal Top/Bottom arrow - val anchorCenterX = if (anchorRect.centerX() > displayWidth && context.activity?.let { - DuoSupportUtils.isDeviceSurfaceDuo(it) - } == true) anchorRect.centerX() - displayWidth - DuoSupportUtils.DUO_HINGE_WIDTH - else anchorRect.centerX() + val anchorCenterX = anchorRect.centerX() val offset = if (isRTL) positionX + contentWidth - anchorCenterX - tooltipArrowWidth @@ -316,10 +299,6 @@ class Tooltip { } else {// Edge Case Left/Right arrow layoutParams.gravity = Gravity.TOP var topMargin = anchorRect.centerY() - positionY - tooltipArrowWidth - val secondScreen = anchorRect.top > displayHeight && context.activity?.let { - DuoSupportUtils.isDeviceSurfaceDuo(it) - } ?: false - topMargin -= if (secondScreen) displayHeight else 0 if (positionY + contentHeight >= displayHeight) topMargin -= cornerRadius layoutParams.topMargin = topMargin } @@ -344,63 +323,19 @@ class Tooltip { val leftEdge = (startPosition - cornerRadius - margin - context.softNavBarOffsetX < 0) || (doesNotFitAboveOrBelow && anchorRect.left < rightSpace) - // Duo Support - val secondScreen = anchorRect.left > displayWidth && context.activity?.let { - DuoSupportUtils.isDeviceSurfaceDuo(it) - } ?: false if (leftEdge) { // checks if the arrow is cut by the left edge of the screen and sets positionX to the left of the anchor with proper width. positionX = anchorRect.right - if (secondScreen) positionX -= displayWidth + DuoSupportUtils.DUO_HINGE_WIDTH } if (rightEdge) { // checks if the arrow is cut by the right edge of the screen and sets positionX to the left of the anchor with proper width. isAboveAnchor = true // Enables right arrow positionX = anchorRect.left - contentWidth - upArrowWidth / 2 - if (secondScreen) positionX -= displayWidth + DuoSupportUtils.DUO_HINGE_WIDTH } if (leftEdge || rightEdge) requireReadjustment = true } - private fun hingeSupport(anchorRect: Rect, dismissLocation: TouchDismissLocation) { - context.activity?.let { - val upArrowWidth = - context.resources.getDimensionPixelSize(R.dimen.fluentui_tooltip_arrow_width) - val tooltipRect = Rect( - positionX, - positionY, - positionX + contentWidth, - positionY + contentHeight - upArrowWidth / 2 - ) - val anchorIntersects = DuoSupportUtils.intersectHinge(it, anchorRect) - val tooltipIntersects = DuoSupportUtils.intersectHinge(it, tooltipRect) - - if (anchorIntersects || tooltipIntersects) { - if (DuoSupportUtils.isWindowDoublePortrait(it)) { - isAboveAnchor = false // Enables left arrow - if (DuoSupportUtils.moreOnLeft(it, anchorRect)) { - isAboveAnchor = true // Enables right arrow - positionX = anchorRect.left - contentWidth - upArrowWidth / 2 - } else { - positionX = anchorRect.right - } - requireReadjustment = true - } else { // Device is in vertical orientation - // Usually the tooltip will occur below the anchor, so the tooltip will intersect only in case its in top screen - // In such case, we make tooltip on the top of the anchor. - if (tooltipIntersects) { - isAboveAnchor = true - isSideAnchor = false - positionY = anchorRect.top - contentHeight - if (dismissLocation == TouchDismissLocation.INSIDE) positionY -= context.statusBarHeight - requireReinit = true - } - } - } - } - } - private fun readjustTooltip(anchorRect: Rect, isRTL: Boolean, config: Config) { val upArrowWidth = context.resources.getDimensionPixelSize(R.dimen.fluentui_tooltip_arrow_width) @@ -427,10 +362,6 @@ class Tooltip { // Otherwise sets positionY such that the content ends at the bottom of anchor else anchorRect.bottom - contentHeight - val secondScreen = anchorRect.top > displayHeight && context.activity?.let { - DuoSupportUtils.isDeviceSurfaceDuo(it) - } ?: false - positionY -= if (secondScreen) displayHeight else 0 // Readjusts positionY if it crosses AppBar on the top if (positionY < topBarHeight + context.statusBarHeight) @@ -438,20 +369,6 @@ class Tooltip { if (config.touchDismissLocation == TouchDismissLocation.INSIDE) positionY -= context.statusBarHeight - // Readjustment for Duo hinge - val tooltipRect = - Rect(positionX, positionY, positionX + contentWidth, positionY + contentHeight) - context.activity?.let { - if (DuoSupportUtils.intersectHinge(it, tooltipRect)) { - positionY = if (DuoSupportUtils.moreOnTop(it, anchorRect)) { - DuoSupportUtils.getHinge(it)!!.top - contentHeight - margin + cornerRadius - } else { - DuoSupportUtils.getHinge(it)!!.bottom + margin - cornerRadius - } - isAboveAnchor = tooltipRect.left < anchorRect.left - } - } - // Reinitialize tooltip with side arrow initTooltipArrow(anchorRect, isRTL, config.offsetX) if (config.touchDismissLocation == TouchDismissLocation.INSIDE) diff --git a/publish.gradle b/publish.gradle index f6c0363fa..68d7e625f 100644 --- a/publish.gradle +++ b/publish.gradle @@ -36,7 +36,7 @@ project.ext.publishingFunc = { artifactIdName -> url rootProject.buildDir.path + '/artifacts' } } - tasks.withType(PublishToMavenRepository) { + tasks.withType(PublishToMavenRepository){ onlyIf { (repository == publishing.repositories.local && !(artifactExists("central", artifactIdName, android.defaultConfig.versionName))) || (repository == publishing.repositories.feed && !(artifactExists("feed", artifactIdName, android.defaultConfig.versionName))) }