Edge-to-edge в Android: делаем правильно

  • Tutorial
Прошедший Google I/O 2019 принёс массу нашумевших новинок, многие из которых будут влиять на индустрию мобильной разработки в ближайшие годы. Не менее интересно было следить за зарождающимися трендами. Сначала в историю ушли механические клавиши управления, экраны смартфонов становились всё больше, а боковые рамки всё незаметнее. На смену экранным системным кнопкам пришли жесты, оставляя всё больше пространства для потребления контента. Приложения отображаются на всей видимой поверхности дисплея, от нижней до верхней рамки, не стесняя себя условными границами статус-бара и навигационной панели. Мы на пороге эры Edge-to-Edge.



Что такое Edge-to-Edge? Если понимать буквально, это означает, что ваше приложение должно отображаться на всей видимой поверхности дисплея, от нижней до верхней рамки, не стесняя себя статус-баром и нижними кнопками навигации.


Edge-to-edge на примере системной оболочки Android.

Когда речь идёт про Android, простая идея далеко не всегда проста в реализации. В этой статье речь пойдет о том, как по-максимуму использовать всё доступное место на экране любого девайса, независимо от производителя, версии системы и многообразия настроек, которыми так любят радовать пользователей производители устройств из поднебесной (и не только). Код, представленный в статье, был протестирован на более чем 30-ти девайсах нами собственноручно, и на 231 разных устройствах 100 тысячами пользователями наших приложений.

Сама по себе проблема создания edge-to-edge интерфейса не нова и была актуальна задолго до I/O 2019. Наверняка каждый из вас вспомнит, как впервые гуглил что-то из разряда: «android transparent status bar» или «android status bar gradient».

Главными критериями соответствию приложения званию «edge-to-edge» являются наличие:

  • прозрачного Status Bar;
  • прозрачного Navigation Bar.

Подробнее про них на material.io.


Приложение Deezer не сильно переживает о соблюдении принципов Edge-to-Edge

Важно отметить, что речь не идёт о том, чтобы убрать их совсем, как в "fullscreen mode". Мы оставляем пользователю возможность видеть важную системную информацию и пользоваться привычной навигацией.

Не менее важное требование к решению — масштабируемость и расширяемость. Есть и ряд других:

  • Корректно сдвигать экран над клавиатурой, не сломав поддержку adjustResize-флагов у Activity;
  • Избегать наложения Status Bar и Navigation Bar на UI-элементы приложения, отображая при этом под ними соответствующий фон;
  • Работать на всех девайсах с актуальными версиями Android и выглядеть идентично.

Немного теории


На поиск решения для такой простой, казалось бы, задачи, у вас может уйти неожиданно много времени, объяснить которое проектному менеджеру будет непросто. А когда QA всё же найдут злосчастный смартфон, на котором ваш экран выглядит не «по канонам»…
В нашем проекте мы ошибались несколько раз. Лишь спустя месяц, пройдя через длинную череду проб и ошибок, мы решили проблему раз и навсегда.

В первую очередь, необходимо разобраться с тем, как Android рисует системные панели. Начиная с Android 5.0 было предоставлено удобное API для работы с системными отступами вдоль горизонтальных граней экрана. Они называются WindowInsets, и на картинке снизу они окрашены красным:


Также, разработчиками из команды Android были добавлены слушатели, позволяющие подписываться на изменения этих отступов, например, при появлении клавиатуры. Строго говоря, WindowInsets — это отступы вашего layout-файла от границ экрана. При изменении размеров вашей Activity (split-screen mode, появление клавиатуры) будут меняться и Inset’ы. Таким образом, для поддержки edge-to-edge нам нужно сделать так, чтобы этих отступов не было. Экран с нулевыми WindowInsets будет выглядеть так:


Реализация


В нашей реализации мы будем активно оперировать Window и его флагами.
Все примеры будут написаны на Kotlin, но вы без труда сможете реализовать их и на Java, используя вместо extension-функций утилиты.

Первым делом у корневого элемента верстки необходимо явно установить флаг:

android:fitsSystemWindows="true"

Это необходимо делать для того, чтобы корневой View рисовался под системными элементами, а также для корректных измерений Inset’ов при подписке на их изменение.
Теперь переходим к самому главному — убираем границы экрана! Однако, это необходимо делать очень аккуратно. И вот почему:

  1. Обнулив нижний Inset мы рискуем остаться без реакции окна на появление клавиатуры: на StackOverflow есть десятки советов по обнулению верхнего Inset’а, но про нижний деликатно молчат. Из-за этого NavigationBar не выходит сделать полностью прозрачным. При обнулении нижнего Inset’а флаг adjustResize перестаёт работать.

    Решение: При каждом изменении Inset’ов определять, содержится ли в нижнем высота клавиатуры, обнулять его только в противном случае.
  2. При обнулении Inset’ов, видимые части View будут заезжать под Status Bar и Navigation Bar. Согласно концепции Material Design (и здравому смыслу) в системных областях не должно располагаться никаких активных элементов. То есть, в этой области не должно быть кнопок, полей для ввода текста, чекбоксов и т.д. 

    Решение: мы добавим listener в listener, чтобы при изменении WindowInsets, транслировать системные отступы в Activity, и реагировать на них внутри, выставляя корректные padding’и и margin’ы для View.


Такое поведение допускать нельзя (Toolbar залезает на Status Bar).

Функция removeSystemInsets() выглядит следующим образом:

fun removeSystemInsets(view: View, listener: OnSystemInsetsChangedListener) {
    ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets ->

        val desiredBottomInset = calculateDesiredBottomInset(
                view,
                insets.systemWindowInsetTop,
                insets.systemWindowInsetBottom,
                listener
        )

        ViewCompat.onApplyWindowInsets(
                view,
                insets.replaceSystemWindowInsets(0, 0, 0, desiredBottomInset)
        )
    }
}

Функция calculateDesiredBottomInset() высчитывает нижний Inset с учётом клавиатуры или без неё, в зависимости от текущей конфигурации устройства.

fun calculateDesiredBottomInset(
        view: View,
        topInset: Int,
        bottomInset: Int,
        listener: OnSystemInsetsChangedListener
): Int {
    val hasKeyboard = isKeyboardAppeared(view, bottomInset)
    val desiredBottomInset = if (hasKeyboard) bottomInset else 0
    listener(topInset, if (hasKeyboard) 0 else bottomInset)
    return desiredBottomInset
}

Для проверки высоты клавиатуры используется метод isKeyboardAppeared(). Мы доверились гипотезе, что клавиатура не может занимать меньше четверти высоты экрана. При желании, вы можете как угодно модифицировать логику проверки.

private fun View.isKeyboardAppeared(bottomInset: Int) =
        bottomInset / resourdisplayMetrics.heightPixels.toDouble() > .25

В методе removeSystemInsets() используется listener. На самом деле, это всего лишь typealias для лямбда-выражения. Его полный код:

typealias OnSystemBarsSizeChangedListener =
                (statusBarSize: Int, navigationBarSize: Int) -> Unit

Следующим шагом является задание прозрачности системным барам:

window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT

Скомпоновав все вышеописанное, получаем следующий метод:

fun Activity.setWindowTransparency(
        listener: OnSystemInsetsChangedListener = { _, _ -> }
) {
        InsetUtil.removeSystemInsets(window.decorView, listener)
        window.navigationBarColor = Color.TRANSPARENT
        window.statusBarColor = Color.TRANSPARENT
}

Теперь, для включения «edge-to-edge» режима у желаемой Activity, нужно всего лишь вызвать следующую функцию в методе onCreate():

setWindowTransparency { statusBarSize, navigationBarSize ->
        //Проставление отступов
}

Таким образом, менее чем за 30 строк кода мы достигли «edge-to-edge» эффекта, при этом не нарушая никаких UX-принципов и не лишая пользователя привычных системных элементов управления. Такая реализация может показаться кому-то простой и тривиальной, однако же именно она обеспечивает надёжную работу вашего приложения на любых устройствах.
Добиться «edge-to-edge» эффекта можно ещё примерно сотней разных способов (количество подобных советов на StackOverflow яркое тому подтверждение), но многие из них ведут либо к некорректному поведению на различных версиях Android, либо не учитывают такие параметры, как необходимость отображения длинных списков, либо ломают ресайз экрана при показе клавиатуры.




Ложка дегтя


Решение, описанное в этой статье подходит для всех актуальных девайсов. Под актуальными подразумеваются устройства на Android Lollipop (5.0) и выше. Для них решение выше будет работать идеально. А вот для более старых версий Android понадобится своя реализация, так как про WindowInsets в те времена ещё ничего не было известно.

Хорошая новость заключается в том, что на Android KitKat (4.4) прозрачность системных панелей всё же поддерживается. А вот более старые версии такую красоту не поддерживают вовсе, можно даже не пытаться.

Сконцентрируемся на сдвиге Inset’ов в Android 4.4. Это возможно сделать в методе fitSystemWindows(). Таким образом, главным элементом в вашей верстке должен быть контейнер с переопределенным методом fitSystemWindows, содержащим в точности такую же реализацию, как и у нашего listener’а в примере для актуальных версий Android.

class KitkatTransparentSystemBarsFrame @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = -1
) : FrameLayout(context, attrs, defStyleAttr), KitkatTransparentSystemBarsContainer {

    override var onSystemInsetsChangedListener: OnSystemInsetsChangedListener = 
        { _, _ -> }

    override fun fitSystemWindows(insets: Rect?): Boolean {
        insets ?: return false
        val desiredBottomInset = InsetUtil.calculateDesiredBottomInset(
                this,
                insets.top,
                insets.bottom,
                onSystemInsetsChangedListener
        )
        return super.fitSystemWindows(Rect(0, 0, 0, desiredBottomInset))
    }

На девайсах с Android 4.4 работает только частичная прозрачность через выставление translucent-флагов:

window.addFlags(
WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS or
WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION
)

Эти флаги делают системные бары полупрозрачными, добавляя им небольшой градиент, который, к сожалению, невозможно убрать. Однако, градиент можно превратить в полупрозрачную цветную полосу с помощью этой библиотеки: https://github.com/jgilfelt/SystemBarTint. Она не раз выручала нас в прошлом. Последние изменения вносились в библиотеку 5 лет назад, поэтому она откроет свою прелесть лишь истинным ретроградам.

Весь процесс проставления флагов для Kitkat будет выглядеть следующим образом:

fun Activity.setWindowTransparencyKitkat(
        rootView: KitkatTransparentSystemBarsContainer,
        listener: OnSystemBarsSizeChangedListener = { _, _ -> }
) {
        rootView.onSystemBarsSizeChangedListener = listener
        window.addFlags(
                WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS or
                        WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION
        )

}

С учётом этого, пишем универсальный метод, который умеет делать системные бары прозрачными (или хотя бы полупрозрачными), независимо от того, на устройстве с какой версией Android запускается приложение:

when {
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP ->
        setWindowTransparency(::updateMargins)
        Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT ->
        setWindowTransparencyKitkat(root_container, ::updateMargins)
        else -> { /*do nothing*/ }
}

Под спойлером ниже вы можете увидеть, как семпл, представленный в статье, выглядит на некоторых из проблемных девайсов:

Скриншоты
Huawei Honor 8, Android 7.0



Xiaomi Redmi Note 4, Android 6.1



HTC Desire Dual Sim, Android 4.4.2



Samsung J3, Android 7.0



Meizu M3s, Android 5.1



Asus Zenfone 3 Max, Android 6.0



Umi Rome, Android 5.0



Nexus 5X, Android 8.0



Samsung Galaxy S8, Android 9.0





Напоследок хочется сказать, что решение даже такой, казалось бы, простой задачки, как задание прозрачности для элементов системного UI, может протащить вас по всему многообразию подводных камней, и не привести в итоге к желаемому результату, а наоборот, станет причиной появления неприятных багов. Хорошо, что теперь у вас есть эта статья.

Полный листинг программы и семпл работы вы можете найти в нашем git-репозитории.

Материал вдохновлен докладом Chris Banes «Becoming a master window fitter».


Выражаю благодарность студии Surf и Евгению Сатурову за помощь в подготовке материала.
  • +10
  • 3,7k
  • 2
Surf
53,47
Компания
Поделиться публикацией

Похожие публикации

Комментарии 2

    0
    Что насчёт landscape ориентации на телефонах? Ведь в этом случае Navigation Bar будет слева/справа. У вас в решении это никак не учтено?
      0
      C Landscape будет немного посложнее, как раз из-за того, что navbar может быть слева/справа, и при этом снизу на планшетах может выдвинуться клавиатура.
      Чтобы пример работал корректно с такими входными данными, нужно добавить недостающие поля в listener:
      typealias OnSystemInsetsChangedListener = (
          statusBarSize: Int,
          bottomNavigationBarSize: Int,
          leftNavigationBarSize: Int,
          rightNavigationBarSize: Int
      ) -> Unit
      

      Добавить эти параметры в вызов listener'а:
      listener(topInset, if (hasKeyboard) 0 else bottomInset, leftInset, rightInset)
      

      И реагировать на них в Activity:
      private fun updateMargins(
          statusBarSize: Int,
          bottomNavigationBarSize: Int,
          leftNavigationBarSize: Int,
          rightNavigationBarSize: Int
      ) {
          toolbar.updateMargin(top = statusBarSize)
          change_bg_btn.updateMargin(bottom = bottomNavigationBarSize)
          toolbar.updateMargin(left = leftNavigationBarSize)
          right_aligned_tv.updateMargin(right = rightNavigationBarSize)
      }
      

      Таким образом, мы будем корректно обрабатывать все кейсы поворота экрана и выезжающую снизу клавиатуру.

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое