값을 전달 받을 곳에 %s.
// string.xml
<string name="tip_amount">Tip Amount: %s</string>
tools 속성으로 텍스트를 세팅하는 것이 포인트다.
// activity_main.xml
<TextView
android:id="@+id/tip_result"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="Tip Amount: $10"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/calculate_button" />
// MaincActivity.kt
val formattedTip = NumberFormat.getCurrencyInstance().format(tip) // 숫자 포매팅
binding.tipResult.text = getString(R.string.tip_amount, formattedTip)
아래 코드에서 위 오류가 나는 부분은 어디일까?
private fun calculateTip() {
val stringInTextField = binding.costOfService.text.toString()
val cost = stringInTextField.toDouble()
val selectedId = binding.tipOptions.checkedRadioButtonId
val tipPercentage = when (selectedId) {
R.id.option_twenty_percent -> 0.20
R.id.option_eighteen_percent -> 0.18
else -> 0.15
}
...
}
바로 cost 값을 받는 부분이다. 문자열을 입력 받는데 값이 비어있거나, 유효하지 않은 자료형일 경우 제목과 같은 오류가 난다. 그렇다면 해결할 수 있는 방법은?
private fun calculateTip() {
val stringInTextField = binding.costOfService.text.toString()
val cost = stringInTextField.toDoubleOrNull()
if (cost == null) {
binding.tipResult.text = "" // 초기화를 해서 이전에는 잘 보냈다가 새롭게 빈 값을 보냈을 때를 대비하자
return
}
val selectedId = binding.tipOptions.checkedRadioButtonId
val tipPercentage = when (selectedId) {
R.id.option_twenty_percent -> 0.20
R.id.option_eighteen_percent -> 0.18
else -> 0.15
}
...
}
다른 자료형 그러니까 문자열과 같은 경우를 따로 처리하지 않은 이유는, 애초에 키보드 입력을 numberDecimal
로만 받아서 숫자 입력만 가능하기 때문이다.
앱에서 사용하는 색상과 관련된 속성들은 다음과 같이 12개로 그룹화 되어있다.
cf) 색상코드 앞 #FF는 알파값으로 100% 불투명하다는 의미.
# | Name | Theme Attribute |
---|---|---|
1 | Primary | colorPrimary |
2 | Primary Variant | colorPrimaryVariant |
3 | Secondary | colorSecondary |
4 | Secondary Variant | colorSecondaryVariant |
5 | Background | colorBackground |
6 | Surface | colorSurface |
7 | Error | colorError |
8 | On Primary | colorOnPrimary |
9 | On Secondary | colorOnSecondary |
10 | On Background | colorOnBackground |
11 | On Surface | colorOnSurface |
12 | On Error | colorOnError |
https://material.io/resources/color/#!/?view.left=0&view.right=0
2023.04.11 new version
미리 정의된 팔레트가 다양하게 있고, 이것을 내 UI 적용했을 때 어떻게 보이는지 쉽게 확인 가능하다.
Android 8.0(API 26)부터 adaptive laucher icon이 지원되어서 앱 아이콘을 더욱 유연하고 시각적으로 구현할 수 있게 되었다. 앱 아이콘은 다음 그림처럼 back과 fore로 구성되어 있다.
다음과 같이 프로젝트 창 > res > mipmap-anydip-v26에서 리소스를 찾아 변경할 수 있다.
기본적으로는 아래 코드.
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
https://material.io/develop/android
UI 위젯으로 이 컴포넌트를 사용하면 앱이 사용자가 사용하는 장치의 다른 앱과 일관된 방식으로 작동한다. 이게 왜 중요하냐면 사용자에게 익숙하기 때문에 앱 사용법을 빨리 익힐 수 있게 된다!
먼저 뷰에 대해 두 가지 예시를 보자.
<EditText
android:id="@+id/cost_of_service"
android:layout_width="160dp"
android:layout_height="wrap_content"
android:hint="@string/cost_of_service"
android:inputType="numberDecimal"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
새 방식은 감싸는 형태다.<com.google.android.material.textfield.TextInputLayout
android:id="@+id/cost_of_service"
android:layout_width="160dp"
android:layout_height="wrap_content"
android:hint="@string/cost_of_service"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/cost_of_service_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal" />
</com.google.android.material.textfield.TextInputLayout>
이런 식으로 생겼기 때문!<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/round_up_switch"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:checked="true"
android:text="@string/round_up_tip"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/tip_options"
app:layout_constraintTop_toBottomOf="@id/tip_options" />
Style에 대해서도 지침이 있는데 우선은 TextView로 맛보기.
// style.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Widget.TipTime.TextView" parent="Widget.MaterialComponents.TextView">
<item name="android:minHeight">48dp</item>
<item name="android:gravity">center_vertical</item>
<item name="android:textAppearance">?attr/textAppearanceBody1</item>
</style>
</resources>
// activity_main.xml
<TextView
android:id="@+id/tip_result"
style="@style/Widget.TipTime.TextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="Tip Amount: $10"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/calculate_button" />
Attribute name | Default style |
---|---|
textAppearanceHeadline1 | Light 96sp |
textAppearanceHeadline2 | Light 60sp |
textAppearanceHeadline3 | Regular 48sp |
textAppearanceHeadline4 | Regular 34sp |
textAppearanceHeadline5 | Regular 24sp |
textAppearanceHeadline6 | Medium 20sp |
textAppearanceSubtitle1 | Regular 16sp |
textAppearanceSubtitle2 | Medium 14sp |
textAppearanceBody1 | Regular 16sp |
textAppearanceBody2 | Regular 14sp |
textAppearanceCaption | Regular 12sp |
textAppearanceButton | Medium all caps 14sp |
textAppearanceOverline | Regular all caps 10sp |
cf) 자주 활용하는 값은 dimens.xml에서 관리하면 편하다.
// style.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Widget.TipTime.TextView" parent="Widget.MaterialComponents.TextView">
<item name="android:minHeight">@dimen/min_text_height</item>
<item name="android:gravity">center_vertical</item>
<item name="android:textAppearance">?attr/textAppearanceBody1</item>
</style>
</resources>
// dimens.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="min_text_height">48dp</dimen>
</resources>
cf) RadioButton이나 Switch, TextLayout.OutlinedBox와 같은 스타일은 Theme.xml에서 수정해야 한다. 다크 버전에도 똑같이!
<!-- Text input fields -->
<item name="textInputStyle">@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox</item>
<!-- For radio buttons -->
<item name="radioButtonStyle">@style/Widget.TipTime.CompoundButton.RadioButton</item>
<!-- For switches -->
<item name="switchStyle">@style/Widget.TipTime.CompoundButton.Switch</item>
binding.costOfServiceEditText.setOnKeyListener { view, keyCode, _ -> handleKeyEvent(view, keyCode) }
private fun handleKeyEvent(view: View, keyCode: Int): Boolean {
if (keyCode == KeyEvent.KEYCODE_ENTER) {
// Hide the keyboard
val inputMethodManager =
getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0)
return true
}
return false
}
당장은 작은 프로그램만 만들기 때문에 수동 테스트가 쉽지만, 앱의 규모가 커지면 자동 테스트가 훨씬 유용하다.
딱 봐도 자동 테스트가 유용해보인다. 그럼 자동 테스트의 종류 중 하나인 단위 테스트를 어떻게 할까?
우선은 JUnit가 라이브러리가 필요하다.
testImplementation 'junit:junit:4.12'
단위 테스트는 항상 test 디렉토리에 있다. 아무것도 하지 않은 상태에서 샘플을 열어보자.
@TEST
주석을 달자.assertEquals()
는 예상값과 결과값이 일치하지 않으면 테스트가 실패했다 본다.cf) 다양한 assert 메서드
assertEquals()
assertNotEquals()
assertThat()
assertTrue()
assertFalse()
assertNull()
assertNotNull()
계측 테스트(Instrumentation Test)를 실습으로 알아보자
Parcel은 꾸러미다. 그러니까 짐을 싸듯 객체를 싸는 클래스를 바로 Parcel 클래스라 한다.
안드로이드에서는 프로세스 간 통신을 위해 Bundle 클래스를 사용하는데, Bundle은 Key와 Value 형태인 Map으로 내부에 많은 데이터가 들어간 것을 Value로 입력하기 쉽지 않다. 이런 때에 사용하는 것이 바로 Parcel이다.
그리고 이 Parcel을 쉽게 만들고 풀어주기 위한(비직렬화) 인터페이스를 Parcelable이라 한다.
참고로 아래 유형을 지원하니 슬쩍 알아두자.
이제 실습으로 어떻게 사용하는지 알아보자. 이 코드는 Room과 Navigation을 활용해 데이터를 변경할 때(업데이트) 사용할 계획이다.
플러그인을 추가하자.
plugins {
id("kotlin-parcelize")
}
꾸러미로 만들 데이터 클래스에 @Parcelize
주석을 하자.
import kotlinx.parcelize.Parcelize
@Parcelize
@Entity(tableName = "user_table")
data class User(
@PrimaryKey(autoGenerate = true)
val id: Int,
val firstName: String,
val lastName: String,
val age: Int
): Parcelable
Nav_Graph에서 Fragment에 매개변수를 추가하자.
Safe Args를 사용해 데이터를 보내자.
// ListAdpater.kt
inner class MyViewHolder(binding: CustomRowBinding) : RecyclerView.ViewHolder(binding.root) {
private val id = binding.idTxt
private val firstName = binding.firstNameTxt
private val lastName = binding.lastNameTxt
private val age = binding.ageTxt
private val container = binding.rowLayout
fun bind(userData: List<User>, position: Int) {
...
container.setOnClickListener {
val action = ListFragmentDirections.actionListFragmentToUpdateFragment(currentItem)
it.findNavController().navigate(action)
}
}
}
코드가 잘 이해가지 않는다면 다음 포스트를 참고하자.
https://velog.io/@hs0204/Navigation을-알아보자#5-안전한-데이터-전달을-위해-safe-args를-사용하자
데이터를 받을 Fragment에서 필요한 작업을 해주자.
// UpdateFragment.kt
private val args by navArgs<UpdateFragmentArgs>() // 데이터를 받는 변수
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = FragmentUpdateBinding.inflate(inflater, container, false)
val view = binding
binding.updateFirstNameEt.setText(args.currentUser.firstName)
binding.updateLastNameEt.setText(args.currentUser.lastName)
binding.updateAgeEt.setText(args.currentUser.age)
return view.root
}