Tài liệu hướng dẫn này trình bày một số phương pháp hay nhất để hỗ trợ hỗ trợ tiếp cận bằng khung hiển thị tuỳ chỉnh trong ứng dụng Android TV. Hướng dẫn này chỉ ra cách tạo một hoạt động đơn giản sử dụng Canvas API để vẽ bốn hình ảnh có thể làm tâm điểm và cho phép các dịch vụ hỗ trợ tiếp cận di chuyển giữa các dịch vụ đó.
Tạo tệp XML bố cục
Tạo tệp bố cục cho hoạt động của khung hiển thị tuỳ chỉnh.
<?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>
Các phần sau đây xác định
Lớp com.example.tvcustomviews.SampleCustomView
.
Xác định một hoạt động đơn giản
Tạo một hoạt động đơn giản để tải tệp XML bố cục.
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);
}
}
Xác định lớp SampleCustomView
Tiếp theo, hãy xác định lớp và vẽ 4 hình ảnh bằng canvas.
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++));
}
}
Ứng dụng ở trạng thái này hiển thị 4 hình chữ nhật màu xanh dương trên khi chạy. Tuy nhiên, khi TalkBack đã bật, bạn không thể điều hướng đến hình chữ nhật. Nói cách khác, các hình chữ nhật này không thể có tâm điểm hỗ trợ tiếp cận.
Định nghĩa ExploreByTouchHelper để hiển thị AccessiblityNodeInfo
Khai báo SampleExploreByTouchHelper
bằng cách kế thừa ExploreByTouchHelper
và
triển khai các phương thức của nó.
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;
}
}
Cuối cùng, hãy gọi setAccessibilityDelegate()
khi tạo thực thể cho khung hiển thị tuỳ chỉnh.
private fun init(@Nullable attrs:AttributeSet) {
...
ViewCompat.setAccessibilityDelegate(this,
SampleExploreByTouchHelper(this))
}
private void init(@Nullable AttributeSet attrs) {
...
ViewCompat.setAccessibilityDelegate(this,
new SampleExploreByTouchHelper(this));
}
Khi TalkBack bật, tiêu điểm hỗ trợ tiếp cận giờ đây có thể điều hướng chính xác đến
bốn hình ảnh bên trong khung hiển thị tuỳ chỉnh. Nếu bạn nhấn phím DPAD_CENTER
trên
bàn phím, ứng dụng sẽ đăng ký sự kiện này dưới dạng sự kiện ACTION_CLICK
.