Muestra de accesibilidad de vistas personalizada para Android TV

En esta guía, se muestra un ejemplo que presenta algunas prácticas recomendadas accesibilidad con vistas personalizadas en una app para Android TV. En la guía se muestra cómo crear una actividad simple que use la API de Canvas para dibujar cuatro imágenes que se enfocables y dejar que los servicios de accesibilidad naveguen entre ellas.

Cómo crear un archivo de diseño en formato XML

Crea un archivo de diseño para la actividad de vistas personalizadas.

<?xml version="1.0" encoding="utf-8">
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android
:orientation="vertical"
        android
:layout_width="match_parent"
        android
:layout_height="wrap_content">

   
<LinearLayout xmlns:app="http://schemas.android.com/apk/res-auto"
            xmlns
:tools="http://schemas.android.com/tools"
            xmlns
:android="http://schemas.android.com/apk/res/android"
            android
:layout_width="match_parent"
            android
:layout_height="match_parent"
            tools
:context=".custom.CustomViewActivity">

   
<com.example.tvcustomviews.SampleCustomView
            android
:layout_width="match_parent"
            android
:layout_height="wrap_content" />

   
</LinearLayout>

</
LinearLayout>

En las siguientes secciones, se definen Clase com.example.tvcustomviews.SampleCustomView.

Cómo definir una actividad simple

Crea una actividad simple para cargar el archivo de diseño en formato XML.

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle

class CustomViewBestPracticeActivity:AppCompatActivity() {

 
protected fun onCreate(savedInstanceState:Bundle) {
   
super.onCreate(savedInstanceState)
    setContentView
(R.layout.custom_view_best_practices)
 
}
}

package com.example.tvcustomviews;

import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;

public class CustomViewBestPracticeActivity extends AppCompatActivity{

   
@Override
   
protected void onCreate(Bundle savedInstanceState) {
       
super.onCreate(savedInstanceState);
        setContentView
(R.layout.custom_view_best_practices);
   
}

}

Cómo definir la clase SampleCustomView

A continuación, define la clase y dibuja cuatro imágenes con un lienzo.

package com.example.tvcustomviews

import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.util.AttributeSet
import android.view.View
import androidx.annotation.Nullable
import androidx.core.view.ViewCompat
import java.util.HashMap

class SampleCustomView:View {

  internal
val virtualIdRectMap:Map<Int, VirtualRect> = HashMap<Int, VirtualRect>()
 
private val rectangleId = 1

  internal
class VirtualRect(rect:Rect, paint:Paint, id:Int) {
   
val rect:Rect
   
val paint:Paint
   
val id:Int = 0
   
init{
     
this.rect = rect
     
this.paint = paint
     
this.id = id
   
}
 
}

 
constructor(context:Context) : super(context) {
   
init()
 
}

 
constructor(context:Context, @Nullable attrs:AttributeSet) : super(context, attrs) {
   
init()
 
}

 
constructor(context:Context, @Nullable attrs:AttributeSet, defStyleAttr:Int) : super(context, attrs, defStyleAttr) {
   
init()
 
}

 
private fun init() {
    setRectangle
()
   
ViewCompat.setAccessibilityDelegate(this,
                                       
SampleExploreByTouchHelper(this))
 
}

 
protected fun onDraw(canvas:Canvas) {
   
super.onDraw(canvas)
   
for (mapElement in virtualIdRectMap.entries)
   
{
     
val virtualRect = mapElement.value
      canvas
.drawRect(virtualRect.rect, virtualRect.paint)
   
}
 
}

 
private fun setRectangle() {
   
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    paint
.setColor(RECTANGLE_COLOR)

   
val leftTopRectangle = Rect()
    leftTopRectangle
.left = RECTANGLE_START_POINT_LEFT
    leftTopRectangle
.right = leftTopRectangle.left + RECTANGLE_WIDTH
    leftTopRectangle
.top = RECTANGLE_START_POINT_TOP
    leftTopRectangle
.bottom = leftTopRectangle.top + RECTANGLE_HEIGHT
    virtualIdRectMap
.put(rectangleId, VirtualRect(leftTopRectangle, paint, rectangleId++))

   
val rightTopRectangle = Rect()
    rightTopRectangle
.left = leftTopRectangle.right + HORIZONTAL_PADDING
    rightTopRectangle
.right = rightTopRectangle.left + RECTANGLE_WIDTH
    rightTopRectangle
.top = leftTopRectangle.top
    rightTopRectangle
.bottom = rightTopRectangle.top + RECTANGLE_HEIGHT
    virtualIdRectMap
.put(rectangleId, VirtualRect(rightTopRectangle, paint, rectangleId++))

   
val leftBottomRectangle = Rect()
    leftBottomRectangle
.left = leftTopRectangle.left
    leftBottomRectangle
.right = leftBottomRectangle.left + RECTANGLE_WIDTH
    leftBottomRectangle
.top = leftTopRectangle.bottom + VERTICAL_PADDING
    leftBottomRectangle
.bottom = leftBottomRectangle.top + RECTANGLE_HEIGHT
    virtualIdRectMap
.put(rectangleId, VirtualRect(leftBottomRectangle, paint, rectangleId++))

   
val rightBottomRectangle = Rect()
    rightBottomRectangle
.left = leftBottomRectangle.right + HORIZONTAL_PADDING
    rightBottomRectangle
.right = rightBottomRectangle.left + RECTANGLE_WIDTH
    rightBottomRectangle
.top = leftBottomRectangle.top
    rightBottomRectangle
.bottom = rightBottomRectangle.top + RECTANGLE_HEIGHT
    virtualIdRectMap
.put(rectangleId, VirtualRect(rightBottomRectangle, paint, rectangleId++))
 
}

 
companion object {
   
private val RECTANGLE_START_POINT_LEFT = 50
   
private val RECTANGLE_START_POINT_TOP = 100
   
private val RECTANGLE_WIDTH = 200
   
private val RECTANGLE_HEIGHT = 100
   
private val HORIZONTAL_PADDING = 50
   
private val VERTICAL_PADDING = 100
   
private val RECTANGLE_COLOR = Color.argb(100, 0, 163, 245)
 
}
}

package com.example.tvcustomviews;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
import java.util.HashMap;
import java.util.Map;

public class SampleCustomView extends View {

 
final Map<Integer, VirtualRect> virtualIdRectMap = new HashMap<>();

 
private static final int RECTANGLE_START_POINT_LEFT = 50;
 
private static final int RECTANGLE_START_POINT_TOP = 100;
 
private static final int RECTANGLE_WIDTH = 200;
 
private static final int RECTANGLE_HEIGHT = 100;
 
private static final int HORIZONTAL_PADDING = 50;
 
private static final int VERTICAL_PADDING = 100;
 
private static final int RECTANGLE_COLOR = Color.argb(100, 0, 163, 245);

 
private int rectangleId = 1;

 
static class VirtualRect {
   
final Rect rect;
   
final Paint paint;
   
final int id;

   
VirtualRect(Rect rect, Paint paint, int id) {
     
this.rect = rect;
     
this.paint = paint;
     
this.id = id;
   
}
 
}

 
public SampleCustomView(Context context) {
   
super(context);
    init
();
 
}

 
public SampleCustomView(Context context, @Nullable AttributeSet attrs) {
   
super(context, attrs);
    init
();
 
}

 
public SampleCustomView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
   
super(context, attrs, defStyleAttr);
    init
();
 
}

 
private void init() {
    setRectangle
();
 
}

 
@Override
 
protected void onDraw(Canvas canvas) {
   
super.onDraw(canvas);

   
for (Map.Entry<Integer, VirtualRect> mapElement :
        virtualIdRectMap
.entrySet()) {
     
VirtualRect virtualRect = mapElement.getValue();
      canvas
.drawRect(virtualRect.rect, virtualRect.paint);
   
}
 
}

 
private void setRectangle() {
   
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    paint
.setColor(RECTANGLE_COLOR);
   
Rect leftTopRectangle = new Rect();
    leftTopRectangle
.left = RECTANGLE_START_POINT_LEFT;
    leftTopRectangle
.right = leftTopRectangle.left + RECTANGLE_WIDTH;
    leftTopRectangle
.top = RECTANGLE_START_POINT_TOP;
    leftTopRectangle
.bottom = leftTopRectangle.top + RECTANGLE_HEIGHT;
    virtualIdRectMap
.put(rectangleId, new VirtualRect(leftTopRectangle, paint, rectangleId++));

   
Rect rightTopRectangle = new Rect();
    rightTopRectangle
.left = leftTopRectangle.right + HORIZONTAL_PADDING;
    rightTopRectangle
.right = rightTopRectangle.left + RECTANGLE_WIDTH;
    rightTopRectangle
.top = leftTopRectangle.top;
    rightTopRectangle
.bottom = rightTopRectangle.top + RECTANGLE_HEIGHT;
    virtualIdRectMap
.put(rectangleId, new VirtualRect(rightTopRectangle, paint, rectangleId++));

   
Rect leftBottomRectangle = new Rect();
    leftBottomRectangle
.left = leftTopRectangle.left;
    leftBottomRectangle
.right = leftBottomRectangle.left + RECTANGLE_WIDTH;
    leftBottomRectangle
.top = leftTopRectangle.bottom + VERTICAL_PADDING;
    leftBottomRectangle
.bottom = leftBottomRectangle.top + RECTANGLE_HEIGHT;
    virtualIdRectMap
.put(rectangleId, new VirtualRect(leftBottomRectangle, paint, rectangleId++));

   
Rect rightBottomRectangle = new Rect();
    rightBottomRectangle
.left = leftBottomRectangle.right + HORIZONTAL_PADDING;
    rightBottomRectangle
.right = rightBottomRectangle.left + RECTANGLE_WIDTH;
    rightBottomRectangle
.top = leftBottomRectangle.top;
    rightBottomRectangle
.bottom = rightBottomRectangle.top + RECTANGLE_HEIGHT;
    virtualIdRectMap
.put(rectangleId, new VirtualRect(rightBottomRectangle, paint, rectangleId++));
 
}
}

La aplicación en este estado muestra cuatro rectángulos azules en la pantalla cuando se ejecuta. Pero cuando TalkBack está activado, no puedes navegar rectángulos. En otras palabras, estos rectángulos no pueden tener el enfoque de accesibilidad.

Define ExploreByTouchHelper para exponer AccessiblityNodeInfo

Para definir SampleExploreByTouchHelper, hereda ExploreByTouchHelper y para implementar sus métodos.

package com.example.tvcustomviews

import android.graphics.Rect
import android.os.Bundle
import android.util.Log
import android.widget.ImageView
import androidx.annotation.NonNull
import androidx.annotation.Nullable
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat
import androidx.customview.widget.ExploreByTouchHelper
import com.example.tvcustomviews.SampleCustomView.VirtualRect
import java.util.Objects

internal
class SampleExploreByTouchHelper(parentView:SampleCustomView):ExploreByTouchHelper(parentView) {

 
private val TAG = SampleExploreByTouchHelper::class.java!!.getName()
 
private val parentView:SampleCustomView

 
init{
   
this.parentView = parentView
 
}

 
/**
   * Define AccessibilityNodeInfo objects with necessary attributes.
   * Set required attributes into the AccessibilityNodeInfo for the UI
   * component whose virtualID is the virtualViewId.
   * @param virtualViewId: virtual ID for the component.
   * @param node: the AccessibilityNodeInfoCompat used to set the attribute.
   */

 
protected fun onPopulateNodeForVirtualView(
    virtualViewId
:Int, @NonNull node:AccessibilityNodeInfoCompat) {
   
val rectEle = parentView.virtualIdRectMap.get(virtualViewId)

    assert
(rectEle != null)
    node
.setContentDescription("Selected rect " + rectEle.id)
    node
.setClassName(ImageView::class.java!!.getName())
    node
.setPackageName(BuildConfig.APPLICATION_ID)
    node
.setVisibleToUser(true)
    node
.addAction(AccessibilityActionCompat.ACTION_CLICK)
   
// *****************************************************************
   
// It is very important to set the rectangle area for each node so
   
// the accessibility service knows where to set accessibility focus.
   
// *****************************************************************
    node
.setBoundsInParent(
     
Objects.requireNonNull(parentView.virtualIdRectMap.get(
        virtualViewId
)).rect)
 
}

 
protected fun getVirtualViewAt(x:Float, y:Float):Int {
   
for (mapElement in parentView.virtualIdRectMap.entrySet())
   
{
     
val rect = mapElement.value.rect
     
if ((rect.left <= x && x <= rect.right
           
&& rect.top <= y && y <= rect.bottom))
     
{
       
return mapElement.value.id
     
}
   
}

   
return ExploreByTouchHelper.INVALID_ID
 
}

 
protected fun getVisibleVirtualViews(virtualViewIds:List<Int>) {
   
for (mapElement in parentView.virtualIdRectMap.entrySet())
   
{
      virtualViewIds
.add(mapElement.key)
   
}
 
}

 
protected fun onPerformActionForVirtualView(
    virtualViewId
:Int, action:Int, @Nullable arguments:Bundle):Boolean {
   
when (action) {
     
// After Talkback is turned on, if you want to handle
     
// DPAD_CENTER key event with your own logic,
     
// implement it under ACTION_CLICK action.
     
AccessibilityNodeInfoCompat.ACTION_CLICK -> {
       
Log.e(TAG, "ACTION_CLICK action handled here.")
       
return true
     
}
   
}

   
// All the DPAD directional keys are handled by
   
// Accessibility based on the AccessibilityNodeInfo Tree
   
// defined above.
   
// fall through
   
return false
 
}
}

package com.example.tvcustomviews;

import android.graphics.Rect;
import android.os.Bundle;
import android.util.Log;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat;
import androidx.customview.widget.ExploreByTouchHelper;
import com.example.tvcustomviews.SampleCustomView.VirtualRect;
import java.util.List;
import java.util.Map;
import java.util.Objects;

class SampleExploreByTouchHelper extends ExploreByTouchHelper {

 
private final String TAG = SampleExploreByTouchHelper.class.getName();

 
private SampleCustomView parentView;

 
SampleExploreByTouchHelper(SampleCustomView parentView) {
   
super(parentView);
   
this.parentView = parentView;
 
}

 
/**
   * Define AccessibilityNodeInfo objects with necessary attributes.
   * Set required attributes into the AccessibilityNodeInfo for the UI
   * component whose virtualID is the virtualViewId.
   * @param virtualViewId: virtual ID for the component.
   * @param node: the AccessibilityNodeInfoCompat used to set the attribute.
   */

 
@Override
 
protected void onPopulateNodeForVirtualView(
     
int virtualViewId, @NonNull AccessibilityNodeInfoCompat node) {
   
VirtualRect rectEle = parentView.virtualIdRectMap.get(virtualViewId);

    assert rectEle
!= null;
    node
.setContentDescription("Selected rect " + rectEle.id);
    node
.setClassName(ImageView.class.getName());
    node
.setPackageName(BuildConfig.APPLICATION_ID);
    node
.setVisibleToUser(true);
    node
.addAction(AccessibilityActionCompat.ACTION_CLICK);
   
// *****************************************************************
   
// It is very important to set the rectangle area for each node so
   
// the accessibility service knows where to set accessibility focus.
   
// *****************************************************************
    node
.setBoundsInParent(
       
Objects.requireNonNull(parentView.virtualIdRectMap.get(
            virtualViewId
)).rect);
 
}

 
@Override
 
protected int getVirtualViewAt(float x, float y) {
   
for (Map.Entry<Integer, VirtualRect> mapElement :
        parentView
.virtualIdRectMap.entrySet()) {
     
Rect rect = mapElement.getValue().rect;
     
if (rect.left <= x && x <= rect.right
         
&& rect.top <= y && y <= rect.bottom) {
       
return mapElement.getValue().id;
     
}
   
}
   
return ExploreByTouchHelper.INVALID_ID;
 
}

 
@Override
 
protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {
   
for (Map.Entry<Integer, VirtualRect> mapElement :
        parentView
.virtualIdRectMap.entrySet()) {
      virtualViewIds
.add(mapElement.getKey());
   
}
 
}

 
@Override
 
protected boolean onPerformActionForVirtualView(
     
int virtualViewId, int action, @Nullable Bundle arguments) {
   
switch (action) {
     
// After Talkback is turned on, if you want to handle
     
// DPAD_CENTER key event with your own logic,
     
// implement it under ACTION_CLICK action.
     
case AccessibilityNodeInfoCompat.ACTION_CLICK:
       
Log.e(TAG, "ACTION_CLICK action handled here.");
       
return true;
   
// All the DPAD directional keys are handled by
   
// Accessibility based on the AccessibilityNodeInfo Tree
   
// defined above.
     
default:
       
// fall through
   
}

   
return false;
 
}
}

Por último, invoca a setAccessibilityDelegate() cuando crees una instancia de la vista personalizada.

private fun init(@Nullable attrs:AttributeSet) {
 
...
 
ViewCompat.setAccessibilityDelegate(this,
                                     
SampleExploreByTouchHelper(this))
}

  private void init(@Nullable AttributeSet attrs) {
       
...
       
ViewCompat.setAccessibilityDelegate(this,
       
new SampleExploreByTouchHelper(this));
   
}

Con TalkBack habilitado, el enfoque de accesibilidad ahora puede navegar correctamente a las cuatro imágenes dentro de la vista personalizada. Si presionas la tecla DPAD_CENTER en un teclado, la app registra este evento como ACTION_CLICK.