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

문승연·2023년 12월 25일
0

0. 시작하기 앞서

완성된 전체 코드가 필요하다면 Github 링크를 참고해주세요.

1. Undo, Redo 기능 구현하기

그림을 그리다보면 획을 잘못 그어서 이전 화면으로 돌아가고 싶거나 반대로 지웠던 획을 다시 불러오고 싶을 때가 있다. 각각 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 기능을 구현할 준비는 끝났다.

  1. 먼저 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 에서 가장 최근 획부터 제거해 해당 리스트에 저장하도록 하면 된다.

  1. 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 기능도 같은 방식으로 구현하면 된다.

  1. Redo 기능은 removedPaths에서 가장 최근 획부터 제거해 paths 리스트에 저장한다.
DrawingRedoButton {
	if (removedPaths.isEmpty()) return@DrawingRedoButton
    // Redo
    val lastRemovedPath = removedPaths.removeLast()
    paths.add(lastRemovedPath)
}

위처럼 구현하면 어렵지않게 Undo, Redo 기능을 구현할 수 있다. 생각보다 쉽게 끝났다.

2. 획 스타일 변경하기 (두께, 투명도, 색상)

그림을 그리기 위해선 당연히 다양한 크기와 색상을 가진 브러시로 그릴 수 있어야한다. 이번에는 획 스타일을 바꿀 수 있는 기능을 구현해보자.

이를 구현하기 위해서 먼저 획 스타일을 기존 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 클래스를 만들어 속성을 관리하기로 했다.

  1. PathStyle 데이터 클래스 생성
data class PathStyle(
    var color: Color = Color.Black,
    var alpha: Float = 1.0f,
    var width: Float = 10.0f
)

PathStyle 클래스를 생성했으면 기존의 획을 저장하는 부분 코드도 변경할 필요가 있다. paths, removedPaths에 획 정보를 저장할 때, Path 뿐만 아니라 PathStyle 정보도 같이 저장해야 해당 획을 그릴 때 속성 값들도 제대로 불러와서 그릴 수 있다.

  1. 기존 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
        )
    }

    ...
}
  1. 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가지 색상 정도로 구성된 팔레트를 구현한다.

  1. 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가 종료되기 전까지 그림 데이터가 온전히 보존되도록 구현해볼 것이다.

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

0개의 댓글