На SDK надейся и сам не плошай: Проблема вложенных скроллов в BottomSheetBehavior
Наверное, каждый, любуясь красивыми и магически плавно съезжающими в разные стороны окошками, тулбарами и остальными вьюхами, задумывался как это работает, наверное, даже что-то читал про CoordianatorLayout, про различные Behaivor, которые позволяют создавать буквально волшебство на андроидовских вьюхах. Конечно, можно писать кастомные вьюхи, с нужным поведением, которое может быть ограничено только твоим воображением или твоими знаниями в Android разработке. Но помимо этого, есть ещё одно ограничение — время, не будешь же ты писать кастомную вьюху на хакатоне, в горящем по срокам проекте, а написанные заранее решения с кастомными вьюхами могут быть освоены не за самый короткий срок членами команды. Именно тогда приходит самое простое и самое логичное решение проблемы — не выпендриваться, использовать стандартные инструменты Android, там же смышлённые ребята сидят, всё будет в шоколаде (ну или в других сладостях андроида).
Но не всё так просто, тут и начинается моё близкое знакомство с магией таких ребят, как CoordinatorLayout, BottomSheetBehavior, а точнее с багом, который выпустили из виду разработчики, когда их писали. В статье будет описан процесс выявления бага, связанного с вложенной прокруткой внутри view-компонентов с поведением BottomSheetBehavior, а также способы его решения.
Передо мной стояла задача сделать несложный функционал с вложенной прокруткой, состоящей из горизонтального RecyclerView, ScrollView и CoordinatorLayout, именно он позволяет, ориентируясь на Behavior-ы вложенных вьюх, вытворять такие фигуры высшего пилотажа, как плавные произвольные сдвиги различных вью.
Разложим по полочкам: делаем корневой CoordinatorLayout, внутри которого, находится RecyclerView, с прописанным поведением из библиотеки android.material BottomSheetBehavior, что позволит нашему ресайклеру, выезжать снизу корневого вью или обратно туда сворачиваться. Внутри item-ов каждого, находится TextView — заголовок item-а, и NestedScrollView, с различным наполнением (допустим тот же TextView).
activity_main.xml
recycler_item.xml
Почему NestedScrollView? Потому что он, согласно официальной документации Android, выполняет ту же функцию, что ScrollView, но помимо этого поддерживает вложенную прокрутку, как со стороны родительской, так и со стороны дочерней вьюхи, по умолчанию.
Накидали вьюхи на layout, написали своих наследников RecyclerView.Adapter и RecyclerView.ViewHolder, сделали модели данных, заглушки, кажется всё готово…
RecyclerViewAdapter.kt
import android.content.Context
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
class RecyclerViewAdapter(private val context: Context, private val itemList: List) :
RecyclerView.Adapter() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerViewHolder {
return RecyclerViewHolder.create(context, parent)
}
override fun getItemCount() = itemList.size
override fun onBindViewHolder(holder: RecyclerViewHolder, position: Int) {
holder.bind(itemList[position])
}
}
RecyclerViewHolder.kt
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.widget.NestedScrollView
import androidx.recyclerview.widget.RecyclerView
class RecyclerViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind(model: ItemModel) {
val textView: TextView = itemView.findViewById(R.id.item_text_view)
val scrollView = itemView.findViewById(R.id.scroll_view) as NestedScrollView
textView.text = ""
for (i in 1..50) {
textView.append(model.number.toString() + "\n")
}
}
companion object {
fun create(context: Context, parent: ViewGroup): RecyclerViewHolder {
return RecyclerViewHolder(
LayoutInflater.from(context).inflate(
R.layout.recycler_item,
parent,
false
)
)
}
}
}
ItemModel.kt
data class ItemModel(val number: Int)
MainActvity.kt
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.android.synthetic.main.activity_main.*
class MainActivity : AppCompatActivity() {
private val itemList = arrayListOf().apply {
for (i in 0..100) {
this.add(ItemModel(i))
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
recycler_view.layoutManager =
LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
recycler_view.adapter = RecyclerViewAdapter(this, itemList)
}
}
Итак, камера, мотор, и… Всё работает? Шторка выезжает и сворачивается, NestedScrollView крутится, в чём подвох? Перелистываем recycler на другой item, пробуем скроллить… Не работает… Прокрутим ещё на несколько item-ов дальше, снова работает, и так продолжается с точной периодичностью в несколько item-ов. Кто знаком с идеей работы RecyclerView, уже знает в чём дело — дело в грязных View, которые Recycler переиспользует, чтобы экономить ресурсы смартфона. Но это не совсем то, из-за чего может не скроллится NestedScrollView, начинаем расследование.
Первое что, пришло мне в голову — посмотреть какие события приходят в рабочем item-е и в нерабочем. Попробуем отлавливать события onScrollChange:
scrollView.setOnScrollChangeListener { v: NestedScrollView?, scrollX: Int, scrollY: Int, oldScrollX: Int, oldScrollY: Int ->
Log.i("TAG", "OnScrollChange")
}
В первом item всё ок, а вот в остальных тишина… Тогда пробуем, кое-что поинтереснее, а именно перехватывать касания:
scrollView.setOnTouchListener { view, motionEvent ->
Log.i("TAG", motionEvent.toString())
false
}
Картина в логах поинтереснее, мы видим, что события касаний логируются, как в рабочем, так и в нерабочем примере, однако если повнимательнее посмотреть, то можно увидеть, что в рабочем примере событий больше, чем в нерабочем, следовательно можно придти к выводу, что кто-то или что-то перехватывает наши события, которые так нужны, чтобы сделать скролл.
Как мы знаем, все события поступают не сразу в дочернюю вьюху, а сначала проходят через все родительские. Что от нас требуется? Найти ту вьюху, в которой появляются пропадающие в NestedScrollView события OnTouch. Пробуем слушать события в корневой вьюхе item-а:
itemView.setOnTouchListener { view, motionEvent ->
Log.i("TAG", motionEvent.toString())
false
}
Без изменений. Идём выше, пробуем перехватить в самом recycler, картина не меняется, однозначно приходят не все события. Проверяем самого главного парня: CoordinatorLayout, и тут есть пробитие — события приходящие из работающего item-а, такие же как из неработающего.
Но как известно, сам по себе CoordinatorLayout, мало чего умеет, он может только отлавливать поведение дочерних компонентов, нет поведения — CoordinatorLayout, грубо говоря, равен FrameLayout. И тут мы вспоминаем, что как раз назначили RecyclerView поведение из библиотеки material — BottomSheetBehavior. Следовало быть, он тот, кого мы и искали.
Что из себя представляет BottomSheetBehavior в двух словах — java класс, наследуемый от CoordinatorLayout.Behavior, предоставляющий инструкции по поведению дочерней вьюхи внутри родительской (CoordinatorLayout). Чтобы разобраться, что и как вызывается, было бы неплохо залогировать все методы этого класса. Как это сделать? Наследуем от BottomSheetBehavior собственный класс TestBehavior, переопределяем, все методы, оставляя внутри логи и вызовы методов супер-класса. Подставляем в RecyclerView новое поведение.
TestBehavior.java
import android.content.Context;
import android.os.Parcelable;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
public class TestBehavior extends BottomSheetBehavior {
public TestBehavior(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onInterceptTouchEvent(@NonNull CoordinatorLayout parent, @NonNull V child, @NonNull MotionEvent event) {
Log.i("TAG", "onInterceptTouchEvent");
return super.onInterceptTouchEvent(parent, child, event);
}
@Override
public void onRestoreInstanceState(
@NonNull CoordinatorLayout parent, @NonNull V child, @NonNull Parcelable state) {
Log.i("TAG", "onRestoreInstanceState");
super.onRestoreInstanceState(parent, child, state);
}
@Override
public void onAttachedToLayoutParams(@NonNull CoordinatorLayout.LayoutParams layoutParams) {
Log.i("TAG", "onAttachedToLayoutParams");
super.onAttachedToLayoutParams(layoutParams);
}
@Override
public void onDetachedFromLayoutParams() {
Log.i("TAG", "onDetachedFromLayoutParams");
super.onDetachedFromLayoutParams();
}
@Override
public boolean onLayoutChild(
@NonNull CoordinatorLayout parent, @NonNull V child, int layoutDirection) {
Log.i("TAG", "onLayoutChild");
return super.onLayoutChild(parent, child, layoutDirection);
}
@Override
public boolean onTouchEvent(
@NonNull CoordinatorLayout parent, @NonNull V child, @NonNull MotionEvent event) {
Log.i("TAG", "onTouchEvent " + event.toString());
return super.onTouchEvent(parent, child, event);
}
@Override
public boolean onStartNestedScroll(
@NonNull CoordinatorLayout coordinatorLayout,
@NonNull V child,
@NonNull View directTargetChild,
@NonNull View target,
int axes,
int type) {
Log.i("TAG", "onStartNestedScroll");
return super.onStartNestedScroll(coordinatorLayout, child, directTargetChild, target, axes, type);
}
@Override
public void onNestedPreScroll(
@NonNull CoordinatorLayout coordinatorLayout,
@NonNull V child,
@NonNull View target,
int dx,
int dy,
@NonNull int[] consumed,
int type) {
Log.i("TAG", "onNestedPreScroll");
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type);
}
@Override
public void onStopNestedScroll(
@NonNull CoordinatorLayout coordinatorLayout,
@NonNull V child,
@NonNull View target,
int type) {
Log.i("TAG", "onStopNestedScroll");
super.onStopNestedScroll(coordinatorLayout, child, target, type);
}
@Override
public void onNestedScroll(
@NonNull CoordinatorLayout coordinatorLayout,
@NonNull V child,
@NonNull View target,
int dxConsumed,
int dyConsumed,
int dxUnconsumed,
int dyUnconsumed,
int type,
@NonNull int[] consumed) {
Log.i("TAG", "onNestedScroll");
super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed);
}
@Override
public boolean onNestedPreFling(
@NonNull CoordinatorLayout coordinatorLayout,
@NonNull V child,
@NonNull View target,
float velocityX,
float velocityY) {
Log.i("TAG", "onNestedPreFling");
return super.onNestedPreFling(coordinatorLayout, child, target, velocityX, velocityY);
}
}
Запускаем и смотрим логи. В рабочем item-е приходят следующие события: onInterceptTouchEvent, onStartNestedScroll, onNestedPreScroll, onNestedScroll, onStopNestedScroll. В нерабочем: onInterceptTouchEvent, onStartNestedScroll, onNestedPreScroll, onStopNestedScroll. Судя по всему, именно в методе onNestedPreScroll происходит заглушение событий. Переходим к методу onNestedPreScroll в классе BottomSheetBehavior и ставим точку остановки, запускаем отладчик, и видим, что условие при котором происходит выход из метода:
View scrollingChild = nestedScrollingChildRef != null ? nestedScrollingChildRef.get() : null;
if (target != scrollingChild) {
return;
}
Итак, что за переменная nestedScrollingChildRef? Слабая ссылка на дочерний View, соответственно в условии осуществляется проверка на соответствие View, которым управляет CoordinatorLayout и View, для которого поступило событие.
@Nullable WeakReference nestedScrollingChildRef;
Смотрим инициализацию и понимаем, что объект инициализируется только при единственном вызове метода onLayoutChild:
@Override
public boolean onLayoutChild(
@NonNull CoordinatorLayout parent, @NonNull V child, int layoutDirection) {
...
nestedScrollingChildRef = new WeakReference<>(findScrollingChild(child));
return true;
}
И тут вспоминаем про Recycler, про то, что при каждом пролистывании обновляется просматриваемый item, следственно отображается другое View, а ссылка внутри BottomSheetBehavior, все ещё ссылается на первую вьюху, именно поэтому, когда первая View переиспользуется, то скролл происходит, когда используется другая — нет. Причина найдена, пробуем исправить проблему.
Что нужно, чтобы всё начало работать? Нужно как-то обновлять ссылку на текущую view в Recycler-е, например сделать метод, который будет обновлять значение ссылки, но так как BottomSheetBehavior это класс, который недоступен для редактирования и поле является приватным, у нас есть два выбора: написать собственную реализацию этого класса (например: скопировав полностью класс, добавив свой метод) или использовать Java Reflection API. Оба варианта выглядят неочень, но выкручиваться надо.
Java Reflection API
Решение с Java Reflection API, через того же наследника TestBeahvior, получаем приватное поле в Runtime и меняем его значение, единственное, что надо учесть, менять поле лучше всего в методе onStartNestedScroll, перед проверкой в onNestedPreScroll.
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
import java.lang.ref.WeakReference;
import java.lang.reflect.Field;
public class TestBeahvior extends BottomSheetBehavior {
public TestBeahvior(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onStartNestedScroll(
@NonNull CoordinatorLayout coordinatorLayout,
@NonNull V child,
@NonNull View directTargetChild,
@NonNull View target,
int axes,
int type) {
try {
Field nestedScrollingChildRefField = this.getClass().getSuperclass().getDeclaredField("nestedScrollingChildRef");
nestedScrollingChildRefField.setAccessible(true);
nestedScrollingChildRefField.set(this, new WeakReference<>(target));
} catch (NoSuchFieldException | IllegalAccessException e) {
e.printStackTrace();
}
return super.onStartNestedScroll(coordinatorLayout, child, directTargetChild, target, axes, type);
}
}
Копипаст
Тут всё просто, копируем класс BottomSheetBehavior, дописываем private метод refreshNestedScrollingChildRef, который будет обновлять содержимое объекта ссылки, вызываем этот метод в методе onStartNestedScroll. Готово, всё работает.
private void refreshNestedScrollingChildRef(View view) {
nestedScrollingChildRef = new WeakReference<>(view);
}
@Override
public boolean onStartNestedScroll(
@NonNull CoordinatorLayout coordinatorLayout,
@NonNull V child,
@NonNull View directTargetChild,
@NonNull View target,
int axes,
int type) {
refreshNestedScrollingChildRef(target);
...
}
Разработчики системы Android предоставляют множество классных и хорошо работающих инструментов, однако это не значит, что все они будут работать идеально. Всегда можно найти кейс при котором, стандартный инструмент не будет работать, как и в моём случае.
Всем интересных кейсов!