본문 바로가기

Android/Android Custom View

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

Unsplash, Elena Rouame.

 

현재 진행중인 사이드 프로젝트에서는 카카오 및 구글 로그인을 사용합니다.

그러므로, 이미지를 촬영 또는 디바이스에서 불러와 이를 수정 및 등록할 수 있도록 구현해야 합니다.

 

당연히 유수한 ImageCropper 라이브러리가 있지만, 앱 내에서 여러 번 사용되는 기능이 아니기에 직접 구현하기로 했습니다. 단순한 기능 하나를 위해 특정 의존성을 추가하는 행위 자체가 꺼려지기도 했고, 불필요한 패키지 사이즈의 증가는 유저로 하여금 다운로드가 꺼려질 수 있기 때문입니다. 또한  UI 와 관련한 라이브러리는 가능하면 배제하자는 것이 제 주관이기도 합니다. 

 


Tl ; DR

소스 코드는 제 깃허브에서 보실 수 있습니다.

 

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

 


SectionSelector

유저로 하여금, 이미지 중 원하는 곳만 잘라낼 수 있도록 해야하며, 그 '원하는 곳'을 선택하기 위한 시각적 가이드가 필요합니다. 

 

처음 어프로치할 때는 alpha 값이 0.5f 인 이미지를 띄워두고 그 위에 어떠한 처리를 통해 필요한 사이즈만큼 만 이미지의 alpha 값이 적용되지 않게 하려고 했으나, 아무리 찾아보아도 방법이 없어 어프로치 자체가 잘못되었다는 생각이 들었습니다.

 

그래서 생각해낸 것이 이미지와 SectionSelector 를 완벽하게 구분해야 한다는 것이었습니다.

이미지를 깔아주고, 그 위에 이미지 사이즈와 같은 레이어를 띄웁니다. 해당 레이어에는 원을 하나 그리고, clipPath() 메서드의 파라미터인 ClipOp.Difference 를 활용하여 원을 제외한 모든 부분을 어둡게 처리합니다.

 

이는 사이즈 조절 및 이동이 가능하여야하므로, Canvas 의 modifier 에 detectDragGestures 를 정의해주었습니다.

 

Canvas(modifier = Modifier
    .fillMaxSize()
    .pointerInput(Unit) {
        detectDragGestures { change, dragAmount ->
            change.consumeAllChanges()
            offsetX.value += dragAmount.x
            offsetY.value += dragAmount.y
        }
    }
    .transformable(state = state), onDraw = {
    val circlePath = Path().apply {
        addOval(
            Rect(
                if (!movedState.value) {
                    offsetX.value = center.x
                    offsetY.value = center.y
                    movedState.value = true
                    center
                } else {
                    Offset(offsetX.value, offsetY.value)
                }, sizeState.value
            )
        )
    }

    clipPath(circlePath, clipOp = ClipOp.Difference) {
        drawRect(brush = SolidColor(Color(0x88000000))) // 어두운 배경 처리
    }
})

 

 


ImageCropper

앱에서는 어떻게 적용될지 모르겠으나, 해당 기능을 미리 구현해두는 과정 중에서는 이미지의 크기를 16 : 9 또는 9 : 16 으로 제한하였습니다.

 

이미지 사이즈를 미리 지정한 크기에 맞추어 변형해줍니다. 해당 작업을 위해서는 Bitmap 클래스의 createScaledBitmap() 메서드가 필요합니다.

 

    public static Bitmap createScaledBitmap(@NonNull Bitmap src, int dstWidth, int dstHeight,
            boolean filter)

 

해당 메서드는 특정 Bitmap 을 사용자 지정 사이즈로 변형시켜줍니다. 파라미터에 직접 지정해주어야 하는 width, height 가 원본과 같으면 그냥 원본이 반환되고, 다르다면 새로운 비트맵을 생성합니다. 이 때, 지정한 width, height 파라미터에 따라 해상도가 변경됩니다. 즉, Bitmap 이기 때문에 해상도가 변경되면 화질 또한 변경될 수 있다는 것입니다.

 

filter 파라미터의 값을 true 로 넘길 경우, 성능 부하는 커지지만 이미지 품질이 향상됩니다. 권장 기본값은 true 입니다.

 

해당 이미지 위에 미리 구현해 둔 SectionSelector 를 띄워줍니다. 이 때, 이미지의 사이즈를 구하여 SectionSelector 의 파라미터로 넘겨주면서, SectionSelector 의 내부에는 해당 파라미터를 통해 원의 움직임 및 확대를 제한하는 기능도 구현해두었습니다.

 


잘라내기

잘라내는 행위를 구현하기 위해서 처음 어프로치에는 이미지나 화면의 특정 부분만큼을 원형으로 가져올 방법이 없을까라는 생각이 지배적이었습니다. 꽤 오랜 시간 찾아보았으나 적당한 답안이 도출되지 않아서, 

Scaled 된 Bitmap 에서 원하는 부분을 사각형으로 자르고, 보여줄 때는 원형으로 보여주기로 결론 지었습니다.

 

화면에 그려지는 Bitmap 의 단위는 기본적으로 Pixel 입니다. 하지만 우리는 UI 를 구현할 때 Pixel 이나 Offset 보단 DP 를 사용하는 경향이 강합니다. 보다 직관적이고, 정수값이므로 사용하기에도 군더더기가 없기 때문입니다.

 

해당 작업을 진행하면서 가장 난처했던 것은 도무지 통일시킬 수 없는 Pixel 과 Offset, DP 의 조합이었습니다. 이 부분을 해결하기 위한 과정 중 제가 알게 된 사실은 다음과 같습니다.

 

- 1 Pixel == 1 Offset

- Pixel 은 절대값, Offset 은 비절대값

- Pixel 과 DP 는 서로 변환이 가능

 

Pixel 과 Offset은 기본적으로 치수가 같습니다. 즉, 화면을 움직이거나 스크롤링하는 경우 등에 Offset 이라는 키워드가 자주 등장하는데, 이 Offset 값은 결국 Pixel 값입니다. 그래서 원의 활동을 제한하거나 이미지의 크기를 결정하거나 하는 행위에 Offset 이 포함되어도 겁먹을 필요가 없습니다. Offset 은 Pixel 과 같고 Pixel 은 DP 로 변환이 가능하기 때문입니다.

 

이미지를 잘라내는 데에는 Bitmap 클래스의 createBitmap() 메서드가 사용됩니다.

 

public static Bitmap createBitmap(@NonNull Bitmap source, int x, int y, int width, int height)

 

해당 메서드는 createScaledBitmap() 과는 다르게, Bitmap 에 대한 어떠한 변형도 하지 않습니다. 밀도와 색상 모든 것이 그대로입니다. 파라미터로 전달된 좌측 상단 좌표에 따라 width, height 값만큼 사각형 Bitmap 을 만들어 반환합니다.

 

다음은 간단합니다. 반환된 비트맵을 원형으로 보여주기만 하면 되겠습니다.

 

 

기존 내장된 이미지는 초고화질 이미지입니다. 하지만 이를 Bitmap.createScaledBitmap() 메서드를 통해 작은 사이즈의 새로운 Bitmap 을 생성하였으므로 원을 작게하고 Crop 하면 저화질로 보이는 것입니다. 만약 화질을 그대로 유지하고 싶다면 createScaledBitmap() 메서드를 통해 이미지를 보여주고, 실제로 Crop 하는 것은 원본 이미지에서 Crop 하여야 할 것입니다.

 

그렇게 되면 SectionSelector 의 위치값에 따라 대응되는 원본 이미지 위치값이 상이할 수 있는데, 이 부분은 Pixel 이나 Offset 이 아닌 비율 개념을 접목시켜 진행하여야 할 것 같습니다. 기회가 된다면 이와 같은 스펙으로 비슷하게 하나 더 만들어 보아도 좋을 것 같다는 생각이 듭니다.


Image, Drawable, Bitmap 을 사용하는 것은 사실 쉽습니다. 메서드를 보고 적절한 파라미터만 넘겨주면 되니 말입니다. 하지만 그 기저부를 파악하고 이를 활용하여 직접 어떠한 기능을 구현하는 것은 마냥 쉬운 것 같지만은 않습니다. 저는 이번 구현을 통해 Pixel 과 Offset, Modifier 의 detectDragGestures, Bitmap 에 관한 이해를 얻을 수 있었습니다.