隨手記Android無障礙實踐

隨手記技術團隊發表於2018-05-22

歡迎關注微信公眾號「隨手記技術團隊」,檢視更多隨手記團隊的技術文章。轉載請註明出處
本文作者:鄧慧
原文連結:mp.weixin.qq.com/s/YckeGC_ZU…

隨手記Android無障礙實踐


前言

根據統計,目前我國有1700多萬視障人士,意味著平均每81人中就有一位視障人士可能會在使用網際網路服務時遇到困難。目前隨手記擁有3億註冊使用者,為了讓財務金融服務惠及每一位使用者,幫助視障人士輕鬆地進行記賬、投資和學習財商知識,讓他們能平等、方便、無障礙地獲取資訊和利用資訊,我們對隨手記Android進行了無障礙改造和優化。

無障礙指南

Android產品的無障礙主要是針對視覺障礙人士,在裝置的輔助功能中開啟無障礙服務(如TalkBack)後,它能夠讀取螢幕上的文字資訊,轉化為語音提示,達到資訊無障礙。

規範細則

  • 所有View應統一通過contentDescription屬性加上標籤
  • 文字標籤要有意義
  • 裝飾性的UI元素需要去掉標籤和焦點
  • EditText需通過hint屬性設定標籤
  • 觸控目標大小至少為48*48dp,觸控目標間距至少為8dp
  • 應將相關的、有相同響應的元素組合在一起
  • 焦點切換順序應遵循視覺順序,從左到右,從上到下
  • 較複雜的頁面應採取分組聚焦的形式,減少細粒度
  • 自定義的控制元件需要進行無障礙改造

WCAG 2.0四大原則

  • 可感知性:資訊和使用者介面元件必須以可感知的方式呈現給使用者。
  • 可操作性:使用者介面元件和導航必須可操作。
  • 可理解性:資訊和使用者介面操作必須是可理解的。
  • 魯棒性:內容必須健壯到可信地被種類繁多的使用者代理(包括輔助技術) 所解釋。

開啟無障礙服務

  1. 下載安裝TalkBack軟體(有些系統自帶),它能讀取螢幕中的文字資訊
  2. 保證有文字轉語音(TTS)輸出引擎,通常手機會自帶一個,另外也可以下載訊飛語記
  3. 進入設定 -> 輔助功能(或高階選項) -> 找到TalkBack服務並開啟

當出現綠區域並伴有語音提示的時候表示進入了無障礙模式。View能被正常選中,並有語音提示其文字資訊,說明該View具有無障礙功能。

隨手記Android無障礙實踐

操作方式有所改變

  • 單擊,選中某個具有焦點的View(綠區域)
  • 雙擊相當於正常模式下的點選(啟動、進入等)
  • 滑動,需要雙指往上、下、左、右

實戰例項

1.給UI元素新增標籤

找到介面中所有有效的元素,設定文字資訊。

隨手記Android無障礙實踐

簡單程式碼示例:

// XML
<ImageButton
    ...
   android:contentDescription="@string/share"  />
複製程式碼
// 程式碼
private void updateImageButton() {
    if (mediaCurrentlyPlaying) {
       playPauseImageView.setContentDescription(getString(R.string.pause));
    } else {
       playPauseImageView.setContentDescription(getString(R.string.play));
    }
}
複製程式碼

1.1 正確新增標籤

  • TextView或者繼承至其的控制元件,如果contentDescription屬性的值為空,無障礙服務會獲取text屬性的文字資訊作為語音提示。
  • EditText,需設定hint屬性的值
  • 其它控制元件(如ImageView、ImageButton)需要通過設定contentDescription的值

1.2 提供清晰和有意義的標籤文字

  • 力求精確、簡潔
  • 避免在描述文字中包含型別和狀態
  • 指明元素功能,而不是描述圖示
  • 狀態改變或功能改變,標籤需隨之改變

2.改造非標準元件的選中狀態

新增標籤
如上,有些介面的選中狀態是通過設定ImageView的背景圖片來控制的。無障礙服務無法識別,語音提示中不包含選中狀態。 處理方法: 一、使用可以朗讀選中狀態的系統標準控制元件,如CheckBox或CheckedTextView。 二、給控制元件新增無障礙代理(AccessibilityDelegate),在onInitializeAccessibilityNodeInfo()方法中呼叫AccessibilityNodeInfo物件的setChecked方法設定選中狀態。 我們使用的是第二種方式。具體實現如下:

rootView.setAccessibilityDelegate(new View.AccessibilityDelegate() {
    @Override
    public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
        super.onInitializeAccessibilityNodeInfo(host, info);
        info.setCheckable(true);
        info.setChecked(itemData.isSelected());
    }
});    
複製程式碼

3.焦點處理

新增標籤
圖中第三方登入的微信圖示和文字分別具有焦點,需要整合到一起。避免多餘的操作,加快瀏覽。對於類似手機快捷註冊文字按鈕,應該擴大可觸碰範圍。

有些介面包含裝飾性的元素,需要去除掉焦點。 例如:隨手記更多介面的間隔塊。

新增標籤

移除焦點程式碼示例:

android:focusable="false" 
android:focusableInTouchMode="false" 
android:importantForAccessibility="no"
複製程式碼

4.自定義View的改造

4.1 如下圖記一筆中的滾輪,未處理時在無障礙模式下無法使用。

記一筆滾輪
改造過程: 1.先設定滾輪皮膚的焦點,保證可選中。

2.在滾輪Item選中的回撥函式中,設定view的contentDescription屬性同時傳送無障礙事件。

// 防止頻率過高,做了延時處理
private void sendAccessibilityViewSelectedEvent() {
    postDelayed(new Runnable() {
        @Override
        public void run() {
            sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
        }
    }, 200L);
}
複製程式碼

3.過載onPopulateAccessibilityEvent方法,新增描述文字

@Override
@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
public void onPopulateAccessibilityEvent(AccessibilityEvent event) {
    super.onPopulateAccessibilityEvent(event);
    int eventType = event.getEventType();
    if (eventType == AccessibilityEvent.TYPE_VIEW_SELECTED
            || eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED) {
        if (viewAdapter != null) {
            event.getText().add(viewAdapter.getItemContentDes(currentItem));
            event.setItemCount(viewAdapter.getItemsCount());
            event.setCurrentItemIndex(currentItem);
        }
    }
}
複製程式碼

4.2 手勢皮膚改造

沒處理之前就是一個塊區,滑動沒反應。

手勢皮膚

較好實現無障礙的方式是藉助ExploreByTouchHelper。(主要參考了Android 5.1系統原始碼中LockPatternView類的無障礙實現) 下面給出了部分程式碼實現: 1.編寫相應的ExploreByTouchHelper類,過載6個方法

private final class PatternExploreByTouchHelper extends ExploreByTouchHelper {
    private Rect mTempRect = new Rect();
    private HashMap<Integer, VirtualViewContainer> mItems = new HashMap<>();
    private static final int  VIRTUAL_BASE_VIEW_ID = 1;

    /**
     * 手勢皮膚有9個點,每個點都做為一個虛擬節點,要根據x,y座標獲取對應的虛擬節點的編號(這個int值由自己約定)
     * @return 其它返回ExploreByTouchHelper.INVALID_ID
     */
    @Override
    protected  int getVirtualViewAt(float x, float y) {
        final int rowHit = getRowHit(y);
        if (rowHit < 0) {
            return ExploreByTouchHelper.INVALID_ID;
        }
        final int columnHit = getColumnHit(x);
        if (columnHit < 0) {
            return ExploreByTouchHelper.INVALID_ID;
        }
        boolean dotAvailable = mPatternDrawLookup[rowHit][columnHit];
        int dotId = (rowHit * 3 + columnHit) + VIRTUAL_BASE_VIEW_ID;
        int view = dotAvailable ? dotId : ExploreByTouchHelper.INVALID_ID;
        return view;
    }

    /**
     * 方法名有點奇怪,它的作用是把虛擬節點的編號放進List中
     * 這裡我們加了9個編號進來,1到9
     * @param virtualViewIds
     */
    @Override
    protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {
        if (!mPatternInProgress) {
            return;
        }

        for (int i = VIRTUAL_BASE_VIEW_ID; i < VIRTUAL_BASE_VIEW_ID + 9; i++) {
            if (!mItems.containsKey(i)) {
                VirtualViewContainer item = new VirtualViewContainer(getTextForVirtualView(i));
                mItems.put(i, item);
            }
            virtualViewIds.add(i);
        }
    }

    /**
     * 給每個虛擬節點填充事件,即手勢皮膚中的9個點設定描述文字
     */
    @Override
    protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
        if (mItems.containsKey(virtualViewId)) {
            CharSequence contentDescription = mItems.get(virtualViewId).description;
            event.getText().add(contentDescription);
        }
    }

    /**
     * 給宿主View填充事件,即手勢皮膚設定描述文字
     */
    @Override
    public  void onPopulateAccessibilityEvent(View host, AccessibilityEvent event) {
        super.onPopulateAccessibilityEvent(host, event);
        if (!mPatternInProgress) {
            CharSequence contentDescription = getContext().getText(R.string.lock_pattern_area);
            event.setContentDescription(contentDescription);
        }
    }

    /**
     * 給虛擬View設定描述文字和邊框
     * 邊框是指無障礙模式下選中的區塊邊界
     * @param virtualViewId
     * @param node
     */
    @Override
    protected void onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfoCompat node) {
        node.setText(getTextForVirtualView(virtualViewId));
        node.setContentDescription(getTextForVirtualView(virtualViewId));

        if (mPatternInProgress) {
            node.setFocusable(true);
            if (isClickable(virtualViewId)) {
                node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK);
                node.setClickable(isClickable(virtualViewId));
            }
        }
        final Rect bounds = getBoundsForVirtualView(virtualViewId);
        node.setBoundsInParent(bounds);
    }

    /**
     * 提供互動,觸發回撥重繪控制元件
     */
    @Override
    protected  boolean onPerformActionForVirtualView(int virtualViewId, int action, Bundle arguments) {
        switch (action) {
            case AccessibilityNodeInfo.ACTION_CLICK:
                return onItemClicked(virtualViewId);
            default:
                break;
        }
        return false;
    }
    
    // ...
}
複製程式碼

2.在建構函式中設定無障礙代理

public LockPatternView(Context context) {
    // something else
    // ...
    
    // 無障礙代理
    mPatternTouchHelper = new PatternExploreByTouchHelper(this);
    ViewCompat.setAccessibilityDelegate(this, mPatternTouchHelper);
}
複製程式碼

3.在LockPatternView中實現onHoverEvent()和dispatchHoverEvent()

@Override
public boolean onHoverEvent(MotionEvent event) {
    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;
        case MotionEvent.ACTION_CANCEL:
            event.setAction(MotionEvent.ACTION_CANCEL);
    }
    onTouchEvent(event);
    event.setAction(action);
    return super.onHoverEvent(event);
}

@Override
protected boolean dispatchHoverEvent(MotionEvent event) {
    boolean handled = super.dispatchHoverEvent(event);
    handled |= mPatternTouchHelper.dispatchHoverEvent(event);
    return handled;
}
複製程式碼

4.手勢狀態(如完成、中斷等)的回撥函式中要呼叫announceForAccessibility()提示使用者。

總結

在實現無障礙的同時,也解決了自定義View的UI自動化測試問題。無障礙需要不斷更新迭代、優化。對此團隊也制定了無障礙編碼規範,列入程式碼審查要點中,來保證產品持續提供良好的無障礙功能。

參考資料

Android官方無障礙指南
Android無障礙寶典
WCAG 2.0

相關文章