Imagine you have a LinearLayout
inside a RelativeLayout
that contains 3 TextViews
with artist, song and album
:
<RelativeLayout
...
<LinearLayout
android:id="@id/text_view_container"
android:layout_width="warp_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@id/artist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Artist"/>
<TextView
android:id="@id/song"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Song"/>
<TextView
android:id="@id/album"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="album"/>
</LinearLayout>
<TextView
android:id="@id/unrelated_textview1/>
<TextView
android:id="@id/unrelated_textview2/>
...
</RelativeLayout>
When you activate the TalkbackReader and click on a TextView
in the LinearLayout
, the TalkbackReader will read "Artist", "Song" OR "Album" for example.
But you could put those first 3 TextViews
into a focus group, by using:
<LinearLayout
android:focusable="true
...
Now the TalkbackReader would read "Artist Song Album".
The 2 unrelated TextViews
still would be on their own and not read, which is the behaviour I want to achieve.
(See Google codelabs example for reference)
I am now trying to re-create this behaviour with the ConstrainLayout
but dont see how.
<ConstraintLayout>
<TextView artist/>
<TextView song/>
<TextView album/>
<TextView unrelated_textview1/>
<TextView unrelated_textview2/>
</ConstraintLayout>
Putting widgets into a "group" does not seem to work:
<android.support.constraint.Group
android:id="@+id/group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:focusable="true"
android:importantForAccessibility="yes"
app:constraint_referenced_ids="artist,song,album"
/>
So how can I re-create focus-groups for accessibility in the ConstrainLayout
?
[EDIT]: It seems to be the case, that the only way to create a solution is to use "focusable=true" on the outer ConstraintLayout and / or "focusable=false" on the views themselves. This has some drawbacks that one should consider when dealing with keyboard navigation / switch-boxes:
https://github.com/googlecodelabs/android-accessibility/issues/4
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:
Group
helper of ConstraintLayout
. View.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO)
on each view. (This avoids having to do this manually.)contentDescription
, getText()
or the hint
. (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>