- ピンチインアウト
- ダブルタップ
で、ZoomIn (Out) する部品を考える
コード
@Composable
fun ZoomableContent(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
val scale = remember { Animatable(1f) }
val offset = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
val constraintSize = remember { mutableStateOf(Size.Zero) }
val scope = rememberCoroutineScope()
val transformableState = rememberTransformableState { zoomChange, offsetChange, _ ->
// ピンチインアウトは3倍が上限
val newScale = (scale.value * zoomChange).coerceIn(1f, 3f)
// コンテンツがズームインされた時(newScale > 1f)中央から最大どれだけ移動できるかを計算: (constraintSize.value.width * (newScale - 1)) / 2
val maxOffsetX = if (newScale > 1f) (constraintSize.value.width * (newScale - 1)) / 2 else 0f
val maxOffsetY = if (newScale > 1f) (constraintSize.value.height * (newScale - 1)) / 2 else 0f
// 移動量が画面幅を超えないようにOffsetを制限
val newOffsetX = (offset.value.x + offsetChange.x).coerceIn(-maxOffsetX, maxOffsetX)
val newOffsetY = (offset.value.y + offsetChange.y).coerceIn(-maxOffsetY, maxOffsetY)
scope.launch { scale.snapTo(newScale) }
scope.launch { offset.snapTo(Offset(newOffsetX, newOffsetY)) }
}
val updateDoubleTapZoomOffset = { doubleTapOffset: Offset ->
// ダブルタップは1.5倍が上限
val newScale = if (scale.value > 1f) 1f else 1.5f
val layoutWidth = constraintSize.value.width
val layoutHeight = constraintSize.value.height
val maxOffsetX = (layoutWidth * (newScale - 1)) / 2
val maxOffsetY = (layoutHeight * (newScale - 1)) / 2
val scaledFocalX = (doubleTapOffset.x - offset.value.x) * newScale
val scaledFocalY = (doubleTapOffset.y - offset.value.y) * newScale
val newOffset = Offset(
x = (layoutWidth / 2 - scaledFocalX).coerceIn(-maxOffsetX, maxOffsetX),
y = (layoutHeight / 2 - scaledFocalY).coerceIn(-maxOffsetY, maxOffsetY)
)
scope.launch { scale.animateTo(newScale, tween()) }
scope.launch { offset.animateTo(newOffset, tween()) }
}
BoxWithConstraints(
modifier = modifier
.transformable(transformableState)
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = { doubleTapOffset ->
updateDoubleTapZoomOffset(doubleTapOffset)
}
)
}
.graphicsLayer(
scaleX = scale.value,
scaleY = scale.value,
translationX = offset.value.x,
translationY = offset.value.y
)
) {
constraintSize.value = Size(constraints.maxWidth.toFloat(), constraints.maxHeight.toFloat())
content()
}
}
解説
Modifier.transformable
でピンチインアウトのScaleとオフセットが取れるので transformableState
でよしなに計算する
Modifier.pointerInput()
のdetectTapGestures
でダブルタップのOffsetがとれるので、updateDoubleTapZoomOffset
でよしなに計算する
.graphicsLayer(
scaleX = scale.value,
scaleY = scale.value,
translationX = offset.value.x,
translationY = offset.value.y
)
graphcsLayerに適応する
結果
singleItem | clickableItem | fullSizeItem |
---|---|---|
![]() |
![]() |
![]() |
課題
- 子要素のタップがあると、イベントが吸われやすい
- LazyColumnが縦スクロールを検知してくれたスクロールだけフリングが効く
- Zoomの伝搬みたいな、子要素にもZoomできるContentがあった場合どうなるかみてない
いずれも結構なカスタムが必要な気がする。
感想
公式ドキュメントがめっちゃヒントをくれていた🤪