JetpackComposeには、UIのタップやスクロール動作のタイムアウト、サイズに関するパラメーターを指定するためのViewConfigurationというインターフェースが存在します。
CompositionLocalProviderで、このインターフェースを実装したクラスを渡すことにより、ロングタップまでの時間などを変更することが可能になります。
この記事ではViewConfigurationのそれぞれのパラメータが、実際のUI動作にどのような影響を及ぼすかを解説し検証します。
- ViewConfigurationの作り方と渡し方
- longPressTimeoutMillis
- doubleTapTimeoutMillis
- doubleTapMinTimeMillis
- touchSlop
- minimumTouchTargetSize
- maximumFlingVelocity
ViewConfigurationの作り方と渡し方
JetpackComposeには、値のバケツリレーを抑制するためにCompositionLocalという仕組みが存在します。
CompositionLocalを用いてViewConfigurationを渡すことにり、渡されたブロックより内側の動作が変更されます。
自分でViewConfigurationを実装したクラスを作成するには、一からViewConfigurationを実装したクラスを定義するか、今渡されているViewConfigurationを継承するかの2つの方法があります。
基本的には今渡されているViewConfigurationを継承するのが一般的で楽なので、そちらの方法を採用します。
例えば、長押しのタイムアウト時間を1000msに設定したViewConfigurationを作成して渡す方法は次のコードのようになります。
@Composable fun ViewConfigurationProvideSample() { val defaultViewConfiguration = LocalViewConfiguration.current class SampleViewConfiguration : ViewConfiguration by defaultViewConfiguration { override val longPressTimeoutMillis: Long get() = 1000L } val sampleViewConfiguration = remember { SampleViewConfiguration() } CompositionLocalProvider( LocalViewConfiguration provides sampleViewConfiguration ) { // このブロック内では、長押しのタイムアウト時間が1000msに設定される } }
longPressTimeoutMillis
longPressTimeoutMillisは、長押し(ロングタップ、ロングプレス)として判定されるまでの時間をミリ秒で指定します。
サンプルアプリ
サンプルアプリでは、longPressTimeoutMillisを3000に設定しています。
3000msタップし続けると、長押し判定となっていることがわかります。
クリックしてソースコードを表示
@OptIn(ExperimentalFoundationApi::class) @Composable fun LongPressTimeoutMillsSurvey() { // region: タイマー val coroutineScope = rememberCoroutineScope() var timerStartTime by remember { mutableLongStateOf(System.nanoTime()) } var timerCurrentTime by remember { mutableLongStateOf(0L) } LaunchedEffect(Unit) { coroutineScope.launch(Dispatchers.Default) { while (true) { timerCurrentTime = (System.nanoTime() - timerStartTime) / 1000000L delay(10) } } } // endregion // region: 長押しまでの時間を3秒に設定するViewConfiguration val defaultViewConfiguration = LocalViewConfiguration.current class AppViewConfiguration : ViewConfiguration by defaultViewConfiguration { override val longPressTimeoutMillis: Long get() = 3000L // 長押しまでの時間を3秒に設定 } val appViewConfiguration = remember { AppViewConfiguration() } // endregion // region: 長押しが検出されたときのダイアログ var showLongPressDetectedDialog by remember { mutableStateOf(false) } if (showLongPressDetectedDialog) { AlertDialog( onDismissRequest = { showLongPressDetectedDialog = false }, confirmButton = { Button(onClick = { showLongPressDetectedDialog = false }) { Text(text = "OK") } }, title = { Text(text = "Long press detected") }, ) } // endregion // region: UI本体 CompositionLocalProvider( LocalViewConfiguration provides appViewConfiguration // ViewConfigurationを設定 ) { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text(text = "Long press timer: $timerCurrentTime") Text( text = "Long press timeout: ${LocalViewConfiguration.current.longPressTimeoutMillis}ms", modifier = Modifier .background(Color(0xFFFF6666)) .combinedClickable( onClick = { }, onLongClick = { showLongPressDetectedDialog = true }, ) .pointerInput(Unit) { awaitEachGesture { awaitFirstDown(requireUnconsumed = true) timerStartTime = System.nanoTime() } } .padding(vertical = 16.dp) ) } } // endregion }
doubleTapTimeoutMillis
doubleTapTimeoutMillisは、2回目のタップがダブルタップとして判定されるまでのタイムアウト時間を指定します。
サンプルアプリ
サンプルアプリでは、doubleTapTimeoutMillisを2000に設定しています。
1回目のタップから、2000ms以内のタップがあればダブルタップ、2000ms以降では普通のタップとして認識されていることがわかります。
クリックしてソースコードを表示
@OptIn(ExperimentalFoundationApi::class) @Composable fun DoubleTapTimeoutMillsSurvey() { // region: タイマー val coroutineScope = rememberCoroutineScope() var timerStartTime by remember { mutableLongStateOf(System.nanoTime()) } var timerCurrentTime by remember { mutableLongStateOf(0L) } LaunchedEffect(Unit) { coroutineScope.launch(Dispatchers.Default) { while (true) { timerCurrentTime = (System.nanoTime() - timerStartTime) / 1000000L delay(10) } } } // endregion // region: ダブルタップのタイムアウト時間を2秒に設定するViewConfiguration val defaultViewConfiguration = LocalViewConfiguration.current class AppViewConfiguration : ViewConfiguration by defaultViewConfiguration { override val doubleTapTimeoutMillis: Long get() = 2000L // ダブルタップのタイムアウト時間を2秒に設定 } val appViewConfiguration = remember { AppViewConfiguration() } // endregion // region: ダブルタップが検出されたときのダイアログ var showDoubleTapDetectedDialog by remember { mutableStateOf(false) } if (showDoubleTapDetectedDialog) { AlertDialog( onDismissRequest = { showDoubleTapDetectedDialog = false }, confirmButton = { Button(onClick = { showDoubleTapDetectedDialog = false }) { Text(text = "OK") } }, title = { Text(text = "Long press detected") }, ) } // endregion // region: UI本体 CompositionLocalProvider( LocalViewConfiguration provides appViewConfiguration // ViewConfigurationを設定 ) { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text(text = "Timer: $timerCurrentTime") Text( text = "Double tap timeout: ${LocalViewConfiguration.current.doubleTapTimeoutMillis}ms", modifier = Modifier .background(Color(0xFFFF6666)) .combinedClickable( onClick = { }, onDoubleClick = { showDoubleTapDetectedDialog = true } ) .pointerInput(Unit) { awaitEachGesture { awaitFirstDown(requireUnconsumed = true) timerStartTime = System.nanoTime() } } .padding(vertical = 16.dp) ) } } // endregion }
doubleTapMinTimeMillis
doubleTapMinTimeMillisは、ダブルタップとして扱われるまでの最小時間を指定します。
サンプルアプリ
サンプルアプリではdoubleTapMinTimeMillisを1000に設定しています。また、doubleTapTimeoutMillisは2000に設定しています。
1回目のタップから1000ms以下のタップはダブルタップとして認識されていません。
しかし、2回目以降(!)のタップが1000ms以降であればダブルタップとして認識されていることがわかります。
ここで注意するべきポイントは、2回目だけでなく2回目以降のタップでもダブルタップとして認識される点です。
クリックしてソースコードを表示
@OptIn(ExperimentalFoundationApi::class) @Composable private fun DoubleTapMinTimeMillsSurvey() { // region: タイマー val coroutineScope = rememberCoroutineScope() var timerStartTime by remember { mutableLongStateOf(System.nanoTime()) } var timerCurrentTime by remember { mutableLongStateOf(0L) } var clickHistoryTime = remember { mutableStateListOf<Long>() } LaunchedEffect(Unit) { coroutineScope.launch(Dispatchers.Default) { while (true) { timerCurrentTime = (System.nanoTime() - timerStartTime) / 1000000L delay(10) } } } // endregion // region: ダブルタップの最小時間を2秒に設定するViewConfiguration val defaultViewConfiguration = LocalViewConfiguration.current class AppViewConfiguration : ViewConfiguration by defaultViewConfiguration { override val doubleTapMinTimeMillis: Long get() = 1000L // ダブルタップの最小時間を1秒に設定 override val doubleTapTimeoutMillis: Long get() = 2000L // ダブルタップのタイムアウト時間を2秒に設定 } val appViewConfiguration = remember { AppViewConfiguration() } // endregion // region: ダブルタップが検出されたときのダイアログ var showDoubleTapDetectedDialog by remember { mutableStateOf(false) } if (showDoubleTapDetectedDialog) { AlertDialog( onDismissRequest = { showDoubleTapDetectedDialog = false }, confirmButton = { Button(onClick = { showDoubleTapDetectedDialog = false }) { Text(text = "OK") } }, title = { Text(text = "Double tap detected") }, ) } // endregion // region: UI本体 CompositionLocalProvider( LocalViewConfiguration provides appViewConfiguration // ViewConfigurationを設定 ) { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { clickHistoryTime.forEachIndexed { index, historyTime -> val diff = if (index == 0) "-" else (historyTime - clickHistoryTime[index - 1]).toString() Text(text = "Click $index: $historyTime ($diff)") } Text(text = "Timer: $timerCurrentTime") Text( text = """ Double tap timeout: ${LocalViewConfiguration.current.doubleTapTimeoutMillis}ms Double tap min time: ${LocalViewConfiguration.current.doubleTapMinTimeMillis}ms """.trimIndent(), modifier = Modifier .background(Color(0xFFFF6666)) .combinedClickable( onClick = { }, onDoubleClick = { showDoubleTapDetectedDialog = true } ) .pointerInput(Unit) { awaitEachGesture { awaitFirstDown(requireUnconsumed = true) clickHistoryTime.add(timerCurrentTime) } } .padding(vertical = 16.dp) ) } } // endregion }
touchSlop
touchSlopは、スクロールまたはドラッグとして扱われるまでのスワイプの距離をピクセル単位で指定します。
サンプルアプリ
サンプルアプリではtouchSlopを128dp分のピクセル数として指定しています。
touchSlop未満のスクロールではスクロールとして扱われず、touchSlop以上ではスクロールとして扱われます。
また、Modifier.pointerInputのdetectDragGesturesが発生するスワイプのしきい値にもこの値が使用されます。
クリックしてソースコードを表示
@Composable private fun TouchSlopSurvey() { // region: TouchSlopを128に設定するViewConfiguration val defaultViewConfiguration = LocalViewConfiguration.current val density = LocalDensity.current val touchSlop = 128.dp val touchSlopPx = with(density) { touchSlop.toPx() } class AppViewConfiguration : ViewConfiguration by defaultViewConfiguration { override val touchSlop: Float get() = touchSlopPx // TouchSlopを128に設定 } val appViewConfiguration = remember { AppViewConfiguration() } // endregion val items = remember { (0..100).toList() } CompositionLocalProvider( LocalViewConfiguration provides appViewConfiguration // ViewConfigurationを設定 ) { Row(modifier = Modifier.fillMaxSize()) { Box( modifier = Modifier .background(Color.Red) .height(touchSlop) .width(8.dp) ) LazyColumn { items(items) { item -> Text( text = "Item $item", modifier = Modifier .background(if (item % 2 == 0) Color.Gray else Color.LightGray) .padding(16.dp) .fillMaxWidth() .weight(1f, fill = false), ) } } } } }
minimumTouchTargetSize
minimumTouchTargetSizeは、このサイズより小さい要素があったとき、タップ範囲が自動的にこのサイズまで拡大されます。
サンプルアプリ
サンプルアプリではminimumTouchTargetSizeを128dpに指定しています。
赤枠が128dp、緑枠が48dpとなっていて、赤枠をタップすればタップ判定が発生していることがわかります。
クリックしてソースコードを表示
@Composable private fun MinimumTouchTargetSizeSurvey() { val context = LocalContext.current val defaultViewConfiguration = LocalViewConfiguration.current // region: MinimumTouchTargetSizeを128dpに設定するViewConfiguration class MinimumTouchTargetSize128ViewConfiguration : ViewConfiguration by defaultViewConfiguration { override val minimumTouchTargetSize: DpSize get() = DpSize(128.dp, 128.dp) // MinimumTouchTargetSizeを128dpに設定 } val minimumTouchTargetSize128ViewConfiguration = remember { MinimumTouchTargetSize128ViewConfiguration() } // endregion CompositionLocalProvider( LocalViewConfiguration provides minimumTouchTargetSize128ViewConfiguration // ViewConfigurationを設定 ) { Row( modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier .size(128.dp) .background(Color.Red), contentAlignment = Alignment.Center, ) { Box( modifier = Modifier .size(48.dp) .background(Color.Green) .clickable { Toast .makeText(context, "Clicked", Toast.LENGTH_SHORT) .show() } ) } } } }
maximumFlingVelocity
maximumFlingVelocityは、スクロールのフライング動作(スクロールに速度がついて勝手に一定距離スクロールする動作)の最大速度をピクセル単位で指定します。
サンプルアプリ
サンプルアプリではmaximumFlingVelocityを600dp分のピクセル数として指定しています。
スクロールのフライング動作が落ちていることがわかります。
クリックしてソースコードを表示
@Composable private fun MaximumFlingVelocitySurvey() { // region: MaximumFlingVelocityを600dp/sに設定するViewConfiguration val defaultViewConfiguration = LocalViewConfiguration.current val density = LocalDensity.current val maximumFlingVelocity = 600.dp val maximumFlingVelocityPx = with(density) { maximumFlingVelocity.toPx() } class AppViewConfiguration : ViewConfiguration by defaultViewConfiguration { override val maximumFlingVelocity: Float get() = maximumFlingVelocityPx // MaximumFlingVelocityを600dp/sに設定 } val appViewConfiguration = remember { AppViewConfiguration() } // endregion val items = remember { (0..100).toList() } CompositionLocalProvider( LocalViewConfiguration provides appViewConfiguration // ViewConfigurationを設定 ) { Row(modifier = Modifier.fillMaxSize()) { Box( modifier = Modifier .background(Color.Red) .height(maximumFlingVelocity) .width(8.dp) ) LazyColumn { items(items) { item -> Text( text = "Item $item", modifier = Modifier .background(if (item % 2 == 0) Color.Gray else Color.LightGray) .padding(16.dp) .fillMaxWidth() .weight(1f, fill = false), ) } } } } }