안드로이드 앱 개발에서 구글의 머티리얼 디자인(Material Design) 가이드라인을 따르는 것은 이제 선택이 아닌 필수가 되었습니다. 머티리얼 디자인의 핵심 요소 중 하나는 UI 컴포넌트의 깊이와 계층을 시각적으로 표현하는 '그림자(Shadow)', 즉 elevation
입니다. 특히 화면 상단에 위치하여 앱의 브랜딩, 탐색, 액션 버튼 등을 담는 AppBarLayout
은 기본적으로 고유의 elevation 값을 가지고 있어 다른 콘텐츠보다 위에 떠 있는 듯한 효과를 줍니다.
하지만 모든 디자인이 기본값을 따르는 것은 아닙니다. 때로는 전체 UI와의 일체감을 위해, 혹은 TabLayout
과 경계 없이 연결되는 깔끔한 디자인을 구현하기 위해 AppBarLayout
의 기본 그림자를 제거해야 할 필요가 있습니다. 많은 개발자들이 이 문제를 해결하기 위해 가장 먼저 시도하는 방법은 XML 레이아웃 파일에서 android:elevation="0dp"
속성을 추가하는 것입니다. 직관적이고 간단해 보이지만, 이 방법이 예상과 달리 동작하지 않아 당혹스러운 경험을 한 개발자들이 적지 않습니다.
이 글에서는 왜 단순한 android:elevation="0dp"
설정이 AppBarLayout
에서 종종 실패하는지, 그리고 이 문제를 해결하기 위한 가장 확실하고 권장되는 방법은 무엇인지 심층적으로 탐구합니다. 단순히 '이 코드를 복사해서 붙여넣으세요'라는 단편적인 해결책을 넘어, AppBarLayout
의 동작 원리와 android:
네임스페이스와 app:
네임스페이스의 근본적인 차이를 이해하여 어떤 상황에서도 유연하게 대처할 수 있는 능력을 기르는 것을 목표로 합니다.
문제의 시작: android:elevation="0dp"
가 동작하지 않는 이유
안드로이드 API 21 (Lollipop)부터 View
클래스에 android:elevation
속성이 도입되었습니다. 이 속성은 Z축(화면 깊이) 방향으로 뷰의 높이를 지정하여 시스템이 자동으로 그림자를 렌더링하도록 만드는 역할을 합니다. 따라서 이론적으로는 AppBarLayout
역시 View
를 상속받으므로 이 속성이 당연히 적용되어야 합니다.
많은 개발자들이 다음과 같은 코드를 작성하며 그림자가 사라지기를 기대합니다.
<!-- 이 방법은 종종 실패합니다 -->
<androidx.coordinatorlayout.widget.CoordinatorLayout
... >
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="0dp"> <!-- 문제의 속성 -->
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
</com.google.android.material.appbar.AppBarLayout>
<!-- ... Scrollable Content ... -->
</androidx.coordinatorlayout.widget.CoordinatorLayout>
하지만 이 코드를 실행해 보면, 스크롤을 하지 않은 초기 상태에서는 그림자가 없는 것처럼 보이다가도, 함께 배치된 RecyclerView
나 NestedScrollView
를 스크롤하는 순간 AppBarLayout
하단에 스르륵 하고 그림자가 다시 나타나는 현상을 목격하게 됩니다. 왜 이런 일이 발생하는 것일까요?
정답은 AppBarLayout
이 단순한 View
가 아니기 때문입니다. AppBarLayout
은 com.google.android.material
라이브러리에 포함된, 고도로 전문화된 위젯입니다. 이 위젯은 부모 뷰인 CoordinatorLayout
과 상호작용하며 스크롤 상태에 따라 자신의 모습을 동적으로 변경하는 복잡한 로직을 내장하고 있습니다. 이 내부 로직이 android:elevation
이라는 안드로이드 프레임워크의 기본 속성 값을 무시하고 덮어쓰기 때문에 문제가 발생합니다. AppBarLayout
은 스크롤이 발생하면 '콘텐츠가 바 아래로 지나가고 있으니, 시각적 구분을 위해 그림자를 만들어야겠다'고 자체적으로 판단하고 elevation 값을 변경하는 것입니다.
가장 간단하고 확실한 해결책: app:elevation="0dp"
사용하기
이 문제를 해결하는 가장 표준적이고 권장되는 방법은 android:
네임스페이스 대신 app:
네임스페이스의 elevation
속성을 사용하는 것입니다.
<!-- 올바른 해결 방법 -->
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" <!-- app 네임스페이스 선언 -->
... >
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:elevation="0dp"> <!-- 이것이 정답입니다! -->
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
</com.google.android.material.appbar.AppBarLayout>
<!-- ... Scrollable Content ... -->
</androidx.coordinatorlayout.widget.CoordinatorLayout>
android:elevation="0dp"
를 app:elevation="0dp"
로 바꾸는 이 간단한 수정만으로 AppBarLayout
은 스크롤 상태와 관계없이 항상 그림자 없는 상태를 유지하게 됩니다. 이는 app:elevation
이 바로 AppBarLayout
컴포넌트가 자신의 상태를 관리하기 위해 직접 참조하는 '공식적인' 속성이기 때문입니다. 개발자가 app:elevation
값을 명시적으로 0으로 설정하면, AppBarLayout
의 내부 로직은 "개발자가 elevation을 0으로 고정하길 원하니, 스크롤이 발생해도 동적으로 그림자를 만들지 말아야겠다"라고 인식하고 동작합니다.
심층 분석 1: `android:` 네임스페이스 vs `app:` 네임스페이스
이 문제를 완전히 이해하려면 XML 레이아웃 파일 상단에서 항상 볼 수 있는 xmlns:android
와 xmlns:app
의 차이를 알아야 합니다.
-
android:
네임스페이스 (xmlns:android="http://schemas.android.com/apk/res/android"
)이것은 안드로이드 OS의 핵심 프레임워크(Framework) 자체에 정의된 속성들을 가리킵니다.
android:layout_width
,android:text
,android:background
와 같이 우리가 흔히 사용하는 대부분의 기본 속성들이 여기에 속합니다. 이 속성들은 안드로이드 시스템이 직접 이해하고 처리하며, 모든View
와 그 자식 클래스에서 공통적으로 사용할 수 있습니다. 컴파일 시점에 이 속성들은 안드로이드 SDK에 포함된android.R
클래스의 리소스 ID에 연결됩니다. -
app:
네임스페이스 (xmlns:app="http://schemas.android.com/apk/res-auto"
)이것은 안드로이드 프레임워크의 일부가 아닌, '라이브러리' 또는 '앱 자체'에 정의된 커스텀 속성들을 가리킵니다.
AppBarLayout
,ConstraintLayout
,RecyclerView
등은 모두 핵심 프레임워크가 아닌, 별도의 라이브러리(AndroidX, Material Components 등)로 제공됩니다. 이 라이브러리들은 기본View
가 제공하지 않는 추가적인 기능과 커스터마이징을 위해 고유의 속성(Custom Attribute)들을 정의하는데, 이것이 바로app:
네임스페이스를 통해 사용됩니다.여기서 중요한 부분은 URL의 마지막이
res-auto
라는 점입니다. 이는 빌드 도구(AAPT2)에게 "이 네임스페이스에 속한 속성들은 프로젝트에 포함된 여러 라이브러리들과 앱 자체의 리소스(res/values/attrs.xml
)를 모두 뒤져서 자동으로 찾아 연결하라"는 의미입니다. 따라서app:elevation
은 Material Components 라이브러리가AppBarLayout
을 위해 특별히 정의한 속성인 것입니다.
결론적으로, AppBarLayout
은 android:elevation
보다 라이브러리 자체에서 정의한 app:elevation
속성에 더 높은 우선순위를 부여하도록 설계되어 있습니다. 이는 스크롤 연동 기능과 같이 라이브러리 고유의 복잡한 동작을 제어하기 위한 당연하고 필수적인 설계입니다.
심층 분석 2: AppBarLayout의 동적 Elevation 변경 메커니즘
AppBarLayout
의 그림자가 동적으로 변하는 현상의 배후에는 두 가지 핵심 요소가 있습니다: CoordinatorLayout
과 liftOnScroll
기능입니다.
CoordinatorLayout의 역할
AppBarLayout
의 특별한 기능들(스크롤 시 사라지거나, 크기가 변하거나, 그림자가 생기는 등)은 AppBarLayout
이 CoordinatorLayout
의 직접적인 자식 뷰로配置되었을 때만 활성화됩니다. CoordinatorLayout
은 자식 뷰들 간의 상호작용을 중재하는 슈퍼 컨테이너 역할을 합니다.
CoordinatorLayout
은 내부에 스크롤 가능한 뷰(RecyclerView
, NestedScrollView
등)가 스크롤될 때 발생하는 이벤트를 감지하여 다른 자식 뷰(AppBarLayout
, FloatingActionButton
등)에게 "지금 스크롤이 발생하고 있다"고 알려줍니다. 이 알림을 받은 AppBarLayout
은 자신의 Behavior
에 정의된 대로 동작하는데, 그 동작 중 하나가 바로 elevation을 변경하는 것입니다.
Lift on Scroll 기능
Material Components 라이브러리의 AppBarLayout
에는 app:liftOnScroll
이라는 중요한 속성이 있습니다. 이 속성은 이름 그대로 '스크롤 시 (AppBar를) 들어 올릴지' 여부를 결정합니다. 기본값은 false
이지만, Material Design 3 테마를 사용하는 경우 등 특정 테마에서는 기본적으로 true
로 설정되기도 합니다.
app:liftOnScroll="true"
로 설정되면, AppBarLayout
은 CoordinatorLayout
내의 스크롤 가능한 뷰가 스크롤되기 시작할 때 지정된 elevation 값(기본적으로는 테마에 정의된 값)을 가지게 됩니다. 스크롤이 최상단으로 돌아오면 elevation은 다시 0이 됩니다. 이것이 바로 우리가 겪었던 '스크롤할 때만 그림자가 나타나는' 현상의 정확한 원인입니다.
만약 특정 스크롤 뷰와 연동하고 싶다면 app:liftOnScrollTargetViewId
속성을 사용하여 해당 뷰의 ID를 직접 지정해 줄 수도 있습니다.
<androidx.coordinatorlayout.widget.CoordinatorLayout ...>
<com.google.android.material.appbar.AppBarLayout
...
app:liftOnScroll="true"
app:liftOnScrollTargetViewId="@id/recycler_view">
...
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
... />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
또 다른 강력한 해결책: 고급 제어 방법
app:elevation="0dp"
가 가장 일반적인 해결책이지만, 상황에 따라 더 적합하거나 강력한 방법들이 있습니다.
방법 1: `liftOnScroll` 기능 비활성화하기
문제의 근원인 '스크롤 시 들어올리기' 기능 자체를 꺼버리는 방법입니다. 이 방법은 elevation의 동적 변화를 원천적으로 차단합니다.
XML에서 설정:
<com.google.android.material.appbar.AppBarLayout
...
app:liftOnScroll="false">
...
</com.google.android.material.appbar.AppBarLayout>
코드에서 설정(Kotlin):
val appBarLayout: AppBarLayout = findViewById(R.id.app_bar_layout)
appBarLayout.isLiftOnScroll = false
가장 확실하게 그림자를 고정하고 싶다면, `app:elevation`과 `app:liftOnScroll`을 둘 다 설정하는 것이 가장 안전한 방법입니다. `app:elevation="0dp"`는 초기 상태 및 정적 상태의 그림자를 0으로 만들고, `app:liftOnScroll="false"`는 스크롤에 따른 동적 변화를 막아줍니다.
<!-- 가장 확실하고 권장되는 조합 -->
<com.google.android.material.appbar.AppBarLayout
...
app:elevation="0dp"
app:liftOnScroll="false">
...
</com.google.android.material.appbar.AppBarLayout>
방법 2: StateListAnimator 직접 제거하기
`AppBarLayout`을 포함한 많은 머티리얼 컴포넌트들은 뷰의 상태(enabled, pressed, focused, lifted 등)에 따라 속성(알파, 크기, elevation 등)을 애니메이션으로 변경하기 위해 StateListAnimator
를 사용합니다. `liftOnScroll` 기능 역시 내부적으로는 AppBarLayout
의 상태를 'lifted' 상태로 변경하고, 이에 연결된 StateListAnimator
가 elevation 값을 바꾸는 방식으로 동작합니다.
이 `StateListAnimator` 자체를 제거하면 모든 상태 기반의 elevation 변경이 비활성화됩니다. 이는 다소 과격한 방법일 수 있지만, 매우 효과적입니다.
XML에서 설정 (API 21 이상):
<com.google.android.material.appbar.AppBarLayout
...
android:stateListAnimator="@null">
...
</com.google.android.material.appbar.AppBarLayout>
주의할 점은 여기서 `app:`이 아닌 `android:` 네임스페이스를 사용한다는 것입니다. `stateListAnimator`는 라이브러리가 아닌 안드로이드 프레임워크 자체에 정의된 속성이기 때문입니다. @null
값을 주어 애니메이터를 비활성화합니다.
코드에서 설정(Kotlin):
val appBarLayout: AppBarLayout = findViewById(R.id.app_bar_layout)
appBarLayout.stateListAnimator = null
이 방법은 `liftOnScroll` 뿐만 아니라 다른 상태 변화에 따른 애니메이션까지 모두 제거하므로, 의도치 않은 부작용이 없는지 확인해야 합니다.
앱 전체에 일관성 적용하기: Theme와 Style 활용
여러 화면에서 반복적으로 AppBarLayout
의 그림자를 제거해야 한다면, 각 XML 파일마다 app:elevation="0dp"
와 같은 속성을 추가하는 것은 비효율적이며 유지보수를 어렵게 만듭니다. 이럴 때는 안드로이드의 강력한 스타일링 시스템을 활용하는 것이 현명합니다.
앱의 테마(Theme)에서 AppBarLayout
에 적용될 기본 스타일(Style)을 재정의할 수 있습니다.
1. res/values/styles.xml
(또는 themes.xml
) 파일에 커스텀 스타일 정의:
먼저, Material Components의 기본 AppBarLayout
스타일을 부모로 상속받는 새로운 스타일을 만듭니다. 그리고 그 안에서 elevation과 관련된 속성들을 원하는 값으로 재정의합니다.
<resources>
<!-- ... 다른 스타일들 ... -->
<style name="App.Custom.AppBarLayout.NoElevation" parent="Widget.MaterialComponents.AppBarLayout.Primary">
- 0dp
- false
</style>
</resources>
여기서 parent
로 지정한 Widget.MaterialComponents.AppBarLayout.Primary
는 머티리얼 라이브러리가 제공하는 기본 스타일 중 하나입니다. 상속을 통해 우리는 기본 스타일의 다른 속성들은 그대로 유지하면서, 원하는 부분만 변경할 수 있습니다.
2. 앱 테마에서 `appBarLayoutStyle` 속성으로 커스텀 스타일 지정:
이제 앱의 기본 테마에서 모든 AppBarLayout
이 방금 만든 커스텀 스타일을 따르도록 설정합니다.
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.MyApp" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- ... Primary brand color, etc ... -->
<!-- AppBarLayout에 적용할 기본 스타일 지정 -->
<item name="appBarLayoutStyle">@style/App.Custom.AppBarLayout.NoElevation</item>
</style>
</resources>
이렇게 설정하면, 이제 앱 내의 모든 AppBarLayout
은 XML에서 별도의 elevation 속성을 지정하지 않아도 기본적으로 그림자가 없는 상태로 생성됩니다. 이 방법은 코드의 중복을 없애고 앱 전체의 UI 일관성을 유지하는 가장 깔끔하고 전문적인 접근 방식입니다.
문제 해결(Troubleshooting) Q&A
위의 방법들을 모두 시도했음에도 문제가 해결되지 않는 경우, 다음 사항들을 확인해 보세요.
- Q:
app:elevation="0dp"
와app:liftOnScroll="false"
를 모두 적용했는데도 스크롤 시 얇은 그림자나 경계선이 보입니다. - A: 그 경계선은
AppBarLayout
자체의 그림자가 아닐 가능성이 높습니다.AppBarLayout
바로 아래에 위치한View
, 예를 들어 탭 레이아웃 아래의 구분선(Divider)이나RecyclerView
의 배경색 등 다른 컴포넌트에 의해 생긴 것일 수 있습니다. 레이아웃 계층 구조를 주의 깊게 분석하고, Android Studio의 'Layout Inspector' 도구를 사용하여 해당 선이 어떤 뷰에 의해 그려지는지 확인해 보세요. - Q: 특정 화면에서만 그림자를 없애고 싶은데, 테마를 건드리지 않고 할 수 있나요?
- A: 네, 가능합니다. 위에서 만든
App.Custom.AppBarLayout.NoElevation
스타일을 특정AppBarLayout
에만 직접 적용하면 됩니다.
이렇게 하면 앱 전역 테마 설정은 그대로 유지하면서 해당 화면의<com.google.android.material.appbar.AppBarLayout ... style="@style/App.Custom.AppBarLayout.NoElevation"> ... </com.google.android.material.appbar.AppBarLayout>
AppBarLayout
에만 스타일을 적용할 수 있습니다. - Q: 오래된 기기(API 21 미만)에서는 그림자가 어떻게 보이나요?
- A:
android:elevation
은 API 21부터 네이티브로 지원됩니다. 하지만 Material Components 라이브러리는 하위 호환성을 위해 API 21 미만 버전에서도 그림자와 유사한 시각적 효과를 그려줍니다(주로 뷰의 배경에 그림자 이미지를 포함시키는 방식).app:elevation
속성을 사용하면 라이브러리가 하위 버전에서도 일관된 시각적 표현을 최대한 보장해주므로, 하위 호환성을 고려할 때도app:
네임스페이스를 사용하는 것이 유리합니다. - Q: 제 프로젝트에는 `com.google.android.material.appbar.AppBarLayout`가 아닌 `android.support.design.widget.AppBarLayout`가 있습니다.
- A:
android.support.*
패키지는 과거에 사용되던 Support Library입니다. 현재는 모든 지원 라이브러리가 AndroidX로 마이그레이션되었습니다.android.support.design.widget.AppBarLayout
에서도app:elevation
의 원리는 동일하게 적용되지만, 안정성과 최신 기능 지원을 위해 가능한 한 빨리 프로젝트를 AndroidX(androidx.*
)로 마이그레이션하는 것을 강력히 권장합니다.
결론: 상황에 맞는 최적의 솔루션 선택
AppBarLayout
의 그림자를 제거하는 것은 단순해 보이는 문제이지만, 그 이면에는 안드로이드 UI 시스템과 머티리얼 컴포넌트 라이브러리의 복잡한 상호작용이 숨어있습니다.
오늘 논의한 내용을 요약하면 다음과 같습니다.
- 가장 빠르고 일반적인 해결책: XML에서
app:elevation="0dp"
와app:liftOnScroll="false"
속성을 함께 사용하는 것입니다. 이것만으로도 99%의 사례는 해결됩니다. - 모든 상태 변화를 무시하는 강력한 해결책: 동적 애니메이션을 완전히 차단하고 싶다면
android:stateListAnimator="@null"
을 사용합니다. - 가장 깨끗하고 재사용 가능한 해결책: 앱 전체에 일관된 디자인을 적용하려면, 커스텀 스타일을 만들고 이를 앱 테마의
appBarLayoutStyle
에 지정하는 것이 최선의 방법입니다.
이제 당신은 "왜 android:elevation
이 안 되지?"라는 질문에 명확히 답할 수 있을 뿐만 아니라, app:
네임스페이스의 역할과 AppBarLayout
의 동작 원리를 이해하게 되었습니다. 이러한 깊이 있는 이해는 단순히 문제를 해결하는 것을 넘어, 앞으로 마주할 더 복잡한 UI 문제들을 해결하는 데 훌륭한 밑거름이 될 것입니다. 항상 공식 문서를 참조하고, '왜'라는 질문을 던지는 습관을 통해 더 나은 안드로이드 개발자로 성장하시길 바랍니다.
0 개의 댓글:
Post a Comment