구멍 뚫린 뷰 만들기

hole-in-a-view.png

완성된 모습

들어가며

QR코드 스캔 앱들을 보면 가운데만 화면 중앙부를 제외한 곳은 어둡게 처리된 것을 볼 수 있다. 스캐닝 앱의 필수요소 같은 것이다.

지금 작업중인 프로젝트에서 대기표를 스캔하는 기능을 추가하게 되었는데, 저 구멍뚫힌 뷰(앞으로는 프레임이라 할게요)가 없으니 영 허전하였다.

그래서 만들기로 마음은 먹었는데, 가운데만 뚫힌 경우라 평소에 자주 볼 수 있는 모습은 아니었다.

어떻게 구현할까

궁극적으로는 ViewonDraw 메소드를 override하면 뷰에 관해서는 거의 모든 일을 할 수 있다. 다만 뷰를 직접 그리는 것에 거부감이 있어 더 간단한 방법이 없나 하고 찾아 보았는데… 없었다.

아무튼 그래서 onDraw에다가 구멍 뚫는 코드를 집어넣게 됐다.

아이디어

안드로이드에서 화면에 무언가를 그리는 행위는 CanvasPaint로 그리는 행위라고 할 수 있다. Paint에는 사각형이 들어갈 수도, 직선이 들어갈 수도 있다.

그런데, 그리는 것 말고 지우는 것도 할 수 있다! PorterDuff 모드를 clear로 설정해주면 된다. 요렇게:

private val eraser = Paint().apply {
    isAntiAlias = true
    xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
}

“Porter Duff”자체는 토마스 포터 (Thomas Porter)와 톰 더프 (Tom Duff)가 쓴 종이의 이름을 딴 알파 합성 기술 입니다.

PorterDuff는 이미지 두 개를 겹쳐 블렌딩하는 기술이다. srcdst 두 개의 이미지에 대한 이런저런 합성을 할 수 있는데, PorterDuff.Mode.CLEAR는 그냥 지워버리는 옵션이다(Destination pixels covered by the source are cleared to 0).

onDraw

자 그럼 구현을 해보자. 뷰에 구멍도 뚫고, border도 그릴 것이다.

시작은 간단하게 만들었다.

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)

    drawBorder(canvas)
    drawHole(canvas)
}

drawBorderdrawHole에게 할 일을 떠맡겼다. drawBorder부터 보자.

private fun drawBorder(canvas: Canvas) {
    path.rewind()
    path.addRoundRect(
        rect.apply {
            setRect(4f/*border width*/)
        },
        holeBorderRadius,
        holeBorderRadius,
        Path.Direction.CW
    )

    canvas.drawPath(path, stroke)
}

이 메소드는 path를 준비한 뒤, canvas에 path를 슥삭한다. path는 속성으로 선언해 놓았다:

private val path = Path()

onDraw 안에서 객체 생성 하지 말라고 warning이 뜬다. 그래서 속성으로 두고 재활용했다.

path.rewind()로 내용을 비우고, path.addRoundRect()로 사각 border를 추가한다. 뭔가 인자가 많다. 원형을 보자.

public void addRoundRect(@NonNull RectF rect, float rx, float ry, @NonNull Direction dir)

첫째 인자는 추가할 사각형, 다음 두개는 각각 x-radius와 y-radius, 마지막은 path를 그릴 방향이다. 방향이라는게 뭔가 해서 찾아보니 주석에 이렇게 써 있다.

/**
 * Specifies how closed shapes (e.g. rects, ovals) are oriented when they
 * are added to a path.
 */

딱히 중요하지 않은 듯 싶으니 CW(시계방향)으로 주기로 했다.

다시 돌아와서, 첫 번째 인자부터 보자.

rect.apply {
    setRect(4f/*border width*/)
},

rect가 등장한다. 이 녀석도 위의 path와 같이 속성으로 두었다.

private val rect = RectF()

addRoundRect를 호출하기 전에, rect에 대해 setRect()를 호출한다. 이 메소드는 rect 의 위치와 크기를 적절하게 맞춰 준다.

private fun setRect(offset: Float = 0f) {
    rect.set(
        ((width - holeWidth)/2) - offset,
        ((height - holeHeight)/2) - offset,
        ((width - holeWidth)/2 + holeWidth) + offset,
        ((height - holeHeight)/2 + holeHeight) + offset
    )
}

이렇게 생겼다. rect.set()에 인자를 4개 넘겨주는데, 각각 left, top, right, bottom이다. rect를 화면 중앙에 위치한 holeWidth X holeHeight 사각형으로 설정한다. holeWidthholeHeightAttributeSet으로부터 넘겨받은 속성이다.

인자로 offset을 넘겨줄 수 있는데, 양수이면 사각형이 커지고 음수이면 작아진다. offset은 각 변에 적용된다.

두번째와 세번째 인자로 들어가는 holeBorderRadius 또한 xml에 지정한 AttributeSet을 통해 받아 속성으로 가져다 놓은 값이다.

네번째 인자는 아까 언급한대로 CW로 지정했다.

여기까지 path를 준비하는 과정이 끝났다. 이제 이걸 가지고 canvas에 그린다.

canvas.drawPath(path, stroke)

stoke은 이렇게 생겼다:

private val stroke = Paint().apply {
    isAntiAlias = true
    strokeWidth = 4f
    color = Color.WHITE
    style = Paint.Style.STROKE
}

두께 4.0짜리 하얀 붓(?)이다.

drawPath까지 끝나면 화면 중앙에 두께 4, holeBorderRadius 둥글기와 holeWidth, holeHeight 크기를 가지는 네모 윤곽선이 그려진다.

다음은 drawHole 호출이다. 구현은 아래와 같다:

private fun drawHole(canvas: Canvas) {
    canvas.drawRoundRect(
        rect.apply {
            setRect()
        },
        holeBorderRadius, holeBorderRadius, eraser
    )
}

여기서는 위에서 border를 그릴 때와는 다르게, canvas.drawPath가 아닌 canvas.drawRoundRect를 호출한다. 인자는 path에다가 사각형을 추가할 때와 거의 비슷하다. 다만 여기에서는 setRect()에 아무 인자도 넘겨주지 않았다.

사각형 path를 설정할 때에는 setRect()offset 인자로 4f를 넘겨 주었다. 이는 border의 크기를 border 두께만큼 크게 만듦으로써(border의 두께는 안쪽으로 자란다) 구멍 뚫린 영역을 침범하지 않게 하기 위함이었다.

마치며

Canvas를 직접 다루게 될 줄이야…

References

  • https://sodocumentation.net/ko/android/topic/377/porterduff-모드

댓글