it-swarm.dev

¿Cómo crear grupos focales accesibles en ConstraintLayout?

Imagine que tiene un LinearLayout dentro de un RelativeLayout que contiene 3 TextViews con 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>        

Cuando active TalkbackReader y haga clic en un TextView en LinearLayout, TalkbackReader leerá "Artista", "Canción" OR "Álbum" por ejemplo .

Pero podría poner esos primeros 3 TextViews en un grupo de enfoque, usando:

<LinearLayout
    Android:focusable="true
    ...

Ahora el TalkbackReader leería "Artist Song Album".

El 2 unrelated TextViewstodavía estarían solos y no leídos, que es el comportamiento que quiero lograr.

(Ver Ejemplo de codelabs de Google para referencia)

Ahora estoy tratando de recrear este comportamiento con ConstrainLayout pero no veo cómo.

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

Poner widgets en un "grupo" no parece funcionar:

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

Entonces, ¿cómo puedo volver a crear grupos de enfoque para la accesibilidad en el ConstrainLayout?

[EDITAR]: Parece ser el caso, que la única forma de crear una solución es usar "focusable = true" en el diseño externo de restricción y/o "focusable = false" en las vistas mismas. Esto tiene algunos inconvenientes que uno debe considerar cuando se trata de la navegación del teclado/cajas de interruptores:

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

20
hamena314

Los grupos de enfoque basados ​​en ViewGroups todavía funcionan dentro de ConstraintLayout, por lo que puede reemplazar LinearLayouts y RelativeLayouts con ConstraintLayouts y TalkBack seguirá funcionando como se esperaba. Pero, si está tratando de evitar anidandoViewGroups dentro de ConstraintLayout, manteniendo el objetivo de diseño de una jerarquía de vista plana, aquí hay una manera de hacerlo .

Mueva el TextViews del foco ViewGroup que menciona directamente al nivel superior ConstraintLayout. Ahora colocaremos una simple View transparente encima de estas TextViews usando las restricciones ConstraintLayout. Cada TextView será miembro del nivel superior ConstraintLayout, por lo que el diseño será plano. Dado que la superposición está encima de TextViews, recibirá todos los eventos táctiles antes del TextViews subyacente. Aquí está la estructura de diseño:

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

Ahora podemos especificar manualmente una descripción de contenido para la superposición que es una combinación del texto de cada uno de los TextViews subyacentes. Para evitar que cada TextView acepte el foco y pronuncie su propio texto, configuraremos Android:importantForAccessibility="no". Cuando tocamos la vista superpuesta, escuchamos el texto combinado del TextViews hablado.

Lo anterior es la solución general pero, mejor aún, sería una implementación de una vista de superposición personalizada que administrará las cosas automáticamente. La superposición personalizada que se muestra a continuación sigue la sintaxis general de Group helper en ConstraintLayout y automatiza gran parte del procesamiento descrito anteriormente.

La superposición personalizada hace lo siguiente:

  1. Acepta una lista de identificadores que serán agrupados por el control como el Group ayudante de ConstraintLayout.
  2. Inhabilita la accesibilidad para los controles agrupados configurando View.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO) en cada vista. (Esto evita tener que hacer esto manualmente).
  3. Cuando se hace clic, el control personalizado presenta una concatenación del texto de las vistas agrupadas al marco de accesibilidad. El texto recopilado para una vista proviene de contentDescription, getText() o hint. (Esto evita tener que hacer esto manualmente. Otra ventaja es que también recogerá cualquier cambio realizado en el texto mientras se ejecuta la aplicación).

La vista de superposición aún necesita posicionarse manualmente dentro del XML de diseño para superponer TextViews.

Aquí hay un diseño de muestra que muestra el enfoque ViewGroup mencionado en la pregunta y la superposición personalizada. El grupo de la izquierda es el enfoque tradicional ViewGroup que demuestra el uso de un ConstraintLayout incorporado; El derecho es el método de superposición que utiliza el control personalizado. El TextView en la parte superior etiquetado como "foco inicial" está ahí para capturar el foco inicial y facilitar la comparación de los dos métodos.

Con el ConstraintLayout seleccionado, TalkBack habla "Artista, Canción, Álbum".

enter image description here

Con la superposición de vista personalizada seleccionada, TalkBack también habla "Artista, Canción, Álbum".

enter image description here

A continuación se muestra el diseño de muestra y el código para la vista personalizada. Advertencia: aunque esta vista personalizada funciona para el propósito establecido usando TextViews, no es un reemplazo robusto para el método tradicional. Por ejemplo: la superposición personalizada hablará el texto de los tipos de vista que se extienden TextView como EditText mientras que el método tradicional no lo hace.

Ver proyecto de muestra en 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
Defina los atributos personalizados para la vista de superposición personalizada.

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

Me encontré con el mismo problema recientemente y decidí implementar una nueva Clase usando los nuevos ayudantes ConstraintLayout (disponibles desde la restricción 1.1) para que podamos usarlo de la misma manera que usamos la vista de Grupo.

La implementación es una versión simplificada de respuesta de Cheticamp y su idea de crear una nueva Vista que manejaría la accesibilidad.

Aquí está mi implementación:

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 {
                constraintLayoutParent.getViewById(it)?.onPopulateAccessibilityEvent(event)
            }
        }
    }
}

También disponible como Gist: https://Gist.github.com/JulienArzul/8068d43af3523d75b72e9d1edbfb4298

Lo usaría de la misma manera que usa un grupo:

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

Esta muestra organiza TextView e ImageView en un solo grupo para fines de accesibilidad. Todavía puede agregar otras vistas que toman el foco y son leídas por el lector de accesibilidad dentro del diseño de restricción.

La Vista es transparente, pero puede elegir el área en la que se muestra cuando se enfoca utilizando los atributos de diseño de restricción regulares.
En mi ejemplo, el grupo de accesibilidad se muestra sobre el RestraintLayout completo, pero puede elegir alinearlo con algunas o todas sus vistas referenciadas modificando el app:"layout_constraint..." atributos.

3
Julien Arzul

Establecer descripción del contenido

Asegúrese de que ConstraintLayout esté configurado en enfocable con un explícito descripción del contenido . Además, asegúrese de que los elementos secundarios TextViews estén no configurados en enfocables, a menos que desee que se lean de forma independiente.

[~ # ~] 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 prefiere establecer dinámicamente la descripción del contenido de ConstraintLayout en el código, puede concatenar los valores de texto de cada TextView relevante:

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

constraintLayout.setContentDescription(description);

Resultados de accesibilidad

Cuando active Talkback, ConstraintLayout ahora se enfocará y leerá su descripción de contenido.

Captura de pantalla con Talkback mostrado como subtítulo:

Accessibility test screen-shot

Explicación detallada

Aquí está el XML completo para la captura de pantalla de ejemplo anterior. Tenga en cuenta que los atributos enfocables y de descripción de contenido se establecen solo en el ConstraintLayout primario, no en las TextViews secundarias. Esto hace que TalkBack nunca se enfoque en las vistas secundarias individuales, sino solo en el contenedor principal (por lo tanto, solo lee la descripción del contenido de ese elemento primario).

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

Elementos de enfoque anidados

Si desea que sus TextViews no relacionadas puedan enfocarse independientemente del ConstraintLayout principal, puede establecer esas TextViews en focusable=true también. Esto hará que esas vistas de texto se vuelvan enfocables y se lean individualmente, ¡después el diseño de restricción.

Si desea agrupar los TextViews no relacionados en un anuncio de TalkBack singular (separado del ConstraintLayout), sus opciones son limitadas:

  1. Anide las vistas no relacionadas en otro ViewGroup, con su propia descripción de contenido, o
  2. Establecer focusable=true solo en el primer elemento no relacionado y establezca su descripción de contenido como un anuncio único para ese subgrupo (por ejemplo, "elementos no relacionados").

La opción n. ° 2 se consideraría un truco, pero le permitiría mantener una jerarquía de vista plana (si realmente desea evitar el anidamiento).

Pero si está implementando múltiples subgrupos de elementos de enfoque, la forma más adecuada sería organizar los grupos como ViewGroups anidados. Según la documentación de accesibilidad Android en agrupaciones naturales :

Para definir el patrón de enfoque adecuado para un conjunto de contenido relacionado, coloque cada parte de la estructura en su propio ViewGroup enfocable

1
hungryghost
  1. Establezca el diseño de restricción como enfocable (configurando Android: focusable = "true" en el diseño de restricción)

  2. Establecer la descripción del contenido en Diseño de restricción

  3. establezca focusable = "false" para las vistas que no se incluirán.

Editar basado en comentarios Solo aplicable si hay un solo grupo de enfoque en el diseño de restricción.

0
m__