Android 시스템 바(상태바, 네비게이션바) 색상 자동 변경 처리하기

아래의 내용을 통하여 시스템 바의 색상/레이아웃을 변경하는 방법을 확인할 수 있습니다.

Android 시스템 바 레이아웃 변경하기

시스템 바 색상 변경시 생기는 문제점


아래와 같이 배경 색상이 다른 화면이 표시되는 경우, 화면의 배경색과 시스템 바의 색상이 겹쳐서 보이지 않는 문제가 발생합니다.

   

Activity, Fragment, Compose 화면 구성 요소가 변경될 때 이와 같은 상황이 발생하게 됩니다.

본 글에서는 Fragment가 변경될 때의 경우에 대처하는 방법을 설명하겠습니다.

화면이 변경될 떄 마다 대처하는 방법

  • Activity 가 변경되는 경우
    • onCreate()에서 색상 변경 코드를 작성
    • XML에서 미리 지정해놓은 테마(스타일)적용
  • Fragment 가 변경되는 경우
    • 시스템 바의 색상이 변경되어야 하는 Fragment 마다 각각 색상 변경 코드를 작성하여야 함

위와 같은 방법은 코드의 중복이 발생하고, 상당히 번거로운 작업이 됩니다.

특히 수 많은 Fragment가 변경되는 경우라면 굉장한 반복 작업이 될 것입니다.

이러한 고된 작업을 하지 않기 위해서 우선 FragmentLifecycleCallbacks 사용을 고려할 수 있습니다.

FragmentLifecycleCallbacks으로 Fragment의 상태를 감지하기


FragmentLifecycleCallbacksFragmentManager 에 등록하여 Fragment의 상태(생명주기)를 감지할 수 있습니다.

supportFragmentManager.registerFragmentLifecycleCallbacks(
    object : FragmentManager.FragmentLifecycleCallbacks() {
        override fun onFragmentResumed(fm: FragmentManager, f: Fragment) {
            super.onFragmentResumed(fm, f)

            // 이 함수는 아래에서 설명하겠습니다.
            systemBarColorAnalyzer.convert()
        }
    },
    true,
)
  • Callback을 등록할 FragmentManagerActivity에서 FragmentManager를 사용하는 것을 권장합니다.
    • registerFragmentLifecycleCallbacks()를 사용할 때 Argument로 true를 전달하면 View에 표시되는 모든 Fragment의 상태를 감지를 할 수 있습니다.

이제 Fragment의 상태를 한 곳에서 감지할 수 있게 되었습니다.

본격적으로 시스템 바의 색상을 자동으로 변경하는 방법을 알아봅시다.

시스템 바의 색상을 자동으로 변경하는 방법

Fragment의 개수가 2 ~ 3개 정도로 아주 적으면, 경우에 따라 각각의 Fragment에서 시스템 바의 색상을 변경하는 코드를 작성하거나 위 Callback의 onFragmentResumed()에서 Fragment 별로 분기문을 만들어 색상을 변경하는 방법이 더 효율적일 수 있습니다.

그러나, Fragment의 개수는 수십 개, 많으면 수백 개가 될 수 있습니다.

이러한 경우에는 분기문으로 처리하는 것은 당연히 매우 부적절한 방법입니다.

따라서, FragmentResumed 상태가 되었을 때, 시스템 바 위치에 있는 View의 배경색을 분석하여 색상을 변경하는 방법을 사용하도록 하겠습니다.

로직을 간단히 설명하면 아래와 같습니다.

  1. Fragment onResumed 응답을 받고 색상변경 요청을 합니다.
  2. 시스템 바 색상을 분석합니다.
  3. 색상을 흑백으로 변환합니다.
  4. 변환된 흑백 값에 따라 시스템 바의 색상을 변경합니다.

상세 로직

1. 색상변경 작업을 구현하는 클래스 초기화

색상 변경을 담당하는 SystemBarColorProcessor 클래스를 만들었습니다.

SystemBarColorProcessor 클래스를 초기화합니다.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    // SystemBarColorProcessor를 초기화합니다.
    SystemBarColorProcessor.init(window, lifecycle)

    // FragmentLifecycleCallbacks를 등록합니다.
    supportFragmentManager.registerFragmentLifecycleCallbacks(
        object : FragmentManager.FragmentLifecycleCallbacks() {
            override fun onFragmentResumed(fm: FragmentManager, f: Fragment) {
                super.onFragmentResumed(fm, f)
                systemBarColorProcessor.convert()
            }
        },
        true,
    )
}
private var _window: Window? = null
private val window: Window
    get() = _window!!

private var _windowInsetsController: WindowInsetsControllerCompat? = null
private val windowInsetsController: WindowInsetsControllerCompat
    get() = _windowInsetsController!!


fun init(window: Window, lifecycle: Lifecycle) {
    _window = window
    _windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)

    lifecycle.addObserver(
        object : DefaultLifecycleObserver {
            override fun onStart(owner: LifecycleOwner) {
                super.onStart(owner)
                // Activity가 Start될 때, 색상변경 처리를 합니다.
                convert()
            }

            override fun onDestroy(owner: LifecycleOwner) {
                super.onDestroy(owner)
                // Activity가 Destroy될 때, 메모리 누수를 방지하기 위한 작업을 해줍니다.
                coroutineScope.cancel()
                _window = null
                _windowInsetsController = null
            }
        },
    )
}

2. Fragment onResumed 응답을 받고, 색상변경 요청

override fun onFragmentResumed(fm: FragmentManager, f: Fragment) {
    super.onFragmentResumed(fm, f)
    SystemBarColorProcessor.convert()
}
private val waitLock = Mutex()
private var waiting: Job? = null
private val coroutineScope = MainScope() + CoroutineName("SystemBarColorProcessor")
private val onChangedFragmentFlow = MutableSharedFlow<Unit>(onBufferOverflow = BufferOverflow.SUSPEND, replay = 0, extraBufferCapacity = 2)
private val delayTime = 80L

fun convert() {
    coroutineScope.launch {
        waitLock.withLock {
            if (waiting?.isActive == true) waiting?.cancel()
            waiting = launch(Dispatchers.Default) {
                delay(delayTime)
                onChangedFragmentFlow.emit(Unit)
            }
        }

    }
}
  1. onResumed 응답 수신
  2. convert(), 시스템 바 색상변경 요청
  3. convert() 함수가 호출되면, 비동기로 처리합니다.
  4. waiting Job이 존재하면, 취소합니다.
  5. 새로운 waiting Job을 생성하고, delayTime : 80ms 시간동안 대기합니다.
  6. 만약 delayTime이내에 추가 요청이 발생하면, 2번 작업을 다시 수행합니다.
  7. delayTime이내에 추가 요청이 없으면, onChangedFragmentFlow에 새로운 요청이 발생했음을 알립니다.

3. 시스템 바 색상 분석, 픽셀 값 변환, 색상 변경

private enum class ColorType {
    BLACK, WHITE
}

private data class ConvertedColor(val statusBarColor: ColorType, val navBarColor: ColorType)


init {
    coroutineScope.launch(Dispatchers.Default) {
        onChangedFragmentFlow.collect {
            val convertJob = launch(start = CoroutineStart.LAZY) {
                val convertedColor = startConvert()
                withContext(Dispatchers.Main) {
                    setStyle(convertedColor)
                }
            }

            window.decorView.doOnPreDraw {
                convertJob.start()
            }
            convertJob.join()
        }
    }
}

private fun setStyle(convertedColor: ConvertedColor) {
    windowInsetsController.apply {
        // 상태바 색상 변경
        isAppearanceLightStatusBars = (statusBarColor == ColorType.BLACK)
        isAppearanceLightNavigationBars = (navBarColor == ColorType.BLACK)
    }
}
private suspend fun startConvert(): ConvertedColor {
    // 비트맵 생성
    val statusBarBitmap = WeakReference(Bitmap.createBitmap(decorView.width, statusBarHeight, Bitmap.Config.ARGB_8888)).get()!!
    val navBarBitmap = WeakReference(Bitmap.createBitmap(decorView.width, navBarHeight, Bitmap.Config.ARGB_8888)).get()!!

    // View에서 시스템 바 위치의 픽셀 값을 비트맵에 복사
    window.decorView.run {
        pixelCopy(Rect(0, 0, width, statusBarHeight), statusBarBitmap)
        pixelCopy(Rect(0, height - navBarHeight, width, height), navBarBitmap)
    }

    // 비트맵 좌표 (10, 10)의 픽셀 값을 최종 시스템 바 색상으로 변환
    val statusBarColor = statusBarBitmap[10, 10].toColor()
    val navBarColor = navBarBitmap[10, 10].toColor()

    // 비트맵 메모리 회수
    statusBarBitmap.recycle()
    navBarBitmap.recycle()

    return ConvertedColor(statusBarColor, navigationBarColor)
}
private suspend fun pixelCopy(rect: Rect, bitmap: Bitmap) = suspendCancellableCoroutine { cancellableContinuation ->
    // Window에서 사각형 영역의 픽셀 값을 비트맵에 복사
    PixelCopy.request(
        window, rect, bitmap,
        {
            cancellableContinuation.resume(it == PixelCopy.SUCCESS)
        },
        Handler(Looper.getMainLooper()),
    )
}
private val criteriaColor = 140

private fun Int.toColor() : ColorType { 
    val r = Color.red(this)
    val g = Color.green(this)
    val b = Color.blue(this)
    val a = Color.alpha(this)

    val gray = if (a == 0) -1
    else (0.2989 * r + 0.5870 * g + 0.1140 * b).toInt()

    // -1 : 투명
    // 0 : 검은색
    // 255 : 흰색
    // 기준 값에 따라 검은색, 흰색으로 분류
    return if (gray == 0 || gray == -1) WHITE
    else if (gray <= criteriaColor) WHITE
    else BLACK
}
  1. Window의 DecorView에 일회성 OnPreDrawListener를 등록합니다.
    1. KTX 라이브러리를 사용한다면 doOnPreDraw로 간편하게 등록할 수 있습니다.
    2. OnPreDrawListener는 View가 그려지기 전에 호출되는 리스너입니다.
    3. OnDrawListener도 있으나, OnDrawListener는 View가 그려진 후에 호출되기 때문에, 조금이라도 더 빠르게 색상변경을 하기위해 OnPreDrawListener를 사용합니다.
  2. startConvert(), 색상 분석 및 변환을 시작합니다.
  3. 변환된 시스템 바 색상을 적용시킵니다.
    1. MainThread에서 실행되어야 하기 때문에, withContext(Dispatchers.Main)을 사용합니다.

요청 순서대로 작업을 수행하기 위해 지연시작으로 코루틴을 동작시키도록 하였습니다.

결과

위와 같은 작업을 실시간으로 수행하면서 색상이 변경됨을 확인할 수 있습니다.

android_systembar_updating

android_systembar