03.CustomExposedDropdownMenuBox

OutlinedTextFieldと同様にmaterial3 1.4.0のドロップダウンメニューボックスExposedDropdownMenuBoxも枠線とテキスト入力域の間に入っているPaddingが広めで調整することができません

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

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

import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import jp.co.progress_llc.portal.core.ui.theme.AppTheme

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomExposedDropdownMenuBox(
  modifier: Modifier = Modifier,
  label: String,
  options: List<Pair<String, List<String>>>,
  selectedOption: String?,
  onOptionSelected: (List<String>) -> Unit,
  showAdditionalItem: Boolean = false,
  additionalItemText: String = "新規登録",
  onAdditionalItemClick: (() -> Unit)? = null
) {
  var expanded by remember { mutableStateOf(false) }
  val hasSelection = selectedOption?.isNotEmpty() == true
  var boxWidth by remember { mutableStateOf(0.dp) }
  val density = LocalDensity.current

  // labelアニメーション制御
  val transition = updateTransition(
    targetState = expanded || hasSelection,
    label = "labelTransition"
  )
  val labelScale   by transition.animateFloat(label = "labelScale") { if (it) 0.85f else 1f }
  val labelOffsetY by transition.animateDp(label = "labelOffsetY") { if (it) (-12).dp else 2.dp }

  // ドロップダウン開閉アニメーション
  val dropdownTransition = updateTransition(targetState = expanded, label = "dropdownTransition")
  val animatedAlpha by dropdownTransition.animateFloat(label = "alpha") { if (it) 1f else 0f }
  val animatedScale by dropdownTransition.animateFloat(label = "scale") { if (it) 1f else 0.95f }

  Spacer(modifier = Modifier.height(4.dp))
  Box(
    modifier = modifier
      .onGloballyPositioned { coordinates ->
        boxWidth = with(density) { coordinates.size.width.toDp() - 8.dp }
      }
  ) {
    // 外枠+ラベル+選択表示
    Box(
      modifier = Modifier
        .background(MaterialTheme.colorScheme.surface.copy(alpha = 1f))
        .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
        .padding(horizontal = 12.dp, vertical = 4.dp)
        .clickable { expanded = !expanded }
    ) {
      // 選択済テキスト
      Row(
        modifier = Modifier
          .fillMaxWidth()
          .background(MaterialTheme.colorScheme.surface)
          .padding(top = 8.dp),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
      ) {
        Text(
          text = selectedOption ?: "",
          style = MaterialTheme.typography.bodyLarge,
          color = MaterialTheme.colorScheme.onSurface
        )
        ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
      }
    }
    // ラベル(OutlinedTextField風アニメーション)
    Text(
      text = label,
      style = MaterialTheme.typography.bodyLarge,
      color = if (expanded) MaterialTheme.colorScheme.primary
      else MaterialTheme.colorScheme.onSurfaceVariant,
      modifier = Modifier
        .offset(x = 10.dp, y = labelOffsetY)
        .background(MaterialTheme.colorScheme.surface)
        .scale(labelScale)
        .padding(horizontal = 2.dp)
    )
    // ドロップダウンメニュー(フェード+拡縮アニメーション)
    Box(
      modifier = Modifier
        .fillMaxWidth()
        .graphicsLayer {
          alpha = animatedAlpha
          scaleY = animatedScale
        }
    ) {
      DropdownMenu(
        expanded = expanded,
        onDismissRequest = { expanded = false },
        offset = DpOffset(x = 4.dp, y = 12.dp),
        modifier = Modifier
          .width(boxWidth)
          .background(MaterialTheme.colorScheme.surface)
      ) {
        options.forEach { (text, values) ->
          DropdownMenuItem(
            text = { Text(text) },
            onClick = {
              onOptionSelected(values)
              expanded = false
            }
          )
        }
        if (showAdditionalItem) {
          if (options.isNotEmpty()) { HorizontalDivider() }
          DropdownMenuItem(
            text = { Text(additionalItemText) },
            onClick = {
              onAdditionalItemClick?.invoke()
              expanded = false
            }
          )
        }
      }
    }
  }
}

@Preview(showBackground = true)
@Composable
fun CustomExposedDropdownMenuBoxPreview() {
  AppTheme {
    var selected by remember { mutableStateOf<String?>(null) }
    CustomExposedDropdownMenuBox(
      label = "選択してください",
      options = emptyList(),
      selectedOption = selected,
      onOptionSelected = { },
    )
  }
}

24行目
ドロップダウンメニューの既定の外観を定義しているExposedDropdownMenuDefaultsを使う際に必要です。
25行目
Jetpack Compose関数であることを宣言します。
27行目~34行目
関数の引数を定義しています。
46行目
ラベルの大きさを調整しています。
47行目
ラベルの位置を調整しています。
54行目
ドロップダウン選択時ラベルがボックスの上罫線から上にはみ出ますので、その分の領域を確保しています。
56行目~59行目
ドロップダウンリストの幅を計算しています。
70行目~84行目
選択された内容を表示しています。
87行目~97行目
ラベルを表示しています。
93行目
ラベルの表示位置を調整しています。
95行目
ラベルの大きさを調整しています。
110行目
ドロップダウンリストの表示位置を調整しています。
115行目~123行目
ドロップダウンリストを表示しています。
124行目~133行目
ドロップダウンリストの追加アイテムを表示しています。
139行目~151行目
Android Studioで画面のプレビューを表示するための関数を定義しています。
なくても構いません。