本指南通过一个示例逐步介绍一些最佳做法, 在 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 文件。
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 类
接下来,定义类并使用画布绘制四张图片。
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
通过继承 ExploreByTouchHelper
和SampleExploreByTouchHelper
实现其方法
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()
。
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
事件。