はじめに
この記事ではTalkBack機能を使います。 TalkBack機能は以下の手順で有効にしてください。
- Google Playストアからユーザー補助ツールをインストールする
- 「設定」の「ユーザー補助」項目内に「TalkBack」が追加されるのでタップし、「TalkBack」の使用を有効にする
TalkBackが有効の間は通常の操作とタップ時等の挙動は異なるので初回起動時のチュートリアルを注意して確認しましょう。
Android Studio Bumblebee | 2021.1.1 の新機能の一つにLayout Inspectorでセマンティクス情報を見られるようになりました。
Android Studio リリースノート | Android Developers
リリースノートに情報追加がありましたが、セマンティクスというものにピンときませんでした……。
ということで今回はセマンティクスについて確認していきたいと思います。
まず、リリースノート内 ではセマンティクスについて以下のように記載されていました。
Compose では、セマンティクスにより、ユーザー補助サービスとテスト フレームワークから認識しやすい別の方法で UI が記述されます。
この時点でセマンティクスとは、Composeで利用できるユーザー補助(TalkBack等)やテストで利用できる機能だということが分かります。
もう少し詳しく見ていきましょう。
セマンティクスについて / セマンティクスツリーの確認
Android DevelopersのCompose のセマンティクスによると以下のように記載されています。
セマンティクス ツリーには、コンポーザブルを描画する方法に関する情報はありませんが、コンポーザブルの意味論的意味に関する情報が含まれています。
アプリが Compose foundation とマテリアル ライブラリのコンポーザブルと修飾子で構成されている場合は、セマンティクス ツリーが自動的に入力、生成されます。
1行目についてはリリースノート内での記載でも「ユーザー補助サービスとテスト フレームワークから認識しやすい別の方法」とある通り、実処理とは分離した役割を持つ情報ということだと分かります。
2行目については実際に実装とセマンティクスツリーを確認してみましょう。
今回はAndroid公式のComposeサンプルアプリ、「JetNews」がセマンティクスを用いたアクセシビリティ対応を実装しているのでこちらを見ながら確認していきます。
確認方法はログとLayoutInspectorで見る2つがありますが、個人的にはbumblebeeをインストールできない事情がない限りはLayoutInspectorで見るのがおすすめです。
bumblebee以前のバージョンの場合、テストコード上でprintToLog()
メソッドを利用することでログから見ることが出来ます。
(詳細はAndroid Developers Compose レイアウトのテストを確認してください。)
このような形で出力されます。
Node #1 at (...)px
|-Node #2 at (...)px
Role = 'Button'
Text = '[Hello, World]'
Actions = [OnClick, GetTextLayoutResult]
MergeDescendants = 'true'
また、bumblebeeでは以下添付画像のようにLayoutInspector上で確認できます。こちらではログに比べ確認しやすいのは勿論、
- コード上にログ出力を記載しなくても良い
- ハイライト機能があり、セマンティクス情報を持っているComposableをすぐ確認することができる
- 特定したComposableの実装場所へツリーから飛べる
とログ出力に比べて非常に使い勝手が良いです。
自動生成されるセマンティクス情報
「セマンティクスについて / セマンティクスツリーの確認」内のLayoutInspector上で選択しているText
に対応するコードは下記ですが、こちらでセマンティクス情報を付与していないため、Declared Semantics
に表示されている情報は自動的に生成されたものになります。
Text(
text = stringResource(
id = R.string.home_post_min_read,
formatArgs = arrayOf(
post.metadata.author.name,
post.metadata.readTimeMinutes
)
),
style = MaterialTheme.typography.body2
)
Text
ではtext
要素に記載した値がTalkBackで読み上げられる要素として設定されます。
また、その他にもフォーカスが当たっている要素の種別を表すRole
, タップ可能であることを伝えたり、通常操作時の操作を上書きできたりするonClick
等が存在します。
onClick
に関しては自動生成のみですと、TalkBack読み上げ上では「有効」「切り替え」のようにどのような処理を行うのかが分かりにくいことになってしまうため、アクセシビリティを意識するのでしたらカスタマイズする必要が出てくるかと思います。
セマンティクスのカスタマイズ
Composeのセマンティクス情報は自動生成されるものもありますが、アクセシビリティ対応のためには自動生成のみでは不十分であったり、そもそも自動生成されなかったりする低レベルのComposableも存在します。
また、逆に一括りにしたいComposableにおいてはまとめてあげる必要があったり、読み上げる必要がないセマンティクスを消したりする必要もあります。
ここではComposableへのセマンティクスの追加、上書き、結合について紹介していきます。
セマンティクス情報を設定する
セマンティクス情報は大きく以下で設定できます。
- Composableのプロパティで直接設定(
Image
やIcon
のcontentDescription
等) Modifire
のプロパティとして設定(Modifire.testTag
等)Modifire.semantics
で設定
結果的にはどれもModifire.semantics
を使って設定することになるのですが、上のレイヤーで設定できるに越したことはないのでセマンティクスを設定したい時はまずComposableのプロパティ→Modifireのプロパティと確認した上で、なければsemanticsで設定という進め方をすると良いかもしれないです。
セマンティクス情報をカスタマイズするパターンとしてよくありそうな対応としては、
- 画像の説明をTalkBackで読み上げたい
- 画像を読み上げる必要がないのでフォーカスしないようにしたい
- タップするとどのような処理が走るのか「有効」「切り替え」のような読み上げでは不適なので書き換えたい
- 一括りにフォーカスするComposable内に複数のタップ処理が存在する
あたりが挙げられるかと思います。
・画像の説明をTalkBackで読み上げたい
・画像を読み上げる必要がないのでフォーカスしないようにしたい
こちらについてはcontentDescription
で設定することができます。
引数はNullableであり、null
を設定した場合はフォーカスが当たらない画像となります。
値を設定した場合はフォーカスが当たるようになり、設定した文字列を読み上げます。
例えばドロワーのヘッダーでは、アイコンはフォーカスが当たらないように、ロゴは「JetNews」と読み上げるように設定されています。
@Composable
private fun JetNewsLogo(modifier: Modifier = Modifier) {
Row(modifier = modifier) {
JetnewsIcon()
Spacer(Modifier.width(8.dp))
Image(
painter = painterResource(R.drawable.ic_jetnews_wordmark),
contentDescription = stringResource(R.string.app_name),
colorFilter = ColorFilter.tint(MaterialTheme.colors.onSurface)
)
}
}
@Composable
fun JetnewsIcon(modifier: Modifier = Modifier) {
Image(
painter = painterResource(R.drawable.ic_jetnews_logo),
contentDescription = null, // decorative
colorFilter = ColorFilter.tint(MaterialTheme.colors.primary),
modifier = modifier
)
}
・タップするとどのような処理が走るのか「有効」「切り替え」のような読み上げでは不適なので書き換えたい
この場合、SemanticsPropertyReceiver.onClick
の上書きが必要なのですが、ComposableやModifireで設定できないため、Modifire.semantics
で直接onClick
を記載して上書きします。
JetNewsでは記事のブックマークボタンで実装しています。
val clickLabel = stringResource(
if (isBookmarked) R.string.unbookmark else R.string.bookmark
)
CompositionLocalProvider(LocalContentAlpha provides contentAlpha) {
IconToggleButton(
checked = isBookmarked,
onCheckedChange = { onClick() },
modifier = modifier.semantics {
// Use a custom click label that accessibility services can communicate to the user.
// We only want to override the label, not the actual action, so for the action we pass null.
this.onClick(label = clickLabel, action = null)
}
) {
Icon(
label
がclickLabel
になったことで、ON/OFF共通で「切り替えるにはダブルタップします。」という文言が、
ブックマーク前(!=isBookmarked
)では、「bookMarkするにはダブルタップします。」
ブックマーク後(=isBookmarked
)では、「unBookmarkするにはダブルタップします。」
と処理を明確に読み上げるようになりました。
action
はアクセシビリティサービスを使っている時の処理を実装するのですが、
通常操作の処理を上書きするので注意してください。
(今回の場合ですと、例えばaction={true}
のようにしてしまうとonCheckedChange
の処理が上書きされて何もしなくなります。)
上記コードの通りnull
にすることで元の処理をそのまま実行します。
・一括りにフォーカスするComposable内に複数のタップ処理が存在する
JetNewsだと画像でフォーカスが当たっている部分になります。
通常操作ですとブックマークボタンを押すのですが、TalkBackを利用しているユーザーとしては記事にフォーカスを当たっている状態からブックマークできる方が嬉しいかと思います。
ブックマークにフォーカスを当てる場合、該当する記事タイトルを読み上げた上でブックマークすることを読み上げる必要があるでしょう。
そこで以下のようにcustomActions
を実装することでTalkBackメニューからブックマークができるようにします。
補足ですが、TalkBackメニューと実装した処理一覧の表示は以下の操作で表示できます。
- 3本指タップまたは下スワイプ後そのまま右スワイプ
- 「操作」にフォーカスしダブルタップ
val bookmarkAction = stringResource(if (isFavorite) R.string.unbookmark else R.string.bookmark)
Row(
modifier = Modifier
.clickable(onClick = { navigateToArticle(post.id) })
.padding(16.dp)
.semantics {
// By defining a custom action, we tell accessibility services that this whole
// composable has an action attached to it. The accessibility service can choose
// how to best communicate this action to the user.
customActions = listOf(
CustomAccessibilityAction(
label = bookmarkAction,
action = { onToggleFavorite(); true }
)
)
}
) {
label
は操作一覧で表示される名前、action
が実行する処理になります。
今回ですと、未ブックマークの場合「bookmark」が表示されブックマークでき、ブックマーク済の場合は「unbookmark」が表示されブックマーク解除ができます。
customActions
はList
を設定するので勿論、複数の処理を設定することも可能です。
ここで注意するべきポイントとしては、ブックマーク処理を追加している親要素のRow
の他、子要素のBookmarkButton
にも自動生成によりブックマーク処理が存在していることです。
そのままですとブックマークボタンにもフォーカスが入るのでTalkBackを利用するユーザーにはどの記事をブックマークするのか分からない読み上げが流れてしまいます。
そこで、記事内ブックマークのセマンティクスを無効にします。
以下のようにclearAndSetSemantics{}
で既存のセマンティクスを消した上で何も行わない処理を上書きするだけです。
BookmarkButton(
isBookmarked = isFavorite,
onClick = onToggleFavorite,
// Remove button semantics so action can be handled at row level
modifier = Modifier.clearAndSetSemantics {},
contentAlpha = ContentAlpha.medium
)
セマンティクスの結合
例えば、写真や名前等の情報を持ったプロフィールのレイアウトや、サムネイル画像、タイトル等の情報を持った記事のレイアウトを作った時、TalkBack時にフォーカスする情報はこれらをひとまとめにしたいと思います。
セマンティクスを結合するには結合したい親のModifireに対してModifire.semantics(mergeDescendants = true)
を設定するだけです。
また、Modifire.clickable
のようにmergeDescendantsが有効なプロパティも存在します。
結合したComposeはButtonのように処理を含むものを除いて親ごとフォーカスが当たり、子要素の全てのテキストを読み上げます。
子要素のボタンのような処理はフォーカスが当たるのと、全てを読み上げるため、「セマンティクス情報を設定する」で上げたように不要なセマンティクスに対しての実装は必要になります。
おわりに
いかがでしたでしょうか。
今回紹介した内容はセマンティクスの一部の機能です。
Android公式でComposeのアクセシビリティや、テストのCodeLabが公開されているためそちらも触れてみるとより理解が深まるかと思います。