본문 바로가기
안드로이드/코틀린

Glide로 이미지 stroke 만들기

by 나이아카 2024. 5. 21.

 안드로이드 개발을 하다가 이미지를 업로드하고 다운로드 하는 과정 그 어딘가에서 항상 이미지를 뷰에 뿌려주는 일들이 있는데, 대부분 이미지를 사각형으로 보여주지 않고 모서리 부분에 라운딩을 먹여 살짝 곡선으로 보여주는 디자인들이 많습니다.

 저는 디자인을 잘 모르고 토도 달지 않는 편이라 매 번 비슷한 디자인이 나올 때 마다 대충 이게 UI의 공식 같은 건가 하면서 지나갔는데요. 요즘은 라운딩에 stroke까지 포함된 부분이 더 많이 보이더라구요. 그 덕분에 시간을 많이 썼습니다. 디자이너분들은 당연히 이미지 외곽에 stroke를 뿌려주는 게 기본으로 제공되는 줄 알고 있더라구요. 뭐지, 나만 모르는 기본으로 제공되는 무언가가 있는 건가... 그런 생각도 했는데 일단은 못찾아서 직접 만들기로 했습니다. 한 번이면 모르겠는데, 너무 자주 쓰이더라구요.

 


 일단 이 글에서의 목표는, 이미지의 네 방향(상단 좌측, 상단 우측, 하단 좌측, 하단 우측)에 라운딩을 각각 먹일 수 있는 것 + 이미지의 외곽을 따라 지정된 색상의 선을 긋는 일입니다.

 

 그럼 먼저 이미지의 네 방향에 각각 라운딩을 먹이는 방법을 생각해보겠습니다. 일단 Glide 라이브러리에서 제공해주는 GranularRoundedCorners라는 BitmapTransformation이 있습니다. 이를 이용하면 네 방향의 모서리를 원하는 방식대로 깍을 수 있습니다. 네 방향 전부 다른 radius를 지닐 수 있죠. 그럼 이 코드에서 작업하는 부분에 선만 그려주면 되겠다는 생각입니다. 그래서 제가 작성한 코드는 거기서 가져온 코드를 추가 가공하는 과정을 더했습니다. 

 먼저 파라미터는 4방향의 round를 결정할 float 값과 stroke의 색상 리소스 id를 위한 Int, 그리고 이 선의 두께를 지정할 Float가 필요합니다.(추가적으로 더 필요할 수도 있겠지만 저는 여기까지만 필요했습니다)

class GranularRoundedAndBorderTransform(
    private val bottomLeft: Float,
    private val bottomRight: Float,
    private val topLeft : Float,
    private val topRight: Float,
    private val strokeWidth: Float,
    private val strokeColorResource: Int
) : BitmapTransformation()

 위처럼 파라미터를 지정했습니다. 이름은 GranularRoundedCorner에서 따왔습니다. 뭐로 할까 고민했습니다만, 저한텐 제일 직관적이더라구요.

override fun updateDiskCacheKey(messageDigest: MessageDigest) {
    val radiusData = ByteBuffer.allocate(16).putFloat(topLeft).putFloat(topRight).putFloat(
        bottomRight
    ).putFloat(bottomLeft).array()
    messageDigest.update(radiusData)
}

override fun transform(pool: BitmapPool, toTransform: Bitmap, p2: Int, p3: Int): Bitmap {
    return transformBitmap(TransformationUtils.roundedCorners(pool, toTransform, this.topLeft, this.topRight, this.bottomRight, this.bottomLeft))
}

 그 다음은 override 해줘야 하는 부분입니다. 이는 라이브러리 내부의 코드를 그대로 가져왔습니다. 여기까지 그대로 가져오면, 각 변수에 맞는 라운딩된 이미지를 얻을 수 있습니다. 이 코드에서 차이점은 transformBitmap이라는 메소드를 transform에서 return 한다는 것인데요. 이 transformBitmap 메소드는 override 메소드가 아닌, 제가 구현한 메소드입니다. 저 메소드에서 Bitmap에 선을 다듬어줄 예정입니다.

 

private fun transformBitmap(source: Bitmap): Bitmap {
    val width = source.width
    val height = source.height

    val context = RunnerBeApplication.ApplicationContext()
    val tl = topLeft.dpToPx(context)
    val tr = topRight.dpToPx(context)
    val bl = bottomLeft.dpToPx(context)
    val br = bottomRight.dpToPx(context)

    val output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(output)

    val paint = Paint().apply {
        isAntiAlias = true
        shader = BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
    }
    val path = Path().apply {
        reset()
        moveTo(0f, tl)
        quadTo(0f, 0f, tl, 0f)
        lineTo(width - tr, 0f)
        quadTo(width.toFloat(), 0f, width.toFloat(), tr)
        lineTo(width.toFloat(), height - br)
        quadTo(width.toFloat(), height.toFloat(), width - br, height.toFloat())
        lineTo(bl, height.toFloat())
        quadTo(0f, height.toFloat(), 0f, height - bl)
        close()
    }

    canvas.drawPath(path, paint)

    val borderPaint = Paint().apply {
        isAntiAlias = true
        color = ContextCompat.getColor(RunnerBeApplication.instance.applicationContext, strokeColorResource)
        style = Paint.Style.STROKE
        strokeWidth = this@GranularRoundedAndBorderTransform.strokeWidth // 원하는 테두리 두께 설정
    }
    canvas.drawPath(path, borderPaint)

    return output
}

 이렇게 정의되어 있습니다. dpToPx의 경우에는 int 값을 dp로 치환해주기 위해 넣어둔 메소드입니다(인터넷에 널려있는 코드를 가져온...). shader는 CLAMP 모드를 통해 깍여나가면서 각지게 되는 모서리 부분을 부드럽게 만들어줍니다. 부드럽게 처리된 이미지를 canvas에 먼저 그리고, Path를 통해 선을 그릴 위치를 지정합니다. Path는 borderPaint에서 설정한 값으로 Path에 지정된 라인을 따라 draw하게 됩니다.

 moveTo는 Path의 기준점을 옮기는 역할을 합니다. 그러니까 처음에는 0, topLeft(우측 상단)에서 부터 그리기를 시작하겠다는 의미입니다. quadTo는 이전 지점을 기준으로 하여(여기서는 moveTo를 한 위치), 파라미터의 첫 2개(x1, y1)와 마지막 2개(x2, y2)를 기준으로 2차 베지어 곡선을 그려주는 메소드입니다. 2차 베지어 곡선을 그리는 방법은 수학적인 문제라 대략적으로 곡선을 그려주는 것이라고 생각하면 좋을 것 같습니다.

 lineTo는 직선을 그려주는 메소드입니다. 현재 위치에서 파라미터로 받은 x(첫 번째)와 y 좌표(두 번째)까지 직선을 그어줍니다.

 총 4번의 곡선과 라인으로 라운딩된 사각형의 외곽에 선을 그어줄 수 있습니다. 이때 중요한 건 이미지와 선의 라운딩을 따로 하기 때문에 두 개의 파라미터가 달라지면 매끄럽게 떨어지지 않는 다는 것입니다. 그 부분은 주의할 필요가 있으니 잘 보면서 작업하셔야 합니다. 물론 현재 제가 만든 코드는 생각을 하긴 했는데, 다른 예외가 있을지는 모르겠네요...

 

아래는 완성된 코드입니다.

class GranularRoundedAndBorderTransform(
    private val bottomLeft: Float,
    private val bottomRight: Float,
    private val topLeft : Float,
    private val topRight: Float,
    private val strokeWidth: Float,
    private val strokeColorResource: Int
) : BitmapTransformation() {

    override fun updateDiskCacheKey(messageDigest: MessageDigest) {
        val radiusData = ByteBuffer.allocate(16).putFloat(topLeft).putFloat(topRight).putFloat(
            bottomRight
        ).putFloat(bottomLeft).array()
        messageDigest.update(radiusData)
    }

    override fun transform(pool: BitmapPool, toTransform: Bitmap, p2: Int, p3: Int): Bitmap {
        return transformBitmap(TransformationUtils.roundedCorners(pool, toTransform, this.topLeft, this.topRight, this.bottomRight, this.bottomLeft))
    }

    private fun transformBitmap(source: Bitmap): Bitmap {
        val width = source.width
        val height = source.height

        val context = RunnerBeApplication.ApplicationContext()
        val tl = topLeft.dpToPx(context)
        val tr = topRight.dpToPx(context)
        val bl = bottomLeft.dpToPx(context)
        val br = bottomRight.dpToPx(context)

        val output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
        val canvas = Canvas(output)

        val paint = Paint().apply {
            isAntiAlias = true
            shader = BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
        }
        val path = Path().apply {
            reset()
            moveTo(0f, tl)
            quadTo(0f, 0f, tl, 0f)
            lineTo(width - tr, 0f)
            quadTo(width.toFloat(), 0f, width.toFloat(), tr)
            lineTo(width.toFloat(), height - br)
            quadTo(width.toFloat(), height.toFloat(), width - br, height.toFloat())
            lineTo(bl, height.toFloat())
            quadTo(0f, height.toFloat(), 0f, height - bl)
            close()
        }

        canvas.drawPath(path, paint)

        val borderPaint = Paint().apply {
            isAntiAlias = true
            color = ContextCompat.getColor(RunnerBeApplication.instance.applicationContext, strokeColorResource)
            style = Paint.Style.STROKE
            strokeWidth = this@GranularRoundedAndBorderTransform.strokeWidth // 원하는 테두리 두께 설정
        }
        canvas.drawPath(path, borderPaint)

        return output

    }
}

 이제 이 코드를 Glide로 이미지를 호출할 때 적용해야 합니다.

Glide.with(binding.imageView)
    .load(it.imgUrl)
    .apply(
    RequestOptions()
    	.override(200.dpToPx(context), 200.dpToPx(context))
        .transform(
            CenterCrop(),
            GranularRoundedAndBorderTransform(
            	bottomLeft = 12f,
                bottomRight = 12f,
                topLeft = 12f,
                topRight = 0f,
                strokeWidth = 1f,
                strokeColorResource = R.color.white_20
            )
        )
    )
    .into(binding.imageView)

 저는 이런식으로 사용했습니다. RequestOptions로 사용했지만 꼭 그러지 않더라도 사용할 수 있는 방법은 여러가지가 있습니다. 그저 BitmapTransformation이 어디어디에서 사용될 수 있는지만 파악하면 됩니다. 마찬가지로 각종 옵션들은 자유롭게 사용해주시면 되겠습니다.


 코드를 하나씩 고쳐나가다가 문득 든 생각인데, ChatGPT가 이런 건 잘 해주지 않을까 해서 ChatGPT에 transformBitmap 코드의 로직을 말해줬더니 바로 알려주더군요. 혹시나 해서 봤는데, 제 코드보다 좀 더 깔끔해졌습니다. 그래서 삽질을 해서 코드를 다 완성 한 후에 GPT의 도움을 받아 버렸습니다.

 요즘에는 깨닫는 게 개발 생산성을 올려줄 툴들은 정말 많이 있고, 이제는 찾아서 쓰는 것도 어려운 수준이라고 생각하는데 이제는 이를 얼마나 잘 효율적으로 이용하느냐에 따라 자신의 수준을 가를 수 있다는 생각이 듭니다. 당연히 알고리즘이나 설계는 직접 하는 것이 좋겠지만, 거기까지만 가도 내부 내용은 AI가 채워주는 시대가 오지 않았나... 안타까우면서도 나쁘진 않은 것 같습니다. 이제는 노가다로 여겨지던 부분들을 직접 채울 필요가 없으니까요.

 GPT 유료버전을 구매해야하나 싶은 생각이 드는 코드였습니다. 하지만 정리해보니까 재밌네요! 요즘은 어차피 이런 코드 정리는 GPT로 가지 블로그로는 잘 안오지만... 필요하신 분들이 본다면 도움이 됐으면 하는 바람입니다.

'안드로이드 > 코틀린' 카테고리의 다른 글

EditText와 RecyclerView(list에서 EditText 사용시 주의점)  (0) 2024.10.24
Android PhotoPicker  (1) 2024.08.19
Generic 이란  (1) 2023.12.08
Android - 시스템 앱 알림 상태 확인  (0) 2023.10.25
(Kotlin) Flow - 1  (0) 2023.08.10

댓글