material-components / material-components-android

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

[BottomSheet] Add way to adjust content to avoid overlap with a bottom sheet #2865

Closed OxygenCobalt closed 2 years ago

OxygenCobalt commented 2 years ago

Is your feature request related to a problem? Please describe. My app has a "content" view and a bottom sheet. The content view should adapt it's size and window insets depending on the presence of a bottom sheet. However, when I try to use BottomSheetBehavior, the bottom sheet ends up overlapping with the content. This prevents me from leveraging the behavior in my app, and instead forces me to make my own layout implementation.

Describe the solution you'd like Something akin to AppBarLayout.ScrollingViewBehavior, however with a bottom sheet. When the bottom sheet is hidden, the view with the behavior should resize to take up the whole view and apply window insets. When the bottom sheet is collapsed, the view should resize to take up the portion of the view not already taken up by the collapsed bottom sheet and not apply window insets. If possible, I would also want this to occur as the bottom sheet is sliding, rather than just during a state change.

Describe alternatives you've considered I tried to hack together a layout that did my expected behavior, as I cannot wrap my head around the CoordinatorLayout Behavior system.

class BottomSheetAwareFrameLayout
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
    FrameLayout(context, attrs, defStyleAttr) {
    private var lastInsets: WindowInsets? = null
    private lateinit var content: View
    private var sheet: View? = null

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        applyContentWindowInsets()
    }

    override fun onFinishInflate() {
        super.onFinishInflate()
        check(childCount == 1)
        content = getChildAt(0)
    }

    override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
        lastInsets = insets
        applyContentWindowInsets()
        return insets
    }

    private fun applyContentWindowInsets() {
        val insets = lastInsets
        if (insets != null) {
            content.dispatchApplyWindowInsets(adjustInsets(insets))
        }
    }

    private fun adjustInsets(insets: WindowInsets): WindowInsets {
        val sheet = getBottomSheet()
        val behavior = (sheet.layoutParams as CoordinatorLayout.LayoutParams).behavior as BottomSheetBehavior
        val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())

        val consumedWhenCollapsed = behavior.peekHeight + bars.bottom
        val adjustedBottomInset = min(max(bars.bottom, measuredHeight - sheet.top), consumedWhenCollapsed)

        return insets.replaceSystemBarInsetsCompat(
                bars.left, bars.top, bars.right, adjustedBottomInset)
    }

    private fun getBottomSheet(): View {
        val currentSheet = sheet
        if (currentSheet != null) {
            return currentSheet
        }

        for (child in (parent as ViewGroup).children) {
            val behavior = (child.layoutParams as CoordinatorLayout.LayoutParams).behavior
            if (behavior is BottomSheetBehavior) {
                behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
                    override fun onSlide(bottomSheet: View, slideOffset: Float) {
                        applyContentWindowInsets()
                    }

                    override fun onStateChanged(bottomSheet: View, newState: Int) {

                    }
                })
                sheet = child
                return child
            }
        }

        error("No bottom sheet found in this layout")
    }
}

fun WindowInsets.replaceSystemBarInsetsCompat(left: Int, top: Int, right: Int, bottom: Int) =
    requireNotNull(
        WindowInsetsCompat.Builder(WindowInsetsCompat.toWindowInsetsCompat(this))
            .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.of(left, top, right, bottom))
            .build()
            .toWindowInsets())

But apparently the height of some views can be zero at the point where I need to apply window insets, so this whole system just doesn't work at all. It also causes variously scrolling issues, as I'm not resizing the view as I am applying padding to it after the initial layout.

drchen commented 2 years ago

You can implement your own [CoordinatorLayout.Behavior](https://developer.android.com/reference/androidx/coordinatorlayout/widget/CoordinatorLayout.Behavior) and set it to your content view in the XML.

You should be able to override [layoutDependsOn](https://developer.android.com/reference/androidx/coordinatorlayout/widget/CoordinatorLayout.Behavior#layoutDependsOn(androidx.coordinatorlayout.widget.CoordinatorLayout,%20V,%20android.view.View)) method to make your content view depends on the bottom sheet.

I'm not super familiar with how CoordinatorLayout is working so I suggest you google it. There should be several good articles on the Web. : )

OxygenCobalt commented 2 years ago

Okay, after some trial and error I was able to create a CoordinatorLayout.Behavior that does what I want properly. Note that I am relying on an extension value called "offset" that returns the current slide offset of the bottom sheet.

Just putting this here for anyone to use. Probably not the best implementation efficiency-wise, although that's because I threw it together in a few hours.

import android.content.Context
import android.util.AttributeSet
import android.util.Log
import android.view.View
import android.view.WindowInsets
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.WindowInsetsCompat
import com.google.android.material.bottomsheet.BottomSheetBehavior
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min

class BottomSheetViewBehavior<V: View>(context: Context, attributeSet: AttributeSet?) :
    CoordinatorLayout.Behavior<V>(context, attributeSet) {
    private var lastInsets: WindowInsets? = null
    private var dep: View? = null
    private var setup: Boolean = false

    override fun layoutDependsOn(parent: CoordinatorLayout, child: V, dependency: View): Boolean {
        if ((dependency.layoutParams as CoordinatorLayout.LayoutParams).behavior is BottomSheetBehavior) {
            dep = dependency
            return true
        }

        return false
    }

    override fun onMeasureChild(
        parent: CoordinatorLayout,
        child: V,
        parentWidthMeasureSpec: Int,
        widthUsed: Int,
        parentHeightMeasureSpec: Int,
        heightUsed: Int
    ): Boolean {
        return measureContent(parent, child, dep ?: return false)
    }

    override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean {
        super.onLayoutChild(parent, child, layoutDirection)
        child.layout(0, 0, child.measuredWidth, child.measuredHeight)

        if (!setup) {
            child.setOnApplyWindowInsetsListener { _, insets ->
                lastInsets = insets

                val dep = dep ?: return@setOnApplyWindowInsetsListener insets

                val bars = insets.systemWindowInsets
                val behavior = (dep.layoutParams as CoordinatorLayout.LayoutParams).behavior
                    as BottomSheetBehavior

                if (behavior.peekHeight < 0 || behavior.offset == Float.MIN_VALUE) {
                    return@setOnApplyWindowInsetsListener insets
                }

                val adjustedBottomInset = (bars.bottom - behavior.consumedByBar).coerceAtLeast(0)

                insets.replaceSystemWindowInsets(
                    bars.left, bars.top, bars.right, adjustedBottomInset)
            }

            setup = true
        }

        return true
    }

    private fun measureContent(parent: View, child: View, dep: View): Boolean {
        val behavior = (dep.layoutParams as CoordinatorLayout.LayoutParams).behavior
            as BottomSheetBehavior

        if (behavior.peekHeight < 0 || behavior.offset == Float.MIN_VALUE) {
            return false
        }

        val contentWidthSpec = View.MeasureSpec.makeMeasureSpec(parent.measuredWidth, View.MeasureSpec.EXACTLY)
        val contentHeightSpec =
            View.MeasureSpec.makeMeasureSpec(parent.measuredHeight - behavior.consumedByBar, View.MeasureSpec.EXACTLY)

        child.measure(contentWidthSpec, contentHeightSpec)

        return true
    }

    private val BottomSheetBehavior<*>.consumedByBar: Int
        get() = if (offset >= 0) {
            peekHeight
        } else {
            (peekHeight * (1 - abs(offset))).toInt()
        }

    override fun onDependentViewChanged(
        parent: CoordinatorLayout,
        child: V,
        dependency: View
    ): Boolean {
        lastInsets?.let(child::dispatchApplyWindowInsets)
        return measureContent(parent, child, dependency) &&
            onLayoutChild(parent, child, parent.layoutDirection)
    }
}