Jetpack Composeのライブラリで提供されるButtonは、ロングクリック(長押し)に対応していません。
ロングクリックは重宝します。
例えば、ミスタッチで起動して欲しくない機能に使ったり、裏技を仕込んだり、などです。なくても困らないけど、あった方が断然便利です。
ロングクリックが無ければ、有るButtonを作るしかありません。
というわけで…
Single/Long/Doubleクリックに対応したButtonを作成してみました。
※環境:Android Studio Giraffe | 2022.3.1
:androidx.compose.material3:material3:1.1.1
:androidx.compose.ui:ui:1.4.3
目次
Buttonのクリック処理
Jetpack Composeのライブラリで提供されるButton(既存Button)のクリック処理を確認しましょう。
Button内でクリック処理は、Surfaceが担当しています。
@Composable
fun Button(
onClick: () -> Unit,
modifier: Modifier = Modifier,
...
) {
...
Surface(
onClick = onClick,
modifier = modifier.semantics { role = Role.Button },
...
) { ... }
}
Surfaceは4タイプあり、Buttonで使用されているのは引数onClickを持つタイプ(Type2)です。※Surfaceの詳細は「Compose UI:Surface」を参照
@Composable
@NonRestartableComposable
fun Surface(
onClick: () -> Unit,
modifier: Modifier = Modifier,
...
) {
val absoluteElevation = LocalAbsoluteTonalElevation.current + tonalElevation
CompositionLocalProvider(
LocalContentColor provides contentColor,
LocalAbsoluteTonalElevation provides absoluteElevation
) {
Box(
modifier = modifier
.minimumInteractiveComponentSize()
.surface(...)
.clickable(
interactionSource = interactionSource,
indication = rememberRipple(),
enabled = enabled,
onClick = onClick
),
propagateMinConstraints = true
) {
content()
}
}
}
Surface内でクリック処理は、Boxの引数modifierで行われていて、Modifier#clickable( )が使われています。ここで、クリック処理は必ず上書きされます。
クリック処理済みのmodifierは関数外部から参照できないので、クリック処理へ変更を加えることは不可能です。ですので、このSurface(Type2)は既存Button向けの特注品と言えます。
ロングクリック(長押し)Buttonを実現したければ、専用のSurfaceが必要になります。
Modifierのアクション
Modifier(Compose修飾子)のアクションは、Modifier#checkable( )の他に、Modifier#combinedClickable( )があります。
#clickable( )SingleClickのみ処理可能です。LongClickは未サポートになります。
Modifier.clickable(
interactionSource: MutableInteractionSource,
indication: Indication?,
enabled: Boolean,
onClickLabel: String?,
role: Role?,
onClick: () -> Unit
)
SingleClick、LongClick、DoubleClickが処理可能です。
ただし、アノテーション@ExperimentalFoundationApiが示すように「実験的な実装」とされています。
@ExperimentalFoundationApi
fun Modifier.combinedClickable(
interactionSource: MutableInteractionSource,
indication: Indication?,
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onLongClickLabel: String? = null,
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
onClick: () -> Unit
): Modifier
CombinedButtonの作成
全てのクリック処理(Single/Long/Double Click)を網羅したCombinedButtonを作成します。
作成の方法は、既存SurfaceとButtonの記述を移植して、クリック処理へカスタマイズを加えました。
Surfaceの作成
始めに、CombinedButton専用のSurface(CombinedClickableSurface)を作成します。
行ったことは以下の通りです。
- ValirousSurface.ktを作成、新規Surfaceの格納場所とする
- Surface(Type2)をコピー、クラス名をCombinedClickableSurfaceへ変更
- Surfaceに付随するPrivate関数をコピー
- Modifier#clickable⇒#combinedClickableへ変更、クリック処理の実装
- 上記に追随して、引数indication(リップルエフェクト)の実装
作成したCombinedClickableSurfaceは次のようになりました。
@OptIn(ExperimentalFoundationApi::class)
@Composable
@NonRestartableComposable
fun CombinedClickableSurface(
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
enabled: Boolean = true,
shape: Shape = RectangleShape,
color: Color = MaterialTheme.colorScheme.surface,
contentColor: Color = contentColorFor(color),
tonalElevation: Dp = 0.dp,
shadowElevation: Dp = 0.dp,
border: BorderStroke? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
indication: Indication? = rememberRipple(),
content: @Composable () -> Unit
) {
val absoluteElevation = LocalAbsoluteTonalElevation.current + tonalElevation
CompositionLocalProvider(
LocalContentColor provides contentColor,
LocalAbsoluteTonalElevation provides absoluteElevation
) {
Box(
modifier = modifier
.minimumInteractiveComponentSize()
.surface(
shape = shape,
backgroundColor = surfaceColorAtElevation(
color = color,
elevation = absoluteElevation
),
border = border,
shadowElevation = shadowElevation
)
.combinedClickable( // アノテーション@OptInが必要
interactionSource = interactionSource,
indication = indication,
enabled = enabled,
onClick = onClick?.let{onClick}?:{ }, // nullを可能にした
onLongClick = onLongClick,
onDoubleClick = onDoubleClick
),
propagateMinConstraints = true
) {
content()
}
}
}
// ==================================================================
private fun Modifier.surface(
shape: Shape,
backgroundColor: Color,
border: BorderStroke?,
shadowElevation: Dp
) = this
.shadow(shadowElevation, shape, clip = false)
.then(if (border != null) Modifier.border(border, shape) else Modifier)
.background(color = backgroundColor, shape = shape)
.clip(shape)
@Composable
private fun surfaceColorAtElevation(color: Color, elevation: Dp): Color {
return if (color == MaterialTheme.colorScheme.surface) {
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
} else {
color
}
}
Buttonの作成
CombinedClickableSurfaceを使って、全てのクリック処理(Single/Long/Double Click)を網羅したButton(CombinedButton)を作成します
行ったことは以下の通りです。
- ValirousButton.ktを作成、新規Buttonの格納場所とする
- 既存Buttonをコピー、クラス名をCombinedButtonへ変更
- Surface⇒CombinedClickableSurfaceへ変更、クリック処理の実装
- 上記に追随して、引数indication(リップルエフェクト)の実装
- 既存Buttonの引数colors: ButtonColorsの簡略化
- 既存Buttonの引数elevation: ButtonElevationの簡略化
作成したCombinedButtonは次のようになりました。
@Composable
fun CombinedButton(
onClick: (() -> Unit)? = null, // nullを可能にした
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
enabled: Boolean = true,
shape: Shape = ButtonDefaults.shape,
containerColor: Color = MaterialTheme.colorScheme.primary,
contentColor: Color = MaterialTheme.colorScheme.onPrimary,
tonalElevation: Dp = 0.0.dp,
shadowElevation: Dp = 0.0.dp,
border: BorderStroke? = null,
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
indication: Indication? = rememberRipple(),
content: @Composable RowScope.() -> Unit
) {
CombinedClickableSurface(
onClick = onClick,
onLongClick = onLongClick,
onDoubleClick = onDoubleClick,
modifier = modifier.semantics { role = Role.Button },
enabled = enabled,
shape = shape,
color = containerColor, // コンポーネントの色
contentColor = contentColor, // コンテンツの色
tonalElevation = tonalElevation, // 色調を変える高さ
shadowElevation = shadowElevation, // 影の長さを変える高さ
border = border,
interactionSource = interactionSource,
indication = indication
) {
CompositionLocalProvider(LocalContentColor provides contentColor) {
ProvideTextStyle(value = MaterialTheme.typography.labelLarge) {
Row(
Modifier
.defaultMinSize(
minWidth = ButtonDefaults.MinWidth,
minHeight = ButtonDefaults.MinHeight
)
.padding(contentPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
content = content
)
}
}
}
}
ButtonColorsクラスは、containerColor(コンポーネントの色)やcontentColor(コンテンツの色)を、UIの有効/無効(enable/disable)で選択する機能を有しています。
@Immutable
class ButtonColors internal constructor(
private val containerColor: Color,
private val contentColor: Color,
private val disabledContainerColor: Color,
private val disabledContentColor: Color,
) {
@Composable
internal fun containerColor(enabled: Boolean): State<Color> {
return rememberUpdatedState(if (enabled) containerColor else disabledContainerColor)
}
@Composable
internal fun contentColor(enabled: Boolean): State<Color> {
return rememberUpdatedState(if (enabled) contentColor else disabledContentColor)
}
...
}
実装された関数がinternal修飾子を持つため、ビルド単位外(モジュール外)の実行を拒否します。
ですので、同等な機能はButtonの外部で行うことにして、CombinedButtonへ引数containerColorとcontentColorを持たせました。
「引数elevation: ButtonElevationの簡略化」についてButtonElevationクラスは、tonalElevation(色調を変える高さ)やshadowElevation(影の長さを変える高さ)を、UIの状態(Disable/Press/Focus/…など)で選択する機能を有しています。
@Stable
class ButtonElevation internal constructor(
private val defaultElevation: Dp,
private val pressedElevation: Dp,
private val focusedElevation: Dp,
private val hoveredElevation: Dp,
private val disabledElevation: Dp,
) {
@Composable
internal fun tonalElevation(enabled: Boolean, interactionSource: InteractionSource): State<Dp> {
return animateElevation(enabled = enabled, interactionSource = interactionSource)
}
@Composable
internal fun shadowElevation(
enabled: Boolean,
interactionSource: InteractionSource
): State<Dp> {
return animateElevation(enabled = enabled, interactionSource = interactionSource)
}
...
}
実装された関数がinternal修飾子を持つため、ビルド単位外(モジュール外)の実行を拒否します。
ですので、同等な機能はButtonの外部で行うことにして、CombinedButtonへ引数tonalElevationとshadowElevationを持たせました。
CombinedButtonのサンプル
CombinedButtonのサンプルです。
クリック処理以外は、外観もリップルエフェクトも、既存Buttonと同じ見栄えになっています。
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
// A surface container using the 'background' color from the theme
Surface(...) {
ButtonPanel(
onClick = ::normalClick,
onSingleClick = ::singleClick,
onLongClick = ::longClick,
onDoubleClick = ::doubleClick
)
}
}
}
}
fun normalClick() { Log.i(TAG, "Normal Click !") }
fun singleClick() { Log.i(TAG, "Single Click !") }
fun longClick() { Log.i(TAG, "Long Click !") }
fun doubleClick() { Log.i(TAG, "Double Click !") }
}
@Composable
fun ButtonPanel(
onClick: () -> Unit,
onSingleClick: () -> Unit,
onLongClick: () -> Unit,
onDoubleClick: () -> Unit
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Button(onClick = onClick) {
Text(text = "Normal")
}
Spacer(modifier = Modifier.size(16.dp))
//
CombinedButton(onClick = onSingleClick) {
Text(text = "Single")
}
CombinedButton(onLongClick = onLongClick) {
Text(text = "Long")
}
CombinedButton(onDoubleClick = onDoubleClick) {
Text(text = "Double")
}
}
}
10:51:47.625 Normal Click ! 10:51:49.056 Single Click ! 10:51:50.581 Long Click ! 10:51:52.631 Double Click ! 10:51:54.359 Single Click ! 10:51:55.922 Long Click ! 10:51:57.895 Double Click !
※Normalは既存のButton
関連記事:
