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
関連記事: