The Problem
I was working on an accessibility project and the TalkBack experience for this view was terrible:
Working with this view took two steps by the user, the TalkBack announcing each section individually:
- “Email note to customer” swipe gesture to move to next view
- “If disabled will add the note as private, OFF, switch…double-tap to toggle”
When the user double-tapped: “on” or “off”
The Goal
I wanted TalkBack to treat my view similar to the experience of interacting with the Mono audio settings option in Accessibility Settings:
The entire group of elements are treated as a widget:
“Mono audio, combine channels when playing audio, OFF, switch….double-tap to activate”
When the user double-tapped: “on” or “off”
Initial Attempts
My custom view wasn’t technically a custom view, it was simply a container of objects nested in a much larger view:
I read through the Android Accessibility documentation and added android:focusable=true
to the root LinearLayout container, but all that did was highlight the entire view on activate, which is better, but it still only announced the title: “Email note to customer”. I still had to swipe to get to the actual switch to hear “If disabled will add the note as private” and be given the option to toggle.
I added android:labelFor="@+id/view_switch"
to the textview containing the summary and tried again.
Now my TalkBack flow was:
- “Email note to customer” swipe gesture to move to next view
- “If disabled will add the note as private, OFF, switch, for email note to customer…double-tap to toggle”
Nope, that wasn’t the fix either. I decided the best plan was to just create a custom view component and handle the accessibility in a more direct manner.
Building the Custom View [GitHub]
I’ve always loved building custom views and Kotlin just makes them so much more elegant. I built a little sandbox project to test out the various changes. Included in this project is the original component – for easily comparing improvements, and the updated version, that I’ve lovingly labeled Talkbackified!
Starting with the layout xml, I always use <merge/> to flatten my hierarchy as much as possible.
view_single_option_toggle.xml:
<?xml version="1.0" encoding="utf-8"?> <merge xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:focusable="true"> <TextView android:id="@+id/switchSetting_title" style="@android:style/TextAppearance.DeviceDefault.Large" android:layout_width="match_parent" android:layout_height="wrap_content" android:clickable="false" android:textAlignment="viewStart" android:importantForAccessibility="no" tools:text="Email note to customer"/> <Switch android:id="@+id/switchSetting_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAlignment="viewStart" android:importantForAccessibility="no" android:clickable="false" tools:text="If disabled will add the note as private"/> </merge>
Then I opened up the ability to customize the title, summary and checked state by adding some custom attributes:
attrs.xml
<?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name="SingleOptionToggleView"> <attr name="switchTitle" format="reference|string"/> <attr name="switchSummary" format="reference|string"/> <attr name="switchChecked" format="boolean"/> </declare-styleable> </resources>
Then create my custom class:
SingleOptionToggleView.kt
class SingleOptionToggleView @JvmOverloads constructor(ctx: Context, attrs: AttributeSet? = null) : LinearLayout(ctx, attrs), Checkable { init { orientation = LinearLayout.VERTICAL View.inflate(context, R.layout.view_single_option_toggle, this) if (attrs != null) { val a = context.obtainStyledAttributes(attrs, R.styleable.SingleOptionToggleView) try { val title = a.getString(R.styleable.SingleOptionToggleView_switchTitle).orEmpty() switchSetting_title.text = title switchSetting_switch.text = a.getString(R.styleable.SingleOptionToggleView_switchSummary).orEmpty() switchSetting_switch.isChecked = a.getBoolean(R.styleable.SingleOptionToggleView_switchChecked, false) switchSetting_switch.textOff = resources.getText(R.string.switch_disabled) switchSetting_switch.textOn = resources.getText(R.string.switch_enabled) setOnClickListener { toggle() } } finally { a.recycle() } } } }
Using this view is as easy as plugging it into your main layout:
android:focusable="true" android:clickable="true" android:importantForAccessibility="yes" android:background="#ffffff" app:switchTitle="@string/option_title" app:switchSummary="@string/option_summary"/>
Supporting Talkback
To get TalkBack working mostly the way I wanted it to (I’ll get to that later), I essentially had to override three methods:
//region Customized Accessibility events override fun getAccessibilityClassName() = this::javaClass.name override fun onInitializeAccessibilityEvent(event: AccessibilityEvent?) { event?.isChecked = isChecked super.onInitializeAccessibilityEvent(event) } override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo?) { info?.isCheckable = true info?.isChecked = isChecked super.onInitializeAccessibilityNodeInfo(info) } //endregion
Lessons Learned
- Setting the child switch element to
importantForAccessiblity="no"
removed the action prompt of “double-tap to toggle”. But if you don’t set this then the focus will go to that switch next even if you set itsfocusable=false
property!- Fix: Add
importantForAccessibility="yes"
to the root layout view. Since the root view isCheckable
, and I’ve included the code to notify the Accessibility service of this inonInitializeAccessibilityInfo(...)
, the action prompt returns.
- Fix: Add
dispatchPopulateAccessibilityEvent()
is only called if your view registers text content change events, but I’m still not 100% sure how all that works.- I was able to customize the initial checked-state announcement when the component first is activated by setting the
textOn
andtextOff
properties of my views switch.
Changes for another day
I tried to change the wording used when the user changes the state of my custom component, but eventually decided to leave that for another day. It defaults to “Checked” or “Not Checked”, when what I really wanted it to say was “Option on” or “Option off”.
Summary
The finished product works nicely with TalkBack. When activated, the component is announced with:
“Email note to customer, if disabled will add the note as private, option not checked”
When the state of the view is changed to on, it simply announces:
“Checked”