てっくらふと

IT技術系ブログ。たまにV系もあるかも : JetpackCompose / Django / FastAPI

ViewConfigurationの動作の解説と検証【JetpackCompose】

JetpackComposeには、UIのタップやスクロール動作のタイムアウト、サイズに関するパラメーターを指定するためのViewConfigurationというインターフェースが存在します。
CompositionLocalProviderで、このインターフェースを実装したクラスを渡すことにより、ロングタップまでの時間などを変更することが可能になります。

この記事ではViewConfigurationのそれぞれのパラメータが、実際のUI動作にどのような影響を及ぼすかを解説し検証します。

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),
                    )
                }
            }
        }
    }
}


CSS Design created by satotaka99. Thankyou.