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

в 16:16, , рубрики: android development, Android sdk, android studio, behavior, bottom sheet behavior, coordinator layout, nested recyclerview, recyclerview, scrollview, view, Разработка под android

Наверное, каждый, любуясь красивыми и магически плавно съезжающими в разные стороны окошками, тулбарами и остальными вьюхами, задумывался как это работает, наверное, даже что-то читал про 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://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js