Привет! Меня зовут Андрей Шоколов, я Android-разработчик KODE.
К нам обратилась компания Forward Leasing с запросом разработать мобильное приложение по готовому дизайну. Прототип содержал дугу, которая по задумке должна была сжиматься в одну линию при скролле. За основу решили взять CoordinatorLayout: у нас уже был положительный опыт работы с ним на другом проекте. Ещё в нашей команде часто любили соревноваться, какой же Layout лучше — CoordinatorLayout или MotionLayout, и именно сейчас настало время узнать.
Сейчас я понимаю, что проблема была создана на ровном месте, — но это я выяснил только в процессе работы. В статье расскажу, с какими 7 сложностями CoordinatorLayout я столкнулся и как сделать за полчаса то, с чем я провозился сутки.
![](https://habrastorage.org/getpro/habr/upload_files/ebe/380/b3d/ebe380b3d9686ce3117fd1b1bda873f4.jpg)
Задача проекта
Мне нужно было создать AppBarLayout с дугой, который скроллится в одну линию. Функциональность добавлялась в существующую Activity приложения Forward. Корневым элементом для главной Activity был выбран CoordinatorLayout — это ViewGroup, целью которой является координация внутренних view-элементов.
![Главный экран мобильного приложения Forward Leasing Главный экран мобильного приложения Forward Leasing](https://habrastorage.org/getpro/habr/upload_files/91b/e42/01b/91be4201b450efb82ab1b71af3385e57.png)
Прежде чем я расскажу, что такое CoordinatorLayout и с какими сложностями в реализации я столкнулся, покажу, как в итоге должно было получиться:
Финальный результат
![](https://habrastorage.org/getpro/habr/upload_files/a60/32b/adb/a6032badbdec154953d6c340cefafcb8.gif)
Что такое CoordinatorLayout с Default behavior
CoordinatorLayout — это просто обычный FrameLayout. Нет, простите: как написано в документации, это super-powered FrameLayout!
![Главная фишка CoordinatorLayout — свои Default behaviors Главная фишка CoordinatorLayout — свои Default behaviors](https://habrastorage.org/getpro/habr/upload_files/549/7fe/68e/5497fe68e0ffb932bc53e33a6a80d783.jpg)
С помощью Behaviors можно управлять дочерней View. Думаю, все видели эти красивые анимации, когда снизу выплывает Snackbar, а вместе с ним вверх поднимается FAB (Floating Action Button). Всё работает отлично. Но именно Behaviors и подставят меня позднее, потому что когда тебе нужно делать не всё по дефолту, а добавлять что-то своё, то возникают некоторые сложности. Для реализации такой дуги был создан класс RoundedAppBarLayout, наследующий AppBarLayout.
Что дано в RoundedAppBarLayout
-
Toolbar — самая обычная вью тулбара из Android.
-
ContentView, где может быть всё, что угодно.
-
Arc — наша дуга.
![](https://habrastorage.org/getpro/habr/upload_files/879/68c/066/87968c066bf7cf3d6eb061716f38446f.jpg)
С какими сложностями я столкнулся и как их решал
№1. Скроллинг
У нас есть RoundedAppBarLayout, в котором находятся Toolbar, ContentView и Arc. Для каждого элемента вью внутри него можно задать scrollFlags, которые описывают поведение вью при скролле: scroll, snap, expandAlways, expandAlwaysCollapsed, exitUntilCollapsed. Как они работают? AppBarLayout проходится по всем дочерним вью — child’ам, которые у него есть, и смотрит: если выставлен scroll, тогда добавить то, что эта вьюшка скроллится, если noScroll — return.
Если на Toolbar написано scroll, то он прокрутится, а всё, что находится ниже него, — нет. Соответственно, если поставить scroll на Toolbar и ContentView, то прокручиваться будут только они. Нам же нужно ровно наоборот: чтобы скроллилась дуга, а не всё, что выше неё.
Решение
CollapsingToolbar. Он позволяет оставить Toolbar наверху и даёт возможность прокручивать ContentView, Arc и содержимое ниже. Это решение работает достаточно хорошо, за исключением того, что Toolbar должен быть всегда последним элементом CollapsingToolbar.
Код
<?xml version="1.0" encoding="utf-8"?> <merge xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@color/transparent" tools:parentTag="com.google.android.material.appbar.AppBarLayout"> <com.google.android.material.appbar.CollapsingToolbarLayout android:id="@+id/collapsingToolbar" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@color/transparent" app:layout_scrollFlags="scroll|snap|exitUntilCollapsed"> </com.google.android.material.appbar.CollapsingToolbarLayout> </merge>
№2. Прозрачные зоны в Arc и отрисовка дуги
Для того чтобы нарисовать дугу в AppBarLayout, в блоке Arc должны находиться зоны, которые будут прозрачными. Поэтому выставляем AppBarLayout transparent, как и ContentView: мы не знаем, какого цвета он должен быть.
Но если мы оставим CollapsingToolbar, который вложен в AppBar, без цвета, как и наш ContentView, то на выходе мы получим прозрачный прямоугольник.
Решение
Покрасить CollapsingToolbar в какой-то цвет. Мы выбрали фиолетовый, так как это основной цвет проекта. Этим решением мы также улучшили анимацию: теперь при прокрутке блок может затемняться, и это выглядит достаточно красиво.
Как это выглядит
![](https://habrastorage.org/getpro/habr/upload_files/66c/19e/68d/66c19e68dfdb82a575abb96f7da36f09.gif)
Остаётся вопрос с дугой. Мы не можем поместить её в в CollapsingToolbar, потому что он имеет свой цвет, а Arc должна быть transparent. Получается, мы должны поместить дугу в AppBarLayout, но он, как мы помним, не даёт скроллить всё, что внизу.
Решение
Рисовать на канвасе и во время скролла просчитывать, как рисовать дугу.
![Слева показано, как выглядит структура Layout'а Слева показано, как выглядит структура Layout'а](https://habrastorage.org/getpro/habr/upload_files/60c/eeb/05b/60ceeb05b2a81be21e9796f296c59cc0.jpg)
Элемент нужно добавить в CollapsingToolbar. Можно было бы сразу добавить сюда Toolbar, но так как нужно, чтобы он был последним элементом в CollapsingToolbarLayout, его приходится записывать отдельно. То есть я делаю Inflate Layout’а и добавляю в конец CollapsingToolbarLayout.
Код
private fun addContentView(){ if (childCount > 1) { val contentChild = this.getChildAt(1) removeView(contentChild) val layoutParams = CollapsingToolbarLayout.LayoutParams(contentChild.layoutParams) with(contentChild) { layoutParams.setMargins( marginStart, marginTop + toolbarHeight, marginEnd, marginBottom ) } contentChild.setBackgroundColor(context.getColor(R.color.accent2)) contentChild.layoutParams = layoutParams collapsingToolbar.addView(contentChild) } } override fun onFinishInflate() { super.onFinishInflate() addContentView() val toolbar = LayoutInflater.from(context) .inflate(R.layout.toolbar_default, collapsingToolbar, false) removeView(toolbar) collapsingToolbar.addView(toolbar) }
В итоге получился такой простенький класс, где есть атрибуты, title, где можно задать иконку для Toolbar, добавить ContentView, который находится в самом нашем RoundedAppBarLayout, и нарисовать дугу.
![Доступные методы Доступные методы](https://habrastorage.org/getpro/habr/upload_files/8e5/588/54f/8e558854fd824b4b50dde7c76f5dab47.jpeg)
№3. Пустое пространство после ContentView
С чем столкнулись дальше. После Toolbar и ContentView у нас нет никакого пространства. По идее, туда нужно было бы добавить вьюшку, но вместе с ней при скролле появлялась бы белая полоса. Я заранее ожидал такое поведение.
Решение
Расширение AppBarLayout. Также сохраняю appbarBottom, чтобы нарисовать дугу: сохраняю нижнюю координату ContentView и увеличиваю AppBarLayout до конца блока Arc. Так нам будет удобнее рисовать. (Спойлер: это решение в итоге со мной сыграет злую шутку.)
Расширяю AppBarLayout
class RoundedAppBarLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : AppBarLayout(context, attrs, defStyleAttr) { init { LayoutInflater.from(context).inflate(R.layout.appbar_rounded_default, this, true) initView() } private fun initView() { this.post { layoutParams = CoordinatorLayout.LayoutParams(width, height + arcHeight) appbarBottom = height.toFloat() appbarHalfWidth = width / 2f } } }
Добавляю ContentChild и Toolbar для нормального отображения контента
override fun onFinishInflate() { super.onFinishInflate() if (childCount > 1) { val contentChild = this.getChildAt(1) removeView(contentChild) val layoutParams = CollapsingToolbarLayout.LayoutParams(contentChild.layoutParams) with(contentChild) { layoutParams.setMargins( marginStart, marginTop + toolbarHeight, marginEnd, marginBottom ) } contentChild.setBackgroundColor(context.getColor(R.color.accent2)) contentChild.layoutParams = layoutParams collapsingToolbar.addView(contentChild) }
Рисую дугу
private fun drawArc(canvas: Canvas) { val scale = bottom / height.toFloat() arcPath.apply { reset() moveTo(0f, appbarBottom) quadTo( appbarHalfWidth, //divide by 2 due to arc drawing logic (appbarBottom + (arcHeight * 2)) * scale, width.toFloat(), appbarBottom ) } canvas.drawPath(arcPath, arcPaint).apply { invalidate() } }
Нужно просчитать расстояние, когда AppBarLayout полностью заскроллен и недоскролен. То есть scale = 1 — это AppBarLayout раскрыт полностью, а scale = 0 — схлопнут. И рисую саму дугу. Я столкнулся здесь с очень интересной логикой: если указывать по y-координате, что крайняя точка дуги должна быть на 48 пикселях, то вершина этой точки будет на 24 пикселях — в два раза меньше. В итоге нужно домножать на два — решил я, не разобравшись с причиной.
Сейчас, во время написания статьи, я понимаю, что использовал функцию, которая рисует не дугу, а кривую Безье. И она строится не через крайнюю точку, а посередине. Вместо Path можно использовать Arc. Реализация останется той же, только без домножения на 2.
Весь код класса AppBarLayout
class RoundedAppBarLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : AppBarLayout(context, attrs, defStyleAttr) { private var appbarBottom = 0f private var appbarHalfWidth = 0f //region toolbarArc private val arcHeight = DimensUtils.convertDpToPixel(48f, context) private val toolbarHeight = DimensUtils.convertDpToPixel(56f, context) private val arcPaint = Paint().apply { color = context.getColor(R.color.accent2) style = Paint.Style.FILL isAntiAlias = true } private val arcPath = Path() //endregion init { LayoutInflater.from(context).inflate(R.layout.appbar_default, this, true) initView() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) drawArc(canvas) } override fun onFinishInflate() { super.onFinishInflate() if (childCount > 1) { val contentChild = this.getChildAt(1) removeView(contentChild) val layoutParams = CollapsingToolbarLayout.LayoutParams(contentChild.layoutParams) with(contentChild) { layoutParams.setMargins( marginStart, marginTop + toolbarHeight, marginEnd, marginBottom ) } contentChild.setBackgroundColor(context.getColor(R.color.accent2)) contentChild.layoutParams = layoutParams collapsingToolbar.addView(contentChild) } val toolbar = LayoutInflater.from(context) .inflate(R.layout.toolbar_default, collapsingToolbar, false) removeView(toolbar) collapsingToolbar.addView(toolbar) } private fun initView() { setBackgroundColor(context.getColor(R.color.transparent)) outlineProvider = null this.post { layoutParams = CoordinatorLayout.LayoutParams(width, height + arcHeight) appbarBottom = height.toFloat() appbarHalfWidth = width / 2f } } private fun drawArc(canvas: Canvas) { val scale = bottom / height.toFloat() arcPath.apply { reset() moveTo(0f, appbarBottom) quadTo( appbarHalfWidth, //divide by 2 due to arc drawing logic (appbarBottom + (arcHeight * 2)) * scale, width.toFloat(), appbarBottom ) } canvas.drawPath(arcPath, arcPaint).apply { invalidate() } } }
№4. Белая полоса при коллапсе
Только я порадовался, что всё хорошо работает, как внезапно обнаружился костыль в основном коде. Когда ты прокручиваешь экран, то буквально за пару пикселей до конца появляется белый прямоугольник.
Начинаешь разбирать — понимаешь, что это проделки Behavior c AppBarLayout. Так как я обновил ширину всего AppBarLayout (там теперь AppBarLayout и ширина нашей дуги), то когда он коллапсится, Layout смотрит, что AppBarLayout должен быть по высоте как Toolbar и дуга, поэтому выделяет место именно под такой размер.
Изначально я думал, что так как AppBarLayout имеет transparent background, то никаких проблем не будет, но когда AppBarLayout коллапсится, цвет background уже не играет роли.
Во время разработки я не понял, почему в AppBarLayout свободное пространство остаётся белым, а не transparent. В коде AppBarLayout я не смог найти этого. Возможно, это уже тонкости работы с CoordinatorLayout.
Решение
Я долго думал, как это исправить, и решил применить метод «инкостыляции». Он заключается в том, что я пишу свой кастомный Behavior, и когда он устанавливается в AppBarLayout, то я вызываю метод setRoundedAppBarBehavior. По сути я просто добавляю дополнительный margin, чтобы он мог отображаться поверх AppBarLayout.
Код
class RoundedAppBarBehavior(context: Context, attrs: AttributeSet) : AppBarLayout.ScrollingViewBehavior(context, attrs) { private var isInstalled = false override fun layoutDependsOn( parent: CoordinatorLayout, child: View, dependency: View ): Boolean { if (!isInstalled && dependency is BaseAppBarLayout) { isInstalled = true child.setRoundedAppBarBehavior() } return super.layoutDependsOn(parent, child, dependency) } } fun View.setRoundedAppBarBehavior() { if (this is ViewGroup) { val arcHeight = resources.getDimensionPixelSize(R.dimen.toolbar_arc_height) this.post { clipToPadding = false val lp = CoordinatorLayout.LayoutParams(layoutParams) lp.behavior = AppBarLayout.ScrollingViewBehavior() lp.setMargins(marginStart, marginTop - arcHeight, marginEnd, marginBottom) layoutParams = lp } } }
Всё снова заработало, ура!
Как выглядит проблема и решение
![](https://habrastorage.org/getpro/habr/upload_files/ba5/e29/ed9/ba5e29ed9ba0ac13a5ab6740e579c6d5.gif)
№5. AppBar без контента не скроллится
Оказалось, что каждого AppBarLayout может быть контент внутри. И AppBarLayout думает: контента внутри меня никакого нет, так зачем мне скроллиться?
Решение
Разделение базового класса на два дочерних: RoundedAppBarLayour и SimpleRoundedAppBarLayout. В первый можно поместить контент, а второй останется без него.
Код базового класса AppBarLayout и 2 наследуемых SimpleRoundedAppBarLayout и RoundedAppBarLayout
abstract class BaseAppBarLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : AppBarLayout(context, attrs, defStyleAttr) { var title: String? = null set(value) { field = value titleToolbar?.text = value ?: "" } var navigationIcon: Int? = null var isEnableCollapsingBehaviour: Boolean = false // The value from which the arc is drawn protected var appbarBottom = 0f protected var appbarHalfWidth = 0f //region toolbarArc protected val arcHeight = context.resources.getDimensionPixelSize(R.dimen.toolbar_arc_height) protected val toolbarHeight = context.resources.getDimensionPixelSize(R.dimen.toolbar_height) val nonScrollableHeight = arcHeight + toolbarHeight protected val arcPaint = Paint().apply { color = context.getColor(R.color.accent2) style = Paint.Style.FILL isAntiAlias = true } protected val arcPath = Path() //endregion var contentChild: View? = null override fun onDraw(canvas: Canvas) { super.onDraw(canvas) drawArc(canvas) } fun updateToolbarTitleMargin( marginStart: Int = 0, marginTop: Int = 0, marginEnd: Int = 0, marginBottom: Int = 0 ) { titleToolbar.layoutParams = Toolbar.LayoutParams(titleToolbar.layoutParams).apply { setMargins(marginStart, marginTop, marginEnd, marginBottom) } } protected fun initToolbar(toolbar: Toolbar) { navigationIcon?.let { toolbar.navigationIcon = ContextCompat.getDrawable(context, it) toolbar.setNavigationOnClickListener { (context as? Activity)?.onBackPressed() } } toolbar.findViewById<TextView>(R.id.titleToolbar)?.let { textView -> textView.text = title if (navigationIcon != null) { textView.layoutParams = Toolbar.LayoutParams(textView.layoutParams).apply { setMargins(0, 0, 64.dp, 0) } } } } protected fun initAttrs(attrs: AttributeSet? = null, defStyleAttr: Int = 0, defStyle: Int = 0) { context.withStyledAttributes( attrs, R.styleable.RoundedAppBarLayout, defStyleAttr, defStyle ) { val defValue = -1 title = getString(R.styleable.RoundedAppBarLayout_ral_title) getResourceId(R.styleable.RoundedAppBarLayout_ral_navigate_icon, defValue).apply { if (this != defValue) navigationIcon = this } isEnableCollapsingBehaviour = getBoolean(R.styleable.RoundedAppBarLayout_ral_collapsing_behaviour, false) } } protected open fun drawArc(canvas: Canvas) { val scale = (bottom - nonScrollableHeight) / (height - nonScrollableHeight).toFloat() contentChild?.alpha = max(0f, scale - (1 - scale)) if (isEnableCollapsingBehaviour) collapsingBehaviour(scale) arcPath.apply { reset() moveTo(0f, appbarBottom) quadTo( appbarHalfWidth, //multiply by 2 due to arc drawing logic appbarBottom + (arcHeight * 2) * scale, width.toFloat(), appbarBottom ) } canvas.drawPath(arcPath, arcPaint).apply { invalidate() } } private fun collapsingBehaviour(scale: Float) { titleToolbar?.alpha = 1 - scale contentChild?.y = (toolbarHeight - arcHeight).toFloat().dp } } class SimpleRoundedAppBarLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : BaseAppBarLayout(context, attrs, defStyleAttr) { init { LayoutInflater.from(context).inflate(R.layout.appbar_rounded_simple, this, true) initView() initAttrs(attrs, defStyleAttr) } override fun onFinishInflate() { super.onFinishInflate() toolbar.setBackgroundColor(ContextCompat.getColor(context, R.color.accent2)) initToolbar(toolbar) } override fun drawArc(canvas: Canvas) { val scale = (bottom - toolbarHeight) / (height - toolbarHeight).toFloat() val startDrawingHeight = appbarBottom + arcHeight * (1 - scale) arcPath.apply { reset() moveTo(0f, startDrawingHeight) quadTo( appbarHalfWidth, //multiply by 2 due to arc drawing logic appbarBottom + (arcHeight * 2) * scale, width.toFloat(), startDrawingHeight ) } canvas.drawPath(arcPath, arcPaint).apply { invalidate() } } private fun initView() { setBackgroundColor(context.getColor(R.color.transparent)) backgroundTintMode = PorterDuff.Mode.OVERLAY outlineProvider = null this.post { appbarBottom = height - arcHeight.toFloat() appbarHalfWidth = width / 2f } } } class RoundedAppBarLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : BaseAppBarLayout(context, attrs, defStyleAttr) { private var isArcDrawn = false init { LayoutInflater.from(context).inflate(R.layout.appbar_rounded_default, this, true) initView() initAttrs(attrs, defStyleAttr) } override fun onFinishInflate() { super.onFinishInflate() if (childCount > 1) addContentView() val toolbar = LayoutInflater.from(context) .inflate(R.layout.toolbar_default, collapsingToolbar, false) removeView(toolbar) collapsingToolbar.addView(toolbar) (toolbar as? Toolbar)?.let { initToolbar(it) } } private fun initView() { setBackgroundColor(context.getColor(R.color.transparent)) backgroundTintMode = PorterDuff.Mode.OVERLAY outlineProvider = null this.post { layoutParams = CoordinatorLayout.LayoutParams(width, height + arcHeight) appbarBottom = height.toFloat() appbarHalfWidth = width / 2f isArcDrawn = true } } private fun addContentView() { val contentChild = this.getChildAt(1) removeView(contentChild) val layoutParams = CollapsingToolbarLayout.LayoutParams(contentChild.layoutParams) layoutParams.setMargins( marginStart, marginTop + toolbarHeight, marginEnd, marginBottom ) contentChild.setBackgroundColor(context.getColor(R.color.accent2)) contentChild.layoutParams = layoutParams this.contentChild = contentChild collapsingToolbar.addView(contentChild) } }
Тот Toolbar, в котором нет контента, теперь можно сделать цветом. Делаем это потому, что теперь не будет эффекта затемнения, как было на гифке в первом пункте (так как контента, кроме Toolbar, нет), поэтому мы можем просто задать цвет, и всё будет отлично работать. Здесь показываю, как в итоге было сделано.
![Схема AppBar Схема AppBar](https://habrastorage.org/getpro/habr/upload_files/7f7/259/6ab/7f72596ab8dd6f160b9c40c5a1fc4c63.jpeg)
Здесь я уже мог добавить ArcView — саму вьюшку дуги — и не беспокоиться с тем, что там не будет транспарентного расстояния. Теперь CollapsingToolbar будет transparent, Toolbar остаётся с цветом, а AppBarLayout не требует расширения.
![Сравнение структур Layout'ов Сравнение структур Layout'ов](https://habrastorage.org/getpro/habr/upload_files/087/b70/d5e/087b70d5ed3a742423510ee3ef4197f3.jpg)
Единственное, что нам пришлось изменить в коде, — изменить высоту и переделать рисование дуги, потому что так как там нет Child, то не надо ничего затемнять.
XML-код для SimpleRoundedAppBarLayout и RoundedAppBarLayout
<?xml version="1.0" encoding="utf-8"?> <merge xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@color/transparent" android:orientation="vertical" tools:parentTag="com.google.android.material.appbar.AppBarLayout"> <com.google.android.material.appbar.CollapsingToolbarLayout android:id="@+id/collapsingToolbar" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@color/transparent" app:layout_scrollFlags="scroll|snap|exitUntilCollapsed"> <View android:id="@+id/arcView" android:layout_width="match_parent" android:layout_height="48dp" android:layout_marginTop="56dp" android:background="@color/transparent" /> <include android:id="@+id/toolbar" layout="@layout/toolbar_default" android:layout_width="match_parent" android:layout_height="56dp" android:layout_gravity="top" app:layout_collapseMode="pin" /> </com.google.android.material.appbar.CollapsingToolbarLayout> </merge> <?xml version="1.0" encoding="utf-8"?> <merge xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@color/transparent" tools:parentTag="com.google.android.material.appbar.AppBarLayout"> <com.google.android.material.appbar.CollapsingToolbarLayout android:id="@+id/collapsingToolbar" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@color/accent2" app:layout_scrollFlags="scroll|snap|exitUntilCollapsed"> </com.google.android.material.appbar.CollapsingToolbarLayout> </merge>
№6. При скрытии и показе AppBarLayout в активности появляется белая полоса
Далее у нас возникли последствия метода «инкостыляции» при выставлении visibility.
У нас есть одна активность и на ней много фрагментов. Сверху активности находится RoundedAppBarLayout, а внизу — контейнер для фрагментов. Если во время переключения между фрагментами менять visibility, то появляется белая полоса.
Причина — у дочернего контейнера для фрагмента выставлялся margin. Белая полоса — это и есть тот самый margin.
![При скрытии и показе AppBarLayout в активности появлялась белая полоса При скрытии и показе AppBarLayout в активности появлялась белая полоса](https://habrastorage.org/getpro/habr/upload_files/0f1/a3b/52f/0f1a3b52fd8c5d4733f5f6ee19391939.png)
Решение
Дополнить кастомный Behavior и добавить в него VisibilityListener.
Новый код Behavoir
class RoundedAppBarBehavior(context: Context, attrs: AttributeSet) : AppBarLayout.ScrollingViewBehavior(context, attrs) { private var isInstalled = false override fun layoutDependsOn( parent: CoordinatorLayout, child: View, dependency: View ): Boolean { if (!isInstalled && dependency is BaseAppBarLayout) { isInstalled = true child.setRoundedAppBarBehavior() addVisibilityListener(dependency, child) } return super.layoutDependsOn(parent, child, dependency) } private fun addVisibilityListener(appBarLayout: AppBarLayout, child: View) { var isVisibleSaved = appBarLayout.isVisible val visibilityListener = ViewTreeObserver.OnGlobalLayoutListener { if (isVisibleSaved != appBarLayout.isVisible) { isVisibleSaved = appBarLayout.isVisible if (isVisibleSaved) { child.setRoundedAppBarBehavior() } else { child.removeRoundedAppBarBehavior() } } } appBarLayout.viewTreeObserver.addOnGlobalLayoutListener(visibilityListener) appBarLayout.doOnDetach { appBarLayout.viewTreeObserver.removeOnGlobalLayoutListener(visibilityListener) } } }
Здесь я подписываюсь на GlobalLayout, когда doOnDetach — отписываюсь от него. Если он видим, тогда я добавляю ему RoundedBehavior, то есть добавляю margin, если он невидим — я убираю их. Это делается для того, чтобы не возникали утечки памяти при работе в приложении.
№7. Белое пространство при нескроллящемся контенте в Child
Прилетел ещё один баг. В профиле клиента появляется белая полоса, если child содержит нескроллящийся контент.
Когда я работал со списками, применялся мой Behavior — весь список прокручивался, и всё работало отлично. Как только в Layout, на который применялся behaviour, появилась статическая часть, то margin сверху оставался.
Решение
Добавить кастомный ScrollListener. Он сделал отображение AppBarLayout ещё более красивым, в скролл — плавным.
Обновлённый код
class RoundedAppBarBehavior(context: Context, attrs: AttributeSet) : AppBarLayout.ScrollingViewBehavior(context, attrs) { private var isInstalled = false private val arcHeight = context.resources.getDimensionPixelSize(R.dimen.toolbar_arc_height) override fun layoutDependsOn( parent: CoordinatorLayout, child: View, dependency: View ): Boolean { if (!isInstalled && dependency is BaseAppBarLayout) { isInstalled = true child.setRoundedAppBarBehavior() addVisibilityListener(dependency, child) addOnScrollListener(dependency, child) } return super.layoutDependsOn(parent, child, dependency) } private fun addOnScrollListener(appBarLayout: BaseAppBarLayout, child: View) { val scrollListener = AppBarLayout.OnOffsetChangedListener { _, _ -> val divider = (appBarLayout.height - appBarLayout.nonScrollableHeight).toFloat() val scale = if (divider == 0f) 1f else (appBarLayout.bottom - appBarLayout.nonScrollableHeight) / divider child.setPadding( child.paddingLeft, max(0, (arcHeight * scale).toInt()), child.paddingEnd, child.paddingBottom ) } appBarLayout.addOnOffsetChangedListener(scrollListener) appBarLayout.doOnDetach { appBarLayout.removeOnOffsetChangedListener(scrollListener) } } private fun addVisibilityListener(appBarLayout: AppBarLayout, child: View) { var isVisibleSaved = appBarLayout.isVisible val visibilityListener = ViewTreeObserver.OnGlobalLayoutListener { if (isVisibleSaved != appBarLayout.isVisible) { isVisibleSaved = appBarLayout.isVisible if (isVisibleSaved) { child.setRoundedAppBarBehavior() } else { child.removeRoundedAppBarBehavior() } } } appBarLayout.viewTreeObserver.addOnGlobalLayoutListener(visibilityListener) appBarLayout.doOnDetach { appBarLayout.viewTreeObserver.removeOnGlobalLayoutListener(visibilityListener) } } }
Здесь вычисления примерно такие же, как с рисованием дуги: рассчитывается, рассчитывается коэффициент скролла scale, где 0 — это полностью скрытая дуга, а 1 — полностью раскрытая дуга. Теперь расстояние контента до дуги будет точно рассчитываться во время скролла.
Как выглядит проблема и решение
![](https://habrastorage.org/getpro/habr/upload_files/5b1/4e6/57c/5b14e657ceb3bbff9626b8d978f827f1.gif)
Как сделать за полчаса то, с чем я провозился сутки
![](https://habrastorage.org/getpro/habr/upload_files/957/0a9/7e5/9570a97e59ea82b7aa828b33c1ff7928.png)
Я прикидывал много вариантов, как можно было бы сделать похожее поведение с CoordinatorLayout, но, к сожалению, в AppBarLayout нельзя сделать прокрутку у контента, который находится ниже статического. Даже если добавить два AppBarLayout, они всё равно будут криво работать.
Когда мы изначально анализировали, как сделать анимацию на главном экране, то казалось, что с задачей не возникнет никаких проблем. Только в ходе проекта начали находить разные баги в том методе, который мы выбрали, и поняли, что есть много кейсов, где стандартное поведение не работает.
С каждым новым багом мы вносили новые изменения, дописывали и доделывали. Уже в финале мы осознали, что лучше было выбрать другое решение. Тогда я бы смог реализовать задачу за полчаса вместо суток.
На какие решения я предлагаю обратить внимание:
1) Написать свой наследник FrameLayout
С ним можно делать всё, что угодно: и на канвасе рисовать, и двигать вьюшки, как хочется.
2) Использовать MotionLayout
Используя MotionLayout, реализовать дугу в AppBarLayout можно буквально за 30 минут! Для этого достаточно указать все нужные элементы на фрагменте или активности, а потом, используя встроенные инструменты, задать поведение при анимации.
? Пример реализации дуги в Motion Layout — на Гитхабе.
ссылка на оригинал статьи https://habr.com/ru/articles/591009/
Добавить комментарий