Hope on the SDK and don’t go wrong: The problem of nested scrolls in BottomSheetBehavior

Probably everyone, admiring the beautiful windows, toolbars and other views magically smoothly moving in different directions, thought about how it works, probably even read something about CoordianatorLayout, about various Behaivor, which allow you to create literally magic on android views. Of course, you can write custom views with the desired behavior, which can be limited only by your imagination or your knowledge in Android development. But besides this, there is another limitation - time, you won’t write custom views on a hackathon, in a time-bound project, and decisions written in advance with custom views can not be mastered by team members in no short time. It is then that the simplest and most logical solution to the problem comes - do not show off, use standard Android tools,smart guys are sitting there, everything will be in chocolate (well, or in other android sweets).


But not everything is so simple, this is where my close acquaintance with the magic of such guys as CoordinatorLayout, BottomSheetBehavior begins, or rather with a bug that developers overlooked when they wrote them. The article will describe the process of identifying a bug related to nested scrolling inside view components with the behavior of BottomSheetBehavior, as well as ways to solve it.


Coordinator Layout Animation


First meeting


My task was to make simple functionality with a nested scroll, consisting of a horizontal RecyclerView, ScrollView and CoordinatorLayout, it is he who allows, guided by Behavior's nested views, to perform such aerobatics as smooth arbitrary shifts of various views.


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);
    }
}


Everything is simple here, copy the BottomSheetBehavior class, add the private method refreshNestedScrollingChildRef, which will update the contents of the link object, call this method in the onStartNestedScroll method. Done, everything works.


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);
        ...
    }

Conclusion


Android developers provide many cool and well-functioning tools, but that does not mean that they will all work perfectly. You can always find a case in which a standard tool will not work, as in my case.


All interesting cases!

Source: https://habr.com/ru/post/undefined/


All Articles