본문 바로가기
내일배움캠프 (스파르타 코딩 클럽) 안드로이드 2기/TIL

[TIL] MVVM 적용 - 안드로이드 코틀린 (예시코드)

by 키윤 2024. 1. 12.

주석에 달아놓은 숫자 순서 따라가면서 읽어보면 이해가 더 쉽다.

SignUpActivity.kt

package com.example.startactitivity.signup

import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.ViewModelProvider
import com.example.startactitivity.HomeActivity
import com.example.startactitivity.SignInActivity
import com.example.startactitivity.User
import com.example.startactitivity.UserDatabase
import com.example.startactitivity.databinding.ActivitySignupBinding

class SignUpActivity : AppCompatActivity() {

    private lateinit var resultLauncher: ActivityResultLauncher<Intent>

    /** [0] 숙련 주차에 와서 처음 배운 바인딩을 이용해서 코드를 작성하였다. **/
    private lateinit var binding: ActivitySignupBinding

    private var accessPath: Int = 0

    private var checkName: String? = ""
    private var checkId: String? = ""
    private var checkPassword: String? = ""

    val editTextArray by lazy {
        arrayOf(binding.etName, binding.etId, binding.etPw)
    }

    /** [1] 가장 먼저 액티비티 클래스와 뷰모델 클래스를 연결해 주어야 한다. **/
    private val viewModel by lazy {
        ViewModelProvider(this@SignUpActivity)[SignUpViewModel::class.java]
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivitySignupBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)

        initView()
        /** [3] 뷰모델 초기 함수를 만들어준다. **/
        initViewModel()
    }

    private fun initView() {
        if (intent.hasExtra("edit")) {
            binding.btnSignup.text = "회원 수정"
            accessPath = 1
        }
        /** [5] 가장 먼저 initView 함수가 실행 될것이거 그에 따라 아래 3개의 함수가 실행될 것이다. **/
        setTextChangedListener()
        setOnFocusChangedListener()
        onClickBtnSignUp()
    }

    /** [4] 이 함수도 = with(viewModel)을 적어주어야 한다.
     *      이 함수에서 뷰모델을 관찰할거라고 말해주는 부분인 것 같다.**/
    private fun initViewModel() = with(viewModel) {
        /** [8] 어느 EditText에 입력값이 변경되었는지 전달 해 주었다.
         *      이제 이 새로 입력되거나 지워진 값이 로그인 유효성 검사를 해주기 위해 setErrorMessage() 함수를 호출한다.
         *      그리고 어느 EditText인지도 it을 통해 전달한다.
         *      추가적으로 입력 정보가 모두 유효하면 로그인 버튼이 활성화되어야 하기 때문에
         *      setConfrimButtenEnable() 함수도 실행해 준다.**/
        editTextTextChange.observe(this@SignUpActivity) {
            setErrorMessage(it)
            setConfirmButtonEnable()
        }
        /** [12] SignUpErrorMessage 타입에서 문자열로 바꾸어준다.
         *       EditText의 type과 그 문자열을  getNullIfValid() 함수를 호출하여 전달한다.**/
        nameErrorMessage.observe(this@SignUpActivity) {
            getNullIfValid("name", getString(it.message))
        }
        idErrorMessage.observe(this@SignUpActivity) {
            getNullIfValid("id", getString(it.message))
        }
        passwordErrorMessage.observe(this@SignUpActivity) {
            getNullIfValid("password", getString(it.message))
        }

        /** [15] checkName에 저장되어있던 값을 EditText가 유효하지 않으면 띄울 수 있게 .error을 사용해준다.
         *       그리고 나중에 이름,비밀번호, 아이디가 유효할 때 로그인 버튼을 활성화 해주기 위해 SignUpActivity에 
         *       있는 checkName 변수가 이름 에딧텍스트가 유효한 경우 null값을 가지게 해준다.**/
        checkName.observe(this@SignUpActivity) {
            binding.etName.error = it
            this@SignUpActivity.checkName = it

        }
        checkId.observe(this@SignUpActivity) {
            binding.etId.error = it
            this@SignUpActivity.checkId = it
        }
        checkPassword.observe(this@SignUpActivity) {
            binding.etPw.error = it
            this@SignUpActivity.checkPassword = it
        }
        enableConfrimButton.observe(this@SignUpActivity) {
            binding.btnSignup.isEnabled = it
        }
        /** [18] [8]번과 동일**/
        editTextOnFocus.observe(this@SignUpActivity) {
            setErrorMessage(it)
            setConfirmButtonEnable()
        }


    }


    private fun onClickBtnSignUp() {
        resultLauncher =
            registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
                if (result.resultCode == RESULT_OK) {
                    intent.putExtra("id", binding.etId.text.toString())
                    intent.putExtra("password", binding.etPw.text.toString())
                    setResult(RESULT_OK, intent)
                    finish()
                }
            }


        binding.btnSignup.setOnClickListener {


            when (accessPath) {
                0 -> {

                    UserDatabase.addUser(
                        User(
                            binding.etName.text.toString(),
                            binding.etId.text.toString(),
                            binding.etPw.text.toString()
                        )
                    )

                    val intent = Intent(this, SignInActivity::class.java).apply {
                        putExtra("id", binding.etId.text.toString())
                        putExtra("password", binding.etPw.text.toString())
                    }
                    setResult(RESULT_OK, intent)
                    resultLauncher.launch(intent)
                }

                1 -> {

                    UserDatabase.editUserData(
                        User(
                            binding.etName.text.toString(),
                            binding.etId.text.toString(),
                            binding.etPw.text.toString()
                        )
                    )

                    val intent = Intent(this, HomeActivity::class.java).apply {
                        putExtra("id", binding.etId.text.toString())
                        putExtra("password", binding.etPw.text.toString())
                    }
                    setResult(RESULT_OK, intent)
                    resultLauncher.launch(intent)
                }
            }


        }
    }

    /** [6] setTextChangedListener은 말 그대로 입력된 값이 변할 때마다 호출 되는 함수이다.
     *      함수가 실행 되면 뷰모델에 있는 같은 이름의 함수를 호출한다.
     *      이때 editTextArray를 넘겨준다.
     *      EditTextArray는 이름/아이디/비밀번호에 입력된 값을 모두 가지고 있다.**/
    private fun setTextChangedListener() {
        viewModel.setTextChangedListener(editTextArray)
    }
    /** [16] setOnFocusChangedListner은 EditText가 활성화 되어있는지 확인해주는 함수이다.
     *       setTextChangedListener와 마찬가지로 editTextArray를 넘겨준다.**/
    private fun setOnFocusChangedListener() {
        viewModel.setOnFocusChangedListener(editTextArray)

    }
    /** [10] 뷰모델에 있는 이름 유효성 검사 로직을 가진 함수를 호출해준다.
     *       그리고 EditText에 적혀있는 입력값을 문자열로 바꾸어 전달한다.**/
    private fun getMessageValidName() {
        viewModel.setMessageValidName(binding.etName.text.toString())
    }

    private fun getMessageValidId() {
        viewModel.setMessageValidId(binding.etId.text.toString())
    }

    private fun getMessageValidPassword() {
        viewModel.setMessageValidPassword(binding.etPw.text.toString())
    }
    /** [13] 받은 값 그대로 뷰모델에 있는 setNullIfValid 함수로 바로 전해준다.
     *       editText에 Error 함수를 사용하여 유효성 검사를 하고 싶은데 유효한 경우가 현재 Null이 아니고 empty String이어서 
     *       empty string을 null로 바꾸어주는 함수이다.**/
    private fun getNullIfValid(type: String, message: String) {
        viewModel.setNullIfValid(type, message)
    }

    private fun setConfirmButtonEnable() {
        viewModel.enableConfirmButton(checkName, checkId, checkPassword)
    }

    /** [9] 어떤 EditText가 전달되었느냐에 따라 이름, 아이디 그리고 비밀번호의 유효성검사를 해준다.
     * 이름 유효성 검사로 가보자!**/
    private fun setErrorMessage(type: String?) {
        when (type) {
            "name" -> getMessageValidName()
            "id" -> getMessageValidId()
            "password" -> getMessageValidPassword()
        }
    }
}

SignUpViewModel.kt

package com.example.startactitivity.signup

import android.widget.EditText
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.example.startactitivity.signup.SignUpValidExtension.includeAlphabetAndNumber
import com.example.startactitivity.signup.SignUpValidExtension.includeKorean
import com.example.startactitivity.signup.SignUpValidExtension.includeSpecialCharacters

/** [2] 뷰모델 클래스는 뷰모델을 상속받는다 (당연히...**/
class SignUpViewModel : ViewModel() {

    private val _nameErrorMessage: MutableLiveData<SignUpErrorMessage> = MutableLiveData()
    private val _idErrorMessage: MutableLiveData<SignUpErrorMessage> = MutableLiveData()
    private val _passwordErrorMessage: MutableLiveData<SignUpErrorMessage> = MutableLiveData()
    private val _checkName: MutableLiveData<String?> = MutableLiveData()
    private val _checkId: MutableLiveData<String?> = MutableLiveData()
    private val _checkPassword: MutableLiveData<String?> = MutableLiveData()
    private val _enableConfirmButton: MutableLiveData<Boolean> = MutableLiveData()
    private val _editTextOnFocus: MutableLiveData<String?> = MutableLiveData()
    private val _editTextTextChange: MutableLiveData<String?> = MutableLiveData()
    val nameErrorMessage: LiveData<SignUpErrorMessage> get() = _nameErrorMessage
    val idErrorMessage: LiveData<SignUpErrorMessage> get() = _idErrorMessage
    val passwordErrorMessage: LiveData<SignUpErrorMessage> get() = _passwordErrorMessage
    val checkName: LiveData<String?> get() = _checkName
    val checkId: LiveData<String?> get() = _checkId
    val checkPassword: LiveData<String?> get() = _checkPassword
    val enableConfrimButton: LiveData<Boolean> get() = _enableConfirmButton
    val editTextOnFocus: LiveData<String?> get() = _editTextOnFocus
    val editTextTextChange: LiveData<String?> get() = _editTextTextChange
    /** [11] 유효성 검사에 필요한 문자열들이 모두 SignUpErrorMessage 클래스에 들어있다.
     *       여기서 문자열을 바로 가져오면 뷰모델에 뷰를 가져오는 것이므로 MVVM을 따르지 않는 것이다.
     *       따라서 변수의 타입을 SignUpErrorMessage로 해주었다.
     *       SignUpErrorMessage.NULL이 입력값이 유효한 경우이고 SignUpErrorMessage.NULL 은 비어있는 문자열이다.**/
    fun setMessageValidName(text: String) {
        _nameErrorMessage.value = when {
            text.isBlank() -> SignUpErrorMessage.EMPTY_NAME
            text.includeKorean() -> SignUpErrorMessage.NULL
            else -> SignUpErrorMessage.INVALID_NAME
        }
    }

    fun setMessageValidId(text: String) {
        _idErrorMessage.value = when {
            text.isBlank() -> SignUpErrorMessage.EMPTY_ID
            text.includeAlphabetAndNumber() -> SignUpErrorMessage.NULL
            else -> SignUpErrorMessage.INVALID_ID
        }
    }

    fun setMessageValidPassword(text: String) {
        _passwordErrorMessage.value = when {
            text.isBlank() -> SignUpErrorMessage.EMPTY_PASSWORD
            text.includeSpecialCharacters() -> SignUpErrorMessage.NULL
            else -> SignUpErrorMessage.INVALID_PASSWORD
        }
    }

    /** [14] 타입에 따라 각각 checkName, checkId, checkPassword에 들어가는 값을 지정해준다.
     *       유효하면(isEmpty()인경우) null값을 부여해주고 아니면 원래 있었던 text를 전달한다.
     *       이 유효성 검사 작업이 모든 EditText에 동일하게 적용되기 때문에 뷰모델 안에 editTextValidation 함수를 만들었다.**/
    fun setNullIfValid(type: String, text: String) {
        when (type) {
            "name" -> {
                _checkName.value = editTextValidation(text)
            }

            "id" -> {
                _checkId.value = editTextValidation(text)
                }

            "password" -> {
                _checkPassword.value = editTextValidation(text)
            }

        }
    }
    /** [15] 앞서 말했듯이 문자열이 비어있으면 null을 부여 아닌 경우에는 원래 있었던 문자열을 리턴한다.**/
    private fun editTextValidation(text:String):String? {
        val validate = when {
            text.isEmpty() -> null
            else -> text
        }
        return validate
    }

    fun enableConfirmButton(name: String?, id: String?, password: String?) {
        _enableConfirmButton.value = when {
            name == null && id == null && password == null -> true
            else -> false
        }
    }
    /** [17] 에딧텍스트가 활성화 되어있을 경우엔ㄴ setTextChangedListener이 잘 작동할 것이기 때문에 아무런 작업을 해줄 필요가 없다.
     *       근데 에딧텍스트가 비활성화 되었을 경우에 에딧텍스트에 들어 있는 값이 유효한지 마지막으로 한번 확인해 주어야한다.
     *       따라서 hasFocus가 false일 때 어느 editText인지 구분하기 위해 전과 마찬가지로 type을 지정해준다.
     *       만약 값이 유효하다면 이때도 null값을 가진다.**/
    fun setOnFocusChangedListener(editTextArray: Array<EditText>) {
        val type = arrayOf("name", "id", "password")
        for (i in type.indices) {
            editTextArray[i].setOnFocusChangeListener { _, hasFocus ->
                _editTextOnFocus.value = when (hasFocus) {
                    false -> type[i]
                    else -> null
                }
            }
        }

    }

    /** [7] editTextArray에 들어있는 editText의 순서가 이름/아이디/비밀번호이기 때문에
     *      동일한 인덱스를 사용하여 코드의 가독성을 높이기 위해 type이라는 배열에
     *      "name, "id", "password"의 상수들을 저장해 주었다.
     *      이 type이라는 배열은 앞으로도 같은 형식으로 계속 사용할 것이다.
     *      이제 각각의 에딧텍스트가 변경 될때마다 어떤 에딧텍스트의 값이 변경되었는지 editTextTextChange라는 변수에 저장해 준다.
     *      이 값을 이제 initViewModel() 함수에서 관찰해 줄 것이다.**/
    fun setTextChangedListener(editTextArray: Array<EditText>) {
        val type = arrayOf("name", "id", "password")
        for (i in type.indices) {
            editTextArray[i].addTextChangedListener {
                _editTextTextChange.value = type[i]
            }
        }
    }


}