はじめに

この記事ではTalkBack機能を使います。 TalkBack機能は以下の手順で有効にしてください。

  1. Google Playストアからユーザー補助ツールをインストールする
  2. 「設定」の「ユーザー補助」項目内に「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の実装場所へツリーから飛べる

とログ出力に比べて非常に使い勝手が良いです。

2202_semantics_01

自動生成されるセマンティクス情報

「セマンティクスについて / セマンティクスツリーの確認」内の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のプロパティで直接設定(ImageIconcontentDescription等)
  • Modifireのプロパティとして設定(Modifire.testTag等)
  • Modifire.semanticsで設定

結果的にはどれもModifire.semanticsを使って設定することになるのですが、上のレイヤーで設定できるに越したことはないのでセマンティクスを設定したい時はまずComposableのプロパティ→Modifireのプロパティと確認した上で、なければsemanticsで設定という進め方をすると良いかもしれないです。

セマンティクス情報をカスタマイズするパターンとしてよくありそうな対応としては、

  • 画像の説明をTalkBackで読み上げたい
  • 画像を読み上げる必要がないのでフォーカスしないようにしたい
  • タップするとどのような処理が走るのか「有効」「切り替え」のような読み上げでは不適なので書き換えたい
  • 一括りにフォーカスするComposable内に複数のタップ処理が存在する

あたりが挙げられるかと思います。


・画像の説明をTalkBackで読み上げたい

・画像を読み上げる必要がないのでフォーカスしないようにしたい

こちらについてはcontentDescriptionで設定することができます。
引数はNullableであり、nullを設定した場合はフォーカスが当たらない画像となります。
値を設定した場合はフォーカスが当たるようになり、設定した文字列を読み上げます。

例えばドロワーのヘッダーでは、アイコンはフォーカスが当たらないように、ロゴは「JetNews」と読み上げるように設定されています。

2202_semantics_02

@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(

labelclickLabelになったことで、ON/OFF共通で「切り替えるにはダブルタップします。」という文言が、
ブックマーク前(!=isBookmarked)では、「bookMarkするにはダブルタップします。」
ブックマーク後(=isBookmarked)では、「unBookmarkするにはダブルタップします。」
と処理を明確に読み上げるようになりました。

actionはアクセシビリティサービスを使っている時の処理を実装するのですが、
通常操作の処理を上書きするので注意してください。
(今回の場合ですと、例えばaction={true}のようにしてしまうとonCheckedChangeの処理が上書きされて何もしなくなります。)
上記コードの通りnullにすることで元の処理をそのまま実行します。


・一括りにフォーカスするComposable内に複数のタップ処理が存在する

JetNewsだと画像でフォーカスが当たっている部分になります。
通常操作ですとブックマークボタンを押すのですが、TalkBackを利用しているユーザーとしては記事にフォーカスを当たっている状態からブックマークできる方が嬉しいかと思います。
ブックマークにフォーカスを当てる場合、該当する記事タイトルを読み上げた上でブックマークすることを読み上げる必要があるでしょう。

2202_semantics_03

そこで以下のようにcustomActionsを実装することでTalkBackメニューからブックマークができるようにします。

補足ですが、TalkBackメニューと実装した処理一覧の表示は以下の操作で表示できます。

  1. 3本指タップまたは下スワイプ後そのまま右スワイプ
  2. 「操作」にフォーカスしダブルタップ
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」が表示されブックマーク解除ができます。
customActionsListを設定するので勿論、複数の処理を設定することも可能です。

2202_semantics_04

ここで注意するべきポイントとしては、ブックマーク処理を追加している親要素の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が公開されているためそちらも触れてみるとより理解が深まるかと思います。

CodeLab
Jetpack Compose のユーザー補助
Testing in Jetpack Compose



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる