[Android / Compose] Compose로 그림판 구현해보기 - 1

문승연·2023년 12월 22일
0

프로젝트 개요.

예전에 Bemong을 개발할 때 직접 앱 내에 내장 그림판을 구현한다고 꽤 애를 먹었던 기억이 있다. 그 당시에는 100% Java로만 구현을 했는데 비슷한 동작을 하는 View를 새로 Compose로 다시 구현해보는 프로젝트이다.


(출처: https://hyunsun99.tistory.com/47)

아마 실제 만들어보면 생긴 건 많이 다르겠지만 위와 비슷한 기능을 가질 것이다.

구현하고자 하는 기능

  1. 사용자의 터치를 인식하여 움직임에 따라 획을 그리는 기능
  2. 획마다 다양한 색상, 브러시 등을 적용하는 기능
  3. Undo, Redo 기능 (Ctrl+Z, Ctrl+Shift+Z 기능이라고 생각하면 될 것 같다.)

1. 프로젝트 생성

깃 허브 링크
DrawingScreen 이라는 이름으로 깃허브 레포지토리를 생성한다.

그리고 AndroidStudio에서 새 프로젝트를 생성해준다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            DrawingScreenTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    Greeting("Android")
                }
            }
        }
    }
}

[New Project]를 생성할 때 [Empty Activity]로 설정해서 생성하면 위처럼 Compose 기본 세팅을 해준다.

2. DrawingScreen.kt 생성

DrawingScreen 파일을 따로 생성하고 MainActivity 에서는 DrawingScreen 만 띄우는 걸로 변경한다.

MainActivity.kt

setContent {
	DrawingScreenTheme {
    	// A surface container using the 'background' color from the theme
        Surface(
        	modifier = Modifier.fillMaxSize(),
            color = MaterialTheme.colorScheme.background
        ) {
        	DrawingScreen()
        }
    }
}

DrawingScreen.kt

@Composable
fun DrawingScreen() {
    Column(
        modifier = Modifier
            .fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        DrawingCanvas()
    }
}

3. 손가락 이동을 따라 획을 그리는 기능 구현

해당 기능을 구현하기 위해서는 먼저 CanvasModifier.pointerInput() 에 대해서 이해할 필요가 있다.

Canvas

Compose에서 맞춤 항목을 그리는 방법으로는 Modifier.drawWithContent, Modifier.drawBehind, Modifier.drawWithCache 처럼 Modifier에 내장된 함수를 활용하는 것이다.

하지만 이번에 나는 아예 그리기에 기능이 치중된 UI를 구현할 것이기 때문에 그런 경우에는 그리기를 실행하는 Composable인 Canvas를 사용할 수 있다.

Compose에서 Canvas는 기존의 Java/Kotlin 환경에서 제공하는 같은 이름의 클래스와 유사한 기능을 제공한다. 하지만 Compose를 기반으로 하고 있기 때문에 좀 더 직관적이고 간단하게 구현할 수 있다.

Canvas의 사용법은 어렵지 않다. 위와 같은 색이 칠해진 직사각형을 그리고 싶다면 아래처럼 하면 된다.

Canvas(modifier = Modifier.fillMaxSize()) {
    val canvasQuadrantSize = size / 2F
    drawRect(
        color = Color.Magenta,
        size = canvasQuadrantSize
    )
}

위에서 보듯이 CanvasdrawRect, drawOval과 같은 그리기 함수를 사용할 수 있는 DrawScope를 제공하기 때문에 무언가 그리고자 할 때는 DrawScope 블럭 내에서 구현하면 된다.

Modifier.pointerInput()

Canvas 가 있다면 도형을 그릴 수 있지만 사용자의 입력, 손 동작 등을 받아 처리하는 것은 다른 문제이다.

사용자에게 보여지는 Compose UI는 사용자와 상호작용할 수 있어야하고 이는 Modifier.pointerInput()에서 처리할 수 있다.

pointerInput()은 단순 클릭부터 시작해서 탭, 스크롤, 드래그, 스와이프, 플링, 멀티 터치 등 다양한 입력 상황을 처리할 수 있는 API를 제공한다.

이 중 나는 오늘 획을 쭉 이어서 그리는 동작에 대해 구현을 할 것이기 때문에 드래그 쪽에만 집중하겠다.

detectDragGestures

detectDragGestures는 사용자의 드래그 동작을 처리할하는 detector를 붙여 처리할 수 있게 해주는 API이다.

공식 홈페이지에서 제공하는 함수에 대한 설명을 보면 onDragStart, onDragEnd, onDragCancel 등 입력의 시작과 끝을 처리하고 onDrag 에서 드래그하면서 실시간으로 offset 값의 변화를 보여준다.

suspend fun PointerInputScope.detectDragGestures(
    onDragStart: (Offset) -> Unit = { },
    onDragEnd: () -> Unit = { },
    onDragCancel: () -> Unit = { },
    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
): Unit

이를 이용해서 사용자가 획을 쭉 이어 그릴 때마다 변화하는 offset 값들을 받아 획을 만들고 이를 Canvas에서 그려준다면 그리기 기능을 구현할 수 있다.

var point by remember { mutableStateOf(Offset.Zero) } // point 위치 추적을 위한 State
val points = remember { mutableListOf<Offset>() } // 새로 그려지는 path 표시하기 위한 points State

var path by remember { mutableStateOf(Path()) } // 새로 그려지고 있는 중인 획 State
val paths = remember { mutableStateListOf<Path>() } // 다 그려진 획 리스트 State

먼저 드래그 위치에 따라 변경되는 offset 값을 저장할 mutableStateOf 변수 point이 필요하다. 이렇게 변화한 point의 움직임을 저장할수 있는 리스트 변수 points도 선언해준다.

드래그 할때마다 실시간으로 points에 저장된 점들로 하나의 Path를 만들어 정상적으로 획이 그려지고 있음을 보여줘야하기 때문에 이를 보여줄 path 변수를 선언한다.

마지막으로 완성되어있는 path 들을 저장한 paths 리스트 변수를 선언해 리컴포지션이 일어나도 이전의 획들도 전부 보여줄 수 있게 한다.

이제 이 변수들을 이용해 구현한 Canvas의 코드는 아래와 같다.

var point by remember { mutableStateOf(Offset.Zero) } // point 위치 추적을 위한 State
val points = remember { mutableListOf<Offset>() } // 새로 그려지는 path 표시하기 위한 points State

var path by remember { mutableStateOf(Path()) } // 새로 그려지고 있는 중인 획 State
val paths = remember { mutableStateListOf<Path>() } // 다 그려진 획 리스트 State

Canvas(
	modifier = Modifier.size(360.dp)
    	.background(Color.White)
        .aspectRatio(1.0f)
        .pointerInput(Unit) {
        	detectDragGestures(
            	onDragStart = { offset ->
                	point = offset
                    points.add(point)
                },
                onDrag = { _, dragAmount ->
                	point += dragAmount
                    points.add(point)
                    // onDrag가 호출될 때마다 현재 그리는 획을 새로 보여줌
                    path = Path()
                    points.forEachIndexed { index, point ->
                    	if (index == 0) {
                        	path.moveTo(point.x, point.y)
                        } else {
                        	path.lineTo(point.x, point.y)
                        }
                    }
                },
                onDragEnd = {
                	paths.add(path)
                    points.clear()
                }
            )
        },
) {
	// 이미 완성된 획들
	paths.forEach { path ->
    	drawPath(
        	path = path,
        	color = Color.Black,
        	style = Stroke()
        )
    }
	// 현재 그려지고 있는 획
    drawPath(
    	path = path,
        color = Color.Black,
        style = Stroke()
    )
}

detectDragGestures 에서 사용자의 드래그 동작을 처리한다.
1. onDragStart: 사용자가 새로운 획을 그리기 시작함. point에 시작점을 위치를 저장한다. points 리스트에도 현재 지점값을 저장한다.
2. onDrag: 사용자가 획을 그리는 중. offset이 변화할 때마다 새로운 point 값을 points 리스트에 저장한다. 그리고 새로운 Path를 만들어 현재 그려진 부분까지 선을 이어서 그린다.
3. onDragEnd: 획 완성. 완성된 획을 paths 리스트에 저장하고 points 를 클리어한다.

그리고 CanvasDrawScope 에서는 이미 완성된 획들이 저장되어있는 paths 변수의 획들을 차례대로 그리고 이어서 현재 그려지고 있는 획도 drawPath를 통해 그려준다.

다음에는 획 되돌리기 기능을 구현해보도록 하자.

profile
"비몽(Bemong)"이라는 앱을 개발 및 운영 중인 안드로이드 개발자입니다.

0개의 댓글