Merged PR 223933: PeoplePicker: Accessibility / Explore By Touch

This introduces a new accessibility helper class for `ExploreByTouch`. This class allows us to create nodes and virtual views for the spans inside of `PeoplePickerTextView` so that they are discoverable in `ExploreByTouch` mode and `TalkBack`.

I added one api entry point to customize the accessibility text to keep it simple - this is now in a separate file.

Also fixed a drag and drop bug - it was too sensitive to touch.

**Future improvements / fixes:**
1. ~~Fix bugs for dropdown list virtual views - these were existing before this PR - and even before I started messing with dropdown height / layout when I added the "Search Directory" button~~
2. ~~Instead of ignoring the text view label (currently we mark it as `android:importantForAccessibility="no"`, it should be hover-able, and tapping to select should focus the edit box. This is what Gmail does - Outlook ignores the label. I think this feature will make the fields easier to navigate and to select - sometimes it's hard to select the field when it's full of persona spans.~~
3. I need to do some more investigation on how to handle "deselection", but I have the feeling it might tie into future work that exposes click events for `PersonaChip`

Related work items: #678124
This commit is contained in:
Emily Lynam 2019-02-12 01:10:03 +00:00
Родитель 92c4b14e20
Коммит 4b7130f879
9 изменённых файлов: 606 добавлений и 80 удалений

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

@ -8,6 +8,7 @@ import android.os.Bundle
import android.support.design.widget.Snackbar
import android.view.ViewGroup
import android.widget.LinearLayout
import com.microsoft.officeuifabric.peoplepicker.PeoplePickerAccessibilityTextProvider
import com.microsoft.officeuifabric.peoplepicker.PeoplePickerPersonaChipClickStyle
import com.microsoft.officeuifabric.peoplepicker.PeoplePickerView
import com.microsoft.officeuifabric.persona.IPersona
@ -30,6 +31,7 @@ class PeoplePickerViewActivity : DemoActivity() {
super.onCreate(savedInstanceState)
samplePersonas = createPersonaList(this)
val accessibilityTextProvider = getAccessibilityTextProvider()
// Use attributes to set personaChipClickStyle and label
@ -53,11 +55,13 @@ class PeoplePickerViewActivity : DemoActivity() {
people_picker_select.onCreatePersona = { name, email ->
createCustomPersona(this, name, email)
}
people_picker_select.accessibilityTextProvider = accessibilityTextProvider
people_picker_select_deselect.availablePersonas = samplePersonas
val selectDeselectPickedPersonas = arrayListOf(samplePersonas[2])
people_picker_select_deselect.pickedPersonas = selectDeselectPickedPersonas
people_picker_select_deselect.allowPersonaChipDragAndDrop = true
people_picker_select_deselect.accessibilityTextProvider = accessibilityTextProvider
// Use code to set personaChipClickStyle and label
@ -82,6 +86,16 @@ class PeoplePickerViewActivity : DemoActivity() {
)
}
private fun getAccessibilityTextProvider() = object : PeoplePickerAccessibilityTextProvider(resources) {
override fun getPersonaQuantityText(personas: ArrayList<IPersona>): String {
return resources.getQuantityString(
R.plurals.people_picker_accessibility_text_view_example,
personas.size,
personas.size
)
}
}
private fun setupPeoplePickerView(
labelText: String,
availablePersonas: ArrayList<IPersona> = ArrayList(),

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

@ -37,6 +37,11 @@
<string name="people_picker_picked_personas_listener">Personas Listener</string>
<string name="people_picker_suggestions_listener">Suggestions Listener</string>
<plurals name="people_picker_accessibility_text_view_example">
<item quantity="one">%1$s recipient</item>
<item quantity="other">%1$s recipients</item>
</plurals>
<!--Persona-->
<string name="persona_email_allan_munger">amunger@microsoft.com</string>
<string name="persona_email_amanda_brady">abrady@microsoft.com</string>

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

@ -0,0 +1,40 @@
/**
* Copyright © 2018 Microsoft Corporation. All rights reserved.
*/
package com.microsoft.officeuifabric.peoplepicker
import android.content.res.Resources
import com.microsoft.officeuifabric.R
import com.microsoft.officeuifabric.persona.IPersona
/**
* Customizes text announced by the screen reader for PeoplePickerTextView.
*/
open class PeoplePickerAccessibilityTextProvider(val resources: Resources) {
/**
* This text announces when the popup opens showing the list of suggested personas.
*/
open fun getPersonaSuggestionsOpenedText(personas: ArrayList<IPersona>): String =
resources.getQuantityString(
R.plurals.people_picker_accessibility_suggestions_opened,
personas.size,
personas.size
)
/**
* This text announces how many personas are in the currently focused PeoplePickerTextView.
*/
open fun getPersonaQuantityText(personas: ArrayList<IPersona>): String =
resources.getQuantityString(
R.plurals.people_picker_accessibility_text_view,
personas.size,
personas.size
)
/**
* This content description is announced any time a specific persona has focus or receives an event.
*/
open fun getPersonaDescription(persona: IPersona): String =
if (persona.name.isNotEmpty()) persona.name else persona.email
}

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

@ -8,8 +8,12 @@ import android.content.ClipData
import android.content.ClipDescription
import android.content.Context
import android.graphics.Rect
import android.os.Handler
import android.os.Build
import android.os.Bundle
import android.support.v4.content.ContextCompat
import android.support.v4.view.ViewCompat
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat
import android.support.v4.widget.ExploreByTouchHelper
import android.text.InputFilter
import android.text.SpannableString
import android.text.Spanned
@ -25,6 +29,7 @@ import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import android.view.inputmethod.InputMethodManager
import com.microsoft.officeuifabric.R
import com.microsoft.officeuifabric.persona.IPersona
@ -40,6 +45,8 @@ import com.tokenautocomplete.TokenCompleteTextView
* Functionality we add in addition to [TokenCompleteTextView]'s functionality includes:
* - Hiding the cursor when a token is selected
* - Styling the [CountSpan]
* - Drag and drop option
* - Accessibility
*
* TODO Known issues:
* - Using backspace to delete a selected token does not work if other text is entered in the input;
@ -52,13 +59,17 @@ import com.tokenautocomplete.TokenCompleteTextView
* -setTokenLimit is not working as intended. Need to debug this and add the public property back into the api.
*
* TODO Future work:
* - Improve accessibility with something like the [TokenCompleteTextViewTouchHelper] class.
* - Limit what appears in the long click context menu.
* - Baseline align chips with other text.
* - Improve vertical spacing for chips.
* - Add click api for persona chip and relevant accessibility events, including handling deselection
* - Announce already selected persona chips, may be related to this ^
*/
internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
companion object {
// Max number of personas the screen reader will announce on focus.
private const val MAX_PERSONAS_TO_READ = 3
// Removes constraints to the input field
private val noFilters = arrayOfNulls<InputFilter>(0)
// Constrains changes that can be made to the input field to none
@ -77,12 +88,45 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
* Flag for enabling Drag and Drop persona chips.
*/
var allowPersonaChipDragAndDrop: Boolean = false
/**
* Store the hint so that we can control when it is announced for accessibility
*/
var valueHint: CharSequence = ""
set(value) {
field = value
hint = value
}
lateinit var onCreatePersona: (name: String, email: String) -> IPersona
private val countSpan: CountSpan?
get() = text.getSpans(0, text.length, CountSpan::class.java).firstOrNull()
val countSpanStart: Int
get() = text.indexOfFirst { it == '+' }
private val countSpanEnd: Int
get() = text.length
private val accessibilityTouchHelper = AccessibilityTouchHelper(this)
private var blockedMovementMethod: MovementMethod? = null
// Keep track of persona selection for accessibility events
private var selectedPersona: IPersona? = null
set(value) {
field = value
if (value != null)
blockInput()
else
unblockInput()
}
private var shouldAnnouncePersonaAddition: Boolean = false
private var shouldAnnouncePersonaRemoval: Boolean = true
private var searchConstraint: CharSequence = ""
init {
ViewCompat.setAccessibilityDelegate(this, accessibilityTouchHelper)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
super.setTokenListener(TokenListener(this))
}
// @JvmOverloads does not work in this scenario due to parameter defaults
constructor(context: Context) : super(context)
@ -100,11 +144,10 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
}
override fun onSelected(selected: Boolean) {
if (selected) {
blockInput()
} else {
unblockInput()
}
if (selected)
selectedPersona = `object`
else
selectedPersona = null
}
}
view.setPersona(`object`)
@ -119,49 +162,62 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
}
override fun performCollapse(hasFocus: Boolean) {
if (getOriginalCountSpan() == null)
removeCountSpanText()
super.performCollapse(hasFocus)
// Replace the CountSpan with a custom styled span
val countSpan = countSpan
if (countSpan == null) {
removeReplacementCountSpan()
} else {
val countSpanStart = text.indexOfLast { it == '+' }
val countSpanEnd = text.length
text.removeSpan(countSpan)
val replacementCountSpan = SpannableString(countSpan.text)
replacementCountSpan.setSpan(
TextAppearanceSpan(context, R.style.TextAppearance_UIFabric_PeoplePickerCountSpan),
0,
replacementCountSpan.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
text.replace(countSpanStart, countSpanEnd, replacementCountSpan)
}
// Remove viewPadding for single line to fix jittery virtual view bounds in ExploreByTouch.
if (!hasFocus())
setPadding(0, 0, 0, 0)
else
setPadding(0, resources.getDimension(R.dimen.uifabric_people_picker_text_view_padding).toInt(), 0, resources.getDimension(R.dimen.uifabric_people_picker_text_view_padding).toInt())
updateCountSpanStyle()
}
override fun onFocusChanged(hasFocus: Boolean, direction: Int, previous: Rect?) {
super.onFocusChanged(hasFocus, direction, previous)
val inputmethodManager: InputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (hasFocus) {
// Soft keyboard does not always show up without this
Handler().post {
inputmethodManager.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
val inputMethodManager: InputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
// Soft keyboard does not always show up without this
if (hasFocus)
post {
inputMethodManager.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}
} else {
inputmethodManager.hideSoftInputFromWindow(windowToken, InputMethodManager.HIDE_IMPLICIT_ONLY);
}
else
inputMethodManager.hideSoftInputFromWindow(windowToken, InputMethodManager.HIDE_IMPLICIT_ONLY)
}
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
// super.onSelectionChanged is buggy, but we still need the accessibility event from the super super call.
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED)
// This fixes buggy cursor position in accessibility mode.
setSelection(text.length)
}
override fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int) {
super.onTextChanged(text, start, lengthBefore, lengthAfter)
unblockInput()
selectedPersona = null
if (lengthAfter > lengthBefore || lengthAfter < lengthBefore && !text.isNullOrEmpty())
setupSearchConstraint(text)
}
override fun replaceText(text: CharSequence?) {
shouldAnnouncePersonaAddition = true
super.replaceText(text)
}
override fun canDeleteSelection(beforeLength: Int): Boolean {
// This method is called from keyboard events so any token removed would be coming from the user.
shouldAnnouncePersonaRemoval = true
return super.canDeleteSelection(beforeLength)
}
override fun removeObject(`object`: IPersona?) {
shouldAnnouncePersonaRemoval = false
super.removeObject(`object`)
}
internal fun removeObjects(personas: List<IPersona>?) {
@ -174,13 +230,42 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
i++
}
removeReplacementCountSpan()
removeCountSpanText()
}
private fun removeReplacementCountSpan() {
val replacementCountSpanStart = text.indexOfFirst { it == '+' }
if (replacementCountSpanStart > -1)
text.delete(replacementCountSpanStart, text.length)
private fun setupSearchConstraint(text: CharSequence?) {
accessibilityTouchHelper.invalidateRoot()
val personaSpanEnd = text?.indexOfLast { it == ',' }?.plus(1) ?: -1
searchConstraint = when {
// Ignore the count span
countSpanStart != -1 -> ""
// If we have personas, we'll also have comma tokenizers to remove from the text
personaSpanEnd > 0 -> text?.removeRange(text.indexOfFirst { it == ',' }, personaSpanEnd)?.trim() ?: ""
// Any other characters will be used as the search constraint to perform filtering.
else -> text ?: ""
}
// This keeps the entered text accessibility focused as the user types, which makes the suggested personas list the next focusable view.
accessibilityTouchHelper.sendEventForVirtualView(objects.size, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED)
}
private fun updateCountSpanStyle() {
val originalCountSpan = getOriginalCountSpan() ?: return
text.removeSpan(originalCountSpan)
val replacementCountSpan = SpannableString(originalCountSpan.text)
replacementCountSpan.setSpan(
TextAppearanceSpan(context, R.style.TextAppearance_UIFabric_PeoplePickerCountSpan),
0,
replacementCountSpan.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
text.replace(countSpanStart, countSpanEnd, replacementCountSpan)
}
private fun removeCountSpanText() {
val countSpanStart = countSpanStart
if (countSpanStart > -1)
text.delete(countSpanStart, countSpanEnd)
}
private fun isEmailValid(email: CharSequence): Boolean = Patterns.EMAIL_ADDRESS.matcher(email).matches()
@ -203,6 +288,35 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
movementMethod = blockedMovementMethod
}
private fun getPersonaSpans(start: Int = 0, end: Int = text.length): Array<TokenCompleteTextView<IPersona>.TokenImageSpan> =
text.getSpans(start, end, TokenImageSpan::class.java) as Array<TokenCompleteTextView<IPersona>.TokenImageSpan>
private fun getOriginalCountSpan(): CountSpan? =
text.getSpans(0, text.length, CountSpan::class.java).firstOrNull()
private fun getSpanForPersona(persona: Any): TokenImageSpan? =
getPersonaSpans().firstOrNull { it.token === persona }
// Token listener
private var tokenListener: TokenCompleteTextView.TokenListener<IPersona>? = null
override fun setTokenListener(l: TokenCompleteTextView.TokenListener<IPersona>?) {
tokenListener = l
}
private class TokenListener(val view: PeoplePickerTextView) : TokenCompleteTextView.TokenListener<IPersona> {
override fun onTokenAdded(token: IPersona) {
view.tokenListener?.onTokenAdded(token)
view.announcePersonaAdded(token)
}
override fun onTokenRemoved(token: IPersona) {
view.tokenListener?.onTokenRemoved(token)
view.announcePersonaRemoved(token)
}
}
// Drag and drop
private var isDraggingPersonaChip: Boolean = false
@ -213,7 +327,7 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
override fun onTouchEvent(event: MotionEvent): Boolean {
var handled = false
if (personaChipClickStyle == TokenClickStyle.None)
if (personaChipClickStyle == PeoplePickerPersonaChipClickStyle.None)
handled = super.onTouchEvent(event)
val touchedPersonaSpan = getPersonaSpanAt(event.x, event.y)
@ -221,8 +335,6 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
if (touchedPersonaSpan != null) {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
if (!isFocused)
requestFocus()
if (allowPersonaChipDragAndDrop) {
initialTouchedPersonaSpan = touchedPersonaSpan
firstTouchX = event.x
@ -246,6 +358,8 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
MotionEvent.ACTION_UP -> {
if (isFocused && text != null && initialTouchedPersonaSpan == touchedPersonaSpan)
touchedPersonaSpan.onClick()
if (!isFocused)
requestFocus()
initialTouchedPersonaSpan = null
handled = true
}
@ -256,7 +370,7 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
}
}
if (!handled && personaChipClickStyle != TokenClickStyle.None)
if (!handled && personaChipClickStyle != PeoplePickerPersonaChipClickStyle.None)
handled = super.onTouchEvent(event)
return handled
@ -324,16 +438,14 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
}
private fun getPersonaSpanAt(x: Float, y: Float): TokenImageSpan? {
if (TextUtils.isEmpty(text))
if (text.isEmpty())
return null
val offset = getOffsetForPosition(x, y)
if (offset == -1)
return null
val personaSpans = text.getSpans(offset, offset, TokenImageSpan::class.java)
as Array<TokenCompleteTextView<IPersona>.TokenImageSpan>
return personaSpans.firstOrNull()
return getPersonaSpans(offset, offset).firstOrNull()
}
private fun addPersonaFromDragEvent(event: DragEvent): Boolean {
@ -350,4 +462,296 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
return true
}
// Accessibility
private var customAccessibilityTextProvider: PeoplePickerAccessibilityTextProvider? = null
private val defaultAccessibilityTextProvider = PeoplePickerAccessibilityTextProvider(resources)
val accessibilityTextProvider: PeoplePickerAccessibilityTextProvider
get() = customAccessibilityTextProvider ?: defaultAccessibilityTextProvider
fun setAccessibilityTextProvider(accessibilityTextProvider: PeoplePickerAccessibilityTextProvider?) {
customAccessibilityTextProvider = accessibilityTextProvider
}
override fun dispatchHoverEvent(motionEvent: MotionEvent): Boolean {
// Accessibility first
return if (accessibilityTouchHelper.dispatchHoverEvent(motionEvent))
true
else
super.dispatchHoverEvent(motionEvent)
}
private fun announcePersonaAdded(persona: IPersona) {
accessibilityTouchHelper.invalidateRoot()
val replacedText = if (searchConstraint.isNotEmpty())
"${resources.getString(R.string.people_picker_accessibility_replaced, searchConstraint)} "
else
""
// We only want to announce when a persona was added by a user.
// If text has been replaced in the text editor and a token was added, the user added a token.
if (shouldAnnouncePersonaAddition) {
announceForAccessibility("$replacedText ${getAnnouncementText(
persona,
R.string.people_picker_accessibility_persona_added
)}")
}
}
private fun announcePersonaRemoved(persona: IPersona) {
accessibilityTouchHelper.invalidateRoot()
// We only want to announce when a persona was removed by a user.
if (shouldAnnouncePersonaRemoval) {
announceForAccessibility(getAnnouncementText(
persona,
R.string.people_picker_accessibility_persona_removed
))
}
}
private fun getAnnouncementText(persona: IPersona, stringResourceId: Int): CharSequence =
resources.getString(stringResourceId, accessibilityTextProvider.getPersonaDescription(persona))
private fun positionIsInsidePersonaBounds(x: Float, y: Float, personaSpan: TokenImageSpan?): Boolean =
getBoundsForPersonaSpan(personaSpan).contains(x.toInt(), y.toInt())
private fun positionIsInsideSearchConstraintBounds(x: Float, y: Float): Boolean {
if (searchConstraint.isNotEmpty())
return getBoundsForSearchConstraint().contains(x.toInt(), y.toInt())
return false
}
private fun getBoundsForSearchConstraint(): Rect {
val start = text.indexOf(searchConstraint[0])
val end = text.length
return calculateBounds(start, end, resources.getDimension(R.dimen.uifabric_people_picker_accessibility_search_constraint_extra_space).toInt())
}
private fun getBoundsForPersonaSpan(personaSpan: TokenImageSpan? = null): Rect {
val start = text.getSpanStart(personaSpan)
val end = text.getSpanEnd(personaSpan)
return calculateBounds(start, end)
}
private fun calculateBounds(start: Int, end: Int, extraSpaceForLegibility: Int = 0): Rect {
val line = layout.getLineForOffset(end)
// Persona spans increase line height. Without them, we need to make the virtual view bound bottom lower.
val bounds = Rect(
layout.getPrimaryHorizontal(start).toInt() - extraSpaceForLegibility,
layout.getLineTop(line),
layout.getPrimaryHorizontal(end).toInt() + extraSpaceForLegibility,
if (getPersonaSpans().isEmpty()) bottom else layout.getLineBottom(line)
)
bounds.offset(paddingLeft, paddingTop)
return bounds
}
private fun setHint() {
if (!isFocused)
// If the edit box is not focused, there is no event that requires a hint.
hint = ""
else
hint = valueHint
}
private inner class AccessibilityTouchHelper(host: View) : ExploreByTouchHelper(host) {
// Host
val peoplePickerTextViewBounds = Rect(0, 0, width, height)
override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
super.onInitializeAccessibilityNodeInfo(host, info)
setHint()
setInfoText(info)
}
override fun onPopulateAccessibilityEvent(host: View?, event: AccessibilityEvent?) {
super.onPopulateAccessibilityEvent(host, event)
/**
* The CommaTokenizer is confusing in the screen reader.
* This overrides announcements that include the CommaTokenizer.
* We handle cases for replaced text and persona spans added / removed through callbacks.
*/
if (event?.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED)
event.text.clear()
}
private fun setInfoText(info: AccessibilityNodeInfoCompat) {
val personas = objects
if (personas == null || personas.isEmpty())
return
var infoText = ""
// Read all of the personas if the list of personas in the field is short
// Otherwise, read how many personas are in the field
if (personas.size <= MAX_PERSONAS_TO_READ)
infoText += personas.map { accessibilityTextProvider.getPersonaDescription(it) }.joinToString { it }
else
infoText = accessibilityTextProvider.getPersonaQuantityText(personas as ArrayList<IPersona>)
info.text = infoText +
// Also ready any entered text in the field
if (searchConstraint.isNotEmpty())
", $searchConstraint"
else
""
}
// Virtual views
override fun getVirtualViewAt(x: Float, y: Float): Int {
if (objects == null || objects.size == 0)
return ExploreByTouchHelper.INVALID_ID
val offset = getOffsetForPosition(x, y)
if (offset != -1) {
val personaSpan = getPersonaSpans(offset, offset).firstOrNull()
if (personaSpan != null && positionIsInsidePersonaBounds(x, y, personaSpan) && isFocused)
return objects.indexOf(personaSpan.token)
else if (searchConstraint.isNotEmpty() && positionIsInsideSearchConstraintBounds(x, y))
return objects.size
else if (peoplePickerTextViewBounds.contains(x.toInt(), y.toInt())) {
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED)
return ExploreByTouchHelper.HOST_ID
}
}
return ExploreByTouchHelper.INVALID_ID
}
override fun getVisibleVirtualViews(virtualViewIds: MutableList<Int>) {
virtualViewIds.clear()
if (objects == null || objects.size == 0 || !isFocused)
return
for (i in objects.indices)
virtualViewIds.add(i)
if (searchConstraint.isNotEmpty())
virtualViewIds.add(objects.size)
}
override fun onPopulateEventForVirtualView(virtualViewId: Int, event: AccessibilityEvent) {
if (objects == null || virtualViewId >= objects.size) {
// The content description is mandatory.
event.contentDescription = ""
return
}
if (!isFocused) {
// Only respond to events for persona chips if the edit box is focused.
// Without this the user still gets haptic feedback when hovering over a persona chip.
event.recycle()
event.contentDescription = ""
return
}
if (virtualViewId == objects.size) {
event.contentDescription = searchConstraint
return
}
val persona = objects[virtualViewId]
val personaSpan = getSpanForPersona(persona)
if (personaSpan != null)
event.contentDescription = accessibilityTextProvider.getPersonaDescription(persona)
if (event.eventType == AccessibilityEvent.TYPE_VIEW_SELECTED)
event.contentDescription = String.format(
resources.getString(R.string.people_picker_accessibility_selected_persona),
event.contentDescription
)
}
override fun onPopulateNodeForVirtualView(virtualViewId: Int, node: AccessibilityNodeInfoCompat) {
if (objects == null || virtualViewId > objects.size) {
// the content description & the bounds are mandatory.
node.contentDescription = ""
node.setBoundsInParent(peoplePickerTextViewBounds)
return
}
if (!isFocused) {
// Only populate nodes for persona chips if the edit box is focused.
node.recycle()
node.contentDescription = ""
node.setBoundsInParent(peoplePickerTextViewBounds)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val clickAction = AccessibilityNodeInfoCompat.AccessibilityActionCompat(
AccessibilityNodeInfoCompat.ACTION_CLICK,
resources.getString(R.string.people_picker_accessibility_select_persona)
)
node.addAction(clickAction)
} else {
node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK)
}
if (virtualViewId == objects.size) {
if (searchConstraint.isNotEmpty()){
node.contentDescription = searchConstraint
node.setBoundsInParent(getBoundsForSearchConstraint())
} else {
node.contentDescription = ""
node.setBoundsInParent(peoplePickerTextViewBounds)
}
return
}
val persona = objects[virtualViewId]
val personaSpan = getSpanForPersona(persona)
if (personaSpan != null) {
if (node.isAccessibilityFocused)
node.contentDescription = accessibilityTextProvider.getPersonaDescription(persona)
else
node.contentDescription = ""
node.setBoundsInParent(getBoundsForPersonaSpan(personaSpan))
}
}
override fun onPerformActionForVirtualView(virtualViewId: Int, action: Int, arguments: Bundle?): Boolean {
if (objects == null || virtualViewId >= objects.size)
return false
if (AccessibilityNodeInfo.ACTION_CLICK == action) {
val persona = objects[virtualViewId]
val personaSpan = getSpanForPersona(persona)
if (personaSpan != null) {
personaSpan.onClick()
onPersonaSpanAccessibilityClick(personaSpan)
shouldAnnouncePersonaRemoval = true
return true
}
}
return false
}
private fun onPersonaSpanAccessibilityClick(personaSpan: TokenImageSpan) {
val personaSpanIndex = getPersonaSpans().indexOf(personaSpan)
when (personaChipClickStyle) {
PeoplePickerPersonaChipClickStyle.Select, PeoplePickerPersonaChipClickStyle.SelectDeselect -> {
if (selectedPersona != null && selectedPersona == personaSpan.token) {
invalidateVirtualView(personaSpanIndex)
sendEventForVirtualView(personaSpanIndex, AccessibilityEvent.TYPE_VIEW_CLICKED)
sendEventForVirtualView(personaSpanIndex, AccessibilityEvent.TYPE_VIEW_SELECTED)
} else {
sendEventForVirtualView(personaSpanIndex, AccessibilityEvent.TYPE_VIEW_CLICKED)
if (personaChipClickStyle == PeoplePickerPersonaChipClickStyle.Select && personaSpanIndex == -1)
invalidateRoot()
}
}
PeoplePickerPersonaChipClickStyle.Delete -> {
sendEventForVirtualView(personaSpanIndex, AccessibilityEvent.TYPE_VIEW_CLICKED)
sendEventForVirtualView(personaSpanIndex, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED)
}
}
}
}
}

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

@ -22,6 +22,10 @@ import java.util.*
* so we use an [ArrayAdapter] to generate the views instead of a [RecyclerView.Adapter].
*/
internal class PeoplePickerTextViewAdapter : ArrayAdapter<IPersona>, Filterable {
private enum class ViewType {
PERSONA, SEARCH_DIRECTORY
}
/**
* Collection of [Persona] objects that hold data to create the [PersonaView]s
*/
@ -53,9 +57,12 @@ internal class PeoplePickerTextViewAdapter : ArrayAdapter<IPersona>, Filterable
private var searchDirectoryView: View? = null
set(value) {
field = value
searchDirectoryTextView = value?.people_picker_search_directory_text
searchDirectoryView?.post {
// We set this in a post so that the we get the correct instance of the text view.
// This assumes that the first view is the correct view.
searchDirectoryTextView = value?.people_picker_search_directory_text
}
value?.setOnClickListener(onSearchDirectoryButtonClicked)
updateSearchDirectoryText()
}
private var searchDirectoryTextView: TextView? = null
set(value) {
@ -78,32 +85,38 @@ internal class PeoplePickerTextViewAdapter : ArrayAdapter<IPersona>, Filterable
override fun getFilter(): Filter = filter
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
return if (isSearchDirectoryButtonPosition(position))
getSearchDirectoryView(parent)
override fun getItemViewType(position: Int): Int {
return if (position < personas.size)
ViewType.PERSONA.ordinal
else
getPersonaView(convertView, position, parent)
ViewType.SEARCH_DIRECTORY.ordinal
}
override fun getViewTypeCount(): Int = ViewType.values().size
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
return when (getItemViewType(position)) {
ViewType.PERSONA.ordinal -> getPersonaView(position, convertView, parent)
ViewType.SEARCH_DIRECTORY.ordinal -> getSearchDirectoryView(convertView, parent)
else -> throw IllegalStateException("ViewType expected")
}
}
private fun isSearchDirectoryButtonPosition(position: Int): Boolean = showSearchDirectoryButton && position == personas.size
private fun getPersonaView(convertView: View?, position: Int, parent: ViewGroup?): View {
private fun getPersonaView(position: Int, convertView: View?, parent: ViewGroup?): View {
val view = convertView as? PersonaView ?: PersonaView(context)
view.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
view.avatarSize = AvatarSize.LARGE
view.setPersona(personas[position])
listView = parent as? ListView
return view
}
private fun getSearchDirectoryView(parent: ViewGroup?): View {
var view = searchDirectoryView
return if (view == null) {
view = LayoutInflater.from(context).inflate(R.layout.people_picker_search_directory, parent, false)
searchDirectoryView = view
view
} else
view
private fun getSearchDirectoryView(convertView: View?, parent: ViewGroup?): View {
// 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)
searchDirectoryView = view
return view
}
private fun updateSearchDirectoryText() {

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

@ -10,6 +10,7 @@ import android.graphics.Rect
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.officeuifabric.R
@ -42,7 +43,7 @@ class PeoplePickerView : TemplateView {
/**
* [valueHint] is important for accessibility but will not be displayed.
*/
var valueHint: String = context.getString(R.string.people_picker_default_hint)
var valueHint: String = context.getString(R.string.people_picker_accessibility_default_hint)
set(value) {
if (field == value)
return
@ -133,6 +134,17 @@ class PeoplePickerView : TemplateView {
field = value
updateViews()
}
/**
* Customizes text announced by the screen reader.
* If there is no custom accessibility text, we use default text.
*/
var accessibilityTextProvider: PeoplePickerAccessibilityTextProvider? = null
set(value) {
if (field == value)
return
field = value
updateViews()
}
/**
* Callback to use your own [IPersona] object in place of our default [Persona].
@ -182,7 +194,7 @@ class PeoplePickerView : TemplateView {
constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : super(context, attrs, defStyleAttr) {
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.PeoplePickerView)
label = styledAttrs.getString(R.styleable.PeoplePickerView_label) ?: ""
valueHint = styledAttrs.getString(R.styleable.PeoplePickerView_valueHint) ?: ""
valueHint = styledAttrs.getString(R.styleable.PeoplePickerView_valueHint) ?: context.getString(R.string.people_picker_accessibility_default_hint)
val personaChipClickStyleOrdinal = styledAttrs.getInt(R.styleable.PeoplePickerView_personaChipClickStyle, PeoplePickerPersonaChipClickStyle.Select.ordinal)
personaChipClickStyle = PeoplePickerPersonaChipClickStyle.values()[personaChipClickStyleOrdinal]
@ -210,34 +222,47 @@ class PeoplePickerView : TemplateView {
updatePersonaChips()
updateViews()
addLabelClickListenerForAccessibility()
super.onTemplateLoaded()
}
private fun updatePersonaChips() {
peoplePickerTextView?.let {
it.removeObjects(it.objects)
for (persona in pickedPersonas)
it.addObject(persona)
}
}
private fun updateViews() {
labelTextView?.text = label
peoplePickerTextView?.apply {
valueHint = this@PeoplePickerView.valueHint
allowCollapse(allowCollapse)
allowDuplicates(allowDuplicatePersonaChips)
threshold = characterThreshold
setAdapter(peoplePickerTextViewAdapter)
personaChipClickStyle = this@PeoplePickerView.personaChipClickStyle
hint = valueHint
allowPersonaChipDragAndDrop = this@PeoplePickerView.allowPersonaChipDragAndDrop
onCreatePersona = ::createPersona
setAccessibilityTextProvider(this@PeoplePickerView.accessibilityTextProvider)
}
peoplePickerTextViewAdapter?.showSearchDirectoryButton = showSearchDirectoryButton
}
private fun updatePersonaChips() {
peoplePickerTextView?.removeObjects(peoplePickerTextView?.objects)
for (persona in pickedPersonas)
peoplePickerTextView?.addObject(persona)
private fun addLabelClickListenerForAccessibility() {
labelTextView?.setOnClickListener {
val accessibilityNodeInfo = it?.createAccessibilityNodeInfo()
if (accessibilityNodeInfo?.isAccessibilityFocused == true) {
peoplePickerTextView?.requestFocus()
peoplePickerTextView?.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED)
}
}
}
private fun createPersona(name: String, email: String): IPersona {
return onCreatePersona?.invoke(name, email) ?: Persona(name, email)
}
private fun createPersona(name: String, email: String): IPersona =
onCreatePersona?.invoke(name, email) ?: Persona(name, email)
// Dropdown
@ -322,14 +347,21 @@ class PeoplePickerView : TemplateView {
override fun publishResults(constraint: CharSequence?, results: Filter.FilterResults) {
val listener = view.personaSuggestionsListener
val accessibilityTextProvider = view.peoplePickerTextView?.accessibilityTextProvider
val countSpanStart = view.peoplePickerTextView?.countSpanStart
if (listener != null) {
listener.onGetSuggestedPersonas(constraint, view.availablePersonas, view.pickedPersonas) {
view.post {
view.peoplePickerTextViewAdapter?.personas = it
if (constraint != null && countSpanStart == -1)
view.announceForAccessibility(accessibilityTextProvider?.getPersonaSuggestionsOpenedText(it))
}
}
} else {
view.peoplePickerTextViewAdapter?.personas = results.values as ArrayList<IPersona>
val personas = results.values as ArrayList<IPersona>
view.peoplePickerTextViewAdapter?.personas = personas
if (constraint != null && countSpanStart == -1)
view.announceForAccessibility(accessibilityTextProvider?.getPersonaSuggestionsOpenedText(personas))
}
}
}

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

@ -18,13 +18,13 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/uifabric_persona_label_spacing"
android:importantForAccessibility="no"
android:labelFor="@id/people_picker_text_view"
android:gravity="center_vertical"
android:minHeight="@dimen/uifabric_min_touch_size"
android:textAppearance="@style/TextAppearance.UIFabric.PeoplePickerLabel"
tools:text="@string/people_picker_sample_label" />
<!--Hint is for accessibility, but we don't want to see it-->
<!--Hint is for accessibility, but we don't want to see it. Hint is set programmatically.-->
<com.microsoft.officeuifabric.peoplepicker.PeoplePickerTextView
android:id="@+id/people_picker_text_view"
android:layout_width="match_parent"

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

@ -40,12 +40,13 @@
<dimen name="uifabric_number_picker_padding_right">8dp</dimen>
<!--PeoplePicker-->
<dimen name="uifabric_people_picker_popup_elevation">0dp</dimen>
<dimen name="uifabric_people_picker_accessibility_search_constraint_extra_space">5dp</dimen>
<dimen name="uifabric_people_picker_dropdown_vertical_offset">0dp</dimen>
<dimen name="uifabric_people_picker_text_view_padding">6dp</dimen>
<dimen name="uifabric_people_picker_popup_elevation">0dp</dimen>
<dimen name="uifabric_people_picker_search_directory_min_height">56dp</dimen>
<dimen name="uifabric_people_picker_search_directory_padding_horizontal">@dimen/uifabric_content_inset</dimen>
<dimen name="uifabric_people_picker_search_directory_padding_vertical">12dp</dimen>
<dimen name="uifabric_people_picker_text_view_padding">6dp</dimen>
<!--Persona-->
<dimen name="uifabric_persona_horizontal_spacing">@dimen/uifabric_content_inset</dimen>

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

@ -75,10 +75,27 @@
<!-- *** PeoplePicker *** -->
<string name="people_picker_sample_label">Label</string>
<string name="people_picker_default_hint">Choose a persona from the list</string>
<string name="people_picker_search_directory">Search Directory</string>
<string name="people_picker_search_progress">Searching…</string>
<!--accessibility-->
<string name="people_picker_accessibility_default_hint">Enter a name or email</string>
<string name="people_picker_accessibility_select_persona">Select</string>
<string name="people_picker_accessibility_selected_persona">Selected %s</string>
<string name="people_picker_accessibility_persona_added">%s added</string>
<string name="people_picker_accessibility_persona_removed">%s removed</string>
<string name="people_picker_accessibility_replaced">%s replaced,</string>
<plurals name="people_picker_accessibility_suggestions_opened">
<item quantity="one">Suggestions list opened with %s person</item>
<item quantity="other">Suggestions list opened with %s people</item>
</plurals>
<plurals name="people_picker_accessibility_text_view">
<item quantity="one">%s person</item>
<item quantity="other">%s people</item>
</plurals>
<!-- *** Persona *** -->
<string name="persona_title_placeholder">No name</string>