Here we provide best practices to support accessibility with custom view implementation.
With the practices, we will build a simple activity. In the activity, we draw four ImageView there using Canvas API. Those ImageViews are focusable and the accessibility focus could be navigating on those.
Create layout xml file
Create a layout file for the custom view 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.nonnativebestpractice.SampleCustomView android:layout_width="match_parent" android:layout_height="wrap_content" /> </LinearLayout> </LinearLayout>
We will define the com.example.nonnativebestpractice.SampleCustomView in next steps.
Define a simple activity
Create a simple activity to load the layout XML file.
Kotlin
import androidx.appcompat.app.AppCompatActivity import android.os.Bundle import com.example.android.tva11ydemo.R class CustomViewBestPracticeActivity:AppCompatActivity() { protected fun onCreate(savedInstanceState:Bundle) { super.onCreate(savedInstanceState) setContentView(R.layout.custom_view_best_practices) } }
Java
package com.example.android.tva11ydemo.custom.bestpractices; import androidx.appcompat.app.AppCompatActivity; import android.os.Bundle; import com.example.android.tva11ydemo.R; public class CustomViewBestPracticeActivity extends AppCompatActivity{ @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.custom_view_best_practices); } }
Define the SampleCustomView class
Define the class and draw four image on that as below. However, with those, the accessibility focus cannot navigate on those yet.
Kotlin
package com.example.android.tva11ydemo.custom.bestpractices 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 SimplestCustomView: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, SimplestExploreByTouchHelper(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 + HORISONTAL_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 + HORISONTAL_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 HORISONTAL_PADDING = 50 private val VERTICAL_PADDING = 100 private val RECTANGLE_COLOR = Color.argb(100, 0, 163, 245) } }
Java
package com.example.android.tva11ydemo.custom.bestpractices; 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 SimplestCustomView 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 HORISONTAL_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 SimplestCustomView(Context context) { super(context); init(); } public SimplestCustomView(Context context, @Nullable AttributeSet attrs) { super(context, attrs); init(); } public SimplestCustomView(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 + HORISONTAL_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 + HORISONTAL_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++)); } }
At this moment, if you run the app, you can see four light blue rectangle on screen. However, if you turn Talkback ON, the accessibility focus cannot navigate on the app yet.
Define ExploreByTouchHelper to expose AccessiblityNodeInfo
At first, we define a private class, SimplestExploreByTouchHelper, which inherit the ExploreByTouchHelper class and implement the required methods as below:
Kotlin
package com.example.android.tva11ydemo.custom.bestpractices 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.android.tva11ydemo.BuildConfig import com.example.android.tva11ydemo.custom.bestpractices.SimplestCustomView.VirtualRect import java.util.Objects internal class SimplestExploreByTouchHelper(parentView:SimplestCustomView):ExploreByTouchHelper(parentView) { private val TAG = SimplestExploreByTouchHelper::class.java!!.getName() private val parentView:SimplestCustomView 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 attribute here. */ 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 rectangle area for each node. As a // result the accessibility service know where to set accessibility // focus on. // ***************************************************************** 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 turned Talkback on, if you want to handle // DPAD_CENTER key event with your own logic, // implement under ACTION_CLICK action. AccessibilityNodeInfoCompat.ACTION_CLICK -> { Log.e(TAG, "ACTION_CLICK action handled here.") return true } } // All the DPAD directional key will be handled by // Accessibility based on the AccessibilityNodeInfo Tree // you defined above. // fall through return false } }
Java
package com.example.android.tva11ydemo.custom.bestpractices; 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.android.tva11ydemo.BuildConfig; import com.example.android.tva11ydemo.custom.bestpractices.SimplestCustomView.VirtualRect; import java.util.List; import java.util.Map; import java.util.Objects; class SimplestExploreByTouchHelper extends ExploreByTouchHelper { private final String TAG = SimplestExploreByTouchHelper.class.getName(); private SimplestCustomView parentView; SimplestExploreByTouchHelper(SimplestCustomView 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 attribute here. */ @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 rectangle area for each node. As a // result the accessibility service know where to set accessibility // focus on. // ***************************************************************** 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 turned Talkback on, if you want to handle // DPAD_CENTER key event with your own logic, // implement under ACTION_CLICK action. case AccessibilityNodeInfoCompat.ACTION_CLICK: Log.e(TAG, "ACTION_CLICK action handled here."); return true; // All the DPAD directional key will be handled by // Accessibility based on the AccessibilityNodeInfo Tree // you defined above. default: // fall through } return false; } }
And then you should also invoke setAccessibilityDelegate() method in the init().
Kotlin
private fun init(@Nullable attrs:AttributeSet) { ... ViewCompat.setAccessibilityDelegate(this, SimplestExploreByTouchHelper(this)) }
Java
private void init(@Nullable AttributeSet attrs) { ... ViewCompat.setAccessibilityDelegate(this, new SimplestExploreByTouchHelper(this)); }
With those, if turn Talkback ON again, you can find that the accessibility focus could be navigate on those four light blue image now. And even if you click the DPAD_CENTER key on remote controller, you can see the ACTION_CLICK handling log in logcat.