Compose Button:Single/Long/Double Click Buttonの作成

投稿日:  更新日:

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
) 
 #combinedClickable( ) 

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
                )
            }
        }
    }
}
 「引数colors: ButtonColorsの簡略化」について 

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

スポンサーリンク

関連記事:

Jetpack composeは、アプリ開発に必要な一通りのUIコンポーネントをライブラリで提供しています。 そのライブラリ中のButtonについて、構成や使用方法などをまとめます。 ※環境:Android Studio Flamingo | 2022.2.1    :androidx.compose.material3:material3:1.1.1    :androidx.compose.ui:ui:1.4.3 ...
Jetpack Composeのライブラリで提供されるButtonは、クリックした時にリップルエフェクト(波紋が広がる)を表示します。クリック感をユーザへ伝える演出です。 このリップルエフェクトですが、デフォルトの動作で固定化されています。動作の変更や置き換えが出来ません。 アプリにボタンは多用されます。より見栄えの良いエフェクトへ変更できれば、アプリの差別化に役立つと思います。なぜ、固定化したのでしょう?!Button開発者の意図が分からないです... 変更できなければ、できるButtonを作るしかありません。 というわけで… リップルエフェクトの動作の変更や置き換えが可能なButtonを作成してみました。 ※環境:Android Studio Giraffe | 2022.3.1    :androidx.compose.material3:material3:1.1.1    :androidx.compose.ui:ui:1.4.3 ...
スポンサーリンク