내일배움캠프 7기 Android TIL 16일차 (2023.08.04)
일일 회고
벌써 금요일이다. 이번 주도 벌써 마무리 되어간다.
강의부터 시작해서 문법, 안드로이드 공부, 과제까지 뭔가 꽤 한 것 같긴하다.
최대한 기록으로 남겨보려고는 하지만 쉽지는 않은 것 같다.
주말은 다음주를 위해 리프레쉬 해야겠다.
오늘의 키워드
- 개인 과제 고도화 (TextInputLayout)
개인 과제를 고도화해 보았다.
아이디나 이름, 비밀번호를 입력할 때 토스트 메시지뿐만 아니라 어디가 잘못되었는지 시각적으로 보여주고 싶었다.
그래서 TextInputLayout을 도입하였다.
TextInput Layout
TextInputLayout은 Linear layout을 상속받고, layout안에서 TextInputEditText에 입력된 텍스트에 반응하는 레이아웃이다
TextInputEditText는 EditText의 상위 버전으로 볼 수 있다.
대표적인 기능 몇 가지와 사용법에 대해 알아보자
- setErrorEnabled(boolean)및 setError(CharSequence)를 통해 오류 표시
- setErrorIconDrawable(Drawable)를 통해 오류 아이콘 표시
- setHelperTextEnabled(boolean)및 setHelperText(CharSequence)를 통해 도우미 텍스트 표시
- setPlaceholderText(CharSequence)을 통해 placeholder(미리 보기) 표시
- setPrefixText(CharSequence)을 통해 접두사 텍스트 표시
- setSuffixText(CharSequence)을 통해 접미사 텍스트 표시
- setCounterEnabled(boolean)및 setCounterMaxLength(int)를 통해 문자 카운터 표시
등등 다양한 기능들을 지원한다. 이러한 기능들을 통해 EditText를 조금 더 다채롭게 꾸며볼 수 또 있다.
그리고 google material design이 적용되어 있어 outlined box, filled box 등 다양한 디자인을 바로 적용해 볼 수 있다.
Material Design
Build beautiful, usable products faster. Material Design is an adaptable system—backed by open-source code—that helps teams build high quality digital experiences.
m3.material.io
TextInput Layout 적용기
이제 과제에서 적용한 방법을 소개해보겠다.
기존 로그인 및 회원가입 EditText (activity_sign_up.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=".SignInActivity">
<ImageView
android:id="@+id/imageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="80dp"
android:src="@drawable/baseline_person_outline_48"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/textView4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="100dp"
android:text="@string/name"
app:layout_constraintBottom_toTopOf="@+id/editTextName"
app:layout_constraintStart_toStartOf="parent" />
<!-- 일반적인 EditText -->
<EditText
android:id="@+id/editTextName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="100dp"
android:ems="10"
android:inputType="text"
android:hint="@string/put_name"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.500"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageView" />
<TextView
android:id="@+id/textView3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="100dp"
android:text="@string/id"
app:layout_constraintBottom_toTopOf="@+id/editTextSignId"
app:layout_constraintStart_toStartOf="parent" />
<!-- 일반적인 EditText -->
<EditText
android:id="@+id/editTextSignId"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="50dp"
android:ems="10"
android:inputType="text"
android:hint="@string/put_id"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.500"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/editTextName" />
<TextView
android:id="@+id/textView2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/password"
app:layout_constraintBottom_toTopOf="@+id/editTextTextSignPassword"
app:layout_constraintStart_toStartOf="@+id/editTextTextSignPassword" />
<!-- 일반적인 EditText -->
<EditText
android:id="@+id/editTextTextSignPassword"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="50dp"
android:ems="10"
android:hint="@string/put_password"
android:inputType="textPassword"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.500"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/editTextSignId" />
<Button
android:id="@+id/buttonSignLogIn"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="36dp"
android:text="@string/log_in"
app:layout_constraintEnd_toEndOf="@+id/editTextTextSignPassword"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/editTextTextSignPassword"
app:layout_constraintTop_toBottomOf="@+id/editTextTextSignPassword" />
</androidx.constraintlayout.widget.ConstraintLayout>
[ SignUpActivity.kt ]
package com.sparta.nbcamp_android_basic
import android.app.Activity
import android.app.Instrumentation.ActivityResult
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Button
import android.widget.EditText
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.widget.doOnTextChanged
import com.sparta.nbcamp_android_basic.model.User
import com.sparta.nbcamp_android_basic.util.toast
class SignUpActivity : AppCompatActivity() {
private val editTextName by lazy { findViewById<EditText>(R.id.editTextName)}
private val editTextId by lazy { findViewById<EditText>(R.id.editTextSignId)}
private val editTextPwd by lazy { findViewById<EditText>(R.id.editTextTextSignPassword)}
private val btnSignIn by lazy {findViewById<Button>(R.id.buttonSignLogIn)}
private lateinit var name: String
private lateinit var id: String
private lateinit var pwd: String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_sign_up)
initEditText()
initButton()
}
private fun initButton() {
btnSignIn.setOnClickListener {
if(::name.isInitialized.not() || ::id.isInitialized.not() || ::pwd.isInitialized.not()) {
toast("입력되지 않은 정보가 있습니다.")
return@setOnClickListener
}
if(list.count{ it.id == id } >= 1) {
toast("중복된 아이디가 있습니다.")
return@setOnClickListener
}
val intent = Intent(this, SignInActivity::class.java).apply {
putExtra("ID", id)
putExtra("PWD",pwd)
}
list.add(User(id, pwd))
setResult(RESULT_OK, intent)
finish()
}
}
private fun initEditText() {
editTextName.doOnTextChanged { text, _, _, _ ->
name = text.toString()
}
editTextId.doOnTextChanged { text, _, _, _ ->
id = text.toString()
}
editTextPwd.doOnTextChanged { text, _, _, _ ->
pwd = text.toString()
}
}
companion object {
val list = mutableListOf<User>()
}
}
일반적인 editText를 넣고 editText에 이벤트를 걸어 변수에 바인딩을 시켜주었다.
결과적으로 다음과 같은 동작 과정이 나온다.
단순히 토스트 메시지만 보일 뿐 다른 시각 적인 효과는 없어 밋밋한 느낌이 있다.
이제 TextInputLayout을 적용시켜서 시각적인 효과를 넣어보자
효과는 입력을 하지 않은 EditText가 있을 때, 해당 위젯이 좌우로 흔들리는 효과와 에러 문구가 출력되는 효과이다.
[ activity_sign_up.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=".SignInActivity">
<ImageView
android:id="@+id/imageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="80dp"
android:src="@drawable/baseline_person_outline_48"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/textView4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="100dp"
android:text="@string/name"
app:layout_constraintBottom_toTopOf="@+id/textInputLayoutName"
app:layout_constraintStart_toStartOf="parent" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/textInputLayoutName"
style="@style/EditTextAppStyle"
android:theme="@style/EditTextAppStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="50dp"
android:layout_marginStart="100dp"
android:layout_marginEnd="100dp"
app:layout_constraintHorizontal_bias="0.500"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageView" >
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:hint="@string/put_name" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/textView3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="100dp"
android:text="@string/id"
app:layout_constraintBottom_toTopOf="@+id/textInputLayoutId"
app:layout_constraintStart_toStartOf="parent" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/textInputLayoutId"
style="@style/EditTextAppStyle"
android:theme="@style/EditTextAppStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="50dp"
android:layout_marginStart="100dp"
android:layout_marginEnd="100dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.500"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textInputLayoutName"
>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextSignId"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="50dp"
android:ems="10"
android:inputType="text"
android:hint="@string/put_id"
/>
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/textView2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/password"
app:layout_constraintBottom_toTopOf="@+id/textInputLayoutPwd"
app:layout_constraintStart_toStartOf="@+id/textInputLayoutPwd" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/textInputLayoutPwd"
style="@style/EditTextAppStyle"
android:theme="@style/EditTextAppStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="50dp"
android:layout_marginStart="100dp"
android:layout_marginEnd="100dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.500"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textInputLayoutId">
<EditText
android:id="@+id/editTextTextSignPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="50dp"
android:ems="10"
android:hint="@string/put_password"
android:inputType="textPassword"
/>
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/buttonSignLogIn"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="36dp"
android:text="@string/sign_in"
app:layout_constraintEnd_toEndOf="@+id/textInputLayoutPwd"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/textInputLayoutPwd"
app:layout_constraintTop_toBottomOf="@+id/textInputLayoutPwd" />
</androidx.constraintlayout.widget.ConstraintLayout>
기존의 EditText에 TextInputLayout을 상위레이아웃으로 감싼다.
그리고 EditText를 TextInputLayout에 맞는 TextInputEditText로 바꾼다.
여기서 기존의 EditText와 다르게 style, theme이 추가가 되었는데 이것은 TextInputLayout에 어떤 스타일, 테마를 적용시킬지 정의하는 것이다.
나는 TextInputLayout을 조금 커스텀해보기로 했다.
[ themes.xml ]
<resources xmlns:tools="http://schemas.android.com/tools">
...
<style name="EditTextAppStyle" parent="Widget.Design.TextInputLayout">
<item name="errorTextColor">@color/error</item>
<item name="boxStrokeErrorColor">@color/error</item>
<item name="errorIconDrawable">@null</item>
<item name="hintAnimationEnabled">false</item>
<item name="hintEnabled">false</item>
<item name="android:textCursorDrawable">@color/gray</item>
</style>
</resources>
먼저 style의 이름을 EditTextAppStyle로 두고 TextInputLayout을 커스텀하기 위해 커스텀할 부모 레이아웃인
Widget.Design.TextInputLayout을 parent로 두고 작업을 진행하였다.
(TextInputLayout 뿐만 아니라 TextView, Button 등도 parent에 두고 하위에서 item 태그로 각 위젯의 속성들을 커스텀할 수 있다.)
속성들을 차례로 설명을 하면
- errorTextColor - 에러 문구의 색을 지정
- boxStrokeErrorColor - 에러 발생 시 TextInputLayout(EditText)의 테두리 색을 지정
- errorIconDrawable - 에러 발생 시 생기는 아이콘 지정 (여기서는 지정하지 않아 null 처리)
- hintAnimationEnabled - hint animation을 사용할지의 여부
- hintEnabled - hint를 사용할지의 여부
이외에도 다양한 속성들이 있으므로 사용할 때마다 필요한 것을 찾아 적용시키면 된다.
[ cycle.xml ]
<?xml version="1.0" encoding="utf-8"?>
<cycleInterpolator
xmlns:android="http://schemas.android.com/apk/res/android"
android:cycles="5"
/>
[ shake.xml ]
<?xml version="1.0" encoding="utf-8"?>
<translate
xmlns:android="http://schemas.android.com/apk/res/android"
android:fromXDelta="0"
android:toXDelta="5"
android:duration="500"
android:interpolator="@anim/cycle"
/>
cycle과 shake는 TextInputLayout이 좌우로 흔들리는 애니메이션을 보여주기 위해 필요한 것들이다.
shake의 속성을 보면
fromXDelta - toXDelta가 있는데 이건 x좌표로 from ~ to까지 이동을 하겠다는 뜻이고, duration은 지속할 시간을 의미한다.
즉, 500ms, 0.5초 동안 0~5만큼 이동을 한다는 의미이다.
마지막에는 interpolator가 있는데 이것이 중요하다. 바로 애니메이션을 어떤 식으로 표현할지를 결정하는 것이다.
interpolator는 cycle interpolator를 정하였는데 이것은 종료지점에 갔다가 시작지점으로 돌아오는 주기가 있는 애니메이션을 구현할 때 사용한다. cycle을 5를 지정해 주었으므로 5번을 반복하게 된다.
이 애니메이션을 하단에 kotlin을 통해 적용을 한다.
[ SignUpActivity.kt ]
package com.sparta.nbcamp_android_basic
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Button
import android.widget.EditText
import androidx.core.widget.doOnTextChanged
import com.google.android.material.textfield.TextInputLayout
import com.sparta.nbcamp_android_basic.model.User
import com.sparta.nbcamp_android_basic.util.shake
import com.sparta.nbcamp_android_basic.util.toast
import com.sparta.nbcamp_android_basic.util.validateEmpty
class SignUpActivity : AppCompatActivity() {
private val editTextName by lazy { findViewById<EditText>(R.id.editTextName)}
private val editTextId by lazy { findViewById<EditText>(R.id.editTextSignId)}
private val editTextPwd by lazy { findViewById<EditText>(R.id.editTextTextSignPassword)}
private val btnSignIn by lazy {findViewById<Button>(R.id.buttonSignLogIn)}
private val textInputId by lazy { findViewById<TextInputLayout>(R.id.textInputLayoutId)}
private val textInputPwd by lazy { findViewById<TextInputLayout>(R.id.textInputLayoutPwd)}
private val textInputName by lazy { findViewById<TextInputLayout>(R.id.textInputLayoutName)}
private var name: String = ""
private var id: String = ""
private var pwd: String = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_sign_up)
initEditText()
initButton()
}
private fun initButton() {
btnSignIn.setOnClickListener {
// 확인
if(!checkValidation()) {
toast(getString(R.string.empty_info_exist))
return@setOnClickListener
}
if(list.count{ it.id == id } >= 1) {
toast(getString(R.string.duplicated_id))
return@setOnClickListener
}
val intent = Intent(this, SignInActivity::class.java).apply {
putExtra("ID", id)
putExtra("PWD",pwd)
}
list.add(User(id, pwd))
setResult(RESULT_OK, intent)
finish()
}
}
private fun checkValidation(): Boolean {
// 먼저 check를 true로 두고 하나라도 입력이 안된 것 발견 시 false로 변경
var check = true
if (!validateEmpty(textInputId, id, getString(R.string.check_id))) {
shake(editTextId, this@SignUpActivity)
check = false
}
if (!validateEmpty(textInputPwd, pwd, getString(R.string.check_pwd))) {
shake(editTextPwd, this@SignUpActivity)
check = false
}
if (!validateEmpty(textInputName, name, getString(R.string.check_name))) {
shake(editTextName, this@SignUpActivity)
check = false
}
return check
}
private fun initEditText() {
editTextName.doOnTextChanged { text, _, _, _ ->
name = text.toString()
}
editTextId.doOnTextChanged { text, _, _, _ ->
id = text.toString()
}
editTextPwd.doOnTextChanged { text, _, _, _ ->
pwd = text.toString()
}
}
companion object {
val list = mutableListOf<User>()
}
}
[ Validation.kt ]
package com.sparta.nbcamp_android_basic.util
import android.content.Context
import android.widget.EditText
import com.google.android.material.textfield.TextInputLayout
import com.sparta.nbcamp_android_basic.R
fun validateEmpty(inputLayout: TextInputLayout, str: String, message:String): Boolean {
return if (str == "") {
inputLayout.error = message
false
} else {
initValidate(inputLayout)
true
}
}
fun initValidate(inputLayout: TextInputLayout) {
inputLayout.apply {
error = null
isErrorEnabled = false
}
}
액티비티에서는 이전과 다르게 checkValidation이라는 메서드를 만들었다.
checkValidation을 통과하게 되면 id, password를 넘기면서 로그인 페이지로 넘어가게 된다.
checkValidation 안에서는 validateEmpty는 메소드를 호출해 모든 EditText에 값이 있는지 없는지 검사를 한다.
값이 없는 EditText 같은 경우 에러 메시지를 띄우고 shake 애니메이션을 실행하여 어떤 부분이 비어있는지 시각적인 효과를 주게 된다.
[ TextInputLayoutUtil.kt ]
import android.content.Context
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import android.widget.EditText
import com.sparta.nbcamp_android_basic.R
fun shake(editText: EditText, context: Context) {
// 애니메이션 객체를 만들고
val animation: Animation = AnimationUtils.loadAnimation(context, R.anim.shake)
// editText에 적용
editText.startAnimation(animation)
editText.requestFocus()
}
안드로이드에서는 애니메이션 작업을 위한 공통 유틸리티가 있는 AnimationUtils라는 클래스가 있다. 이 클래스의 loadAnimation이라는 메서드로 애니메이션을 불러오고
editText에 애니메이션을 적용시키는 메서드인 startAnimation에 생성한 애니메이션을 넣어준다.
최종적으로 TextInputLayout(EditText)에 애니메이션을 적용시킨 후의 동작이다.
++ 디자인 수정....?
++ 깜짝 도전과제
버튼 클릭시 동적으로 디자인변경 (kt파일 변경없이 xml파일로만!! - selector사용)
ref.
TextInputLayout | Android Developers
developer.android.com
AnimationUtils | Android Developers
developer.android.com
애니메이션 리소스 | Android 개발자 | Android Developers
애니메이션 리소스 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 애니메이션 리소스는 다음 두 가지 유형의 애니메이션 중 하나를 정의할 수 있습니다. 속
developer.android.com