본문 바로가기

Android/Jetpack Compose

[Compose] 스티커 캔버스를 만들어보자 - 복합 제스처, Spring 물리, 필오프(Peel-Off) 애니메이션

728x90

필자가 회사에서 업무를 진행하다 보면, 포토 에디터나 화이트보드 같은 화면에서 사용자가 직접 요소를 자유롭게 배치하고 크기·각도를 조절할 수 있는 UI가 필요한 경우가 종종 있다. 카카오톡 사진 편집기의 스티커 기능이나 인스타그램 스토리의 텍스트 배치를 떠올리면 익숙한 형태이다.

막상 직접 구현해 보려고 하면 드래그·핀치·회전이 동시에 일어나는 복합 제스처를 다뤄야 하고, 거기에 자연스러운 물리 애니메이션까지 얹어야 해서 진입 장벽이 꽤 높게 느껴진다.

최근 안드로이드 관련 기술 블로그를 확인하다가 Adit Lal의 "Building StickerExplode Part 1" 글을 보게 되었고, 거기서 다루는 패턴을 정리해 두면 실무에서 응용하기 좋겠다는 생각이 들었다.

이번 글에서는 Jetpack Compose로 드래그·핀치·회전을 동시에 처리하는 복합 제스처, Spring 물리 애니메이션, 그리고 진짜 비닐 스티커를 잡았을 때의 느낌을 내는 필오프(Peel-Off) 효과까지 한 번에 정리해 보고자 한다.


스티커 데이터 모델 설계

본격적인 제스처 코드에 들어가기 전에, 스티커가 가져야 할 상태를 정리하는 데이터 클래스부터 만들어보자.

data class StickerItem(
      val id: Int,
      val emoji: String,
      val label: String,
      val bgColor: Color,
      var offsetX: Float = 0f,
      var offsetY: Float = 0f,
      var rotation: Float = 0f,
      var pinchScale: Float = 1f,
      var zIndex: Float = 0f,        // 단조 증가 카운터
      var isDragging: Boolean = false,
      var doubleTapZoomed: Boolean = false,
  )
  

여기서 주목할 부분이 두 가지 있다.

1. zIndex — 스티커를 탭할 때마다 단조 증가하는 카운터를 부여해 최상위로 올라오게 한다. 포토 에디터에서 가려진 스티커를 톡 치면 위로 올라오는 동작을 자연스럽게 만들 수 있다.

2. isDragging / doubleTapZoomed — 단순한 좌표/스케일 외에 "현재 잡혀 있는가" 같은 인터랙션 상태도 모델에 포함시켜두면, 필오프 같은 시각 효과를 분리해서 처리할 수 있다.

있다.


핵심 API: detectTransformGestures

Compose에서 드래그·핀치·회전을 한 번에 다룰 때 사용하는 API가 detectTransformGestures이다. 한 콜백에서 pan(이동), zoom(확대), rotation(회전)을 모두 받을 수 있다는 점이 매우 편리하다.

.pointerInput(sticker.id) {
      detectTransformGestures(
          onGesture = { _, pan, zoom, rotation ->
              // pan: 한 손가락 드래그의 이동량 (Offset)
              // zoom: 두 손가락 핀치의 배율 변화량 (Float, 1f = 변화 없음)
              // rotation: 두 손가락 회전 각도 변화량 (Float, 단위: 도)
              onOffsetChange(pan.x, pan.y)
              onScaleRotateChange(zoom, rotation)
          }
      )
  }
  

pointerInput의 key에 sticker.id를 넘긴 이유는, 스티커가 추가·삭제될 때 제스처 처리기가 올바르게 리바인딩되도록 하기 위해서이다. 이 key를 깜빡하면 새로 추가한 스티커가 제스처를 받지 못하는 문제가 생긴다.

주의할 점은 zoomrotation변화량(델타)이라는 것이다. 그래서 상위에서 누적해줘야 하고, 동시에 0.5x~3x 같은 범위 제한도 걸어줘야 한다.


탭 / 더블 탭 분리하기

드래그와 별개로 단일 탭(최상위로 가져오기)과 더블 탭(2배 확대 토글)을 처리해야 한다. detectTransformGestures와는 별도의 pointerInput 블록을 두고 detectTapGestures로 받는다.

.pointerInput(sticker.id) {
      detectTapGestures(
          onTap = { onTap() },             // zIndex 증가 → 최상위
          onDoubleTap = { onDoubleTap() }, // 더블 탭 줌 토글
      )
  }
  .pointerInput(sticker.id) {
      detectTransformGestures(
          onGesture = { _, pan, zoom, rotation -> /* ... */ }
      )
  }
  

처음에는 두 제스처가 충돌해서 한쪽이 동작하지 않을까 걱정했었는데, pointerInput 블록을 분리해서 작성하면 Compose가 알아서 잘 처리해 주었다. 이게 더 깔끔하게 의도를 표현할 수 있다는 점에서도 좋은 것 같다.


필오프(Peel-Off) 효과 만들기

이번 글에서 개인적으로 가장 마음에 들었던 부분이다. 사용자가 스티커를 잡는 순간, 마치 진짜 비닐 스티커를 표면에서 떼어내는 듯한 느낌을 주는 효과이다. 구현 자체는 의외로 단순한 세 가지 조합이다.

  • 그림자(Elevation): 4dp → 16dp로 증가 (들어올린 느낌)
  • 스케일(Scale): 1.0 → 1.1로 살짝 확대 (가까워지는 느낌)
  • 알파(Alpha): 1.0 → 0.92로 살짝 투명 (들어올렸을 때의 자연스러운 색감 변화)

이 세 가지를 animateDpAsStateanimateFloatAsState + spring으로 처리한다.

val animatedElevation by animateDpAsState(
      targetValue = if (sticker.isDragging) 16.dp else 4.dp,
      animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
      label = "elevation"
  )
  val animatedScale by animateFloatAsState(
      targetValue = when {
          sticker.isDragging -> 1.1f
          sticker.doubleTapZoomed -> 2f
          else -> 1f
      },
      animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
      label = "peelScale"
  )
  val animatedAlpha by animateFloatAsState(
      targetValue = if (sticker.isDragging) 0.92f else 1f,
      animationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy),
      label = "peelAlpha"
  )
  

여기서 핵심은 spring(DampingRatioMediumBouncy) 이다. 단순한 tween으로 보간하면 기계적인 느낌이 나는데, spring으로 살짝 튀어 오르는 효과를 더하면 진짜로 손가락에 끌려가는 듯한 자연스러운 느낌이 만들어진다.

그리고 알파만 NoBouncy로 처리한 이유는, 투명도가 튀어 오르면 깜빡이는 것처럼 보이기 때문이다. 시각적으로 부자연스러운 부분은 바운스를 빼는 게 좋다는 점을 직접 시도해 보면서 알게 되었다.


드래그 종료 감지: awaitPointerEventScope

드래그 시작은 detectTransformGestures의 pan 콜백에서 쉽게 감지할 수 있지만, "언제 손가락을 뗐는가"를 알려면 약간의 트릭이 필요하다. detectTransformGestures 자체에는 onEnd 콜백이 없기 때문이다.

그래서 별도의 pointerInput 블록을 하나 더 두고, awaitPointerEventScope로 직접 포인터 이벤트를 관찰한다.

.pointerInput(sticker.id) {
      awaitPointerEventScope {
          while (true) {
              val event = awaitPointerEvent()
              // 모든 포인터가 떼어진 시점 = 드래그 종료
              if (event.changes.all { !it.pressed }) {
                  onDragEnd()
              }
          }
      }
  }
  

이렇게 하면 위에서 본 isDragging 플래그를 false로 돌릴 시점을 정확히 잡을 수 있고, 필오프 효과가 자연스럽게 원래 상태로 복귀하는 흐름이 완성된다.


모든 변환을 graphicsLayer로 한 번에

스케일·회전·알파는 graphicsLayer Modifier 하나로 모두 처리한다. 각각을 개별 Modifier로 적용하지 않고 한 블록에 묶는 이유는 성능 때문이다. graphicsLayer는 하드웨어 가속을 활용하기 때문에 변환이 여러 개 있을수록 한 번에 처리하는 게 유리하다.

Box(
      modifier = Modifier
          .offset {
              IntOffset(
                  (sticker.offsetX - 40).roundToInt(),
                  (sticker.offsetY - 40).roundToInt()
              )
          }
          .zIndex(sticker.zIndex)
          .graphicsLayer {
              scaleX = sticker.pinchScale * animatedScale
              scaleY = sticker.pinchScale * animatedScale
              rotationZ = sticker.rotation
              alpha = animatedAlpha
          }
          .shadow(animatedElevation, RoundedCornerShape(14.dp))
          // ... 제스처 핸들러들
  )
  

또 한 가지 주의할 점은 scaleXscaleY에 두 값(pinchScaleanimatedScale)을 곱해서 적용한다는 것이다. 핀치로 사용자가 설정한 크기와, 필오프로 잠깐 들어 올리는 효과를 분리해서 보관하다가 그릴 때 합치는 방식이다. 이렇게 분리해두면 드래그가 끝나도 사용자가 설정한 핀치 배율은 그대로 유지된다.


Die-Cut 렌더링 - 진짜 비닐 스티커처럼

실제 스티커를 보면 콘텐츠 주위에 흰색 테두리가 둘러져 있다. 이걸 Die-Cut이라고 부르는데, Compose에서는 Box 두 개를 중첩해서 매우 간단하게 구현할 수 있다.

Box(
      modifier = Modifier
          .size(80.dp)
          .background(Color.White, RoundedCornerShape(14.dp))  // 외부: 흰색 테두리
          .padding(4.dp)                                        // 테두리 두께
          .background(
              sticker.bgColor.copy(alpha = 0.12f),              // 내부: 옅은 배경
              RoundedCornerShape(10.dp)
          ),
      contentAlignment = Alignment.Center
  ) {
      Text(text = sticker.emoji, fontSize = 36.sp)
  }
  

외부 Box에는 흰색 배경 + 둥근 모서리, 그 안쪽에 padding으로 인셋을 만든 뒤 다시 내부 background를 깔아주는 구조이다. 여기에 shadow까지 더하면 진짜 표면에서 살짝 떠 있는 스티커처럼 보인다.


Z-Ordering: 탭하면 위로 올라오는 패턴

여러 스티커가 겹쳐 있을 때, 탭한 스티커를 항상 최상위로 끌어올리는 패턴은 단조 증가 카운터로 간단히 구현할 수 있다.

var zIndexCounter by remember { mutableFloatStateOf(0f) }

  // 탭 시
  onTap = {
      zIndexCounter += 1f
      sticker.zIndex = zIndexCounter
  }

  // 그릴 때
  .zIndex(sticker.zIndex)
  

리스트를 정렬하거나 순서를 바꿀 필요 없이 그냥 카운터를 늘려주기만 하면 된다. 새 스티커를 추가할 때도 동일한 방식으로 최상위 zIndex를 부여하면 자연스럽게 가장 위에 놓이게 된다.


Spring DampingRatio 비교

참고로 Compose의 Spring은 다음 네 가지 프리셋을 제공한다. 각각의 차이를 알고 있으면 상황에 맞게 골라 쓰기 좋다.

  • Spring.DampingRatioNoBouncy — 바운스 없음. 알파나 색상 변경처럼 시각적으로 튀면 부자연스러운 곳에 적합.
  • Spring.DampingRatioLowBouncy — 약간 튀어 오름. 차분한 UI 변경에 적합.
  • Spring.DampingRatioMediumBouncy — 가장 자연스러운 바운스. 스티커 놓을 때, 더블 탭 줌 등 대부분의 인터랙션에 가장 잘 어울림.
  • Spring.DampingRatioHighBouncy — 매우 튀어 오름. 캐릭터 등장이나 알림처럼 주목을 끌어야 할 때.

필자가 직접 네 가지 모두 테스트해 본 결과, 스티커 인터랙션에는 Medium 이 가장 어울리는 것 같다. Low는 차분하긴 한데 약간 밋밋하고, High는 너무 과해서 산만한 느낌이 들었다.


구현하면서 신경 써야 했던 부분

1. pointerInput key를 빼먹지 말기

스티커가 동적으로 추가·삭제되는 환경에서 pointerInput(Unit)으로 두면 새 스티커가 제스처를 받지 못하는 문제가 생긴다. 반드시 pointerInput(sticker.id)처럼 식별 가능한 key를 넣어줘야 한다.

2. 스케일과 회전은 변화량(델타)

detectTransformGestures가 넘기는 zoomrotation은 절댓값이 아니라 변화량이다. 그래서 상위에서 누적해줘야 하고, 동시에 범위 제한(예: scale 0.5~3.0)도 걸어줘야 한다.

3. 알파 애니메이션에는 바운스 빼기

스케일이나 그림자는 살짝 튀어 올라도 자연스럽지만, 알파(투명도)가 튀어 오르면 깜빡이는 것처럼 보인다. 알파만큼은 NoBouncy를 쓰는 게 좋다.

4. graphicsLayer로 변환을 한 번에 묶기

scale·rotation·alpha를 각각 별도 Modifier로 적용하지 말고 graphicsLayer 한 블록에서 처리해야 하드웨어 가속 이점을 살릴 수 있다.


마무리

이번 글에서는 Jetpack Compose로 드래그·핀치·회전을 동시에 처리하는 스티커 캔버스와, Spring 물리·필오프 애니메이션·Die-Cut 렌더링·Z-Ordering 같은 디테일들을 살펴보았다.

처음에는 "복합 제스처 처리가 까다롭겠다"는 생각으로 시작했는데, 막상 detectTransformGestures 하나로 거의 모든 게 해결된다는 점이 인상 깊었다. 거기에 spring 애니메이션 한 줄을 더하는 것만으로도 인터랙션의 품질이 확연히 달라진다는 사실도 다시 한번 체감했다. 코드 양은 적은데 "느낌"이 살아나는 그 차이가 결국 사용자가 앱을 좋아하는 이유가 되는 것 같다.

다음에는 이 스티커 캔버스에 텍스트 입력·이미지 업로드·undo/redo 같은 기능을 추가해서 좀 더 실제 포토 에디터에 가까운 형태로 확장하는 작업도 한번 정리해 볼 예정이다.

필자도 아직 Compose의 제스처와 애니메이션에 대해서는 공부 중이기 때문에 작성한 개념이나 설명에 오류가 있을 가능성이 있다는 점을 감안하고 읽어주었으면 좋겠다.

해당 게시글에 사용한 예제는 다음 Github에 올려두었다.
https://github.com/HeeGyeong/ComposeSample

 

GitHub - HeeGyeong/ComposeSample: This project provides various examples needed to actually use Jetpack Compose.

This project provides various examples needed to actually use Jetpack Compose. - HeeGyeong/ComposeSample

github.com

728x90