이 프로젝트의 전체 코드는 Github 링크에서 확인할 수 있습니다.
이번 포스트에서는 저번에 말한대로 DrawingScreen에 ViewModel을 적용해보도록 할 거다.
ViewModel을 적용하는 이유는 그림판에 그림을 그리다보면 화면을 이리저리 움직여서 회전하게 되는 경우가 많은데, 그리기와 관련된 Path 데이터를 View에서 관리하면 화면 회전시마다 그림판의 그림이 모두 날아가버리기 때문이다.
기존 코드에서는 point, points, path, paths, pathStyle 총 5개의 데이터를 View에서 관리한다. 여기서 paths
와 pathStyle
만 ViewModel 패턴을 적용해볼 것이다.
그 이유는 point, points, path는 한 획을 그리고 손을 떼면 바로 초기화가 되는 데이터인데다가 드래그 움직임에 따라 계속해서 변화가 발생하기 때문에 ViewModel <-> View 간 데이터 전송 과정이 지나치게 많이 발생하는 것을 방지하기 위해서이다.
물론 나머지 데이터 모두 ViewModel을 적용해도 상관없다.
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 클래스를 생성해준다.
위의 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)
}
}
}
}
}