02.CustomOutlinedTextField

androidx.compose:compose-bom:2025.10.01に入っているandroidx.compose.material3:material3はバージョン1.4.0になります。

material3 1.4.0のテキストボックスOutlinedTextFieldは、枠線とテキスト入力域の間に入っているPaddingが広めで調整することができません

調整可能なOutlinedTextFieldコンポーネントCustomOutlinedTextField.ktcomponentsディレクトリ(パッケージ)の直下に作成します。

package jp.co.progress_llc.portal.core.ui.components

import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.*

@Composable
fun CustomOutlinedTextField(
  value: String,
  onValueChange: (String) -> Unit,
  modifier: Modifier = Modifier,
  label: (@Composable (() -> Unit))? = null,
  placeholder: String? = null,
  textStyle: TextStyle = MaterialTheme.typography.bodySmall,
  singleLine: Boolean = true,
  shape: Shape = MaterialTheme.shapes.small,
  contentPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
  height: Dp = 48.dp,
  readOnly: Boolean = false,
  onClick: (() -> Unit)? = null
) {
  var isFocused by remember { mutableStateOf(false) }
  // MaterialThemeを@Composable内で安全に参照
  val colorScheme = MaterialTheme.colorScheme
  // focus状態に応じて枠線色を変更
  val borderColor = if (isFocused) colorScheme.primary else colorScheme.outline
  // labelの移動・縮小アニメーション
  val labelScale   by animateFloatAsState(targetValue = if (value.isNotEmpty() || isFocused) 0.75f else 1f)
  val labelOffsetY by animateDpAsState(targetValue = if (value.isNotEmpty() || isFocused) (-8).dp else 2.dp)
  // プレースホルダーの表示条件
  val isPlaceholderVisible = value.isEmpty() && !isFocused && placeholder != null

  Spacer(modifier = Modifier.height(4.dp))
  Box(modifier = modifier) {
    Box(
      modifier = Modifier
        .height(height)
        .border(1.dp, borderColor, shape)
        .padding(contentPadding),
      contentAlignment = Alignment.CenterStart
    ) {
      // プレースホルダー
      if (isPlaceholderVisible) {
        androidx.compose.material3.Text(
          text = placeholder,
          style = textStyle.copy(
            color = colorScheme.onSurface.copy(alpha = 0.6f),
            fontSize = 14.sp
          ),
          modifier = Modifier.fillMaxWidth()
        )
      }
      // 入力フィールド
      BasicTextField(
        value = value,
        onValueChange = onValueChange,
        readOnly = readOnly,
        singleLine = singleLine,
        textStyle = textStyle.copy(color = colorScheme.onSurface, fontSize = 14.sp),
        cursorBrush = SolidColor(colorScheme.primary),
        modifier = Modifier
          .fillMaxWidth()
          .onFocusChanged { focusState ->
            val newFocus = focusState.isFocused
            if (isFocused != newFocus) isFocused = newFocus
          }
      )
      // readOnlyかつonClickが指定されている場合、透明なBoxを重ねてクリック可能にする
      if (readOnly && onClick != null) {
        Box(
          modifier = Modifier
            .fillMaxSize()
            .clickable { onClick() }
        )
      }
    }
    // ラベルが指定されていて、プレースホルダーが表示されていなければラベルがを描画
    if (label != null && !isPlaceholderVisible) {
      Box(
        modifier = Modifier
          .offset(x = 10.dp, y = labelOffsetY)
          .background(color = MaterialTheme.colorScheme.background)
          .scale(labelScale)
      ) {
        label()
      }
    }
  }
}

@Preview(showBackground = true)
@Composable
fun CustomOutlinedTextFieldPreview() {
  var text by remember { mutableStateOf("") }
  CustomOutlinedTextField(
    value = text,
    onValueChange = { },
    placeholder = "プレースホルダー"
  )
}

23行目
Jetpack Compose関数であることを宣言します。
25行目~36行目
関数の引数を定義します。
44行目
ラベルの大きさを調整しています。
45行目
ラベルの位置を調整しています。
49行目
テキスト入力時ラベルがボックスの上罫線から上にはみ出ますので、その分の領域を確保しています。
86行目~90行目
TextBoxreadOnry属性を付けると.clickableは処理されません
空のBoxは親要素の領域を引き継ぎますので、親要素に透明のBoxをかぶせています。
.clickableを最上位の透明Boxで処理します。
97行目
ラベルの表示位置を調整しています。
99行目
ラベルの大きさを調整しています。
107行目~115行目
Android Studioで画面のプレビューを表示するための関数を定義しています。
なくても構いません。