material-components / material-components-android

Modular and customizable Material Design UI components for Android
Apache License 2.0
16.37k stars 3.07k forks source link

[CoordinatorLayout] HeaderScrollingViewBehavior measure child spec overflow #2559

Open kyze8439690 opened 2 years ago

kyze8439690 commented 2 years ago

Description: When CoordinatorLayout's height is very small, for example 86dp, and headerHeight in link is larger than CoordinatorLayout's height(like 90dp), it will make height in link a negative value(90dp - 86dp = -4dp). heightMeasureSpec in link will be overflow and make second child in CoordinatorLayout measure incorrectly.

If seond child view is a RecyclerView or ListView with many item in it, it will make RecyclerView onMeasure spends lots of time. Because measureSpec overflow will provide a huge height measure size, and make RecylerView layout all item in it's adapter.

Expected behavior: height should not be negative, or heightMeasureSpec 's size should not be overflow Source code: https://github.com/material-components/material-components-android/blob/master/lib/java/com/google/android/material/appbar/HeaderScrollingViewBehavior.java#L89

Android API version: Android 11

Material Library version: 1.5.0

Device: Xiaomi Civi

To help us triage faster, please check to make sure you are using the latest version of the library.

We also happily accept pull requests.

kyze8439690 commented 2 years ago

Why CoordinatorLayout will be very small? Because I am using Activity shared element transtion. When playing sharedElementReturnTransition, ActivityTransitionCoordinator will use the final size to measure current layout, in some scenario, final size will be very small, which make CoordinatorLayout's measure by a small size spec

https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/app/ActivityTransitionCoordinator.java#562

kyze8439690 commented 2 years ago

https://android.googlesource.com/platform/frameworks/base/+/946d05b%5E!/

kyze8439690 commented 2 years ago

Create a new ScrollingViewBehavior and seems can be used to solve the issue above:

class ScrollingViewBehavior(context: Context, attrs: AttributeSet):
    AppBarLayout.ScrollingViewBehavior(context, attrs) {

    @SuppressLint("RestrictedApi")
    override fun onMeasureChild(
        parent: CoordinatorLayout, child: View, parentWidthMeasureSpec: Int,
        widthUsed: Int, parentHeightMeasureSpec: Int, heightUsed: Int
    ): Boolean {
        val childLpHeight = child.layoutParams.height
        if (childLpHeight == ViewGroup.LayoutParams.MATCH_PARENT
            || childLpHeight == ViewGroup.LayoutParams.WRAP_CONTENT
        ) {
            // If the menu's height is set to match_parent/wrap_content then measure it
            // with the maximum visible height
            val dependencies = parent.getDependencies(child)
            val header: View? = dependencies.find { it is AppBarLayout }
            if (header != null) {
                var availableHeight = View.MeasureSpec.getSize(parentHeightMeasureSpec)
                if (availableHeight > 0) {
                    if (ViewCompat.getFitsSystemWindows(header)) {
                        val parentInsets = parent.lastWindowInsets
                        if (parentInsets != null) {
                            availableHeight += (parentInsets.systemWindowInsetTop
                                    + parentInsets.systemWindowInsetBottom)
                        }
                    }
                } else {
                    // If the measure spec doesn't specify a size, use the current height
                    availableHeight = parent.height
                }
                val scrollRange = if (header is AppBarLayout) {
                    header.totalScrollRange
                } else {
                    header.measuredHeight
                }
                var height = availableHeight + scrollRange
                val headerHeight = header.measuredHeight
                if (shouldHeaderOverlapScrollingChild()) {
                    child.translationY = -headerHeight.toFloat()
                } else {
                    height -= headerHeight
                }
                if (height < 0) height = availableHeight // add this line to solve measure overflow issue
                val heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(
                    height,
                    if (childLpHeight == ViewGroup.LayoutParams.MATCH_PARENT) View.MeasureSpec.EXACTLY else View.MeasureSpec.AT_MOST
                )

                // Now measure the scrolling view with the correct height
                parent.onMeasureChild(
                    child, parentWidthMeasureSpec, widthUsed, heightMeasureSpec, heightUsed
                )
                return true
            }
        }
        return false
    }
}