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.
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.
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
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.