30

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 TextViewsstill 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

hamena314
  • 2,969
  • 5
  • 30
  • 57

8 Answers8

18

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:

  1. Accepts a list of ids that will be grouped by the control like the Group helper of ConstraintLayout.
  2. Disables accessibility for the grouped controls by setting View.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO) on each view. (This avoids having to do this manually.)
  3. 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 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".

enter image description here

With the custom view overlay selected, TalkBack also speaks "Artist, Song, Album".

enter image description here

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>
Cheticamp
  • 61,413
  • 10
  • 78
  • 131
  • I think this is a pretty good solution, altough not perfect. You still have to set the `contentDescription` manually either in the layout or in the sourcecode. But the part with the `View` seems to work fine, I even could add other elements to the group. Btw. the downvote was not from me, but I think the `LinearLayout` you are using might confuse more than it is helping to show that you can add other groups as well. Is there a way to get the elements within a `View` to read their own `contentDescription`? Else you have to manually create one, which is a downside (maintenance). – hamena314 Oct 21 '18 at 20:31
  • @hamena314 All noted. I do have a custom overlay view that I will post sometime later that will be more automatic and dynamic. The `LinearLayout` is there to permit side-by-side comparisons of the two methods. I will make the `LinearLayout` a `ConstraintLayout` and make the purpose a little clearer. – Cheticamp Oct 21 '18 at 21:12
  • I have chosen this solution as it offers the flexibility to create several, different groups in which you can set individual `contentDescriptions` which will often enough be needed in complex layouts. I **did not upvote** this solution, since it is quite complicated to read / understand. Hopefully @Cheticamp can edit this down to the basics a bit more...? But the main idea is still valid. – hamena314 Oct 22 '18 at 16:18
  • @hamena314 Did you go with the manual overlay or with the custom view overlay? – Cheticamp Oct 22 '18 at 17:17
9

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.

Julien Arzul
  • 961
  • 9
  • 7
  • I like this solution, as it should track the referenced views, if they're animated to another position. – Andrew Kelly Aug 16 '19 at 03:19
  • This is extremely elegant, as it resembles general ConstraintLayout practices. I was worried that accessibility was going to be painful in minimally hierarchical layouts, but this made my day, thanks. – Basti Vagabond Oct 23 '19 at 11:43
  • @BastiVagabond how did you get around the fact that these ids are private in the superclass "if (this.mReferenceIds != null) { this.setIds(this.mReferenceIds) } – Princeps Polycap Mar 05 '20 at 15:10
  • 1
    @PrincepsPolycap If you are referring to the `mIds` variable, this variable is declared protected. So if you are deriving from ConstraintHelper (as the example above demonstrates), you should be totally fine to read and write them. I had no issues getting the code above to run as is. You might want to check if you are deriving from the correct class. – Basti Vagabond Mar 05 '20 at 16:18
4

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:

Accessibility test screen-shot

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:

  1. Either nest the unrelated views into another ViewGroup, with its own content description, or
  2. 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

hungryghost
  • 9,463
  • 3
  • 23
  • 36
  • While I have learned through your answer that you globally can set a `contentDescription` in a `ConstraintLayout` this solution does not work as intended. In your example are 3 `TextViews` which have a manually defined `contentDescription`. But in my example I had 5 `TextViews`: 3 that should be read as a group and 2 that are "unrelated". Is there a way to make your solution work for my example? I will point out the importance of my example structure in my question. – hamena314 Oct 21 '18 at 20:10
  • As long as you don't have `focusable=true` for the unrelated TextViews, then it will work. Just remove focusable from any child view you don't want to be included in TalkBack. This follows standard accessibility practices. If a view is focusable (navigable in touch mode), it will be read out by screen readers. If not focusable, it will be excluded from TalkBack. Using `contentDescription` is the correct way to control accessibility read outs, per design. – hungryghost Oct 21 '18 at 22:16
  • I'll update my answer to clarify, match your question about the unrelated TextViews. – hungryghost Oct 21 '18 at 22:17
1

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

Madhan
  • 361
  • 4
  • 17
0
  1. Set the constraint layout as focusable (by setting android:focusable="true" in constraint layout)

  2. Set content description to Constraint Layout

  3. set focusable="false" for views that are not to be included.

Edit Based on Comments Only applicable if there is single focus group in constraint layout.

Mayank Kumar Chaudhari
  • 16,027
  • 10
  • 55
  • 122
  • 1
    While this solves my poorly constructed example, this will fail if you try to have more than one focus group in a ConstraintLayout. Imagine 5 Textviews, the first 2 should be a group, the third should be its own group, Textview 4 and 5 shall be in another group. – hamena314 Oct 22 '18 at 16:20
  • 1
    Also in step 2 you should add that the `contentDescription` needs to be added to the ConstraintLayout itself. – hamena314 Oct 22 '18 at 16:21
0

In XML ONLY.

For my particular situation I had the views grouped out in accessibility by default and I set the "important for accessibility field" to yes. android: importantForAccessibility="yes" this did nothing,

But when I went to EACH view and set importantForAccessibility respectively

android: importantForAccessibility="yes" if you want it announced

android: importantForAccessibility="no" if you don't want it announced --this solved my issue.

0

I have made a version of Julien Arzul's answer that does not require you to manually set the constraints for the invisible View. The class extends ConstraintLayout's Layer class, which automatically adapts to display over the referenced View ids.

    import android.content.Context
    import android.util.AttributeSet
    import android.view.View
    import android.view.accessibility.AccessibilityEvent
    import androidx.constraintlayout.helper.widget.Layer
    import androidx.constraintlayout.widget.ConstraintLayout
    import androidx.core.view.isVisible
    
    /**
     * This class can be used inside ConstraintLayouts to aggregate a particular group of its children into a single accessibility
     * focus group so they are all read together, in the same swipe stop.
     * This creates an invisible view that is drawn above all the specified children and receives focus in their place, reading all
     * of their descriptions in sequence.
     * The children's ids should be specified just like in a ConstraintLayout's Group, with app:constraint_referenced_ids.
     */
    
    class ConstraintLayoutFocusGroup @JvmOverloads constructor(
        context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
    ) : Layer(context, attrs, defStyleAttr) {
    
        init {
            importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
            isScreenReaderFocusable = true
        }
    
        override fun updatePreLayout(container: ConstraintLayout) {
            super.updatePreLayout(container)
    
            if (this.mReferenceIds != null) {
                this.setIds(this.mReferenceIds)
            }
    
            val children = mIds.map { container.getViewById(it) }
            makeNotImportantForAccessibility(children)
        }
    
        override fun onPopulateAccessibilityEvent(event: AccessibilityEvent) {
            super.onPopulateAccessibilityEvent(event)
    
            val constraintLayoutParent = parent as? ConstraintLayout
            if (constraintLayoutParent != null) {
                event.text.clear()
    
                val children = mIds.map { constraintLayoutParent.getViewById(it) }
                populateWithChildrenInfo(event, children)
            }
        }
    
        private fun makeNotImportantForAccessibility(views: Iterable<View?>) {
            views.filterNotNull().forEach { child ->
                child.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
            }
        }
    
        private fun populateWithChildrenInfo(event: AccessibilityEvent, views: Iterable<View?>) {
            views.filterNotNull().forEach { view ->
                if (view.isVisible) {
                    view.onPopulateAccessibilityEvent(event)
                }
            }
        }
    }

There is an issue I've been facing with it lately, though (which also happens with Julien's version), and if you have a solution please let me know.

Since version 13 Talkback has a feature that recognizes text/icons/images without content descriptions and does OCR on them speaking what it recognized in the Views, so with this solution Talkback first reads our invisible View's description like we wanted but after that it also reads the content of the Views we tried to hide from Talkback with importantForAccessibility="no".

So it ends up speaking something like: "textViewOneText, textViewTwoText; Detected: text textViewOneText textViewTwoText".

Victor Raft
  • 41
  • 1
  • 4
0

I followed @Victor Raft's solution and it worked great just like he said. However, I also ran into the same issue he talks about in his answer. I was finally able to find a solution/work around of that issue.

To add some context, the issue was with TalkBack version 13 recognizing texts/images/icons automatically and reading them if they are unlabeled. So my work around for this was to set the contentDescription of the custom view.

Now this will only work by taking textViews and adding those to the contentDescription of this new overlay/custom view. I will put below the method I updated that has my changes. I hope this helps!

override fun onPopulateAccessibilityEvent(event: AccessibilityEvent) {
    super.onPopulateAccessibilityEvent(event)
    var contentDescription = ""
    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)
            }
            (view as? TextView)?.text?.let {
                contentDescription += it
            }
        }
        this.contentDescription = contentDescription
    }
}
Sam Smith
  • 1
  • 2