안드로이드 패턴 patternLockView 쉽게 구현하는 방법 - Android PatternLockView
✨ 개요
Android에서는 기본적으로 PIN, 패턴, 생체인증 등을 제공하지만, 자체 인증 화면을 만들거나 보조 잠금 화면을 구성할 때 패턴 락(Pattern Lock) UI가 유용하게 쓰입니다.
이번 포스팅에서는 라이브러리 없이 순수 Custom View로 Pattern Lock UI를 구현하는 방법을 소개합니다.
1. ✅ 주요 기능
항목 | 설명 |
---|---|
Custom View |
View를 상속 받아 직접 그리기(Canvas) |
onDraw() |
패턴 원 및 선을 그림 |
onTouchEvent() |
사용자 입력(터치 좌표)을 처리 |
패턴 배열 반환 |
이어진 선 목록 및 해쉬 값 |
2. ✅ 패턴 PatternLockView 코드
2.1 패턴 PatternLockView 코드
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import java.security.MessageDigest
import kotlin.math.hypot
class PatternLockView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
interface PatternListener {
fun onPatternDetected(pattern: List<Int>, hash: String)
}
var patternListener: PatternListener? = null
var showLines: Boolean = true
private val gridSize = 3
private val points = Array(gridSize) { Array(gridSize) { PointF() } }
// 선택된 점 인덱스 및 좌표
private val selectedIndices = mutableListOf<Int>()
private val selectedPoints = mutableListOf<PointF>()
private val selectedIndexSet = mutableSetOf<Int>() // 최적화를 위한 Set (contains 빠름)
// 점 및 선 색상 설정
private val defaultPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.GRAY
style = Paint.Style.FILL
}
// 선택된 점 색상
private val selectedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLUE
style = Paint.Style.FILL
}
private val errorPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
style = Paint.Style.FILL
}
// 이어진 선 색상
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLUE
strokeWidth = 10f
style = Paint.Style.STROKE
}
private val errorLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
strokeWidth = 10f
style = Paint.Style.STROKE
}
private var currentTouchX = -1f
private var currentTouchY = -1f
private var isError = false
// 뷰 크기 변경 시 각 점 좌표 계산
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
val offsetX = w / (gridSize + 1)
val offsetY = h / (gridSize + 1)
for (i in 0 until gridSize) {
for (j in 0 until gridSize) {
points[i][j] = PointF((j + 1) * offsetX.toFloat(), (i + 1) * offsetY.toFloat())
}
}
}
// 점과 선을 그림
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 점 그리기
for (i in 0 until gridSize) {
for (j in 0 until gridSize) {
val index = i * gridSize + j
val p = points[i][j]
val paint = when {
isError && selectedIndexSet.contains(index) && showLines -> errorPaint
selectedIndexSet.contains(index) && showLines -> selectedPaint
else -> defaultPaint
}
canvas.drawCircle(p.x, p.y, 30f, paint)
}
}
// 선 그리기
if (showLines && selectedPoints.isNotEmpty()) {
val paint = if (isError) errorLinePaint else linePaint
for (i in 0 until selectedPoints.size - 1) {
canvas.drawLine(
selectedPoints[i].x,
selectedPoints[i].y,
selectedPoints[i + 1].x,
selectedPoints[i + 1].y,
paint
)
}
// 현재 터치 중이면 마지막 점에서 손가락까지 선 그리기
if (currentTouchX >= 0 && currentTouchY >= 0) {
val last = selectedPoints.last()
canvas.drawLine(last.x, last.y, currentTouchX, currentTouchY, paint)
}
}
}
// 터치 이벤트 처리
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
currentTouchX = event.x
currentTouchY = event.y
val (p, index) = findNearestPoint(event.x, event.y) ?: return true
if (selectedIndexSet.add(index)) {
selectedIndices.add(index)
selectedPoints.add(p)
}
invalidate()
}
MotionEvent.ACTION_UP -> {
currentTouchX = -1f
currentTouchY = -1f
if (selectedIndices.isNotEmpty()) {
val hash = hashPattern(selectedIndices)
patternListener?.onPatternDetected(selectedIndices.toList(), hash)
}
resetPattern()
}
}
return true
}
// 터치 위치가 가까운 점을 찾음
private fun findNearestPoint(x: Float, y: Float): Pair<PointF, Int>? {
for (i in 0 until gridSize) {
for (j in 0 until gridSize) {
val p = points[i][j]
if (hypot(x - p.x, y - p.y) < 50f) {
val index = i * gridSize + j
return p to index
}
}
}
return null
}
// 잘못된 패턴 입력 시 애니메이션과 함께 초기화
fun showErrorAndReset() {
isError = true
invalidate()
this.animate().translationX(30f).setDuration(50).withEndAction {
this.animate().translationX(-30f).setDuration(50).withEndAction {
this.animate().translationX(0f).setDuration(50).start()
}.start()
}.start()
postDelayed({
isError = false
resetPattern()
}, 1000)
}
// 패턴 리셋
private fun resetPattern() {
selectedIndices.clear()
selectedPoints.clear()
selectedIndexSet.clear()
invalidate()
}
// 패턴을 해시 문자열로 변환 (SHA-256)
private fun hashPattern(pattern: List<Int>): String {
val message = pattern.joinToString("")
val digest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(message.toByteArray())
return hashBytes.joinToString("") { "%02x".format(it) }
}
}
- onSizeChanged()
- View의 크기가 결정될 때마다 호출되어 원 9개의 위치 좌표를 계산합니다.
- offsetX, offsetY를 사용해 균등하게 배치합니다.
- override fun onDraw(canvas: Canvas)
- 모든 원을 반복하며 선택 상태에 따라 색상을 달리하여 원을 그림.
- 사용자가 선택한 경로를 선으로 연결합니다.
- 현재 터치 중일 경우 마지막 점에서 현재 터치 지점까지 선을 연장합니다.
- onTouchEvent()
- 터치 이벤트(DOWN, MOVE, UP)를 받아 처리합니다.
- ACTION_DOWN/MOVE: 사용자의 손가락 위치가 점 내부에 들어오면 선택 처리
- ACTION_UP: 입력 완료 시 해시값을 구해 콜백 전달 후 초기화
- findNearestPoint()
- 사용자가 터치한 지점에서 가장 가까운 원을 찾아 반환합니다.
- 원의 반지름 거리 안에 있는 경우 인식.
- showErrorAndReset()
- 잘못된 패턴일 경우 붉은색으로 점/선을 표시하며, 약간의 진동 애니메이션 후 자동 초기화됩니다.
- 보안성 향상과 사용자 경험 개선을 위한 기능입니다.
- hashPattern()
- 사용자의 패턴 입력 리스트(Int 배열)를 SHA-256 해시로 변환합니다.
- 서버 전송, 저장 시 패턴을 평문으로 저장하지 않기 위한 보안 기능입니다
2.2 레이아웃 코드
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/resultTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="패턴을 그려주세요!!"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.example.sample.PatternLockView
android:id="@+id/patternLockView"
android:layout_width="0dp"
android:layout_height="500dp"
android:layout_margin="24dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/resultTextView" />
<CheckBox
android:id="@+id/checkBox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="false"
android:text="패턴 숨기기"
app:layout_constraintEnd_toEndOf="@id/patternLockView"
app:layout_constraintStart_toStartOf="@id/patternLockView"
app:layout_constraintTop_toBottomOf="@id/patternLockView" />
</androidx.constraintlayout.widget.ConstraintLayout>
- View 를 PatternLockView 로 설정
- CheckBox 패턴 숨기기 여부
2.3 호출 방법
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import com.example.sample.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val TAG: String = MainActivity::class.java.simpleName
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.patternLockView.patternListener = object : PatternLockView.PatternListener {
override fun onPatternDetected(pattern: List<Int>, hash: String) {
Log.e(TAG, "patternNumber=${pattern.joinToString(",")} hash=$hash")
}
}
binding.checkBox.setOnCheckedChangeListener { _, isChecked ->
binding.patternLockView.showLines = !isChecked
binding.patternLockView.invalidate()
}
}
}
- 패턴 입력 값의 결과를 선택된 점의 목록과 해쉬값으로 받을 수 있기에 패턴 등록/변경 시 활용 가능 함
- 체크박스(패턴 숨기기 여부)를 통해 패턴 보안 강화 가능
3.🧠 결론 & 보안 유의사항
- 결론
- Pattern Lock UI는 직접 구현해보면 Canvas, View, Touch 처리에 대한 이해를 높일 수 있습니다.
- 라이브러리에 의존하지 않고 앱에 맞는 UX를 자유롭게 구성할 수 있는 장점이 있습니다.
- 더 고도화된 UX가 필요하다면, 애니메이션, 진동, 보안 알고리즘 등을 함께 고려해보세요 😊