Compose のセマンティクス

コンポジションは、アプリの UI を記述するもので、コンポーザブルを実行することで生成されます。コンポジションは、UI を記述するコンポーザブルで構成されるツリー構造です。

Composition の隣には、セマンティクス ツリーと呼ばれる並列ツリーがあります。このツリーでは、ユーザー補助サービスとテスト フレームワークからわかりやすい別の方法で UI を記述します。ユーザー補助サービスはこのツリーを使用して、特定のニーズを持つユーザーにアプリを説明します。テスト フレームワークは、このツリーを使用してアプリを操作し、アプリに関するアサーションを作成します。セマンティクス ツリーには、コンポーザブルを描画する方法に関する情報は含まれていませんが、コンポーザブルのセマンティックな意味に関する情報が含まれています。

一般的な UI 階層とそのセマンティクス ツリー
図 1. 一般的な UI 階層とそのセマンティクス ツリー

アプリが Compose foundation とマテリアル ライブラリのコンポーザブルと修飾子で構成されている場合は、セマンティクス ツリーが自動的に入力、生成されます。ただし、カスタムの低レベルのコンポーザブルを追加する場合は、そのセマンティクスを手動で指定する必要があります。また、ツリーが画面上の要素の意味を正しく表現していないか、完全に表現していない場合もあります。その場合は、ツリーを調整できます。

たとえば、このカスタム カレンダーのコンポーザブルを考えてみましょう。

選択可能な日付要素を含むカスタム カレンダー コンポーザブル
図 2. 選択可能な日付要素を含むカスタム カレンダー コンポーザブル。

この例では、カレンダー全体が 1 つの下位レベルのコンポーザブルとして実装され、Layout コンポーザブルを使用して Canvas に直接描画しています。何もしなければ、コンポーザブルのコンテンツとカレンダー内でのユーザーの選択に関する十分な情報がユーザー補助サービスに送信されません。たとえば、ユーザーが 17 の日にちをクリックしても、ユーザー補助機能のフレームワークが受け取るのは、カレンダー全体のコントロールを説明する情報だけです。この場合、TalkBack ユーザー補助サービスは「カレンダー」、あるいは少しだけ優れた「4 月のカレンダー」と通知します。ユーザーは、どの日が選択されたか迷うことになります。このコンポーザブルにアクセスしやすくするには、セマンティック情報を手動で追加する必要があります。

セマンティクス プロパティ

意味論的意味を持つ UI ツリーのすべてのノードには、セマンティクス ツリーの並列ノードがあります。セマンティクス ツリーのノードには、対応するコンポーザブルの意味を伝えるプロパティが含まれています。たとえば、Text コンポーザブルにはセマンティック プロパティ text が含まれています。これは、そのコンポーザブルの意味だからです。Icon には、Icon の意味をテキストで伝える contentDescription プロパティが含まれています(デベロッパーが設定している場合)。Compose 基盤ライブラリ上に構築されたコンポーザブルと修飾子では、関連するプロパティがすでに設定されています。必要に応じて、semantics 修飾子と clearAndSetSemantics 修飾子を使用して、プロパティを自分で設定またはオーバーライドします。たとえば、カスタムのユーザー補助アクションをノードに追加する、切り替え可能な要素に代替の状態の説明を提供する、特定のテキスト コンポーザブルを見出しとみなすよう指示する、といったことが可能です。

セマンティクス ツリーを可視化するには、Layout Inspector ツールを使用するか、テスト内で printToLog() メソッドを使用します。これにより、Logcat 内に現在のセマンティクス ツリーが出力されます。

class MyComposeTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun MyTest() {
        // Start the app
        composeTestRule.setContent {
            MyTheme {
                Text("Hello world!")
            }
        }
        // Log the full semantics tree
        composeTestRule.onRoot().printToLog("MY TAG")
    }
}

このテストの出力は次のようになります。

    Printing with useUnmergedTree = 'false'
    Node #1 at (l=0.0, t=63.0, r=221.0, b=120.0)px
     |-Node #2 at (l=0.0, t=63.0, r=221.0, b=120.0)px
       Text = '[Hello world!]'
       Actions = [GetTextLayoutResult]

セマンティクス プロパティがコンポーザブルの意味をどのように伝えるかについて考えてみましょう。Switch について考えてみましょう。これは次のように表示されます。

図 3. 「オン」状態と「オフ」状態のスイッチ

この要素の意味を説明するには、次のように言ってみてください。「これはスイッチであり、「オン」状態の切り替え可能な要素です。クリックすると操作できます」

これこそがセマンティクス プロパティが使用される目的です。この Switch 要素のセマンティクス ノードには、Layout Inspector で可視化されたように、次のプロパティが含まれています。

Switch コンポーザブルのセマンティクス プロパティが表示されている Layout Inspector
図 4. Switch コンポーザブルのセマンティクス プロパティを表示する Layout Inspector

Role は要素のタイプを示します。StateDescription は、「オン」状態の参照方法について説明します。デフォルトでは、「On」という言葉のローカライズ版ですが、コンテキストに応じてさらに具体的に(「Enabled」など)できます。ToggleableState は、Switch の現在の状態です。OnClick プロパティは、この要素の操作に使用されるメソッドを参照します。セマンティクス プロパティの完全なリストについては、SemanticsProperties オブジェクトをご覧ください。利用可能なユーザー補助アクションの一覧については、SemanticsActions オブジェクトをご覧ください。

アプリで各コンポーザブルのセマンティクス プロパティを追跡することで、アプリの可能性が大きく広がります。次のような例が挙げられます。

  • TalkBack はプロパティを使用して画面に表示されている内容を読み上げ、ユーザーがスムーズに操作できるようにします。Switch コンポーザブルの場合、TalkBack は「ON: 切り替え、ダブルタップで切り替え」と言います。ユーザーは画面をダブルタップして、スイッチをオフに切り替えられます。
  • テスト フレームワークは、このプロパティを使用してノードを検出し、それを操作して、アサーションを行います。Switch のテストの例を次に示します。
    val mySwitch = SemanticsMatcher.expectValue(
        SemanticsProperties.Role, Role.Switch
    )
    composeTestRule.onNode(mySwitch)
        .performClick()
        .assertIsOff()

マージされたセマンティクス ツリーとマージされていないセマンティクス ツリー

前述のように、UI ツリーの各コンポーザブルには、0 個以上のセマンティクス プロパティが設定されます。コンポーザブルにセマンティクス プロパティが設定されていない場合、コンポーザブルはセマンティクス ツリーの一部として含まれません。このように、セマンティクス ツリーには実際に意味論的意味を含んでいるノードのみが含まれます。ただし、画面に表示される内容の正しい意味を伝えるには、ノードの特定のサブツリーをマージして 1 つのノードとして扱うと便利な場合があります。これにより、子孫の各ノードを個別に扱うのではなく、ノードのセット全体について推論できます。おおまかには、このツリーの各ノードが、ユーザー補助サービスを使用する場合のフォーカス可能な要素を表しています。

このようなコンポーザブルの例は、Button です。1 つのボタンに複数の子ノードが含まれていても、1 つの要素と見なすことができます。

Button(onClick = { /*TODO*/ }) {
    Icon(
        imageVector = Icons.Filled.Favorite,
        contentDescription = null
    )
    Spacer(Modifier.size(ButtonDefaults.IconSpacing))
    Text("Like")
}

セマンティクス ツリーでは、ボタンの子孫のプロパティがマージされ、ボタンがツリー内の単一のリーフノードとして表示されます。

結合された単一リーフのセマンティクス表現
図 5. マージされた単一リーフのセマンティクス表現。

コンポーザブルと修飾子は、Modifier.semantics (mergeDescendants = true) {} を呼び出すことで、子孫のセマンティクス プロパティをマージしたいことを示すことができます。このプロパティを true に設定することで、セマンティクス プロパティのマージが必要であることが示されます。Button の例では、Button コンポーザブルは、この semantics 修飾子を含む clickable 修飾子を内部的に使用しています。したがって、ボタンの子孫ノードはマージされます。コンポーザブルのマージ動作を変更するタイミングについて詳しくは、ユーザー補助に関するドキュメントをご覧ください。

Foundation ライブラリと Material Compose ライブラリの修飾子とコンポーザブルには、このプロパティ セットが用意されています。たとえば clickable 修飾子と toggleable 修飾子を使用すると、その子孫が自動的にマージされます。また、ListItem コンポーザブルも、その子孫をマージします。

ツリーを検査する

セマンティクス ツリーは、実際には 2 つの異なるツリーです。マージ セマンティクス ツリーがあります。これは、mergeDescendantstrue に設定されている場合に子孫ノードをマージします。「マージされていない」セマンティクス ツリーもあります。このツリーはマージを適用しませんが、すべてのノードを維持します。ユーザー補助サービスでは、マージされていないツリーを使用し、mergeDescendants プロパティを考慮した独自のマージ アルゴリズムを適用します。テスト フレームワークでは、デフォルトでマージツリーが使用されます。

両方のツリーを printToLog() メソッドで調べることができます。デフォルトでは、前の例と同様に、マージされたツリーがログに記録されます。マージされていないツリーを出力するには、onRoot() マッチャーの useUnmergedTree パラメータを true に設定します。

composeTestRule.onRoot(useUnmergedTree = true).printToLog("MY TAG")

Layout Inspector では、ビューフィルタで優先するセマンティクス ツリーを選択することで、マージされたセマンティクス ツリーとマージされていないセマンティクス ツリーの両方を表示できます。

マージされたセマンティクス ツリーとマージされていないセマンティクス ツリーの両方を表示できる、Layout Inspector の表示オプション
図 6. マージされたセマンティクス ツリーとマージされていないセマンティクス ツリーの両方を表示できる、Layout Inspector の表示オプション

Layout Inspector では、ツリー内のノードごとに、マージされたセマンティクスとそのノードに設定されているセマンティクスの両方が、プロパティ パネルに表示されます。

セマンティクス プロパティが統合されて設定されている
図 7. セマンティクス プロパティがマージされて設定されました。

テスト フレームワークのマッチャーでは、デフォルトで、マージされたセマンティクス ツリーが使用されます。 そのため、Button 内のテキストを照合することで、Button を操作できます。

composeTestRule.onNodeWithText("Like").performClick()

onRoot マッチャーの場合と同様に、マッチャーの useUnmergedTree パラメータを true に設定して、この動作をオーバーライドします。

結合の動作

コンポーザブルで子孫のマージが指示された場合、このマージは正確にはどのように行われるのでしょうか。

各セマンティクス プロパティには、明確なマージ戦略があります。たとえば、ContentDescription プロパティは、子孫のすべての ContentDescription 値をリストに追加します。SemanticsProperties.ktmergePolicy の実装をチェックし、セマンティクス プロパティのマージ戦略を確認します。プロパティは、親または子の値を受け取る、値をリストまたは文字列にマージする、マージを一切許可せず、代わりに例外をスローする、その他のカスタムマージ戦略を実行できます。

重要な点は、自身で mergeDescendants = true を設定した子孫はマージに含まれないことです。次の例をご覧ください。

画像、一部のテキスト、ブックマーク アイコンを含むリストアイテム
図 8. 画像、テキスト、ブックマーク アイコンを含むリストアイテム

こちらがクリック可能なリストアイテムです。ユーザーが行をタップすると、記事の詳細ページに移動し、そこで記事を読むことができます。リストアイテム内には、記事をブックマークするボタンがあります。このボタンはネストされたクリック可能な要素を形成するため、マージツリー内で個別にボタンが表示されます。行の残りのコンテンツはマージされます。

マージされたツリーには、行ノード内のリストに複数のテキストがある。マージされていないツリーには、テキスト コンポーザブルごとに別々のノードがある。
図 9. マージされたツリーには、行ノード内のリストに複数のテキストがある。マージされていないツリーには、テキスト コンポーザブルごとに個別のノードが含まれます。

セマンティクス ツリーを適応させる

前述のように、特定のセマンティクス プロパティをオーバーライドまたはクリアしたり、ツリーのマージ動作を変更したりできます。これは、独自のカスタム コンポーネントを作成する場合に特に当てはまります。適切なプロパティとマージ動作を設定しないと、アプリにアクセスできなくなることがあります。また、テストが想定とは異なる動作になる可能性もあります。セマンティクス ツリーの調整が必要な一般的なユースケースについては、ユーザー補助に関するドキュメントをご覧ください。テストの詳細については、テストガイドをご覧ください。

参考情報