들어가며
스타카토는 우테코에서 시작해 지금까지도 진행하고 있는 프로젝트입니다. 최근 스타카토는 Compose로 기능 개발 및 마이그레이션을 진행하고 있는데요. 이번에 구현한 기능 중 “원본 이미지 조회 화면”의 구현을 빙티와 함께 담당했습니다.
이번 글에서는 줌, 드래그 등의 동작으로 이미지 확대가 가능한 Pinch-to-zoom 기능을 Compose로 구현하는 방법을 알아보고, 개발 중 발생한 이슈를 해결한 과정을 담았습니다.
Pinch Zoom 구현하기
핀치 줌이란?
Pinch-to-zoom이란 두 손가락으로 대상을 확대하고 축소하는 동작을 가리키며, Pinch Zoom(핀치 줌)으로 줄여 부르기도 합니다. 본 글에서는 핀치 줌이라고 부르겠습니다.
핀치 줌은 다양한 앱에서 지원해주는 매우 익숙한 기능이며 어렵지 않게 만날 수 있어요!
아래는 Slack에서 핀치 줌으로 이미지를 조회하는 모습입니다. 참고로 카카오톡과 Slack은 더블 탭으로 확대 및 축소하는 기능도 포함되어 있습니다.
카카오톡과 Slack의 화면을 참고해 나름 비슷하게 동작하는 핀치 줌 화면을 구현했습니다. (확대/축소, 드래그, 더블 탭)
자세한 구현 과정은 이후에 자세히 다뤄보고, 우선 구현에 참고할 수 있는 제스처 관련 API를 살펴보겠습니다.
멀티 터치를 감지하는 API
핀치 줌을 구현하기 위해서는 두 손가락의 터치를 감지해야 합니다. Compose에서는 멀티 터치 제스처를 비교적 편리하게 감지하고 제어할 수 있는 여러 Modifier API를 제공합니다.
- transformable : 여러 손가락을 이용한 멀티 터치 제스처(줌, View 이동(팬), 회전)를 감지하여 원하는 동작을 정의할 수 있습니다. 고수준 API이며, 가장 쉽게 구현할 수 있습니다.
- detectTransformGestures : transformable과 동작이 비슷하지만, 다양한 제스처와 함께 제어할 수 있는 고수준 API입니다. transformable보다 세세한 제어가 필요한 경우 사용할 수 있습니다.
또는 각각의 터치 입력을 감지할 수 있는 API를 활용할 수도 있습니다.
- awaitEachGesture : 개발자가 직접 각 터치 이벤트를 감지하고 원하는 동작을 정의할 수 있는 저수준 API입니다. 터치 이벤트를 직접 제어해야 하므로 구현이 복잡할 수 있으나, 원하는 제스처를 세부적으로 제어 가능합니다.
위 API들의 동작 방식과 사용 방법에 대해 가볍게 살펴보겠습니다.
해당 글에서는 위 API들의 동작과 사용 방법을 간략하게 설명합니다. 더 자세한 정보와 다양한 API는 공식 문서 - 동작 이해하기를 참고해주시기 바랍니다.
동작 이해하기 | Jetpack Compose | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. 동작 이해하기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 이해해야 할 몇 가지 용어와 개념이 있
developer.android.com
transformable + rememberTransformableState
transformable은 확대/축소(Zoom), 이동(Pan), 회전(Rotation) 제스처를 한 번에 감지할 수 있는 고수준 API입니다.
// transformable 정의
@OptIn(markerClass = {androidx. compose. foundation. ExperimentalFoundationApi::class})
public fun Modifier.transformable(
state: TransformableState,
lockRotationOnZoomPan: Boolean = false,
enabled: Boolean = true,
): Modifier
rememberTransformableState 와 함께 사용합니다.
// rememberTransformableState 정의
@Composable
public fun rememberTransformableState(
onTransformation: (zoomChange: Float, panChange: Offset, rotationChange: Float) -> Unit
): TransformableState
내부적으로 detectTransformGestures 와 유사하게 동작하지만, 한 단계 더 추상화되어 있어 간편하게 사용할 수 있습니다.
장점
- 상태 변경을 Modifier 외부에서 제어할 수 있습니다.
- rememberTransformableState 를 사용해 Modifier의 외부에서 제스처 변화 상태를 기억하고 받아들일 수 있습니다.
- 이로써 코드를 간결하고 깔끔하게 관리할 수 있습니다.
- 스케일, 회전, 이동 이벤트를 람다 파라미터로 전달 받아 편리하게 처리 가능합니다.
- rememberTransformableState 의 onTransformation 람다로부터 제스처의 상태 변화를 받아들입니다.
- 제스처 감지 및 상태 변화 연산은 Compose가 내부적으로 처리해 전달합니다. 따라서 개발자가 이벤트 변화를 직접 감지하고 처리할 필요 없이 원하는 동작을 간단히 구현할 수 있습니다.
- 스크롤 화면과 안정적으로 호환됩니다.
- scrollable , nestedScroll Modifier API와 함께 사용할 수 있습니다.
단점
- 세부적인 제어가 어렵고 호환성이 떨어집니다.
- 이벤트 소비 시점, 조건부 처리 등의 커스터마이징이 어렵습니다.
- 내부적으로 이벤트를 모두 소비하기 때문에, 탭, 드래그 등 다른 제스처와 병행하기 힘듭니다.
아래는 간단한 사용 예시입니다.
사용 예시
// 확대 비율을 기억
var scale by remember { mutableStateOf(1f) }
// 멀티 터치 제스처의 상태를 Modifier의 외부로 분리하여 감지할 수 있습니다.
val transformableState = rememberTransformableState { zoomChange, panChange, rotationChange ->
/* onTransformation 람다 내부입니다. 해당 람다는 기본적으로 TransformScope입니다.
* zoomChange(Float): 줌 비율
* panChange(Offset): 이동 거리
* rotationChange(Float): 회전 각도
*/
scale *= zoomChange
}
Box(
modifier = Modifier
// graphicsLayer로 변화된 줌 비율, 이동 거리, 회전 각도를 적용합니다.
.graphicsLayer(
scaleX = scale,
scaleY = scale,
)
// rememberTransformableState로 제공 받은 TransformableState를 넘겨줍니다.
.transformable(transformableState)
.fillMaxSize()
) { /* ... */ }
더 유연한 커스텀과 세부적인 처리를 위해서는 detectTransformGestures와 awaitEachGesture 를 사용할 수 있습니다. detectTransformGestures와 awaitEachGesture는 Modifier의 pointerInput API를 호출하여 사용합니다.
Modifier.pointerInput()
pointerInput은 사용자의 터치 및 제스처를 인식하고 처리할 동작을 정의하는 Modifier입니다.
- pointerInput 블록 안에는 suspending gesture detection 함수들이 들어갈 수 있습니다.
- 이 블록의 컨텍스트는 PointerInputScope로, 사용자의 터치 이벤트 정보를 제공합니다.
- 내부에서 사용하는 detectTransformGestures, detectTapGestures 등은 모두 suspend 함수이고, 코루틴으로 작동합니다.
// pointerInput 정의
fun Modifier.pointerInput(
vararg keys: Any?,
block: suspend PointerInputScope.() -> Unit
): Modifier = this then SuspendPointerInputElement(
keys = keys,
pointerInputHandler = block
)
PointerInputScope
PointerInputScope는 pointerInput 블록 내부의 컨텍스트로, 터치 이벤트와 관련된 정보와 기능들을 제공합니다.
// PointerInputScope 정의 (자주 사용하는 멤버만 추렸습니다.)
interface PointerInputScope : Density {
/**
* 해당 영역의 크기 (터치 좌표 기준 범위)
*/
val size: IntSize
/**
* 터치 감지를 위한 설정 (예: 더블탭 시간, 슬라이드 거리 등)
*/
val viewConfiguration: ViewConfiguration
/**
* 포인터 이벤트를 처리하는 suspend 가능 블록
* 내부에서 awaitPointerEvent(), awaitFirstDown() 등 사용 가능
*/
suspend fun <R> awaitPointerEventScope(
block: suspend AwaitPointerEventScope.() -> R
): R
}
- size : 해당 영역의 크기(터치한 좌표를 기준으로 한 범위)를 나타내는 Offset 프로퍼티입니다.
- viewConfiguration : 터치 이벤트 감지와 관련된 설정 값을 가지고 있습니다.
- 더블탭 감지 시간, 드래그 감지 거리 등
- awaitPointerEventScope : 이벤트 감지, 좌표 변환 등 다양한 유틸 메서드를 사용할 수 있습니다.
- awaitFirstDown() : 사용자의 첫 번째 터치 입력 이벤트를 기다립니다. suspend로 구현되어있기 때문에 이벤트가 입력될 때까지 중단 가능합니다.
- awaitPointerEvent() : 사용자 모든 터치 입력 이벤트를 전달받습니다.
detectTransformGestures
detectTransformGestures는 transformable과 마찬가지로 멀티터치 제스처(확대/축소, 이동, 회전)를 감지하는 중간 수준의 API입니다.
onGesture 콜백에서 중심점 위치, 확대/축소, 이동 거리, 회전 변화 정도을 직접 전달받아 처리합니다.
// detectTransformGestures 정의
suspend fun PointerInputScope.detectTransformGestures(
panZoomLock: Boolean = false,
onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit,
)
- centroid : 터치 포인트들의 중심점
- pan : 사용자가 손가락으로 움직인 방향과 거리
- zoom : 확대/축소 비율 (1f이면 변함 없음, 2f는 2배 확대, 0.5f는 절반 축소)
- rotation : 회전된 각도 (라디안 값)
장점
- transformable 보다 더 자세한 구현과 커스터마이징이 가능합니다.
- pointerInput 내부에서 사용되기에, pointerInput을 사용하는 다른 제스처와의 호환성을 가져올 수 있습니다.
단점
- 코드가 복잡해질 수 있습니다.
- Modifier 내부에서 제스처 이벤트 변화를 수신받고 처리하기 때문에, 상대적으로 코드가 길어지고 가독성이 떨어집니다.
- 더 세부적인 커스터마이징에 한계가 있습니다.
- detectTransformGestures 또한 내부적으로 제스처 이벤트를 모두 소비합니다.
- 때문에 탭, 드래그 등의 제스처와 충돌하거나 상위 컴포저블의 제스처와 충돌할 수 있습니다.
사용 예시
var scale by remember { mutableStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = Modifier
.pointerInput(Unit) {
// pointerInput 내부에서(PointerInputScope) detectTransformGestures 사용
detectTransformGestures { _, pan, zoom, rotation ->
// 확대/축소 비율 업데이트
scale *= zoom
// 화면 이동
offset += pan
}
}
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offset.x,
translationY = offset.y,
)
) { /* ... */ }
awaitEachGesture
awaitEachGesture는 터치 이벤트 흐름을 세밀하게 제어 가능한 저수준 API입니다.
awaitPointerEvent() 또는 awaitFirstDown() 등을 조합해서 제스처 흐름을 구성합니다.
장점
- 아주 세부적인 제스처 제어가 가능합니다.
- 발생한 터치 이벤트의 개수(손가락의 개수), 각 이벤트가 이동한 거리 등을 세밀하게 추적할 수 있습니다.
- 조건에 따라 분기하여 이벤트를 수신하거나 소비하는 등 터치 이벤트의 복합적인 충돌 제어가 필요한 경우 사용합니다.
단점
- 구현이 어렵고 코드가 복잡해집니다.
- awaitPointerEvent() , awaitFirstDown() 등으로 각 터치 이벤트를 직접 수신해야 합니다.
- 개발자가 모든 터치 이벤트를 수동으로 추적하고 판단해야 하며, 잘못 구현할 경우 원하는 동작을 수행하지 못할 위험이 존재합니다.
- 각 터치 이벤트를 직접 제어하는 코드로 복잡해지기 쉽습니다.
사용 예시
var scale by remember { mutableStateOf(1f) }
Box(
modifier = Modifier
.pointerInput(Unit) {
awaitEachGesture {
// awaitFirstDown으로 터치 이벤트를 수동으로 감지합니다.
// awaitFirstDown을 사용하지 않으면 터치 이벤트를 수신할 수 없습니다.
val down = awaitFirstDown()
do {
val event = awaitPointerEvent()
if (event.changes.size > 1) {
val zoomChange = event.calculateZoom()
scale *= zoomChange
}
} while (event.changes.any { it.pressed })
}
}
.graphicsLayer(
scaleX = scale,
scaleY = scale,
)
)
핀치 줌 구현하기
이제 핀치 줌을 직접 구현해보겠습니다. 저는 줌 제스처와 더블탭 제스처(두번 빠르게 터치하여 확대 및 축소)도 함께 구현했습니다.
우선 가장 간단한 구현 방식인 transformable을 활용한 코드를 살펴보겠습니다.
transformable 사용
간단하게 최소/최대 확대 비율, 기본 확대 비율과 더블탭 시 확대 비율을 인자로 받고, 더블탭 동작을 제어하기 위해 detectTapGestures를 이용해 구현했습니다.
@Composable
fun PinchToZoom(
modifier: Modifier = Modifier,
defaultScale: Float = 1f,
minScale: Float,
maxScale: Float,
doubleTapScale: Float,
content: @Composable () -> Unit,
) {
// 확대 및 축소 비율, 원래 크기는 1f(100%)
var scale by remember { mutableFloatStateOf(defaultScale) }
// content 이동 위치
var offset by remember { mutableStateOf(Offset.Zero) }
// Transformable 상태 감지
val transformableState = rememberTransformableState { zoomChange, offsetChange, _ ->
// 확대 비율과 이동 위치 업데이트
scale = (scale * zoomChange).coerceIn(minScale, maxScale)
offset += offsetChange
}
Box(
modifier = modifier
.fillMaxSize()
// 더블탭 제스처 감지
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
if (scale != defaultScale) {
// 이미 확대/축소된 상태면 원래 크기로 리셋
scale = defaultScale
offset = Offset.Zero
} else {
// 원래 크기라면 확대
scale = doubleTapScale
}
}
)
}
// 핀치 줌 제스처 처리
.transformable(transformableState)
// 확대 비율 및 이동 위치 적용
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offset.x,
translationY = offset.y
)
) {
content()
}
}
*offset 보정은 구현되지 않은 코드입니다.
detectTransformGestures 사용
다음으로는 transformable보다 조금 더 저수준인 detectTransformGestures를 활용한 코드입니다.
@Composable
fun PinchToZoom(
modifier: Modifier = Modifier,
defaultScale: Float = 1f,
minScale: Float,
maxScale: Float,
doubleTapScale: Float,
content: @Composable () -> Unit,
) {
var scale by remember { mutableFloatStateOf(defaultScale) }
var offset by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = modifier
.fillMaxSize()
// 더블탭 제스처
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
if (scale != defaultScale) {
scale = defaultScale
offset = Offset.Zero
} else {
scale = doubleTapScale
}
}
)
}
// 핀치 줌 제스처 (detectTransformGestures 사용)
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
val newScale = (scale * zoom).coerceIn(minScale, maxScale)
scale = newScale
offset += pan
}
}
// 확대/이동 적용
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offset.x,
translationY = offset.y
)
) {
content()
}
}
transformable을 사용한 코드와 동일한 동작을 하지만, 제스처에 따라 scale, offset을 변경하는 로직을 Modifier에서 처리하고 있습니다. 때문에 transformable을 사용한 코드보다 조금 더 복잡해보입니다.
아래는 위 코드로 구현된 핀치 줌의 시연 영상입니다.
발생한 이슈와 해결 방법
핀치 줌을 구현하면서 크게 두 가지 이슈를 마주했습니다.
- 이미지 범위를 벗어남
- Pager 스와이프가 동작하지 않음
화면이 이미지 범위를 벗어남
현재 구현은 사용자의 화면이 이미지의 바깥 영역을 벗어날 수 있습니다. 확대를 하거나, 확대 상태에서 드래그를 하면 이미지의 바깥 부분까지도 화면이 이동할 수 있어 사용자의 입장에서 불편할 수 있습니다.
해결 방법: Clamp Offset
불필요한 영역까지 화면이 이동하는걸 방지하려면 offset의 범위를 제한해야 합니다. 이를 Clamp Offset이라고 부릅니다.
Clamp란?
Clamp는 물건이 움직이지 못하도록 조아서 고정시키는 도구를 가리킵니다. 컴퓨터 용어에서는 특정 값이 범위를 벗어나지 못하도록 조정 및 제한하는 기능 또는 메서드를 의미합니다.
clamp offset을 계산하는 clampOffset 메서드를 추가합니다.
fun clampOffset(offset: Offset, scale: Float, size: IntSize): Offset {
// 확대된 영역 중, 화면 밖으로 벗어나는 절반의 크기를 계산
// 화면 너비 대비 확대된 추가 너비의 절반이 최대 이동 거리
val maxX = (size.width * (scale - 1)) / 2
val maxY = (size.height * (scale - 1)) / 2
// coerceIn 메서드로 offset 값의 범위를 제한하여, 이미지가 화면 밖으로 나가지 않도록 함
return Offset(
x = offset.x.coerceIn(-maxX, maxX),
y = offset.y.coerceIn(-maxY, maxY),
)
}
그리고 컴포지션 이후의 Box 크기를 바탕으로 offset을 계산할 수 있도록, Box의 크기를 계산하고 저장합니다.
var containerSize by remember { mutableStateOf(IntSize.Zero) }
// ...
Box(
modifier =
modifier
.fillMaxSize()
// onSizeChanged로 Box의 크기 변경을 감지해 containerSize를 변경
.onSizeChanged { containerSize = it }
그리고 rememberTransformableState에서 새로운 scale, offset 값을 계산하는 부분에도 clampOffset을 적용합니다.
val transformableState = rememberTransformableState { zoomChange, offsetChange, _ ->
val newScale = (scale * zoomChange).coerceIn(minScale, maxScale)
val newOffset = offset + offsetChange
scale = newScale
offset = clampOffset(newOffset, newScale, containerSize)
}
더블 탭 동작에서도 마찬가지로 clampOffset을 적용합니다.
.pointerInput(scale) {
detectTapGestures(
onDoubleTap = { tapOffset ->
if (scale != defaultScale) {
scale = defaultScale
offset = Offset.Zero
} else {
scale = doubleTapScale
offset = clampOffset(Offset.Zero, doubleTapScale, containerSize)
}
}
)
}
디테일을 조금 더 추가해볼까요?
지금의 구현은 화면의 중심점을 기준으로 더블 탭 확대가 일어납니다. 만약 더블 탭을 한 위치를 중심으로 확대를 하고 싶다면, 아래와 같이 코드를 수정할 수 있습니다.
if (scale != defaultScale) {
scale = defaultScale
offset = Offset.Zero
} else {
scale = doubleTapScale
// 화면의 중심점 계산
val center = Offset(containerSize.width / 2f, containerSize.height / 2f)
// 더블 탭한 위치까지의 거리와 확대 비율을 보정하여 화면 이동 거리 계산
val pan = (center - tapOffset) * doubleTapScale
offset = clampOffset(pan, doubleTapScale, containerSize)
}
이렇게 하면 offset의 값을 보정하여, 사용자가 바라보는 화면이 이미지 범위 밖으로 벗어나지 않습니다.
Pager의 스와이프가 동작하지 않음
이번에 핀치 줌을 구현하면서 제일 많은 시간을 투자한 이슈입니다.
스타카토의 핀치 줌은 이미지 원본을 조회하는 HorizontalPager 안에서 사용됩니다.
하지만 현재까지 구현한 코드들은 Pager의 스와이프 동작이 이루어지지 않습니다.
처음에는 transformable + rememberTransformableState와 스크롤 동작을 제어하는 nestedScroll을 함께 활용하면 되지 않을까 생각했지만, 해당 구현으로도 해결되지 않았습니다.
대체 왜 그러는 걸까요?
이는 터치 이벤트가 소비되는 과정과 연관이 있습니다.
터치 이벤트 처리 흐름
안드로이드의 화면은 여러 뷰가 트리 형태로 이루어진 구조입니다. 이를 UI 트리라고 하며, 최상위인 Root 부터(화면 상에서 가장 아래에 위치) 최하위인 Leaf 까지(화면 상에서 제일 위에 위치) 구성되어있습니다. 터치 이벤트도 이 UI 트리를 통해 전달됩니다.
이벤트가 UI 트리로 전달될 때, 만약 특정 뷰에서 이벤트를 소비하면 하위 또는 상위의 다른 뷰에서는 이 이벤트를 사용할 수 없습니다. 즉, 다른 뷰에서는 이벤트로 인한 동작이 이루어지지 않습니다. 이러한 흐름으로 안드로이드 시스템은 UI의 이벤트 충돌을 제어합니다.
이벤트는 아래 세 가지 과정을 통해 뷰에게 전달됩니다.
- Initial Pass : 이벤트가 UI 트리의 상단에서 하단으로 흐릅니다.
- 이 단계에서 경우에 따라 부모는 자식이 이벤트를 소비하기 전에 이벤트를 가로챌 수 있습니다.
- Main Pass : 이벤트가 UI 트리의 하단에서 상단으로 거꾸로 흐릅니다.
- 이 단계에서 일반적으로 제스처를 소비하는 동작이 발생합니다. 즉, Main Pass는 이벤트를 수신하는 기본 패스로 사용됩니다.
- 이 패스에서 제스처를 처리한다는 것은 리프 노드(하위 뷰)가 부모 노드(상위 뷰)보다 우선한다는 것을 의미합니다.
- Final Pass : 이벤트가 UI 트리의 상단에서 하단으로 한 번 더 흐릅니다.
- 이 흐름을 통해, 현재 이벤트가 부모가 처리할 제스처인 경우 자식에게 전달하여 이벤트 소비를 중지시킬 수 있습니다.
- 예를 들어, 버튼에 대한 터치 동작이 부모 스크롤 뷰의 스크롤 제스처로 변경되면, 자식 뷰인 버튼은 리플 애니메이션을 제거합니다.
예를 들어, 아래 도서 아이템 뷰가 있다고 가정합니다.
아이템 우측 상단의 북마크 버튼을 누르면, 북마크 버튼 만이 이벤트를 감지하고 도서 아이템은 이벤트를 받아들이지 않습니다. 이는 북마크 버튼이 이벤트를 소비했기 때문입니다. 그래서 북마크 버튼의 상위에 있는 도서 아이템 뷰와 그 위의 List 뷰(LazyColumn과 같은)는 이벤트를 소비할 수 없습니다.
터치 이벤트 흐름과 소비에 관한 더 자세한 설명은 아래 두 문서를 참고해주세요.
동작 이해하기 | Jetpack Compose | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. 동작 이해하기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 이해해야 할 몇 가지 용어와 개념이 있
developer.android.com
동작 이해하기 | Jetpack Compose | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. 동작 이해하기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 이해해야 할 몇 가지 용어와 개념이 있
developer.android.com
결국 문제 원인은? 이벤트 소비 때문이다!
직접 구현한 PinchToZoom 컴포저블에서 이벤트를 소비했기 때문에, 상위 뷰인 Pager에서 스와이프 동작이 이루어지지 않은 것입니다.
엥, 하지만 저는 이벤트를 소비한 적이 없는 걸요?
네, 여러분은 소비하지 않았지만 Compose가 내부적으로 소비해버렸습니다!
위의 핀치 줌 예제에서 사용한 transformable과 detectTranformGestures의 내부 코드를 확인하면, API에서 받아들인 모든 이벤트를 소비하는 코드가 있음을 알 수 있습니다.
transformable은 내부적으로 TransformableNode라는 클래스를 사용해 이벤트를 감지합니다.
해당 클래스의 내부 코드에는 줌 동작을 감지하는 detectZoom 메서드가 있는데, 해당 메서드에서 이벤트를 소비하는 동작을 합니다.
// TransformableNode 내부 코드
/* ... */
awaitEachGesture {
try {
detectZoom(lockRotationOnZoomPan, channel, updatedCanPan)
} catch (exception: CancellationException) {
if (!isActive) throw exception
} finally {
channel.trySend(TransformStopped)
}
}
// detectZoom 내부 코드
private suspend fun AwaitPointerEventScope.detectZoom(
panZoomLock: Boolean,
channel: Channel<TransformEvent>,
canPan: (Offset) -> Boolean
) {
/* ... */
awaitFirstDown(requireUnconsumed = false)
do {
val event = awaitPointerEvent()
val canceled = event.changes.fastAny { it.isConsumed }
if (!canceled) {
/* ... */
// 이 곳에서 모든 이벤트를 consume() 하고 있습니다.
event.changes.fastForEach {
if (it.positionChanged()) {
it.consume()
}
}
}
}
/* ... */
} while (!canceled && !finallyCanceled && event.changes.fastAny { it.pressed })
}
detectTranformGestures 도 내부적으로 이벤트를 소비하는 동작을 합니다.
// detectTranformGestures 내부 코드
awaitEachGesture {
/* ... */
awaitFirstDown(requireUnconsumed = false)
do {
val event = awaitPointerEvent()
val canceled = event.changes.fastAny { it.isConsumed }
if (!canceled) {
/* ... */
if (pastTouchSlop) {
/* ... */
// 여기서 모든 이벤트를 consume 합니다.
event.changes.fastForEach {
if (it.positionChanged()) {
it.consume()
}
}
}
}
} while (!canceled && event.changes.fastAny { it.pressed })
}
Pager와 PinchToZoom의 이벤트 충돌 문제를 해결하기 위해서는, 특정 조건에서 PinchToZoom이 이벤트를 소비하지 않아야 합니다.
해결 방법: awaitEachGesture
이 문제를 해결하기 위해, 저수준 제스처 제어 API인 awaitEachGesture 를 활용했습니다. 앞서 설명했듯 해당 API는 모든 터치 이벤트를 직접 감지하고 소비해야 하는 복잡함이 있지만, 지금의 이슈와 같이 다른 뷰와 이벤트가 충돌하는 상황에서는 유연하게 대처할 수 있습니다.
필자는 확대 상태에서는 Pager의 좌우 스와이프를 막고, 확대가 되지 않은 상태에서는 Pager의 스와이프가 가능하도록 구현하고자 했습니다. 이를 위해 확대 상태가 아닐 때에는 PinchToZoom이 드래그 이벤트를 소비하지 않도록 하여, 상위의 Pager에 이벤트가 전달되게 했습니다.
- PinchToZoom이 함부로 이벤트를 소비해서 상위 뷰의 동작이 막히면 안됩니다.
- 따라서 PinchToZoom에서의 드래그 동작이 이벤트를 소비할지 여부는 상위 뷰의 허가가 있어야 합니다.
- 그래서 onDrag 콜백을 통해 상위 뷰로부터 드래그 동작 이벤트를 소비할지 여부를 결정받도록 했습니다.
@Composable
fun PinchToZoom(
modifier: Modifier = Modifier,
minScale: Float = DEFAULT_MIN_ZOOM_SCALE,
maxScale: Float = DEFAULT_MAX_ZOOM_SCALE,
// 상위 뷰가 확대 상태를 알 수 있도록 scale을 전달하는 콜백
onScaleChange: ((scale: Float) -> Unit)? = null,
// PinchToZoom의 드래그 동작으로 인한 이벤트 소비를 상위 뷰가 제어할 수 있도록 하는 콜백
onDrag: ((Offset) -> Boolean)? = null,
content: @Composable () -> Unit,
) {
//...
modifier.pointerInput(Unit) {
awaitEachGesture {
var isZoomGesture = false // 두 손가락 줌 동작 여부를 나타내는 Flag
var isDragGesture = false // 한 손가락 드래그 동작 여부를 나타내는 Flag
val touchSlop = viewConfiguration.touchSlop // 드래그 동작을 판단하는 기준 거리
// awaitFirstDown으로 터치를 감지합니다. 이미 소비된 동작도 모두 감지합니다.
awaitFirstDown(requireUnconsumed = false)
do {
// 터치 이벤트를 받아들입니다.
val event = awaitPointerEvent()
if (event.changes.size > 1) {
// 터치 이벤트가 2개 이상인 경우(확대 동작인 경우)
isZoomGesture = true
// 터치 이벤트로부터 줌, 화면 이동 값을 가져옵니다.
val zoomChange = event.calculateZoom()
val panChange = event.calculatePan()
// 확대 비율을 계산합니다.
val newScale = (scale * zoomChange).coerceIn(minScale, maxScale)
scale = newScale
onScaleChange?.invoke(scale)
// 확대 동작에 따라 offset을 보정합니다.
if (newScale > minScale) {
offset += panChange * newScale
offset = clampOffset(offset, newScale, containerSize)
} else {
offset = Offset.Zero
}
// 확대 동작인 경우에는 모든 이벤트를 소비시킵니다.
event.changes.fastForEach { it.consume() }
} else if (!isZoomGesture) {
// 터치 이벤트가 1개 이하, 확대 동작이 아닌 경우(드래그 동작인 경우)
val dragChange = event.changes.first() // 이벤트 변화를 가져옵니다.
val dragAmount = dragChange.positionChange() // 이벤트의 이동 거리를 가져옵니다.
// 확대 상태인 경우에만 이미지 드래그 동작이 이루어집니다.
if (scale > minScale) {
// 현재의 터치 이벤트가 드래그 동작인지 확인합니다.
// 이벤트의 이동 거리가 touchSlop보다 크다면 드래그 동작으로 판단
if (!isDragGesture && dragAmount.getDistance() > touchSlop) {
isDragGesture = true
}
// 드래그 동작이라면 offset을 조정하고 이벤트 소비 여부를 확인합니다.
if (isDragGesture) {
offset += dragAmount * scale * SLOW_MOVEMENT_COEFFICIENT
offset = clampOffset(offset, scale, containerSize)
// onDrag 콜백으로 상위 뷰에게서 이벤트 소비 여부를 결정 받습니다.
val shouldConsume = onDrag?.invoke(dragAmount) ?: false
if (shouldConsume) dragChange.consume()
}
}
}
} while (event.changes.fastAny { it.pressed })
}
}
}
상위 컴포저블인 Pager에서는 PinchToZoom의 이벤트 소비 가능 여부를 아래와 같이 제어했습니다.
private const val ZOOM_SCROLLABLE_TOLERANCE = 0.05f
@Composable
fun OriginalPhotoPager(
// 필요한 상태를 파라미터로 넘겨받습니다.
) {
// 스크롤 가능 여부를 나타내는 상태입니다.
var scrollable by remember { mutableStateOf(true) }
HorizontalPager(
// 스크롤 가능 여부를 scrollable로 전달받습니다.
userScrollEnabled = scrollable,
) { page ->
PinchToZoom(
// 핀치 줌의 확대 비율에 따라 scrollable을 결정합니다.
// 현재는 확대 비율이 0.95 ~ 1.05 사이라면 스크롤이 가능하도록 설정했습니다.
onScaleChange = { scale ->
scrollable = (scale - DEFAULT_MIN_ZOOM_SCALE).absoluteValue < ZOOM_SCROLLABLE_TOLERANCE
},
// 핀치 줌의 드래그 동작이 이벤트를 소비할 수 있는지 전달합니다.
onDrag = { !scrollable },
) {
// 이미지 컴포저블
}
}
}
그리하여 최종 구현 완료한 핀치 줌의 모습입니다.
마치며
이렇게 Compose에서 멀티 제스처 이벤트를 제어하여 핀치 줌을 구현하는 과정을 함께 살펴보았습니다. 그리고 Compose에서 이벤트가 UI 트리에서 전달되는 흐름도 알아보았습니다.
개인적으로는 Compose를 도입하고 제대로 다뤄보면서 마주한 꽤 흥미로운 이슈였기에, 왜 안되지 답답하기도 했지만 오랜만에 재미있었습니다. xml로 구현할 당시 터치로 키보드를 숨기는 기능을 구현했던게 생각나기도 했구요. 터치로 키보드를 숨기는 기능도 여유가 되면 트러블 슈팅 포스트로 다뤄보겠습니다.
이렇게 첫 트러블 슈팅 포스트를 마무리 지어봅니다! 최대한 이해가 쉬우면서 고민한 흔적이 잘 나타나도록 글을 써봤는데 여러분께 잘 전달되었을지 모르겠네요.
글에서 궁금하거나 틀린 부분이 있다면, 댓글로 알려주시면 감사하겠습니다~ 🙇♂️
참고 자료
https://developer.android.com/develop/ui/compose/touch-input/pointer-input/understand-gestures
동작 이해하기 | Jetpack Compose | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. 동작 이해하기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 이해해야 할 몇 가지 용어와 개념이 있
developer.android.com
https://developer.android.com/develop/ui/compose/touch-input/pointer-input/multi-touch
멀티터치: 화면 이동, 확대/축소, 회전 | Jetpack Compose | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. 멀티터치: 화면 이동, 확대/축소, 회전 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 화면 이동, 확대
developer.android.com
https://medium.com/globant/implementing-pinch-to-zoom-in-jetpack-compose-dc824155e313
Implementing Pinch to Zoom in Jetpack Compose
Jetpack Compose is the modern Android UI toolkit, developed by Google. It has revolutionized the creation of user interfaces for Android…
medium.com
https://pluu.github.io/blog/android/2024/06/15/compose/
Pluu Dev - [정리] Compose 가이드 문서 ~ 터치&입력
[요약] What's new in Android (Google I/O '25) Posted on 03 Jun 2025 [요약] What's new in Android development tools (Google I/O '25) Posted on 25 May 2025 Jetpack Compose: LazyColumn/LazyRow 내부 코드 분석 ~ 3부 LazyLayout Posted on 20 Apr 2025 C
pluu.github.io
'개발 > Android' 카테고리의 다른 글
[Android] GitHub Actions로 QA용 CD 구축하기 - Firebase 활용 (5) | 2025.08.03 |
---|---|
[Android] GitHub Actions로 CI 적용하기 - 간단 가이드 (0) | 2025.04.02 |
[Android] Compose 상태 관리 심화 (0) | 2025.02.15 |
[Android] Compose 상태 관리의 기본 개념 (0) | 2025.01.30 |
[Android] 우리가 RecyclerView를 사용하는 이유 (feat. ListView) (0) | 2025.01.10 |