На SDK надейся и сам не плошай: Проблема вложенных скроллов в BottomSheetBehavior

от автора

Наверное, каждый, любуясь красивыми и магически плавно съезжающими в разные стороны окошками, тулбарами и остальными вьюхами, задумывался как это работает, наверное, даже что-то читал про CoordianatorLayout, про различные Behaivor, которые позволяют создавать буквально волшебство на андроидовских вьюхах. Конечно, можно писать кастомные вьюхи, с нужным поведением, которое может быть ограничено только твоим воображением или твоими знаниями в Android разработке. Но помимо этого, есть ещё одно ограничение — время, не будешь же ты писать кастомную вьюху на хакатоне, в горящем по срокам проекте, а написанные заранее решения с кастомными вьюхами могут быть освоены не за самый короткий срок членами команды. Именно тогда приходит самое простое и самое логичное решение проблемы — не выпендриваться, использовать стандартные инструменты Android, там же смышлённые ребята сидят, всё будет в шоколаде (ну или в других сладостях андроида).

Но не всё так просто, тут и начинается моё близкое знакомство с магией таких ребят, как CoordinatorLayout, BottomSheetBehavior, а точнее с багом, который выпустили из виду разработчики, когда их писали. В статье будет описан процесс выявления бага, связанного с вложенной прокруткой внутри view-компонентов с поведением BottomSheetBehavior, а также способы его решения.

Анимация CoordinatorLayout

Первая встреча

Передо мной стояла задача сделать несложный функционал с вложенной прокруткой, состоящей из горизонтального RecyclerView, ScrollView и CoordinatorLayout, именно он позволяет, ориентируясь на Behavior-ы вложенных вьюх, вытворять такие фигуры высшего пилотажа, как плавные произвольные сдвиги различных вью.

Purpose animation

Разложим по полочкам: делаем корневой CoordinatorLayout, внутри которого, находится RecyclerView, с прописанным поведением из библиотеки android.material BottomSheetBehavior, что позволит нашему ресайклеру, выезжать снизу корневого вью или обратно туда сворачиваться. Внутри item-ов каждого, находится TextView — заголовок item-а, и NestedScrollView, с различным наполнением (допустим тот же TextView).

NestedScrollViewMeme

activity_main.xml

<?xml version="1.0" encoding="utf-8"?> <androidx.coordinatorlayout.widget.CoordinatorLayout     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:id="@+id/main_layout"     android:layout_width="match_parent"     android:layout_height="match_parent"     tools:context=".MainActivity">      <androidx.recyclerview.widget.RecyclerView         android:id="@+id/recycler_view"         android:layout_width="match_parent"         android:layout_height="match_parent"         android:nestedScrollingEnabled="false"         app:behavior_hideable="false"         app:behavior_peekHeight="80dp"         app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" /> </androidx.coordinatorlayout.widget.CoordinatorLayout>

recycler_item.xml

<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"     android:id="@+id/item_bottom_sheet"     android:layout_width="match_parent"     android:layout_height="wrap_content"     android:background="?attr/colorControlHighlight"     android:orientation="vertical">      <TextView         android:layout_width="match_parent"         android:layout_height="80dp"         android:background="@color/colorAccent"         android:text="@string/head_text"         android:textAlignment="center"         android:textColor="@android:color/white"         android:textSize="24sp" />      <androidx.core.widget.NestedScrollView         android:id="@+id/scroll_view"         android:layout_width="match_parent"         android:layout_height="match_parent">         <TextView             android:id="@+id/item_text_view"             android:layout_width="match_parent"             android:layout_height="wrap_content"             android:textAlignment="center"             android:textSize="24sp" />     </androidx.core.widget.NestedScrollView> </LinearLayout>

Почему 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<ItemModel>) :     RecyclerView.Adapter<RecyclerViewHolder>() {      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<ItemModel>().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 }

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

Ищем перехватчика

InterceptMeme

Как мы знаем, все события поступают не сразу в дочернюю вьюху, а сначала проходят через все родительские. Что от нас требуется? Найти ту вьюху, в которой появляются пропадающие в NestedScrollView события OnTouch. Пробуем слушать события в корневой вьюхе item-а:

itemView.setOnTouchListener { view, motionEvent ->     Log.i("TAG", motionEvent.toString())     false }

Без изменений. Идём выше, пробуем перехватить в самом recycler, картина не меняется, однозначно приходят не все события. Проверяем самого главного парня: CoordinatorLayout, и тут есть пробитие — события приходящие из работающего item-а, такие же как из неработающего.

Но как известно, сам по себе CoordinatorLayout, мало чего умеет, он может только отлавливать поведение дочерних компонентов, нет поведения — CoordinatorLayout, грубо говоря, равен FrameLayout. И тут мы вспоминаем, что как раз назначили RecyclerView поведение из библиотеки material — BottomSheetBehavior. Следовало быть, он тот, кого мы и искали.

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<V extends View> extends BottomSheetBehavior<V> {      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);     } }

    <androidx.recyclerview.widget.RecyclerView         android:id="@+id/recycler_view"         android:layout_width="match_parent"         android:layout_height="match_parent"         android:nestedScrollingEnabled="false"         app:behavior_hideable="false"         app:behavior_peekHeight="80dp"         app:layout_behavior=".TestBehavior" />

Запускаем и смотрим логи. В рабочем 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<View> 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

ReflectionMeme

Решение с 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<V extends View> extends BottomSheetBehavior<V> {      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 предоставляют множество классных и хорошо работающих инструментов, однако это не значит, что все они будут работать идеально. Всегда можно найти кейс при котором, стандартный инструмент не будет работать, как и в моём случае.

Всем интересных кейсов!

ссылка на оригинал статьи https://habr.com/ru/post/487650/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *