[Android] Part1-단위 변환기 앱
아래 내용은 패스트 캠퍼스 [35개 프로젝트로 배우는 Android 앱 개발 feat. Jetpack Compose]의 강의 내용을 기반으로 작성한 자습 기록입니다.
📚단위 변환기 앱
ℹ️앱 설명
미터(m)와 센티미터(cm) 간 단위를 변환시켜 주는 앱. 변환을 원하는 값을 입력하면, 아래 변환된 값을 노출시키는 앱.
✅구현 기능
- 상단 부분에 변환되길 원하는 값 입력.
- 하단 부분에 변환된 값 출력.
바꾸기
이미지 버튼을 누르면 두 단위의 값을 변환. ex) cm -> m, m -> cm
✅사용되는 기능
- UI
- ConstraintLayout
- EditText
- Android
- ViewBinding
- onSaveInstanceState
📔UI 구현
단위 변환기 앱은 ConstraintLayout을 기반으로 구현할 것이다. 프로젝트를 생성하자마자 ConstraintLayout이 설정된 기본 화면을 볼 수 있다. ConstraintLayout에 대해서 나중에 자세히 다루어보도록 하자.
<?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">
</androidx.constraintlayout.widget.ConstraintLayout>
UI는 단위를 입력해야 하기 때문에 입력 부분에는 EditText를 상단에 배치하였고, 하단에는 변환된 결괏값을 출력해야 하므로 TextView를 지정하였다. 그리고 각각의 View 옆에 단위를 표시하는 TextView 2개를 표시하였다. 마지막으로, 클릭하였을 때 두 단위를 바꾸는 ImageButton을 추가하였다.
📖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:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<EditText
android:id="@+id/inputEditText"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:gravity="end"
android:hint="자연수를 입력해주세요."
android:inputType="number"
android:maxLength="7"
android:textColor="@color/purple_700"
android:textColorHint="@color/purple_200"
android:textStyle="italic"
app:layout_constraintBottom_toTopOf="@id/outputTextView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.3"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/outputTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:gravity="end"
android:text="100"
android:textColor="@color/black"
android:textSize="30sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/inputEditText"
app:layout_constraintStart_toStartOf="@id/inputEditText"
app:layout_constraintTop_toBottomOf="@+id/inputEditText" />
<TextView
android:id="@+id/inputUnitTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="cm"
android:textSize="20sp"
app:layout_constraintBaseline_toBaselineOf="@id/inputEditText"
app:layout_constraintStart_toEndOf="@id/inputEditText"
app:layout_constraintTop_toTopOf="@id/inputEditText" />
<TextView
android:id="@+id/outputUnitTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="m"
android:textSize="20sp"
app:layout_constraintBaseline_toBaselineOf="@id/outputTextView"
app:layout_constraintStart_toEndOf="@id/outputTextView" />
<ImageButton
android:id="@+id/swapImageButton"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginStart="16dp"
android:backgroundTint="#ffffffff"
android:src="@drawable/ic_baseline_swap_vert_36"
app:layout_constraintBottom_toBottomOf="@id/outputTextView"
app:layout_constraintStart_toEndOf="@id/inputUnitTextView"
app:layout_constraintTop_toTopOf="@id/inputEditText" />
</androidx.constraintlayout.widget.ConstraintLayout>
위의 코드의 결과는 다음과 같다.
제약 조건을 설정함에 있어서 각각의 view는 필수적으로 id 값을 갖고 있어야 한다. id 값이 없다면 view들을 구분할 수 없어 배치하기가 어렵다.
📔ViewBinding
이번 프로젝트는 findViewById를 사용하지 않고 viewBinding을 이용하였다.
viewBinding은 findViewById와 비교해 다음과 같은 장점을 가진다.
📖ViewBinding의 장점
- Null 안정성: findViewById에서는 유효하지 않은 id를 사용했을 때 NullException이 발생하는데, viewBinding은 바인딩이 각각의 레이아웃에 1대 1로 할당되기 때문에 이런 오류가 발생할 일이 없다.
- Type 안정성: findViewById에서는 해당 id의 타입을 잘못 입력해서 사용했을 때 오류가 CastException이 발생하지만, viewBinding에서는 이러한 오류가 발생할 수 없다.
- 속도가 빠르다.
이제 viewBinding을 사용해보자.
아래 과정은 안드로이드 developer 공식문서의 과정을 참고하였다.
먼저 build.gradle(module) 파일에서 아래와 같은 코드를 추가한 후 sync를 한다.
📖build.gradle(module)
android {
...
viewBinding {
enabled = true
}
}
그리고 클래스 파일과 와서 다음의 순서를 따른다.
- onCreate() 메서드에서 생성된 바인딩 클래스에 포함된 inflate() 메서드를 호출한다.
- getRoot() 메서드를 호출하거나 Kotlin 속성 구문을 사용하여 루트 뷰 참조를 가져온다.
- 루트 뷰를 setContentView()에 전달하여 화면상의 활성 뷰로 만든다.
📖MainActivity.kt
private lateinit var binding: ActivityMainBinding
바인딩 변수를 먼저 선언해 주었다. 뷰 바인딩을 허용했기 때문에 ActivityMainBinding
와 같이 바인딩을 위한 클래스가 생성된 것을 볼 수 있다. 이때, lateinit 키워드로 지연 초기화를 해주었는데, 이 키워드를 사용할 때에는 타입이 반드시 명시되어 있어야 한다.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// binding 변수에 인스턴스 할당
binding = ActivityMainBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
}
이렇게 하면 viewBinding을 사용하기 위한 준비가 끝났다.
val outputTextView = binding.outputTextView
val outputUnitTextView = binding.outputUnitTextView
val inputEditText = binding.inputEditText
val inputUnitTextView = binding.inputUnitTextView
val swapImageButton = binding.swapImageButton
view에 접근하는 방법은 다음과 같이 binding.~
을 사용해서 view에 접근할 수 있다.
📔기능 구현 - 단위 변환
먼저 inputEditText.addTextChangedListener{}
를 사용해 변환하고자 하는 값이 입력될 때마다 기능을 수행하도록 설정하고 로그를 찍어 잘 입력되었는지 확인해 보았다.
📖MainActivity.kt
inputEditText.addTextChangedListener{ text ->
inputNumber = text.toString().toInt()
Log.d("inputNumber", inputNumber.toString())
}
입력값이 잘 들어가는 것을 확인할 수 있다.
⚠️오류
입력을 계속해서 확인하던 중 오류가 발생했는데, 오류의 내용은 다음과 같았다.
오류는 NumberFormatException으로 해당 내용에서는 위의
inputNumber = text.toString().toInt()
문장에서 toInt()를 통해 String을 Int 타입으로 변환하는 중 오류가 발생한 것 같다. Integer 자료형의 표현 가능 범위는 -2147483648
~2147483647
이다. 즉, 입력값이 Integer 자료형이 표현할 수 있는 범위를 초과했기 때문에 발생한 오류인 것이다. 이를 해결하기 위해 try ~ catch
구문을 사용하여 예외를 처리해 줄 수 있지만, 더 쉬운 방법으로 해결하였다.
EditText의 속성값을 활용해서 이 오류를 해결할 수 있다. android:maxLength
속성값은 입력할 수 있는 범위의 최대 자릿수를 설정할 수 있게 해주는데, 여기서 Integer 자료형의 범위를 넘지 않도록 7로 값을 설정해 주었다. 9가 아닌 7로 설정한 이유는 나중에 이미지 버튼을 눌러 단위를 서로 바꾸어 주는 데 있어 m -> cm로 변환시에 cm 값은 100을 곱해주어야 하기 때문에 9로 설정했을 경우 또다시 Integer 자료형의 범위를 초과할 수 있기 때문이다.
activity_main.xml
<EditText
...
android:maxLength="7"
...
/>
값을 변경하고 난 후 실행해 보니 입력할 수 있는 범위가 9자리를 초과하지 않도록 설정된 것을 볼 수 있었다.
해당 오류를 해결하고 나니 또 다른 오류가 발생했는데, 이번에는 다른 오류가 발생했다.
⚠️오류
이번에도 오류의 내용은 NumberFormatException
이었는데 오류의 내용이 다음과 같았다.
java.lang.NumberFormatException: For input string: ""
입력한 뒤 지우다가 빈 값이 되었을 때 발생하는 오류였는데, 이 과정에서 숫자로 변환할 수 없기 때문에 발생한 듯하다. 이를 위해 값을 입력하는 과정에서 null 체크를 하는 코드를 추가하였다.
inputEditText.addTextChangedListener { text ->
inputNumber = if (text.isNullOrEmpty()) {
0
} else {
text.toString().toInt()
}
Log.d("inputNumber", inputNumber.toString())
}
이후 다시 실행을 해보니 오류가 해결된 것을 볼 수 있었다.
다시 돌아와서, 변환된 결괏값을 보여주기 위해 다음과 같이 outputNumber
라는 변수를 생성하였고, 여기에 변환된 값을 넣어 outputTextView
에 값을 띄우도록 했다.
var outputNumber = inputNumber.times(0.01)
outputTextView.text = outputNumber.toString()
그리고 cm -> m로의 변환인지, 아니면 그 반대 경우인지를 구분하기 위해 cmToM
라는 변수를 생성하였고 기본값으로 true
를 지정해 주었다.
var cmToM = true
이후 단위 변환 ImageButton
이 눌렸을 때마다 두 단위가 서로 바뀌기 때문에 Boolean으로 설정된 cmToM
의 값을 not()
키워드를 사용하여 변경되도록 해주었고, 이때 각각의 단위에 맞게 보이는 값들 또한 변경될 수 있도록 해주었다. 또한 앞서 작성했던 outputNumber
변수는 계산식이 중복이 되기 때문에 지운 뒤, 변환이 된 뒤에도 변환된 값으로 입력을 계속해서 할 수 있도록 해당 구문에 변환이 구현되는 코드를 추가해 주었다.
inputEditText.addTextChangedListener { text ->
inputNumber = if (text.isNullOrEmpty()) {
0
} else {
text.toString().toInt()
}
if(cmToM) {
outputTextView.text = inputNumber.times(0.01).toString()
} else {
outputTextView.text = inputNumber.times(100).toString()
}
}
swapImageButton.setOnClickListener {
cmToM = cmToM.not()
if (cmToM) {
inputUnitTextView.text = "cm"
outputUnitTextView.text = "m"
outputTextView.text = inputNumber.times(0.01).toString()
} else {
inputUnitTextView.text = "m"
outputUnitTextView.text = "cm"
outputTextView.text = inputNumber.times(100).toString()
}
}
각각 잘 구현된 것을 확인할 수 있다.
📔기능 구현 - 화면전환 시 데이터 유지
마지막으로 화면 전환 시에도 값이 유지될 수 있도록 해보자. 저번과 마찬가지로 onSaveInstanceState()
를 활용한다. 다만 이번에는 데이터를 복원할 때 onCreate()
에서 저장된 Bundle 값을 활용해서 가져와 복원했던 것과는 달리, onRestoreInstanceState()
를 활용해서 복원해 보도록 한다.
onRestoreInstanceState()
는 onStart()
메서드 다음에 호출되며, 복원할 저장 상태가 있을 경우에 해당 메서드를 호출한다. 따라서 저번처럼 Bundle이 null인지 확인할 필요가 없다.
먼저 onSaveInstanceState()
를 사용하여 저장할 값을 지정해 주었다. 여기서는 cmToM
값이 유지가 되지 않기 때문에 저장시켜 주었다.
override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean("cmToM", cmToM)
super.onSaveInstanceState(outState)
}
이후 onRestoreInstanceState()
를 사용해 저장된 값을 불러왔다. cmToM
값 이외에도 해당 값에 영향을 받는 inputUnitTextView
와 outputTextView
값 또한 저장된 값에 따라 설정될 수 있도록 적용시켜 주었다.
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
cmToM = savedInstanceState.getBoolean("cmToM")
binding.inputUnitTextView.text = if(cmToM) "cm" else "m"
binding.outputUnitTextView.text = if(cmToM) "m" else "cm"
super.onRestoreInstanceState(savedInstanceState)
}
위 코드들을 추가하고 실행시켜보니 값이 잘 유지되는 것을 볼 수 있었다.
📔전체 코드
https://github.com/Becomeproo/unit-transducers/tree/master
📝마무리
이번 과정을 진행하며 다시 한번 Activity Lifecycle은 확실히 잡고 가야 한다는 생각을 했고 조금 더 레퍼런스들을 자주, 더 많이 들여야 보아야겠다는 생각을 하게 되었다. 이제는 정말 부지런해질 필요가 있음을 느낀다.