2015年4月18日 星期六

Android Accessibility

關於 Android 的無障礙支援文章非常非常的少,中文搜尋結果大多都是將 Android Developer 官方文件翻譯成簡中的資源,繁體中文的資料文章和範例似乎一個都沒有,所以我想還蠻適合來仔細的寫一篇 Android Accessibility 實作教學…

Accessibility 的中文意義是無障礙支援,意指針對聽障、視障、肢障人事所增加實作項目,可以方便社會上此類有需要更進階操作的人士有機會成功使用我們的 App,目前中文方面有幾項工具可增加 App 的無障礙支援,而我這篇主要針對 TalkBack 的部份做說明:

1、Screen reader ( TalkBack )
    讀出畫面上的資訊,主要針對全盲的人士所設計
2、Braille support ( BrailleBack )
    將文字轉為點字,需要相關的硬體設備
3、放大手勢
    通常是連敲畫面 3 下即可放大畫面局部,主要針對弱視群眾
4、輕觸並按住的延遲時間
    延期 OnLongClicked 事件的時間,主要針對肢體動作不流暢的群眾

除了全盲的人士,其他狀況大部份都能完成一支全新手機的初始化作業,所以 Android 手機在起始過程就提供了開啟 TalkBack 的功能,在 Android 4.0 是使用手指在螢幕上畫一個矩型,而 Android 4.1 以上是改為以雙指長按畫面任一處,等到語音出現後即開啟 TalkBack 功能,在初始化完的裝置,可以進入設定、協助工具、TalkBack 開啟,此外建議將 "協助工具捷徑" 也一併開啟,即可在裝置的任何畫面上透過 "長按電源" + "雙指長按" 的方式來快速啟動 TalkBack 方便做測試。

啟動 TalkBack 後,原本手勢的單擊 ( Click ) 動作,會變成選擇 ( Hover ) 這個作用,手指所移動到或是單點到的控制項,都會觸發 onFocus 事件並透過 TalkBack 將該控制項的資訊念出來。可以嚐試一下閉著眼睛在 TalkBack 的模式下操作裝置,相信各位會發現要找到 Back 鍵是一件不容易的事,尤其需要等到按鍵資訊被念完,再雙擊點選是一個需要時間且密集操作的過程,以下列出一些 TalkBack 開啟後常用的操作方式,熟悉後會方便一些:

選擇 ( Hover ):單擊
開啟 ( Click ):雙擊
捲動:雙指往上、下、左、右
選擇下或上一個項目:單指往上、下、左、右 ( 相當於遙控器 )
快速回到主畫面:單指上滑 + 左滑
返回鍵:單指下滑 + 左滑
最近畫面鍵:單指左滑 + 上滑
通知欄:單指右滑 + 下滑

如果開發者計畫將元件加入 TalkBack 支援,而不增加額外的操作,事實上只要注意 android:contentDescription 和 android:hint 兩個屬性就可以了,之所以有兩個不同的屬性是因為有些特別的元件在被念出資訊時 contentDescription 並不是第一順位。

絕大多數的元件都會以 contentDescription > text > hint 這樣的優先順序將資訊念出來,像 TextView 這種元件因為本身就是顯示文字,所以不設定 contentDescription 往往還是能正確的讓人透過 text 的值聽出意義,但像 ImageView、ImageButton、CheckBox 等元件上面經常不會有文字,所以沒有設定 contentDescription 的狀況就會讓人無法理解現在的畫面資訊。而有些特殊元件像 EditText,被念出的優先權是 text > hint > contentDescription,也就是被填上文字時念文字,沒文字時念預設提示資訊,聽起來好像很複雜,我們需要特別去注意每種元件被念出來的屬性是哪一個,事實上有一些實作經驗後大概就會發現我們只需要照著直覺去做就行了,並不太需要去擔心將資訊寫在哪個屬性的問題。

如果在自己 extend View 去實作控制項的狀況下,有一些針對 Accessibility 的事情必須注意,或是我們所製作的控制項如果需要在使用遙控器的裝置上執行,同樣需要注意到 setFocusable()、isFocusable()、requestFocus()、android:nextFocusDown、android:nextFocusLeft、android:nextFocusRight 這些屬性的值必須設定正確,否則控制項將無法被選擇到,自然也不會觸發 TalkBack 的事件念出資訊。

我針對一些常見的狀況寫了一個 Demo Project 放在 GitHub 上,有興趣可以 clone 下來跑看看:https://github.com/AsciiHuang/AndroidAccessibilityPractices

這個 Demo 中第一個範例是說明 ArrayAdapter 這種 Class 並不適合用在需要實作無障礙支援的環境中,以 Drawer 上面的 ListView 為例,我們經常會顯示很簡短 ( 或非中文 ) 的資訊在畫面上,這種狀況下被念出來的資訊無法讓人清楚的了解到該項目的意義,更何況外語的字串被以中文的 TalkBack 念出來時,會呈現奇怪的結果,例如 Label 這個字會被念成 "拉貝爾" 這樣的語音訊息。在這種狀況下我們盡可能的使用 BaseAdapter 來取代 ArrayAdapter 例如以下這樣,我們才可以針對 contentDescription 去額外設值。

listView.setAdapter(
new ArrayAdapter<String>(
  getActionBar().getThemedContext(),
  android.R.layout.simple_list_item_activated_1,
  android.R.id.text1,
  new String[] {
   "Custom View",
   "Bad List",
   "Grid And Navigation",
   }
  )
);

// 以 BaseAdapter 取代

private class DrawerItemAdapter extends BaseAdapter {
    private LayoutInflater inflater;
    String[] drawerItems = new String[] {
            getString(R.string.title_section1),
            getString(R.string.title_section2),
            getString(R.string.title_section3),
    };
    String[] drawerItemDescriptions = new String[] {
            getString(R.string.description_section1),
            getString(R.string.description_section2),
            getString(R.string.description_section3),
    };
    public DrawerItemAdapter(Context context) {
        inflater = LayoutInflater.from(context);
    }
    @Override
    public int getCount() {
        return drawerItems.length;
    }
    @Override
    public Object getItem(int position) {
        return drawerItems[position];
    }
    @Override
    public long getItemId(int position) {
        return position;
    }
    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        if (convertView == null) {
            convertView = inflater.inflate(
                    android.R.layout.simple_list_item_activated_1,
                    parent, false);
        }
        ((TextView) convertView).setText(drawerItems[position]);
        convertView.setContentDescription(drawerItemDescriptions[position]);
        return convertView;
    }
};


在自製控制項的部份,我們可以透過 setFocusable、setScrollalbe、setCheckable 等 method 讓此控制項被 focus 時念出像 CheckBox 一樣自動在 contnetDescription 後加上已勾選或未勾選的資訊,或像 ListView 一樣在捲動完後,念出 "總共 X 項,現在顯示第 X 項" 這樣子的資訊,當然除了將以上 method 設為 true 之外,還要在相對應的事件中做 setChecked、setItemCount、setFromIndex 等設定更新目前的狀態,才能正確的念出我們想表達的資訊。

而上述相對應的事件,指的是 View 的這幾個 function ( 務必配合 Demo 學習 )

sendAccessibilityEvent:
    通常用來針對 eventType 做更新資訊的動作
onPopulateAccessibilityEvent:
    透過 event.getText().add() 讓 TalkBack 在相對事件時念出資訊
dispatchPopulateAccessibilityEvent:
    與上一個類似的事件,可針對 child view 做動作
onInitializeAccessibilityNodeInfo:
    初始化元件的支援狀態,例如設定 info.setScrollable(true);
onInitializeAccessibilityEvent:
    更新元件預設支援項目的狀態,如 event.setFromIndex(5);


若我們的 Custom View 在選取後會有勾選的效果,可以加入以下程式碼,讓 contentDescription 被念完後,再補充 "已選取" 或 "未選取" 的資訊

private boolean mChecked = false;

private View.OnClickListener onChangeContentBClicked = new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        if (mChecked) {
            ((Button) v).setText(getString(R.string.default_button_text));
        } else {
            ((Button) v).setText(getString(R.string.changed_button_text));
        }
        mChecked = !mChecked;
    }
};

private View.AccessibilityDelegate buttonAccessibilityDelegate = new View.AccessibilityDelegate() {
    @Override
    public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) {
        super.onInitializeAccessibilityEvent(host, event);
        event.setChecked(mChecked);
    }
    @Override
    public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
        super.onInitializeAccessibilityNodeInfo(host, info);
        info.setCheckable(true);
        info.setChecked(mChecked);
    }
};

若我們的 Custom View 具備 scroll 的功能,可以加入以下程式碼,在捲動的流程結束後念出像 ListView 一樣表示資料總數和目前顯示資料區間的資訊

protected int currentIndex = 0;
private AccessibilityDelegate accessibilityDelegate = new AccessibilityDelegate() {
    @Override
    public void sendAccessibilityEvent(View host, int eventType) {
        if (eventType == AccessibilityEvent.TYPE_VIEW_SCROLLED) {
            currentIndex = // 計算目前捲到的位置
        }
        super.sendAccessibilityEvent(host, eventType);
    }
    @Override
    public boolean dispatchPopulateAccessibilityEvent(View host, AccessibilityEvent event) {
        return super.dispatchPopulateAccessibilityEvent(host, event);
    }
    @Override
    public void onPopulateAccessibilityEvent(View host, AccessibilityEvent event) {
        Log.e("onPopulateAccessibilityEvent", event.toString());
        if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED) {
            event.getText().add("向左或向右滑動可選擇不同圖片");
        }
        super.onPopulateAccessibilityEvent(host, event);
    }
    @Override
    public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) {
        event.setScrollable(true);
        event.setItemCount(10);
        event.setFromIndex(currentIndex);
        super.onInitializeAccessibilityEvent(host, event);
    }
    @Override
    public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
        info.setScrollable(true);
        super.onInitializeAccessibilityNodeInfo(host, info);
    }
    @Override
    public boolean onRequestSendAccessibilityEvent(ViewGroup host, View child, AccessibilityEvent event) {
        return super.onRequestSendAccessibilityEvent(host, child, event);
    }
};

如果實作 Custom View 時程式碼過長,可以考慮 extned AccessibilityDelegate 這類類別,裡面包含了所有 Accessibility 必須的 callback function,我們可以透過簡單的 setAccessibilityDelegate() function 將無障礙支援的程式碼搬到另一個檔案中讓程式碼更單純。

此外,在 API 16 以上,有一個新的 announceForAccessibility 可以使用,這個 API 非常方便,可以讓開發者在任何狀況透過 TalkBack 念出資訊,例如 Timer 的事件,Resume 事件等以往不容易實作的狀態改變時機,例如我在範例中做了一個方型的 View,並且在 onHoverEvent 時透過 announceForAccessibility 念出目前用方的位置,這在實作一些複雜操作 Canvas 的控制項時非常好用,例如日曆元件

@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
@Override
public boolean onHoverEvent(MotionEvent event) {
int currentPos = getBlock(event.getX(), event.getY());
if (currentPos != prePos) {
prePos = currentPos;
if (currentPos == 0) {
this.announceForAccessibility("左上方");
} else if (currentPos == 1) {
this.announceForAccessibility("左下方");
} else if (currentPos == 2) {
this.announceForAccessibility("右上方");
} else if (currentPos == 3) {
this.announceForAccessibility("右下方");
}
}
return super.onHoverEvent(event);
}

先前在某社群做的分享簡報:http://www.slideshare.net/itsAscii/ui-accessibility-45942579


沒有留言:

張貼留言