适用于 Android TV 的自定义视图无障碍功能示例

本指南通过一个示例逐步介绍一些最佳做法, 在 Android TV 应用中通过自定义视图实现无障碍功能。本指南介绍了如何 创建一个简单的 activity,使用 Canvas API 绘制 并且可让无障碍服务在它们之间导航。

创建布局 XML 文件

为自定义视图 activity 创建一个布局文件。

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

以下部分定义了 com.example.tvcustomviews.SampleCustomView 类。

定义简单的 activity

创建一个简单的 activity 来加载布局 XML 文件。

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

}

定义 SampleCustomView 类

接下来,定义类并使用画布绘制四张图片。

KotlinJava
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++));
 
}
}

应用在此状态下显示 4 个蓝色矩形, 屏幕。但开启 TalkBack 后,您将无法前往 矩形。也就是说,这些矩形不能拥有无障碍功能焦点。

定义 DiscoverByTouchHelper 以公开 AccessiblityNodeInfo

通过继承 ExploreByTouchHelperSampleExploreByTouchHelper 实现其方法

KotlinJava
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;
 
}
}

最后,在实例化自定义视图时调用 setAccessibilityDelegate()

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

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

启用 Talkback 后,无障碍功能焦点现在可以正确导航到 自定义视图中的四张图片如果您按下某个设备上的 DPAD_CENTER 键, 键盘上,应用会将此事件注册为 ACTION_CLICK 事件。