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 클래스 정의

이제 클래스를 정의하고 캔버스를 사용하여 이미지 4개를 그립니다.

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

이 상태의 애플리케이션은 실행될 때 화면에 4개의 파란색 직사각형을 표시합니다. 하지만 TalkBack이 사용 설정되어 있으면 직사각형으로 이동할 수 없습니다. 즉, 이러한 직사각형에는 접근성 포커스를 포함할 수 없습니다.

ExploreByTouchHelper를 정의하여 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을 사용 설정하면 이제 접근성 포커스를 맞춤 뷰 내 4개의 이미지로 올바르게 이동할 수 있습니다. 키패드에서 DPAD_CENTER 키를 누르면 앱에서 이 이벤트를 ACTION_CLICK 이벤트로 등록합니다.