완성된 전체 코드가 필요하다면 Github 링크를 참고해주세요.
그림을 그리다보면 획을 잘못 그어서 이전 화면으로 돌아가고 싶거나 반대로 지웠던 획을 다시 불러오고 싶을 때가 있다. 각각 PC 환경에서는 Ctrl+Z
, Ctrl+Shift+Z
단축키를 눌러 사용할 수 있지만 모바일 환경에서는 그럴 수 없으니 직접 구현해보도록 하자.
해당 기능을 구현하기에 앞서 이전 코드에서 수정해야할 부분이 있다.
Canvas(
modifier = Modifier.size(360.dp)
.background(Color.White)
.aspectRatio(1.0f)
.pointerInput(Unit) {
detectDragGestures(
...
onDragEnd = {
paths.add(Pair(path, pathStyle.copy()))
points.clear()
path = Path() // 이 부분을 추가해줘야한다.
}
)
},
) {
....
}
onDragEnd
에서 path
를 초기화를 해주지 않으면 드래그가 끝나도 path
그리는 과정이 아직 끝나지 않은 것으로 인식해 Undo
버튼을 눌렀을 때 가장 최근에 그린 획이 지워지지 않고 그 이전 획부터 지워지는 현상이 발생한다. 따라서 해당 부분을 추가해 path
를 초기화를 해주자.
이제 Undo, Redo 기능을 구현할 준비는 끝났다.
@Composable
fun DrawingCanvas() {
...
// Undo, Redo 버튼
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
DrawingUndoButton {
// Undo
}
Spacer(modifier = Modifier.width(24.dp))
DrawingRedoButton {
// Redo
}
}
}
@Composable
fun DrawingUndoButton(
onClick: () -> Unit
) {
Button(onClick = { onClick() }) {
Text(text = "Undo")
}
}
@Composable
fun DrawingRedoButton(
onClick: () -> Unit
) {
Button(onClick = { onClick() }) {
Text(text = "Redo")
}
}
Undo
기능을 구현하는 것은 간단하다. 버튼을 누를 때마다 paths
안에 저장되어 있는 획들을 가장 최근 거부터 하나씩 제거해주면 된다.
이 때 Redo
기능으로 제거했던 획을 다시 불러와야할 수도 있기 때문에 paths
에서 제거된 획을 따로 저장할 removedPaths
리스트를 만들고 paths
에서 가장 최근 획부터 제거해 해당 리스트에 저장하도록 하면 된다.
removedPaths
리스트를 만들고 paths
에서 가장 최근 획부터 제거하면서 removedPaths
에 저장한다.val removedPaths = remember { mutableStateListOf<Pair<Path, PathStyle>>() }
// Undo, Redo 버튼
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
DrawingUndoButton {
if (paths.isEmpty()) return@DrawingUndoButton
// Undo
val lastPath = paths.removeLast()
removedPaths.add(lastPath)
}
...
}
Redo
기능도 같은 방식으로 구현하면 된다.
Redo
기능은 removedPaths
에서 가장 최근 획부터 제거해 paths
리스트에 저장한다.DrawingRedoButton {
if (removedPaths.isEmpty()) return@DrawingRedoButton
// Redo
val lastRemovedPath = removedPaths.removeLast()
paths.add(lastRemovedPath)
}
위처럼 구현하면 어렵지않게 Undo, Redo 기능을 구현할 수 있다. 생각보다 쉽게 끝났다.
그림을 그리기 위해선 당연히 다양한 크기와 색상을 가진 브러시로 그릴 수 있어야한다. 이번에는 획 스타일을 바꿀 수 있는 기능을 구현해보자.
이를 구현하기 위해서 먼저 획 스타일을 기존 Compose의 drawPath
함수에서 어떻게 적용하는지 알아보자.
fun drawPath(
path: Path,
color: Color,
/*@FloatRange(from = 0.0, to = 1.0)*/
alpha: Float = 1.0f,
style: DrawStyle = Fill,
colorFilter: ColorFilter? = null,
blendMode: BlendMode = DefaultBlendMode
)
color
, alpha
, style
, colorFilter
, blendMode
등 다양하게 속성을 바꿀 수 있다. 이 중 나는 path
, color
, alpha
, style
만 사용할 거기 때문에 해당 속성을 포함한 Data Class인 PathStyle
클래스를 만들어 속성을 관리하기로 했다.
PathStyle
데이터 클래스 생성data class PathStyle(
var color: Color = Color.Black,
var alpha: Float = 1.0f,
var width: Float = 10.0f
)
PathStyle
클래스를 생성했으면 기존의 획을 저장하는 부분 코드도 변경할 필요가 있다. paths
, removedPaths
에 획 정보를 저장할 때, Path
뿐만 아니라 PathStyle
정보도 같이 저장해야 해당 획을 그릴 때 속성 값들도 제대로 불러와서 그릴 수 있다.
paths
, removedPaths
에 획을 저장하는 부분에 PathStyle
도 같이 저장할 수 있게 코드를 수정한다.@Composable
fun DrawingCanvas() {
...
val paths = remember { mutableStateListOf<Pair<Path, PathStyle>>() } // 다 그려진 획 리스트 State
val removedPaths = remember { mutableStateListOf<Pair<Path, PathStyle>>() }
val pathStyle = PathStyle()
Canvas(
modifier = Modifier
.size(360.dp)
.background(Color.White)
.aspectRatio(1.0f)
.pointerInput(Unit) {
detectDragGestures(
...
onDragEnd = {
paths.add(Pair(path, pathStyle.copy()))
points.clear()
path = Path()
}
)
},
) {
paths.forEach { pair ->
drawPath(
path = pair.first,
style = pair.second
)
}
drawPath(
path = path,
style = pathStyle
)
}
...
}
drawPath
함수에 PathStyle
을 넘겨주면 color
, alpha
, width
를 매핑해주는 internal fun
을 선언한다.internal fun DrawScope.drawPath(
path: Path,
style: PathStyle
) {
drawPath(
path = path,
color = style.color,
alpha = style.alpha,
style = Stroke(width = style.width)
)
}
PathStyle
변경 기능을 위한 준비가 완료되었으니 이제 기능만 구현하면된다. 두께와 투명도 변경은 Slider
를 이용하고 색상 변경은 간단하게 6가지 색상 정도로 구성된 팔레트를 구현한다.
PathStyle
변경을 담당할 UI 컴포넌트 영역 DrawingStyleArea
를 구현한다.@Composable
fun DrawingCanvas() {
...
// 획 스타일 조절하는 영역
DrawingStyleArea(
onSizeChanged = { pathStyle.width = it },
onColorChanged = { pathStyle.color = it },
onAlphaChanged = { pathStyle.alpha = it }
)
}
@Composable
fun DrawingStyleArea(
onSizeChanged: (Float) -> Unit,
onColorChanged: (Color) -> Unit,
onAlphaChanged: (Float) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
modifier = Modifier
.width(72.dp)
.padding(horizontal = 8.dp),
text = "두께",
textAlign = TextAlign.Center
)
var size by remember { mutableStateOf(10.0f) }
Slider(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
value = size,
valueRange = 1.0f..30.0f,
onValueChange = {
size = it
onSizeChanged(it)
}
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
modifier = Modifier
.width(72.dp)
.padding(horizontal = 8.dp),
text = "투명도",
textAlign = TextAlign.Center
)
var alpha by remember { mutableStateOf(1.0f) }
Slider(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
value = alpha,
valueRange = 0.0f..1.0f,
onValueChange = {
alpha = it
onAlphaChanged(it)
}
)
}
DrawingColorPalette(
onColorChanged = onColorChanged
)
}
}
@Composable
fun DrawingColorPalette(
onColorChanged: (Color) -> Unit
) {
var selectedIndex by remember { mutableStateOf(0) }
val colors = listOf(Color.Black, Color.Red, Color.Green, Color.Blue, Color.Magenta, Color.Yellow)
Row(
modifier = Modifier.fillMaxWidth()
.padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
colors.forEachIndexed { index, color ->
Box(
modifier = Modifier.size(36.dp)
) {
Image(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.clickable {
selectedIndex = index
onColorChanged(color)
},
painter = ColorPainter(color),
contentDescription = "색상 선택"
)
if (selectedIndex == index) {
Image(
modifier = Modifier.align(Alignment.Center),
painter = painterResource(id = R.drawable.ic_check),
contentDescription = "선택된 색상 체크 표시"
)
}
}
}
}
}
이렇게해서 Undo, Redo기능과 획의 두께, 색상, 투명도 등을 변경할 수 있는 아주 기본적인 그림판을 구현해보았다. 최종 결과물은 아래에서 확인할 수 있다.
하지만 지금 이 그림판에는 치명적인 문제가 있다. 바로 화면 회전 등 Activity가 생명주기 상 onDestroy
가 발생하고 다시 생성되거나 하는 경우 그림 데이터가 전부 날아간다는 것이다.
이를 해결하기 위해서 다음 포스트에서는 ViewModel 패턴을 적용해 Activity가 종료되기 전까지 그림 데이터가 온전히 보존되도록 구현해볼 것이다.