제가 작성한 오픈소스 라이브러리를 사용해보시면 좋을것 같습니다. 깃허브로 이동하시면 자세한 연동 방법 및 가이드 문서가 있으니 도움이 되실거라 생각합니다. 만약 사용하시는 중이라면 star하나 박아주시면 아주 기분이 좋을것 같습니다 ^^
[Compose로 구현한 Pattern 잠금화면 깃허브 이동하기]
처음 잠금화면을 구현하려던 당시, 단순 VerticalGrid
와 같은 API를 사용하여 구현할 수 있을 줄 알았다. 하지만 이건 큰 오산이었는데, Pattern 잠금화면의 경우, 손으로 드래그할 때 선이 쭉 이이지는 제스쳐 필요했는데, 이를 VerticalGrid
로는 구현에 무리가 있기 때문이었다.
VerticalGrid
나 Column
을 사용하여 3 * 3형식의 dot를 그리는데까진 성공하였다. 또한 dot가 클릭되었을 때 색칠이 되는것까진 성공하였다. 하지만 선으로 쭉 잇는 것이 문제였다. 어떻게 구현해야 할까?
결론부터 말하자면, draw
관련 API를 사용하고 Offset
을 계산하여 구현하는 방법이 최선이란 생각이 들었다.
이번 포스팅에선 Offset
을 사용 및 x
, y
좌표를 계산해가며 Pattern 잠금 화면을 구현하기까지의 과정을 간략히 설명한다.
[읽기 전]
아래의 구현코드엔 생략된 부분이 매우 많습니다. 이해를 돕기 위해 단순 의사코드로만 작성되어 있으며, 해당 의사코드 구현을 위해 사전 정의가 필요한 메서드와 변수 또한 많은 생략이 들어갔습니다. 따라서 자세한 분석을 원하시면 아래 글을 가볍게 읽으신 후, 오픈소스를 직접 읽어보시길 권장드립니다 :)
draw
API를 사용하기 위해선, Canvas
를 사용해야 한다. 이 API에는 onDraw
라는 고차함수가 존재하는데, 이 고차함수를 통해 3 * 3 dot를 그려준다. 의사코드를 작성해보겠다.
onDraw = {
repeat(BasePatternDrawingUiState.DOT_SIZE) { rowIndex ->
repeat(BasePatternDrawingUiState.DOT_SIZE) { colIndex ->
// 위로부터 colPosition를 계산 및 drawCircle API를 사용하여 dot를 그린다.
drawCircle(
center = colPosition,
radius = drawingSetting.dotSize.toPx()
)
}
}
위 코드는 생략된게 많지만, 핵심은 위 코드가 전부이다. 우선, device의 물리적 크기를 구한다. 그리고 이를 등분 및 Offset를 계산한다. 그 후, 3 x 3 반복문을 돌림으로써 drawCiecle
API를 호출해준다. 그러면 위와 같은 3 x 3형식의 dot가 그려진다.
Canvas
의 Modifier
를 사용하여 pointerInteropFilter
를 사용한다. 그러면 MotionEvent.ACTION_DOWN
과 MotionEvent.ACTION_UP
과 MotionEvent.ACTION_MOVE
에 각각 코드를 작성해줄 수 있다.
Canvas(
modifier = modifier
.size(264.dp)
.align(Alignment.CenterHo
.pointerInteropFilter(
onTouchEvent = { motionEvent ->
when (motionEvent.action) {
MotionEvent.ACTION_DOWN -> { ... }
MotionEvent.ACTION_MOVE -> { ... }
MotionEvent.ACTION_UP -> { ... }
)
)
위를 아래와 같이 작성한다.
Canvas(
modifier = modifier
.size(264.dp)
.align(Alignment.CenterHo
.pointerInteropFilter(
onTouchEvent = { motionEvent ->
when (motionEvent.action) {
MotionEvent.ACTION_DOWN -> {
repeat(BasePatternDrawingUiState.DOT_SIZE) { rowIndex ->
repeat(BasePatternDrawingUiState.DOT_SIZE) { colIndex ->
// 선택된 Offset식별 및 색깔 변경을 위해 selectedOffsets에 현재 선택된 좌표를 저장한다.
// 이 부분의 변경으로 selectedOffsets에 해당하는 좌표만 리컴포지션 될것이다.
Offset(
x = dotOffset.x,
y = dotOffset.y
).let { firstSelectedOffset ->
uiState =
uiState.copy(
selectedOffsets = uiState.selectedOffsets.apply {
add(firstSelectedOffset)
}
)
}
}
}
}
)
)
위와 같은 작업이 처리되면 onDraw
메서드가 Recomposition
되면서 변경된 색깔을 반영하게 된다. 아래와 같이 말이다.
onDraw = {
repeat(BasePatternDrawingUiState.DOT_SIZE) { rowIndex ->
repeat(BasePatternDrawingUiState.DOT_SIZE) { colIndex ->
// drawCircle내에 있는 color파라미터에 변형을 준다.
drawCircle(
color = uiState.getDotColor(
rowIndex = rowIndex,
colIndex = colIndex,
selectedDotColor = drawingSetting.selectedDotColor,
unselectedDotColor = drawingSetting.unselectedDotColor),
center = colPosition,
radius = drawingSetting.dotSize.toPx()
)
}
}
위의 코드가 작성되면 손으로 터치를 했을 때, dot의 색깔이 변하게 된다.
해당 부분이 가장 애를 먹은 부분이기도 하다. 우선, 이는 MotionEvent.ACTION_MOVE
쪽에 코드를 지속적으로 Recomposition
을 시킴으로써 line이 그려지도록 만들었다.
Canvas(
modifier = modifier
.pointerInteropFilter(
onTouchEvent = { motionEvent ->
when (motionEvent.action) {
MotionEvent.ACTION_MOVE -> {
uiState =
uiState.copy(
// 현재 움직여지는 좌표를 트래킹한다.
draggingOffset = Offset(
x = motionEvent.x,
y = motionEvent.y
)
)
repeat(BasePatternDrawingUiState.DOT_SIZE) { rowDotIndex ->
repeat(BasePatternDrawingUiState.DOT_SIZE) { colDotIndex ->
// 그 후, 해당 좌표의 line값을 지속적으로 계산한다.
// 이때의 line이란건 직선을 의미하며, 직선은 당연히 '시작점'과 '끝점'이 존재한다. 따라서 2개의 Offset을 저장해야 한다.
// 그리고 이를 저장하기 위한 자료형은 Pair를 선택했다.
drawDotAndLine(
currentUiState = uiState,
destinationOffset = middleOffset,
onDrawSuccess = { newUiState ->
// 성공적으로 그려지면 이를 uiState프로퍼티에 저장하고 Recomposition트리거 준비를 한다.
uiState = newUiState
}
)
}
}
}
}
}
true
}
)
.drawWithContent {
drawContent()
// API를 사용하여 직선을 그린다.
// 이때 직선은 'drawDotAndLine'을 통해 저장된 Offset을 기준으로 그려지게 된다.
drawLine(
color = drawingSetting.lineColor,
start = uiState.latestSelectedDotOffset,
end = uiState.draggingOffset,
strokeWidth = convertDpToPx(context = context, dp = drawingSetting.lineWidth)
)
}
})
지금까지 Composable함수로 만든 Pattern 잠금 화면 라이브러리가 어떤 방식으로 동작하는지를 설명드렸습니다. 궁금하신 부분이나 이해가 안가는 부분 있으면 댓글 남겨주시면 성의껏 답변드리겠습니다. ^^