아티클 목록으로 가기

Crayfish 소개: Compose Multiplatform을 위한 이미지 크로퍼

skydovesJaewoong Eum (skydoves)||12분 소요

Crayfish 소개: Compose Multiplatform을 위한 이미지 크로퍼

프로필 사진, 커버 이미지, 영수증, 상품 사진처럼 사용자가 사진을 고르고 필요한 부분만 잘라 내는 기능은 거의 모든 앱에 한 번쯤 들어갑니다. 안드로이드에서는 보통 View 기반 크로퍼(cropper)를 가져다 쓰고, 별도의 Activity를 띄운 뒤 onActivityResult로 비트맵을 돌려받는 방식을 써 왔습니다. 그런데 이 방식은 Compose로 작성한 화면과 잘 맞지 않고, iOS나 데스크톱, 웹에서는 아예 동작하지 않습니다. Crayfish는 Compose Multiplatform 위에서 만든 이미지 크로퍼라서, 똑같은 몇 줄의 코드로 안드로이드와 iOS, 데스크톱, 웹에서 사진을 자를 수 있습니다. Painter를 받아 크롭한 뒤 Painter로 돌려주므로, 화면에 떠 있는 이미지를 곧장 잘라 다시 Image에 그릴 수 있습니다.

이 글에서는 Crayfish를 바깥쪽 API부터 차례로 살펴봅니다. 한 줄로 끝나는 다이얼로그, Cropper 컴포저블과 그 상태, 세 가지 크롭 소스, 가로세로 비율(aspect ratio)과 제스처, 스타일, 모양을 바꾸는 옵션, 크롭을 저장해 두었다가 다시 편집하는 레시피, 그리고 Crayfish를 Activity와 Coil, Landscapist에 연결하는 모듈까지 다룹니다.

안드로이드iOS데스크톱
안드로이드에서 동작하는 CrayfishiOS에서 동작하는 Crayfish데스크톱에서 동작하는 Crayfish

시작하기: 의존성 하나로 모든 타겟 지원

안드로이드 프로젝트라면 의존성 하나만 추가하시면 됩니다.

// 모듈 수준 build.gradle.kts
dependencies {
    implementation("com.github.skydoves:crayfish:0.1.0")
}

Kotlin Multiplatform 프로젝트에서는 같은 아티팩트를 commonMain에 추가합니다. 타겟마다 맞는 변형(variant)은 Gradle이 알아서 골라 줍니다.

kotlin {
    sourceSets {
        // 모든 타겟이 공유하는 commonMain에 한 번만 추가합니다.
        commonMain.dependencies {
            implementation("com.github.skydoves:crayfish:0.1.0")
        }
    }
}

Crayfish는 android, JVM용 desktop, iosArm64, iosSimulatorArm64, macosArm64, wasmJs 타겟을 배포합니다. 네이티브 코드가 없고 Compose와 kotlinx.coroutines 외에는 의존하는 라이브러리가 없어서, HTTP 클라이언트나 이미지 로더가 의존성 그래프에 딸려 들어오지 않습니다. Play 스토어의 16KB 페이지 크기 요구 사항은 네이티브 라이브러리(.so)를 16KB 단위로 정렬하라는 조건이라, 네이티브 코드가 없는 Crayfish는 이 부분도 신경 쓸 필요가 없습니다. commonMain에 작성한 크로퍼 코드가 그대로 모든 플랫폼의 크로퍼가 됩니다.

한 줄 크롭: 코루틴에서 호출하는 다이얼로그

크롭 기능을 가장 빠르게 붙이는 방법은 rememberImageCropper입니다. 코루틴에서 호출할 수 있는 크로퍼를 돌려주고, 크롭이 진행되는 동안에는 ImageCropperDialog가 다이얼로그를 띄웁니다.

val cropper = rememberImageCropper()
val scope = rememberCoroutineScope()
var cropped by remember { mutableStateOf<ImageBitmap?>(null) }

Button(
  onClick = {
    scope.launch {
      // 사용자가 확인하거나 취소할 때까지 여기서 일시 중단됩니다.
      when (val result = cropper.cropToImage(source)) {
        is CropImage.Success -> cropped = result.image
        is CropImage.Cancelled -> Unit
        is CropImage.Failure -> showError(result.reason)
      }
    }
  },
) {
  Text("Crop a photo")
}

// 크롭이 진행 중일 때만 다이얼로그가 나타납니다.
ImageCropperDialog(cropper)

호출하는 코드가 어떤 모습인지 눈여겨보세요. cropToImage는 사용자가 확인하거나 취소할 때까지 일시 중단되므로, 다른 곳에 콜백을 등록하거나 결과 코드를 비교할 필요 없이 전체 흐름이 when 하나로 위에서 아래로 읽힙니다. 호출한 코루틴이 취소되면 다이얼로그도 함께 닫히기 때문에, 화면이 컴포지션에서 빠지면 진행 중이던 크롭도 같이 정리됩니다.

결과는 ImageBitmap이므로 평소처럼 Image 컴포저블로 그립니다.

cropped?.let { image ->
  Image(
    bitmap = image,
    contentDescription = "Cropped photo",
    modifier = Modifier.size(120.dp).clip(CircleShape),
  )
}

이 경로에서는 인코딩이 일어나지 않으므로, 크롭한 결과를 화면에 그리기 전에 다시 디코딩할 필요도 없습니다. Painter가 필요한 곳에 넘길 수 있도록 같은 픽셀을 담은 CropImage.Success.painter도 함께 제공됩니다. 크롭 결과를 서버에 올리거나 파일로 저장해야 한다면 인코딩된 바이트를 돌려주는 crop을 사용합니다.

when (val result = cropper.crop(CropSource.FilePath(path))) {
  is CropResult.Success -> upload(result.bytes)
  is CropResult.Cancelled -> Unit
  is CropResult.Failure -> showError(result.reason)
}

다이얼로그도 Cropper 컴포저블과 같은 스타일 옵션을 받기 때문에, 원형 아바타 크롭 역시 한 줄로 끝납니다.

ImageCropperDialog(
  cropper = cropper,
  shape = CropShape.Circle,
  accessibility = CropAccessibility(contentDescription = "Profile photo crop area"),
)

크롭 소스: Painter, 파일, 로더

크로퍼가 얼마나 편한지는 어떤 입력을 받을 수 있느냐에 달려 있고, Crayfish는 Compose 앱이 가지고 있는 것을 그대로 받습니다. 화면에 사진을 띄웠다면 그 과정에서 이미 Painter나 ImageBitmap을 손에 쥐고 계실 텐데, 둘 다 곧바로 크롭 소스가 됩니다. 그래서 Coil이나 Landscapist 같은 라이브러리로 불러온 URL 이미지도 중간 변환 없이 바로 크롭할 수 있습니다.

// Coil이 불러온 Painter를 그대로 크롭 소스로 씁니다.
val painter = rememberAsyncImagePainter(url)
val source = rememberCropSource(painter, cacheKey = url)

if (source != null) {
  val state = rememberCropState(source)
  Cropper(state = state, modifier = Modifier.fillMaxSize())
}

cacheKey는 이미지를 식별하는 값입니다. Crayfish는 저장해 둔 크롭 상태를 이 키로 찾습니다. 그러니 URL이나 콘텐츠 Uri처럼 프로세스 종료(process death) 뒤에도 바뀌지 않는 값을 넘기면, 사용자가 멈췄던 자리 그대로 크롭 상태가 돌아옵니다.

원본이 큰 사진이라면 사정이 다릅니다. Painter는 이미 전체 픽셀을 디코딩해 메모리에 올려 둔 상태라서 큰 원본에는 맞지 않는 입력입니다. 그래서 Crayfish는 인코딩된 이미지에 대한 참조도 받습니다. 참조를 받으면 Crayfish가 직접 열어 크롭 프레임 아래 영역만 디코딩하므로, 1억 화소 카메라 사진도 통째로 메모리에 올라가지 않습니다.

// 기기에 저장된 파일 경로
CropSource.FilePath("/storage/emulated/0/DCIM/Camera/IMG_0001.jpg")

// 이미 읽어 둔 인코딩된 바이트
CropSource.Bytes(bytes, cacheKey = uri.toString())

// 앱에서 쓰는 HTTP 클라이언트로 가져오기
CropSource.Loader(cacheKey = url) { httpClient.get(url).readRawBytes() }

CropSource.Loader는 suspend 함수를 받아서, 그 함수가 돌려준 값을 CropSource.Bytes와 똑같이 다룹니다. 크로퍼가 소스를 열 때 한 번만 실행되며, null을 반환하면 이미지를 가져오지 못한 것으로 처리합니다. Crayfish에는 자체 HTTP 클라이언트가 없으니, 앱에서 쓰던 클라이언트를 로더 안에서 호출하시면 됩니다. 어떤 소스를 고를지는 지금 무엇을 가지고 있느냐에 달려 있습니다.

  • 화면에 이미 떠 있는 Painter나 ImageBitmap: rememberCropSource를 사용합니다. 픽셀이 이미 메모리에 있으므로 추가 비용이 없습니다.
  • 카메라 원본이나 큰 사진, Uri: CropSource.FilePath나 CropSource.Bytes를 사용합니다. 잘라 낼 영역만 디코딩합니다.
  • 아주 클 수도 있는 이미지의 URL: 직접 준비한 HTTP 클라이언트와 함께 CropSource.Loader를 사용합니다. 인코딩된 바이트를 한 번 받아 오는 비용만 들고, 이미지 전체를 디코딩하지는 않습니다.

직접 만드는 화면: Cropper와 CropState

대부분의 경우는 다이얼로그로 충분합니다. 직접 디자인한 화면 안에 크롭 기능을 넣고 싶다면, 다이얼로그 뒤에서 동작하는 Cropper 컴포저블과 이를 조작하는 CropState를 사용하세요.

val state = rememberCropState(
  source = CropSource.FilePath(path),
  initialAspectRatio = AspectRatio.Square,
)

Cropper(
  state = state,
  modifier = Modifier.fillMaxSize(),
)

rememberLazyListState나 rememberScrollState에서 익숙하게 쓰시던 상태 호이스팅(state hoisting) 패턴과 같습니다. 컴포저블은 그리기만 맡고, 소스 로딩 상태를 비롯해 툴바에 필요한 것은 모두 상태 객체에 들어 있습니다. 상태를 컴포저블 밖으로 끌어올려 두었기 때문에 툴바나 버튼 같은 다른 UI에서도 같은 상태를 함께 다룰 수 있습니다.

// 소스를 여는 중, 준비 완료, 실패를 구분합니다.
when (val status = state.status) {
  is CropStatus.Loading -> CircularProgressIndicator()
  is CropStatus.Ready -> Text("${status.imageSize.width} x ${status.imageSize.height}")
  is CropStatus.Failed -> Text("Could not open this image")
}

// 툴바 버튼은 상태의 함수를 호출하기만 하면 됩니다.
Row {
  IconButton(onClick = { state.rotateBy(-90f) }) { Icon(Icons.Default.RotateLeft, null) }
  IconButton(onClick = { state.toggleFlipHorizontal() }) { Icon(Icons.Default.Flip, null) }
  IconButton(onClick = { state.reset() }) { Icon(Icons.Default.Refresh, null) }
}

rotateBy는 90도 단위뿐 아니라 어떤 각도든 받을 수 있고, rotateToNearestQuarterTurn은 0, 90, 180, 270도 중 가장 가까운 각도로 맞춥니다. 어떤 연산을 하든 끝나면 크롭 사각형이 이미지 안으로 돌아오므로, 프레임은 크롭이 어떤 픽셀을 가져갈지 언제나 정확하게 가리킵니다. 회전과 반전은 결과물에도 그대로 반영되어, rotateBy(90f)를 호출한 뒤 크롭하면 회전된 결과가 나옵니다.

크롭 사각형과 변환 상태는 구성 변경(configuration change)이나 프로세스 종료 뒤에도 유지됩니다. Android 16을 타겟으로 하는 앱은 대화면 기기에서 화면 방향을 더 이상 고정할 수 없기 때문에, 태블릿이나 폴더블에서는 앱이 원하지 않아도 크로퍼 화면이 회전합니다. 그만큼 상태 유지가 예전보다 훨씬 중요해졌습니다.

사용자가 편집을 마치면 상태 객체에서 바로 결과를 얻을 수 있습니다. cropToImage는 Image에 그릴 픽셀을, crop은 원하는 포맷으로 인코딩한 바이트를 돌려줍니다.

when (val result = state.cropToImage()) {
  is CropImage.Success -> Image(bitmap = result.image, contentDescription = null)
  is CropImage.Cancelled -> Unit
  is CropImage.Failure -> showError(result.reason)
}

// PNG로 인코딩한 결과(CropResult)
val encoded = state.crop(EncodeOptions(format = EncodedFormat.PNG))

crop에는 디코딩에 쓸 메모리 상한을 정하는 DecodeBudget도 넘길 수 있습니다. 결과물을 작게 쓸 때 유용한데, 아바타라면 8MiB, 1024픽셀로 상한을 두는 식입니다.

크로퍼 커스터마이징: 비율, 제스처, 스타일, 모양

디자인에 따라 바꾸고 싶을 만한 부분은 모두 기본값이 있는 파라미터로 열려 있어서, 화면에 필요한 부분만 골라 바꾸시면 됩니다.

가로세로 비율

처음 열릴 때의 비율은 AspectRatio로 넘기고, 나중에 state.aspectRatio에 값을 대입하면 그 자리에서 크롭 사각형의 모양이 바뀝니다. 비율을 고르는 UI는 칩 한 줄이면 충분합니다.

Row {
  listOf(
    "Free" to AspectRatio.Free,
    "1:1" to AspectRatio.Square,
    "3:4" to AspectRatio.Portrait3x4,
    "16:9" to AspectRatio.Widescreen16x9,
  ).forEach { (label, ratio) ->
    FilterChip(
      selected = state.aspectRatio == ratio,
      onClick = { state.aspectRatio = ratio },
      label = { Text(label) },
    )
  }
}

AspectRatio.Fixed(ratio)에는 가로를 세로로 나눈 임의의 값을 넣을 수 있고, 미리 정의된 비율로는 Landscape4x3와 Portrait9x16도 있습니다. 비율을 바꾸면 다음 드래그를 기다리지 않고 사각형 모양이 즉시 바뀝니다.

제스처

기본 설정에서는 사진이 움직이지 않습니다. 평범한 Image처럼 화면에 맞춰 배치되고, 모든 제스처는 크롭 사각형이 받습니다. 대부분의 크로퍼는 반대로 프레임 아래에서 사진을 이동하고 확대하고 돌리게 하는데, 그러면 화면에 가만히 있는 것이 하나도 없어서 사각형의 변을 어디에 맞춰야 할지 가늠하기 어렵습니다. 사진이 고정되어 있으면 무엇이 잘려 나올지 프레임만 보고 언제든 알 수 있습니다. 결과물은 미리보기가 아니라 원본에서 디코딩하므로, 이렇게 해도 해상도를 잃지 않습니다.

사진까지 움직이는 방식이 필요한 화면이라면 파라미터 하나로 바꿀 수 있습니다.

Cropper(
  state = state,
  modifier = Modifier.fillMaxSize(),
  gestures = CropGestures.Zoomable,
)

CropGestures.Zoomable은 핀치 줌, 두 손가락 이동, 더블 탭 확대, 플링을 켜고, CropGestures.All은 여기에 두 손가락 회전까지 더합니다. 각 플래그는 서로 독립적이라서 CropGestures(zoom = true)처럼 쓰면 확대는 되지만 끌어서 옮길 수는 없는 사진이 됩니다.

스타일

CropStyle은 프레임 주변의 스크림(scrim), 테두리, 격자 같은 외형을 정합니다. 모든 값에 기본값이 있습니다.

Cropper(
  state = state,
  modifier = Modifier.fillMaxSize(),
  style = CropStyle(
    scrimColor = Color.Black.copy(alpha = 0.7f),
    frameColor = Color.White,
    gridMode = CropGridMode.OnTouch,
    handleTouchRadius = 24.dp,
  ),
)

CropGridMode는 Never, Always, OnTouch 중 하나입니다. handleTouchRadius는 핸들이 그려지는 크기가 아니라 터치를 받는 영역의 크기를 정하므로, 핸들을 크게 그리지 않고도 쉽게 잡을 수 있습니다.

모양

마스크 모양과 결과물의 가로세로 비율은 서로 독립된 파라미터라서, 원형 마스크를 쓴다고 결과가 정사각형으로 고정되지 않고, 정사각형 비율을 쓴다고 마스크가 사각형으로 고정되지도 않습니다. 오버레이는 컴포저블 슬롯이며, 모양은 CropOverlay에 넘깁니다.

Cropper(
  state = state,
  modifier = Modifier.fillMaxSize(),
  overlay = { cropState ->
    CropOverlay(state = cropState, shape = CropShape.Circle)
  },
)

CropShape는 Rectangle, RoundedRectangle(cornerRadius), Circle, 그리고 크롭 사각형의 크기로 Path를 만드는 Custom 중 하나입니다. 모양은 뷰파인더 위에 그려지는 데서 끝나지 않고 결과물에서도 실제로 잘려 나갑니다. state.shape = CropShape.Circle로 지정하면 cropToImage는 네 귀퉁이가 투명한 이미지를 돌려줍니다. crop은 인코딩 포맷이 알파 채널을 지원할 때만 모양대로 잘라 내므로 PNG와 WebP는 투명도를 유지하고, JPEG는 임의의 배경색으로 채워지는 대신 사각형 그대로 남습니다.

수평 맞추기

수평을 맞추는 슬라이더는 절대 각도를 알려 주므로, 절대 각도를 받는 rotateTo를 사용합니다.

var degrees by remember { mutableStateOf(0f) }

Slider(
  value = degrees,
  // 슬라이더 값(절대 각도)을 그대로 넘깁니다.
  onValueChange = { degrees = it; state.rotateTo(it) },
  valueRange = -45f..45f,
)

사용자가 정해 둔 크롭 사각형은 그 자리에 머물고, 사진은 사각형을 꽉 채울 만큼만 확대됩니다. 각도가 바뀔 때마다 프레임이 조금씩 줄어드는 일이 없어서, 슬라이더를 밀었다가 되돌리면 크롭도 처음 자리로 정확하게 돌아옵니다.

다시 편집할 수 있는 크롭: CropRecipe

대부분의 크로퍼는 비트맵을 돌려주고 나면 그 결과가 어떻게 만들어졌는지 잊어버립니다. 그래서 "지난주에 자른 사진을 조금만 고치고 싶다"는 요청은 처음부터 다시 자르라는 말이 됩니다. Crayfish는 크롭을 설명하는 데 픽셀이 필요하지 않습니다. 원본에 대한 참조와 그 위의 사각형만 있으면 되고, CropRecipe는 이 설명을 float 11개로 기록한 것입니다.

// 사용자가 만족하면 레시피만 저장합니다. 픽셀은 저장하지 않습니다.
database.save(photoId, state.recipe.encodeToString())

// 나중에 같은 원본을 다시 열면 멈췄던 그대로 이어서 편집할 수 있습니다.
val recipe = CropRecipe.decodeFromString(database.load(photoId))
val state = rememberCropState(source, initialRecipe = recipe)

원본은 전혀 수정되지 않습니다. 같은 원본에 다른 레시피를 적용하면 그저 다른 크롭이 될 뿐이라 그 사이에 잃어버리는 정보가 없습니다. 사용자가 둔 적 없는 엉뚱한 위치로 크롭을 여는 것보다는 기본 상태로 여는 편이 낫기 때문에, decodeFromString은 알아볼 수 없는 값을 받으면 null을 반환합니다. 앞으로 나올 버전에서 만든 레시피도 마찬가지입니다. 프로세스 종료 후 복원에도 같은 11개의 숫자를 쓰므로, 포맷이 하나뿐이라 서로 어긋날 일이 없습니다.

기본값이 곧 정답: Exif, 접근성, 성능

크로퍼가 반드시 제대로 처리해야 하는 몇 가지에는 아예 파라미터가 없습니다. 잘못 설정할 여지를 둘 이유가 없기 때문입니다.

  • Exif 방향: Crayfish는 사진의 방향 태그를 읽어 미리보기와 결과물에 모두 적용합니다. 여덟 가지 값을 모두 처리하며, 미러 방향 네 가지도 빠짐없이 다룹니다. 회전만 처리하는 크로퍼는 이 값을 만나면 똑바로 서 있지만 좌우가 뒤집힌 사진을 만들어 냅니다. HEIF 이미지는 컨테이너 자체의 변환 속성과 Exif 태그를 서로 맞춰 봅니다.
  • 접근성: WCAG 2.2 SC 2.5.7은 드래그로만 할 수 있는 상호작용을 레벨 AA 위반으로 봅니다. 그래서 크롭 프레임 전체와 각 변은 접근성 액션, 방향키, D-pad로도 움직일 수 있고, 프레임이 지금 어디에 있는지를 실시간 설명으로 알려 줍니다. CropAccessibility를 쓰면 다른 설정은 건드리지 않고 문구와 이동 간격만 바꿀 수 있습니다.
  • Baseline Profile: 안드로이드 아티팩트에 Baseline Profile이 들어 있어서, Crayfish를 사용하는 앱은 첫 실행부터 디코딩과 좌표 계산, 제스처 처리 경로가 미리 컴파일된 상태로 동작합니다.

Baseline Profile의 효과는 수치로도 확인할 수 있습니다. Android 16을 실행하는 Galaxy S23에서 조건마다 다섯 번씩 측정했더니, 첫 프레임까지의 콜드 스타트 시간은 중앙값 기준 312ms에서 262ms로 줄었고, 크롭 프레임을 드래그하는 동안의 P90 프레임 오버런(frame overrun)은 23.0ms에서 5.6ms로 줄었습니다. 프레임 오버런은 프레임이 마감 시간을 얼마나 넘겨서 그려졌는지를 나타내는 값이라, 두 번째 수치의 차이가 곧 드래그가 눈에 띄게 끊기느냐 매끄럽게 따라오느냐의 차이입니다.

Compose 화면 밖에서: Activity, Coil, Landscapist

Compose 화면에서 직접 크롭하지 않는 곳도 Crayfish로 다룰 수 있습니다.

crayfish-activity는 View나 Fragment처럼 크로퍼를 화면 안에 넣기보다 별도 화면으로 띄우는 쪽이 나은 곳에서 쓰는 모듈입니다. 계약(contract)을 한 번 등록해 두고, 자를 이미지가 생길 때마다 실행하시면 됩니다.

// Activity나 Fragment를 생성할 때 한 번만 등록합니다.
private val cropper = registerForActivityResult(CropImageContract()) { result ->
  when (result) {
    is CropImageResult.Success -> imageView.setImageURI(result.uri)
    is CropImageResult.Cancelled -> Unit
    is CropImageResult.Failure -> showError(result.reason)
  }
}

// 자를 이미지가 생기면 실행합니다.
cropper.launch(CropImageRequest(source = photoUri, aspectRatio = 1f, mask = CropMask.Circle))

매니페스트에 따로 선언할 것은 없습니다. Activity 결과가 거쳐 가는 Binder 트랜잭션에는 엄격한 크기 상한이 있어서 평범한 사진 한 장만 실어도 이를 넘깁니다. 이런 이유로 크롭 결과는 바이트가 아니라 content:// Uri로 돌아옵니다. Binder 트랜잭션 버퍼는 현재 1MB이고, 한 프로세스에서 진행 중인 모든 트랜잭션이 이 버퍼를 나눠 씁니다.

crayfish-coil과 crayfish-landscapist는 이미지 로더가 원본을 불러오는 과정에서 저장해 둔 크롭을 적용하므로, 잘라 낸 결과를 보여 주려고 파일을 하나 더 만들거나 디코딩을 한 번 더 할 일이 없습니다. Coil에서는 크롭 결과의 영역(region)으로 만든 CropTransformation을 요청에 추가합니다.

val region = result.region
val request = remember(region) {
  ImageRequest.Builder(context)
    .data(url)
    .transformations(CropTransformation(region))
    .build()
}

AsyncImage(model = request, contentDescription = null)

Landscapist에서는 같은 이름의 CropTransformation이 Landscapist의 Transformation으로 구현되어 있어서, 요청 빌더에 추가하기만 하시면 됩니다. 두 모듈 모두 CropTransformation의 캐시 키에 크롭 영역이 들어가므로, 한 이미지를 두 가지로 자르면 캐시 항목도 둘로 나뉩니다. 나중에 요청한 화면이 앞 화면의 캐시를 덮어쓰는 일이 없습니다. 여기에 CropRecipe를 함께 쓰면 비파괴(non-destructive) 방식으로 작업할 수 있습니다. 원본은 그대로 두고 레시피만 저장해 두면, 이미지를 그릴 때마다 로더가 사각형을 적용합니다.

결론

Crayfish를 앱에 처음 붙이신다면 다이얼로그부터 시작해 보세요. 프로필 사진이나 커버 이미지를 고르는 흐름은 대부분 rememberImageCropper와 when 하나로 해결되고, 크롭이 그저 suspend 함수일 뿐이라 호출하는 코드도 읽기 쉽게 유지됩니다. 직접 만든 화면 안에 크롭 기능이 들어가야 할 때는 Cropper와 CropState로 넘어가세요. 소스는 가지고 있는 것에 맞춰 고르시면 됩니다. 이미 화면에 떠 있는 이미지에는 Painter가, 통째로 디코딩하기에는 너무 큰 원본에는 파일이나 바이트, 로더가 맞습니다. 그다음의 커스터마이징은 모두 기본값이 있는 파라미터라서, 디자인이 실제로 요구하는 부분만 바꾸셔도 충분합니다.

저는 Crayfish를 Compose의 다른 API와 똑같이 동작하는 크로퍼로 만들고 싶었습니다. 상태는 끌어올려 관리하고, 컴포저블은 원하는 대로 배치하고, 결과는 곧바로 화면에 그리며, 앱이 돌아가는 모든 플랫폼에서 같은 코드를 쓰는 방식입니다. 큰 사진, 미러 방향의 Exif 값, 드래그 없이도 움직일 수 있는 프레임처럼 가장 공들인 세부 사항이야말로 크로퍼를 호출하는 쪽에서는 전혀 신경 쓰지 않아도 되어야 한다고 생각했습니다.

아티클 목록으로 가기