안드로이드 애플리케이션 개발, 특히 게임이나 미디어 플레이어, 키오스크 앱처럼 몰입감 있는 사용자 경험을 제공해야 하는 프로젝트에서 '전체 화면(Full Screen)' 혹은 '몰입 모드(Immersive Mode)'는 필수적인 기능입니다. 사용자의 시선을 화면 콘텐츠에만 집중시키기 위해 상단의 상태 표시줄(Status Bar)과 하단의 네비게이션 바(Navigation Bar, Soft Key)를 숨기는 것은 이제 기본 중의 기본이 되었습니다. 개발자들은 WindowInsetsController
나 레거시 방식인 SYSTEM_UI_FLAG
를 사용하여 이 기능을 비교적 간단하게 구현할 수 있습니다.
하지만 완벽해 보이던 전체 화면에 예상치 못한 복병이 나타나곤 합니다. 바로 안드로이드 UI의 기본 위젯 중 하나인 스피너(Spinner)입니다. 모든 것이 완벽하게 숨겨진 화면에서 사용자가 스피너를 터치하여 드롭다운 목록을 여는 순간, 마법처럼 화면 하단에서 스르륵 하고 네비게이션 바가 다시 나타나는 현상을 마주하게 됩니다. 더 큰 문제는, 한번 나타난 네비게이션 바가 스피너 목록을 닫아도 사라지지 않고 그대로 남아 레이아웃을 밀어 올리거나 가리는 등 전체 UI를 망가뜨린다는 점입니다. 이 문제로 인해 수많은 개발자들이 몇 시간, 혹은 며칠을 디버깅에 허비하곤 합니다.
이 글에서는 단순히 '이 코드를 복사해서 붙여넣으세요'라는 단편적인 해결책을 넘어, 왜 이런 현상이 발생하는지에 대한 근본적인 원인을 깊이 파고들어 안드로이드 윈도우 시스템의 동작 방식을 이해하고, 이를 바탕으로 가장 확실하고 안정적인 해결책과 다양한 대안을 제시하고자 합니다. 이 글을 끝까지 읽으신다면, 더 이상 스피너 때문에 전체 화면 UI가 깨지는 일로 스트레스받지 않게 될 것입니다.
1. 모든 문제의 시작: 몰입 모드(Immersive Mode)와 시스템 UI
먼저, 우리가 목표로 하는 '완벽한 전체 화면' 즉, 몰입 모드가 어떻게 동작하는지부터 정확히 짚고 넘어가야 합니다. 안드로이드는 시스템이 그리는 UI 요소(상태 표시줄, 네비게이션 바 등)의 가시성을 앱이 제어할 수 있도록 여러 API를 제공합니다.
1.1. 레거시 방식: 시스템 UI 플래그(System UI Flags)
안드로이드 11 (API 30) 이전 버전에서 주로 사용되던 방식입니다. 액티비티의 윈도우(Window)에 특정 플래그(flag)를 설정하여 시스템 UI의 모양과 동작을 변경합니다.
// onCreate() 또는 onResume() 등 적절한 시점에 호출
private void hideSystemUI() {
// API 30 이상에서는 WindowInsetsController 사용이 권장됩니다.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getWindow().setDecorFitsSystemWindows(false);
WindowInsetsController controller = getWindow().getInsetsController();
if(controller != null) {
controller.hide(WindowInsets.Type.statusBars() | WindowInsets.Type.navigationBars());
controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
} else {
// 레거시 방식: API 30 미만
View decorView = getWindow().getDecorView();
decorView.setSystemUiVisibility(
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
// 콘텐츠를 시스템 바 뒤에 배치하여 전체 화면으로 보이게 함
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
// 네비게이션 바와 상태 표시줄을 숨김
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_FULLSCREEN);
}
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) {
hideSystemUI();
}
}
여기서 중요한 플래그는 다음과 같습니다.
- SYSTEM_UI_FLAG_HIDE_NAVIGATION: 하단 네비게이션 바를 숨깁니다.
- SYSTEM_UI_FLAG_FULLSCREEN: 상단 상태 표시줄을 숨깁니다.
- SYSTEM_UI_FLAG_IMMERSIVE: 사용자가 화면 가장자리를 스와이프하면 시스템 바가 일시적으로 나타납니다. 몇 초 후 자동으로 다시 사라집니다.
- SYSTEM_UI_FLAG_IMMERSIVE_STICKY: IMMERSIVE와 유사하지만, 스와이프 시 시스템 바가 반투명하게 나타났다가 잠시 후 자동으로 사라집니다. 사용자의 상호작용이 없으면 계속 숨겨진 상태를 유지하려는 '끈끈한' 속성이 있습니다. 게임이나 동영상 플레이어에 가장 적합합니다.
- SYSTEM_UI_FLAG_LAYOUT_* 계열: 이 플래그들은 시스템 바가 숨겨졌을 때와 나타났을 때 레이아웃 크기가 변경되어 UI가 깨지는 것을 방지합니다. 콘텐츠 뷰가 시스템 바가 차지하던 공간까지 모두 사용하도록 만들어줍니다.
1.2. 최신 방식: WindowInsetsController (API 30+)
안드로이드 11부터는 WindowInsetsController
라는 더 직관적이고 강력한 API가 도입되었습니다. 플래그의 비트 연산 조합보다 이해하기 쉽고 명확합니다.
// API 30 이상에서만 동작
private void hideSystemUIModern() {
// 시스템 윈도우에 맞게 콘텐츠 크기를 조정하지 않도록 설정 (전체 화면 사용)
getWindow().setDecorFitsSystemWindows(false);
WindowInsetsController controller = getWindow().getInsetsController();
if (controller != null) {
// 상태 표시줄과 네비게이션 바를 숨김
controller.hide(WindowInsets.Type.statusBars() | WindowInsets.Type.navigationBars());
// 시스템 바가 나타날 때의 동작 설정
// BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE: 화면 가장자리를 스와이프하면 일시적으로 나타남
controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
}
이러한 설정을 통해 개발자는 액티비티의 윈도우가 시스템 UI를 제어하도록 명령합니다. 문제는, 우리가 제어하고 있다고 믿었던 이 설정이 '현재 액티비티의 메인 윈도우'에만 국한된다는 점입니다.
2. 범인은 바로 '너': 스피너와 PopupWindow의 은밀한 관계
그렇다면 왜 스피너는 이 모든 설정을 무시하고 시스템 UI를 다시 불러오는 것일까요? Layout Inspector로 UI 계층 구조를 뜯어봐도 명확한 원인을 찾기 어렵습니다. 원인을 이해하려면 스피너가 드롭다운 목록을 화면에 어떻게 그리는지를 알아야 합니다.
결론부터 말하자면, 스피너의 드롭다운 목록은 현재 액티비티의 뷰 계층(View Hierarchy)에 속한 위젯이 아닙니다. 대신, 스피너는 PopupWindow
라는 완전히 별개의 윈도우를 생성하여 그 위에 목록을 그립니다.
핵심 개념: 안드로이드의 윈도우(Window)
안드로이드에서 '윈도우'는 단순히 화면에 보이는 사각형 영역 이상입니다. 윈도우는 시스템의 윈도우 매니저(WindowManager)가 직접 관리하는 그리기 표면(Surface)의 단위입니다. 모든 액티비티는 자신만의 메인 윈도우를 가집니다. 다이얼로그(Dialog), 토스트(Toast), 그리고 스피너의 드롭다운 목록 같은 PopupWindow
역시 각각 독립적인 윈도우를 생성합니다. 시스템 UI(상태 표시줄, 네비게이션 바)의 가시성은 현재 포커스를 받은 최상위 윈도우의 속성에 따라 결정됩니다.
이것이 바로 문제의 핵심입니다.
- 개발자는 'MainActivity의 윈도우'에 "시스템 UI를 숨겨라!" 라고
SYSTEM_UI_FLAG_HIDE_NAVIGATION
과 같은 명령을 내렸습니다. - 사용자가 스피너를 터치합니다.
- 스피너는 드롭다운 목록을 보여주기 위해 '새로운 PopupWindow'를 생성하고 화면에 띄웁니다.
- 이 새로 생성된 PopupWindow는 MainActivity의 윈도우가 아닙니다. 따라서, 이 윈도우에는 "시스템 UI를 숨겨라" 라는 명령이 설정되어 있지 않습니다. 이 윈도우는 기본적인 시스템 기본값을 따릅니다.
- 안드로이드의 WindowManager는 새로 나타나 포커스를 받은 PopupWindow를 보고 "어? 이 새로운 윈도우는 시스템 UI를 숨기라는 설정이 없네? 그럼 기본값에 따라 다시 보여줘야겠다!" 라고 판단합니다.
- 그 결과, 네비게이션 바와 상태 표시줄이 다시 화면에 나타납니다.
스피너 목록을 닫으면 PopupWindow는 사라지지만, 이미 시스템 UI를 다시 그리라는 명령이 내려진 상태이므로, WindowManager가 MainActivity의 윈도우 상태를 다시 확인하여 UI를 숨기기 전까지는 네비게이션 바가 계속 남아있게 되는 것입니다. onWindowFocusChanged
에서 hideSystemUI()
를 호출하는 이유도 바로 이처럼 포커스가 바뀔 때마다 몰입 모드를 강제로 재적용하기 위함이지만, PopupWindow의 등장 속도나 타이밍 문제로 인해 완벽하게 동작하지 않을 때가 많습니다.
3. 해결책: PopupWindow를 직접 제어하라
원인을 알았으니 해결은 간단합니다. 스피너가 생성하는 그 '새로운 PopupWindow'를 찾아서, 그 윈도우에도 "너도 시스템 UI를 숨겨!" 라고 똑같이 명령을 내려주면 됩니다. 하지만 스피너 위젯은 자신이 사용하는 PopupWindow를 직접적으로 반환하는 public 메소드를 제공하지 않아 약간의 트릭이 필요합니다.
3.1. 가장 간단하고 효과적인 해결책: `setModal(false)`
가장 널리 알려져 있고, 대부분의 경우에 효과적인 해결책입니다. 스피너가 생성하는 PopupWindow의 모달(modal) 속성을 변경하는 것입니다.
모달 윈도우는 자신 이외의 다른 윈도우와 상호작용하는 것을 차단하는 속성을 가집니다. 기본적으로 스피너의 PopupWindow는 모달로 동작하며, 이 과정에서 포커스와 윈도우 상태에 더 깊이 관여하게 됩니다. 이 모달 속성을 꺼주면 PopupWindow가 부모 윈도우의 UI 상태에 미치는 영향이 줄어들어, 시스템 UI를 다시 불러오는 문제를 회피할 수 있습니다.
문제는 `Spinner` 클래스 자체에는 `getPopupWindow()` 같은 메소드가 없다는 것입니다. 다행히 스피너의 슈퍼클래스까지 거슬러 올라가면 해당 기능을 찾을 수 있거나, 리플렉션을 통해 접근할 수 있습니다. 안드로이드 API 레벨 16(Jelly Bean)부터 Spinner
클래스에 getPopupContext()
와 함께 숨겨진 `getPopupWindow()` 메소드가 존재해왔습니다. AppCompatSpinner의 경우에도 내부적으로 `mPopup`이라는 필드로 `ListPopupWindow`를 관리합니다. 가장 쉬운 접근 방법은 다음과 같습니다.
// 스피너 위젯 찾기
Spinner mySpinner = findViewById(R.id.my_spinner);
// 어댑터 설정 등 스피너 초기화 코드...
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
android.R.layout.simple_spinner_item, a_list);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
mySpinner.setAdapter(adapter);
// --- 핵심 해결 코드 ---
try {
// Spinner 클래스에서 'mPopup'이라는 이름의 필드를 찾는다.
// 이 필드가 바로 드롭다운 목록을 관리하는 ListPopupWindow 객체이다.
Field popup = Spinner.class.getDeclaredField("mPopup");
popup.setAccessible(true); // private 필드에 접근 가능하도록 설정
// 현재 스피너 인스턴스에서 mPopup 필드의 값을 가져온다. (이것이 ListPopupWindow)
android.widget.ListPopupWindow popupWindow = (android.widget.ListPopupWindow) popup.get(mySpinner);
// 가져온 ListPopupWindow 객체에 setModal(false)를 호출한다.
// 이렇게 하면 팝업이 부모 윈도우의 UI 상태를 변경하지 않도록 한다.
popupWindow.setModal(false);
} catch (NoSuchFieldException | IllegalAccessException e) {
// 리플렉션은 향후 안드로이드 버전에서 필드 이름이 바뀌면 실패할 수 있으므로
// 예외 처리는 필수이다.
Log.e("SpinnerFix", "Failed to set spinner modal false", e);
}
// --- 여기까지 ---
이 코드는 리플렉션(Reflection)을 사용하여 `Spinner` 클래스의 private 멤버 변수인 `mPopup`에 직접 접근합니다. `mPopup`은 드롭다운 리스트를 실제로 구현하는 `ListPopupWindow`의 인스턴스입니다. 이 객체를 가져와 `setModal(false)`를 호출하면, 팝업이 더 이상 시스템 UI 상태에 간섭하지 않게 되어 네비게이션 바가 나타나지 않습니다.
주의: 리플렉션은 private API를 사용하는 것이므로, 향후 안드로이드 OS 버전 업데이트 시 `mPopup` 필드의 이름이 바뀌거나 구조가 변경되면 코드가 동작하지 않을 위험이 있습니다. 하지만 `mPopup`은 매우 오랫동안 안정적으로 유지되어 온 필드이므로, 현재로서는 가장 실용적인 해결책 중 하나로 널리 사용됩니다.
3.2. 대안 1: 커스텀 스피너 클래스 만들기
리플렉션 코드가 여러 곳에서 반복적으로 사용된다면, 이를 캡슐화한 커스텀 스피너를 만드는 것이 좋은 방법입니다.
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.widget.Spinner;
import java.lang.reflect.Field;
// androidx.appcompat.widget.AppCompatSpinner를 상속받는 것이 더 안정적일 수 있습니다.
public class ImmersiveSpinner extends androidx.appcompat.widget.AppCompatSpinner {
public ImmersiveSpinner(Context context) {
super(context);
}
public ImmersiveSpinner(Context context, AttributeSet attrs) {
super(context, attrs);
}
public ImmersiveSpinner(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
// 이 메소드는 스피너가 자신의 팝업을 초기화한 후에 호출되어야 의미가 있습니다.
// 예를 들어, 어댑터를 설정한 직후에 호출할 수 있습니다.
public void DONT_TOUCH_MY_UI() {
try {
Field popup = Spinner.class.getDeclaredField("mPopup");
popup.setAccessible(true);
android.widget.ListPopupWindow popupWindow = (android.widget.ListPopupWindow) popup.get(this);
popupWindow.setModal(false);
} catch (NoSuchFieldException | IllegalAccessException | ClassCastException e) {
Log.e("ImmersiveSpinner", "Failed to set spinner modal false", e);
}
}
}
이제 레이아웃 XML 파일에서는 <Spinner>
대신 <com.example.myapp.ImmersiveSpinner>
를 사용하고, 액티비티 코드에서는 어댑터를 설정한 후 myImmersiveSpinner.DONT_TOUCH_MY_UI()
와 같이 호출해주면 됩니다. 코드가 훨씬 깔끔해지고 재사용성이 높아집니다.
3.3. 대안 2: 스피너를 포기하고 다른 UI 사용하기 (근본적인 해결책)
사실 몰입 모드와 같이 고도로 커스터마이징된 UI 환경에서, 시스템 기본 스타일을 많이 따르는 스피너는 디자인적으로도 잘 어울리지 않는 경우가 많습니다. "문제를 해결"하는 것에서 한 걸음 더 나아가 "문제를 원천적으로 피하는" 방법도 고려해볼 수 있습니다.
스피너 대신 DialogFragment를 사용하여 직접 만든 목록 선택 UI를 띄우는 것입니다. `DialogFragment`는 커스텀 레이아웃을 가질 수 있고, 자체 윈도우의 속성을 완벽하게 제어할 수 있다는 엄청난 장점이 있습니다.
DialogFragment를 사용한 해결 과정:
- 선택 가능한 아이템 목록을 보여주는 레이아웃 XML 파일을 만듭니다. (예: `RecyclerView`를 포함)
DialogFragment
를 상속받는 클래스를 만듭니다.onCreateView()
에서 위에서 만든 레이아웃을 인플레이트합니다.onStart()
또는onResume()
에서 DialogFragment의 윈도우를 가져와 몰입 모드 플래그를 직접 설정합니다. 이렇게 하면 다이얼로그 자체도 완벽한 전체 화면으로 나타납니다.- 사용자가 아이템을 선택하면, 인터페이스를 통해 부모 액티비티로 선택된 값을 전달합니다.
- 원래 스피너가 있던 자리에는 `TextView`나 `Button`을 두고, 클릭 시 이 `DialogFragment`를 보여주도록 구현합니다.
예시 코드 (개념):
// CustomListDialogFragment.java
public class CustomListDialogFragment extends DialogFragment {
// ... RecyclerView 설정 및 인터페이스 정의 ...
@Override
public void onStart() {
super.onStart();
Dialog dialog = getDialog();
if (dialog != null && dialog.getWindow() != null) {
Window window = dialog.getWindow();
// 다이얼로그 윈도우의 크기 및 속성 설정
window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
// 다이얼로그 윈도우에 직접 몰입 모드 설정!!!
hideSystemUI(window);
}
}
private void hideSystemUI(Window window) {
// ... 위에서 설명한 hideSystemUI() 메소드와 동일한 로직을
// 액티비티 윈도우가 아닌, 다이얼로그의 윈도우에 적용 ...
View decorView = window.getDecorView();
decorView.setSystemUiVisibility(
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_FULLSCREEN);
}
}
// Activity 코드
TextView spinnerTextView = findViewById(R.id.spinner_placeholder);
spinnerTextView.setOnClickListener(v -> {
CustomListDialogFragment dialog = new CustomListDialogFragment();
dialog.show(getSupportFragmentManager(), "CustomList");
});
이 방식은 초기 구현 비용이 스피너를 사용하는 것보다 높지만, 다음과 같은 장점이 있습니다.
- 완벽한 UI 제어: 애니메이션, 배경, 아이템 레이아웃 등 모든 것을 앱의 디자인 컨셉에 맞게 100% 커스터마이징할 수 있습니다.
- 안정성: 리플렉션과 같은 비공개 API에 의존하지 않으므로 향후 안드로이드 버전 호환성 문제에서 자유롭습니다.
- 확장성: 단순 목록 선택 외에도 검색 기능, 다중 선택 등 복잡한 기능을 쉽게 추가할 수 있습니다.
4. 최종 정리 및 권장 사항
안드로이드 전체 화면 앱에서 스피너 클릭 시 네비게이션 바가 나타나는 문제는 스피너가 사용하는 `PopupWindow`가 부모 액티비티와는 별개의 윈도우이며, 부모의 UI 숨김 설정을 상속받지 않기 때문에 발생합니다. 안드로이드 윈도우 매니저가 새로 포커스된 `PopupWindow`의 기본 정책에 따라 시스템 UI를 다시 보여주게 되는 것입니다.
이 문제에 대한 해결책은 개발 리소스와 프로젝트의 요구 사항에 따라 선택할 수 있습니다.
해결책 | 장점 | 단점 | 추천 대상 |
---|---|---|---|
리플렉션으로 `setModal(false)` 호출 | 구현이 매우 간단하고 빠름. 기존 코드를 거의 수정하지 않음. | 비공개 API(리플렉션) 사용으로 인한 잠재적 불안정성. | 빠른 수정이 필요하거나, 프로젝트의 복잡도가 낮은 경우. |
`DialogFragment`로 대체 | 완벽한 UI/UX 커스터마이징 가능. 안정적이고 표준적인 방법. | 초기 개발 공수가 더 많이 듦. 보일러플레이트 코드 증가. | 높은 품질의 UI/UX가 중요하고, 장기적으로 유지보수할 프로젝트. |
대부분의 경우, 리플렉션을 이용한 `setModal(false)` 방법으로 충분히 만족스러운 결과를 얻을 수 있습니다. 하지만 안드로이드 개발의 본질적인 원리를 이해하고 더 견고하며 확장 가능한 애플리케이션을 만들고 싶다면, `DialogFragment`를 이용한 접근 방식을 적극적으로 검토해보시길 권장합니다. 이제 여러분은 스피너의 배신에 더 이상 당황하지 않고, 안드로이드의 윈도우 시스템을 이해하는 한 단계 더 높은 수준의 개발자로 거듭나게 되었습니다.
0 개의 댓글:
Post a Comment