web-dev-qa-db-fra.com

Comment créer des groupes de discussion accessibles dans ConstraintLayout?

Imaginez que vous avez une LinearLayout dans une RelativeLayout qui contient 3 TextViews avec 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>        

Lorsque vous activez TalkbackReader et que vous cliquez sur une TextView dans la LinearLayout, TalkbackReader lit par exemple "Artiste", "Morceau" OR "Album".

Mais vous pouvez mettre ces 3 TextViews premières dans un groupe de discussion en utilisant:

<LinearLayout
    Android:focusable="true
    ...

TalkbackReader se lirait alors comme suit: "Album de chansons d'artiste". 

Les 2 unrelated TextViewsstill seraient par eux-mêmes et non lus, ce qui est le comportement que je veux obtenir.

(Voir exemple Google Codelabs pour référence)

J'essaie maintenant de recréer ce comportement avec la variable ConstrainLayout mais je ne vois pas comment.

<ConstraintLayout>
    <TextView artist/>
    <TextView song/>
    <TextView album/>
    <TextView unrelated_textview1/>
    <TextView unrelated_textview2/>
</ConstraintLayout>

Mettre les widgets dans un "groupe" ne semble pas fonctionner:

<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"
    />

Alors, comment puis-je recréer des groupes de discussion pour l'accessibilité dans la ConstrainLayout?

[EDIT]: Il semble que le seul moyen de créer une solution consiste à utiliser "focusable = true" sur le ConstraintLayout extérieur et/ou "focusable = false" sur les vues elles-mêmes. . Cela présente certains inconvénients qu’il faut prendre en compte lorsqu’il s’agit de la navigation au clavier/des boîtes de commutation: 

https://github.com/googlecodelabs/Android-accessibility/issues/4

18
hamena314

Les groupes de discussion basés sur ViewGroups fonctionnent toujours dans ConstraintLayout, de sorte que vous puissiez remplacer LinearLayouts et RelativeLayouts avec ConstraintLayouts et TalkBack fonctionnera toujours comme prévu. Mais, si vous essayez d'éviter imbricationViewGroups dans ConstraintLayout, tout en respectant l'objectif de conception d'une hiérarchie à vue plane, voici une façon de le faire.

Déplacez la TextViews du focus ViewGroup que vous mentionnez directement dans le niveau supérieur ConstraintLayout. Nous allons maintenant placer un simple View transparent sur ces TextViews en utilisant ConstraintLayout contraintes. Chaque TextView sera un membre du plus haut niveau ConstraintLayout, de sorte que la présentation sera plate. Comme la superposition est au-dessus de la TextViews, elle recevra tous les événements tactiles avant la TextViews sous-jacente. Voici la structure de mise en page:

<ConstaintLayout>
    <TextView>
    <TextView>
    <TextView>
    <View> [overlays the above TextViews]
</ConstraintLayout>

Nous pouvons maintenant spécifier manuellement une description du contenu de la superposition qui est une combinaison du texte de chacun des TextViews sous-jacents. Pour empêcher chaque TextView d’accepter la mise au point et de prononcer son propre texte, nous allons définir Android:importantForAccessibility="no". Lorsque nous touchons la vue de superposition, nous entendons le texte combiné du TextViews parlé.

Ce qui précède est la solution générale mais, mieux encore, serait une implémentation d’une vue de superposition personnalisée qui gérera les choses automatiquement. La superposition personnalisée illustrée ci-dessous suit la syntaxe générale de l'assistant Group dans ConstraintLayout et automatise une grande partie du traitement décrit ci-dessus.

La superposition personnalisée fait ce qui suit:

  1. Accepte une liste d'identifiants qui seront regroupés par le contrôle comme l'assistant Group de ConstraintLayout
  2. Désactive l'accessibilité pour les contrôles groupés en définissant View.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO) sur chaque vue. (Cela évite d'avoir à le faire manuellement.)
  3. Lorsque vous cliquez dessus, le contrôle personnalisé présente une concaténation du texte des vues groupées dans la structure d'accessibilité. Le texte collecté pour une vue provient soit de contentDescription, getText() ou de hint. (Cela évite de devoir le faire manuellement. Un autre avantage est qu'il détectera également les modifications apportées au texte pendant l'exécution de l'application.)

La vue de superposition doit encore être positionnée manuellement dans le XML de présentation pour superposer la TextViews.

Voici un exemple de présentation montrant l’approche ViewGroup mentionnée dans la question et la superposition personnalisée. Le groupe de gauche est l'approche traditionnelle ViewGroup démontrant l'utilisation d'un ConstraintLayout intégré; La droite est la méthode de superposition utilisant le contrôle personnalisé. Le TextView en haut intitulé "focus initial" est juste là pour capturer le focus initial afin de faciliter la comparaison des deux méthodes.

Avec le ConstraintLayout sélectionné, TalkBack parle "Artiste, chanson, album".

 enter image description here

Avec la superposition de vue personnalisée sélectionnée, TalkBack parle également "Artiste, chanson, album". 

 enter image description here

Vous trouverez ci-dessous un exemple de présentation et le code de la vue personnalisée. Mise en garde: bien que cette vue personnalisée fonctionne dans le but indiqué en utilisant TextViews, elle ne remplace pas de manière robuste la méthode traditionnelle. Par exemple: la superposition personnalisée exprimera le texte des types de vues étendus TextView comme EditText alors que la méthode traditionnelle ne le fait pas.

Voir le exemple de projet sur 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
Définissez les attributs personnalisés pour la vue de superposition personnalisée.

<resources>  
    <declare-styleable name="AccessibilityOverlay">  
        <attr name="accessible_group" format="string" />  
    </declare-styleable>  
</resources>
1
Cheticamp

Définir le contenu Description

Assurez-vous que la variable ConstraintLayout est définie sur focusable avec un description du contenu explicite. Assurez-vous également que les enfants TextViews sont non définis sur focusable, sauf si vous souhaitez qu'ils soient lus indépendamment.

XML

<ConstraintLayout
  Android:focusable="true"
  Android:contentDescription="artist, song, album">

    <TextView artist/>
    <TextView song/>
    <TextView album/>
    <TextView unrelated 1/>
    <TextView unrelated 2/>

</ConstraintLayout>

Java

Si vous préférez définir de manière dynamique la description du contenu de ConstraintLayout dans le code, vous pouvez concaténer les valeurs de texte de chaque TextView pertinente:

String description = tvArtist.getText().toString() + ", " 
    + tvSong.getText().toString() + ", "
    + tvAlbum.getText().toString();

constraintLayout.setContentDescription(description);

Résultats d'accessibilité

Lorsque vous activez Talkback, ConstraintLayout se concentre maintenant et lit sa description de contenu.

Capture d'écran avec Talkback affiché en légende:

Accessibility test screen-shot

Explication détaillée

Voici le code XML complet pour l'exemple ci-dessus. Notez que les attributs de description de contenu et de focus ne sont définis que dans ConstraintLayout parent, et non dans TextViews enfant. Cela a pour effet que TalkBack ne se concentre jamais sur les vues enfants individuelles, mais uniquement sur le conteneur parent (donc, ne lisant que la description du contenu de ce 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>

Éléments de focus imbriqués

Si vous voulez que vos TextViews non liés puissent être centrés indépendamment du parent ConstraintLayout, vous pouvez également définir ces TextViews sur focusable=true. Cela fera en sorte que ces TextViews puissent être mis au point et lus individuellement, après le ConstraintLayout.

Si vous souhaitez regrouper les TextViews non liées dans une annonce TalkBack unique (distincte de ConstraintLayout), vos options sont limitées:

  1. Soit imbriquer les vues indépendantes dans une autre ViewGroup, avec sa propre description de contenu, ou
  2. Définissez focusable=true uniquement sur le premier élément non associé et définissez sa description de contenu comme une annonce unique pour ce sous-groupe (par exemple, "éléments non associés").

L'option 2 serait considérée un peu comme un hack, mais vous permettrait de maintenir une hiérarchie de vues à plat (si vous voulez vraiment éviter l'imbrication).

Mais si vous implémentez plusieurs sous-groupes d'éléments prioritaires, la méthode la plus appropriée consiste à organiser les groupes en tant que ViewGroups imbriqués. Selon la documentation d'accessibilité Android sur groupements naturels :

Pour définir le modèle de mise au point approprié pour un ensemble de contenu associé, placez chaque élément de la structure dans son propre groupe de vues focusable

1
hungryghost
  1. Définissez la disposition de la contrainte sur focusable (en définissant Android: focusable = "true" dans la présentation de la contrainte)

  2. Définir la description du contenu sur Mise en forme des contraintes

  3. set focusable = "false" pour les vues à ne pas inclure.

Édition basée sur les commentaires Applicable uniquement s'il existe un seul groupe de focus dans la disposition des contraintes.

0
m__