예전에 Bemong을 개발할 때 직접 앱 내에 내장 그림판을 구현한다고 꽤 애를 먹었던 기억이 있다. 그 당시에는 100% Java로만 구현을 했는데 비슷한 동작을 하는 View를 새로 Compose로 다시 구현해보는 프로젝트이다.
(출처: https://hyunsun99.tistory.com/47)
아마 실제 만들어보면 생긴 건 많이 다르겠지만 위와 비슷한 기능을 가질 것이다.
깃 허브 링크
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 기본 세팅을 해준다.
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()
}
}
해당 기능을 구현하기 위해서는 먼저 Canvas
와 Modifier.pointerInput()
에 대해서 이해할 필요가 있다.
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
)
}
위에서 보듯이 Canvas
는 drawRect
, drawOval
과 같은 그리기 함수를 사용할 수 있는 DrawScope
를 제공하기 때문에 무언가 그리고자 할 때는 DrawScope
블럭 내에서 구현하면 된다.
Canvas
가 있다면 도형을 그릴 수 있지만 사용자의 입력, 손 동작 등을 받아 처리하는 것은 다른 문제이다.
사용자에게 보여지는 Compose UI는 사용자와 상호작용할 수 있어야하고 이는 Modifier.pointerInput()
에서 처리할 수 있다.
pointerInput()
은 단순 클릭부터 시작해서 탭, 스크롤, 드래그, 스와이프, 플링, 멀티 터치 등 다양한 입력 상황을 처리할 수 있는 API를 제공한다.
이 중 나는 오늘 획을 쭉 이어서 그리는 동작에 대해 구현을 할 것이기 때문에 드래그 쪽에만 집중하겠다.
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
를 클리어한다.
그리고 Canvas
의 DrawScope
에서는 이미 완성된 획들이 저장되어있는 paths
변수의 획들을 차례대로 그리고 이어서 현재 그려지고 있는 획도 drawPath
를 통해 그려준다.
다음에는 획 되돌리기 기능을 구현해보도록 하자.