Android insets: разбираемся со страхами и готовимся к Android Q

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.
Android Q — это десятая версия Android с 29-м уровнем API. Одна из главных идей новой версии это концепция edge-to-edge, когда приложения занимают весь экран, от нижней рамки до верхней. Это значит, что Status Bar и Navigation Bar должны быть прозрачными. Но, если они прозрачны, то системный UI нет — он перекрывает интерактивные компоненты приложения. Эта проблема решается с помощью insets.

Мобильные разработчики избегают insets, они вызывают у них страх. Но в Android Q обойти insets не удастся — придется их изучить и применять. На самом деле, в insets нет ничего сложного: они показывают, какие элементы экрана пересекаются с системным интерфейсом, и подсказывают, как переместить элемент, чтобы он не конфликтовал с системным UI. О том, как работают insets и чем они полезны, расскажет Константин Цховребов.


Константин Цховребов (terrakok) работает в Redmadrobot. Занимается Android 10 лет и накопил много опыта в разных проектах, в которых не было места insets, их всегда удавалось как-то обойти. Константин расскажет о долгой истории избегания проблемы insets, об изучении и борьбе с Android. Рассмотрит типичные задачи из своего опыта, в которых можно было бы применить insets, и покажет, как перестать бояться клавиатуры, узнавать ее размер и реагировать на появление.

Примечание. Статья написана на основе доклада Константина на Saint AppsConf 2019. В докладе использованы материалы из нескольких статей по insets. Ссылка на эти материалы в конце.

Типичные задачи


Цветной Status Bar. Во многих проектах дизайнер рисует цветной Status Bar. Это стало модно, когда появился Android 5 с новым Material Design.



Как покрасить Status Bar? Элементарно — добавляем colorPrimaryDark и цвет подставляется.

 <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
    ...
    <item name="colorPrimaryDark">@color/colorAccent</item>
    ...
</style>

Для Android выше пятой версии (API от 21 и выше) можно задать специальные параметры в теме:

<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
    ...
    <item name="android:statusBarColor">@color/colorAccent</item>
    ...
</style>

Разноцветный Status Bar. Иногда дизайнеры на разных экранах рисуют Status Bar разного цвета.



Ничего страшного, самый простой способ, который работает, это разные темы в разных activity.

Способ интереснее — менять цвета прямо в runtime.

override fun onCreateView(...): View {
    requireActivity().window.statusBarColor = requireContext().getColor(R.color.colorPrimary)
    ...
}

Параметр меняется через специальный флаг. Но главное не забыть сменить цвет обратно, когда пользователь выходит с экрана назад.

Прозрачный Status Bar. Это сложнее. Чаще всего прозрачность связана с картами, потому что именно на картах лучше всего видно прозрачность. В этом случае также как и раньше ставим специальный параметр:

<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">    
    ...
    <item name="android:windowTranslucentStatus">true</item>
    ...
</style>

Здесь, конечно, есть известная хитрость — поставить отступ выше, иначе на иконку наложится Status Bar и будет некрасиво.


Но на других экранах все ломается.



Как решить проблему? Первый способ, который приходит в голову, это разные activity: у нас разные темы, разные параметры, они по-разному работают.

Работа с клавиатурой. Мы избегаем insets не только со Status Bar, но и при работе с клавиатурой.



Вариант слева никто не любит, но, чтобы превратить его в вариант справа, есть простое решение.

<activity
    ...
    android:windowSoftInputMode="adjustResize">
    ...
</activity>

Теперь activity может менять свой размер, когда появляется клавиатура. Работает просто и сурово. Но не забудьте еще одну хитрость — обернуть все в ScrollView. Вдруг клавиатура займет весь экран и останется маленькая полоска сверху?

Бывают случаи сложнее, когда мы хотим изменить верстку при появлении клавиатуры. Например, иначе расставить кнопки или спрятать логотип.



Мы наставили столько костылей с клавиатурой и прозрачным Status Bar, теперь нас сложно остановить. Идем на StackOverflow и копируем прекрасный код.

boolean isOpened = false;

public void setListenerToRootView() {
    final View activityRootView = getWindow().getDecorView().findViewById(android.R.id.content);
    activityRootView.getViewTreeObserver().addOnGlobalLayoutListener(new OnGlobalLayoutListener() {
        @Override
        public void onGlobalLayout() {
    
            int heightDiff = activityRootView.getRootView().getHeight() - activityRootView.getHeight();      
            if (heightDiff > 100) { // 99% of the time the height diff will be due to a keyboard.       
                Toast.makeText(getApplicationContext(), "Gotcha!!! softKeyboardup", 0).show();

                if (isOpened == false) {
                   //Do two things, make the view top visible and the editText smaller
                }
                isOpened = true;
            } else if (isOpened == true) {
                Toast.makeText(getApplicationContext(), "softkeyborad Down!!!", 0).show();
                isOpened = false;
            }    
        }
    });
}

Здесь даже рассчитана вероятность появления клавиатуры. Код работает, в одном из проектов мы его даже использовали, но давно.

Много костылей в примерах связаны с использованием разных activity. Но они плохи не только тем, что это костыли, но и другими причинами: проблемой «холодного запуска», асинхронностью. Подробнее проблемы я описал в статье «Лицензия на вождение болида, или почему приложения должны быть Single-Activity». К тому же, в документации Google указывает, что рекомендуемый подход — это Single Activity приложение.

Что мы делали до Android 10


Мы (в Redmadrobot) разрабатываем достаточно качественные приложения, но долго избегали insets. Как же нам удавалось их избегать без больших костылей и в одном activity?

Примечание. Скриншоты и код взяты из моего pet-проекта GitFox.

Представим экран приложения. Когда мы разрабатывали наши приложения, никогда не задумывались что внизу может быть прозрачный Navigation Bar. Внизу черная полоска? Ну и что, пользователи привыкли.



Сверху мы изначально ставим параметр, что Status Bar черный с прозрачностью. Как это выглядит с точки зрения верстки и кода?



На рисунке абстракция: красный блок это activity приложения, синий это фрагмент с ботом навигации (с Tab'ами), а внутри него переключаются зеленые фрагменты с контентом. Видно, что Toolbar не находится под Status Bar. Как мы этого добились?

В Android есть хитрый флаг fitSystemWindow. Если установить его в «true», то контейнер сам себе добавит padding, чтобы внутри него ничего не попадало под Status Bar. Я считаю, что этот флаг — официальный костыль от Google для тех, кто боится insets. Используйте, все будет относительно хорошо работать и без insets.

Флаг FitSystemWindow=”true” добавляет padding контейнера, который вы указали. Но важна иерархия: если кто-то из родителей выставил этот флаг в «true», дальше его распространение учитываться не будет, потому что контейнер уже применил отступы.

Флаг работает, но появляется другая проблема. Представим экран с двумя табами. При переключении запускается транзакция, которая вызывает replace фрагмента один на другой, и все ломается.



У второго фрагмента тоже выставлен флаг FitSystemWindow=”true”, и подобного не должно случаться. Но произошло, почему? Ответ в том, что это костыль, а он иногда не работает.

Но мы нашли решение на Stack Overflow: использовать для своих фрагментов корневым не FrameLayout, а CoordinatorLayout. Он создан для других целей, но здесь работает.

Почему работает?


Посмотрим в исходниках что происходит в CoordinatorLayout.

@Override
public void onAttachedToWindow() {   
    super.onAttachedToWindow();
    ...    
    if (mLastInsets == null && ViewCompat.getFitsSystemWindows(this)) {         
        // We're set to fitSystemWindows but we haven't had any insets yet...
        // We should request a new dispatch of window insets
        ViewCompat.requestApplyInsets(this);
    }     
    ...
}

Видим красивый комментарий, что здесь нужны insets, а их нет. Надо их перезапросить, когда мы запрашиваем окно. Мы разобрались, что внутри как-то работают insets, но мы с ними не хотим работать и оставляем Coordinator.

Весной 2019 мы разрабатывали приложение, в это время как раз прошел Google I/O. Мы еще не со всем разобрались, поэтому продолжали держаться за предрассудки. Но мы замечали, что переключение Tab'ов внизу какое-то медленное, потому что у нас сложная верстка, нагруженный UI. Находим простой способ это решить — поменять replace на show/hide, чтобы каждый раз заново не создавать верстку.



Меняем, и опять ничего не работает — все сломалось! Видимо просто так костыли разбросать не получится, надо понять, почему они работали. Изучаем код и оказывается, что любая ViewGroup тоже умеет работать с insets.


@Override
public WindowInsets dispatchApplyWindowInsets(WindowInsets insets) {    
    insets = super.dispatchApplyWindowInsets(insets);     
    if (!insets.isConsumed()) {         
        final int count = getChildCount();        
        for (int i = 0; i < count; i++) {
            insets = getChildAt(i).dispatchApplyWindowInsets(insets);             
            if (insets.isConsumed()) {          
                break;
            }
        }
    }
    return insets;
}

Внутри такая логика: если хоть кто-то insets уже обработал, то все последующие View внутри ViewGroup их не получат. Что это значит для нас? Покажу на примере нашего FrameLayout, который переключает Tab'ы.



Внутри есть первый фрагмент, у которого выставлен флаг fitSystemWindow=”true”. Это значит, что первый фрагмент обрабатывает insets. После этого вызываем HIDE первого фрагмента и SHOW второго. Но первый остается в верстке — у него View осталась, просто скрыта.

Контейнер идет по своим View: берет первый фрагмент, дает ему insets, а у него fitSystemWindow=”true” — он взял их и обработал. Прекрасно, FrameLayout посмотрел, что insets обработаны, и второму фрагменту не отдает. Все работает как надо. Но нас это не устраивает, что же делать?

Пишем собственную ViewGroup


Мы пришли из Java, а там ООП во всей красе, поэтому решили наследоваться. Мы написали свою собственную ViewGroup, у которой переопределили метод dispatchApplyWindowInsets. Он работает так, как нам нужно: всем дочерним элементам всегда отдает insets, которые пришли, независимо от того, обработали мы их или нет.

class WindowInsetFrameLayout @JvmOverloads constructor(
    context: Context,      
    attrs: AttributeSet? = null,     
    defStyleRes: Int = 0
) : FrameLayout(context, attrs, defStyleRes) {

    override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets { 
        for (child in (0 until childCount)) {
            getChildAt(child).dispatchApplyWindowInsets(insets)
        }
        return insets.consumeSystemWindowInsets()
    }
}

Кастомная ViewGroup работает, проект тоже — все, как нам надо. Но до общего решения еще не дотягивает. Когда мы разобрались, что нам рассказали на Google IO, то поняли, что дальше так действовать не получится.

Android 10


Android 10 нам показал две важные концепции UI, которых строго рекомендовано придерживаться: edge-to-edge и Gestural Navigation.

Edge-to-edge. Эта концепция говорит о том, что контент приложения должен занимать все возможное пространство на экране. Для нас, как разработчиков, это значит, что приложения должны размещаться под системными панелями Status Bar и Navigation Bar.



Раньше мы могли это как-то игнорировать или размещаться только под Status Bar.

Что касается списков, то они должны прокручиваться не только до последнего элемента, но и дальше, чтобы не остаться под Navigation Bar.


Gestural Navigation. Этовторая важная концепция — навигация жестами. Она позволяет управлять приложением пальцами с края экрана. Режим жестов нестандартный, все выглядит иначе, но теперь можно не переключаться между двумя разными Navigation Bar.

В этот момент мы поняли, что все не так просто. Не получится дальше избегать insets, если мы хотим разрабатывать качественные приложения. Пора изучать документацию и разбираться, что же такое insets.

Insets. Что о них нужно знать?


Insets были созданы, чтобы пугать разработчиков.
Им это прекрасно удается, начиная с момента появления в Android 5.

Конечно, все не так страшно. Концепция insets проста — они сообщают о наложении системного UI на экран приложения. Это могут быть Navigation Bar, Status Bar или клавиатура. Клавиатура это тоже обычный системный UI, который накладывается поверх приложения. Ее не надо пытаться обрабатывать никакими костылями, только insets.

Цель insets — разрешать конфликты. Например, если над нашей кнопкой есть еще элемент, мы можем сдвинуть кнопку, чтобы пользователь мог продолжать пользоваться приложением.

Для обработки inset используйте Windowlnsets для Android 10 и WindowInsetsCompat для остальных версий.

В Android 10 есть 5 разных видов insets и один «бонусный», который называется не inset, а иначе. Разберемся со всеми видами, потому что большинство знает только один — System Window Insets.

System Window Insets


Появились в Android 5.0 (API 21). Получаются методом getSystemWindowInsets().

Это основной тип insets, с которым надо научиться работать, потому что остальные работают аналогично. Они нужны, чтобы обрабатывать Status Bar, в первую очередь, а потом Navigation Bar и клавиатуру. Например, они решают проблему когда Navigation Bar находится над приложением. Как на картинке: кнопка осталась под Navigation Bar, пользователь не может на нее кликнуть и очень недоволен.



Tappable element insets


Появились только в Android 10. Получаются методом getTappableElementInsets().

Эти insets полезны только для обработки разных режимов Navigation Bar. Как говорит сам Крис Бейн, можно забыть про этот тип insets и обходиться только System Window Insets. Но если вы хотите приложение крутое на 100%, а не на 99,9%, стоит им воспользоваться.

Посмотрите на картинку.



Сверху малиновым цветом отмечены System Window Insets, которые придут в разных режимах Navigation Bar. Видно, что и справа, и слева они равны Navigation Bar.

Вспомним как работает навигация жестами: при режиме справа мы никогда не нажимаем на новую панель, а всегда тянем пальцами снизу вверх. Это значит, что кнопки можно и не убирать. Мы же можем продолжать нажимать на нашу кнопку FAB (Floating Action Button), никто мешать не будет. Поэтому именно TappableElementInsets придут пустые, потому что FAB двигать необязательно. Но если сдвинем немного выше, ничего страшного.

Разница появляется только при навигации жестами и прозрачном Navigation Bar (color adaptation). Это не очень приятная ситуация.



Все будет работать, но выглядит неприятно. Пользователь может быть смущен близким расположением элементов. Можно объяснить, что один для жестов, а другой для нажатия, но все равно не красиво. Поэтому либо поднимаем FAB выше, либо оставляем справа.

System Gesture Insets


Появились только в Android 10. Получаются методом getSystemGestureInsets().

Эти insets связаны с навигацией жестами. Изначально предполагалось, что системный UI рисуется поверх приложения, но System Gesture Insets говорят, что не UI отрисовывается, а система сама обрабатывает жесты. Они описывают те места, где система по умолчанию будет обрабатывать жесты.

Области этих insets примерно те, что отмечены на схеме желтым.



Но хочу предупредить — они не всегда будут такие. Мы никогда не узнаем, какие новые экраны придумает Samsung и другие производители. Уже есть девайсы, в которых экран со всех сторон. Возможно, insets будут совсем не там, где мы ожидаем. Поэтому нужно с ними работать как с некоторой абстракцией: есть такие insets в System Gesture Insets, в которых система сама обрабатывает жесты.

Эти insets можно переопределить. Например, вы разрабатываете редактор фотографий. В некоторых местах вы сами хотите обрабатывать жесты, даже если они рядом с краем экрана. Укажите системе, что точку на экране в углу фотографии будете обрабатывать самостоятельно. Его можно переопределить, сказав системе: «Квадрат вокруг точки я буду всегда обрабатывать сам».

Mandatory system gesture insets


Появились только в Android 10. Это подтип System Gesture Insets, но они не могут быть переопределены приложением.

Мы используем метод getMandatorySystemGestureInsets(), чтобы определить область, где они не будут работать. Это сделано намеренно, чтобы нельзя было разработать «безвыходное» приложение: переопределить жест навигации снизу вверх, который позволяет выйти из приложения на главный экран. Здесь система всегда обрабатывает жесты сама.


Не обязательно он будет снизу на устройстве и не обязательно таких размеров. Работайте с этим, как с абстракцией.

Это были относительно новые виды insets. Но есть те, что появились даже до Android 5 и назывались иначе.

Stable Insets


Появились с Android 5.0 (API 21). Получаются методом getStableInsets().

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

Если какую-нибудь кнопку «НАЗАД» вы ставили в верхней части экрана, при этом она правильно обрабатывает System Window Insets, то при каждом Tab на экран сверху появляется UI, а кнопка прыгает вниз. Если некоторое время не трогать экран, Status Bar исчезает, кнопка подпрыгивает вверх, потому что System Window Insets исчезли.

Для таких случаев как раз подходят Stable Insets. Они говорят, что сейчас никакой элемент не отрисовывается над вашим приложением, но может это сделать. С этим insets можно заранее узнать величину Status Bar в этом режиме, например, и расположить кнопку там, где вам хочется.

Примечание. Метод getStableInsets() появился только с API 21. Раньше было несколько методов для каждой стороны экрана. 

Мы рассмотрели 5 видов insets, но есть еще один, который не относится к insets напрямую. Он помогает разобраться с челками и вырезами.

Челки и вырезы


Экраны давно уже не квадратные. Они продолговатые, с одной или несколькими вырезами под камеры и челками со всех сторон.



До 28 API мы не могли их обработать. Приходилось догадываться через insets, что там происходит. Но с 28 API и дальше (с предыдущего Android) официально появился класс DisplayCutout. Он находится в тех же insets, из которых можно достать все остальные типы. Класс позволяет узнать расположение и размер артефактов.

Кроме информации о расположении для разработчика предоставлен набор флагов у WindowManager.LayoutParams. Они позволяют включать разное поведение вокруг вырезов. Ваш контент может отображаться вокруг них, а может не отображаться: в пейзажном и в портретном режиме по-разному.

Флаги window.attributes.layoutInDisplayCutoutMode =

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT. В портретном — есть, а в пейзажном — черная полоса по умолчанию.
  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER. Нет совсем — черная полоса.
  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES — всегда есть.

Отображением можно управлять, но мы работаем с ними так же, как с любыми другим Insets.

insets.displayCutout
    .boundingRects
    .forEach { rect -> ... }

К нам приходит callback с insets с массивом displayCutout. Дальше можем по нему пробежать, обработать все вырезы и челки, которые есть в приложении. Подробнее о том, как работать с ними, можно узнать в статье.

Разобрались с 6 видами insets. Теперь поговорим о том, как это работает.

Как это работает


Insets нужны, в первую очередь, когда поверх приложения что-то рисуется, например, Navigation Bar.



Без insets у приложения не будет прозрачных Navigation Bar и Status Bar. Не удивляйтесь, что вам не приходят SystemWindowInsets, такое бывает.

Распространение insets


Вся иерархия UI выглядит как дерево, на концах которого есть View. Узлы — это обычно ViewGroup. Они также наследуются от View, поэтому insets им приходят специальным методом dispatchApplyWindowInsets. Начиная с корневой View, система рассылает insets по всему дереву. Посмотрим, как работает в данном случае View.



Система вызывает на ней метод dispatchApplyWindowInsets, в который приходят insets. Обратно эта View должна что-то вернуть. Что сделать, чтобы обработать это поведение?

Для простоты рассмотрим только WindowInsets.

Обработка insets


В первую очередь кажется логичным переопределение метода dispatchApplyWindowInsets и реализация внутри собственной логики: пришли insets, мы реализовали все внутри.

Если открыть класс View и посмотреть на Javadoc, написанный поверх этого метода, можно увидеть: «Не переопределяйте этот метод!»
Insets обрабатываем через делегирование или наследование.
Используйте делегирование. Google предоставил возможность выставить собственного делегата, который отвечает за обработку insets. Установить его можно через метод setOnApplyWindowInsetsListener(listener). Мы ставим callback, который обрабатывает эти insets.

Если нам это почему-то не подходит, можно наследоваться от View и переопределить другой метод onApplyWindowInsets(WindowInsets insets). В него подставим собственные insets.

Google не стал выбирать между делегированием и наследованием, а позволил сделать все вместе. Это здорово, потому что нам не придется переопределять все Toolbar или ListView, чтобы обработать insets. Мы можем брать любую готовую View, даже библиотечную, и сами устанавливать туда делегат, который обработает insets без переопределения.

Что же нам возвращать?

Зачем что-то возвращать?


Для этого надо разобраться, как работает распределение insets, то есть ViewGroup. Что она делает внутри себя. Рассмотрим подробней поведение по умолчанию на примере, который я показывал раньше.

Системные insets пришли сверху и снизу, например, по 100 пикселей. ViewGroup отправляет их первой View, которая у него есть. Эта View их как-то обработала и возвращает insets: сверху уменьшает до 0, а снизу оставляет. Сверху она добавила padding или margin, а снизу не трогала и об этом сообщила. Дальше ViewGroup передает insets второй View.



Следующая View обрабатывает и отдает insets. Теперь ViewGroup видит, что insets обработаны и сверху, и снизу — больше ничего не осталось, все параметры по нулям.

Третья View даже не узнает, что были какие-то insets и что-то происходило. ViewGroup вернет insets назад тому, кто их выставил.

Когда мы начали разбираться с этой темой, то записали себе идею — всегда возвращать те же insets, что пришли. Мы хотели, чтобы не происходило такой ситуации, когда какие-то View даже не узнали, что были insets. Идея выглядела логичной. Но оказалось, что нет. Google не зря добавил в insets поведение, как в примере.

Это нужно, чтобы до всей иерархии View всегда доходили insets и всегда можно было с ними работать. В этом случае не будет ситуации когда мы переключаем два фрагмента, один кто-то обработал, а второй еще не получил. В разделе с практикой мы вернемся к этому моменту.

Практика


С теорией закончили. Посмотрим, как это выглядит в коде, потому что в теории всегда все гладко. Будем использовать AndroidX.

Примечание. В GitFox все уже реализовано в коде, полная поддержка всех плюшек из нового Android. Чтобы не проверять версии Android и не искать нужный тип Insets, всегда используйте вместо view.setOnApplyWindowInsetsListener { v, insets -> ... } версию из AndroidX — ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> ... }.

Есть экран с темным Navigation Bar. Он не накладывается сверху ни на один элемент. Все так, как мы привыкли.


Но оказывается, что теперь нам надо сделать его прозрачным. Как?

Включаем прозрачность


Самое простое — указать в теме, что Status Bar и Navigation Bar прозрачные.

<style name="AppTheme" parent="Theme.MaterialComponents.Light">
    <item name="android:windowTranslucentStatus">true</item>
    <item name="android:windowTranslucentNavigation">true</item>
</style>

В этот момент все начинает накладываться друг на друга и сверху, и снизу.

Интересно, что здесь два флага, а вариантов всегда 3. Если укажете прозрачность у Navigation Bar, Status Bar станет прозрачным сам собой — такое ограничение. Можно первую строчку не писать, но я всегда люблю ясность, поэтому пишу 2 строки, чтобы последователи могли понять, что происходит, а не копаться внутри.

Если выберете этот способ, то Navigation Bar и Status Bar станут черными с прозрачностью. Если приложение белого цвета, то добавить цвета можно уже только из кода. Как это сделать?

Включаем прозрачность с цветом


Хорошо, что у нас Single Activity приложение, поэтому в одном activity проставляем два флага.

rootView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or  
    View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION

<style name="AppTheme" parent="Theme.MaterialComponents.Light">
    <!-- Set the navigation bar to 50% translucent white -->
    <item name="android:navigationBarColor">#80FFFFFF</item> 
</style>

Флагов там очень много, но именно эти два помогут сделать прозрачность у Navigation Bar и Status Bar. Цвет можно указать через тему.

Это странное поведение Android: что-то через тему, а что-то через флаги. Но мы можем указывать параметры как в коде, так и в теме. Это не Android такой плохой, просто на старых версиях Android флаг, указанный в теме, будет игнорироваться. navigationBarColor подсветит, что на Android 5 такого нет, но все соберется. В GitFox я именно из кода установил цвет Navigation Bar.

Мы провели махинации — указали, что Navigation Bar белый с прозрачностью. Теперь приложение выглядит так.


Что может быть проще, чем обработать insets?


Действительно, элементарно.

ViewCompat
    .setOnApplyWindowInsetsListener(bottomNav) { view, insets ->  
        view.updatePadding(bottom = insets.systemWindowInsetBottom) 
        insets
    }

Берем метод setOnApplyWindowInsetsListener и передаем в него bottomNav. Говорим, что когда придут insets, установи снизу padding, который пришел в systemWindowInsetBottom. Мы хотим, чтобы он был под ним, но все наши кликабельные элементы оказались сверху. Возвращаем полностью insets, чтобы все другие View в нашей иерархии тоже их получили.

Все выглядит отлично.


Но есть подвох. Если в верстке мы указали какой-то padding у Navigation Bar снизу, то здесь его затерли — выставили updatePadding равным insets. Наша верстка выглядит не так, как хотелось бы.

Чтобы сохранить значения из верстки, надо сначала сохранить то, что в нижнем padding. Позже, когда придут insets, сложить и выставить результирующие значения.

val bottomNavBottomPadding = bottomNav.paddingBottom
ViewCompat
    .setOnApplyWindowInsetsListener(bottomNav) { view, insets ->                 
        view.updatePadding(
        bottom = bottomNavBottomPadding + insets.systemWindowInsetBottom
    )
    Insets
}

Так писать неудобно: везде в коде, где применяете insets, надо сохранять значение из верстки, потом их складывать на UI. Но есть Kotlin, и это прекрасно — можно написать собственное расширение (extension), которое все это сделает за нас.

Добавим Kotlin!


Запоминаем initialPadding и отдаем обработчику, когда приходят новые insets (вместе с ними). Это поможет их как-то вместе складывать или строить какую-то логику сверху.

fun View.doOnApplyWindowInsets(block: (View, WindowInsetsCompat, Rect) -> WindowInsetsCompat) {    

    val initialPadding = recordInitialPaddingForView(this)

    ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets ->        
        block(v, insets, initialPadding)
    }
    
    requestApplyInsetsWhenAttached()
}

private fun recordInitialPaddingForView(view: View) =
   Rect(view.paddingLeft, view.paddingTop, view.paddingRight, view.paddingBottom)

Теперь все проще.

bottomNav.doOnApplyWindowInsets { view, insets, padding ->   
    view.updatePadding(
        bottom = padding.bottom + insets.systemWindowInsetBottom
    ) 
    insets
}

Мы должны переопределить лямбду. В ней есть не только insets, но и padding'и. Мы их можем сложить не только снизу, но и сверху, если это Toolbar, который обрабатывает Status Bar.

Кое о чем забыли!


Здесь есть вызов непонятного метода.

fun View.doOnApplyWindowInsets(block: (View, WindowInsetsCompat, Rect) -> WindowInsetsCompat) {

    val initialPadding = recordInitialPaddingForView(this)
     
    ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets ->        
        block(v, insets, initialPadding)
    }
      
    requestApplyInsetsWhenAttached() 
}

private fun recordInitialPaddingForView(view: View) =
      Rect(view.paddingLeft, view.paddingTop, view.paddingRight, view.paddingBottom)

Связано это с тем, что нельзя просто надеяться на то, что система вам пришлет insets. Если мы создаем View из кода или устанавливаем какой-то InsetsListener чуть позже, система сама может не передать последнее значение.

Мы должны проверить, что когда View находится на экране, сказали системе: «Пришли insets, мы хотим их обработать». Мы выставили Listener и должны сделать запрос requestsApplyInsets».

fun View.requestApplyInsetsWhenAttached() {    
    if (isAttachedToWindow) {        
        requestApplyInsets()
    } else {
        addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {            
            override fun onViewAttachedToWindow(v: View) {
                v.removeOnAttachStateChangeListener(this)
                v.requestApplyInsets()           
            }

            override fun onViewDetachedFromWindow(v: View) = Unit
        })
    }
}

Если же мы создали View кодом и еще не прикрепили к нашей верстке, то должны подписаться на момент когда это сделаем. Только тогда запрашиваем insets.

Но опять же, есть Kotlin: написали простое расширение и больше нам об этом не надо думать.

Доработка RecyclerView


Теперь поговорим о доработке RecyclerView. Последний элемент попадает под Navigation Bar и остается снизу — некрасиво. На него еще и кликать неудобно. Если это еще не новая панель, а старая, большая, то вообще весь элемент может скрыться под ней. Что делать?



Первая идея — добавить элемент снизу, а высоту выставлять по размеру insets. Но если у нас приложение с сотней списков, то каждый список как-то придется подписывать на insets, добавлять туда новый элемент и выставлять высоту. Кроме того RecyclerView — асинхронная, неизвестно, когда она сработает. Проблем много.

Но есть очередной официальный хак. На удивление, он работает качественно.

<androidx.recyclerview.widget.RecyclerView 
        android:id="@+id/recyclerView"        
    android:layout_width="match_parent"    
    android:layout_height="match_parent"             
    android:clipToPadding="false" />

recyclerView.doOnApplyWindowInsets { view, insets, padding ->    
    view.updatePadding(
        bottom = padding.bottom + insets.systemWindowInsetBottom
    )
}

Есть такой флаг в верстке clipToPadding. По умолчанию он всегда «true». Это говорит о том, что не надо рисовать элементы, которые оказываются там, где выставлен padding. Но если выставить clipToPadding="false", то рисовать можно.

Теперь, если выставить padding снизу, в RecyclerView он будет работать так: снизу выставлен padding, и элемент рисуется поверх него, пока мы не прокрутили. Как только дошли до конца RecyclerView прокрутит элементы до того положения, которое нам нужно.

Выставив один такой флаг мы можем работать с RecyclerView как с обычной View. Не надо думать, что там есть элементы, которые прокручиваются — просто выставляем padding снизу, как, например, в Bottom Navigation Bar.

Сейчас мы всегда возвращали insets, будто их не обработали. К нам пришли insets целиком, мы с ними что-то сделали, выставили padding'и и вернули опять все insets целиком. Это нужно, чтобы любая ViewGroup всегда передавала эти insets всем View. Это работает.

Баг в приложениях


Во многих приложениях в Google Play, которые уже обработали insets, я заметил маленький баг. Сейчас о нем расскажу.

Есть экран с навигацией в подвале. Справа тот же экран на котором показана иерархия. Зеленый фрагмент отображает контент на экране, внутри у него RecyclerView.



Кто здесь обработал insets? Toolbar: он применил padding сверху чтобы его контент сместился под Status Bar. Соответственно, Bottom Navigation Bar применил insets снизу и приподнялся над Navigation Bar. А RecyclerView никак не обрабатывает insets, он же под них не попадает, ему не нужно обрабатывать insets — все правильно сделано.



Но тут оказывается, что зеленый фрагмент RecyclerView мы хотим использовать в другом месте, где уже нет Bottom Navigation Bar. В этом месте RecyclerView уже начинает обрабатывать insets снизу. Мы должны к нему применить padding чтобы правильно прокручиваться из-под Navigation Bar. Поэтому в RecyclerView тоже добавляем обработку insets.



Идем обратно на наш экран, где все обрабатывают insets. Помните, никто не сообщает о том, что он их обработал?



Видим такую ситуацию: RecyclerView обработал insets снизу, хотя не доходит до Navigation Bar — внизу появился пробел. Я это замечал в приложениях, причем довольно крупных и популярных.



Раз все так, мы помним, что возвращаем все insets, чтобы обработать. Значит (оказывается!) надо сообщать о том, что insets обработаны: Navigation Bar должен сообщить о том, что обработала insets. До RecyclerView они не должны дойти.



Для этого в Bottom Navigation Bar устанавливаем InsetsListener. Внутри вычитаем bottom + insets, что они не обработаны, возвращаем 0.

doOnApplyWindowInsets { view, insets, initialPadding ->    
    view.updatePadding(
        bottom = initialPadding.bottom + insets.systemWindowInsetBottom
    )
    insets.replaceSystemWindowInsets(
        Rect(
            insets.systemWindowInsetLeft,            
            insets.systemWindowInsetTop,            
            insets.systemWindowInsetRight,
            0
        )
    )
}

Возвращаем новые insets, у которых все старые параметры равны, а bottom + insets равен 0. Вроде все хорошо. Запускаем и снова ерунда — RecyclerView все равно почему-то обрабатывает insets.



Я не сразу понял, что проблема в том, что контейнер для этих трех View — это LinearLayout. Внутри них Toolbar, фрагмент с RecyclerView и внизу Bottom Navigation Bar. LinearLayout берет свои дочерние элементы по порядку и применяет к ним insets.

Получается, что Bottom Navigation Bar будет последним. Он сообщил кому-то, что обработал все insets, но уже поздно. Все insets обработались сверху вниз, RecyclerView их уже получил, и нас это не спасает.



Что делать? LinearLayout работает не так, как я хочу, он сверху вниз их передает, а мне нужно получить сначала нижний.

Все переопределим


Во мне сыграл Java-разработчик — надо все переопределить! Хорошо, сейчас dispatchApplyWindowInsets переопределим, поставим МyLinearLayout, который всегда будет идти снизу вверх. Он будет сначала отправлять insets Bottom Navigation Bar, а потом всем остальным.

@Override
public WindowInsets dispatchApplyWindowInsets(WindowInsets insets) {     
    insets = super.dispatchApplyWindowInsets(insets);     
    if (!insets.isConsumed()) {        
        final int count = getChildCount();
        for (int i = 0; i < count; i++) {
            insets = getChildAt(i).dispatchApplyWindowInsets(insets);
            if (insets.isConsumed()) {
                break;
            }
        }
    }
    return insets;
}

Но я вовремя остановился, вспомнив комментарий, что не надо переопределять этот метод.

Здесь чуть выше есть спасение: ViewGroup проверяет на делегата, который обрабатывает insets, потом вызывает суперметод у View и включает собственную обработку. В этом коде мы получаем insets, и если они еще не были обработаны, начинаем стандартную логику по нашим дочерним элементам.

Я написал простое расширение. Оно позволяет, применив InsetsListener к одной View, сказать, кому эти insets передать.

fun View.addSystemBottomPadding(    
    targetView: View = this
) {
    doOnApplyWindowInsets { _, insets, initialPadding ->           
        targetView.updatePadding(
            bottom = initialPadding.bottom + insets.systemWindowInsetBottom
        )
        insets.replaceSystemWindowInsets(
            Rect(
                insets.systemWindowInsetLeft,
                insets.systemWindowInsetTop,     
                insets.systemWindowInsetRight,
                0
            )
        )
    }
}

Здесь targetView равна по умолчанию той же самой View, поверх которой мы применяем addSystemBottomPadding. Мы можем ее переопределить.

На мой LinearLayout я повесил такой обработчик, передав как targetView — это мой Bottom Navigation Bar.

  • Сначала он даст insets Bottom Navigation Bar. 
  • Тот обработает insets, вернет ноль bottom.
  • Дальше, по умолчанию, они пойдут сверху вниз: Toolbar, фрагмент с RecyclerView.
  • Потом, возможно, он опять отправит insets Bottom Navigation Bar. Но это уже не важно, все будет и так прекрасно работать.

Я добился ровно того, чего хотел: все insets обрабатываются в том порядке, в котором нужно.



Важное


Несколько важных вещей, о которых надо помнить.

Клавиатура — это системный UI поверх вашего приложения. Не надо относиться к ней по-особенному. Если посмотреть на Google-клавиатуру, то это не просто Layout с кнопками, на которые можно нажимать. Есть сотни режимов этой клавиатуры: поиск гифок и мемов, голосовой ввод, смена размеров от 10 пикселей в высоту до размеров экрана. Не думайте о клавиатуре, а подписывайтесь на insets.

Navigation Drawer все еще не поддерживает навигацию жестами. На Google IO обещали, что все будет работать из коробки. Но в Android 10 Navigation Drawer до сих пор это не поддерживает. Если обновиться до Android 10 и включить навигацию жестами, Navigation Drawer отвалится. Теперь надо кликать на меню гамбургер, чтобы он появился, либо стечение случайных обстоятельств позволяет его вытянуть.

В пре-альфа версии Android Navigation Drawer работает, но я не рискнул обновляться — это же пре-альфа. Поэтому, даже если вы поставите из репозитория последнюю версию GitFox, там есть Navigation Drawer, но его не вытянуть. Как только выйдет официальная поддержка, я обновлю и все будет прекрасно работать.

Чек-лист подготовки к Android 10


Ставьте прозрачность Navigation Bar и Status Bar с начала проекта. Google строго рекомендует поддерживать прозрачный Navigation Bar. Для нас это важная практическая часть. Если проект работает, а вы не включили — выберете время на поддержку Android 10. Включайте сначала их в теме, как translucent, и исправляйте верстку, где она сломалась.

Добавьте расширения на Kotlin — с ними проще.

Добавьте ко всем Toolbar сверху padding. Toolbars всегда наверху, так и надо поступать.

У всех RecyclerView добавьте padding снизу и возможность прокручивать через clipToPadding="false".

Вспомните про все кнопки на краях экрана(FAB). FAB'ы, скорее всего, окажутся не там, где вы ожидаете.

Не переопределяйте и не делайте собственные реализации для всех LinearLayout и других подобных кейсов. Сделайте трюк, как в GitFox, или возьмите мое готовое расширение в помощь.

Проверьте жесты на краях экрана у кастомных View. Navigation Drawer это не поддерживает. Но не так сложно его поддерживать руками, переопределить insets для жестов на экране с Navigation Drawer. Возможно, у вас есть редакторы картинок, где жесты работают.

Прокрутка в ViewPager работает не с краю, а только с центра вправо-влево. Google говорит, что это нормально. Если тянуть ViewPager с края, то это воспринимается как нажатие кнопки «Назад».

В новом проекте сразу включайте все прозрачности. Можно сказать дизайнерам, что у вас нет Navigation Bar и Status Bar. Весь квадрат — это ваш контент приложения. А разработчики уже разберутся с тем, где и что поднять.

Полезные ссылки:

  • @rmr_spb в телеграме — записи внутренних митапов Redmadrobot SPb.
  • Весь исходный код в этой статье и даже больше в GitFox. Там есть отдельная ветка Android 10, где по коммитам можно посмотреть, как я дошел до всего этого и как поддержал новый Android.
  • Библиотека Insetter Криса Бейна. Она содержит 5-6 extensions, которые я показывал. Чтобы использовать в своем проекте, обратитесь в библиотеку, скорее всего она переедет в Android Jetpack. Я их развил у себя в проекте и, мне кажется, они стали лучше.
  • Статья Криса Бейна.
  • Статья от компании FunCorn, в которой они рассказали, как работать с вырезами и челками.
  • Статья «Why would I want to fitsSystemWindows?» Яна Лэйка.

Константин Цховребов постоянный спикер AppConf, на youtube-канале конференции можно найти несколько его докладов. Но в организации конференции мы не полагаемся только на проверенные временем подходы, а все время придумываем что-то новое. Фишки осенней AppsConf сейчас как раз в разработке, но рассказывать о них будет уже новый руководитель Программного комитета Евгений Суворов. Подписывайтесь на рассылку, телеграм, stay tuned!
Источник: https://habr.com/ru/company/oleg-bunin/blog/488196/


Интересные статьи

Интересные статьи

Unreal Engine 4 предоставляет достаточный функционал для полноценной разработки игр на мобильные платформы. К сожалению, тема недостаточно освещена, а сам процесс сборки ...
Автор статьи, перевод которой мы сегодня публикуем, приводит 15 вопросов, которые задавали на собеседованиях ему, и которые он задавал соискателям на должности фронтенд-разработчиков ...
С каждым годом требования к in-app подпискам в мобильных приложениях в App Store и Google Play меняются, становится все сложнее учесть их с первого раза и не получить серию реджекто...
Сегодня многие СМИ пестрят заголовками, что аудиокассеты возвращаются в пространство популярной культуры. Разбираемся, что происходит на самом деле, и какую долю рынка удалось завоевать ретро-нак...
Цены на коммутаторы для дата-центров в 2018 году снизились. Аналитики ожидают, что тенденция продолжится и в 2019-м. Под катом разбираемся, в чем причина.