[Android] 앱 바와 메뉴

잘 디자인된 안드로이드 앱에서 중요한 컴포넌트 중 하나가 앱 바 app bar 다. 앱 바는 사용자가 수행할 수 있는 액션과 화면 간을 이동할 수 있는 매커니즘을 제공한다. 더불어 디자인의 일관성도 제공한다.

앱 바는 액션 바 action bar 또는 툴바 toolbar 자세한 내용은 앱 바 vs 액션 바 vs 툴바 에서 다룬다.

AppCompat의 기본 앱 바

앱 바를 포함하는 이유는 새로운 프로젝트를 생성할 때 안드로이드 스튜디오가 AppCompatActivity의 서브 클래스인 모든 액티비티에 앱 바를 기본으로 포함하도록 설정하기 때문이다. 이때 안드로이드 스튜디오가 다음 내용을 수행함으로써 아래와 같은 일이 가능해진다.

  • Jetpack의 AppCompat 라이브러리 의존성을 추가한다.
  • 앱 바를 포함하는 AppCompat 테마 중 하나를 적용한다.

app/build.gradle 파일을 열면 AppCompat 라이브러리의 의존성이 이미 추가되어 있는 걸 확인할 수 있다.

1
2
3
4
5
dependencies {
...
implementation 'androidx.appcompat:appcompat:1.3.0'
...
}

AppCompat은 'application compatibility’의 단축어다. Jetpack의 AppCompat 라이브러리는 안드로이드 버전이 달라도 일관된 UI를 유지하는 데 핵심이 되는 클래스와 리소스들을 포함한다. AppCompat의 각 하위 패키지와 관련된 내용은 이곳에서 확인 가능하다.

안드로이드 스튜디오 4.1.1 버전부터는 새 프로젝트를 생성할 때 앱의 테마를 자동으로 Theme.MaterialComponents.DayNight.DarkActionBar으로 설정한다. 이 테마는 res/values/themes.xml에 설정되어 있으며, 앱 전체의 기본 스타일을 지정한다.(스타일 이름은 Theme.앱이름으로 지정된다.)

1
2
3
4
5
6
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.CriminalIntent" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
...
</style>
</resources>

애플리케이션의 테마는 매니페스트 파일에 애플리케이션 수준으로 지정되며, 액티비티마다 선택적으로 지정될 수도 있다. 매니페스트 파일의 태그에 포함된 android:theme 속성을 보면 아래와 같이 지정되어 있음을 볼 수 있다.

1
2
3
4
5
6
7
<manifest ...>
<application
...
android:theme="@style/Theme.CriminalIntent">
...
</application>
</manifest>

메뉴

앱 바의 오른쪽 위에는 메뉴를 넣을 수 있다. 메뉴는 액션 항목 action item 으로 구성되며 (때로는 메뉴 항목이라고도 함), 액션 항목은 현재 화면과 관련된 액션 또는 앱 전체의 액션을 수행할 수 있다.

본문에선 사용자가 새로운 데이터를 추가할 수 있는 액션 항목을 생성해본다. 액션 항목의 이름은 문자열 리소스로 만들어야 하므로 res/values/strings.xml을 열어 새로운 액션을 나타내는 문자열을 추가한다.

메뉴 문자열 추가 (res/values/strings.xml)

1
2
3
4
5
<resources>
...
<string name="new_crime">새로운 범죄</string>

</resources>

XML로 메뉴 정의하기

메뉴는 레이아웃과 유사한 리소스로, XML 파일로 생성해 프로젝트의 res/menu 디렉터리에 둔다. 그리고 코드에서 메뉴를 인플레이트해 사용할 수 있도록 앱을 빌드하면 메뉴 파일의 리소스 ID가 자동 생성된다.

메뉴 파일 생성하기

메뉴 파일의 이름이 CrimeListFragment의 레이아웃 파일의 이름과 같지만 메뉴 파일은 res/menu/ 에 생성된다.

CrimeListFragment의 메뉴 리소스 생성하기 (res/menu/fragment_crime_list.xml)

1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/new_crime"
android:icon="@android:drawable/ic_menu_add"
android:title="@string/new_crime"
app:showAsAction="ifRoom|withText" />
</menu>

showAsAction 속성은 액션 항목이 앱 바 자체에 보이게 할 것인지, 아니면 오버플로 메뉴 overflow menu 에 포함되어 보이게 될 것인지를 나타낸다. 여기서는 ifRoom과 withText 두 값을 같이 지정했으므로 앱 바에 공간이 있으면 액션 항목의 아이콘과 텍스트 모두 앱 바에 나타난다. 만일 아이콘을 보여줄 공간은 있지만 텍스트의 공간은 없다면 아이콘만 나타나고, 둘 다 보여줄 공간이 없으면 해당 액션 항목은 오버플로 메뉴에 들어간다.

showAsAction 속성의 다른 값으로는 always와 never가 있는데, always는 액션 항목을 항상 앱 바에 보여주기 때문에 권장하지 않는다. 대신에 ifRoom을 사용해서 안드로이드 운영체제가 결정하게 하는 것이 좋다. 자주 사용하지 않는 액션에는 never를 지정해서 오버플로 메뉴에 두는 것이 좋다. 화면이 너무 어수선해지는 것을 피하려면 사용자가 자주 사용할 액션 항목들만 앱 바에 두어야 한다. 따라서 위와 같이 ifRoom과 withText 두 값을 같이 지정하는 것이 좋다.

앱의 네임스페이스

fragment_crime_list.xml에서는 xmls 태그를 사용해서 새로운 네임스페이스로 app을 정의하는데, 보통의 android 네임스페이스와는 다르다. 여기서는 showAsAction 속성을 지정하기 위해 app 네임스페이스가 사용되었다.

app과 같이 특이한 네임스페이스에는 AppCompat 라이브러리와 관련해서 필요하다. 앱 바 API는 안드로이드 3.0에서 처음 추가되었다(당시에는 액션 바라고 했다). 원래 AppCompat 라이브러리의 앱 바는 더 이전 버전의 안드로이드를 지원하는 앱에 호환성 버전의 액션 바를 넣을 수 있게 만든 것으로, 액션 바를 지원하지 않는 안드로이드 버전을 실행하는 장치까지도 액션 바가 나타날 수 있게 한다.

AppCompat 라이브러리는 커스텀 showAsAction 속성을 정의하고 있으며, 안드로이드의 내장된 showAsAction 속성을 사용하지 않는다.

메뉴 생성하기

메뉴는 Activity 클래스의 콜백 함수가 관리한다. 메뉴가 필요하면 안드로이드는 Activity 함수인 onCreateOptionsMenu(Menu)를 호출한다.

그런데 이 앱에서는 액티비티가 아닌 프래그먼트에 구현된 코드를 호출한다. Fragment는 자신의 메뉴 콜백 함수들을 갖고 있다. 본문에서는 이 함수들을 CrimeListFragment에 구현한다. 메뉴를 생성하고 액션 항목의 선택에 응답하는 함수들은 다음과 같다.

1
2
onCreateOptionsMenu(menu: Menu, inflater: MenuInflater)
onOptionsItemSelected(item: MenuItem): Boolean

CrimeListFragment.kt에서 onCreateOptionsMenu(Menu, MenuInflater)를 오버라이드해 fragment_crime_list.xml에 정의된 메뉴를 인플레이트하자.

메뉴 리소스 인플레이트하기 (CrimeListFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
class CrimeListFragment : Fragment() {
...
override fun onDetach() {
...
}

override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.fragment_crime_list, menu)
}
...
}

이 함수에서는 MenuInflater.inflate(Int, Menu)를 호출할 때 메뉴 파일의 리소스 ID를 인자로 전달한다. 이렇게 함으로써 파일에 정의된 액션 항목들로 Menu 인스턴스가 채워진다.

여기서는 슈퍼 클래스에 구현된 onCreateOptionsMenu(...)를 먼저 호출했다. 따라서 슈퍼 클래스에 정의된 어떤 메뉴 기능도 여전히 작동할 수 있다. 하지만 슈퍼 클래스인 Fragment의 onCreateOptionsMenu(...) 함수에서는 아무 일도 하지 않기 때문에 특별한 의미는 없다.

CrimeListFragment를 호스팅하는 액티비티가 운영체제로부터 자신의 onCreateOptionsMenu(...) 콜백 함수 호출을 받았을 때 FragmentManager는 Fragment.onCreateOptionsMenu(Menu, MenuInflater)를 호출하는 책임을 갖는다. 단, 다음의 Fragment 함수를 호출해서 CrimeListFragment가 onCreateOptionsMenu(...) 호출을 받아야 함을 FragmentManager가 명시적으로 알려주어야 한다.

1
setHasOptionsMenu(hasMenu: Boolean)

따라서 CrimeListFragment.onCreate(Bundle?)에 CrimeListFragment가 메뉴 콜백 호출을 받아야 함을 FragmentManager에 알려주는 코드를 추가한다.

메뉴 콜백 호출을 받도록 하기 (CrimeListFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
class CrimeListFragment : Fragment() {
...
override fun onAttach(context: Context) {
...
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
...
}

앱 바에 나타난 범죄 추가 액션 항목 아이콘과 텍스트

앱 바의 아이콘 제목 보기

대부분의 폰에서는 아이콘을 보여줄 공간밖에 없기에 액션 항목의 텍스트가 표시되지 않는다. 앱 바의 + 아이콘을 길게 누르면 텍스트를 볼 수 있다.

메뉴 선택에 응답하기

사용자가 ‘새로운 범죄’ 액션 항목을 눌렀을 때 그에 대한 응답을 하려면 CrimeListFragment가 데이터베이스에 새로운 범죄 데이터(Crime 인스턴스)를 추가할 방법이 필요하다. 그러기 위해서는 리포지터리의 addCrime(Crime) 함수를 호출하는 코드를 CrimeListViewModel에 추가하면 된다.

새로운 범죄 데이터 추가하기 (CrimeListViewModel.kt)

1
2
3
4
5
6
7
8
class CrimeListViewModel : ViewModel() {
private val crimeRepository = CrimeRepository.get()
val crimeListLiveData = crimeRepository.getCrimes()

fun addCrime(crime: Crime) {
crimeRepository.addCrime(crime)
}
}

사용자가 메뉴 항목을 누르면 프래그먼트에서 onOptionsItemSelected(MenuItem) 함수의 콜백 호출을 받게 되고, 이 함수는 사용자가 선택한 MenuItem의 인스턴스를 인자로 받는다.

지금 메뉴에는 액션 항목 하나만 있지만, 메뉴는 대개 둘 이상의 액션 항목을 갖는다. 이때 어떤 액션 항목이 선택되었는지는 MenuItem의 ID를 확인해서 알아낸 뒤 해당 항목에 적합한 응답을 하면된다. 이 ID는 메뉴 파일의 MenuItem에 지정한 리소스 ID와 일치한다.

이제 CrimeListFragment.kt의 onOptionsItemSeleceted(MenuItem) 함수를 구현해서 MenuItem의 선택에 응답하게 한다. 이 함수에서는 새로운 Crime 객체를 생성하고 데이터베이스에 추가한다. 그다음에 부모 액티비티에 구현된 onCrimeSelected(...) 콜백 함수를 호출해 CrimeListFragment를 CrimeFragment로 교체한다. 이렇게 하면 데이터베이스에 새로 추가된 범죄 데이터가 상세 내역 화면에 보이고, 사용자가 변경할 수도 있게 된다.

메뉴 선택에 응답하기 (CrimeListFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class CrimeListFragment : Fragment() {
...
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.fragment_crime_list, menu)
}

override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.new_crime -> {
val crime = Crime()
crimeListViewModel.addCrime(crime)
callbacks?.onCrimeSelected(crime.id)
true
}
else -> return super.onOptionsItemSelected(item)
}
}
...
}

onOptionsItemSeleceted(MenuItem) 함수는 Boolean 값을 반환한다. 즉, 선택된 MenuItem을 정상적으로 처리하고 나면 더 이상의 처리가 필요 없음을 나타내는 true를 반환해야 한다. 만일 false를 반환하면, 호스팅 액티비티의 onOptionsItemSeleceted(MenuItem) 함수를 호출해 메뉴 처리가 계속된다. 그리고 처리를 구현하지 않은 액션 항목 ID에는 슈퍼 클래스에 구현된 onOptionsItemSeleceted(MenuItem) 함수를 호출한다.

새로운 범죄 데이터 추가

궁금증 해소 💁🏻‍♂️ : 앱 바 vs 액션 바 vs 툴바

앱 바를 ‘툴바’ 또는 '액션 바’라고 하는 경우를 심심치 않게 볼 수 있으며 안드로이드 문서에서도 이 용어들을 혼용해서 사용한다. 하지만 앱 바, 액션 바, 툴바는 정말로 같은 것일까? 이 용어들은 서로 관련이 있으나 정확하게 같은 것은 아니며, UI 설계 요소로는 '앱 바’라고 한다.

안드로이드 5.0(롤리팝, API 레벨 21) 이전에는 앱 바가 ActionBar 클래스를 사용해서 구현되었다. 따라서 액션 바와 앱 바 두 용어는 같은 것으로 간주했다. 그러나 안드로이드 5.0부터는 앱 바를 구현하는 방법으로 Toolbar 클래스가 도입되었다.

2021년을 기준으로 AppCompat 라이브러리는 Jetpack의 Toolbar 위젯을 사용해서 액션 바(앱 바)를 아래와 같이 구현한다.

레이아웃 검사기로 본 액션 바

앱을 실행하고 리스트에서 항목을 하나 선택해 상세 내역의 화면이 나타나게 한다. 그리고 안드로이드 스튜디오 메뉴 바의 Tools -> Layout Inspector 를 선택하면 아래와 같이 레이아웃 검사기 도구 창이 열린다. 그리고 왼쪽의 컴포넌트 트리 패널에서 원하는 레이아웃이나 컴포넌트를 확장하고 선택하면 중앙의 레이아웃 화면에 표시해주며, 속성과 상세 정보를 오른쪽 패널에 보여준다.

ActionBar와 Toolbar는 매우 유사한 컴포넌트다. 그러나 툴바는 변경된 UI를 가지며 액션 바보다 유연성 있게 사용할 수 있는 반면에, 액션 바는 항상 화면의 제일 위쪽에 나타나며 한 화면에 하나만 있는 등 많은 제약을 가졌다. 게다가 액션 바의 크기는 정해져 있어서 변경할 수 없지만, 툴바는 이런 제약을 갖지 않는다.

본문에서는 AppCompat 테마 중 하나에서 제공한 툴바를 사용했지만, 액티비티나 프래그먼트의 레이아웃 파일에 포함된 뷰로 툴바를 포함할 수 있다. 그리고 화면의 어떤 위치에도 툴바를 둘 수 있고 여러 개를 넣을 수도 있다. 이런 유연성 덕분에 흥미로운 화면 디자인이 가능하다. 예를 들어, 각 프래그먼트가 자신의 툴바를 갖는다고 해보자. 그리고 한 화면에서 동시에 여러 개의 프래그먼트를 수용한다면, 화면 위에 하나의 툴바를 공유하지 않고 각 프래그먼트가 자신의 툴바를 갖고 나타날 수 있다. 또한 툴바는 내부에 다른 뷰들을 둘 수 있고 높이도 조정할 수 있어서 앱의 작동 방식에 훨씬 더 좋은 유연성을 제공한다.

댓글