Android TV 的自訂檢視畫面無障礙功能範例

本指南逐步說明範例,說明一些最佳做法,可支援 Android TV 應用程式中自訂檢視畫面的無障礙功能。該指南將說明如何建立使用 Canvas API 繪製四張可聚焦圖片的簡單活動,並讓無障礙服務在圖片之間導覽。

建立版面配置 XML 檔案

為自訂檢視畫面活動建立版面配置檔案。

<?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 類別。

定義簡易活動

建立簡易活動來載入版面配置 XML 檔案。

Kotlin

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

Java

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

接著定義類別,並使用畫布繪製四張圖片。

Kotlin

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

Java

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

在此狀態下,應用程式執行時,會在螢幕上顯示四個藍色矩形。不過,開啟 TalkBack 後,就無法前往矩形。也就是說,這些長方形無法聚焦於無障礙焦點。

定義 DiscoveryByTouchHelper 以公開 AccessiblityNodeInfo

繼承 ExploreByTouchHelper 並實作其方法來定義 SampleExploreByTouchHelper

Kotlin

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

Java

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

Kotlin

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

Java

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

啟用 TalkBack 後,無障礙功能焦點現在可以正確瀏覽自訂檢視畫面中的四個圖片。如果您按下鍵盤上的 DPAD_CENTER 鍵,應用程式會將此事件註冊為 ACTION_CLICK 事件。