728x90


해당 내용은 코드스쿼드 2022 안드로이드 미션을 수행하면서 공부한 내용을 정리한 글입니다.

 

모바일 앱을 이용하면 회원가입을 할 때 위와 같은 화면을 많이 접했을 것이다. 

사용자 입장에서 당연하게만 사용했던 해당 UI를 오늘 직접 구현해 보려 한다.

 

 

 

 

📌  요구사항 분석

 

아이디

  • 5~20자의 영문 or 숫자
  • 주어진 Hint 적용

 

비밀번호

  • *****의 형태로 표시,
  • 8자 이상 16자 이하
  • 영문 대문자를 최소 1자 이상 포함
  • 숫자를 최소 1자 이상 포함
  • 특수문자를 최소 1자 이상 포함
  • 토글 아이콘 클릭 시 입력된 비밀번호 노출
  • 주어진 Hint 적용

비밀번호 재확인

  • 위 비밀번호에 쓰인 값과 동일

 

이름

  • 필수입력값

위의 경우 중 하나라도 만족하지 않을 경우 TextInputLayout에 Error 문구를 띄우며 다음 버튼이 활성화되지 않아야 한다.

 

 

 

📌  뷰 바인딩 설정

https://jminie.tistory.com/141?category=1040997 

 

안드로이드 [Kotlin] - 뷰 바인딩 (View Binding)

📌 왜 View Binding? Kotlin의 장점 중 하나는 findViewById를 쓰지 않아도 되는 점이다. kotlin extension으로 바로 접근이 가능했다. 그러나 코틀린 익스텐션이 deprecated 되었다. https://developer.android...

jminie.tistory.com

해당 내용은 위 포스팅에서 자세히 설명해 놓았으니 넘어가도록 하겠다.

 

 

 

 

📌  Layout 설정

 

Constraint Layout 을 이용해 TextView와 Button, 그리고 TextInputLayout의 위치를 지정해주고 Style 속성을 이용해 TextInputLayout의 hint 텍스트 사이즈를 조정해준다.

 

themes.xml

    <style name="TextLabel" parent="TextAppearance.Design.Hint">
        <item name="android:textSize">12sp</item>
    </style>

 

비밀번호에 쓰일 토글 버튼 역시 Layout 단에서 설정해 줄 수 있는데 

<com.google.android.material.textfield.TextInputLayout
        android:id="@+id/password_text_input_layout"
        ...
        ...
        app:passwordToggleEnabled="true"> -> 토글 버튼 활성화

        <com.google.android.material.textfield.TextInputEditText
            android:id="@+id/password_text_input_edit_text"
            ...
            ...
            android:inputType="textPassword" -> 비밀번호를 **** 형식으로 표현
            android:textAppearance="@style/TextLabel" -> 바로 위에서 정의한 hint 사이즈
        />
    </com.google.android.material.textfield.TextInputLayout>

위와 같이 passwordToggleEnabled와 inputType을 설정해 주면 된다.

 

TextView에 쓰일 String 값 들은 string.xml에 지정해준다.

해당 프로젝트에서는 지키지 못했지만 string.xml 에 TextView 에 쓰일 String 값을 지정할 때는 접두사에 네이밍 컨밴션을 만들어 지켜주는 것이 좋다. ( 예를 들어 label_id, label_password, label_name... )

 

string.xml

<resources>
    <string name="main_text">회원가입</string>
    <string name="id">아이디</string>
    <string name="id_hint">영문, 소문자, 숫자, 특수기호 : 5 ~ 20자</string>
    <string name="password_hint">영문 대/소문자, 숫자, 특수문자(!@#$$%) 8~16자</string>
    <string name="password">비밀번호</string>
    <string name="password_recheck">비밀번호 재확인</string>
    <string name="name">이름</string>
    <string name="next">다음</string>
</resources>

 

 

activitiy_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:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/main_textview"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="50dp"
        android:text="@string/main_text"
        android:textColor="@color/purple_500"
        android:textSize="30sp"
        android:textStyle="bold"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/id_textview"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="40dp"
        android:layout_marginTop="50dp"
        android:text="@string/id"
        android:textColor="@color/black"
        android:textSize="15sp"
        android:textStyle="bold"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/main_textview" />

    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/id_text_input_layout"
        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="40dp"
        android:layout_marginTop="10dp"
        app:counterEnabled="true"
        app:counterMaxLength="20"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/id_textview">

        <com.google.android.material.textfield.TextInputEditText
            android:id="@+id/id_text_input_edit_text"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textAppearance="@style/TextLabel" />
    </com.google.android.material.textfield.TextInputLayout>

    <TextView
        android:id="@+id/password_textview"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="40dp"
        android:layout_marginTop="20dp"
        android:text="@string/password"
        android:textColor="@color/black"
        android:textSize="15sp"
        android:textStyle="bold"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/id_text_input_layout" />

    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/password_text_input_layout"
        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="40dp"
        android:layout_marginTop="10dp"
        app:counterEnabled="true"
        app:counterMaxLength="16"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/password_textview"
        app:passwordToggleEnabled="true">

        <com.google.android.material.textfield.TextInputEditText
            android:id="@+id/password_text_input_edit_text"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:inputType="textPassword"
            android:textAppearance="@style/TextLabel" />
    </com.google.android.material.textfield.TextInputLayout>

    <TextView
        android:id="@+id/password_recheck_textview"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="40dp"
        android:layout_marginTop="20dp"
        android:text="@string/password_recheck"
        android:textColor="@color/black"
        android:textSize="15sp"
        android:textStyle="bold"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/password_text_input_layout" />

    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/password_recheck_text_input_layout"
        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="40dp"
        android:layout_marginTop="10dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/password_recheck_textview"
        app:passwordToggleEnabled="true">

        <com.google.android.material.textfield.TextInputEditText
            android:id="@+id/password_recheck_text_input_edit_text"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:inputType="textPassword"
            android:textAppearance="@style/TextLabel" />
    </com.google.android.material.textfield.TextInputLayout>

    <TextView
        android:id="@+id/name_textview"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="40dp"
        android:layout_marginTop="20dp"
        android:text="@string/name"
        android:textColor="@color/black"
        android:textSize="15sp"
        android:textStyle="bold"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/password_recheck_text_input_layout" />

    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/name_textview_input_layout"
        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="40dp"
        android:layout_marginTop="10dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/name_textview">

        <com.google.android.material.textfield.TextInputEditText
            android:id="@+id/name_textview_input_edit_text"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textAppearance="@style/TextLabel" />
    </com.google.android.material.textfield.TextInputLayout>

    <Button
        android:id="@+id/next_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="50dp"
        android:text="@string/next"
        android:textSize="15sp"
        android:textStyle="bold"
        app:icon="@drawable/ic_baseline_arrow_forward_24"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />


</androidx.constraintlayout.widget.ConstraintLayout>

Design Tab에서 본 모습

 

 

📌  TextWatcher 구현

앱을 만들다 보면 TextInputLayout(editText)에 입력한 값을 실시간으로 관찰하면서 입력값에 따른 처리를 해야 할 때가 있다.

그때 사용할 수 있는 편리한 기능이 TextWatcher란 인터페이스이다. 

일단 인터페이스기 때문에 구현하면 TextWatcher가 갖고 있는 모든 메서드를 재정의해야 하는데, TextWatcher에는 아래 3가지 메서드가 있다.

  • beforeTextChanged(CharSequence charSequence, int i, int i1, int i2)
  • onTextChanged(CharSequence charSequence, int i, int i1, int i2)
  • afterTextChanged(Editable editable)

 

단순히 TextInputLayout(editText)에 입력한 값의 변경사항에 따른 로직을 처리하려고 하는 경우에는 beforeTextChanged와 onTextChanged는 사용할 필요가 없다. 

인자에 int 값들이 있는 것을 보았을 때 문자열의 위치 값에 따른 정교한 처리가 필요할 때 쓰일 것으로 유추할 수 있는데 나는 아직 사용해 본 적이 없다.

 

 

따라서 afterTextChanged의 로직을 구현해보자.

첫 번째로 아이디의 경우이다. 위의 요구사항을 보았을 때 TextWatcher에서 아이디를 관찰할 때 처리해야 될 로직은 

  1. 아이디 칸이 비어있지 않은지 확인
  2. 아이디 양식이 올바른지 확인

 

 

첫 번째 경우는 kotlin text 패키지에서 제공하는 isEmpty() 메서드를 사용하면 될 것 같고,

문제는 두 번째 경우이다. 커스텀한 정규식이 필수적으로 필요한 부분이다.

    // 특수문자 존재 여부를 확인하는 메서드
    private fun hasSpecialCharacter(string: String): Boolean {
        for (i in string.indices) {
            if (!Character.isLetterOrDigit(string[i])) {
                return true
            }
        }
        return false
    }

    // 영문자 존재 여부를 확인하는 메서드
    private fun hasAlphabet(string: String): Boolean {
        for (i in string.indices) {
            if (Character.isAlphabetic(string[i].code)) {
                return true
            }
        }
        return false
    }
	
    // 위의 두 메서드를 포함하여 종합적으로 id 정규식을 확인하는 메서드
    fun idRegex(id: String): Boolean {
        if ((!hasSpecialCharacter(id)) and (hasAlphabet(id)) and (id.length >= 5)) {
            return true
        }
        return false
    }

 

 

 

두 번째로 비밀번호이다. 

비밀번호의 경우에는 정규식의 내용이 복잡하기 때문에 구글링을 통해 비슷한 정규식을 가져와 필요한 내용만 수정한 뒤 

https://regex101.com/r/ZG1naC/2  

 

regex101: build, test, and debug regex

Regular expression tester with syntax highlighting, explanation, cheat sheet for PHP/PCRE, Python, GO, JavaScript, Java, C#/.NET.

regex101.com

 

해당 사이트에서 확인해 보았다. 그렇게 해서 만들어진 메서드는 다음과 같다.

커스텀한 정규식과 인자로 정한 password: String 이 일치하면 true를 리턴해주는 간단한 메서드이다.

fun passwordRegex(password: String): Boolean {
    return password.matches("^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[$@$!%*#?&.])[A-Za-z[0-9]$@$!%*#?&.]{8,16}$".toRegex())
}

 

 

나머지 비밀번호 재확인과 이름은 간단하니 넘어가도록 하자.

 

 

이제 이를 idTextWatcher와, passwordTextWatcher에 적용하면 다음과 같다.

private val idListener = object : TextWatcher {
        override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
        }

        override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
        }

        override fun afterTextChanged(s: Editable?) {
            if (s != null) {
                when {
                    s.isEmpty() -> {
                        binding.idTextInputLayout.error = "아이디를 입력해주세요."
                        idFlag = false
                    }
                    !idRegex(s.toString()) -> {
                        binding.idTextInputLayout.error = "아이디 양식이 맞지 않습니다"
                        idFlag = false
                    }
                    else -> {
                        binding.idTextInputLayout.error = null
                        idFlag = true
                    }
                }
                flagCheck()
            }
        }
    }
private val passwordListener = object : TextWatcher {
        override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
        }

        override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
        }

        override fun afterTextChanged(s: Editable?) {
            if (s != null) {
                when {
                    s.isEmpty() -> {
                        binding.passwordTextInputLayout.error = "비밀번호를 입력해주세요."
                        passwordFlag = false
                    }
                    !passwordRegex(s.toString()) -> {
                        binding.passwordTextInputLayout.error = "비밀번호 양식이 일치하지 않습니다."
                        passwordFlag = false
                    }
                    else -> {
                        binding.passwordTextInputLayout.error = null
                        passwordFlag = true
                    }
                }
                flagCheck()
            }
        }
    }

 

idListener라는 변수와 passwordListener라는 변수에 각각 익명 객체로 TextWatcher를 만들어 주어 대입해 주었다.

여기서 주의할 점은 else 구문에 binding.passwordTextInputLayout.error = null 을 적용해 주지 않으면 한번 Error를 띄운 EditText가 다시 정상적인 값을 입력해도 Error를 계속 띄우게 되는 버그가 발생한다.

 

 

그리고 코드를 보면 보지 못했던 Flag 값들과 flagCheck 메서드가 존재하는 것을 볼 수 있다. 이 변수와 메서드는 위의 요구사항 중 "위의 경우 중 하나라도 만족하지 않을 경우 TextInputLayout에 Error 문구를 띄우며 다음 버튼이 활성화되지 않아야 한다."와 관련되어 있다.

 

우선 MainActivity에 전역 변수로

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    private var idFlag = false
    private var passwordFlag = false
    private var passwordCheckFlag = false
    private var nameFlag = false
    ...
    ...
    fun flagCheck() {
        binding.nextButton.isEnabled = idFlag && passwordFlag && passwordCheckFlag && nameFlag
    }

다음과 같이 모든 Flag 들을 false로 지정해 놓고 이 모든 Flag가 true일 때 button을 활성화시키는 전역 함수를 만들어 놓는다

 

위의 TextWatcher에서 보았던 flag와 flagCheck 함수는 다음과 같이 구성되어 있던 것이다.

 

 

 

 

📌  TextInputLayout과 TextWatcher 연결

이제 MainActivity의 onCreated 함수에서 TextInputLayout 과 TextWatcher를 연결시켜주자.

로직은 모두 동일하니 예시로 id의 경우를 보면

binding.idTextInputLayout.editText?.addTextChangedListener(idListener)
binding.idTextInputEditText.hint = resources.getString(R.string.id_hint)
binding.idTextInputEditText.setOnFocusChangeListener { _, hasFocus ->
    if (hasFocus) {
        binding.idTextInputEditText.hint = ""
    } else {
        binding.idTextInputEditText.hint = resources.getString(R.string.id_hint)
    }
}

 

 

우선 addTextChangedListener 함수에 idLister 즉 TextWatcher를 집어넣어 주고,

EditText의 hint에 위에서 지정한 String.xml 에서 가져온 값을 넣어준다.

 

 

마지막으로 setOnFocusChangeListener가 있는데 이는 TextInputLayout의 특징이 Focus를 받게 되면 hint 가 좌측 상단으로 올라가는 애니메이션이 자동으로 나타나게 된다는 것인데 요구사항 사진에는 해당 내용이 없으므로 hasFocus 즉 Focus를 받게 되었을 때 hint를 공백으로 주고 그렇지 않은 경우에는 정상적으로 hint를 주는 방식으로 구현했다.

 

 

 

 

 

 

📱  결과물

 

 

 

 


 

+ 🚨 버그 수정 (2022-06-14)

다른 프로젝트에서 비밀번호 확인을 구현하는데 위의 내용대로 할 때 한 가지 버그가 있다.

아이디 -> 비밀번호 -> 비밀번호 확인 순서대로 작성하면 이상이 없는데 비밀번호 확인까지 한 뒤 비밀번호를 수정하면 비밀번호 확인이 수정된 비밀번호를 인식하지 못하고 Error를 뱉지 않는다.

 

버그 화면

 

 

 

 

 

코드 수정

private val passwordListener = object : TextWatcher {
        override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {

        }

        override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
        }

        override fun afterTextChanged(s: Editable?) {
            if (s != null) {
                when {
                    s.isEmpty() -> {
                        binding.passwordTextInputLayout.error = "비밀번호를 입력해주세요."
                        passwordFlag = false
                    }
                    s.isNotEmpty() -> {
                        binding.passwordTextInputLayout.error = null
                        passwordFlag = true
                        when {
                            binding.passwordRecheckTextInputLayout.editText?.text.toString() != ""
                                    && binding.passwordRecheckTextInputLayout.editText?.text.toString() != binding.passwordTextInputLayout.editText?.text.toString() -> {
                                binding.passwordRecheckTextInputLayout.error = "비밀번호가 일치하지 않습니다"
                                passwordCheckFlag = false
                                passwordFlag = true
                            }
                            else -> {
                                binding.passwordRecheckTextInputLayout.error = null
                                passwordCheckFlag = true
                            }
                        }
                    }
                }
                flagCheck()
            }
        }
    }

 

해결책은 비밀번호의 TextWatcher에 비밀번호 확인의 에러를 넣어주는 것이다. 

이렇게 되면 비밀번호 확인이 되어있는 상황에서 비밀번호가 다시 변경되면 비밀번호 확인에서 다시 Error를 뱉게 된다.

마지막 else에는 비밀번호 확인에 대한 Error 체크를 해주어 다시 비밀번호와 비밀번호 확인이 같아졌을 때 버튼이 활성화 될 수 있도록 해준다.

 

버그 수정 화면

 

 

 

 

 

 

 

복사했습니다!