본문 바로가기

Android/지식저장소

[Android] 간단한 그리기 및 지우기 with S-Pen

스타일러스펜(SPen)으로 드로잉하는거 대충 테스트해보다가 기억할겸 정리한다. 설명은 간단히 주석으로 달았다.

팜리젝션을 위해 일반 터치 이벤트는 무시하고 SPen 만 인식하도록 구현했다.
터치 이벤트가 발생한 좌표에 그리기, 펜에 있는 버튼을 누른 채 문지르면 지워진다. (획단위 아님)

풀 코드
class DrawingView(context: Context, attributeSet: AttributeSet) : View(context) {

    init {
        // 아래와 같이 지정해주지 않으면 지우개모드에서 이상한 검정색 원이 생긴다.
        // 참고로 기본 동작은 LAYER_TYPE_SOFTWARE 인데 자세한 내용은 찾아봐야한다.
        setLayerType(FrameLayout.LAYER_TYPE_HARDWARE, null)
    }

    companion object {
        private const val ERASER_SIZE = 20F
        private const val TOUCH_TOLERANCE = 2f

        // event.buttonState == MotionEvent.BUTTON_STYLUS_PRIMARY 일 때 각 액션이 아래로 치환되어 내려온다.
        // 이유는 모르겠다.
        private const val SPEN_ACTION_DOWN = 211
        private const val SPEN_ACTION_UP = 212
        private const val SPEN_ACTION_MOVE = 213
    }

    private var strokePoint = PointF(0F, 0F)

    private val strokePath = Path()

    // 드로잉 펜 설정 
    private val strokePaint = Paint().apply {
        isAntiAlias = true
        isDither = true
        style = Paint.Style.STROKE
        strokeJoin = Paint.Join.ROUND
        strokeCap = Paint.Cap.ROUND
        strokeWidth = convertDpToPixel(2F)
    }

    // 지우개 모드일 때 지워지는 영역을 표시하기 위한 설정
    private val eraserCirclePaint = Paint().apply {
        isAntiAlias = true
        style = Paint.Style.STROKE
        strokeWidth = convertDpToPixel(1F)
    }

    // 지우개 설정
    private val eraserPaint = Paint().apply {
        isAntiAlias = true
        xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
    }

    private var isMoving = false
    private var isErasing = false

    private var lastEraserPositionX = 0F
    private var lastEraserPositionY = 0F

    private var scribeCanvasBitmap: Bitmap? = null

    private lateinit var scribeCanvas: Canvas

    private var canvasWidth = -1
    private var canvasHeight = -1

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)

        canvasWidth = w
        canvasHeight = h

        scribeCanvasBitmap = Bitmap.createBitmap(canvasWidth, canvasHeight, Bitmap.Config.ARGB_8888)

        scribeCanvasBitmap?.let {
            scribeCanvas = Canvas(it)
        }
    }

    override fun onDraw(canvas: Canvas?) {
        // 그린 path 를 비트맵 객체에 중간 저장, 다시 캔버스에 그리는 부분이라고 보면 된다.
        scribeCanvasBitmap?.let { canvas?.drawBitmap(it, 0F, 0F, strokePaint) }

        canvas?.drawPath(strokePath, if (isErasing) eraserPaint else strokePaint)

        if (isErasing && isMoving) {
            canvas?.drawCircle(lastEraserPositionX, lastEraserPositionY, ERASER_SIZE, eraserCirclePaint)
        }
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        if (event == null) return false

        // 스타일러스 펜 터치가 아닐 경우 터치 이벤트를 처리하지 않는다.
        // 아마도 멀티 터치일 경우 무조건 0으로 하면 안될 것
        if (event.getToolType(0) != MotionEvent.TOOL_TYPE_STYLUS) return false

        val touchX = event.x
        val touchY = event.y

        if (event.buttonState == MotionEvent.BUTTON_STYLUS_PRIMARY) {
            // 화면에서 손을 떼지 않는 이상 드로잉/지우개 모드 전환을 하지 않을 것(대부분 앱이 그렇게 동작하길래..)
            if (!isMoving) {
                isErasing = !isErasing
            }
        }

        when (event.action) {
            MotionEvent.ACTION_DOWN, SPEN_ACTION_DOWN -> {
                isMoving = true

                strokePath.reset()
                strokePath.moveTo(touchX, touchY)

                if (isErasing) {
                    scribeCanvas.drawCircle(touchX, touchY, ERASER_SIZE, eraserPaint)

                    lastEraserPositionX = touchX
                    lastEraserPositionY = touchY
                } else {
                    strokePoint = PointF(touchX, touchY)
                }

                invalidate()
            }

            MotionEvent.ACTION_MOVE, SPEN_ACTION_MOVE -> {
                if (isErasing) {
                    strokePath.addCircle(touchX, touchY, ERASER_SIZE, Path.Direction.CW)

                    lastEraserPositionX = touchX
                    lastEraserPositionY = touchY
                } else {
                    val dx = abs(touchX - strokePoint.x)
                    val dy = abs(touchY - strokePoint.y)

                    if (dx >= TOUCH_TOLERANCE || dy >= TOUCH_TOLERANCE) {
                        strokePath.quadTo(
                            strokePoint.x,
                            strokePoint.y,
                            (touchX + strokePoint.x) / 2,
                            (touchY + strokePoint.y) / 2
                        )

                        strokePoint = PointF(touchX, touchY)
                    }
                }

                invalidate()
            }

            MotionEvent.ACTION_UP, SPEN_ACTION_UP -> {
                if (isErasing) {
                    scribeCanvas.drawPath(strokePath, eraserPaint)
                } else {
                    scribeCanvas.drawPath(strokePath, strokePaint)
                }

                isMoving = false
                isErasing = false

                lastEraserPositionX = 0F
                lastEraserPositionY = 0F

                strokePath.reset()

                invalidate()
            }
        }

        return true
    }

    private fun convertDpToPixel(dp: Float): Float {
        return if (context != null) {
            val resources = context.resources
            val metrics = resources.displayMetrics

            dp * (metrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)
        } else {
            val metrics = Resources.getSystem().displayMetrics

            dp * (metrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)
        }
    }

}


추가로 했던 삽질 공유 ㅠㅠ

펜 버튼을 누른 상태에서 지우다가(ACTION_MOVE 유지), 버튼 떼고 계속 문지르면 지우개 모양 잔상이 남는다(ACTION_UP 처리가 안된다). 액션을 찍어보니 event.buttonState == MotionEvent.BUTTON_STYLUS_PRIMARY 는 false 인데도, event.action 이 원래 값으로 내려오지 않기 때문이다.
결론은 event.buttonStateMotionEvent.action 값에 영향을 끼치는데, 화면에서 손을 떼지 않은 경우 buttonState 가 아무리 변경되어도 영향을 끼친 그 상태를 유지한다.

참고