Edit the Blog Post Header component above this, then place your content here, then fill out the Related Articles section below, if possible. You should have at least one related article and ideally, all three.
Feel free to add supplementary content to the sidebar at right, but please retain the Twitter component (it can live at the bottom of your added content).
This text component can be edited or deleted as necessary.
Related articles only have an image (the squarer version of the banner) and a title. This text can be deleted. Place 1-3 related articles.
Fire OSのVoiceView固有の拡張機能を使用することで、Androidアクセシビリティフレームワークを拡張できます。これにより、スクリーンリーダーのユーザーに、さらに豊富なコンテキスト情報を提供できます。ここで重要なのが、次のようなAccessibilityNodeInfoのextrasバンドルを使用してキーを追加するという手法です。
例えば次のようになります。
public void onInitializeAccessibilityNodeInfo(
View host,
AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(host, info);
info
.getExtras()
.putString(
"com.amazon.accessibility.describedBy",
"この行には話題の映画が示されています。"
);
info
.getExtras()
.putString(
"com.amazon.accessibility.usageHint.touch", // Fireタブレットの場合
"タップして長押しするとオプションが表示されます。ダブルタップすると選択できます。"
);
info
.getExtras()
.putString(
"com.amazon.accessibility.usageHint.remote", // Fire TVの場合
"探すには左または右を押してください"
)
});
ReactやJetpack Composeを使用する開発者の方々は、これらのフレームワークではAccessibilityNodeInfoの直接操作が抽象化されていることに注意が必要です。このような場合は、同等の機能を実現するため、各フレームワークが提供するガイドラインに従って実装してください。たとえば、Jetpack Composeの場合、コンポーネントにセマンティックプロパティを定義するという方法があります。これは、AccessibilityNodeInfoでextrasを使用する方法と似ています。
カスタムUIコンポーネントを作成する場合、アクセシビリティを手動で実装する必要があります。たとえば、CircularSeekBarというカスタムコンポーネントがあるとします。これは円形の進捗状況セレクターで、ユーザーは円形のトラックの周囲にあるつまみをドラッグして、0%~100%の間で値を設定できます。
この視覚的デザインは、目の見えるユーザーには直感的なものです。しかし、VoiceViewを利用している目の不自由なユーザーの場合、このコンポーネントの機能を理解して操作するための別の方法が必要になります。そこで、複数の主要なユーザー補助機能を実装します。
まず、キーボードやスクリーンリーダーで確実にこのビューに移動できるようにします。具体的には、クラス定義でisFocusable=trueを設定します。
class CircularSeekBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var progress = 0
private val maxProgress = 100
init {
isFocusable = true
}
次に、アクセシビリティサービスに対してこのビューを説明します。onInitializeAccessibilityNodeInfoをオーバーライドし、これが0~100の範囲のシークバーであり、ユーザーが前後にスクロールして値を調整できることをVoiceViewに伝えます。
override fun onInitializeAccessibilityNodeInfo(
info: AccessibilityNodeInfo
) {
super.onInitializeAccessibilityNodeInfo(info)
// 下位互換性を高めるために互換性のあるラッパーを使用する。
val infoCompat = AccessibilityNodeInfoCompat.wrap(info)
// アクセシビリティサービスに対して、ビューがSeekBarであることを伝える。
infoCompat.className = "android.widget.SeekBar"
infoCompat.roleDescription = "シークバー"
infoCompat.contentDescription = "円形の進捗状況セレクター"
// ビューの範囲(min、max、currentの値)について説明する。
infoCompat.rangeInfo =
AccessibilityNodeInfoCompat.RangeInfoCompat.obtain(
AccessibilityNodeInfoCompat.RangeInfoCompat.RANGE_TYPE_INT,
0f,
maxProgress.toFloat(),
progress.toFloat()
)
// このビューで実行可能なアクションを宣言する。
// これにより、ユーザーがスクリーンリーダーのジェスチャーで値を調整できるようになる。
infoCompat.addAction(
AccessibilityNodeInfoCompat
.AccessibilityActionCompat
.ACTION_SCROLL_FORWARD
)
infoCompat.addAction(
AccessibilityNodeInfoCompat
.AccessibilityActionCompat
.ACTION_SCROLL_BACKWARD
)
infoCompat.isScrollable = true
}
performAccessibilityActionをオーバーライドして、VoiceViewのユーザーを対象とした実際の機能を提供します。たとえば、ユーザーが上下にスワイプして値を5ずつ変更し、即座に音声フィードバックを得られるようにします。
override fun performAccessibilityAction(
action: Int,
arguments: Bundle?
): Boolean {
val currentProgress = getProgress()
// アクションに基づいて新しい進捗状況を判断する。
val newProgress = when (action) {
AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD -> {
(currentProgress + 5).coerceAtMost(getMaxProgress())
}
AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD -> {
(currentProgress - 5).coerceAtLeast(0)
}
else -> return super.performAccessibilityAction(action, arguments)
}
// 進捗状況が変わったら、更新して新しい値を音声で通知する。
if (newProgress != currentProgress
setProgress(newProgress
val accessibilityManager =
context.getSystemService(Context.ACCESSIBILITY_SERVICE)
as AccessibilityManager
if (accessibilityManager.isEnabled) {
val event = AccessibilityEvent
.obtain(AccessibilityEvent.TYPE_ANNOUNCEMENT)
event.text.add("Progress: $newProgress%")
accessibilityManager.sendAccessibilityEvent(event)
}
return true
}
// アクションが処理されなかった場合はfalseを返す。
return super.performAccessibilityAction(action, arguments)
}
D-padリモコンで操作するFire TVアプリにこの手法を適用する場合は、明示的にperformAccessibilityActionをトリガーしてD-Padのキーの押下を処理するコードを含めることをお勧めします。例えば次のようになります。
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
return when (keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> {
performAccessibilityAction(
AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD, null
)
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
performAccessibilityAction(
AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD, null
)
}
else -> super.onKeyDown(keyCode, event)
}
}
アクセシビリティメニューに表示されるカスタムアクションを追加することもできます。MainActivityで、カスタムコンポーネントを含むコンテナにAccessibilityDelegateCompatを関連付けます。
ViewCompat.setAccessibilityDelegate(
seekBarContainer,
object : AccessibilityDelegateCompat() {
/**
* このメソッドは、アクセシビリティサービスがビューのノード情報を
* 構築するときに呼び出される。それを使用してカスタムアクションを追加する。
*/
override fun onInitializeAccessibilityNodeInfo(
host: View,
info: AccessibilityNodeInfoCompat
) {
super.onInitializeAccessibilityNodeInfo(host, info)
val actionLabel = "進捗状況のリセット"
val customAction =
AccessibilityNodeInfoCompat.AccessibilityActionCompat(
R.id.action_reset_progress, // res/values/ids.xmlで定義されたID
actionLabel
)
info.addAction(customAction)
}
/**
* このメソッドは、ユーザーがカスタムアクセシビリティアクションを
* トリガーするときに呼び出される。
*/
override fun performAccessibilityAction(
host: View,
action: Int,
args: Bundle?
): Boolean {
// 実行されたアクションが自分たちのカスタムアクションであるかどうかを確認する。
if (action == R.id.action_reset_progress) {
// アクションを実行:シークバーの進捗状況を0にリセットする。
circularSeekBar.setProgress(0)
val accessibilityManager =
host.context.getSystemService(Context.ACCESSIBILITY_SERVICE)
as AccessibilityManager
if (accessibilityManager.isEnabled) {
val event = AccessibilityEvent
.obtain(AccessibilityEvent.TYPE_ANNOUNCEMENT)
event.text.add("Progress reset to 0%")
accessibilityManager.sendAccessibilityEvent(event)
}
return true // trueを返し、アクションが処理されたことを示す。
}
// 自分たちのアクションではない場合は、デフォルトの実装に処理させる。
return super.performAccessibilityAction(host, action, args)
}
}
)
目の見えるユーザーがタッチで操作する場合も、最終の値を音声で通知してフィードバックを送ります。これは、カスタムコンポーネントのonTouchEvent定義内で処理できます。
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
…
MotionEvent.ACTION_UP -> {
// ユーザーがタッチ操作で指を離したときに、
// アクセシビリティのための音声通知を送る。
// これにより、スクリーンリーダーのユーザーに、選択された最終の値を伝える。
val accessibilityManager =
context.getSystemService(Context.ACCESSIBILITY_SERVICE)
as AccessibilityManager
if (accessibilityManager.isEnabled) {
val accessibilityEvent = AccessibilityEvent
.obtain(AccessibilityEvent.TYPE_ANNOUNCEMENT)
accessibilityEvent.text.add("Progress set to $progress%")
accessibilityManager.sendAccessibilityEvent(accessibilityEvent)
}
// アクセシビリティ基準を満たすためにperformClickを呼び出す。
performClick()
return true
}
}
return super.onTouchEvent(event)
}
キーボード操作やスクリーンリーダーを利用するユーザーには、直感的なナビゲーションエクスペリエンスが不可欠です。ボタンが不規則に配置されたレイアウトを考えてみましょう。インターフェイスに機能的な意味を持たない装飾用の画像を追加する場合があります。たとえば、次のようなものです。
適切なフォーカス管理を行わない場合、VoiceViewのユーザーが複雑なナビゲーションパターンに直面したり、装飾的なコンテンツについて不要な音声通知を受け取ったりします。フォーカス制御とコンテンツのフィルタリングを適切に実装することで、ユーザーが理解しやすく、予測可能なナビゲーション操作を実現してください。
そのためには、レイアウトでandroid:importantForAccessibility="no"を使用します。これにより、ナビゲーション中に装飾的な画像をすべてスキップするようVoiceViewに指示できます。その結果、ユーザーがインターフェイスを理解するうえで不要な「画像」という音声通知を聞く必要がなくなります。レイアウトXMLで、次のように指定します。
<ImageView
android:layout_width="100dp"
android:layout_height="100dp"
android:src="@android:drawable/ic_menu_gallery"
android:importantForAccessibility="no"
android:layout_gravity="center_horizontal"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:contentDescription="@null" />
こちらのレイアウトでは、要素がランダムに並んでいます。
この場合、デフォルトの位置ベースのナビゲーションを、論理的な順序に基づいた動作で上書きしてください。この実装ではnextFocus*属性が鍵となります。
<!-- Center Button -->
<Button
android:id="@+id/button_center"
android:text="中央"
android:nextFocusForward="@id/button_right"
android:nextFocusUp="@id/button_left"
android:nextFocusDown="@id/button_right"
android:nextFocusLeft="@id/button_left"
android:nextFocusRight="@id/button_right" />
<!-- Right Button -->
<Button
android:id="@+id/button_right"
android:text="右"
android:nextFocusForward="@id/button_left"
android:nextFocusUp="@id/button_center"
android:nextFocusDown="@id/button_left"
android:nextFocusLeft="@id/button_center"
android:nextFocusRight="@id/button_left" />
<!-- Left Button -->
<Button
android:id="@+id/button_left"
android:text="左"
android:nextFocusForward="@id/button_center"
android:nextFocusUp="@id/button_right"
android:nextFocusDown="@id/button_center"
android:nextFocusLeft="@id/button_right"
android:nextFocusRight="@id/button_center" />
この実装により、ナビゲーションの順序が予測可能になります。特にFire TVでは、タッチ操作ではなく方向キーによる操作が主となるため、重要な要素となります。
視覚的なデザインでは、効率的に意味を伝えるためにアイコンや画像を使用することが珍しくありません。しかし、目の見えるユーザーにとって明白なものが、スクリーンリーダーのユーザーにとっても同じであるとは限りません。VoiceViewがラベルの付いていないボタンや画像に直面すると、「ラベルのないボタン」または単に「画像」と音声で通知する可能性があります。 これはあまり有用な情報ではなく、ユーザーが混乱することになります。
また、インターフェイスに、目の見えるユーザーならほぼ例外なく見分けられるであろう歯車のアイコンのみを使用した「設定」ボタンがあるところを想像してみてください。適切にラベルが付けられていない場合、この要素にフォーカスすると、VoiceViewのユーザーは「ラベルのないボタン」という説明を聞くことになります。これでは、このボタンの機能を理解することは不可能です。このギャップを埋めるために、 情報や機能を伝えるテキスト以外の要素すべてに代替テキストを用意しましょう。
android:contentDescription属性で、明確かつ説明的なラベルを指定します。
<ImageButton
android:id="@+id/settings_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@android:drawable/ic_menu_preferences"
android:contentDescription="設定" />
コンテンツの説明は、視覚的デザインを介して情報を伝えるすべての要素に追加する必要があります。
効果的な説明を記述するには、外観ではなく機能に焦点を当てることが重要です。「どのように見えるか」ではなく「何をするか」に対する回答となる説明である必要があります。
最近のアプリは、ステータスメッセージの表示、リストへの新規アイテムの追加、バックグラウンドでの処理の完了など、コンテンツの動的なアップデートを頻繁に行います。目の見えるユーザーはこうした変更を即座に把握できますが、スクリーンリーダーのユーザーの場合、明示的に音声で通知されなければ重要なアップデートを見逃す可能性があります。こうした音声通知がない場合、アプリで情報のギャップが生じ、一部のユーザーがアプリ内の状態を把握できず混乱し続けることになります。
ページ全体を更新しなくてもコンテンツが変更されるアプリがあります。たとえば、バックグラウンドでプロフィールのアップデートが完了するようなものです。その場合、AccessibilityEvent.TYPE_ANNOUNCEMENTを使用することが重要です。
updateButton.setOnClickListener {
// ここで、何らかのバックグラウンドアクション(データの保存など)が発生する。
// AccessibilityEvent.TYPE_ANNOUNCEMENTを使用してユーザーに結果を伝える。
// 画面上に表示されているテキストを変更する必要はない。
val accessibilityManager =
context.getSystemService(Context.ACCESSIBILITY_SERVICE)
as AccessibilityManager
if (accessibilityManager.isEnabled) {
val event = AccessibilityEvent
.obtain(AccessibilityEvent.TYPE_ANNOUNCEMENT)
event.text.add("Your profile has been updated.")
accessibilityManager.sendAccessibilityEvent(event)
}
新しいコンテンツが表示される、アプリのディスプレイ内の領域についてはどうすればよいでしょう。 このシナリオでは、コンテナをライブリージョンに指定して新しいコンテンツを自動的に音声で通知します。例えば次のようになります。
<LinearLayout
android:id="@+id/live_region_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="vertical"
android:accessibilityLiveRegion="polite" />
このコンテナに新しいコンテンツが追加されると、VoiceViewが自動的に音声で通知します。
addContentItemButton.setOnClickListener {
itemCount++
val newTextView = TextView(this)
newTextView.text = "新しいアイテム#$itemCountが追加されました。"
// このコンテナにはandroid:accessibilityLiveRegion="polite"が指定されているため、
// 追加されるとすぐに、スクリーンリーダーがこの新しいTextViewの
// テキストを自動的に読み上げる。
container.addView(newTextView)
}
android:accessibilityLiveRegion属性は、さまざまな音声通知動作をサポートしています。
多くのアプリのインターフェイスにリストは欠かせません。曲のライブラリ、製品カタログ、連絡先リストなど、あらゆるものに使用されています。しかし、スクリーンリーダーのデフォルト動作では、こうしたリストの操作が非効率的です。VoiceViewがリストアイテム内のすべてのテキスト要素を個別のフォーカス可能要素として扱う場合、ユーザーは1つのアイテムを処理するまでに複数回スワイプする必要があります。これではすぐにストレスがたまってしまいます。
たとえば、曲のリストを使用する音楽アプリがあるとします。各リストアイテムには曲名とアーティスト名が含まれており、それぞれ個別のテキスト要素として表示されます。
アクセシブルデザインではない場合、VoiceViewのユーザーは1曲当たり2回(曲名で1回、アーティスト名で1回)スワイプする必要があります。このようなナビゲーションには手間がかかりますし、ユーザーは複数のフォーカスポイント間で情報を記憶している必要があります。
関連情報をグループ化することで、より使いやすく分かりやすい操作性を実現できます。各リストアイテムのルートレイアウトをフォーカス可能に設定し、子要素をまとめてグループ化しましょう。
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:focusable="true"
android:background="?android:attr/selectableItemBackground">
<!--
ルートレイアウトでandroid:focusable="true"を設定することで、
このリストアイテム全体を単一の対話型要素として扱うように
VoiceViewに指示する。これによって子TextViewがグループ化され
より整合性のあるスクリーンリーダーエクスペリエンスが実現する。contentDescriptionは
プログラムでアダプターに設定される。
-->
<TextView
android:id="@+id/song_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="曲名"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/song_artist"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="アーティスト名"
android:textSize="14sp"
android:layout_marginTop="4dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/song_title"
app:layout_constraintEnd_toEndOf="parent"
</androidx.constraintlayout.widget.ConstraintLayout>
鍵となる属性「android:focusable="true"」により、リストアイテム全体を、個別の子要素としてではなく、単一のフォーカス可能なユニットとして扱うようVoiceViewに指示します。
その後、リストオブジェクトのRecyclerViewアダプターで、関連情報すべてを含む包括的なコンテンツの説明を作成します。
override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
val song = songs[position]
holder.titleTextView.text = song.title
holder.artistTextView.text = song.artist
// デフォルトでは、スクリーンリーダーは曲名とアーティスト名を
// 2つのフォーカス可能な個別要素として扱う。これらを親ビュー(XMLでフォーカス可能と
// マーク済み)の単一のcontentDescriptionにまとめることで、
// ユーザーはアイテムの情報をまとめて聞くことが可能になる。
holder.view.contentDescription =
"曲:${song.title}、アーティスト:${song.artist}"
}
このアプローチにより、2つの個別の音声通知(「Bohemian Rhapsody」の後に「Queen」)を1つの包括的な音声通知(「曲: Bohemian Rhapsody、アーティスト: Queen」)に変換することができます。
重要なのは、 contentDescriptionを作成するときは、明確かつ予測可能なパターンを使用することです。例えば次のようになります。
このようなアプローチで開発を行うことで、リストナビゲーションの効率が向上し、使いやすくなります。スクリーンリーダーのユーザーも、ストレスを感じることなくコンテンツをすばやくブラウズできます。
アプリへのVoiceViewの実装は、可能な限り多くのユーザーに自分のアプリを利用してもらうための効果的な方法の1つであり、開発者とユーザーの両方にとって重要です。