V2 Peoplepicker implementation (#445)

* peoplepicker impl

* Peoplepicker Impl

* trailing content for ppl picker

* activity changes

* added peoplepicker remember saveable to save state

* using textfield for pplPicker

* removing textfield dependency

* adding api doc

* review changes and adding UI testcases

* personachip accessibility

* refactoring tokens file

* removing unwanted files

* ui test updated

---------

Co-authored-by: PraveenKumar <pyeruva@microsoft.com>
This commit is contained in:
PraveenKumar yeruva 2023-07-18 17:12:43 +05:30 коммит произвёл GitHub
Родитель 5292b576a3
Коммит 802d07ce53
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
11 изменённых файлов: 966 добавлений и 2 удалений

Просмотреть файл

@ -18,6 +18,7 @@ import org.junit.runners.Suite
V2DrawerActivityUITest::class,
V2LabelUITest::class,
V2ListItemActivityUITest::class,
V2PeoplePickerUITest::class,
V2PersonaUITest::class,
V2PersonaChipActivityUITest::class,
V2PersonaListActivityUITest::class,

Просмотреть файл

@ -0,0 +1,159 @@
package com.microsoft.fluentuidemo.demos
import android.view.KeyEvent.KEYCODE_BACK
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.NativeKeyEvent
import androidx.compose.ui.input.key.nativeKeyCode
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotFocused
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyPress
import androidx.compose.ui.test.printToLog
import com.microsoft.fluentui.tokenized.peoplepicker.PeoplePicker
import com.microsoft.fluentui.tokenized.peoplepicker.PeoplePickerItemData
import com.microsoft.fluentui.tokenized.peoplepicker.rememberPeoplePickerItemDataList
import com.microsoft.fluentui.tokenized.persona.Person
import org.junit.Rule
import org.junit.Test
class V2PeoplePickerUITest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testV2PeoplePicker() {
composeTestRule.setContent {
PeoplePicker(modifier = Modifier.testTag("V2PeoplePicker"), onValueChange = { _, _ ->
})
}
composeTestRule.onRoot().printToLog("testPeoplePickerChip")
val peoplePicker = composeTestRule.onNodeWithTag("V2PeoplePicker")
peoplePicker.assertExists()
peoplePicker.assertIsDisplayed()
}
@Test
fun testV2PeoplePickerTextField() {
composeTestRule.setContent {
PeoplePicker(modifier = Modifier.testTag("V2PeoplePicker"), onValueChange = { _, _ ->
})
}
composeTestRule.onRoot().printToLog("testPeoplePickerChip")
val textField = composeTestRule.onNodeWithTag("Text field")
textField.assertExists()
textField.assertIsDisplayed()
textField.assertIsNotFocused()
textField.performClick()
textField.assertIsFocused()
}
@Test
fun testPeoplePickerChip() {
composeTestRule.setContent {
PeoplePicker(
modifier = Modifier.testTag("V2PeoplePicker"),
onValueChange = { _, _ ->
},
selectedPeopleList = mutableListOf(
PeoplePickerItemData(
Person("firstName"),
mutableStateOf(false)
)
),
)
}
val peoplePickerChip = composeTestRule.onNodeWithContentDescription("firstName", true)
peoplePickerChip.assertExists()
peoplePickerChip.assertIsDisplayed()
peoplePickerChip.assertHasClickAction()
}
@Test
fun testPeoplePickerChipActions() {
composeTestRule.setContent {
var selectedPeopleList = rememberPeoplePickerItemDataList()
selectedPeopleList.add(
PeoplePickerItemData(
Person("firstName"),
mutableStateOf(false)
)
)
PeoplePicker(
modifier = Modifier.testTag("V2PeoplePicker"),
onValueChange = { _, _ ->
},
selectedPeopleList = selectedPeopleList,
onChipClick = {
it.selected.value = !it.selected.value
},
onChipCloseClick = {
selectedPeopleList.remove(it)
}
)
}
val peoplePickerChip = composeTestRule.onNodeWithText("firstName", true, useUnmergedTree = true)
peoplePickerChip.performClick()
val cancelButton = composeTestRule.onNodeWithContentDescription("Close", useUnmergedTree = true)
cancelButton.assertExists()
cancelButton.assertIsDisplayed()
cancelButton.assertHasClickAction()
cancelButton.performClick()
peoplePickerChip.assertDoesNotExist()
}
@OptIn(ExperimentalComposeUiApi::class)
@Test
fun testPeoplePickerBackPress() {
composeTestRule.setContent {
var selectedPeopleList = rememberPeoplePickerItemDataList()
selectedPeopleList.add(
PeoplePickerItemData(
Person("firstName"),
mutableStateOf(false)
)
)
PeoplePicker(
modifier = Modifier.testTag("V2PeoplePicker"),
onValueChange = { _, _ ->
},
selectedPeopleList = selectedPeopleList,
onBackPress = { _, it ->
selectedPeopleList.remove(it)
}
)
}
val textField = composeTestRule.onNodeWithTag("Text field")
textField.assertExists()
textField.assertIsDisplayed()
val peoplePickerChip = composeTestRule.onNodeWithText("firstName", true, useUnmergedTree = true)
peoplePickerChip.assertExists()
peoplePickerChip.assertIsDisplayed()
textField.performClick()
textField.performKeyPress(
KeyEvent(
NativeKeyEvent(
KEYCODE_BACK,
Key.Backspace.nativeKeyCode
)
)
)
peoplePickerChip.assertDoesNotExist()
}
}

Просмотреть файл

@ -44,6 +44,7 @@
<activity android:name="com.microsoft.fluentuidemo.demos.V2ListItemActivity" />
<activity android:name="com.microsoft.fluentuidemo.demos.V2MenuActivity"
android:windowSoftInputMode="adjustResize" />
<activity android:name="com.microsoft.fluentuidemo.demos.V2PeoplePickerActivity" />
<activity android:name="com.microsoft.fluentuidemo.demos.V2PersonaActivity" />
<activity android:name="com.microsoft.fluentuidemo.demos.V2PersonaChipActivity" />
<activity android:name="com.microsoft.fluentuidemo.demos.V2PersonaListActivity" />

Просмотреть файл

@ -25,6 +25,7 @@ const val V2DIALOG = "V2 Dialog"
const val V2DRAWER = "V2 Drawer"
const val V2LIST_ITEM = "V2 ListItem"
const val V2MENU = "V2 Menu"
const val V2PEOPLE_PICKER = "V2 People Picker"
const val V2PERSONA = "V2 Persona"
const val V2PERSONA_CHIP = "V2 PersonaChip"
const val V2PERSONA_LIST = "V2 PersonaList"
@ -82,6 +83,7 @@ val DEMOS = arrayListOf(
Demo(V2LABEL, V2LabelActivity::class),
Demo(V2LIST_ITEM, V2ListItemActivity::class),
Demo(V2MENU, V2MenuActivity::class),
Demo(V2PEOPLE_PICKER, V2PeoplePickerActivity::class),
Demo(V2PERSONA, V2PersonaActivity::class),
Demo(V2PERSONA_CHIP, V2PersonaChipActivity::class),
Demo(V2PERSONA_LIST, V2PersonaListActivity::class),

Просмотреть файл

@ -0,0 +1,258 @@
package com.microsoft.fluentuidemo.demos
import android.os.Bundle
import android.view.LayoutInflater
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Person
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.microsoft.fluentui.theme.FluentTheme
import com.microsoft.fluentui.theme.token.FluentAliasTokens
import com.microsoft.fluentui.theme.token.Icon
import com.microsoft.fluentui.theme.token.controlTokens.AvatarStatus
import com.microsoft.fluentui.theme.token.controlTokens.PersonaChipStyle
import com.microsoft.fluentui.tokenized.controls.Label
import com.microsoft.fluentui.tokenized.notification.Snackbar
import com.microsoft.fluentui.tokenized.notification.SnackbarState
import com.microsoft.fluentui.tokenized.peoplepicker.PeoplePicker
import com.microsoft.fluentui.tokenized.peoplepicker.PeoplePickerItemData
import com.microsoft.fluentui.tokenized.peoplepicker.rememberPeoplePickerItemDataList
import com.microsoft.fluentui.tokenized.persona.AvatarGroup
import com.microsoft.fluentui.tokenized.persona.Group
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.DemoActivity
import com.microsoft.fluentuidemo.R
import com.microsoft.fluentuidemo.databinding.V2ActivityComposeBinding
import kotlinx.coroutines.launch
class V2PeoplePickerActivity : DemoActivity() {
override val contentNeedsScrollableContainer: Boolean
get() = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val v2ActivityComposeBinding = V2ActivityComposeBinding.inflate(
LayoutInflater.from(container.context),
container,
true
)
v2ActivityComposeBinding.composeHere.setContent {
FluentTheme {
CreatePeoplePickerActivity()
}
}
}
@Composable
private fun CreatePeoplePickerActivity() {
val people = mutableListOf(
Person(
"Allan", "Munger",
image = R.drawable.avatar_allan_munger,
email = "allan.munger@xyz.com",
isActive = true
),
Person(
"Amanda", "Brady",
email = "amanda.brady@xyz.com",
isActive = false, status = AvatarStatus.Offline
),
Person(
"Abhay", "Singh",
email = "abhay.singh@xyz.com",
isActive = true, status = AvatarStatus.DND, isOOO = true
),
Person(
"Carlos", "Slathery",
email = "carlos.slathery@xyz.com",
isActive = false, status = AvatarStatus.Busy, isOOO = true
),
Person(
"Celeste", "Burton",
email = "celeste.burton@xyz.com",
image = R.drawable.avatar_celeste_burton,
isActive = true, status = AvatarStatus.Away
),
Person(
"Ankit", "Gupta",
email = "ankit.gupta@xyz.com",
isActive = true, status = AvatarStatus.Unknown
),
Person(
"Miguel", "Garcia",
email = "miguel.garcia@xyz.com",
image = R.drawable.avatar_miguel_garcia,
isActive = true, status = AvatarStatus.Blocked
)
)
var selectedPeopleList = rememberPeoplePickerItemDataList()
val snackbarState = remember { SnackbarState() }
val scope = rememberCoroutineScope()
var suggested by rememberSaveable { mutableStateOf(people) }
var suggestedPersonaList = mutableListOf<Persona>()
var selectedPersonList = mutableListOf<Person>()
var errorPeopleList = mutableListOf<Person>()
var assistiveText by rememberSaveable { mutableStateOf(true) }
var errorText by rememberSaveable { mutableStateOf(false) }
Column {
Row(modifier = Modifier.padding(8.dp)) {
PeoplePicker(
onValueChange = { query, selectedPerson ->
scope.launch {
suggested = people.filter {
it.firstName.lowercase().contains(query.lowercase()) ||
it.lastName.lowercase().contains(query.lowercase())
} as MutableList<Person>
}
},
selectedPeopleList = selectedPeopleList,
chipValidation = {
if (!it.email.isNullOrBlank()) {
if (it.email?.contains("@") == true)
PersonaChipStyle.Neutral
else {
errorText = true
errorPeopleList.add(it)
PersonaChipStyle.Danger
}
} else {
errorText = true
errorPeopleList.add(it)
PersonaChipStyle.Danger
}
},
modifier = Modifier.weight(1f),
onChipClick = {
scope.launch {
it.selected.value = !it.selected.value
snackbarState.showSnackbar("Clicked ${it.person.firstName} ${it.person.lastName}")
}
},
onChipCloseClick = {
selectedPeopleList.remove(it)
run outer@{
errorPeopleList.forEach { errorPerson ->
if (errorPerson == it.person) {
errorPeopleList.remove(errorPerson)
return@outer
}
}
}
if (errorPeopleList.isEmpty())
errorText = false
},
onTextEntered = { queryText ->
if (queryText.isNotBlank() && queryText.isNotEmpty()) {
selectedPeopleList.add(
PeoplePickerItemData(
Person(queryText, ""),
mutableStateOf(false)
)
)
}
},
onBackPress = { queryText, it ->
if (queryText.isEmpty() && it != null) {
if (!it.selected.value) {
it.selected.value = !it.selected.value
} else {
selectedPeopleList.remove(it)
errorPeopleList.forEach { errorPerson ->
if (errorPerson == it.person)
errorPeopleList.remove(errorPerson)
}
if (errorPeopleList.isEmpty())
errorText = false
}
}
},
leadingAccessoryContent = {
Icon(
Icons.Filled.Person,
contentDescription = "Person",
modifier = Modifier
.size(24.dp)
)
},
trailingAccessoryContent = {
Icon(
Icons.Filled.Add,
contentDescription = "Add",
modifier = Modifier
.size(24.dp)
)
},
label = "People Picker",
searchHint = "Search People",
assistiveText = if (assistiveText) "This is a sample Assistive Text" else null,
errorString = if (errorText) "This is a sample Error text" else null,
)
}
suggested.forEach outer@{
selectedPeopleList.forEach { selectedPerson ->
if (selectedPerson.person.email == it.email) {
return@outer
}
}
suggestedPersonaList.add(
Persona(
it,
"${it.firstName} ${it.lastName}",
subTitle = it.email,
onClick = {
selectedPeopleList.add(PeoplePickerItemData(it, mutableStateOf(false)))
scope.launch {
snackbarState.showSnackbar(
"Added ${it.firstName} ${it.lastName}",
enableDismiss = true
)
}
}
)
)
}
selectedPeopleList.forEach {
selectedPersonList.add(it.person)
}
Column {
PersonaList(personas = suggestedPersonaList, modifier = Modifier.padding(8.dp))
Spacer(modifier = Modifier.height(8.dp))
Label(
modifier = Modifier.padding(8.dp),
text = "Selected People from people picker",
textStyle = FluentAliasTokens.TypographyTokens.Body1
)
AvatarGroup(group = Group(selectedPersonList), modifier = Modifier.padding(8.dp))
Box(Modifier.fillMaxHeight(), contentAlignment = Alignment.Center) {
Snackbar(snackbarState, Modifier.padding(bottom = 12.dp))
}
}
}
}
}

Просмотреть файл

@ -53,6 +53,7 @@ open class ControlTokens : IControlTokens {
LinearProgressIndicator,
ListItem,
Menu,
PeoplePicker,
PersonaChip,
PillButton,
PillBar,
@ -97,6 +98,7 @@ open class ControlTokens : IControlTokens {
ControlType.ListItem -> ListItemTokens()
ControlType.Menu -> MenuTokens()
ControlType.PersonaChip -> PersonaChipTokens()
ControlType.PeoplePicker -> PeoplePickerTokens()
ControlType.PillButton -> PillButtonTokens()
ControlType.PillBar -> PillBarTokens()
ControlType.PillSwitch -> PillSwitchTokens()

Просмотреть файл

@ -0,0 +1,138 @@
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.text.TextStyle
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
data class PeoplePickerInfo(
val isStatusError: Boolean = false,
val isFocused: Boolean = false
) : ControlInfo
@Parcelize
open class PeoplePickerTokens : IControlToken, Parcelable {
@Composable
open fun backgroundBrush(peoplePickerInfo: PeoplePickerInfo): Brush {
return SolidColor(
FluentTheme.aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value(
themeMode = FluentTheme.themeMode
)
)
}
@Composable
open fun leadingAccessorySpacing(peoplePickerInfo: PeoplePickerInfo): Dp {
return FluentGlobalTokens.size(FluentGlobalTokens.SizeTokens.Size160)
}
@Composable
open fun trailingAccessoryPadding(peoplePickerInfo: PeoplePickerInfo): PaddingValues {
return PaddingValues(end = FluentGlobalTokens.size(FluentGlobalTokens.SizeTokens.Size160))
}
@Composable
open fun chipSpacing(peoplePickerInfo: PeoplePickerInfo): Dp {
return FluentGlobalTokens.size(FluentGlobalTokens.SizeTokens.Size80)
}
@Composable
open fun minHeight(peoplePickerInfo: PeoplePickerInfo): Dp {
return FluentGlobalTokens.size(FluentGlobalTokens.SizeTokens.Size480)
}
@Composable
open fun textColor(peoplePickerInfo: PeoplePickerInfo): Color {
return FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground1].value(
themeMode = FluentTheme.themeMode
)
}
@Composable
open fun textFieldTypography(peoplePickerInfo: PeoplePickerInfo): TextStyle {
return FluentTheme.aliasTokens.typography[FluentAliasTokens.TypographyTokens.Body1]
}
@Composable
open fun cursorBrush(peoplePickerInfo: PeoplePickerInfo): Brush {
return SolidColor(
FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground3].value(
themeMode = FluentTheme.themeMode
)
)
}
@Composable
open fun labelTypography(peoplePickerInfo: PeoplePickerInfo): TextStyle {
return FluentTheme.aliasTokens.typography[FluentAliasTokens.TypographyTokens.Caption2]
}
@Composable
open fun labelColor(peoplePickerInfo: PeoplePickerInfo): Color {
return if (peoplePickerInfo.isStatusError)
FluentTheme.aliasTokens.errorAndStatusColor[FluentAliasTokens.ErrorAndStatusColorTokens.DangerForeground1].value()
else if (peoplePickerInfo.isFocused)
FluentTheme.aliasTokens.brandForegroundColor[FluentAliasTokens.BrandForegroundColorTokens.BrandForeground1].value()
else
FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground2].value()
}
@Composable
open fun assistiveTextColor(peoplePickerInfo: PeoplePickerInfo): Color {
return if (peoplePickerInfo.isStatusError)
FluentTheme.aliasTokens.errorAndStatusColor[FluentAliasTokens.ErrorAndStatusColorTokens.DangerForeground1].value()
else
FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground2].value()
}
@Composable
open fun assistiveTextTypography(peoplePickerInfo: PeoplePickerInfo): TextStyle {
return FluentTheme.aliasTokens.typography[FluentAliasTokens.TypographyTokens.Caption2]
}
@Composable
open fun assistiveTextPadding(peoplePickerInfo: PeoplePickerInfo): PaddingValues {
return PaddingValues(
top = FluentGlobalTokens.size(FluentGlobalTokens.SizeTokens.Size40),
bottom = FluentGlobalTokens.size(FluentGlobalTokens.SizeTokens.Size40)
)
}
@Composable
open fun hintColor(peoplePickerInfo: PeoplePickerInfo): Color {
return FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground2].value()
}
@Composable
open fun hintTextTypography(peoplePickerInfo: PeoplePickerInfo): TextStyle {
return FluentTheme.aliasTokens.typography[FluentAliasTokens.TypographyTokens.Body1]
}
open fun strokeWidth(peoplePickerInfo: PeoplePickerInfo): Dp =
FluentGlobalTokens.strokeWidth(FluentGlobalTokens.StrokeWidthTokens.StrokeWidth05)
@Composable
open fun dividerBrush(peoplePickerInfo: PeoplePickerInfo): Brush {
return SolidColor(
if (peoplePickerInfo.isStatusError)
FluentTheme.aliasTokens.errorAndStatusColor[FluentAliasTokens.ErrorAndStatusColorTokens.DangerForeground1].value()
else if (peoplePickerInfo.isFocused)
FluentTheme.aliasTokens.brandStroke[FluentAliasTokens.BrandStrokeColorTokens.BrandStroke1].value()
else
FluentTheme.aliasTokens.neutralStrokeColor[FluentAliasTokens.NeutralStrokeColorTokens.Stroke2].value()
)
}
}

Просмотреть файл

@ -238,4 +238,12 @@ open class PersonaChipTokens : IControlToken, Parcelable {
open fun avatarSize(personaChipInfo: PersonaChipControlInfo): AvatarSize {
return AvatarSize.Size16
}
@Composable
open fun maxHeight(personaChipInfo: PersonaChipControlInfo): Dp {
return when (personaChipInfo.size) {
Small -> FluentGlobalTokens.size(FluentGlobalTokens.SizeTokens.Size200)
Medium -> FluentGlobalTokens.size(FluentGlobalTokens.SizeTokens.Size240)
}
}
}

Просмотреть файл

@ -21,6 +21,7 @@ android {
}
buildFeatures {
viewBinding true
compose true
}
lintOptions {
abortOnError false
@ -31,6 +32,20 @@ android {
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
composeOptions {
kotlinCompilerExtensionVersion composeVersion
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
useIR = true
}
buildFeatures {
compose true
}
testOptions {
unitTests {
includeAndroidResources = true
@ -59,6 +74,11 @@ dependencies {
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:$composeVersion")
implementation("androidx.compose.runtime:runtime:$composeVersion")
implementation("androidx.compose.ui:ui:$composeVersion")
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$extJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"

Просмотреть файл

@ -0,0 +1,362 @@
package com.microsoft.fluentui.tokenized.peoplepicker
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.LayoutDirection
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.AvatarStatus
import com.microsoft.fluentui.theme.token.controlTokens.DividerInfo
import com.microsoft.fluentui.theme.token.controlTokens.DividerTokens
import com.microsoft.fluentui.theme.token.controlTokens.PeoplePickerInfo
import com.microsoft.fluentui.theme.token.controlTokens.PeoplePickerTokens
import com.microsoft.fluentui.theme.token.controlTokens.PersonaChipStyle
import com.microsoft.fluentui.tokenized.divider.Divider
import com.microsoft.fluentui.tokenized.persona.Person
import com.microsoft.fluentui.tokenized.persona.PersonaChip
import kotlinx.coroutines.launch
/**
* API to create a customized PeoplePicker for users to add a list of PersonaChips
*
* Whenever the user edits the text or a new PersonaChip is added onValueChange is called with the most up to date data
* with which developer is expected to update their state.
*
* PeoplePicker uses [PeoplePickerItemData] to represent a PersonaChip. This is a wrapper around [Person].
*
* Note: Use rememberPeoplePickerState function on selectedPeople list to create a rememberSaveable state for PeoplePicker.
*
* @param selectedPeopleList List of PersonaChips to be shown in PeoplePicker.
* @param onValueChange The callback that is triggered when the input service updates the text or [selectedPeopleList].
* An updated text and List of selectedPeople comes as a parameter of the callback
* @param modifier Optional modifier for the TextField
* @param onBackPress The callback that is triggered when the back button is pressed.
* @param onChipClick The callback that is triggered when a PersonaChip is clicked.
* @param onChipCloseClick The callback that is triggered when the close button of a PersonaChip is clicked.
* Note: use this callback to show the cancel button for a persona chip. To disable/not show the close button leave this callback as null.
* @param chipValidation The callback that is triggered when a PersonaChip is added. This callback is used to validate
* the PersonaChip before adding it to the list of selectedPeople.
* @param onTextEntered The callback that is triggered when the user clicks done on the keyboard.
* @param leadingAccessoryContent The content to be placed towards the start of PeoplePicker.
* @param trailingAccessoryContent The content to be placed towards the end of PeoplePicker.
* @param label String which acts as a description for the PeoplePicker.
* @param assistiveText String which assists users with the PeoplePicker
* @param errorString String to describe the error. PeoplePicker goes in error mode if this is provided.
* @param searchHint String to be shown as hint when the PeoplePicker is in rest state.
* @param peoplePickerTokens Customization options for the PeoplePicker.
*/
@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
@Composable
fun PeoplePicker(
selectedPeopleList: MutableList<PeoplePickerItemData> = mutableStateListOf(),
onValueChange: (String, MutableList<PeoplePickerItemData>) -> Unit,
modifier: Modifier = Modifier,
onBackPress: ((String, PeoplePickerItemData?) -> Unit)? = null,
onChipClick: ((PeoplePickerItemData) -> Unit)? = null,
onChipCloseClick: ((PeoplePickerItemData) -> Unit)? = null,
chipValidation: (Person) -> PersonaChipStyle = { PersonaChipStyle.Neutral },
onTextEntered: ((String) -> Unit)? = null,
leadingAccessoryContent: (@Composable () -> Unit)? = null,
trailingAccessoryContent: (@Composable () -> Unit)? = null,
label: String? = null,
assistiveText: String? = null,
errorString: String? = null,
searchHint: String? = null,
peoplePickerTokens: PeoplePickerTokens? = null
) {
val themeID =
FluentTheme.themeID //Adding This only for recomposition in case of Token Updates. Unused otherwise.
val token = peoplePickerTokens
?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.PeoplePicker] as PeoplePickerTokens
var isFocused: Boolean by rememberSaveable { mutableStateOf(false) }
val peoplePickerInfo = PeoplePickerInfo(
isStatusError = !errorString.isNullOrBlank(),
isFocused = isFocused,
)
val leadingAccessorySpacing = token.leadingAccessorySpacing(peoplePickerInfo = peoplePickerInfo)
val trailingAccessoryPadding =
token.trailingAccessoryPadding(peoplePickerInfo = peoplePickerInfo)
val chipSpacing = token.chipSpacing(peoplePickerInfo = peoplePickerInfo)
val textTypography = token.textFieldTypography(peoplePickerInfo)
val textColor = token.textColor(peoplePickerInfo = peoplePickerInfo)
val cursorBrush = token.cursorBrush(peoplePickerInfo = peoplePickerInfo)
val focusRequester = remember { FocusRequester() }
val bringIntoViewRequester = remember { BringIntoViewRequester() }
var queryText by rememberSaveable { mutableStateOf("") }
var selectedPeopleListSize by rememberSaveable { mutableStateOf(0) }
val scope = rememberCoroutineScope()
Row(
modifier = modifier
.onFocusChanged { focusState ->
when {
focusState.isFocused -> {
focusRequester.requestFocus()
}
}
}, verticalAlignment = Alignment.CenterVertically
) {
if (leadingAccessoryContent != null) {
leadingAccessoryContent()
Spacer(modifier = Modifier.width(leadingAccessorySpacing))
}
Column {
if (!label.isNullOrBlank()) {
Spacer(Modifier.requiredHeight(12.dp))
BasicText(
label,
style = token.labelTypography(peoplePickerInfo).merge(
TextStyle(
color = token.labelColor(peoplePickerInfo)
)
)
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(token.minHeight(peoplePickerInfo = peoplePickerInfo)),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.weight(1f)
.horizontalScroll(rememberScrollState())
.onKeyEvent {
if (it.key == Key.Backspace) {
if (onBackPress != null) {
onBackPress.invoke(queryText, selectedPeopleList.lastOrNull())
onValueChange(queryText, selectedPeopleList)
}
}
true
},
verticalAlignment = Alignment.CenterVertically,
) {
if (selectedPeopleList.isNotEmpty()) {
selectedPeopleList.forEach {
PersonaChip(person = it.person, selected = it.selected.value,
onCloseClick = if (onChipCloseClick != null) {
{
onChipCloseClick.invoke(it)
onValueChange(queryText, selectedPeopleList)
}
} else {
null
},
onClick = {
onChipClick?.invoke(it)
onValueChange(queryText, selectedPeopleList)
},
style = chipValidation(it.person)
)
Spacer(modifier = Modifier.width(chipSpacing))
}
}
if (selectedPeopleListSize < selectedPeopleList.size) {
queryText = ""
onValueChange(queryText, selectedPeopleList)
}
selectedPeopleListSize = selectedPeopleList.size
BasicTextField(
value = queryText,
onValueChange = {
queryText = it
onValueChange(queryText, selectedPeopleList)
},
singleLine = true,
keyboardOptions = KeyboardOptions(),
keyboardActions = KeyboardActions(onDone = {
if (onTextEntered != null) {
onTextEntered.invoke(queryText)
queryText = ""
onValueChange(queryText, selectedPeopleList)
}
}),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.bringIntoViewRequester(bringIntoViewRequester)
.onFocusEvent { focusState ->
when {
focusState.isFocused -> {
scope.launch {
bringIntoViewRequester.bringIntoView()
}
}
}
}
.semantics { contentDescription = searchHint ?: "" }
.testTag("Text field"),
textStyle = textTypography.merge(
TextStyle(
color = textColor,
textDirection = TextDirection.ContentOrLtr
)
),
decorationBox = @Composable { innerTextField ->
Box(
Modifier
.fillMaxWidth(),
contentAlignment = if (LocalLayoutDirection.current == LayoutDirection.Rtl)
Alignment.CenterEnd
else
Alignment.CenterStart
) {
if (queryText.isEmpty()) {
BasicText(
searchHint ?: "",
style = token.hintTextTypography(peoplePickerInfo)
.merge(
TextStyle(
color = token.hintColor(peoplePickerInfo)
)
)
)
}
}
innerTextField()
},
cursorBrush = cursorBrush
)
}
if (trailingAccessoryContent != null) {
Box(modifier = Modifier.padding(trailingAccessoryPadding)) {
trailingAccessoryContent()
}
}
}
Divider(
height = token.strokeWidth(peoplePickerInfo),
dividerToken = object : DividerTokens() {
@Composable
override fun verticalPadding(dividerInfo: DividerInfo): PaddingValues {
return PaddingValues(0.dp)
}
@Composable
override fun dividerBrush(dividerInfo: DividerInfo): Brush =
token.dividerBrush(peoplePickerInfo)
}
)
if (!assistiveText.isNullOrBlank() || !errorString.isNullOrBlank()) {
BasicText(
errorString ?: assistiveText!!,
style = token.assistiveTextTypography(peoplePickerInfo).merge(
TextStyle(
color = token.assistiveTextColor(peoplePickerInfo)
)
),
modifier = Modifier.padding(token.assistiveTextPadding(peoplePickerInfo))
)
}
}
}
}
data class PeoplePickerItemData(
val person: Person,
var selected: MutableState<Boolean> = mutableStateOf(false)
)
@Composable
fun rememberPeoplePickerItemDataList(
initialValue: SnapshotStateList<PeoplePickerItemData> = mutableStateListOf(),
): SnapshotStateList<PeoplePickerItemData> {
return rememberSaveable(
saver = Saver(
save = {
var saved = mutableListOf<Map<String, Any?>>()
it.forEach { itemData ->
saved.add(
mapOf(
"selectedKey" to itemData.selected.value,
"firstName" to itemData.person.firstName,
"lastName" to itemData.person.lastName,
"email" to itemData.person.email,
"image" to itemData.person.image,
"imageBitmap" to itemData.person.imageBitmap,
"isActive" to itemData.person.isActive,
"isOOO" to itemData.person.isOOO,
"status" to itemData.person.status
)
)
}
saved
},
restore = { restored ->
val list = mutableStateListOf<PeoplePickerItemData>()
restored.forEach { item ->
list.add(
PeoplePickerItemData(
person = Person(
firstName = item["firstName"] as String,
lastName = item["lastName"] as String,
email = item["email"] as String?,
image = item["image"] as Int?,
imageBitmap = item["imageBitmap"] as ImageBitmap?,
isActive = item["isActive"] as Boolean,
isOOO = item["isOOO"] as Boolean,
status = item["status"] as AvatarStatus
),
selected = mutableStateOf(item["selectedKey"] as Boolean)
)
)
}
list
}
)
) {
initialValue
}
}

Просмотреть файл

@ -3,7 +3,12 @@ package com.microsoft.fluentui.tokenized.persona
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.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.heightIn
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.icons.Icons
@ -16,6 +21,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import com.microsoft.fluentui.persona.R
@ -83,17 +90,23 @@ fun PersonaChip(
token.avatarToTextSpacing(personaChipInfo = personaChipInfo)
val cornerRadius =
token.cornerRadius(personaChipInfo = personaChipInfo)
val maxHeight =
token.maxHeight(personaChipInfo = personaChipInfo)
Box(
modifier = modifier
.clip(RoundedCornerShape(cornerRadius))
.background(backgroundColor)
.heightIn(max = maxHeight)
.clickable(
enabled = enabled,
onClick = onClick ?: {},
interactionSource = interactionSource,
indication = rememberRipple()
)
.then(if (onCloseClick != null && selected) Modifier else Modifier.clearAndSetSemantics {
this.contentDescription = person.getLabel()
})
)
{
Row(
@ -120,7 +133,7 @@ fun PersonaChip(
tint = textColor
)
} else {
Avatar(person = person, size = avatarSize)
Avatar(modifier = Modifier.clearAndSetSemantics { }, person = person, size = avatarSize)
}
}
BasicText(