Compose TextFieldの落とし穴(デフォルト余白設定)に対処する

`material.textfield.TextInputLayout`と`EditText`を使った入力フォームと近しいデザインをComposeで作りたかったのですが、実現に意外と苦戦したので共有します。

※ composeVersionは1.2.0-rc02です。
**※androidx.compose.material:material:1.2.0-alpha04でリリースされたTextFieldDefaults.TextFieldDecorationBox を使えることが必須条件になります。**

|今回実現したいレイアウト|
|----|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670405/d5f9faf9-70c8-4f09-c0f1-0c859e279623.png" width=80%>|


## TextFieldの落とし穴
TextFieldを使えばフォーム自体は簡単に作れたのですが、
`value`と`label`に**padding値がデフォルトで埋め込まれていて、且つそれが外から修正できない**という大きな落とし穴がありました。
そのため、下記のようなレイアウトとなり、実現したいレイアウトと乖離してしまいました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670405/d8cfca43-a48b-4bcc-5e53-51b4a8ceb088.png" width=60%>


```Kotlin:Sample実装
@Composable
fun NormalTextField(
    value: String,
    label: String,
    keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
    keyboardActions: KeyboardActions = KeyboardActions.Default,
    onValueChange: (String) -> Unit
) {
    TextField(
        value = value,
        onValueChange = { onValueChange(it) },
        label = { Text(text = label) },
        colors = TextFieldDefaults.textFieldColors(
            backgroundColor = Color.Transparent
        ),
        modifier = Modifier.fillMaxWidth(),
        keyboardOptions = keyboardOptions,
        keyboardActions = keyboardActions,
    )
}
```
## 原因元調査: TextFieldの内部を見てみる

```Kotlin:TextField.kt
@Composable
fun TextField(
    value: String,
    onValueChange: (String) -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    readOnly: Boolean = false,
    textStyle: TextStyle = LocalTextStyle.current,
    label: @Composable (() -> Unit)? = null,
    placeholder: @Composable (() -> Unit)? = null,
    leadingIcon: @Composable (() -> Unit)? = null,
    trailingIcon: @Composable (() -> Unit)? = null,
    isError: Boolean = false,
    visualTransformation: VisualTransformation = VisualTransformation.None,
    keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
    keyboardActions: KeyboardActions = KeyboardActions(),
    singleLine: Boolean = false,
    maxLines: Int = Int.MAX_VALUE,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    shape: Shape =
        MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize),
    colors: TextFieldColors = TextFieldDefaults.textFieldColors()
) {
    // If color is not provided via the text style, use content color as a default
    val textColor = textStyle.color.takeOrElse {
        colors.textColor(enabled).value
    }
    val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))

    @OptIn(ExperimentalMaterialApi::class)
    BasicTextField(
        value = value,
        modifier = modifier
            .background(colors.backgroundColor(enabled).value, shape)
            .indicatorLine(enabled, isError, interactionSource, colors)
            .defaultMinSize(
                minWidth = TextFieldDefaults.MinWidth,
                minHeight = TextFieldDefaults.MinHeight
            ),
        onValueChange = onValueChange,
        enabled = enabled,
        readOnly = readOnly,
        textStyle = mergedTextStyle,
        cursorBrush = SolidColor(colors.cursorColor(isError).value),
        visualTransformation = visualTransformation,
        keyboardOptions = keyboardOptions,
        keyboardActions = keyboardActions,
        interactionSource = interactionSource,
        singleLine = singleLine,
        maxLines = maxLines,
        decorationBox = @Composable { innerTextField ->
          
            TextFieldDefaults.TextFieldDecorationBox(  // ← here
                value = value,
                visualTransformation = visualTransformation,
                innerTextField = innerTextField,
                placeholder = placeholder,
                label = label,
                leadingIcon = leadingIcon,
                trailingIcon = trailingIcon,
                singleLine = singleLine,
                enabled = enabled,
                isError = isError,
                interactionSource = interactionSource,
                colors = colors
            )  // ↑ 先に結論： contentPaddingが設定されていない
        }
    )
}
```

次にTextFieldDefaults.TextFieldDecorationBoxの中身を辿ってみます。

```Kotlin:TextFieldDefaults.kt
    @Composable
    @ExperimentalMaterialApi
    fun TextFieldDecorationBox(
        value: String,
        innerTextField: @Composable () -> Unit,
        enabled: Boolean,
        singleLine: Boolean,
        visualTransformation: VisualTransformation,
        interactionSource: InteractionSource,
        isError: Boolean = false,
        label: @Composable (() -> Unit)? = null,
        placeholder: @Composable (() -> Unit)? = null,
        leadingIcon: @Composable (() -> Unit)? = null,
        trailingIcon: @Composable (() -> Unit)? = null,
        colors: TextFieldColors = textFieldColors(),
        contentPadding: PaddingValues =
            if (label == null) {
                textFieldWithoutLabelPadding() // ← here
            } else {
                textFieldWithLabelPadding() // ← here
            }
    ) {
        CommonDecorationBox(
            type = TextFieldType.Filled,
            value = value,
            innerTextField = innerTextField,
            visualTransformation = visualTransformation,
            placeholder = placeholder,
            label = label,
            leadingIcon = leadingIcon,
            trailingIcon = trailingIcon,
            singleLine = singleLine,
            enabled = enabled,
            isError = isError,
            interactionSource = interactionSource,
            colors = colors,
            contentPadding = contentPadding
        )
    }

    // ここで余白のデフォルト値を設定してる...
    @ExperimentalMaterialApi
    fun textFieldWithLabelPadding(
        start: Dp = TextFieldPadding,
        end: Dp = TextFieldPadding,
        top: Dp = FirstBaselineOffset,
        bottom: Dp = TextFieldBottomPadding
    ): PaddingValues = PaddingValues(start, top, end, bottom)

    // ここで余白のデフォルト値を設定してる...　　
    @ExperimentalMaterialApi
    fun textFieldWithoutLabelPadding(
        start: Dp = TextFieldPadding,
        top: Dp = TextFieldPadding,
        end: Dp = TextFieldPadding,
        bottom: Dp = TextFieldPadding
    ): PaddingValues = PaddingValues(start, top, end, bottom)
```

```Kotlin:TextFieldImpl
internal val TextFieldPadding = 16.dp
internal val HorizontalIconPadding = 12.dp
```

はい、ということで勝手にPaddingが設定されてしまったのは
**TextFieldDecorationBoxにcontentPaddingが指定されていない**ことが原因でした。
逆に言えば、contentPaddingをカスタマイズさえできればPadding問題が解決できそうです。

## 解決方針
TextFieldDecorationBoxにcontentPaddingを指定する。
→ BasicTextFieldを自前で作る
→１から作る必要はなく、TextField内のBasicTextFieldの設定を真似しつつ必要な部分をカスタマイズする


## 実装

```Kotlin
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun CustomTextField(
    label: String,
    value: String,
    enabled: Boolean = true,
    singleLine: Boolean = true,
    visualTransformation: VisualTransformation = VisualTransformation.None,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    isError: Boolean = false,
    keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
    keyboardActions: KeyboardActions = KeyboardActions.Default,
    onValueChange: (String) -> Unit
) {
    BasicTextField(
        value = value,
        modifier = Modifier
            .background(Color.Transparent, TextFieldDefaults.TextFieldShape)
            .indicatorLine(
                enabled,
                isError,
                interactionSource,
                TextFieldDefaults.textFieldColors()
            )
            .fillMaxWidth(),
        onValueChange = { onValueChange(it) },
        enabled = enabled,
        keyboardOptions = keyboardOptions,
        keyboardActions = keyboardActions,
        interactionSource = interactionSource,
        singleLine = singleLine,
    ) { innerTextField ->
        TextFieldDefaults.TextFieldDecorationBox(
            value = value,
            innerTextField = innerTextField,
            enabled = enabled,
            singleLine = singleLine,
            visualTransformation = visualTransformation,
            interactionSource = interactionSource,
            label = { Text(label) },
            // ↓ ここでPaddingを調整
            contentPadding = TextFieldDefaults.textFieldWithLabelPadding(0.dp)
        )
    }
}
```

結果、余白をなくすことに成功しました！
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670405/14bc1824-8930-1f0d-93f4-9db0ad7bce6f.png" width=60%>





## 最後に
ここまで実装はできたのですが、versionを1.2.0-alpha04以上にする必要があるということで、今回案件でのCompose使用は諦めました🥲
(composeVersionを1.2.0-rc02に上げるためにはcompileSdkを32に上げる必要があったため)


デフォルトでpadding設定されるのはいいけど、せめて外から設定変えさせてよ。というのが正直な感想です。
Composeはまだまだ改善の余地がありそうですね。