Is there a 'touch delegate' equivalent in jetpack compose?

Viewed 882

An android view can have a touch delegate to increase the clickable area of an element, without increasing it's padding. Is there something like that in jetpack compose? I can't find a modifier that will do it.

3 Answers

Move state to parent

@Composable
fun ViewScreen() {
    var scale by remember { mutableStateOf(1f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    var rotation by remember { mutableStateOf(0f) }

    Column(
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier
            // touch event
            .pointerInput(Unit) {
                detectTransformGestures(
                    onGesture = { _, pan, gestureZoom, gestureRotate ->
                        scale *= gestureZoom
                        offset = offset.plus(pan)
                        rotation += gestureRotate
                    }
                )
            }
            .fillMaxSize()
    ) {
        Cube(scale, offset, rotation)
    }
}

@Composable
fun Cube(scale: Float, offset: Offset, rotation: Float) {
    Box(
        Modifier
            .graphicsLayer(
                scaleX = scale,
                scaleY = scale,
                rotationZ = rotation,
                translationX = offset.x,
                translationY = offset.y
            )
            .background(Color.Blue)
            .size(256.dp)
    )
}

This is implementation i build, i haven't countered any errors in calculations but if you do comment or hope it helps you build better one based on this answer.

1-) Create data classes to increase or decrease dp size of touch area

@Immutable
data class DelegateRect(
    val left: Dp = 0.dp,
    val top: Dp = 0.dp,
    val right: Dp = 0.dp,
    val bottom: Dp = 0.dp
) {
    companion object {
        val Zero = DelegateRect()
    }
}

@Immutable
data class RectF(
    val left: Float = 0f,
    val top: Float = 0f,
    val right: Float = 0f,
    val bottom: Float = 0f
)

2-) Create a composed Modifier to restore or remember its state

fun Modifier.touchDelegate(
    dpRect: DelegateRect = DelegateRect.Zero,
    onClick: () -> Unit
) =
    composed(
    inspectorInfo = {
        name = "touchDelegate"
        properties["dpRect"] = dpRect
        properties["onClick"] = onClick
    },
        factory = {

            val density = LocalDensity.current

            var initialSize by remember {
                mutableStateOf(IntSize.Zero)
            }

            val updatedRect = remember(dpRect) {
                with(density) {
                    RectF(
                        left = dpRect.left.toPx(),
                        top = dpRect.top.toPx(),
                        right = dpRect.right.toPx(),
                        bottom = dpRect.bottom.toPx(),
                    )
                }
            }


            val scale = remember(initialSize, updatedRect) {
                getScale(initialSize, updatedRect)
            }


            Modifier
                .graphicsLayer {
                    scaleX = scale.x
                    scaleY = scale.y
                    this.translationX = -updatedRect.left
                    this.translationY = -updatedRect.top

                    transformOrigin = TransformOrigin(0f, 0f)
                }
                .clickable {
                    onClick()
                }
                .graphicsLayer {
                    val scaleX = if (scale.x == 0f) 1f else 1 / scale.x
                    val scaleY = if (scale.y == 0f) 1f else 1 / scale.y
                    this.scaleX = scaleX
                    this.scaleY = scaleY
                    this.translationX = (updatedRect.left) * scaleX
                    this.translationY = (updatedRect.top) * scaleY
                    transformOrigin = TransformOrigin(0f, 0f)
                }
                .onSizeChanged {
                    initialSize = it
                }
        }
    )

Let me explain step by step

3-) Modifier.graphicsLayer{} can scale, translate or rotate our Composable's layer. And order of it matters, if we set it before Modifier.clickable it increases both clickable area and Composable scale. Responsibility of Modifier.graphicsLayer{} on top is to scale up touch area and Composable, to scale and translate back to original position second Modifier.graphicsLayer{} is required.

4-) Basically we scale as new added dp size and translate left and top because our transform origin, normally it's center but more difficult to calculate translations.

5-) When translating back we need to consider reverse of scale.

6-) To have accurate scaling we need size of our Composable which we get from Modifier.onSizeChanged{}

7-) Scale function that creates scale using initial non zero size and the size after we add touch offset via rectangle

private fun getScale(initialSize: IntSize, updatedRect: RectF): Offset =
    if (initialSize.width == 0 ||
        initialSize.height == 0
    ) {
        Offset(1f, 1f)
    } else {
        val initialWidth = initialSize.width
        val initialHeight = initialSize.height
        val scaleX =
            ((updatedRect.left + updatedRect.right + initialWidth) / initialWidth)
                .coerceAtLeast(0f)
        val scaleY =
            ((updatedRect.top + updatedRect.bottom + initialHeight) / initialHeight)
                .coerceAtLeast(0f)
        Offset(scaleX, scaleY)
    }

Usage

Column(
    modifier = Modifier.fillMaxSize(),
    horizontalAlignment = Alignment.CenterHorizontally
) {

    Image(
        painter = painterResource(id = R.drawable.landscape1),
        contentDescription = null,
        contentScale = ContentScale.FillBounds,
        modifier = Modifier
            .size(200.dp)
            .clickable { }
    )

    Spacer(modifier = Modifier.height(40.dp))

    Image(
        painter = painterResource(id = R.drawable.landscape1),
        contentDescription = null,
        contentScale = ContentScale.FillBounds,
        modifier = Modifier
            .size(200.dp)
            .touchDelegate(
                DelegateRect(
                    left = 50.dp,
                    top = 40.dp,
                    right = 70.dp,
                    bottom = 90.dp
                )
            ) {

            }
    )
}

Result

enter image description here

I was looking for a cleaner solution for this and stumbled upon ViewConfiguration class. So basically clickable modifier relies upon LocalViewConfiguration.current to provide a default implementation. If you already know the size you want increase the touch area to, you can just provide a custom implementation of this class and override minimumTouchTargetSize to provide an updated size. Here is an example sublcass of View Configuration

class CustomViewConfiguration(
    private val viewConfiguration: android.view.ViewConfiguration,
    private val size: Dp
) : ViewConfiguration {
    override val longPressTimeoutMillis: Long
        get() = 
    android.view.ViewConfiguration.getLongPressTimeout().toLong()

    override val doubleTapTimeoutMillis: Long
        get() = 
    android.view.ViewConfiguration.getDoubleTapTimeout().toLong()

    override val doubleTapMinTimeMillis: Long
        get() = 40

    override val touchSlop: Float
        get() = viewConfiguration.scaledTouchSlop.toFloat()

    override val minimumTouchTargetSize: DpSize
        get() = DpSize(size, size)
}

Once this is defined, you can provide this to your composable by using CompositionLocalProider

CompositionLocalProvider(
    LocalViewConfiguration provides CustomViewConfiguration(
        android.view.ViewConfiguration.get(
            LocalContext.current
        ),
        size.dp
    )
) {
    // Your composable here that needs an increased size

}

If you don't know the increased size before hand, you can use BoxWithConstraints or any other SubComposeLayout which measures the composables and provides them in the constraints for you to use.

Related