Как создать доступные фокус-группы в ConstraintLayout?

Представьте, что у вас есть LinearLayout внутри RelativeLayout, который содержит 3 TextViews с 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>        

Когда вы активируете TalkbackReader и нажимаете TextView в LinearLayout, TalkbackReader будет читать, например, "Artist", "Song" ИЛИ "Album".

Но вы можете поместить эти первые 3 TextViews в фокус-группу, используя:

<LinearLayout
    android:focusable="true
    ...

Теперь TalkbackReader будет читать "Artist Song Album".

2 unrelated TextViews все еще был бы сам по себе и не читался, и я хочу добиться такого поведения.

(См. пример кода Google для)

Сейчас я пытаюсь воссоздать это поведение с помощью ConstrainLayout, но не понимаю, как.

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

Помещение виджетов в "группу" не работает:

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

Так как я могу заново создать фокус-группы для доступности в ConstrainLayout?

[EDIT]: Похоже, что единственный способ создать решение - использовать "focusable = true" на внешнем ConstraintLayout и/или "focusable = false" на самих представлениях. Это имеет ряд недостатков, которые следует учитывать при работе с клавиатурой/переключателями:

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

Ответ 1

Фокус-группы, основанные на ViewGroups, по-прежнему работают в ConstraintLayout, поэтому вы можете заменить LinearLayouts и RelativeLayouts на ConstraintLayouts, и TalkBack все равно будет работать, как и ожидалось. Но если вы пытаетесь избежать вложения ViewGroups в ConstraintLayout, следуя цели разработки иерархии плоского представления, вот способ сделать это.

Переместите TextViews из фокуса ViewGroup, который вы упомянули, прямо в верхний уровень ConstraintLayout. Теперь мы поместим простой прозрачный View поверх этих TextViews, используя ограничения ConstraintLayout. Каждый TextView будет членом верхнего уровня ConstraintLayout, поэтому макет будет плоским. Поскольку наложение находится над TextViews, оно получит все события касания до нижележащего TextViews. Вот структура макета:

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

Теперь мы можем вручную указать описание содержимого для наложения, которое представляет собой комбинацию текста каждого базового элемента TextViews. Чтобы запретить каждому TextView принимать фокус и произносить свой собственный текст, мы установим android:importantForAccessibility="no". Когда мы дотрагиваемся до оверлейного вида, мы слышим объединенный текст разговора TextViews.

Предыдущее является общим решением, но, еще лучше, будет реализация настраиваемого представления наложения, которое будет управлять вещами автоматически. Настраиваемый оверлей, показанный ниже, соответствует общему синтаксису помощника Group в ConstraintLayout и автоматизирует большую часть обработки, описанной выше.

Пользовательский оверлей делает следующее:

  1. Принимает список идентификаторов, которые будут сгруппированы элементом управления, например, помощником Group из ConstraintLayout.
  2. Отключает доступ для сгруппированных элементов управления, устанавливая View.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO) в каждом представлении. (Это позволяет избежать необходимости делать это вручную.)
  3. При щелчке пользовательский элемент управления представляет конкатенацию текста сгруппированных представлений в каркасе специальных возможностей. Текст, собранный для просмотра, взят из contentDescription, getText() или hint. (Это позволяет избежать необходимости делать это вручную. Еще одним преимуществом является то, что он также учитывает любые изменения, внесенные в текст во время работы приложения.)

Оверлейное представление по-прежнему необходимо размещать вручную в XML макета для наложения TextViews.

Вот примерный макет, показывающий подход ViewGroup, упомянутый в вопросе, и пользовательское наложение. Левая группа - это традиционный подход ViewGroup, демонстрирующий использование встроенного ConstraintLayout; Справа - метод наложения с использованием пользовательского элемента управления. TextView сверху с надписью "начальный фокус" как раз для того, чтобы захватить начальный фокус для облегчения сравнения двух методов.

С выбранным ConstraintLayout TalkBack говорит "Исполнитель, Песня, Альбом".

enter image description here

С выбранным наложением пользовательского представления TalkBack также говорит "Исполнитель, Песня, Альбом".

enter image description here

Ниже приведен пример макета и код для пользовательского представления. Предостережение: хотя это пользовательское представление работает для заявленной цели с использованием TextViews, оно не является надежной заменой традиционному методу. Например: пользовательское наложение будет озвучивать текст типов представлений, расширяющих TextView, таких как EditText, в то время как традиционный метод этого не делает.

Смотрите пример проекта на 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
Определите пользовательские атрибуты для пользовательского представления наложения.

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

Ответ 2

Недавно я столкнулся с той же проблемой, и я решил реализовать новый класс с помощью новых помощников ConstraintLayout (доступных начиная с limitintlayout 1.1), чтобы мы могли использовать его так же, как мы используем представление группы.

Реализация представляет собой упрощенную версию ответа Cheticamp и его идею создания нового представления, которое будет обрабатывать специальные возможности.

Вот моя реализация:

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

Также доступно в виде сущности: https://gist.github.com/JulienArzul/8068d43af3523d75b72e9d1edbfb4298

Вы будете использовать его так же, как вы используете группу:

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

Этот образец объединяет TextView и ImageView в одну группу для обеспечения доступности. Вы по-прежнему можете добавлять другие представления, которые фокусируются и читаются читателем специальных возможностей внутри ConstraintLayout.

Вид прозрачен, но вы можете выбрать область, на которой он отображается, когда сфокусирован, используя атрибуты макета обычного ограничения.
В моем примере группа специальных возможностей отображается поверх полного ConstraintLayout, но вы можете настроить ее для соответствия некоторым или всем ссылочным представлениям, изменив атрибуты app:"layout_constraint...".

Ответ 3

Установить описание контента

Убедитесь, что ConstraintLayout настроен на фокусировку с явным описанием содержания. Кроме того, убедитесь, что дочерний элемент TextViews не настроен на фокусировку, если только вы не хотите, чтобы его считывали независимо.

XML

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

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

</ConstraintLayout>

Java

Если вы предпочитаете динамически устанавливать описание содержимого ConstraintLayout в коде, вы можете объединить текстовые значения из каждого соответствующего TextView:

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

constraintLayout.setContentDescription(description);

Результаты специальных возможностей

Когда вы включите Talkback, ConstraintLayout теперь сфокусируется и прочитает описание его содержания.

Снимок экрана с Talkback, отображаемым в заголовке:

Accessibility test screen-shot

Подробное объяснение

Вот полный XML-код для приведенного выше снимка экрана. Обратите внимание, что атрибуты focusable и content description устанавливаются только в родительском ConstraintLayout, а не в дочерних TextViews. Это заставляет TalkBack никогда не фокусироваться на отдельных дочерних представлениях, а только на родительском контейнере (таким образом, считывая только описание содержимого этого родителя).

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

Вложенные элементы фокуса

Если вы хотите, чтобы ваши несвязанные TextViews были фокусируемыми независимо от родительского ConstraintLayout, вы можете также установить для этих TextViews значение focusable=true. Это приведет к тому, что эти TextViews станут фокусируемыми и будут считываться по отдельности после ConstraintLayout.

Если вы хотите сгруппировать несвязанные TextViews в отдельное объявление TalkBack (отдельно от ConstraintLayout), ваши возможности ограничены:

  1. Либо вложите несвязанные представления в другой ViewGroup с собственным описанием контента, либо
  2. Установите focusable=true только для первого несвязанного элемента и задайте его описание контента как отдельное объявление для этой подгруппы (например, "несвязанные элементы").

Вариант № 2 будет считаться чем-то вроде взлома, но он позволит вам поддерживать иерархию плоских представлений (если вы действительно хотите избежать вложения).

Но если вы реализуете несколько подгрупп фокусирующих элементов, более подходящим способом было бы организовать группировки как вложенные ViewGroups. Согласно документации о доступности Android для естественных группировок:

Чтобы определить правильный шаблон фокусировки для набора связанного контента, поместите каждый фрагмент структуры в свою собственную фокусируемую ViewGroup

Ответ 4

  1. Установите макет ограничения как фокусируемый (установив android: focusable = "true" в макете ограничения)

  2. Установить описание содержимого в макет ограничения

  3. установите focusable = "false" для представлений, которые не должны быть включены.

Редактировать на основе комментариев Применяется только в том случае, если в макете ограничений есть одна фокус-группа.