Готовим Window Inset под соусом Jetpack Compose и щепоткой View

8a08a97e8c5ce9ba06d3e51de89f0516.jpg

Привет! Меня зовут Тимур, я занимаюсь Android-разработкой в KTS.

К сожалению, сейчас все еще встречаются Android-приложения, которые не поддерживают edge-to-edge. Складывается ощущение, что разработчики либо не знают о такой возможности, либо боятся работать с WindowInsets. На самом деле реализовать edge-to-edgeне сложно, а благодаря этой статье вы сможете разобраться в этой темев разы быстрее. 

Сегодня я расскажу, что такое режим edge-to-edge в мобильных приложениях и как работать с WindowInsets в Android. Еще мы разберем примеры обработки insets не только во View, но и в Jectpack Compose.  Если статьи о том, как работать с insets в View, еще можно найти на просторах интернета, то информация о работе с ними в Jetpack Compose есть только в официальной документации. 

Все примеры из статьи можно посмотреть в этом репозитории.

Содержание:

Что такое edge-to-edge?

a6407e52dfe5dbb0b1d9d027476c91ce.gif


В современном мире мобильные приложения все чаще отображаются на всей видимой поверхности дисплея, не ограничиваясь пользовательским интерфейсом системы. В таких приложениях используется подход edge-to-edge, который предполагает отрисовку приложения под системным UI, т.е. под Status Bar и Navigation Bar. 

Спросите, зачем это нужно? Для того чтобы создать более привлекательный и современный пользовательский интерфейс. Согласитесь, всем будет приятнее пользоваться красивым приложением.

Переходим к реализации edge-to-edge.

Этапы настройки edge-to-edge

Для реализации режима edge-to-edge в вашем приложении необходимо:

  • изменить цвет системного UI

  • запросить отрисовку приложения под системным UI

  • устранить визуальные конфликты

Изменение цвета системного UI

Начиная с Android 5 (API 21) появилась возможность задать цвет для Status Bar и Navigation Bar. Для этого нужно использовать следующие атрибуты темы:

@color/colorAccent
@color/colorAccent

Еще мы можем сделать цвет системного UI прозрачным или полупрозрачным. Чтобы добиться полупрозрачности, достаточно установить android: windowTranslucentStatus и android: windowTranslucentNavigation:

true
true

25ac52e0804b789f99d9573d74fe676f.jpg

Чтобы добиться полностью прозрачного системного интерфейса, необходимо установить android: navigationBarColor и android: statusBarColor с прозрачным цветом и отключить контрастность с помощью следующих атрибутов android: enforceNavigationBarContrast, android: enforceStatusBarContrast. Отключение контрастности необходимо, потому что с 10 версии Android обеспечивает достаточную контрастность Navigation Bar.

@android:color/transparent
@android:color/transparent
false
false

9343c97eee772431ea4ae7b05c805fa4.jpg

Вы можете заметить, что на скриншоте выше плохо видны кнопки на Navigation Bar. В Status Bar была бы та же проблема, если бы не пурпурный цвет. Чтобы исправить это, используйте атрибуты android: windowLightStatusBar, android: windowLightNavigationBar. Обратите внимание, что windowLightStatusBarдоступен с 23 api, а windowLightNavigationBarс 27 api.

ca23735ab722966ac11abc935f1bdb9b.jpg

В Jetpack Compose для изменения цвета вы можете использовать библиотеку System UI Controller, которая  предоставляет простые утилиты для изменения цвета системного UI. Изменить цвет системного интерфейса с помощью этой библиотеки можно так:

private val BlackScrim = Color(0f, 0f, 0f, 0.3f) // 30% opaque black

@Composable
fun TransparentSystemBars() {
   val systemUiController = rememberSystemUiController()
   val useDarkIcons = MaterialTheme.colors.isLight
   SideEffect {
       systemUiController.setSystemBarsColor(
           color = Color.Transparent,
           darkIcons = useDarkIcons,
           isNavigationBarContrastEnforced = false,
           transformColorForLightContent = { original ->
               BlackScrim.compositeOver(original)
           }
       )
   }
}

Пример использования функции TransparentSystemBars:

override fun onCreate(savedInstanceState: Bundle?) { 
   super.onCreate(savedInstanceState)
   WindowCompat.setDecorFitsSystemWindows(window, false)
   setContent {
       TransparentSystemBars()
       Sample()
   }
}

Метод setSystemBarsColorпозволяет:

  • установить цвет для системного UI

  • указать, когда использовать светлые или темные иконки

  • можно отключить контрастность с помощью isNavigationBarContrastEnforced

  • можно использовать лямбду transformColorForLightContent, которая будет вызвана для преобразования цвета, если запрошены темные значки, но они недоступны. По умолчанию применяется черная накладка (в примере выше в transformColorForLightContent приведено поведение по умолчанию, поэтому писать этого не нужно).

Запросить отрисовку приложения  под системным UI

Кроме изменения цвета системного интерфейса, нужно сказать системе, что приложение нужно отрисовывать на весь экран. Для этого в Android есть специальные флаги View SYSTEM_UI_FLAGS (далее UI_FLAGS). Они deprecated начиная с API 30, и теперь следует использовать новый класс WindowCompat, который проставляет требуемые флаги на более ранних версиях API.

Запрос полноэкранного режима через WindowCompatвыглядит так:

WindowCompat.setDecorFitsSystemWindows(window, false)

Если применить это в activity, фреймворк не будет подставлять insets для содержимого вашего приложения, и нам нужно будет сделать это вручную.

Запрашивать режим отрисовки целесообразно для всего приложения (в onCreate() вашей activity, если вы используете подход single-activity). 

В Jetpack Compose этот этап ничем не отличается.

Устранить визуальные конфликты

Если выполнить прошлые шаги и запустить приложение, можно увидеть, что система больше не учитывает место под системный UI. Теперь нам нужно сделать это самим:

3fdb4de398b00a6bff05857727ec10d6.png

В приложениях под Android для обработки системного UI используются WindowInsets. Insets— это объект, представляющий из себя область окна, которая конфликтует с приложением. Конфликты могут быть разные, и для этого существуют разные типы insets(области обработки жестов, системные панели, челки и т.д.). 

Для обработки insetsиспользуется класс WindowInsetsCompat с обратной совместимостью и удобным разделением на типы insets.

Сама обработка заключается в прикреплении слушателя на View, в которой система передает объект insets. После получения объекта можно применить требуемые padding или margin на View:

ViewCompat.setOnApplyWindowInsetsListener(navBar) { view, insets ->
    val systemBarInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
    view.updatePadding(
        bottom = systemBarInsets.bottom,
        left = systemBarInsets.left,
        right = systemBarInsets.right
    )
    insets
}

В KTS мы используем библиотеку insetter, которая работает на базе этого подхода. В библиотеке реализован удобный Kotlin DSL applyInsetter для обработки insets.

toolbar.applyInsetter {
    type(navigationBars = true, statusBars = true) {
        padding(horizontal = true, top = true)
    }
}

ВJetpack Compose для установки instesранее использовалась библиотека из репозитория accompanist, но она устарела, так как в Compose 1.2.0 теперь доступна официальная поддержка insets. Если вы уже использовали accompanist, то на сайте есть подробное руководство по миграции.

TopAppBar(
    contentPadding = WindowInsets.systemBars
        .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top)
        .asPaddingValues(),
    backgroundColor = MaterialTheme.colors.primary
) {
    // content...
}

WindowInsets vs fitSystemWindow?

В Android есть флаг fitSystemWindow. Если установить его в «true», то этот флаг добавляет padding для контейнера, у которого вы указали флаг. 

FitsSystemWindowsсбивает с толкумногих разработчиков. Например, этот флаг работает с CoordinatorLayout и не работает для FrameLayout. FitsSystemWindows = true не перемещает ваш контент под строку состояния, но это работает для таких макетов, как CoordinatorLayout и DrawerLayout, потому что они переопределяют поведение по умолчанию. Под капотом они устанавливают флаги setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE  | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) если fitsSystemWindows равен true

  • SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN перемещает содержимое под строку состояния

  • SYSTEM_UI_FLAG_LAYOUT_STABLE обеспечивает применение максимально возможных системных insets, даже если текущие меньше этого.

Если мы установим эти флаги к нашему rootLayout, то все должно заработать даже на FrameLayout.

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

На самом деле это не все нюансы из-за которых использование флага fitSystemWindow не рекомендуется (о других проблемах можно почитать в этой статье), поэтому чтобы избежать различных проблем и не очевидного поведения, рекомендуется использовать WindowInsets.

Примеры обработки insets

System Window Insets

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

f8aa21d804122a6e4d42a6b14ab523d4.png

В данном случае нам нужно установить insets для toolbar, который находится в AppBarLayout с пурпурным background. Это даст нам эффект продолжения AppBar за Status Bar. В примере ниже используется библиотека insetter.

toolbar.applyInsetter {
   type(navigationBars = true, statusBars = true) {
       padding(horizontal = true)
       margin(top = true)
   }
}

В Jetpack Compose такого же эффекта можно добиться, установив insets в contentPadding compose функции TopAppBar. В данном примере мы используем WindowInsets.systemBars для горизонтали и верха, чтобы во время поворота экрана Navigation Bar не перекрывала начало заголовка или кнопку выхода.

TopAppBar(
    contentPadding = WindowInsets.systemBars
        .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top)
        .asPaddingValues(),
    backgroundColor = MaterialTheme.colors.primary
) {
    // content...
}

Также в Jetpack Compose есть множество extensions для Modifier, такие как systemBarsPadding (), navigationBarsPadding (), statusBarsPadding () и другие. 

После установки insets toolbar будет выглядеть так:

c41c78ec077616d8d0b12a69180337eb.png

Ime Insets (Обработка клавиатуры)

Обработка клавиатуры осуществляется с помощью WindowInsetsCompat.Type.ime (). Также для ime insets можно дополнительно обработать анимацию появления/скрытия клавиатуры с помощью нового API ViewCompat.setWindowInsetsAnimationCallback. Подробнее про возможности анимации ime insets тут.

Вызов setWindowInsetsAnimationCallbackреализован в библиотеке insetter (активация происходит через флаг animatedна padding/margin) и позволяет дополнительно связать анимацию не только с View, на которой вызывается DSL, но и с другими View для того, чтобы синхронизировать анимацию на нескольких элементах UI (метод syncTranslationTo).

f65c5c7c79fc81bc938f821c627af3b4.gif

Пример обработки клавиатуры с анимацией с помощью insetter.

private fun setupInsets() = with(binding) {
   messageWrapper.applySystemBarsImeInsetter(syncTranslationView = list) {
       margin(horizontal = true, bottom = true, animated = true)
   }
}

inline fun View.applySystemBarsImeInsetter(
   syncTranslationView: View? = null,
   crossinline insetterApply: InsetterApplyTypeDsl.() -> Unit
) {
   applyInsetter {
       type(ime = true, navigationBars = true, statusBars = true) {
           insetterApply()
       }
       syncTranslationView?.let {
           syncTranslationTo(it)
       }
   }
}

Чтобы добиться такого эффекта в Jetpack Compose, вам нужно использовать  функцию расширения imePadding () для Modifier (не забудьте сделать inset для Navigation Bar с помощью navigationBarsPadding ()):

@Composable
fun BottomEditText(
    placeholderText: String = "Type text here..."
) {
    val text = rememberSaveable(stateSaver = TextFieldValue.Saver) {
        mutableStateOf(TextFieldValue(""))
    }
    Surface(elevation = 1.dp) {
        OutlinedTextField(
            value = text.value,
            onValueChange = { text.value = it },
            placeholder = { Text(text = placeholderText) },
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp, vertical = 8.dp)
                .navigationBarsPadding()
                .imePadding()
        )
    }
}

Jetpack Compose также позволяет открыть клавиатуру скроллом с помощью imeNestedScroll():

LazyColumn(
    contentPadding = contentPadding,
    reverseLayout = true,
    modifier = Modifier
        .weight(1f)
        .imeNestedScroll()
) {
    items(listItems) { SampleListItem(it) }
}

fd4c3828b21579e7eaaac91cf74e4ea3.gif

Stable Insets

Stable Insets полезны только в полноэкранных приложениях, таких как видеоплееры, галереи и игры. Например, в режиме проигрывания плеера вы могли заметить, что у вас прячется весь системный UI, в том числе Status Bar, который перемещается за край экрана. Но стоит коснуться экрана, как Status Bar появится сверху. Особенность insets в галерее или плеере в том, что при показе/скрытии системного UI к View приходят пустые insets (когда системный UI скрыт). Из-за этого элементы Ui приложения, которые обрабатывают insets, могут подпрыгивать как на гифке ниже.

d0049461348f482e53c34e9f68f5a5e5.gif

Поэтому существует специальный тип Stable Insets, который система всегда выдает со значениями, как будто системный UI показывается. В insetter предусмотрен метод ignoreVisibility, который говорит системе отдавать Stable Insets для этого View.

toolbar.applyInsetter {
    type(navigationBars = true, statusBars = true) {
        padding(horizontal = true)
            margin(top = true)
    }
    ignoreVisibility(true)
}

На удивление, в Jetpack Composeнет никакого готового решения для Stable Insets, но мы можем реализовать его следующим образом:

class StableStatusBarsInsetsHolder {
   private var stableStatusBarsInsets: WindowInsets = WindowInsets(0.dp)

   val stableStatusBars: WindowInsets
       @Composable
       get() {
           val density = LocalDensity.current
           val layoutDirection = LocalLayoutDirection.current
           val statusBars = WindowInsets.statusBars
           return remember {
               derivedStateOf {
                   if (statusBars.exclude(stableStatusBarsInsets).getTop(density) > 0) {
                       stableStatusBarsInsets 
                                   = statusBars.deepCopy(density, layoutDirection)
                   }
                   stableStatusBarsInsets
               }
           }.value
       }
 
}

private fun WindowInsets.deepCopy(density: Density, layoutDirection: LayoutDirection): WindowInsets {
   return WindowInsets(
       left = getLeft(density, layoutDirection),
       top = getTop(density),
       right = getRight(density, layoutDirection),
       bottom = getBottom(density)
   )
}

Пример использования:

val stableInsetsHolder = remember { StableStatusBarsInsetsHolder()}
SampleTopBar(
   titleRes = R.string.insets_sample_fullscreen_stable,
   contentPadding = stableInsetsHolder.stableStatusBars
       .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top)
       .asPaddingValues(),
)

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

6cca6597f483786801356661e2b1a8ec.gif

Immersive mode (Полноэкранный режим без элементов UI)

Чтобы показать или скрыть Ui вместо проставления определённых комбинаций UI_FLAGS, начиная с API 30 используется новый класс WindowInsetsController(его compat-версия WindowInsetsControllerCompat), который имеет удобное API на базе новых классов API 30.

e84ebeb056500a7d9bf56a82f0e383ee.gif

Каким образом можно вернуть системный Ui на экран, задается с помощью флагов WindowInsetsController (установка через метод setSystemBarsBehavior):

  1. BEHAVIOR_SHOW_BARS_BY_SWIPE — Для возврата SystemUi требуется свайп, убирать самостоятельно

  2. BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE — Для возврата системного Ui требуется свайп, скрытие автоматически через некоторое время.

Есть следующие extension-функции с возможностью добавления вызовов на WindowInsetsController через extraAction.

Для скрытия UI

fun Window.hideSystemUi(extraAction:(WindowInsetsControllerCompat.() -> Unit)? = null) {
    WindowInsetsControllerCompat(this, this.decorView).let { controller ->
        controller.hide(WindowInsetsCompat.Type.systemBars())
        extraAction?.invoke(controller)
    }
}

// Usage
hideSystemUi{
    systemBarsBehavior =
    WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}

Для показа UI

fun Window.showSystemUi(extraAction: (WindowInsetsControllerCompat.() -> Unit)? = null) {
    WindowInsetsControllerCompat(this, this.decorView).let { controller ->
        controller.show(WindowInsetsCompat.Type.systemBars())
        extraAction?.invoke(controller)
    }
}

// Usage
showSystemUi()

В Jetpack Compose, чтобы скрыть или показать пользовательский интерфейс системы, вы можете использовать rememberSystemUiController ()из библиотекиaccompanist systemui controller.

val systemUiController = rememberSystemUiController()

InsetsExamplesTheme {
   FullscreenCutoutSample(
       systemUiController.toggleUi
   )
}

val SystemUiController.toggleUi: () -> Unit
   get() = {
       isSystemBarsVisible = !isSystemBarsVisible
   }

Но эта библиотека не позволяет изменять флаги WindowInsetsControllerCompat. Поэтому был написан класс на основе rememberSystemUiController() из accompanist systemui controller. В этой имплементации для WindowInsetsControllerCompatустанавливается BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE.

Пример

@Composable
fun rememberSystemUiVisibilityController(
    window: Window? = findWindow()
): SystemUiVisibilityController {
    val view = LocalView.current
    return remember(view) {  
        AndroidSystemUiVisibilityController(window, view) 
    }
}

interface SystemUiVisibilityState {
   val isVisible: StateFlow
}

interface SystemUiVisibilityController :SystemUiVisibilityState {
   var isSystemBarsVisible: Boolean
}

internal class AndroidSystemUiVisibilityController(
   window: Window?,
   private val view: View
) : SystemUiVisibilityController {

   private val windowInsetsController = window?.let {
       WindowInsetsControllerCompat(window, view).apply {
           systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
       }
   }

   private val isVisibleStateFlow = MutableStateFlow(isSystemBarsVisible)

   private fun systemUiBars() =
       WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars()

   override var isSystemBarsVisible: Boolean
       get() {
           return ViewCompat.getRootWindowInsets(view)
               ?.isVisible(systemUiBars()) == true
       }
       set(value) {
           if (value) {
               windowInsetsController?.show(systemUiBars())
           } else {
               windowInsetsController?.hide(systemUiBars())
           }
           isVisibleStateFlow.value = value
       }

   override val isVisible: StateFlow
       get() = isVisibleStateFlow.asStateFlow()
}

val SystemUiVisibilityController.toggleUi: () -> Unit
   get() = {
       isSystemBarsVisible = !isSystemBarsVisible
   }

@Composable
private fun findWindow(): Window? =
   (LocalView.current.parent as? DialogWindowProvider)?.window
       ?: LocalView.current.context.findWindow()

private tailrec fun Context.findWindow(): Window? =
   when (this) {
       is Activity -> window
       is ContextWrapper -> baseContext.findWindow()
       else -> null
   }

Пример использования:

val systemUiVisibilityController = rememberSystemUiVisibilityController()
InsetsExamplesTheme {
   FullscreenCutoutSample(
       systemUiVisibilityController.toggleUi
   )
}

Display Cutouts (Поддержка вырезов дисплея)

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

ba9e544473ea6538e3ddb37688f92052.png

В android 9 (api 28) появился класс DisplayCutout, который позволяет обработку области вырезов. Помимо этого, есть набор флагов у WindowManager.LayoutParams, которые позволяют включать разное поведение вокруг вырезов.

Для установки флагов используется layoutInDisplayCutoutMode, определяющий, как ваш контент отображается в области вырезов. Существуют следующие значения:

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT — в портретном режиме содержимое отображается  под областью выреза в портретном режиме, а в альбомном режиме будет черная полоса.

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES — Содержимое отображается в области выреза как в портретном, так и в альбомном режимах.

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER — Содержимое никогда не отображается в области выреза.

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS — В этом режиме окно расширяется под вырезами по всем краям дисплея как в портретной, так и в альбомной ориентации, независимо от того, скрывает ли окно системные панели (LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES полный аналог, но доступен с 28 api, когда LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS появился только в 30 api).

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

Пример использования:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
    window.attributes.layoutInDisplayCutoutMode =  WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}

В библиотеке insetter есть флаг для displayCutout, поэтому вы можете обработать padding и margin от области выреза. 

applyInsetter {
   type(displayCutout = true) {
       // укажите нужные вам стороны 
       // например padding(top = true)
       padding() 
   } 
}

В Jetpack Compose вам также нужно использовать layoutInDisplayCutoutMode чтобы установить режим для DisplayCotout. В compose для обработки padding можно использовать WindowInsets.displayCutout или Modifier.displayCutoutPadding ().

Если вам вдруг понадобится получить зону выреза, то для этого вы можете использовать DisplayCutoutCompat.getBoundingRects (). Этот метод возвращает список прямоугольников каждый из которых является ограничивающим прямоугольником для нефункциональной области на дисплее. 

ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
   val boundingRects = insets.displayCutout?.boundingRects
   insets
}

В Jetpack Compose с помощью WindowInsets.displayCutout получить  boundingRects нельзя.

Я считаю, что располагать элементы относительно выреза не имеет смысла по следующим причинам:

  • там где нет выреза находится информация из Status Bar (время, иконки);

  • api возвращает список, а значит вырезов может быть несколько;

  • вырезы бываю разных форм и размеров.

В общем все говорит о том, что так лучше не делать, но если очень надо, то сделать можно. 

System Gesture Insets

Этот вид insets появился в Android 10  и возвращает области жестов домой снизу и назад справа и слева от экрана. 

Такого рода insets появились в Android 10. Они возвращают области жестов домой снизу и обратно в правую и левую части экрана.

2c2dec155871ffa0563cbbed07987d95.png

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

Например: на гифке ниже пользователь хочет сделать скролл и без исключения жестов будет вызывать выход. Также всегда стоит помнить, что большие зоны с исключением жестов могут привести к непонятному поведению для пользователя, поэтому стоит исключать жесты только в тех местах, где это необходимо. Если рассматривать пример ниже, то исключение жестов должно выполняется только для элемента с горизонтальным скроллом.

2dbd9ca1bd621e6953aaf06e8b353fb7.gif

К сожалению, я не нашел способа исключить жесты с помощью библиотеки insetter, поэтому пришлось использовать стандартные инструменты. В примере ниже мы исключаем жесты для BottomSheetBehavior, когда он находится в развернутом состоянии и включаем, когда BottomSheetBehavior свернут. 

Во время реализации примера для View я столкнулся с проблемой, что windowInsets.getInsets(WindowInsetsCompat.Type.systemGestures())возвращает пустое значение. Я долго мучился с этим, пока не решил запустить пример на эмуляторе и не увидел, что все работает. Чтобы уж точно удостовериться, я скинул apk своим коллегам и получил в ответ — все работает. Проблема была в том, что я тестировал пример на своем устройстве (Realme c21). Вендоры как обычно шалят.

Код

private fun setupInsets() = with(binding) { 
   ViewCompat.setOnApplyWindowInsetsListener(root) { _, windowInsets ->
       setupBottomSheetCollback(bottomSheetBehavior, windowInsets)
       windowInsets
   }
}

private fun  setupBottomSheetCollback(
   bottomSheetBehavior: BottomSheetBehavior,
   windowInsets: WindowInsetsCompat
) {
   bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
       override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit

       override fun onStateChanged(bottomSheet: View, newState: Int) {
           when (newState) {
               BottomSheetBehavior.STATE_EXPANDED -> {
                   excludeGesturesHorizontalLit(windowInsets)
               }
               BottomSheetBehavior.STATE_COLLAPSED -> {
                   ViewCompat.setSystemGestureExclusionRects(binding.root, listOf())
               }
           }
       }
   }
   bottomSheetCallback?.let(bottomSheetBehavior::addBottomSheetCallback)
}

private fun excludeGesturesHorizontalLit(windowInsets: WindowInsetsCompat) {
   binding.root.doOnLayout {
       val gestureInsets =
           windowInsets.getInsets(WindowInsetsCompat.Type.systemGestures())

       with(binding) {
          val rectTop = root.bottom - bottomSheetInclude.list.height
          val rectBottom = root.bottom
          
          val leftExclusionRectLeft = 0
          val leftExclusionRectRight = gestureInsets.left
          
          val rightExclusionRectLeft = root.right - gestureInsets.right
          val rightExclusionRectRight = root.right
          
          root.setSystemGestureExclusionRectsCompat(
              rects = listOf(
                  Rect(
                      leftExclusionRectLeft,
                      rectTop,
                      leftExclusionRectRight,
                      rectBottom
                  ),
                  Rect(
                      rightExclusionRectLeft,
                      rectTop,
                      rightExclusionRectRight,
                      rectBottom
                  )
              )
          )
       }
   }
}

Чтобы исключить жесты в Jetpack Compose, вы можете использовать Modifier.systemGestureExclusion ().

LazyRow(
   modifier = Modifier
       .padding(vertical = 16.dp)
       .systemGestureExclusion(),
   contentPadding = PaddingValues(horizontal = 16.dp),
   horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
    //...
}

Mandatory System Gesture Insets

Данный тип insets появился в Android 10 и является подтипом System Gesture Insets. Они указывают области экрана, где поведение системного жеста будет всегда в приоритете над жестами в приложении. Обязательные зоны жестов никогда не могут быть исключены приложениями (с android 10 обязательной зоной жестов является зона с жестом домой). Обязательные insets отодвигают контент от обязательных вставок жестов. Например, если у вас есть seekbar снизу экрана, вам необходимо использовать Mandatory System Gesture Insets, чтобы избежать вызова жестов.

7ff59eb71954691244b360677a3131b0.gif

В View обязательные insets можно обработать с помощью библиотеки insetter:  

seekBar.applyInsetter {
   type(mandatorySystemGestures = true) {
       padding(bottom = true)
   }
}

В Jetpack Compose обязательные жесты можно получить с помощью  WindowInsets.mandatorySystemGestures:

Surface(
   modifier = Modifier
       .fillMaxWidth()
       .windowInsetsPadding(
           WindowInsets.mandatorySystemGestures
               .only(WindowInsetsSides.Bottom)
               .union(
                   WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)
               )
       ),
   color = Color.LightGray.copy(alpha = 0.3f)
) {
   // content
}

Tappable element insets

Данный тип вставки появился в android 10 и нужен для обработки разных режимов Navigation Bar. Данный вид insets редко кто использует, потому что отличия от system window insets минимальны. Но согласитесь, приятно осознавать, что ваше приложение написано максимально круто. 

На картинке ниже вы можете видеть, что tappable element insets и system window insets действуют одинаково, когда устройство настроено на навигацию с помощью кнопок. Отличие можно заметить только в режиме навигации с помощью жестов.

ce315b04fe20eeae584cf49a64c82ad6.png

Дело в том, что при навигации жестами мы не нажимаем, а делаем свайп снизу вверх. Это говорит о том, что мы можем использовать в этой зоне интерактивные элементы (например FloatingActionButton), а это значит, что отступ делать не нужно и tappable element insets вернет 0.

Пример использования c view (все также используем библиотеку insetter):

fab.applyInsetter {
   type(tappableElement = true) {
       margin(bottom = true)
   }
}

В Jetpack Compose используем WindowInsets.tappableElement:

Scaffold(
   floatingActionButton = {
      FloatingActionButton(
          modifier = Modifier.padding(
              bottom = WindowInsets.tappableElement
                  .only(WindowInsetsSides.Bottom)
                  .asPaddingValues()
                  .calculateBottomPadding()
          ),
          backgroundColor = backgroundColor,
          onClick = onClick
      ) {
          Icon(
              imageVector = Icons.Filled.Add,
              contentDescription = null
          )
      }
){ // content... }

Заключение

Весь код из статьи можно посмотреть в этом репозитории. 

Мы рассмотрели нюансы реализации e2e в мобильных приложениях на Android и пример реализации с помощью библиотеки insetter для View, а также использовали встроенные insets в Jetpack Compose. В этой статье я хотел донести до всех android разработчиков, что e2e на самом деле очень прост в реализации, а результат стоит затраченного времени. Я надеюсь, что эта статья была вам полезна, и все больше и больше разработчиков будут внедрять e2e в свои приложения.

Реализуете ли вы edge-to-edge в своих приложениях? Используете ли вы библиотеки для работы с insets? С какими проблемами сталкивались при реализации edge-to-edge?

Выражаем благодарность Вадиму (@vad99lord) за помощь в подготовке статьи.

© Habrahabr.ru