Talkback improvements for New Habit and New Daily screens (#1317)

* Include state in content description of positive/negative habit buttons

- Now instead of announcing just 'Positive' or 'Negative' when these
buttons are focused with Talkback on, it also announces their current
state (e.g. 'Positive On').

- Also, when the state is changed, it now sends an accessibility event
so the new state is announced immediately (as advised at
https://developer.android.com/guide/topics/ui/accessibility/custom-views#send-events).

* Make headings in Task Form be read out as headings by Talkback

* Include state in content description of task difficulty buttons

- Now instead of announcing just 'Trivial', 'Medium' etc when these
buttons are focused with Talkback on, it also announces their current
state (e.g. 'Medium, Selected' / 'Trivial, Not Selected').

- Also, when a difficulty button is clicked, it now sends an
accessibility event for the button corresponding to that difficulty
level, so the new state is announced immediately.

  - Note that since buttons are recreated every time one is clicked,
    we can't simply send the event from the button's OnClickListener.
    Instead we have to record which button is selected and send the
    event for that button once it has been recreated.

* Include state in content description of reset streak buttons

- Now instead of announcing just 'Daily', 'Weekly' etc when these
  buttons are focused with Talkback on, it also announces their current
  state (e.g. 'Daily, Selected' / 'Weekly, Not Selected').

- Also, when a reset streak button is clicked, it now sends an
  accessibility event with the new content description, so the new state
  is announced immediately.

  - Note that since buttons are recreated every time one is clicked,
    we can't simply send the event from the button's OnClickListener.
    Instead we have to record which button is selected and send the
    event for that button once it has been recreated.

* Add labelFor attributes for repeats_every_spinner/edittext

- This adds more context for the drop-down menu and the edit-text
content descriptions read out by Talkback when editing how often dailies
repeat. This is advised by the accessibility principle described at
https://developer.android.com/guide/topics/ui/accessibility/principles#content-pairs

- For example instead of reading out 'Drop-down menu, Weekly' it now
reads out 'Drop-down menu, Weekly, for Repeats'. And instead of reading
out '1, Edit box' it now reads out '1, Edit box for Weeks'.

* Improve content descriptions of weekday buttons on New Daily screen

- Now instead of 'Capital M', 'Capital T', etc. Talkback reads out
'Monday, Selected', 'Tuesday, Not selected', etc.

- It also reads out the new status whenever a day is selected or
deselected.

* Mini refactor: reduce duplication in configureMonthlyRepeatViews

* Improve content description of weekly repeats buttons

- Now the content descriptions of the weekly repeats buttons in the
New Daily screen include whether they are Selected / Not selected.

- Also an accessibility event is sent when they are toggled, so the
content description is read out again with the updated status.

* Improve content descriptions of ReminderItemFormView

- Previously, the '+' button was focusable, even though it didn't do
anything, and Talkback read out 'Unlabelled button', regardless of
whether it was a '+' button or a 'x' button.

- Now, we only make it focusable when it becomes a delete button, and
Talkback reads out 'Delete Reminder, Button for 6.16pm' (or whatever
the time of the reminder it relates to is - this is achieved by setting
the android:labelFor property on the reminder TextView).

* Improve content descriptions of ChecklistItemFormView

- Previously, the '+' button was focusable, even though it didn't do
anything, and Talkback read out 'Unlabelled button', regardless of
whether it was a '+' button or a 'x' button.

- Now, we only make it focusable when it becomes a delete button,
and Talkback reads out 'Delete Checklist Entry, Button for Checklist
 Text' (or whatever the checklist text is - this is achieved by setting
 the android:labelFor property on the checklist EditText).
This commit is contained in:
Anita W 2020-05-04 16:27:29 +01:00 committed by GitHub
parent 348b7fd19b
commit 89f40defb8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 112 additions and 20 deletions

View file

@ -60,7 +60,8 @@
android:layout_height="wrap_content"
android:text="@string/repeats"
android:textColor="@color/gray_300"
android:textSize="12sp" />
android:textSize="12sp"
android:labelFor="@+id/repeats_every_spinner"/>
<Spinner
android:id="@+id/repeats_every_spinner"
@ -108,8 +109,9 @@
android:id="@+id/repeats_every_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="@string/days"
android:textColor="@color/gray_300"/>
android:text="@string/days"
android:textColor="@color/gray_300"
android:labelFor="@+id/repeats_every_edittext"/>
</LinearLayout>
</LinearLayout>
</LinearLayout>

View file

@ -86,6 +86,10 @@
<string name="start_date">Start Date</string>
<string name="positive_habit_form">Positive</string>
<string name="negative_habit_form">Negative</string>
<string name="on">On</string>
<string name="off">Off</string>
<string name="selected">Selected</string>
<string name="not_selected">Not selected</string>
<string name="checklist">Checklist</string>
<string name="reminders">Reminders</string>
@ -821,6 +825,7 @@
<string name="invite_to_party">Invite to Party</string>
<string name="are_you_sure">Are you sure?</string>
<string name="delete_task">Delete Task</string>
<string name="delete_reminder">Delete Reminder</string>
<string name="not_now">Not right now</string>
<string name="levelup_detail_10">Choose a class that fits your playstyle and unlock special skills and armor to help you on your journey</string>
<string name="levelup_title_10">Class System unlocked!</string>
@ -1009,4 +1014,5 @@
<string name="read_more">Read More</string>
<string name="purchase_amount_error">You are unable to buy that amount.</string>
<string name="still_questions">Still have a question?</string>
<string name="delete_checklist_entry">Delete Checklist entry</string>
</resources>

View file

@ -636,6 +636,7 @@
<item name="android:textColor">@color/gray_10</item>
<item name="android:layout_marginTop">@dimen/spacing_large</item>
<item name="android:layout_marginBottom">@dimen/spacing_medium</item>
<item name="android:accessibilityHeading">true</item>
</style>
<style name="TaskFormToggle">

View file

@ -41,6 +41,11 @@ class ChecklistItemFormView @JvmOverloads constructor(
var animDuration = 0L
var isAddButton: Boolean = true
set(value) {
// Button is only clickable when it is *not* an add button (ie when it is a delete button),
// so make screenreaders skip it when it is an add button.
button.importantForAccessibility =
if (value) View.IMPORTANT_FOR_ACCESSIBILITY_NO
else View.IMPORTANT_FOR_ACCESSIBILITY_YES
if (field == value) {
return
}
@ -83,11 +88,15 @@ class ChecklistItemFormView @JvmOverloads constructor(
(parent as? ViewGroup)?.removeView(this)
}
}
// It's ok to make the description always be 'Delete ..' because when this button is
// a plus button we set it as 'unimportant for accessibility' so it can't be focused.
button.contentDescription = context.getString(R.string.delete_checklist_entry)
button.drawable.mutate().setTint(tintColor)
editText.addTextChangedListener(OnChangeTextWatcher { s, _, _, _ ->
item.text = s.toString()
textChangedListener?.let { it(s.toString()) }
})
editText.labelFor = button.id
}
}

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
import android.view.View
import android.view.accessibility.AccessibilityEvent
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
@ -22,8 +23,12 @@ class HabitResetStreakButtons @JvmOverloads constructor(
field = value
removeAllViews()
addAllButtons()
selectedButton.sendAccessibilityEvent(
AccessibilityEvent.CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION)
}
private lateinit var selectedButton: TextView
init {
addAllButtons()
}
@ -43,14 +48,22 @@ class HabitResetStreakButtons @JvmOverloads constructor(
button.gravity = Gravity.CENTER
button.layoutParams = layoutParams
addView(button)
if (resetOption == selectedResetOption) {
selectedButton = button;
}
}
}
private fun createButton(resetOption: HabitResetOption): TextView {
val isActive = selectedResetOption == resetOption
val button = TextView(context)
button.text = context.getString(resetOption.nameRes)
val buttonText = context.getString(resetOption.nameRes)
button.text = buttonText
button.contentDescription = toContentDescription(buttonText, isActive)
button.background = ContextCompat.getDrawable(context, R.drawable.layout_rounded_bg_white)
if (selectedResetOption == resetOption) {
if (isActive) {
button.background.setTint(tintColor)
button.setTextColor(ContextCompat.getColor(context, R.color.white))
} else {
@ -62,4 +75,11 @@ class HabitResetStreakButtons @JvmOverloads constructor(
}
return button
}
private fun toContentDescription(buttonText: CharSequence, isActive: Boolean): String {
val statusString = if (isActive) {
context.getString(R.string.selected)
} else context.getString(R.string.not_selected)
return "$buttonText, $statusString"
}
}

View file

@ -5,6 +5,7 @@ import android.util.AttributeSet
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.accessibility.AccessibilityEvent
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
@ -18,7 +19,7 @@ class HabitScoringButtonsView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
private val positiveVew: ViewGroup by bindView(R.id.positive_view)
private val positiveView: ViewGroup by bindView(R.id.positive_view)
private val negativeView: ViewGroup by bindView(R.id.negative_view)
private val positiveImageView: ImageView by bindView(R.id.positive_image_view)
private val negativeImageView: ImageView by bindView(R.id.negative_image_view)
@ -34,8 +35,10 @@ class HabitScoringButtonsView @JvmOverloads constructor(
positiveImageView.setImageDrawable(HabiticaIconsHelper.imageOfHabitControlPlus(tintColor, value).asDrawable(resources))
if (value) {
positiveTextView.setTextColor(tintColor)
positiveView.contentDescription = toContentDescription(R.string.positive_habit_form, R.string.on)
} else {
positiveTextView.setTextColor(ContextCompat.getColor(context, R.color.gray_100))
positiveView.contentDescription = toContentDescription(R.string.positive_habit_form, R.string.off)
}
}
@ -45,20 +48,28 @@ class HabitScoringButtonsView @JvmOverloads constructor(
negativeImageView.setImageDrawable(HabiticaIconsHelper.imageOfHabitControlMinus(tintColor, value).asDrawable(resources))
if (value) {
negativeTextView.setTextColor(tintColor)
negativeView.contentDescription = toContentDescription(R.string.negative_habit_form, R.string.on)
} else {
negativeTextView.setTextColor(ContextCompat.getColor(context, R.color.gray_100))
negativeView.contentDescription = toContentDescription(R.string.negative_habit_form, R.string.off)
}
}
private fun toContentDescription(descriptionStringId: Int, statusStringId: Int): String {
return context.getString(descriptionStringId) + ", " + context.getString(statusStringId)
}
init {
View.inflate(context, R.layout.task_form_habit_scoring, this)
gravity = Gravity.CENTER
positiveVew.setOnClickListener {
positiveView.setOnClickListener {
isPositive = !isPositive
sendAccessibilityEvent(AccessibilityEvent.CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION)
}
negativeView.setOnClickListener {
isNegative = !isNegative
sendAccessibilityEvent(AccessibilityEvent.CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION);
}
isPositive = true

View file

@ -5,6 +5,7 @@ import android.app.TimePickerDialog
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.animation.Animation
import android.view.animation.LinearInterpolator
@ -59,12 +60,16 @@ class ReminderItemFormView @JvmOverloads constructor(
rotate.interpolator = LinearInterpolator()
rotate.fillAfter = true
button.startAnimation(rotate)
// This button is not clickable in this state, so make screen readers skip it.
button.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
} else {
val rotate = RotateAnimation(0f, 135f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f)
rotate.duration = animDuration
rotate.interpolator = LinearInterpolator()
rotate.fillAfter = true
button.startAnimation(rotate)
// This button IS now clickable, so allow screen readers to focus it.
button.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
}
}
@ -80,6 +85,9 @@ class ReminderItemFormView @JvmOverloads constructor(
(parent as? ViewGroup)?.removeView(this)
}
}
// It's ok to make the description always be 'Delete Reminder' because when this button is
// a plus button we set it as 'unimportant for accessibility' so it can't be focused.
button.contentDescription = context.getString(R.string.delete_reminder)
button.drawable.mutate().setTint(tintColor)
textView.setOnClickListener {
@ -99,6 +107,7 @@ class ReminderItemFormView @JvmOverloads constructor(
timePickerDialog.show()
}
}
textView.labelFor = button.id
}
override fun onTimeSet(view: TimePicker?, hourOfDay: Int, minute: Int) {
valueChangedListener?.let {

View file

@ -3,6 +3,7 @@ package com.habitrpg.android.habitica.ui.views.tasks.form
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.accessibility.AccessibilityEvent
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.Space
@ -24,7 +25,9 @@ class TaskDifficultyButtons @JvmOverloads constructor(
field = value
removeAllViews()
addAllButtons()
selectedButton.sendAccessibilityEvent(AccessibilityEvent.CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION)
}
private lateinit var selectedButton: View
init {
addAllButtons()
@ -42,6 +45,9 @@ class TaskDifficultyButtons @JvmOverloads constructor(
space.layoutParams = layoutParams
addView(space)
}
if (difficulty.value == selectedDifficulty) {
selectedButton = button;
}
}
}
@ -59,10 +65,21 @@ class TaskDifficultyButtons @JvmOverloads constructor(
}
val drawable = HabiticaIconsHelper.imageOfTaskDifficultyStars(difficultyColor, difficulty.value, true).asDrawable(resources)
view.findViewById<ImageView>(R.id.image_view).setImageDrawable(drawable)
view.findViewById<TextView>(R.id.text_view).text = context.getText(difficulty.nameRes)
val buttonText = context.getText(difficulty.nameRes)
view.findViewById<TextView>(R.id.text_view).text = buttonText
view.contentDescription = toContentDescription(buttonText, isActive)
view.setOnClickListener {
selectedDifficulty = difficulty.value
}
return view
}
private fun toContentDescription(buttonText: CharSequence, isActive: Boolean): String {
val statusString = if (isActive) {
context.getString(R.string.selected)
} else context.getString(R.string.not_selected)
return "$buttonText, $statusString"
}
}

View file

@ -9,6 +9,7 @@ import android.text.TextUtils
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.view.accessibility.AccessibilityEvent
import android.widget.*
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.content.ContextCompat
@ -226,6 +227,8 @@ class TaskSchedulingControls @JvmOverloads constructor(
1 -> weeklyRepeat.su = isActive
}
createWeeklyRepeatViews()
weeklyRepeatWrapper.findViewWithTag<TextView>(weekday).sendAccessibilityEvent(
AccessibilityEvent.CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION)
}
private fun isWeekdayActive(weekday: Int): Boolean {
@ -247,10 +250,12 @@ class TaskSchedulingControls @JvmOverloads constructor(
val lastWeekday = weekdayOrder.last()
for (weekdayCode in weekdayOrder) {
val button = TextView(context, null, 0, R.style.TaskFormWeekdayButton)
val isActive = isWeekdayActive(weekdayCode)
val layoutParams = LayoutParams(size, size)
button.layoutParams = layoutParams
button.text = weekdays[weekdayCode].first().toUpperCase().toString()
val isActive = isWeekdayActive(weekdayCode)
button.contentDescription = toContentDescription(weekdays[weekdayCode], isActive)
button.tag = weekdayCode
if (isActive) {
button.background = context.getDrawable(R.drawable.habit_scoring_circle_selected)
button.background.mutate().setTint(tintColor)
@ -274,25 +279,37 @@ class TaskSchedulingControls @JvmOverloads constructor(
}
private fun configureMonthlyRepeatViews() {
val white = ContextCompat.getColor(context, R.color.white)
val unselectedText = ContextCompat.getColor(context, R.color.gray_100)
val unselectedBackground = ContextCompat.getColor(context, R.color.taskform_gray)
if (daysOfMonth != null && daysOfMonth?.isEmpty() != true) {
monthlyRepeatDaysButton.setTextColor(white)
monthlyRepeatDaysButton.background.mutate().setTint(tintColor)
styleButtonAsActive(monthlyRepeatDaysButton)
} else {
monthlyRepeatDaysButton.setTextColor(unselectedText)
monthlyRepeatDaysButton.background.mutate().setTint(unselectedBackground)
styleButtonAsInactive(monthlyRepeatDaysButton)
}
if (weeksOfMonth != null && weeksOfMonth?.isEmpty() != true) {
monthlyRepeatWeeksButton.setTextColor(white)
monthlyRepeatWeeksButton.background.mutate().setTint(tintColor)
styleButtonAsActive(monthlyRepeatWeeksButton)
} else {
monthlyRepeatWeeksButton.setTextColor(unselectedText)
monthlyRepeatWeeksButton.background.mutate().setTint(unselectedBackground)
styleButtonAsInactive(monthlyRepeatWeeksButton)
}
}
private fun styleButtonAsActive(button: TextView) {
button.setTextColor(ContextCompat.getColor(context, R.color.white))
button.background.mutate().setTint(tintColor)
button.contentDescription = toContentDescription(button.text, true)
}
private fun styleButtonAsInactive(button: TextView) {
button.setTextColor(ContextCompat.getColor(context, R.color.gray_100))
button.background.mutate().setTint(ContextCompat.getColor(context, R.color.taskform_gray))
button.contentDescription = toContentDescription(button.text, false)
}
private fun toContentDescription(buttonText: CharSequence, isActive: Boolean): String {
val statusString = if (isActive) {
context.getString(R.string.selected)
} else context.getString(R.string.not_selected)
return "$buttonText, $statusString"
}
private fun generateSummary() {
var frequencyQualifier = ""