Android/Android Custom View

[Jetpack Compose] ImageCropper 라이브러리 없이 구현하기 Part II

jyotti 2023. 12. 1. 02:30

Unsplash, Pine Watt.

동기

비슷한 포스트를 이전에도 작성한 일이 있습니다. 그 당시에도 프로젝트에 들어갔던 코드를 활용해서 포스팅 했었는데, 당시엔 Jetpack Compose 가 그다지 익숙치 않기도 했고, 애초에 개발에 대한 인사이트도 그리 훌륭하진 않았습니다. 이번 네이버 부스트캠프 그룹 프로젝트에서도 비슷한 기능을 구현하게 되었는데, 이전과 조금은 다른 점이 있기에, 기록을 통해 다시 한 번 학습하고자 합니다.

 


이전엔 어땠는가

 

[Jetpack Compose] ImageCropper 라이브러리 없이 구현하기

현재 진행중인 사이드 프로젝트에서는 카카오 및 구글 로그인을 사용합니다. 그러므로, 이미지를 촬영 또는 디바이스에서 불러와 이를 수정 및 등록할 수 있도록 구현해야 합니다. 당연히 유수

blothhundr.tistory.com

 

이전에 작성했던 포스트입니다. 내용을 보시면 아시겠지만, 다소 간단한 로직으로 이루어져 있으며, 원본 사진에 대한 조작도 불가능합니다. 원본 사진을 조작하지 못하게 했던 건, 원본 사진을 조작해버리면 원의 위치에 따른 색상 값을 가져오기 어려워지기 때문입니다.

 

물론 그 정도로도 이미지의 특정 영역을 원하는 대로 선택하고 사용할 수는 있지만, 다소 사용성이 떨어집니다. 또한, 시중에 나와있는 대부분의 앱들은 원본 사진에 대한 조작도 지원을 하고요. 

 

그래서, 현재 진행 중인 프로젝트에 지난 코드를 그대로 사용할까 하다가, '조금 더 사용성을 개선해보자' 라는 생각으로 다시금 작성하게 되었습니다.

 


어떻게 구현하였는가

구현 과정을 따라가며, 중요한 부분의 코드를 첨부하고 설명하도록 합니다.

원본 이미지 조작 

.pointerInput(Unit) {
    detectTransformGestures { _, offset, zoom, rotation ->
        event(SetImageOffset(offset))
        event(SetImageScale(zoom))
        event(SetImageRotation(rotation))
    }
}

 

가장 먼저, 원본 이미지를 조작할 수 있도록 하여야합니다. 저는 이미지가 보여지는 프레임에 대한 조작을 통해 이미지를 조작하고 싶었습니다.

 

Modifier.pointerInput() 은 Composable 에 대한 유저의 직접적인 조작으로 값을 반환합니다. 꽤 다양한 메서드를 이용할 수 있는데, 해당 구현에서는 detectTransformGestures { } 를 사용했습니다. offset, zoom, rotation 이 모두 필요했기 때문입니다. 해당 프로젝트는 MVI Architecture 를 채택했기 때문에, 변화되는 값을 ViewModel 로 넘겨줍니다.

 

offset 은 움직인 거리를 x, y 값을 포함한 Offset 객체로 반환하기 때문에, 현재 xy 위치에 offset 에 포함된 값을 더해주면 됩니다. zoom 은 확대된 비율을 의미하므로, 기존의 값에 넘겨준 값을 곱해주면 되겠습니다. rotation 은 회전된 값이며, 퍼센테이지 개념이 아니기 때문에 기존의 값에 더해주기만 하면 됩니다.

 

AsyncImage(
    modifier = Modifier
        .fillMaxSize()
        .absoluteOffset(
            x = imageState.offset.x.pxToDp(),
            y = imageState.offset.y.pxToDp()
        )
        .graphicsLayer(
            scaleX = imageState.scale,
            scaleY = imageState.scale,
            rotationZ = imageState.rotation
        ),
    model = image,
    contentScale = ContentScale.Fit,
    contentDescription = null
)

 

Coil 을 사용하고 있기 때문에 AsyncImage 를 사용했습니다만, 넘겨진 값을 통한 조작은 모두 Modifier 에서 이루어지기 때문에 큰 문제는 없습니다.

 

absoluteOffset() 은 말 그대로 절대 오프셋 값입니다. 특정한 Box, Column, Row 내에 위치한 Composable 이더라도 x, y 값에 따라 얼마든지 화면 밖으로 벗어날 수 있습니다.

 

조금 의아한 점은, 대부분의 OffsetPixel 과 같은 개념으로 간주되어 Float 으로 취급되게끔 되어 있는데, absoluteOffset() 은 이를 Dp 값으로 받습니다. 그러므로, Pixel 을 Dp 값으로 변환해줘야 합니다. 이를 위해 다음과 같은 함수를 선언하여 사용합니다.

 

@Composable
fun Float.pxToDp() = with(LocalDensity.current) {
    roundToInt().toDp()
}

 

이후 실행되는 graphicsLayer() 는 Composable 의 크기(물론 Modifier.size() 를 통해서도 제어가 가능하지만), 투명도, 회전 등 다양한 그래픽적 편집을 수행할 수 있도록 합니다.

 

이번 구현에 사용한 rotationZ 만 조금 크게 표기하였습니다. rotationX, rotationY 값도 수정할 수 있도록 만들면 더욱 재미있는 구현을 할 수 있게 됩니다. 이에 관해 학습하고 활용하는 Composable 을 만들어 보는 것도 좋겠네요.

SectionSelector

이미지에서 잘라내고 싶은 위치를 결정하는 원입니다. 이는 이전과 비슷하게 Canvas Composable 을 통해 구현하였으며, 이 역시 원본 이미지와 마찬가지로 이동, 확대가 가능합니다. 원형이라 회전은 의미가 없고요. 그러므로, 이전과 같이 Modifier.pointerInpit() 을 통해 처리할 수 있습니다.

 

.pointerInput(Unit) {
    detectTransformGestures { _, offset, zoom, _ ->
        event(SetSectionSelectorOffsetX(offset.x))
        event(SetSectionSelectorOffsetY(offset.y))
        event(SetSectionSelectorSize(zoom))
    }
}


rotation 과 관련된 부분 외에도 약간 다른 부분이 있는데요. 바로 Offset 을 한 번에 처리하지 않고, 와 y 에 대한 이벤트를 각각 보낸다는 점입니다. 이유는 다음과 같습니다.


이와 같이 SectionSelector 가 화면 끝에 닿는 경우(위 그림의 경우엔 y 가 닿는), 지정하는 조건에 따라 조작이 안 될 수 있습니다. 이 때, 나머지 한 축에 대한 조작이 작동이 가능하도록 하기 위함입니다. 즉, 각자의 조건에 따라 x 축과 y 축이 따로 조작되도록 구현하였습니다.

Offset 값은 원의 좌측 상단을 의미하기 때문에, 단순히 Offset 을 그대로 사용하면 원을 확대 및 축소하는 경우 좌측 상단을 기준으로 확대되고 줄어듭니다. 예상되지 않는 형태로 동작한다고 판단하여, 실제 Offset 에 원 사이즈의 일부만큼 감소시키고 사용하여 중앙을 기준으로 확대되고 축소되도록 구현하였습니다.

 

비트맵 반환하기

원본 이미지를 조작하는 것이 사용성 측면에선 좋지만, 기능을 구현하는 입장에서 그다지 좋은 것은 아닙니다. 원본 이미지가 그대로 있는다면 원의 위치만 확인해서 원본 이미지의 비트맵에 접근하면 구현이 완료되는데, 원본 이미지를 조작한다면 그렇지 못하기 때문입니다.

 

/**
 * Provides a mechanisms to issue pixel copy requests to allow for copy
 * operations from {@link Surface} to {@link Bitmap}
 */
public final class PixelCopy {


이를 해결하기 위해 PixelCopy 클래스를 이용하기로 결정했습니다. 보통은 캡처에 사용하는 것으로 알고 있는데, 이러한 방식으로도 사용이 가능하다 정도만 알아주시면 좋겠습니다. 해당 클래스에는 수많은 request() 메서드가 오버로딩 되어 있고, 저는 이 중 source 로 Window 를 넘겨 받는 메서드를 사용합니다.

 

public static void request(@NonNull Window source, @Nullable Rect srcRect,
        @NonNull Bitmap dest, @NonNull OnPixelCopyFinishedListener listener,
        @NonNull Handler listenerThread) {
    validateBitmapDest(dest);
    final Rect insets = new Rect();
    final Surface surface = sourceForWindow(source, insets);
    request(surface, adjustSourceRectForInsets(insets, srcRect), dest, listener,
            listenerThread);
}


Window 위에 그려지는 제공된 Rect 의 픽셀 복사본을 요청하는 메서드입니다. 

메서드 본문을 보면, 픽셀 복사본을 담을 Bitmap 에 대한 Validation 을 수행하고, 넘겨 준 WindowSuface 객체로 변환한 뒤 요청을 보냅니다.

이후 실행되는 request() 메서드의 내부에서는 HardwareRenderer 를 통해 복사 작업을 실시합니다. HardwareRenderer copySurfaceInto() 를 실행하면, 네이티브 메서드인 nCopySurfaceInto() 메서드가 호출됩니다. 이를 통해 복사 작업이 완료되면 콜백 메소드를 호출하여 비트맵을 반환 받을 수 있고요.

이 때, PixelCopy.request() 메서드를 통해 화면의 특정 부분의 픽셀을 복사하면 지정한 위치보다 약간 아래로 획득됩니다. 이유는 안드로이드의 StatueBar 까지 DecorView 에 포함되기 때문인데, 이 역시 계산에 포함시켜야 합니다.

이후에는 각 데이터를 Composable 에 연결해주는 작업만 수행하면 완성입니다.
전체 코드는 깃허브에서 확인하실 수 있습니다.

 

 

GitHub - jangjh123/Jetpack-Compose-CustomView: Jetpack Compose 를 활용한 CustomView 기록용 Repository

Jetpack Compose 를 활용한 CustomView 기록용 Repository. Contribute to jangjh123/Jetpack-Compose-CustomView development by creating an account on GitHub.

github.com

 

구조가 직관적이지 않을 수 있는데, Modifier.pointerInput() 메서드에서 생산되는 값이 각 컴포넌트에 어떠한 방식으로 적용되는지만 파악한다면 이해하기 쉬우실 겁니다.


결과물

 

원하는 조작에 따라 원본 이미지와 SectionSelector 를 자유롭게 조절할 수 있고, 자르기 텍스트를 터치하면 원에 맞춰 비트맵을 획득할 수 있습니다.


사실 우리가 구현하려고 하는 대부분의 기능은 보통 100이면 100 라이브러리가 있을 겁니다. 그래서 라이브러리를 사용한다면, 이와 같은 기능을 구현하는데에 길어봐야 10분, 20분일 겁니다. 생산성에서 꽤 큰 차이가 나게 되지요.

 

그럼에도 불구하고 직접 기능을 구현하는 건, 기능을 구현하면서 얻게 되는 지식의 양이 꽤 되고, 그 지식들이 모여 다른 기능을 개발하거나 라이브러리가 없는 경우에 대한 어프로치가 쉬워지기 때문이겠지요. 패키지 크기가 줄어드는 것도 하나의 장점이 될 수 있고요.

 

시간적 여유가 되고 여력이 닿는다면, 앞으로도 이렇게 직접 구현하며 학습하고 싶습니다.