Monday, May 27, 2019

안드로이드 네비게이션 바 숨기기, 키보드 등장에도 완벽 대응하는 최종 가이드 (최신 API 포함)

안드로이드 앱 개발 시 사용자에게 몰입감 높은 경험을 제공하기 위해 상태 표시줄(Status Bar)과 네비게이션 바(Navigation Bar)를 숨기는 '전체화면' 또는 '몰입 모드(Immersive Mode)'를 구현하는 경우는 매우 흔합니다. 비디오 플레이어, 게임, 이미지 뷰어, 키오스크 앱 등에서 화면 전체를 콘텐츠로 채우는 것은 이제 선택이 아닌 필수가 되었습니다.

인터넷 검색을 통해 네비게이션 바를 숨기는 방법을 찾아보면 대부분 비슷한 코드를 제시합니다. 하지만 많은 개발자들이 특정 상황에서 좌절을 겪게 되는 공통적인 문제가 있습니다. 바로 EditText와 같은 입력 필드를 터치하여 가상 키보드가 올라올 때, 애써 숨겨두었던 네비게이션 바가 불쑥 다시 나타나고 사라지지 않는 현상입니다. 이는 사용자의 몰입감을 심각하게 해치며, 앱의 완성도를 떨어뜨리는 치명적인 문제입니다.

이 글에서는 수많은 개발자들을 괴롭혔던 '키보드 등장 시 네비게이션 바 부활' 문제를 완벽하게 해결하는 방법을 기초부터 심층적으로 다룹니다. 단순히 코드 조각을 복사 붙여넣기 하는 수준을 넘어, 왜 문제가 발생하는지 근본적인 원인을 파악하고, 구글이 권장하는 최신 API를 사용한 현대적인 해결책까지 총망라하여 제시합니다. 이 가이드를 끝까지 읽으시면 더 이상 네비게이션 바 때문에 골머리를 앓는 일은 없을 것입니다.

1. 문제의 발단: 흔하지만 함정이 있는 방법들

먼저, 왜 기존의 방법들이 특정 상황에서 실패하는지 이해해야 합니다. 대부분의 가이드는 Activity의 onResume() 이나 onWindowFocusChanged() 생명주기 콜백에서 시스템 UI 가시성을 변경하는 코드를 추천합니다.


// 흔히 볼 수 있는 네비게이션 바 숨기기 코드 (문제 발생 가능성 있음)
@Override
public void onWindowFocusChanged(boolean hasFocus) {
    super.onWindowFocusChanged(hasFocus);
    if (hasFocus) {
        hideSystemUI();
    }
}

private void hideSystemUI() {
    View decorView = getWindow().getDecorView();
    decorView.setSystemUiVisibility(
        View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
        // 이 플래그는 다른 UI 플래그와 함께 사용될 때 효과적입니다.
        | 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);
}

이 코드는 앱이 포커스를 얻을 때마다 네비게이션 바를 숨기도록 설정합니다. 일반적인 상황에서는 잘 동작하는 것처럼 보입니다. 하지만 EditText에 포커스가 주어지면서 가상 키보드(IME, Input Method Editor)가 화면에 나타나는 순간, 상황은 복잡해집니다.

왜 onWindowFocusChanged는 실패하는가?

가상 키보드는 현재 Activity의 창(Window) 위에 별도의 창으로 나타납니다. 이로 인해 시스템 전체의 UI 상태가 변경되고, 창의 포커스 상태와 레이아웃에 영향을 미칩니다. 시스템은 키보드가 사용자 입력을 받아야 하므로, 사용자의 상호작용을 위해 일시적으로 시스템 UI(네비게이션 바 포함)를 다시 표시하려고 시도할 수 있습니다.

이때 onWindowFocusChanged()가 다시 호출되더라도 이미 시스템의 다른 메커니즘이 UI를 강제로 표시한 후일 수 있으며, 타이밍 문제로 인해 hideSystemUI() 호출이 무시되거나 덮어씌워지는 경우가 발생합니다. 결과적으로 개발자의 의도와 달리 네비게이션 바는 화면 하단에 꿋꿋이 자리를 지키게 됩니다.

기본 플래그에 대한 깊은 이해

문제를 정확히 해결하기 위해, setSystemUiVisibility()에 사용되는 주요 플래그들의 역할을 명확히 짚고 넘어가야 합니다. 이 플래그들은 현재는 Deprecated 되었지만, 여전히 많은 레거시 코드에서 사용되고 있으며 개념을 이해하는 것은 중요합니다.

  • View.SYSTEM_UI_FLAG_HIDE_NAVIGATION: 네비게이션 바를 숨깁니다. 하지만 사용자가 화면과 조금이라도 상호작용하면(예: 화면 터치) 네비게이션 바가 즉시 다시 나타납니다. 일시적인 숨김에 불과합니다.
  • View.SYSTEM_UI_FLAG_FULLSCREEN: 상태 표시줄을 숨깁니다. HIDE_NAVIGATION과 비슷한 특성을 가집니다.
  • View.SYSTEM_UI_FLAG_IMMERSIVE: 이 플래그를 HIDE_NAVIGATION 또는 FULLSCREEN과 함께 사용하면, 사용자가 화면 가장자리(상단 또는 하단)를 스와이프해야만 시스템 바가 나타납니다. 바가 나타나면 일정 시간 동안 표시되다가 다시 자동으로 사라집니다.
  • View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY: 이것이 우리가 원하는 '진정한 몰입 모드'의 핵심입니다. 이 플래그를 사용하면 사용자가 가장자리를 스와이프했을 때 시스템 바가 반투명하게 나타났다가, 잠시 후 상호작용이 없으면 자동으로 사라집니다. 사용자가 명시적으로 시스템 UI를 불러내지 않는 한 계속 숨겨진 상태를 유지하려는 강력한 의지를 표현합니다.
  • View.SYSTEM_UI_FLAG_LAYOUT_STABLE, View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION, View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN: 이 플래그들은 시스템 바가 나타나거나 사라질 때 앱의 전체 레이아웃이 출렁이며 리사이징되는 것을 방지합니다. 시스템 바가 차지할 공간을 미리 확보하여, 시스템 바가 나타나도 콘텐츠가 위로 밀려나는 현상 없이 부드럽게 오버레이되도록 만들어 줍니다. 안정적인 전체화면 구현을 위한 필수 플래그입니다.

결국 우리의 목표는 IMMERSIVE_STICKY 상태를 어떤 방해 요인(예: 키보드 등장)에도 불구하고 끈질기게 유지하는 것입니다.


2. 강력하고 안정적인 해결책: SystemUiVisibilityChangeListener 활용

생명주기 콜백의 타이밍 문제에 의존하는 대신, 시스템 UI의 '가시성 상태가 변경될 때마다' 직접 대응하는 것이 가장 확실한 방법입니다. 이를 위해 View.OnSystemUiVisibilityChangeListener를 사용합니다.

이 리스너는 이름 그대로 시스템 UI의 가시성에 어떠한 변경이라도 감지되면 즉시 콜백을 실행합니다. 즉, 키보드가 나타나면서 시스템이 네비게이션 바를 강제로 표시하려고 시도하는 바로 그 순간을 포착하여, 우리가 원하는 숨김 상태로 즉시 되돌려 놓을 수 있습니다.

가장 효과적인 구현 위치는 Activity의 onCreate() 입니다. Activity가 생성될 때 단 한 번 리스너를 설정해두면, 해당 Activity가 살아있는 동안 계속해서 시스템 UI 상태를 감시하고 제어하게 됩니다.

구현 코드 (API 30 미만 대응)

다음은 EditText 포커스 문제까지 완벽하게 해결하는, 리스너를 이용한 전체 코드입니다.


import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;

public class FullscreenActivity extends AppCompatActivity {

    private View decorView;
    private int uiOption;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_fullscreen);

        // 1. decorView와 UI 옵션 초기화
        decorView = getWindow().getDecorView();
        uiOption = getWindow().getDecorView().getSystemUiVisibility();
        // 새롭게 추가될 플래그들
        uiOption |= View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
        uiOption |= View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
        uiOption |= View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION;
        uiOption |= View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN;
        uiOption |= View.SYSTEM_UI_FLAG_HIDE_NAVIGATION;
        uiOption |= View.SYSTEM_UI_FLAG_FULLSCREEN;

        // 2. onCreate에서 최초로 UI를 숨김
        decorView.setSystemUiVisibility(uiOption);

        // 3. 리스너 설정: UI 가시성이 변경될 때마다 호출됨
        decorView.setOnSystemUiVisibilityChangeListener(new View.OnSystemUiVisibilityChangeListener() {
            @Override
            public void onSystemUiVisibilityChange(int visibility) {
                // 주의: 'visibility' 파라미터는 현재 변경된 상태를 나타냅니다.
                // 시스템에 의해 UI가 강제로 보여진 경우 (예: 키보드 등장)
                // (visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0 은 상태바가 보임을 의미
                if ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) {
                    // 여기에 로직을 추가하여 상태바가 보일 때 특정 작업을 할 수 있습니다.
                    // 우리의 목표는 다시 숨기는 것이므로, 준비된 uiOption을 다시 설정합니다.
                    decorView.setSystemUiVisibility(uiOption);
                }
                // 다른 경우에 대한 처리도 추가할 수 있습니다.
            }
        });
    }
}

핵심 원리:

  1. 초기 설정 (onCreate): Activity가 생성되자마자 setSystemUiVisibility()를 호출하여 일단 네비게이션 바를 숨깁니다.
  2. 감시자 설정 (Listener): setOnSystemUiVisibilityChangeListener를 등록합니다. 이 리스너는 이제부터 파수꾼 역할을 합니다.
  3. 상태 변경 감지 및 대응: 사용자가 EditText를 터치하면, 키보드가 올라오면서 안드로이드 시스템이 네비게이션 바를 표시하려고 시도합니다. 이 순간, 시스템 UI 가시성이 변경되었으므로 리스너의 onSystemUiVisibilityChange() 콜백이 즉시 트리거됩니다.
  4. 상태 복원: 콜백 함수 내부에서 우리는 어떤 상태로 변경되었는지 확인할 수 있지만, 우리의 목표는 '무조건 숨김 상태 유지'이므로, 고민할 필요 없이 미리 정의해둔 uiOption 값을 다시 decorView.setSystemUiVisibility()에 전달하여 강제로 숨김 상태로 되돌립니다.
이 '변경 -> 감지 -> 복원'의 순환 구조 덕분에, 키보드가 나타나든 다른 어떤 이유로 시스템 UI가 보이려고 하든, 우리 코드는 즉각적으로 반응하여 몰입 모드를 유지할 수 있게 됩니다.


3. 더 나은 미래: 최신 WindowInsetsController API 사용하기 (API 30+)

앞서 설명한 setSystemUiVisibility()와 관련 플래그들은 Android 11 (API 30)부터 공식적으로 Deprecated(사용 자제) 처리되었습니다. 물론 하위 호환성을 위해 계속 동작하지만, 구글은 더 직관적이고 강력한 새로운 API인 WindowInsetsController를 사용할 것을 강력히 권장합니다.

WindowInsetsController는 시스템 바(상태 바, 네비게이션 바, 캡션 바 등)의 모양과 동작을 훨씬 더 세밀하게 제어할 수 있는 통합 인터페이스를 제공합니다. Jetpack Core 라이브러리의 WindowInsetsControllerCompat을 사용하면 API 레벨에 상관없이 일관된 코드로 최신 기능을 활용할 수 있어 더욱 편리합니다.

WindowInsetsController의 장점

  • 명확한 API: hide(), show() 등 메서드 이름이 직관적입니다. 복잡한 비트마스크 플래그를 외울 필요가 없습니다.
  • 타입 안정성: WindowInsetsCompat.Type.systemBars(), WindowInsetsCompat.Type.ime() 와 같이 제어하려는 UI의 종류를 타입으로 명시하여 실수를 줄일 수 있습니다.
  • 동작 제어 분리: 바를 숨기는 동작(hide)과, 숨겨진 바를 어떻게 다시 표시할지에 대한 동작(Behavior) 설정이 분리되어 코드가 명료해집니다.

최신 API를 사용한 구현 코드 (권장)

build.gradle (Module: app) 파일에 다음 의존성이 추가되어 있는지 확인하세요. (일반적으로 최신 Android Studio 프로젝트에는 기본 포함됩니다.)


implementation 'androidx.core:core-ktx:1.9.0' // 또는 최신 버전

이제 Activity 코드를 현대적인 방식으로 리팩토링해 보겠습니다.


import androidx.appcompat.app.AppCompatActivity;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import android.os.Bundle;

public class ModernFullscreenActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_fullscreen);
        
        // Step 1: 레이아웃이 시스템 바 뒤에 그려지도록 설정
        // 이것은 시스템 바가 나타나거나 사라질 때 레이아웃 크기가 변경되는 것을 방지합니다.
        WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
        
        hideSystemBars();
    }
    
    // onResume에서도 호출하여 다른 앱으로 갔다가 돌아왔을 때도 전체화면을 유지할 수 있습니다.
    @Override
    protected void onResume() {
        super.onResume();
        hideSystemBars();
    }
    
    private void hideSystemBars() {
        WindowInsetsControllerCompat windowInsetsController =
                WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
        if (windowInsetsController == null) {
            return;
        }
        
        // Step 2: 시스템 바(상태 바, 네비게이션 바)를 숨깁니다.
        windowInsetsController.hide(WindowInsetsCompat.Type.systemBars());
        
        // Step 3: 시스템 바가 숨겨졌을 때의 동작을 정의합니다.
        // BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE는 가장자리 스와이프 시에만
        // 시스템 바가 일시적으로 나타나도록 하는 동작이며, 이는
        // 구 버전의 IMMERSIVE_STICKY와 동일한 효과를 냅니다.
        windowInsetsController.setSystemBarsBehavior(
                WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
        );
    }
}

최신 코드 해설:

  1. WindowCompat.setDecorFitsSystemWindows(getWindow(), false): 이 코드는 매우 중요합니다. 과거의 LAYOUT_STABLE, LAYOUT_HIDE_NAVIGATION 플래그 역할을 한 번에 수행합니다. 앱의 뷰가 시스템 바가 있을 영역까지 포함하여 전체 화면을 사용하도록 만들어, 시스템 바가 사라졌을 때 레이아웃이 출렁이는 현상을 원천적으로 방지합니다.
  2. WindowInsetsControllerCompat.getInsetsController(...): 현재 창을 제어할 수 있는 컨트롤러 객체를 가져옵니다.
  3. controller.hide(WindowInsetsCompat.Type.systemBars()): "시스템 바들을(상태 바 + 네비게이션 바) 숨겨라" 라는 매우 명확한 명령입니다. WindowInsetsCompat.Type.navigationBars()만 사용하여 네비게이션 바만 숨길 수도 있습니다.
  4. controller.setSystemBarsBehavior(...): 숨겨진 바의 동작 방식을 설정합니다. BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE는 사용자가 화면 가장자리를 스와이프해야만 시스템 바가 일시적으로(transient) 나타나게 하고, 상호작용이 없으면 자동으로 다시 사라지게 합니다. 이것이 바로 IMMERSIVE_STICKY 모드와 정확히 일치하는 동작입니다.

이 최신 API는 내부적으로 EditText 포커스 및 키보드 등장과 같은 복잡한 시나리오를 훨씬 더 잘 처리하도록 설계되었습니다. OnSystemUiVisibilityChangeListener와 같은 리스너를 직접 구현할 필요 없이, 시스템에 "내 앱의 전체화면 정책은 이것이다"라고 한 번만 선언해두면 시스템이 알아서 그 상태를 최대한 유지하려고 노력합니다. 따라서 코드는 훨씬 간결해지고 안정성은 더욱 높아집니다.

4. 심화 학습 및 고려사항

키보드(IME) 높이에 따른 레이아웃 조정

네비게이션 바 문제는 해결했지만, 전체화면 앱에서 키보드가 나타날 때 키보드가 중요한 UI(예: 로그인 버튼)를 가리는 또 다른 문제에 직면할 수 있습니다. setDecorFitsSystemWindows(false)를 사용하면 키보드 역시 내 앱의 화면 위로 그냥 오버레이됩니다.

이 문제를 해결하려면 WindowInsets를 직접 수신하여 키보드의 높이만큼 UI에 패딩이나 마진을 동적으로 적용해야 합니다. 이는 ViewCompat.setOnApplyWindowInsetsListener를 사용하여 구현할 수 있습니다.


// 예시: 루트 레이아웃에 리스너를 설정하여 IME(키보드) Insets에 대응
View rootView = findViewById(R.id.root_layout); // activity_fullscreen.xml의 최상위 뷰

ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> {
    // 키보드로 인한 Insets (화면을 가리는 영역)
    int imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom;

    // 시스템 바로 인한 Insets (상태바, 네비게이션바 등)
    int systemBarsHeight = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom;

    // 키보드가 올라왔을 때만 패딩을 주고, 아닐 때는 시스템 바 높이만 고려
    // 키보드가 보일때 imeHeight가 systemBarsHeight 보다 큼.
    int bottomPadding = Math.max(imeHeight, systemBarsHeight);
    
    v.setPadding(v.getPaddingLeft(), v.getPaddingTop(), v.getPaddingRight(), bottomPadding);
    
    // 변경된 Insets을 자식 뷰에게도 전달
    return insets;
});

이 코드는 키보드가 나타나면 그 높이만큼 루트 뷰의 하단에 패딩을 적용하여, 키보드에 가려질 수 있는 콘텐츠를 위로 밀어 올리는 효과를 줍니다. 이로써 진정으로 반응형 전체화면 UI를 완성할 수 있습니다.

사용자 경험(UX) 고려

기술적으로 네비게이션 바를 완벽하게 숨길 수 있게 되었지만, 이것이 항상 사용자에게 좋은 경험을 주는 것은 아닙니다.

  • 콘텐츠 중심 앱: 동영상, 게임, 사진 갤러리, 드로잉 앱처럼 콘텐츠 자체가 주인공인 앱에서는 전체화면이 몰입감을 극대화합니다.
  • 유틸리티/정보성 앱: 반면, 기능 사용이 주목적인 일반적인 유틸리티 앱에서 네비게이션 바를 숨기면 사용자가 뒤로 가기, 홈으로 가기 등의 기본 조작에 어려움을 겪어 오히려 불편함을 느낄 수 있습니다.

따라서 네비게이션 바 숨김 기능은 앱의 성격에 맞게 신중하게 적용해야 합니다. 필요한 경우, 사용자가 화면을 한 번 탭하면 시스템 UI가 나타나고 다시 탭하면 사라지는 식의 자체적인 UI 컨트롤을 제공하는 것도 좋은 방법입니다.

결론: 최종 정리

안드로이드에서 키보드 등장 시 네비게이션 바가 다시 나타나는 문제는 단순히 생명주기 콜백에 숨김 코드를 넣는 것만으로는 해결하기 어려운, 타이밍과 시스템 상호작용의 문제입니다.

이 문제를 해결하기 위한 여정을 정리하면 다음과 같습니다.

  1. 문제 인식: onWindowFocusChanged()onResume()만으로는 키보드 등장 시의 UI 변화에 안정적으로 대응할 수 없다.
  2. 전통적인 해법: setOnSystemUiVisibilityChangeListener를 사용하여 시스템 UI 가시성 변경을 '감시'하고, 변경될 때마다 원하는 숨김 상태로 '강제 복원'하는 방법이 효과적이다.
  3. 현대적인 해법 (강력 추천): Android 11(API 30) 이상을 타겟팅하거나 androidx.core 라이브러리를 사용한다면, WindowInsetsControllerCompat을 사용하는 것이 훨씬 간단하고 안정적이다.
    • WindowCompat.setDecorFitsSystemWindows(getWindow(), false)로 레이아웃이 출렁이지 않게 한다.
    • controller.hide(WindowInsetsCompat.Type.systemBars())로 시스템 바를 숨긴다.
    • controller.setSystemBarsBehavior(BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE)로 '스티키 몰입 모드' 동작을 정의한다.
  4. 완성도 높이기: setOnApplyWindowInsetsListener를 활용해 키보드 높이에 반응하여 레이아웃을 조정하면 사용자 경험을 한 단계 더 끌어올릴 수 있다.

이제 여러분은 어떤 상황에서도 네비게이션 바를 자유자재로 제어하고, 사용자에게 완벽한 몰입 경험을 선사하는 고품질의 안드로이드 앱을 만들 수 있는 확실한 지식과 기술을 갖추게 되었습니다.


0 개의 댓글:

Post a Comment