ComposeでのlabelFor対応
最近「labelFor属性はComposeでどうすれば良いか?」と聞かれて、「2021年のDroidKaigi登壇で答えたから見てね」って答えたけど、確認したら説明してませんでした。失礼しました😇
labelFor属性とは
XMLでラベルとEditTextを分けて書いた場合、入力箇所の用途がTalkBackユーザーにより分かりやすく伝わるように、ラベルのTextViewに設定すべきものです。
入力箇所のラベルと入力された内容(もしくは初期のヒント)が一緒に読み上げられます。
ヒントは何か入力されたら消えるものなので、入力した内容しか案内されなくなってしまいます。
そして特に一つの画面に複数の入力箇所があれば、要素をグループ化してフォーカス移動の数を減らした方がより良い体験に繋がるそうです。
なので、通常の体験で見えないラベルでも良いから、ラベルをつけた方が良さそうです。
<TextView
// ...
android:labelFor="@id/editText"
android:text="お名前" />
<EditText
android:id="@+id/editText"
// ... />
結果はこんな感じになります。
- 何も入力されてない場合:「編集ボックス、お名前」
- 「ティフェン」と入力した場合:「ティフェン、編集ボックス、お名前」
詳しくは公式ドキュメントとアクセシビリティサポートを確認しましょう。
Composeだとこの書き方が出来ませんけど、別のやり方で同じ効果が得られます。
① マテリアルデザインに従うアプリの場合
マテリアルデザインのシステムではこの問題は既に配慮されています。デフォルトのコンポーネントを使えば、ラベルは直接TextFieldに設定できます。
TextFieldかOutlinedTextFieldを使って、Text Composableをlabelとして渡すだけで、完成です。labelForと同じ感じで一緒に読み上げられます。終わり 🎉
var name by rememberSaveable { mutableStateOf("") }
TextField(
value = name,
onValueChange = { newText -> name = newText },
label = { Text(text = "お名前") }
)
。。。しかし担当アプリのデザインがマテリアルデザインのシステムに従わない場合、同じ効果を得るのに少し工夫が必要になります 😵💫
② その他(ほとんど)のアプリの場合
例えば、こんな仕様を渡されたとしましょう。
セマンティクスを結合する
まぁラベルはTextで、入力する部分はマテリアルデザインのラベル無しOutlinedTextFieldで、両方をColumnに入れれば、と考えてしまうでしょう。
そしてlabelFor効果は、その親Columnのセマンティクスを結合させればいいじゃん、楽勝〜、と呟きながらこう書くでしょう。(少なくとも私はそうです🥲)
var name by rememberSaveable { mutableStateOf("") }
// コピペしないでね
Column(modifier = Modifier.semantics(mergeDescendants = true){}) {
Text(text = "お名前")
OutlinedTextField(
value = name,
onValueChange = { newText -> name = newText }
)
}
。。。残念ながらこれじゃ問題は解決されません🙅♀️
確かにTalkBackフォーカスは見た目的にColumn全体を包みますが、読み上げられるのは「お名前」のラベルだけです。「編集ボックス」や「ダブルタップでテキストを編集します」とか、TextField系は何も案内されません。
実際ダブルタップすれば、ちゃんと入力モードに入りますけど、TalkBackユーザーからすれば、入力できる事がまず伝わってません。
ちなみにマテリアルデザインのTextFieldはヒントがないとTalkBackフォーカスをもらえないバグ(?)があるので、上のコードでセマンティクスを結合しなければ、そもそもTalkBackモードで入力ができません。
一応、マテリアルデザインのTextFieldではなく、Composeのfoundationライブラリに入っているBasicTextFieldなら、TextField系の案内は読み上げられますし、ヒントがなくても無視されません。
var name by rememberSaveable { mutableStateOf("") }
// これもlabelFor効果にならないよ
Column(modifier = Modifier.semantics(mergeDescendants = true) {}) {
Text(text = "お名前")
BasicTextField(
// 適当に枠をつける(padding調整などは省略する)
modifier = Modifier.border(
width = 1.dp,
color = Color.Gray,
shape = RoundedCornerShape(percent = 8)
),
value = name,
onValueChange = { newText -> name = newText }
)
}
しかし、上と同様にColumn全体がフォーカスされても、読み上げられるのは「お名前」のラベルだけです。BasicTextFieldの内容を案内してもらうには、フォーカスを次の要素へ動かす必要があります。
つまりlabelFor効果になってません。
セマンティクス経由でグループ化する方法は、もう一つあります。Modifier.clearAndSetSemantics{}ですね。しかしこれを使うと、TextFieldのアクション(テキスト編集など)や案内を自分で管理することになります。
非常に面倒・・・かなりややこしくなりますし、TalkBackのプロではない限りあまりおすすめできません。
今回はセマンティクスに頼らず、別の方法を試してみましょう。
BasicTextFieldのdecorationBoxをカスタマイズする
decorationBox属性はTextFieldのレイアウトをカスタマイズする時に使います。
こんな感じでラベルとTextFieldの入ったColumnをdecorationBoxに渡します。
var name by rememberSaveable { mutableStateOf("") }
BasicTextField(
value = name,
onValueChange = { newText -> name = newText },
// フォントサイズを指定し直す必要がある
// TextStyle.Default.copy()はinnerTextFieldのレイアウトが崩れるから使わない
textStyle = TextStyle(fontSize = 16.sp),
decorationBox = { innerTextField ->
Column {
// ラベル
Text(text = "お名前")
// 枠のあるTextField(自作のComposable)
OutlinedTextFieldInput(
inputValue = name,
innerTextField = innerTextField
)
}
}
)
コードを読みやすくするために、入力する部分は別途書き出します。
枠をつけたり、デフォルトのpaddingなどOutlinedTextFieldのスタイルを引き継ぐためにOutlinedTextFieldDecorationBoxを使います。
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun OutlinedTextFieldInput(
inputValue: String,
innerTextField: @Composable () -> Unit
) {
// 枠をつけてもらう
TextFieldDefaults.OutlinedTextFieldDecorationBox(
value = inputValue,
innerTextField = {
Box(modifier = Modifier.fillMaxWidth()) {
// 入力したものは実際これで表示される
innerTextField()
}
},
// ここから下は本来親のBasicTextFieldから引き継ぐべき
// (とりあえずデフォルトの値にする)
visualTransformation = VisualTransformation.None,
interactionSource = MutableInteractionSource(),
enabled = true,
singleLine = false
)
}
これでちゃんとlabelFor効果になります😭✨
TL;DR
labelFor効果は、マテリアルデザインのシステムを使うか、BasicTextFieldのDecorationBoxをカスタマイズすれば再現できます。
実際動かしてみたい方はどうぞこちらへ:
ノシ