How to create accessible focus groups in ConstraintLayout?
The focus groups based upon ViewGroups
still work within ConstraintLayout
, so you could replace LinearLayouts
and RelativeLayouts
with ConstraintLayouts
and TalkBack will still work as expected. But, if you are trying to avoid nesting ViewGroups
within ConstraintLayout
, keeping with the design goal of a flat view hierarchy, here is a way to do it.
Move the TextViews
from the focus ViewGroup
that you mention directly into the top-level ConstraintLayout
. Now we will place a simple transparent View
on top of these TextViews
using ConstraintLayout
constraints. Each TextView
will be a member of the top-level ConstraintLayout
, so the layout will be flat. Since the overlay is on top of the TextViews
, it will receive all touch events before the underlying TextViews
. Here is the layout structure:
<ConstaintLayout>
<TextView>
<TextView>
<TextView>
<View> [overlays the above TextViews]
</ConstraintLayout>
We can now manually specify a content description for the overlay that is a combination of the text of each of the underlying TextViews
. To prevent each TextView
from accepting focus and speaking its own text, we will set android:importantForAccessibility="no"
. When we touch the overlay view, we hear the combined text of the TextViews
spoken.
The preceding is the general solution but, better yet, would be an implementation of a custom overlay view that will manage things automatically. The custom overlay shown below follows the general syntax of the Group
helper in ConstraintLayout
and automates much of the processing outlined above.
The custom overlay does the following:
- Accepts a list of ids that will be grouped by the control like the
Group
helper ofConstraintLayout
. - Disables accessibility for the grouped controls by setting
View.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO)
on each view. (This avoids having to do this manually.) - When clicked, the custom control presents a concatenation of the text of grouped views to the accessibility framework. The text collected for a view is either from the
contentDescription
,getText()
or thehint
. (This avoids having to do this manually. Another advantage is that it will also pick up any changes made to the text while the app is running.)
The overlay view still needs to be positioned manually within the layout XML to overlay the TextViews
.
Here is a sample layout showing the ViewGroup
approach mentioned in the question and the custom overlay. The left group is the traditional ViewGroup
approach demonstrating the use of an embedded ConstraintLayout
; The right is the overlay method using the custom control. The TextView
on top labeled "initial focus" is just there to capture the initial focus for ease of comparing the two methods.
With the ConstraintLayout
selected, TalkBack speaks "Artist, Song, Album".
With the custom view overlay selected, TalkBack also speaks "Artist, Song, Album".
Below is the sample layout and the code for the custom view. Caveat: Although this custom view works for the stated purpose using TextViews
, it is not a robust replacement for the traditional method. For example: The custom overlay will speak the text of view types extending TextView
such as EditText
while the traditional method does not.
See the sample project on GitHub.
activity_main.xml
<android.support.constraint.ConstraintLayout
android:id="@+id/layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.constraint.ConstraintLayout
android:id="@+id/viewGroup"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:focusable="true"
android:gravity="center_horizontal"
app:layout_constraintEnd_toStartOf="@+id/guideline"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/viewGroupHeading">
<TextView
android:id="@+id/artistText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Artist"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/songText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Song"
app:layout_constraintStart_toStartOf="@+id/artistText"
app:layout_constraintTop_toBottomOf="@+id/artistText" />
<TextView
android:id="@+id/albumText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Album"
app:layout_constraintStart_toStartOf="@+id/songText"
app:layout_constraintTop_toBottomOf="@+id/songText" />
</android.support.constraint.ConstraintLayout>
<TextView
android:id="@+id/artistText2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Artist"
app:layout_constraintBottom_toTopOf="@+id/songText2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toTopOf="@+id/viewGroup" />
<TextView
android:id="@+id/songText2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Song"
app:layout_constraintStart_toStartOf="@id/artistText2"
app:layout_constraintTop_toBottomOf="@+id/artistText2" />
<TextView
android:id="@+id/albumText2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Album"
app:layout_constraintStart_toStartOf="@+id/artistText2"
app:layout_constraintTop_toBottomOf="@+id/songText2" />
<com.example.constraintlayoutaccessibility.AccessibilityOverlay
android:id="@+id/overlay"
android:layout_width="0dp"
android:layout_height="0dp"
android:focusable="true"
app:accessible_group="artistText2, songText2, albumText2, editText2, button2"
app:layout_constraintBottom_toBottomOf="@+id/albumText2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/guideline"
app:layout_constraintTop_toTopOf="@id/viewGroup" />
<android.support.constraint.Guideline
android:id="@+id/guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.5" />
<TextView
android:id="@+id/viewGroupHeading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:importantForAccessibility="no"
android:text="ViewGroup"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@+id/guideline"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView4" />
<TextView
android:id="@+id/overlayHeading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:importantForAccessibility="no"
android:text="Overlay"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toTopOf="@+id/viewGroupHeading" />
<TextView
android:id="@+id/textView4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:text="Initial focus"
app:layout_constraintEnd_toStartOf="@+id/guideline"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toTopOf="parent" />
</android.support.constraint.ConstraintLayout>
AccessibilityOverlay.java
public class AccessibilityOverlay extends View {
private int[] mAccessibleIds;
public AccessibilityOverlay(Context context) {
super(context);
init(context, null, 0, 0);
}
public AccessibilityOverlay(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(context, attrs, 0, 0);
}
public AccessibilityOverlay(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context, attrs, defStyleAttr, 0);
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public AccessibilityOverlay(Context context, @Nullable AttributeSet attrs,
int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init(context, attrs, defStyleAttr, defStyleRes);
}
private void init(Context context, @Nullable AttributeSet attrs,
int defStyleAttr, int defStyleRes) {
String accessibleIdString;
TypedArray a = context.getTheme().obtainStyledAttributes(
attrs,
R.styleable.AccessibilityOverlay,
defStyleAttr, defStyleRes);
try {
accessibleIdString = a.getString(R.styleable.AccessibilityOverlay_accessible_group);
} finally {
a.recycle();
}
mAccessibleIds = extractAccessibleIds(context, accessibleIdString);
}
@NonNull
private int[] extractAccessibleIds(@NonNull Context context, @Nullable String idNameString) {
if (TextUtils.isEmpty(idNameString)) {
return new int[]{};
}
String[] idNames = idNameString.split(ID_DELIM);
int[] resIds = new int[idNames.length];
Resources resources = context.getResources();
String packageName = context.getPackageName();
int idCount = 0;
for (String idName : idNames) {
idName = idName.trim();
if (idName.length() > 0) {
int resId = resources.getIdentifier(idName, ID_DEFTYPE, packageName);
if (resId != 0) {
resIds[idCount++] = resId;
}
}
}
return resIds;
}
@Override
public void onAttachedToWindow() {
super.onAttachedToWindow();
View view;
ViewGroup parent = (ViewGroup) getParent();
for (int id : mAccessibleIds) {
if (id == 0) {
break;
}
view = parent.findViewById(id);
if (view != null) {
view.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
}
}
}
@Override
public void onPopulateAccessibilityEvent(AccessibilityEvent event) {
super.onPopulateAccessibilityEvent(event);
int eventType = event.getEventType();
if (eventType == AccessibilityEvent.TYPE_VIEW_SELECTED ||
eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED &&
getContentDescription() == null) {
event.getText().add(getAccessibilityText());
}
}
@NonNull
private String getAccessibilityText() {
ViewGroup parent = (ViewGroup) getParent();
View view;
StringBuilder sb = new StringBuilder();
for (int id : mAccessibleIds) {
if (id == 0) {
break;
}
view = parent.findViewById(id);
if (view != null && view.getVisibility() == View.VISIBLE) {
CharSequence description = view.getContentDescription();
// This misbehaves if the view is an EditText or Button or otherwise derived
// from TextView by voicing the content when the ViewGroup approach remains
// silent.
if (TextUtils.isEmpty(description) && view instanceof TextView) {
TextView tv = (TextView) view;
description = tv.getText();
if (TextUtils.isEmpty(description)) {
description = tv.getHint();
}
}
if (description != null) {
sb.append(",");
sb.append(description);
}
}
}
return (sb.length() > 0) ? sb.deleteCharAt(0).toString() : "";
}
private static final String ID_DELIM = ",";
private static final String ID_DEFTYPE = "id";
}
attrs.xml
Define the custom attributes for the custom overlay view.
<resources>
<declare-styleable name="AccessibilityOverlay">
<attr name="accessible_group" format="string" />
</declare-styleable>
</resources>
I ran into the same issue recently and I decided to implement a new Class using the new ConstraintLayout helpers (available since constraintlayout 1.1) so that we can use it in the same way that we use the Group view.
The implementation is a simplified version of Cheticamp's answer and his idea of creating a new View that would handle the accessibility.
Here is my implementation:
package com.julienarzul.android.accessibility
import android.content.Context
import android.os.Build
import android.util.AttributeSet
import android.view.View
import android.view.accessibility.AccessibilityEvent
import androidx.constraintlayout.widget.ConstraintHelper
import androidx.constraintlayout.widget.ConstraintLayout
class ConstraintLayoutAccessibilityHelper
@JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ConstraintHelper(context, attrs, defStyleAttr) {
init {
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
isScreenReaderFocusable = true
} else {
isFocusable = true
}
}
override fun updatePreLayout(container: ConstraintLayout) {
super.updatePreLayout(container)
if (this.mReferenceIds != null) {
this.setIds(this.mReferenceIds)
}
mIds.forEach {
container.getViewById(it)?.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}
}
override fun onPopulateAccessibilityEvent(event: AccessibilityEvent) {
super.onPopulateAccessibilityEvent(event)
val constraintLayoutParent = parent as? ConstraintLayout
if (constraintLayoutParent != null) {
event.text.clear()
mIds.forEach { id ->
val view: View? = constraintLayoutParent.getViewById(id)
// Adds this View to the Accessibility Event only if it is currently visible
if (view?.isVisible == true) {
view.onPopulateAccessibilityEvent(event)
}
}
}
}
}
Also available as a gist: https://gist.github.com/JulienArzul/8068d43af3523d75b72e9d1edbfb4298
You would use it the same way that you use a Group:
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/myTextView"
/>
<ImageView
android:id="@+id/myImageView"
/>
<com.julienarzul.android.accessibility.ConstraintLayoutAccessibilityHelper
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:constraint_referenced_ids="myTextView,myImageView" />
</androidx.constraintlayout.widget.ConstraintLayout>
This sample organises the TextView and ImageView in a single group for accessibility purposes. You can still add other Views that take the focus and are read by the Accessibility reader inside the ConstraintLayout.
The View is transparent but you can choose the area it is displayed on when focused by using the regular constraint layout attributes.
In my example, the accessibility group is displayed over the full ConstraintLayout but you could choose to align it with some or all of your referenced views by modifying the app:"layout_constraint..."
attributes.
EDIT: As suggested by @Mel' in the comments, I updated the ConstraintLayoutAccessibilityHelper
class to make sure only visible Views are added in the Accessibility event.
Android introduced android:screenReaderFocusable
to group contents in constraint layout. This will work for the above mentioned case. But requires API level 27.
https://developer.android.com/guide/topics/ui/accessibility/principles#content-groups
Set Content Description
Make sure the ConstraintLayout
is set to focusable with an explicit content description. Also, make sure the child TextViews
are not set to focusable, unless you want them to be read out independently.
XML
<ConstraintLayout
android:focusable="true"
android:contentDescription="artist, song, album">
<TextView artist/>
<TextView song/>
<TextView album/>
<TextView unrelated 1/>
<TextView unrelated 2/>
</ConstraintLayout>
Java
If you'd rather set the ConstraintLayout's content description dynamically in code, you can concatenate the text values from each relevant TextView
:
String description = tvArtist.getText().toString() + ", "
+ tvSong.getText().toString() + ", "
+ tvAlbum.getText().toString();
constraintLayout.setContentDescription(description);
Accessibility Results
When you turn Talkback on, the ConstraintLayout will now take focus and read out its content description.
Screenshot with Talkback displayed as caption:
Detailed Explanation
Here is the full XML for the above example screenshot. Notice that focusable and content description attributes are set only in the parent ConstraintLayout, not in the child TextViews. This causes TalkBack to never focus on the individual child views, but only the parent container (thus, reading out only the content description of that parent).
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="artist, song, album"
android:focusable="true"
tools:context=".MainActivity">
<TextView
android:id="@+id/text1"
style="@style/TextAppearance.AppCompat.Display1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Artist"
app:layout_constraintBottom_toTopOf="@+id/text2"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/text2"
style="@style/TextAppearance.AppCompat.Display1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Song"
app:layout_constraintBottom_toTopOf="@+id/text3"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/text1" />
<TextView
android:id="@+id/text3"
style="@style/TextAppearance.AppCompat.Display1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Album"
app:layout_constraintBottom_toTopOf="@id/text4"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/text2" />
<TextView
android:id="@+id/text4"
style="@style/TextAppearance.AppCompat.Display1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Unrelated 1"
app:layout_constraintBottom_toTopOf="@id/text5"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/text3" />
<TextView
android:id="@+id/text5"
style="@style/TextAppearance.AppCompat.Display1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Unrelated 2"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/text4" />
</android.support.constraint.ConstraintLayout>
Nested Focus Items
If you want your unrelated TextViews to be focusable independent of the parent ConstraintLayout, you can set those TextViews to focusable=true
as well. This will cause those TextViews to become focusable and read out individually, after the ConstraintLayout.
If you want to group the unrelated TextViews into a singular TalkBack announcement (separate from the ConstraintLayout), your options are limited:
- Either nest the unrelated views into another
ViewGroup
, with its own content description, or - Set
focusable=true
only on the first unrelated item and set its content description as a single announcement for that sub-group (e.g. "unrelated items").
Option #2 would be considered a bit of a hack, but would allow you to maintain a flat view hierarchy (if you really want to avoid nesting).
But if you are implementing multiple sub-groupings of focus items, the more appropriate way would be to organize the groupings as nested ViewGroups. Per the Android accessibility documentation on natural groupings:
To define the proper focusing pattern for a set of related content, place each piece of the structure into its own focusable ViewGroup