Compose | Modifier.Zoomable 구현하기
Android 개발을 하다 보면 이미지나 콘텐츠를 확대/축소하거나 드래그하여 이동시키는 기능이 필요한 경우가 있습니다. 이러한 기능을 구현하려면 ZoomState 클래스와 같은 커스텀 상태 클래스를 사용할 수 있습니다. 이 클래스는 Android Jetpack Compose를 사용하여 화면에 구현된 이미지나 콘텐츠를 제어하고 상호작용하는 데 도움을 주는 클래스입니다.
ZoomState 클래스 소개
@Stable
class ZoomState(
@FloatRange(from = 1.0) private val maxScale: Float = 5f,
private var contentSize: Size = Size.Zero,
private val velocityDecay: DecayAnimationSpec<Float> = exponentialDecay(),
) {
init {
require(maxScale >= 1.0f) { "maxScale must be at least 1.0." }
}
private var _scale = Animatable(1f).apply {
updateBounds(0.9f, maxScale)
}
val scale: Float
get() = _scale.value
private var _offsetX = Animatable(0f)
val offsetX: Float
get() = _offsetX.value
private var _offsetY = Animatable(0f)
val offsetY: Float
get() = _offsetY.value
private var layoutSize = Size.Zero
fun setLayoutSize(size: Size) {
layoutSize = size
updateFitContentSize()
}
fun setContentSize(size: Size) {
contentSize = size
updateFitContentSize()
}
private var fitContentSize = Size.Zero
private fun updateFitContentSize() {
if (layoutSize == Size.Zero) {
fitContentSize = Size.Zero
return
}
if (contentSize == Size.Zero) {
fitContentSize = layoutSize
return
}
val contentAspectRatio = contentSize.width / contentSize.height
val layoutAspectRatio = layoutSize.width / layoutSize.height
fitContentSize = if (contentAspectRatio > layoutAspectRatio) {
contentSize * (layoutSize.width / contentSize.width)
} else {
contentSize * (layoutSize.height / contentSize.height)
}
}
suspend fun reset() = coroutineScope {
launch { _scale.snapTo(1f) }
_offsetX.updateBounds(0f, 0f)
launch { _offsetX.snapTo(0f) }
_offsetY.updateBounds(0f, 0f)
launch { _offsetY.snapTo(0f) }
}
private var shouldConsumeEvent: Boolean? = null
private val velocityTracker = VelocityTracker()
internal fun startGesture() {
shouldConsumeEvent = null
velocityTracker.resetTracking()
}
internal fun canConsumeGesture(pan: Offset, zoom: Float): Boolean {
return shouldConsumeEvent ?: run {
var consume = true
if (zoom == 1f) {
if (scale == 1f) {
consume = false
} else {
val ratio = (abs(pan.x) / abs(pan.y))
if (ratio > 3) { // Horizontal drag
if ((pan.x < 0) && (_offsetX.value == _offsetX.lowerBound)) {
consume = false
}
if ((pan.x > 0) && (_offsetX.value == _offsetX.upperBound)) {
consume = false
}
} else if (ratio < 0.33) { // Vertical drag
if ((pan.y < 0) && (_offsetY.value == _offsetY.lowerBound)) {
consume = false
}
if ((pan.y > 0) && (_offsetY.value == _offsetY.upperBound)) {
consume = false
}
}
}
}
shouldConsumeEvent = consume
consume
}
}
internal suspend fun applyGesture(
pan: Offset,
zoom: Float,
position: Offset,
timeMillis: Long
) = coroutineScope {
val newScale = (scale * zoom).coerceIn(0.9f, maxScale)
val newOffset = calculateNewOffset(newScale, position, pan)
val newBounds = calculateNewBounds(newScale)
_offsetX.updateBounds(newBounds.left, newBounds.right)
launch {
_offsetX.snapTo(newOffset.x)
}
_offsetY.updateBounds(newBounds.top, newBounds.bottom)
launch {
_offsetY.snapTo(newOffset.y)
}
launch {
_scale.snapTo(newScale)
}
if (zoom == 1f) {
velocityTracker.addPosition(timeMillis, position)
} else {
velocityTracker.resetTracking()
}
}
suspend fun changeScale(
targetScale: Float,
position: Offset,
animationSpec: AnimationSpec<Float> = spring(),
) = coroutineScope {
val newScale = targetScale.coerceIn(1f, maxScale)
val newOffset = calculateNewOffset(newScale, position, Offset.Zero)
val newBounds = calculateNewBounds(newScale)
val x = newOffset.x.coerceIn(newBounds.left, newBounds.right)
launch {
_offsetX.updateBounds(null, null)
_offsetX.animateTo(x, animationSpec)
_offsetX.updateBounds(newBounds.left, newBounds.right)
}
val y = newOffset.y.coerceIn(newBounds.top, newBounds.bottom)
launch {
_offsetY.updateBounds(null, null)
_offsetY.animateTo(y, animationSpec)
_offsetY.updateBounds(newBounds.top, newBounds.bottom)
}
launch {
_scale.animateTo(newScale, animationSpec)
}
}
private fun calculateNewOffset(
newScale: Float,
position: Offset,
pan: Offset,
): Offset {
val size = fitContentSize * scale
val newSize = fitContentSize * newScale
val deltaWidth = newSize.width - size.width
val deltaHeight = newSize.height - size.height
val xInContent = position.x - offsetX + (size.width - layoutSize.width) * 0.5f
val yInContent = position.y - offsetY + (size.height - layoutSize.height) * 0.5f
val deltaX = (deltaWidth * 0.5f) - (deltaWidth * xInContent / size.width)
val deltaY = (deltaHeight * 0.5f) - (deltaHeight * yInContent / size.height)
val x = offsetX + pan.x + deltaX
val y = offsetY + pan.y + deltaY
return Offset(x, y)
}
private fun calculateNewBounds(
newScale: Float,
): Rect {
val newSize = fitContentSize * newScale
val boundX = java.lang.Float.max((newSize.width - layoutSize.width), 0f) * 0.5f
val boundY = java.lang.Float.max((newSize.height - layoutSize.height), 0f) * 0.5f
return Rect(-boundX, -boundY, boundX, boundY)
}
internal suspend fun endGesture() = coroutineScope {
val velocity = velocityTracker.calculateVelocity()
if (velocity.x != 0f) {
launch {
_offsetX.animateDecay(velocity.x, velocityDecay)
}
}
if (velocity.y != 0f) {
launch {
_offsetY.animateDecay(velocity.y, velocityDecay)
}
}
if (_scale.value < 1f) {
launch {
_scale.animateTo(1f)
}
}
}
suspend fun centerByContentCoordinate(
offset: Offset,
scale: Float = 3f,
animationSpec: AnimationSpec<Float> = tween(700),
) = coroutineScope {
val fitContentSizeFactor = fitContentSize.width / contentSize.width
val boundX = java.lang.Float.max((fitContentSize.width * scale - layoutSize.width), 0f) / 2f
val boundY = java.lang.Float.max((fitContentSize.height * scale - layoutSize.height), 0f) / 2f
suspend fun executeZoomWithAnimation() {
listOf(
async {
val fixedTargetOffsetX =
((fitContentSize.width / 2 - offset.x * fitContentSizeFactor) * scale)
.coerceIn(
minimumValue = -boundX,
maximumValue = boundX,
)
_offsetX.animateTo(fixedTargetOffsetX, animationSpec)
},
async {
val fixedTargetOffsetY = ((fitContentSize.height / 2 - offset.y * fitContentSizeFactor) * scale)
.coerceIn(minimumValue = -boundY, maximumValue = boundY)
_offsetY.animateTo(fixedTargetOffsetY, animationSpec)
},
async {
_scale.animateTo(scale, animationSpec)
},
).awaitAll()
}
if (scale > _scale.value) {
_offsetX.updateBounds(-boundX, boundX)
_offsetY.updateBounds(-boundY, boundY)
executeZoomWithAnimation()
} else {
executeZoomWithAnimation()
_offsetX.updateBounds(-boundX, boundX)
_offsetY.updateBounds(-boundY, boundY)
}
}
suspend fun centerByLayoutCoordinate(
offset: Offset,
scale: Float = 3f,
animationSpec: AnimationSpec<Float> = tween(700),
) = coroutineScope {
val boundX = java.lang.Float.max((fitContentSize.width * scale - layoutSize.width), 0f) / 2f
val boundY = java.lang.Float.max((fitContentSize.height * scale - layoutSize.height), 0f) / 2f
suspend fun executeZoomWithAnimation() {
listOf(
async {
val fixedTargetOffsetX =
((layoutSize.width / 2 - offset.x) * scale)
.coerceIn(
minimumValue = -boundX,
maximumValue = boundX,
)
_offsetX.animateTo(fixedTargetOffsetX, animationSpec)
},
async {
val fixedTargetOffsetY = ((layoutSize.height / 2 - offset.y) * scale)
.coerceIn(minimumValue = -boundY, maximumValue = boundY)
_offsetY.animateTo(fixedTargetOffsetY, animationSpec)
},
async {
_scale.animateTo(scale, animationSpec)
},
).awaitAll()
}
if (scale > _scale.value) {
_offsetX.updateBounds(-boundX, boundX)
_offsetY.updateBounds(-boundY, boundY)
executeZoomWithAnimation()
} else {
executeZoomWithAnimation()
_offsetX.updateBounds(-boundX, boundX)
_offsetY.updateBounds(-boundY, boundY)
}
}
}
@Composable
fun rememberZoomState(
@FloatRange(from = 1.0) maxScale: Float = 5f,
contentSize: Size = Size.Zero,
velocityDecay: DecayAnimationSpec<Float> = exponentialDecay(),
) = remember {
ZoomState(maxScale, contentSize, velocityDecay)
}
ZoomState 클래스는 확대/축소 및 드래그 기능을 제어하기 위한 주요 로직을 포함하고 있습니다. 여기서 간단하게 클래스의 주요 특징을 설명하겠습니다.
- maxScale: 최대 확대 비율을 나타내는 속성입니다. 예를 들어, 2.0으로 설정하면 화면을 2배로 확대할 수 있습니다.
- contentSize: 제어하려는 콘텐츠의 크기를 나타내는 속성입니다.
- velocityDecay: 애니메이션에 사용되는 속성으로, 빠른 드래그 후 콘텐츠가 서서히 감속되도록 도와줍니다.
ZoomState 클래스는 또한 scale, offsetX, offsetY와 같은 현재 확대 비율 및 위치 정보를 제공하며, 사용자 제스처 및 애니메이션을 통해 이러한 값을 변경할 수 있도록 합니다.
ZoomState 클래스의 주요 메서드
ZoomState 클래스에는 확대/축소 및 드래그 제스처를 처리하고 애니메이션을 적용하는 여러 메서드가 있습니다. 이 메서드들을 통해 콘텐츠를 사용자 친화적으로 제어할 수 있습니다. 주요 메서드는 다음과 같습니다.
- reset(): 확대/축소와 드래그를 초기 상태로 리셋합니다.
- startGesture(): 사용자 제스처가 시작될 때 호출되어, 제스처 추적을 초기화합니다.
- canConsumeGesture(pan: Offset, zoom: Float): Boolean: 현재 제스처 이벤트를 처리할 수 있는지 확인하고 처리 가능 여부를 반환합니다.
- applyGesture(pan: Offset, zoom: Float, position: Offset, timeMillis: Long): 제스처를 적용하여 확대/축소 및 드래그를 처리하고 애니메이션을 적용합니다.
- changeScale(targetScale: Float, position: Offset, animationSpec: AnimationSpec<Float>): 특정 확대 비율로 변경하고 애니메이션을 적용합니다.
- centerByContentCoordinate(offset: Offset, scale: Float, animationSpec: AnimationSpec<Float>): 특정 좌표를 기준으로 화면 중앙에 콘텐츠를 위치시키는 메서드입니다.
- centerByLayoutCoordinate(offset: Offset, scale: Float, animationSpec: AnimationSpec<Float>): 특정 좌표를 기준으로 화면 중앙에 레이아웃을 위치시키는 메서드입니다.
- endGesture(): 제스처가 종료될 때 호출되어 드래그의 감속 애니메이션을 적용합니다.
rememberZoomState 함수
rememberZoomState 함수는 Composable 함수 내에서 ZoomState 인스턴스를 생성하고 기억합니다. 이 함수는 화면 구성 요소 내에서 ZoomState를 사용할 때 ZoomState를 다시 생성하지 않고 이전에 생성된 ZoomState를 반환합니다. 이렇게 하면 ZoomState의 상태가 유지되어 사용자의 상호작용에 따라 지속적으로 업데이트됩니다.
Modifier.Zoomable
private suspend fun PointerInputScope.detectTransformGestures(
onGesture: (centroid: Offset, pan: Offset, zoom: Float, timeMillis: Long) -> Boolean,
onGestureStart: () -> Unit = {},
onGestureEnd: () -> Unit = {},
onTap: () -> Unit = {},
onDoubleTap: (position: Offset) -> Unit = {},
enableOneFingerZoom: Boolean = true,
) = awaitEachGesture {
val firstDown = awaitFirstDown(requireUnconsumed = false)
onGestureStart()
var firstUp: PointerInputChange = firstDown
var isTap = true
val touchSlop = TouchSlop(viewConfiguration.touchSlop)
forEachPointerEventUntilReleased { event ->
if (touchSlop.isPast(event)) {
val zoomChange = event.calculateZoom()
val panChange = event.calculatePan()
if (zoomChange != 1f || panChange != Offset.Zero) {
val centroid = event.calculateCentroid(useCurrent = false)
val timeMillis = event.changes[0].uptimeMillis
val canConsume = onGesture(centroid, panChange, zoomChange, timeMillis)
if (canConsume) {
event.consumePositionChanges()
}
}
isTap = false
}
if (event.changes.size > 1) {
isTap = false
}
firstUp = event.changes[0]
}
if (firstUp.uptimeMillis - firstDown.uptimeMillis > viewConfiguration.longPressTimeoutMillis) {
isTap = false
}
// Vertical scrolling following a double tap is treated as a zoom gesture.
if (isTap) {
val secondDown = awaitSecondDown(firstUp)
if (secondDown == null) {
onTap()
} else {
var isDoubleTap = true
var secondUp: PointerInputChange = secondDown
val secondTouchSlop = TouchSlop(viewConfiguration.touchSlop)
forEachPointerEventUntilReleased { event ->
if (secondTouchSlop.isPast(event)) {
if (enableOneFingerZoom) {
val panChange = event.calculatePan()
val zoomChange = 1f + panChange.y * 0.004f
if (zoomChange != 1f) {
val centroid = event.calculateCentroid(useCurrent = false)
val timeMillis = event.changes[0].uptimeMillis
val canConsume = onGesture(centroid, Offset.Zero, zoomChange, timeMillis)
if (canConsume) {
event.consumePositionChanges()
}
}
}
isDoubleTap = false
}
if (event.changes.size > 1) {
isDoubleTap = false
}
secondUp = event.changes[0]
}
if (secondUp.uptimeMillis - secondDown.uptimeMillis > viewConfiguration.longPressTimeoutMillis) {
isDoubleTap = false
}
if (isDoubleTap) {
onDoubleTap(secondUp.position)
}
}
}
onGestureEnd()
}
private suspend fun AwaitPointerEventScope.forEachPointerEventUntilReleased(
action: (PointerEvent) -> Unit,
) {
do {
val event = awaitPointerEvent()
if (event.changes.fastAny { it.isConsumed }) {
break
}
action(event)
} while (event.changes.fastAny { it.pressed })
}
private suspend fun AwaitPointerEventScope.awaitSecondDown(
firstUp: PointerInputChange
): PointerInputChange? = withTimeoutOrNull(viewConfiguration.doubleTapTimeoutMillis) {
val minUptime = firstUp.uptimeMillis + viewConfiguration.doubleTapMinTimeMillis
var change: PointerInputChange
// The second tap doesn't count if it happens before DoubleTapMinTime of the first tap
do {
change = awaitFirstDown()
} while (change.uptimeMillis < minUptime)
change
}
private fun PointerEvent.consumePositionChanges() {
changes.fastForEach {
if (it.positionChanged()) {
it.consume()
}
}
}
private class TouchSlop(private val threshold: Float) {
private var zoom = 1f
private var pan = Offset.Zero
private var _isPast = false
fun isPast(event: PointerEvent): Boolean {
if (_isPast) {
return true
}
zoom *= event.calculateZoom()
pan += event.calculatePan()
val zoomMotion = abs(1 - zoom) * event.calculateCentroidSize(useCurrent = false)
val panMotion = pan.getDistance()
_isPast = zoomMotion > threshold || panMotion > threshold
return _isPast
}
}
fun Modifier.zoomable(
zoomState: ZoomState,
enableOneFingerZoom: Boolean = true,
onTap: () -> Unit = {},
onDoubleTap: suspend (position: Offset) -> Unit = { position -> zoomState.toggleScale(2.5f, position) },
): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "zoomable"
properties["zoomState"] = zoomState
}
) {
val scope = rememberCoroutineScope()
Modifier
.onSizeChanged { size ->
zoomState.setLayoutSize(size.toSize())
}
.pointerInput(zoomState) {
detectTransformGestures(
onGestureStart = { zoomState.startGesture() },
onGesture = { centroid, pan, zoom, timeMillis ->
val canConsume = zoomState.canConsumeGesture(pan = pan, zoom = zoom)
if (canConsume) {
scope.launch {
zoomState.applyGesture(
pan = pan,
zoom = zoom,
position = centroid,
timeMillis = timeMillis,
)
}
}
canConsume
},
onGestureEnd = {
scope.launch {
zoomState.endGesture()
}
},
onTap = onTap,
onDoubleTap = { position ->
scope.launch {
onDoubleTap(position)
}
},
enableOneFingerZoom = enableOneFingerZoom,
)
}
.graphicsLayer {
scaleX = zoomState.scale
scaleY = zoomState.scale
translationX = zoomState.offsetX
translationY = zoomState.offsetY
}
}
suspend fun ZoomState.toggleScale(
targetScale: Float,
position: Offset,
animationSpec: AnimationSpec<Float> = spring(),
) {
val newScale = if (scale == 1f) targetScale else 1f
changeScale(newScale, position, animationSpec)
}
Zoomable Modifier는 사용자의 제스처 이벤트를 감지하고, 이를 ZoomState 객체에 전달하여 확대/축소 및 드래그를 처리합니다. 여기서 주요 메서드는 다음과 같습니다.
- detectTransformGestures: Zoomable Modifier에서 주로 사용되는 메서드로, 제스처 감지와 처리를 수행합니다. 이 메서드는 단일 탭, 더블 탭, 확대/축소, 드래그 등을 처리합니다.
- awaitSecondDown: 더블 탭 이벤트를 감지하는 메서드로, 첫 번째 탭 이후 두 번째 탭까지의 시간을 체크하여 더블 탭 이벤트를 확인합니다.
- consumePositionChanges: PointerEvent의 위치 변경 이벤트를 소비하는 메서드로, 확대/축소 및 드래그 제스처의 위치 변경 이벤트를 소비합니다.