[Android] Compose로 그림판 구현하기 -3

문승연·2024년 1월 12일
0

이 프로젝트의 전체 코드는 Github 링크에서 확인할 수 있습니다.

1. 그림판에 ViewModel 적용하기

이번 포스트에서는 저번에 말한대로 DrawingScreen에 ViewModel을 적용해보도록 할 거다.
ViewModel을 적용하는 이유는 그림판에 그림을 그리다보면 화면을 이리저리 움직여서 회전하게 되는 경우가 많은데, 그리기와 관련된 Path 데이터를 View에서 관리하면 화면 회전시마다 그림판의 그림이 모두 날아가버리기 때문이다.

기존 코드에서는 point, points, path, paths, pathStyle 총 5개의 데이터를 View에서 관리한다. 여기서 pathspathStyleViewModel 패턴을 적용해볼 것이다.

그 이유는 point, points, path는 한 획을 그리고 손을 떼면 바로 초기화가 되는 데이터인데다가 드래그 움직임에 따라 계속해서 변화가 발생하기 때문에 ViewModel <-> View 간 데이터 전송 과정이 지나치게 많이 발생하는 것을 방지하기 위해서이다.

물론 나머지 데이터 모두 ViewModel을 적용해도 상관없다.

1. DrawingViewModel 생성

class DrawingViewModel: ViewModel() {
	// MutableLiveData는 수정이 가능함
	private val _paths = NonNullLiveData<MutableList<Pair<Path, PathStyle>>>(
		 mutableListOf()
	)
	private val _pathStyle = NonNullLiveData(
		PathStyle()
	)

	private val removedPaths = mutableListOf<Pair<Path, PathStyle>>()

	// LiveData는 외부에서 수정이 불가능하게 설정
	// getter를 사용하여 데이터를 읽는 과정만 수행 가능
	val paths: LiveData<MutableList<Pair<Path, PathStyle>>>
		get() = _paths
	val pathStyle: LiveData<PathStyle>
		get() = _pathStyle

	fun updateWidth(width: Float) {
		val style = _pathStyle.value
		style.width = width

		_pathStyle.value = style
	}

	fun updateColor(color: Color) {
		val style = _pathStyle.value
		style.color = color

		_pathStyle.value = style
	}

	fun updateAlpha(alpha: Float) {
		val style = _pathStyle.value
		style.alpha = alpha

		_pathStyle.value = style
	}

	fun addPath(pair: Pair<Path, PathStyle>) {
		val list = _paths.value
		list.add(pair)
		_paths.value = list
	}

	fun undoPath() {
		val pathList = _paths.value
		if (pathList.isEmpty())
			return
		val last = pathList.last()
		val size = pathList.size

		removedPaths.add(last)
		_paths.value = pathList.subList(0, size-1)
	}

	fun redoPath() {
		if (removedPaths.isEmpty())
			return
		_paths.value = (_paths.value + removedPaths.removeLast()) as MutableList<Pair<Path, PathStyle>>
	}
}

위처럼 DrawingViewModel 클래스를 생성해준다.

2. NonNullLiveData 클래스 생성

위의 DrawingViewModel을 보면 일반적인 MutableLiveData가 아닌 NonNullLiveData를 사용했다.

/**
 * Returns the current value.
 * Note that calling this method on a background thread does not guarantee that the latest
 * value set will be received.
 *
 * @return the current value
 */
@SuppressWarnings("unchecked")
@Nullable
public T getValue() {
    Object data = mData;
    if (data != NOT_SET) {
        return (T) data;
    }
    return null;
}

위 코드를 보면 알 수 있듯이 일반적인 LiveData의 getValue() 메소드는 T가 NonNull 타입이어도 초기값이 설정되어있지 않으면 null을 리턴한다.

이러한 특성 때문에 MutableLiveData를 업데이트할 때 계속 null-check를 하는 번거로운 과정을 없애기 위해 MutableLiveData를 상속받는 NonNullLiveData 클래스를 만들어 사용했다.

class NonNullLiveData<T: Any>(defaultValue: T) : MutableLiveData<T>(defaultValue) {

	init {
		value = defaultValue
	}

	override fun getValue() = super.getValue()!!
}

paths, pathStyle State로 Observe하기

val paths by viewModel.paths.observeAsState()
val pathStyle by viewModel.pathStyle.observeAsState()

observeAsState를 사용하면 LiveData를 옵저빙하면서도 State로 사용할 수 있다.

ViewModel 패턴에 맞게 로직 수정
DrawingScreen.kt

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

@Composable
fun DrawingCanvas(
    viewModel: DrawingViewModel
) {
    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 by viewModel.paths.observeAsState()
    val pathStyle by viewModel.pathStyle.observeAsState()

    Canvas(
        modifier = Modifier
            .size(360.dp)
            .background(Color.White)
            .aspectRatio(1.0f)
            .clipToBounds()
            .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 = {
                        viewModel.addPath(Pair(path, pathStyle!!.copy()))
                        points.clear()

                        path = Path()
                    }
                )
            },
    ) {
        paths?.forEach { pair ->
            drawPath(
                path = pair.first,
                style = pair.second
            )
        }

        drawPath(
            path = path,
            style = pathStyle!!
        )
    }

    Spacer(modifier = Modifier.height(12.dp))
    // Undo, Redo 버튼
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.Center
    ) {
        DrawingUndoButton {
            viewModel.undoPath()
        }

        Spacer(modifier = Modifier.width(24.dp))

        DrawingRedoButton {
           viewModel.redoPath()
        }
    }
    // 획 스타일 조절하는 영역
    DrawingStyleArea(
        onSizeChanged = { viewModel.updateWidth(it) },
        onColorChanged = { viewModel.updateColor(it) },
        onAlphaChanged = { viewModel.updateAlpha(it) }
    )
}

...

MainActivity.kt

class MainActivity : ComponentActivity() {

    private val viewModel: DrawingViewModel by viewModels()

    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
                ) {
                    DrawingScreen(viewModel)
                }
            }
        }
    }
}
profile
"비몽(Bemong)"이라는 앱을 개발 및 운영 중인 안드로이드 개발자입니다.

0개의 댓글