现在很多的App都有考虑到要适配一些特殊人群,不如盲人,对于盲人模式这个辅助功能的适配,参考Google官方文档:http://developer.android.com/intl/zh-cn/guide/topics/ui/accessibility/apps.html ,下面是我在研究盲人模式开发中的一些总结,希望对有需要的人能够起到一定的帮助作用。
一、常见的需求场景
1、描述用户界面控件:为没有可见文本的用户界面控件提供内容描述,尤其是ImageButton,ImageView 和CheckBox组件。使用android:contentDescription XML layout属性或者setContentDescription(CharSequence)方法来为无障碍服务提供信息(装饰性图形除外)
2、设置焦点导航:确保用户可以使用基于硬件或软件的定向控制(D-垫,轨迹球,键盘和导航手势)导航。在某些情况下,需要设置UI组件为可获取焦点,或者设置焦点的跳跃顺序更合用户操作逻辑
3、自定义视图控件:若要为应用建立自定义界面控件,须为自定义视图设置无障碍界面且提供内容描述。
4、不使用纯音频的反馈:音频反馈必须有一个次级反馈机制,确保语音提示总是伴随着另一种视觉提示或通知, 来为失聪或者听力障碍的用户提供支持。例如,短信接收的提示音必须伴随一个系统提示,触觉反馈(如具备条件)或者视觉提示。
二、一些特殊情况和注意事项
盲人模式适配在开发过程中可能会遇到一些特殊的情况,应用开发应该具体情况具体分析,并选择合适的技术处理,这里总结一些比较常见的注意事项:
1、文本域提示:对于EditText域,提供一个android:hint属性而不是内容描述,来帮助用户理解,当文本域为空时,期望填写什么内容。且当内容填充时,允许语音系统以音频形式播放内容。
2、高可视化语境的自定义控件:如果应用包含高可视化语境的自定义控件(如日历控件),默认的无障碍服务不能为用户提供良好的体验,应该考虑使用AccessibilityNodeProvider为控件提供虚拟的视图层。虚拟视图层怎么提供要具体情况具体分析,可以参考这篇博客文章 http://www.programcreek.com/java-api-examples/index.php?api=android.view.accessibility.AccessibilityNodeProvider
3、自定义控件和点击事件处理:如果自定义控件提供用户触摸交互的处理,例如onTouchEvent(MotionEvent)对MotionEvent.ACTION_DOWN和MotionEvent.ACTION_UP的监听,并将之视为一次点击事件。需要触发一个AccessiblityEvent,等同于一次点击事件并为用户行为提供无障碍服务。具体参考下面自定义触摸事件处理。
4、改变功能的控件:如果应用中有button或者其他在正常用户行为中能改变功能的控件(例如,button从play变成了pause),确保button的android:contentDescription也对应做了改变。
5、为相关控件提供提示:当用户与控件集中的控件(如DatePicker)交互时,确保控件集提供有效的音频反馈。
6、视频播放与字幕:如果应用提供视频播放功能,应该支持字幕以帮助失聪或者听力障碍的用户理解信息。视频播放控件也必须清楚得表明字幕是否可用,并提供一个明确的方法以启用字幕。
7、增补无障碍音频反馈:使用安卓的无障碍框架为应用提供无障碍音频反馈。如TalkBack这样的无障碍服务应该是为应用提供无障碍视频提示的唯一服务。使用android:contentDescription XML layout属性或者使用无障碍框架API动态增加来提供提示信息。例如,如果你想通过声音控制应用程序,如使用announceForAccessibility(CharSequence)方法,获取辅助性服务,当用户说出信息时,实现自动翻转书页功能。
8、复杂视觉交互的自定义控件:对于提供复杂视觉交互或者非标准视觉交互的自定义控件,使用可为无障碍服务提供简单交互模型的AccessibilityNodeProvider为用户操作提供一个虚拟的视觉层。如果这种方法不可行,考虑提供一个不同的可访问的视觉层。
9、装饰性的图像与图形:应用屏幕上只为装饰的且不提供内容或者可供用户操作的元素(比如自己画的线或者小图案这些装饰),不应该有无障碍内容描述。
三、自定义视图及处理自定义的触摸事件
如果应用程序需要一个自定义的视图组件,需要做一些额外的工作来确保自定义视图是支持辅助功能:
1、处理方向控制器点击
2、实现accessibility API 的方法(Eg:sendAccessibilityEvent() ,dispatchPopulateAccessibilityEvent(),onInitializeAccessibilityEvent(),onInitializeAccessibilityNodeInfo()等等)
3、发送自定义视图特定的AccessibilityEvent对象
4、为自定义视图填充AccessibilityEvent和AccessibilityNodeInfo对象
接下来介绍一下自定义触摸事件的处理:
1、自定义视图控件可能需要非标准的触摸事件的行为。例如,一个自定义控件可以使用onTouchEvent(MotionEvent)来侦测ACTION_DOWN 和 ACTION_UP事件,触发特殊的单击事件。为了用一个 有效的方法来处理这些需求,代码应该重写performClick()方法,该方法必须调用超类方法的实现,然后执行任何需要通过点击事件完成的操作。当检测到自定义点击击动作,代码调performClick()方法。下面的代码片段来自google官方文档。
class CustomTouchView extends View {
public CustomTouchView(Context context) {
super(context);
}
boolean mDownTouch = false;
@Override
public boolean onTouchEvent(MotionEvent event) {
super.onTouchEvent(event);
// Listening for the down and up touch events
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mDownTouch = true;
return true;
case MotionEvent.ACTION_UP:
if (mDownTouch) {
mDownTouch = false;
performClick(); // Call this method to handle the response, and
// thereby enable accessibility services to
// perform this action for a user who cannot
// click the touchscreen.
return true;
}
}
return false; // Return false for other touch events
}
@Override
public boolean performClick() {
// Calls the super implementation, which generates an AccessibilityEvent
// and calls the onClick() listener on the view, if any
super.performClick();
// Handle the action for the custom click here
return true;
}
}
注意:
Note: If your custom view has distinct clickable regions, such as a custom calendar view, you must implement a virtual view hierarchy by overriding getAccessibilityNodeProvider() in your custom view in order to be compatible with accessibility services.
2、可能还会遇到这样子的一种情况,在talkback模式下触摸事件被上层View消费掉,没有传到下面具体的子view上,类似密码锁在talkback模式上无法输入密码这种问题,遇到类似的问题可以重写onHoverEvent(MotionEvent event)对事件进行分发处理。核心代码片段:
@Override
public boolean onHoverEvent(MotionEvent event) {
AccessibilityManager accessibilityManager = (AccessibilityManager) getContext().getSystemService(Context.ACCESSIBILITY_SERVICE);
if (accessibilityManager != null && accessibilityManager.isTouchExplorationEnabled()) {
final int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_HOVER_ENTER:
event.setAction(MotionEvent.ACTION_DOWN);
break;
case MotionEvent.ACTION_HOVER_MOVE:
event.setAction(MotionEvent.ACTION_MOVE);
break;
case MotionEvent.ACTION_HOVER_EXIT:
event.setAction(MotionEvent.ACTION_UP);
break;
}
onTouchEvent(event);
event.setAction(action);
}
return super.onHoverEvent(event);
}
四、AccessibilityEvent事件类型
这里列出AccessibilityEvent的所有事件类型(Mask for {@link AccessibilityEvent} all types),具体可以参考AccessibilityEvent.java源码,里面有所有事件类型的介绍。
/**
* Represents the event of clicking on a {@link android.view.View} like
* {@link android.widget.Button}, {@link android.widget.CompoundButton}, etc.
*/
public static final int TYPE_VIEW_CLICKED = 0x00000001;
/**
* Represents the event of long clicking on a {@link android.view.View} like
* {@link android.widget.Button}, {@link android.widget.CompoundButton}, etc.
*/
public static final int TYPE_VIEW_LONG_CLICKED = 0x00000002;
/**
* Represents the event of selecting an item usually in the context of an
* {@link android.widget.AdapterView}.
*/
public static final int TYPE_VIEW_SELECTED = 0x00000004;
/**
* Represents the event of setting input focus of a {@link android.view.View}.
*/
public static final int TYPE_VIEW_FOCUSED = 0x00000008;
/**
* Represents the event of changing the text of an {@link android.widget.EditText}.
*/
public static final int TYPE_VIEW_TEXT_CHANGED = 0x00000010;
/**
* Represents the event of opening a {@link android.widget.PopupWindow},
* {@link android.view.Menu}, {@link android.app.Dialog}, etc.
*/
public static final int TYPE_WINDOW_STATE_CHANGED = 0x00000020;
/**
* Represents the event showing a {@link android.app.Notification}.
*/
public static final int TYPE_NOTIFICATION_STATE_CHANGED = 0x00000040;
/**
* Represents the event of a hover enter over a {@link android.view.View}.
*/
public static final int TYPE_VIEW_HOVER_ENTER = 0x00000080;
/**
* Represents the event of a hover exit over a {@link android.view.View}.
*/
public static final int TYPE_VIEW_HOVER_EXIT = 0x00000100;
/**
* Represents the event of starting a touch exploration gesture.
*/
public static final int TYPE_TOUCH_EXPLORATION_GESTURE_START = 0x00000200;
/**
* Represents the event of ending a touch exploration gesture.
*/
public static final int TYPE_TOUCH_EXPLORATION_GESTURE_END = 0x00000400;
/**
* Represents the event of changing the content of a window and more
* specifically the sub-tree rooted at the event's source.
*/
public static final int TYPE_WINDOW_CONTENT_CHANGED = 0x00000800;
/**
* Represents the event of scrolling a view.
*/
public static final int TYPE_VIEW_SCROLLED = 0x00001000;
/**
* Represents the event of changing the selection in an {@link android.widget.EditText}.
*/
public static final int TYPE_VIEW_TEXT_SELECTION_CHANGED = 0x00002000;
/**
* Represents the event of an application making an announcement.
*/
public static final int TYPE_ANNOUNCEMENT = 0x00004000;
/**
* Represents the event of gaining accessibility focus.
*/
public static final int TYPE_VIEW_ACCESSIBILITY_FOCUSED = 0x00008000;
/**
* Represents the event of clearing accessibility focus.
*/
public static final int TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED = 0x00010000;
/**
* Represents the event of traversing the text of a view at a given movement granularity.
*/
public static final int TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY = 0x00020000;
/**
* Represents the event of beginning gesture detection.
*/
public static final int TYPE_GESTURE_DETECTION_START = 0x00040000;
/**
* Represents the event of ending gesture detection.
*/
public static final int TYPE_GESTURE_DETECTION_END = 0x00080000;
/**
* Represents the event of the user starting to touch the screen.
*/
public static final int TYPE_TOUCH_INTERACTION_START = 0x00100000;
/**
* Represents the event of the user ending to touch the screen.
*/
public static final int TYPE_TOUCH_INTERACTION_END = 0x00200000;
/**
* Represents the event change in the windows shown on the screen.
*/
public static final int TYPE_WINDOWS_CHANGED = 0x00400000;
/**
* Change type for {@link #TYPE_WINDOW_CONTENT_CHANGED} event:
* The type of change is not defined.
*/
public static final int CONTENT_CHANGE_TYPE_UNDEFINED = 0x00000000;
/**
* Change type for {@link #TYPE_WINDOW_CONTENT_CHANGED} event:
* A node in the subtree rooted at the source node was added or removed.
*/
public static final int CONTENT_CHANGE_TYPE_SUBTREE = 0x00000001;
/**
* Change type for {@link #TYPE_WINDOW_CONTENT_CHANGED} event:
* The node's text changed.
*/
public static final int CONTENT_CHANGE_TYPE_TEXT = 0x00000002;
/**
* Change type for {@link #TYPE_WINDOW_CONTENT_CHANGED} event:
* The node's content description changed.
*/
public static final int CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION = 0x00000004;
五、例子说明
1、我之前做过的适配
(1)创建自定义控件视图(控件要视具体情况而定,复杂情况需要处理事件的分发等)
/**
* 由于原生的checkbox已经写死TalkBack的声音提示,重写setAccessibilityDelegate()设置提示文本,最终还是会被原生覆盖
* 所以需要自定义checkbox重写onInitializeAccessibilityNodeInfo设置自己的文本声音提示
*/
public class SosCheckBox extends CheckBox {
private boolean mIsAccessibilityEnable = false;
public SosCheckBox(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(info);
info.setCheckable(false);
info.setPackageName(null);
info.setClassName(null);
info.setText(null);
}
@Override
public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
super.onInitializeAccessibilityEvent(event);
//当事件为单点的事件,就是一只手指点下去响应的事件
if(event.getEventType() == AccessibilityEvent.TYPE_VIEW_HOVER_ENTER){
if(isChecked()){
setContentDescription(getResources().getString(R.string.sos_open_state));
}else{
setContentDescription(getResources().getString(R.string.sos_close_state));
}
}
}
}
(2)在Activity中初始化服务并实现语音播报(这里要根据自己具体情况具体分析,根据自己的业务逻辑来处理)
//当前状态是否支持accessibility
protected void onResume() {
super.onResume();
//当前状态是否支持accessibility
AccessibilityManager accessibilityManager = (AccessibilityManager) this.getSystemService(Context.ACCESSIBILITY_SERVICE);
mIsAccessibilityEnable = accessibilityManager.isEnabled();
}
/**
* 发送Accessibility事件
* @param host 目标View
* @param eventType 事件类型
* @param info 事件信息
*/
public void sendAccessibilityEvent(View host, int eventType, String info) {
if (!mIsAccessibilityEnable) {
return;
}
if (host == null) {
return;
}
host.setContentDescription(info);
host.sendAccessibilityEvent(eventType);
}
}
//然后在应用相对应的地方调用此方法进行语音播报。
sendAccessibilityEvent(mSosCheckBox, AccessibilityEvent.CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION, getString(R.string.sos_open));
2.例如 屏幕锁屏状态下朗读锁屏密码
(1)处理具体逻辑
//是否朗读密码
private boolean shouldSpeakPasswordForAccessibility() {
final boolean speakPassword = Settings.Secure.getInt(
getContext().getContentResolver(), Settings.Secure.ACCESSIBILITY_SPEAK_PASSWORD, 0) != 0;
final boolean hasHeadphones = mAudioManager != null ?
(mAudioManager.isWiredHeadsetOn() || mAudioManager.isBluetoothA2dpOn())
: false;
return speakPassword || hasHeadphones;
}
//然后在addCellToPattern(Cell newCell)方法中加上要朗读的内容
private void addCellToPattern(Cell newCell) {
if (android.os.Build.VERSION.SDK_INT > 16 && shouldSpeakPasswordForAccessibility()) {
int num = newCell.getRow() * 3 + newCell.getColumn() + 1;
if (num == 11) {
num = 0;
}
announceForAccessibility(num + "");//当API大于16,就可以用这个方法播报语音内容
}
mPattern.add(newCell);
notifyCellAdded();
}
(2)初始化AccessibilityService服务
public LockDigitView(Context context, AttributeSet attrs) {
super(context, attrs);
mAudioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE);
}
(3)由于密码锁事件没有传递到具体的“数字”子view上就会被上层view消费掉,所以这里需要做事件的处理
@Override
public boolean onHoverEvent(MotionEvent event) {
AccessibilityManager accessibilityManager = (AccessibilityManager) getContext().getSystemService(Context.ACCESSIBILITY_SERVICE);
if (accessibilityManager != null && accessibilityManager.isTouchExplorationEnabled()) {
final int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_HOVER_ENTER:
event.setAction(MotionEvent.ACTION_DOWN);
break;
case MotionEvent.ACTION_HOVER_MOVE:
event.setAction(MotionEvent.ACTION_MOVE);
break;
case MotionEvent.ACTION_HOVER_EXIT:
event.setAction(MotionEvent.ACTION_UP);
break;
}
onTouchEvent(event);
event.setAction(action);
}
return super.onHoverEvent(event);
}
Ok,到这里屏幕锁屏密码播报就已经适配结束
3.来个简单的例子,搜索框
搜索框其实就是一个布局文件(mc_search_layout.xml)都是用基本控件组成,所以直接在相应组件上加android:contentDescription就可以了。
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/search_layout">
<ImageView
android:id="@+id/search_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_alignParentLeft="true"
android:layout_alignParentStart="true"
android:scaleType="centerInside"
android:contentDescription="@string/search_icon"/>
<ImageView
android:id="@+id/search_icon_input_clear"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:scaleType="centerInside"
android:contentDescription="@string/search_icon_input_clear"
android:visibility="gone" />
</RelativeLayout>