Simple&Natural

[Android] 커스텀 뷰(CustomView) 이용한 비밀번호 입력 화면 만들어보기 본문

카테고리 없음

[Android] 커스텀 뷰(CustomView) 이용한 비밀번호 입력 화면 만들어보기

Essense 2023. 2. 3. 12:29
728x90

 

보안이 필요한 여러 앱에서 볼 수 있는 화면의 모습입니다.

키보드로 입력을 하면 문자를 노출하는 대신 동그란 아이콘으로 가려 표시해줍니다.

 

저런 화면을 어떤 식으로 구현하는지 제가 구현한 코드를 공유해볼게요.

 

우선 가장 중요한 것은 키보드입니다.

 

안드로이드에서 제공하는 KeyboardView나 인터페이스를 구현하여 만들 수도 있지만,

저희는 시스템 키보드로 발생할 수 있는 여러가지 이슈들을 피하고자 했고 간단한 화면에서 주로 사용할 목적이었기 때문에 커스텀 뷰를 만들어 키보드를 구현했습니다.

 

아래는 키보드 구현부의 소스코드입니다.

 

CustomKeyboard.kt

import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import android.util.TypedValue
import android.view.Gravity
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import kotlin.math.roundToInt

@SuppressLint("SetTextI18n")
class CustomKeyboard: LinearLayout {

    companion object {
        private const val SHUFFLE = "재배열"
        private const val BACKSPACE = "←"
    }

    constructor(context: Context): super(context)
    constructor(context: Context, attrs: AttributeSet?): super(context, attrs)

    private val keySet = mutableListOf(
        Key("1", 24f, R.color.white, KeyType.COMMON),
        Key("2", 24f, R.color.white, KeyType.COMMON),
        Key("3", 24f, R.color.white, KeyType.COMMON),
        Key("4", 24f, R.color.white, KeyType.COMMON),
        Key("5", 24f, R.color.white, KeyType.COMMON),
        Key("6", 24f, R.color.white, KeyType.COMMON),
        Key("7", 24f, R.color.white, KeyType.COMMON),
        Key("8", 24f, R.color.white, KeyType.COMMON),
        Key("9", 24f, R.color.white, KeyType.COMMON),
        Key(SHUFFLE, 16f, R.color.white, KeyType.UTIL),
        Key("0", 24f, R.color.white, KeyType.COMMON),
        Key(BACKSPACE, 24f, R.color.white, KeyType.UTIL),
    )

    private var listener: OnKeyboardListener? = null

    init {
        orientation = VERTICAL
        initKeySet()
    }

    fun setOnKeyboardListener(listener: OnKeyboardListener) {
        this.listener = listener
    }

    private fun initKeySet() {
        val columnCount = 3
        val rowCount = if (keySet.size%columnCount==0) {
            keySet.size/columnCount
        } else {
            (keySet.size/columnCount) + 1
        }

	// 언뜻 복잡해 보이지만, row by column 만큼 버튼을 만들어주는 코드에 불과합니다!
        repeat(rowCount) { rowIndex ->
            val rowView = LinearLayout(context).apply row@{
                orientation = HORIZONTAL
                layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)

                repeat(columnCount) column@{ columnIndex ->
                    val key = keySet[(columnIndex+1 + columnCount*rowIndex)-1]
                    val cell = TextView(context).apply {
                        layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT, 1f)
                        setPadding(
                            key.getTextSizePx(context),
                            key.getTextSizePx(context),
                            key.getTextSizePx(context),
                            key.getTextSizePx(context)
                        )

			// 키보드 클릭 시 Ripple effect
                        foreground = with(TypedValue()) {
                            context.theme.resolveAttribute(android.R.attr.selectableItemBackground, this, true)
                            ContextCompat.getDrawable(context, resourceId)
                        }
                        setBackgroundColor(ContextCompat.getColor(context, R.color.color_2c6ceb))

                        text = key.text
                        setTextColor(Color.WHITE)
                        when(key.text) {
                            SHUFFLE -> {
                                setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
                                this.setOnClickListener {
                                    shuffle()
                                }
                            }
                            BACKSPACE -> {
                                setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
                                this.setOnClickListener {
                                    listener?.onDelete()
                                }
                            }
                            else -> {
                                setTextSize(TypedValue.COMPLEX_UNIT_SP, 24f)
                                this.setOnClickListener {
                                    listener?.onEnterText(key.text)
                                }
                            }
                        }

                        gravity = Gravity.CENTER
                        // 설정하지 않으면 Ripple effect가 생기지 않습니다
                        // 하지만 onClickListener 를 설정하는 경우엔 아래 코드는 필요없습니다!
                        isClickable = true 
                    }
                    this.addView(cell)
                }

            }

            addView(rowView)
        }
    }

    private fun shuffle() {
        val newKeySet = keySet.filter { it.keyType==KeyType.COMMON }
            .shuffled()
        var index = 0
        for (i in 0..keySet.lastIndex) {
            if (keySet[i].keyType == KeyType.COMMON) {
                keySet[i] = newKeySet[index]
                index++
            }
        }
        removeAllViews()
        initKeySet()
    }

    data class Key(
        val text: String,
        val textSizeSp: Float,
        val textColorId: Int,
        val keyType: KeyType
    ) {
        fun getTextSizePx(context: Context): Int = textSizeSp.toPixel(context)
    }

    interface OnKeyboardListener {
        fun onEnterText(text: String)

        fun onDelete()
    }

    enum class KeyType {
        COMMON,
        UTIL
    }

}


fun Float.toPixel(context: Context): Int {
    val density = context.resources.displayMetrics.density
    return (this * density).roundToInt()
}

 

 

사용은 아래와 같이 하면 됩니다.

 

 

 

MainActivity.kt

binding.customKeyboard.setOnKeyboardListener(object: CustomKeyboard.OnKeyboardListener {

    @SuppressLint("SetTextI18n")
    override fun onEnterText(text: String) {
        binding.tvInput.text = binding.tvInput.text.toString() + text
        makeToast(text)
    }

    override fun onDelete() {
        binding.tvInput.text = binding.tvInput.text.toString().dropLast(1)
    }

})

binding.tvInput.doAfterTextChanged {
    (0 until binding.layoutBullet.childCount).forEach { index ->
        (binding.layoutBullet.getChildAt(index) as CardView?)?.apply {
            if (index > it.toString().lastIndex) {
                setCardBackgroundColor(ContextCompat.getColor(context, R.color.color_eaedf0))
            } else {
                setCardBackgroundColor(ContextCompat.getColor(context, R.color.color_2c6ceb))
            }
        }
    }
}

 

 

activity_main.xml

<?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:background="@color/white"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <LinearLayout
        android:id="@+id/layout_bullet"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toTopOf="@id/tv_input"
        android:gravity="center"
        android:padding="24dp">
        <androidx.cardview.widget.CardView
            android:id="@+id/cv_1"
            android:layout_width="12dp"
            android:layout_height="12dp"
            android:layout_margin="8dp"
            app:cardCornerRadius="6dp"
            app:cardBackgroundColor="@color/color_eaedf0"/>
        <androidx.cardview.widget.CardView
            android:id="@+id/cv_2"
            android:layout_width="12dp"
            android:layout_height="12dp"
            android:layout_margin="8dp"
            app:cardCornerRadius="6dp"
            app:cardBackgroundColor="@color/color_eaedf0"/>
        <androidx.cardview.widget.CardView
            android:id="@+id/cv_3"
            android:layout_width="12dp"
            android:layout_height="12dp"
            android:layout_margin="8dp"
            app:cardCornerRadius="6dp"
            app:cardBackgroundColor="@color/color_eaedf0"/>
        <androidx.cardview.widget.CardView
            android:id="@+id/cv_4"
            android:layout_width="12dp"
            android:layout_height="12dp"
            android:layout_margin="8dp"
            app:cardCornerRadius="6dp"
            app:cardBackgroundColor="@color/color_eaedf0"/>
        <androidx.cardview.widget.CardView
            android:id="@+id/cv_5"
            android:layout_width="12dp"
            android:layout_height="12dp"
            android:layout_margin="8dp"
            app:cardCornerRadius="6dp"
            app:cardBackgroundColor="@color/color_eaedf0"/>
        <androidx.cardview.widget.CardView
            android:id="@+id/cv_6"
            android:layout_width="12dp"
            android:layout_height="12dp"
            android:layout_margin="8dp"
            app:cardCornerRadius="6dp"
            app:cardBackgroundColor="@color/color_eaedf0"/>
    </LinearLayout>

    <TextView
        android:id="@+id/tv_input"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toTopOf="@id/custom_keyboard"
        android:maxLength="6"
        android:textColor="@color/black"
        android:textSize="20sp"
        android:padding="24dp"
        android:lineSpacingExtra="8dp"
        android:gravity="center"/>

    <com.example.customkeyboardapp.CustomKeyboard
        android:id="@+id/custom_keyboard"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

키보드의 변경 이벤트는 interface를 통해서 처리하도록 구현했습니다.

 

Bullet Layout 아래에 하위 뷰로 CardView를 비밀번호 길이만큼 만들고 

키보드 입력이 들어오면 총 텍스트의 갯수만큼 ViewGroup 하위의 CardView를 업데이트 해주는 방식입니다.

 

Bullet layout의 경우도 여러 화면에서 사용한다면 따로 class로 만들어 사용하면 더 좋겠죠?

 

 

구현 영상을 올리니 티스토리 화면 전체가 깨지는 오류가 있어 파일로 올립니다.

질문 있으시면 댓글 남겨주세요!

XRecorder_Edited_03022023_120711.mp4
2.47MB

 

728x90