playback: use bottomsheetbehavior
Use BottomSheetBehavior with the playback sheet. This is the result of two weeks of painful hacking to get a working implementation that did not immediately have a brain aneursym. It also requires me to still vendor BottomSheetBehavior for the time being. However, this greatly reduces technical issues on my end and allows the addition of new playback UI concepts, while still retaining the UI fluidity of prior.
This commit is contained in:
parent
62ae5e5cb2
commit
cc3cb343b0
12 changed files with 2560 additions and 745 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -15,3 +15,6 @@ captures/
|
|||
.externalNativeBuild
|
||||
*.iml
|
||||
.cxx
|
||||
|
||||
# Patched material
|
||||
app/src/main/com/google/android/material
|
||||
|
|
|
@ -26,6 +26,7 @@ at the cost of longer loading times
|
|||
#### What's Changed
|
||||
- Play and skip icons are filled again
|
||||
- Updated music hashing (Will wipe playback state)
|
||||
- Migrated to BottomSheetBehavior
|
||||
|
||||
## 2.5.0
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -17,18 +17,23 @@
|
|||
|
||||
package org.oxycblt.auxio
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.bottomsheet.NeoBottomSheetBehavior
|
||||
import com.google.android.material.transition.MaterialFadeThrough
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import org.oxycblt.auxio.databinding.FragmentMainBinding
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackSheetBehavior
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.ui.MainNavigationAction
|
||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||
|
@ -36,6 +41,7 @@ import org.oxycblt.auxio.ui.fragment.ViewBindingFragment
|
|||
import org.oxycblt.auxio.util.androidActivityViewModels
|
||||
import org.oxycblt.auxio.util.collect
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.coordinatorLayoutBehavior
|
||||
|
||||
/**
|
||||
* A wrapper around the home fragment that shows the playback fragment and controls the more
|
||||
|
@ -46,6 +52,7 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
|
|||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||
private val navModel: NavigationViewModel by activityViewModels()
|
||||
private var callback: DynamicBackPressedCallback? = null
|
||||
private var lastInsets: WindowInsets? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -61,19 +68,25 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
|
|||
.onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner, DynamicBackPressedCallback().also { callback = it })
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
// Auxio's layout completely breaks down when it's window is resized too small,
|
||||
// but for some insane reason google decided to cripple the window APIs one could use
|
||||
// to limit it's size. So, we just have our own special layout that is shown whenever
|
||||
// the screen is too small because of course we have to.
|
||||
if (requireActivity().isInMultiWindowMode) {
|
||||
val config = resources.configuration
|
||||
if (config.screenHeightDp < 250 || config.screenWidthDp < 250) {
|
||||
binding.layoutTooSmall.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
binding.root.setOnApplyWindowInsetsListener { v, insets ->
|
||||
lastInsets = insets
|
||||
insets
|
||||
}
|
||||
|
||||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
|
||||
|
||||
playbackSheetBehavior.addBottomSheetCallback(
|
||||
object : NeoBottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {
|
||||
handleSheetTransitions()
|
||||
}
|
||||
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {}
|
||||
})
|
||||
|
||||
binding.root.post { handleSheetTransitions() }
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
|
||||
collect(navModel.mainNavigationAction, ::handleMainNavigation)
|
||||
|
@ -91,13 +104,51 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
|
|||
callback?.isEnabled = false
|
||||
}
|
||||
|
||||
private fun handleSheetTransitions() {
|
||||
val binding = requireBinding()
|
||||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
|
||||
|
||||
val playbackRatio = max(playbackSheetBehavior.calculateSlideOffset(), 0f)
|
||||
val queueRatio = 0f
|
||||
|
||||
val outRatio = 1 - playbackRatio
|
||||
val halfOutRatio = min(playbackRatio * 2, 1f)
|
||||
val halfInPlaybackRatio = max(playbackRatio - 0.5f, 0f) * 2
|
||||
val halfOutQueueRatio = min(queueRatio * 2, 1f)
|
||||
val halfInQueueRatio = max(queueRatio - 0.5f, 0f) * 2
|
||||
|
||||
playbackSheetBehavior.sheetBackgroundDrawable.alpha = (outRatio * 255).toInt()
|
||||
binding.playbackSheet.translationZ = 3f * outRatio
|
||||
binding.playbackPanelFragment.alpha = min(halfInPlaybackRatio, 1 - halfOutQueueRatio)
|
||||
// binding.queueRecycler.alpha = max(queueOffset, 0f)
|
||||
|
||||
binding.exploreNavHost.apply {
|
||||
alpha = outRatio
|
||||
isInvisible = alpha == 0f
|
||||
}
|
||||
|
||||
binding.playbackBarFragment.apply {
|
||||
alpha = max(1 - halfOutRatio, halfInQueueRatio)
|
||||
lastInsets?.let { translationY = it.systemWindowInsetTop * halfOutRatio }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMainNavigation(action: MainNavigationAction?) {
|
||||
if (action == null) return
|
||||
|
||||
val binding = requireBinding()
|
||||
when (action) {
|
||||
is MainNavigationAction.Expand -> binding.bottomSheetLayout.expand()
|
||||
is MainNavigationAction.Collapse -> binding.bottomSheetLayout.collapse()
|
||||
is MainNavigationAction.Expand -> {
|
||||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
|
||||
playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
is MainNavigationAction.Collapse -> {
|
||||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
|
||||
playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
is MainNavigationAction.Settings ->
|
||||
findNavController().navigate(MainFragmentDirections.actionShowSettings())
|
||||
is MainNavigationAction.About ->
|
||||
|
@ -112,18 +163,24 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
|
|||
|
||||
private fun handleExploreNavigation(item: Music?) {
|
||||
if (item != null) {
|
||||
requireBinding().bottomSheetLayout.collapse()
|
||||
val binding = requireBinding()
|
||||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
|
||||
|
||||
if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) {
|
||||
playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSong(song: Song?) {
|
||||
val binding = requireBinding()
|
||||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
|
||||
if (song != null) {
|
||||
binding.bottomSheetLayout.isDraggable = true
|
||||
binding.bottomSheetLayout.show()
|
||||
playbackSheetBehavior.unsideSafe()
|
||||
} else {
|
||||
binding.bottomSheetLayout.isDraggable = false
|
||||
binding.bottomSheetLayout.hide()
|
||||
playbackSheetBehavior.hideSafe()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -136,7 +193,11 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
|
|||
inner class DynamicBackPressedCallback : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
val binding = requireBinding()
|
||||
if (!binding.bottomSheetLayout.collapse()) {
|
||||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
|
||||
if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) {
|
||||
playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
|
||||
} else {
|
||||
val navController = binding.exploreNavHost.findNavController()
|
||||
|
||||
if (navController.currentDestination?.id ==
|
||||
|
|
|
@ -1,670 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowInsets
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.customview.widget.ViewDragHelper
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import java.lang.reflect.Field
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.util.disableDropShadowCompat
|
||||
import org.oxycblt.auxio.util.getAttrColorSafe
|
||||
import org.oxycblt.auxio.util.getDimenSafe
|
||||
import org.oxycblt.auxio.util.isUnder
|
||||
import org.oxycblt.auxio.util.lazyReflectedField
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.pxOfDp
|
||||
import org.oxycblt.auxio.util.replaceSystemBarInsetsCompat
|
||||
import org.oxycblt.auxio.util.stateList
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
|
||||
/**
|
||||
* A layout that *properly* handles bottom sheet functionality.
|
||||
*
|
||||
* BottomSheetBehavior has a multitude of shortcomings based that make it a non-starter for Auxio,
|
||||
* such as:
|
||||
* - God-awful edge-to-edge support
|
||||
* - Does not allow other content to adapt
|
||||
* - Extreme jank
|
||||
* - Terrible APIs that you have to use just to make the UX tolerable
|
||||
* - Inexplicable layout and measuring inconsistencies
|
||||
* - Reliance on CoordinatorLayout, which is just a terrible component in general and everyone
|
||||
* responsible for creating it should be publicly shamed
|
||||
*
|
||||
* So, I decided to make my own implementation. With blackjack, and referential humor.
|
||||
*
|
||||
* The actual internals of this view are based off of a blend of Hai Zhang's PersistentBarLayout and
|
||||
* Umano's SlidingUpPanelLayout, albeit heavily minified to remove extraneous use cases and updated
|
||||
* to support the latest SDK level and androidx tools.
|
||||
*
|
||||
* What is hilarious is that Google now hates CoordinatorLayout and it's behavior implementations as
|
||||
* much as I do. Just look at all the new boring layout implementations they are introducing like
|
||||
* SlidingPaneLayout. It's almost like re-inventing the layout process but buggier and without
|
||||
* access to other children in the ViewGroup was a bad idea. Whoa.
|
||||
*
|
||||
* **Note:** If you want to adapt this layout into your own app. Good luck. This layout has been
|
||||
* reduced to Auxio's use case in particular and is really hard to understand since it has a ton of
|
||||
* state and view magic. I tried my best to document it, but it's probably not the most friendly or
|
||||
* extendable. You have been warned.
|
||||
*
|
||||
* @author OxygenCobalt (With help from Umano and Hai Zhang)
|
||||
*/
|
||||
class BottomSheetLayout
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
|
||||
ViewGroup(context, attrs, defStyle) {
|
||||
private enum class State {
|
||||
EXPANDED,
|
||||
COLLAPSED,
|
||||
HIDDEN,
|
||||
DRAGGING
|
||||
}
|
||||
|
||||
// Core views [obtained when layout is inflated]
|
||||
private lateinit var contentView: View
|
||||
private lateinit var barView: View
|
||||
private lateinit var panelView: View
|
||||
|
||||
private val elevationNormal = context.getDimenSafe(R.dimen.elevation_normal)
|
||||
|
||||
// We have to define the background before the bottom sheet declaration as otherwise it wont
|
||||
// work
|
||||
private val sheetBackground =
|
||||
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
|
||||
fillColor = context.getAttrColorSafe(R.attr.colorSurface).stateList
|
||||
elevation = context.pxOfDp(elevationNormal).toFloat()
|
||||
}
|
||||
|
||||
private val sheetView =
|
||||
FrameLayout(context).apply {
|
||||
id = R.id.bottom_sheet_view
|
||||
|
||||
isClickable = true
|
||||
isFocusable = false
|
||||
isFocusableInTouchMode = false
|
||||
|
||||
// The way we fade out the elevation overlay is not by actually reducing the
|
||||
// elevation but by fading out the background drawable itself. To be safe,
|
||||
// we apply this background drawable to a layer list with another colorSurface
|
||||
// shape drawable, just in case weird things happen if background drawable is
|
||||
// completely transparent.
|
||||
val fallbackBackground =
|
||||
MaterialShapeDrawable().apply {
|
||||
fillColor = context.getAttrColorSafe(R.attr.colorSurface).stateList
|
||||
}
|
||||
|
||||
background = LayerDrawable(arrayOf(fallbackBackground, sheetBackground))
|
||||
|
||||
disableDropShadowCompat()
|
||||
}
|
||||
|
||||
/** The drag helper that animates and dispatches drag events to the bottom sheet. */
|
||||
private val dragHelper =
|
||||
ViewDragHelper.create(this, DragHelperCallback()).apply {
|
||||
minVelocity = MIN_FLING_VEL * resources.displayMetrics.density
|
||||
}
|
||||
|
||||
/**
|
||||
* The current window insets. Important since this layout must play a long with Auxio's
|
||||
* edge-to-edge functionality.
|
||||
*/
|
||||
private var lastInsets: WindowInsets? = null
|
||||
|
||||
/** The current bottom sheet state. Can be [State.DRAGGING] */
|
||||
private var state = INIT_SHEET_STATE
|
||||
|
||||
/** The last bottom sheet state before a drag event began. */
|
||||
private var lastIdleState = INIT_SHEET_STATE
|
||||
|
||||
/** The range of pixels that the bottom sheet can drag through */
|
||||
private var sheetRange = 0
|
||||
|
||||
/**
|
||||
* The relative offset of this bottom sheet as a percentage of [sheetRange]. A value of 1 means
|
||||
* a fully expanded sheet. A value of 0 means a collapsed sheet. A value below 0 means a hidden
|
||||
* sheet.
|
||||
*/
|
||||
private var sheetOffset = 0f
|
||||
|
||||
// Miscellaneous touch things
|
||||
private var initMotionX = 0f
|
||||
private var initMotionY = 0f
|
||||
private val tRect = Rect()
|
||||
|
||||
var isDraggable = false
|
||||
|
||||
init {
|
||||
setWillNotDraw(false)
|
||||
}
|
||||
|
||||
// / --- CONTROL METHODS ---
|
||||
|
||||
/**
|
||||
* Show the bottom sheet, only if it's hidden.
|
||||
* @return if the sheet was shown
|
||||
*/
|
||||
fun show(): Boolean {
|
||||
if (state == State.HIDDEN) {
|
||||
applyState(State.COLLAPSED)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the bottom sheet if it is currently collapsed.
|
||||
* @return If the sheet was expanded
|
||||
*/
|
||||
fun expand(): Boolean {
|
||||
if (state == State.COLLAPSED) {
|
||||
applyState(State.EXPANDED)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse the sheet if it is currently expanded.
|
||||
* @return If the sheet was collapsed
|
||||
*/
|
||||
fun collapse(): Boolean {
|
||||
if (state == State.EXPANDED) {
|
||||
applyState(State.COLLAPSED)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the sheet if it is not hidden.
|
||||
* @return If the sheet was hidden
|
||||
*/
|
||||
fun hide(): Boolean {
|
||||
if (state != State.HIDDEN) {
|
||||
applyState(State.HIDDEN)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun applyState(newState: State) {
|
||||
logD("Applying bottom sheet state $newState")
|
||||
|
||||
// Dragging events are really complex and we don't want to mess up the state
|
||||
// while we are in one.
|
||||
if (newState == this.state) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isLaidOut) {
|
||||
// Not laid out, just apply the state and let the measure + layout steps apply it for
|
||||
// us.
|
||||
setSheetStateInternal(newState)
|
||||
} else {
|
||||
// We are laid out. In this case we actually animate to our desired target.
|
||||
when (newState) {
|
||||
State.COLLAPSED -> smoothSlideTo(0f)
|
||||
State.EXPANDED -> smoothSlideTo(1.0f)
|
||||
State.HIDDEN -> smoothSlideTo(calculateSheetOffset(measuredHeight))
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFinishInflate() {
|
||||
super.onFinishInflate()
|
||||
|
||||
contentView = getChildAt(0) // Child 1 is assumed to be the content
|
||||
barView = getChildAt(1) // Child 2 is assumed to be the bar used when collapsed
|
||||
panelView = getChildAt(2) // Child 3 is assumed to be the panel used when expanded
|
||||
|
||||
// We actually move the bar and panel views into a container so that they have consistent
|
||||
// behavior when be manipulate layouts later.
|
||||
removeView(barView)
|
||||
removeView(panelView)
|
||||
|
||||
sheetView.apply {
|
||||
addView(
|
||||
barView,
|
||||
FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||
.apply { gravity = Gravity.TOP })
|
||||
|
||||
addView(
|
||||
panelView,
|
||||
FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
.apply { gravity = Gravity.CENTER })
|
||||
}
|
||||
|
||||
addView(sheetView)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
|
||||
// Sanity check. The last thing I want to deal with is this view being WRAP_CONTENT.
|
||||
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
|
||||
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
|
||||
|
||||
check(widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY) {
|
||||
"This view must be MATCH_PARENT"
|
||||
}
|
||||
|
||||
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
|
||||
setMeasuredDimension(widthSize, heightSize)
|
||||
|
||||
// First measure our actual bottom sheet. We need to do this first to determine our
|
||||
// range and offset values.
|
||||
val sheetWidthSpec = MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY)
|
||||
val sheetHeightSpec = MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY)
|
||||
sheetView.measure(sheetWidthSpec, sheetHeightSpec)
|
||||
|
||||
sheetRange = measuredHeight - barView.measuredHeight
|
||||
|
||||
if (!isLaidOut) {
|
||||
logD("Doing initial bottom sheet layout")
|
||||
|
||||
// This is our first layout, so make sure we know what offset we should work with
|
||||
// before we measure our content
|
||||
sheetOffset =
|
||||
when (state) {
|
||||
State.EXPANDED -> 1f
|
||||
State.HIDDEN -> calculateSheetOffset(measuredHeight)
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
updateBottomSheetTransition()
|
||||
}
|
||||
|
||||
applyContentWindowInsets()
|
||||
measureContent()
|
||||
}
|
||||
|
||||
private fun measureContent() {
|
||||
// We need to find out how much the panel should affect the view.
|
||||
// When the panel is in it's bar form, we shorten the content view. If it's being expanded,
|
||||
// we keep the same height and just overlay the panel.
|
||||
val barHeightAdjusted = measuredHeight - (calculateSheetTopPosition(min(sheetOffset, 0f)))
|
||||
|
||||
// Note that these views will always be a fixed MATCH_PARENT. This is intentional,
|
||||
// as it reduces the logic we have to deal with regarding WRAP_CONTENT views.
|
||||
val contentWidthSpec = MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY)
|
||||
val contentHeightSpec =
|
||||
MeasureSpec.makeMeasureSpec(measuredHeight - barHeightAdjusted, MeasureSpec.EXACTLY)
|
||||
|
||||
contentView.measure(contentWidthSpec, contentHeightSpec)
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
||||
// Figure out where our panel should be and lay it out there.
|
||||
val panelTop = calculateSheetTopPosition(sheetOffset)
|
||||
sheetView.layout(0, panelTop, sheetView.measuredWidth, sheetView.measuredHeight + panelTop)
|
||||
layoutContent()
|
||||
}
|
||||
|
||||
private fun layoutContent() {
|
||||
// We already did our magic while measuring. No need to do anything here.
|
||||
contentView.layout(0, 0, contentView.measuredWidth, contentView.measuredHeight)
|
||||
}
|
||||
|
||||
override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean {
|
||||
val save = canvas.save()
|
||||
|
||||
// Drawing views that are under the bottom sheet is inefficient, clip the canvas
|
||||
// so that doesn't occur.
|
||||
if (child == contentView) {
|
||||
canvas.getClipBounds(tRect)
|
||||
tRect.bottom = tRect.bottom.coerceAtMost(sheetView.top)
|
||||
canvas.clipRect(tRect)
|
||||
}
|
||||
|
||||
return super.drawChild(canvas, child, drawingTime).also { canvas.restoreToCount(save) }
|
||||
}
|
||||
|
||||
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
// One issue with handling a bottom bar with edge-to-edge is that if you want to
|
||||
// apply window insets to a view, those insets will cause incorrect spacing if the
|
||||
// bottom navigation is consumed by a bar. To fix this, we modify the bottom insets
|
||||
// to reflect the presence of the bottom sheet [at least in it's collapsed state]
|
||||
sheetView.dispatchApplyWindowInsets(insets)
|
||||
lastInsets = insets
|
||||
applyContentWindowInsets()
|
||||
return insets
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply window insets to the content views in this layouts. This is done separately as at times
|
||||
* we want to re-inset the content views but not re-inset the bar view.
|
||||
*/
|
||||
private fun applyContentWindowInsets() {
|
||||
val insets = lastInsets
|
||||
if (insets != null) {
|
||||
contentView.dispatchApplyWindowInsets(adjustInsets(insets))
|
||||
}
|
||||
}
|
||||
|
||||
/** Adjust window insets to line up with the bottom sheet */
|
||||
private fun adjustInsets(insets: WindowInsets): WindowInsets {
|
||||
// We kind of do a reverse-measure to figure out how we should inset this view.
|
||||
// Find how much space is lost by the panel and then combine that with the
|
||||
// bottom inset to find how much space we should apply.
|
||||
// There is a slight shortcoming to this. If the playback bar has a height of
|
||||
// zero (usually due to delays with fragment inflation), then it is assumed to
|
||||
// not apply any window insets at all, which results in scroll desynchronization on
|
||||
// certain views. This is considered tolerable as the other options are to convert
|
||||
// the playback fragments to views, which is not nice.
|
||||
val bars = insets.systemBarInsetsCompat
|
||||
val consumedByPanel = calculateSheetTopPosition(sheetOffset) - measuredHeight
|
||||
val adjustedBottomInset = (consumedByPanel + bars.bottom).coerceAtLeast(0)
|
||||
return insets.replaceSystemBarInsetsCompat(
|
||||
bars.left, bars.top, bars.right, adjustedBottomInset)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable =
|
||||
Bundle().apply {
|
||||
putParcelable("superState", super.onSaveInstanceState())
|
||||
putSerializable(
|
||||
KEY_SHEET_STATE,
|
||||
if (state != State.DRAGGING) {
|
||||
state
|
||||
} else {
|
||||
lastIdleState
|
||||
})
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedState: Parcelable) {
|
||||
if (savedState is Bundle) {
|
||||
this.state = savedState.getSerializable(KEY_SHEET_STATE) as? State ?: INIT_SHEET_STATE
|
||||
super.onRestoreInstanceState(savedState.getParcelable("superState"))
|
||||
} else {
|
||||
super.onRestoreInstanceState(savedState)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("Redundant")
|
||||
override fun performClick(): Boolean {
|
||||
return super.performClick()
|
||||
}
|
||||
|
||||
override fun onTouchEvent(ev: MotionEvent): Boolean {
|
||||
performClick()
|
||||
|
||||
return if (!isDraggable) {
|
||||
super.onTouchEvent(ev)
|
||||
} else
|
||||
try {
|
||||
dragHelper.processTouchEvent(ev)
|
||||
true
|
||||
} catch (ex: Exception) {
|
||||
// Ignore the pointer out of range exception
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
|
||||
if (!isDraggable) {
|
||||
return super.onInterceptTouchEvent(ev)
|
||||
}
|
||||
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
initMotionX = ev.x
|
||||
initMotionY = ev.y
|
||||
|
||||
if (!sheetView.isUnder(ev.x, ev.y)) {
|
||||
// Pointer is not on our view, do not intercept this event
|
||||
dragHelper.cancel()
|
||||
return false
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val adx = abs(ev.x - initMotionX)
|
||||
val ady = abs(ev.y - initMotionY)
|
||||
|
||||
val pointerUnder = sheetView.isUnder(ev.x, ev.y)
|
||||
val motionUnder = sheetView.isUnder(initMotionX, initMotionY)
|
||||
|
||||
if (!(pointerUnder || motionUnder) || ady > dragHelper.touchSlop && adx > ady) {
|
||||
// Pointer has moved beyond our control, do not intercept this event
|
||||
dragHelper.cancel()
|
||||
return false
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL,
|
||||
MotionEvent.ACTION_UP ->
|
||||
if (dragHelper.isDragging) {
|
||||
// Stopped pressing while we were dragging, let the drag helper handle it
|
||||
dragHelper.processTouchEvent(ev)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return dragHelper.shouldInterceptTouchEvent(ev)
|
||||
}
|
||||
|
||||
override fun computeScroll() {
|
||||
// Make sure that we continue settling as we scroll
|
||||
if (dragHelper.continueSettling(true)) {
|
||||
postInvalidateOnAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
private val ViewDragHelper.isDragging: Boolean
|
||||
get() {
|
||||
// We can't grab the drag state outside of a callback, but that's stupid and I don't
|
||||
// want to vendor ViewDragHelper so I just do reflection instead.
|
||||
val state =
|
||||
try {
|
||||
VIEW_DRAG_HELPER_STATE_FIELD.get(this)
|
||||
} catch (e: Exception) {
|
||||
ViewDragHelper.STATE_IDLE
|
||||
}
|
||||
|
||||
return state == ViewDragHelper.STATE_DRAGGING
|
||||
}
|
||||
|
||||
private fun setSheetStateInternal(newState: State) {
|
||||
if (this.state == newState) {
|
||||
return
|
||||
}
|
||||
|
||||
logD("New state: $newState")
|
||||
this.state = newState
|
||||
|
||||
// TODO: Improve accessibility by:
|
||||
// 1. Adding a (non-visible) handle. Material components now technically does have
|
||||
// this, but it relies on the busted BottomSheetBehavior.
|
||||
// 2. Adding the controls that BottomSheetBehavior defines
|
||||
sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Do the nice view animations that occur whenever we slide up the bottom sheet. The way we
|
||||
* transition is largely inspired by Android 12's notification panel, with the compact view
|
||||
* fading out completely before the panel view fades in.
|
||||
*/
|
||||
private fun updateBottomSheetTransition() {
|
||||
val ratio = max(sheetOffset, 0f)
|
||||
|
||||
val outRatio = 1 - ratio
|
||||
val halfOutRatio = min(ratio * 2, 1f)
|
||||
val halfInRatio = max(ratio - 0.5f, 0f) * 2
|
||||
|
||||
contentView.apply {
|
||||
alpha = outRatio
|
||||
isInvisible = alpha == 0f
|
||||
}
|
||||
|
||||
// Slowly reduce the elevation of the bottom sheet as we slide up, eventually resulting in a
|
||||
// neutral color instead of an elevated one when fully expanded.
|
||||
sheetBackground.alpha = (outRatio * 255).toInt()
|
||||
sheetView.translationZ = elevationNormal * outRatio
|
||||
|
||||
// Fade out our bar view as we slide up
|
||||
barView.apply {
|
||||
alpha = min(1 - halfOutRatio, 1f)
|
||||
isInvisible = alpha == 0f
|
||||
|
||||
// When edge-to-edge is enabled, we want to make the bar move along with the top
|
||||
// window insets as it goes upwards. Do this by progressively modifying the y
|
||||
// translation with a fraction of the said inset.
|
||||
lastInsets?.let { insets ->
|
||||
val bars = insets.systemBarInsetsCompat
|
||||
translationY = bars.top * halfOutRatio
|
||||
}
|
||||
}
|
||||
|
||||
// Fade in our panel as we slide up
|
||||
panelView.apply {
|
||||
alpha = halfInRatio
|
||||
isInvisible = alpha == 0f
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateSheetTopPosition(sheetOffset: Float): Int =
|
||||
measuredHeight - barView.measuredHeight - (sheetOffset * sheetRange).toInt()
|
||||
|
||||
private fun calculateSheetOffset(top: Int): Float =
|
||||
(calculateSheetTopPosition(0f) - top).toFloat() / sheetRange
|
||||
|
||||
private fun smoothSlideTo(offset: Float) {
|
||||
logD("Smooth sliding to $offset")
|
||||
|
||||
if (dragHelper.smoothSlideViewTo(
|
||||
sheetView, sheetView.left, calculateSheetTopPosition(offset))) {
|
||||
// Slide has started, begin animating
|
||||
postInvalidateOnAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class DragHelperCallback : ViewDragHelper.Callback() {
|
||||
// Only capture on a fully shown panel view
|
||||
override fun tryCaptureView(child: View, pointerId: Int) =
|
||||
child === sheetView && sheetOffset >= 0
|
||||
|
||||
override fun onViewDragStateChanged(dragState: Int) {
|
||||
when (dragState) {
|
||||
ViewDragHelper.STATE_DRAGGING -> {
|
||||
if (!isDraggable) {
|
||||
return
|
||||
}
|
||||
|
||||
// We're dragging, so we need to update our state accordingly
|
||||
if (this@BottomSheetLayout.state != State.DRAGGING) {
|
||||
lastIdleState = this@BottomSheetLayout.state
|
||||
}
|
||||
|
||||
setSheetStateInternal(State.DRAGGING)
|
||||
}
|
||||
ViewDragHelper.STATE_IDLE -> {
|
||||
sheetOffset = calculateSheetOffset(sheetView.top)
|
||||
|
||||
val newState =
|
||||
when {
|
||||
sheetOffset == 1f -> State.EXPANDED
|
||||
sheetOffset == 0f -> State.COLLAPSED
|
||||
sheetOffset < 0f -> State.HIDDEN
|
||||
else -> State.EXPANDED
|
||||
}
|
||||
|
||||
setSheetStateInternal(newState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCaptured(capturedChild: View, activePointerId: Int) {}
|
||||
|
||||
override fun onViewPositionChanged(
|
||||
changedView: View,
|
||||
left: Int,
|
||||
top: Int,
|
||||
dx: Int,
|
||||
dy: Int
|
||||
) {
|
||||
// Update our sheet offset using the new top value
|
||||
sheetOffset = calculateSheetOffset(top)
|
||||
if (sheetOffset < 0) {
|
||||
// If we are hiding/showing the sheet, make sure we relayout our content too.
|
||||
applyContentWindowInsets()
|
||||
measureContent()
|
||||
layoutContent()
|
||||
}
|
||||
|
||||
updateBottomSheetTransition()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) {
|
||||
val newOffset =
|
||||
when {
|
||||
// Swipe Up -> Expand to top
|
||||
yvel < 0 -> 1f
|
||||
// Swipe down -> Collapse to bottom
|
||||
yvel > 0 -> 0f
|
||||
// No velocity, far enough from middle to expand to top
|
||||
sheetOffset >= 0.5f -> 1f
|
||||
// Collapse to bottom
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
dragHelper.settleCapturedViewAt(
|
||||
releasedChild.left, calculateSheetTopPosition(newOffset))
|
||||
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun getViewVerticalDragRange(child: View) = sheetRange
|
||||
|
||||
override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int {
|
||||
val collapsedTop = calculateSheetTopPosition(0f)
|
||||
val expandedTop = calculateSheetTopPosition(1.0f)
|
||||
return top.coerceAtLeast(expandedTop).coerceAtMost(collapsedTop)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val INIT_SHEET_STATE = State.HIDDEN
|
||||
private val VIEW_DRAG_HELPER_STATE_FIELD: Field by
|
||||
lazyReflectedField(ViewDragHelper::class, "mDragState")
|
||||
|
||||
private const val MIN_FLING_VEL = 400
|
||||
private const val KEY_SHEET_STATE = BuildConfig.APPLICATION_ID + ".key.BOTTOM_SHEET_STATE"
|
||||
}
|
||||
}
|
|
@ -19,7 +19,6 @@ package org.oxycblt.auxio.playback
|
|||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import kotlin.math.max
|
||||
import org.oxycblt.auxio.R
|
||||
|
@ -31,8 +30,6 @@ import org.oxycblt.auxio.ui.fragment.ViewBindingFragment
|
|||
import org.oxycblt.auxio.util.androidActivityViewModels
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.getColorStateListSafe
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
import org.oxycblt.auxio.util.systemGestureInsetsCompat
|
||||
import org.oxycblt.auxio.util.textSafe
|
||||
|
||||
/**
|
||||
|
@ -58,17 +55,6 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
|||
playbackModel.song.value?.let(navModel::exploreNavigateTo)
|
||||
true
|
||||
}
|
||||
|
||||
setOnApplyWindowInsetsListener { view, insets ->
|
||||
// Since we swipe up this view, we need to make sure it does not collide with
|
||||
// any gesture events. So, apply the system gesture insets if present as long
|
||||
// as they are *larger* than the bar insets. This is to resolve issues where
|
||||
// the gesture insets are not sane on OEM devices.
|
||||
val bars = insets.systemBarInsetsCompat
|
||||
val gestures = insets.systemGestureInsetsCompat
|
||||
view.updatePadding(bottom = max(bars.bottom, gestures.bottom))
|
||||
insets
|
||||
}
|
||||
}
|
||||
|
||||
// Load the track color in manually as it's unclear whether the track actually supports
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowInsets
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import org.oxycblt.auxio.ui.AuxioSheetBehavior
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
class PlaybackSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
|
||||
AuxioSheetBehavior<V>(context, attributeSet) {
|
||||
private var lastInsets: WindowInsets? = null
|
||||
|
||||
// Hack around issue where the playback sheet will try to intercept nested scrolling events
|
||||
// before the queue sheet.
|
||||
override fun onInterceptTouchEvent(
|
||||
parent: CoordinatorLayout,
|
||||
child: V,
|
||||
event: MotionEvent
|
||||
): Boolean = super.onInterceptTouchEvent(parent, child, event) && state != STATE_EXPANDED
|
||||
|
||||
override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean {
|
||||
val success = super.onLayoutChild(parent, child, layoutDirection)
|
||||
|
||||
(child as ViewGroup).apply {
|
||||
setOnApplyWindowInsetsListener { v, insets ->
|
||||
lastInsets = insets
|
||||
peekHeight = getChildAt(0).measuredHeight + insets.systemGestureInsets.bottom
|
||||
insets
|
||||
}
|
||||
}
|
||||
|
||||
return success
|
||||
}
|
||||
|
||||
fun hideSafe() {
|
||||
isDraggable = false
|
||||
isHideable = true
|
||||
state = STATE_HIDDEN
|
||||
}
|
||||
|
||||
fun unsideSafe() {
|
||||
isHideable = false
|
||||
isDraggable = true
|
||||
logD(state)
|
||||
}
|
||||
}
|
64
app/src/main/java/org/oxycblt/auxio/ui/AuxioSheetBehavior.kt
Normal file
64
app/src/main/java/org/oxycblt/auxio/ui/AuxioSheetBehavior.kt
Normal file
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import com.google.android.material.bottomsheet.NeoBottomSheetBehavior
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.util.*
|
||||
|
||||
abstract class AuxioSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
|
||||
NeoBottomSheetBehavior<V>(context, attributeSet) {
|
||||
private var elevationNormal = context.getDimenSafe(R.dimen.elevation_normal)
|
||||
val sheetBackgroundDrawable =
|
||||
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
|
||||
fillColor = context.getAttrColorSafe(R.attr.colorSurface).stateList
|
||||
elevation = context.pxOfDp(elevationNormal).toFloat()
|
||||
}
|
||||
|
||||
init {
|
||||
isFitToContents = false
|
||||
}
|
||||
|
||||
override fun shouldSkipHalfExpandedStateWhenDragging() = true
|
||||
override fun shouldExpandOnUpwardDrag(dragDurationMillis: Long, yPositionPercentage: Float) =
|
||||
true
|
||||
|
||||
override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean {
|
||||
val success = super.onLayoutChild(parent, child, layoutDirection)
|
||||
|
||||
(child as ViewGroup).apply {
|
||||
background =
|
||||
LayerDrawable(
|
||||
arrayOf(
|
||||
ColorDrawable(context.getAttrColorSafe(R.attr.colorSurface)),
|
||||
sheetBackgroundDrawable))
|
||||
|
||||
disableDropShadowCompat()
|
||||
}
|
||||
|
||||
return success
|
||||
}
|
||||
}
|
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import com.google.android.material.bottomsheet.NeoBottomSheetBehavior
|
||||
import kotlin.math.abs
|
||||
import org.oxycblt.auxio.util.replaceSystemBarInsetsCompat
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
|
||||
class BottomSheetContentViewBehavior<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 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.systemBarInsetsCompat
|
||||
val behavior =
|
||||
(dep.layoutParams as CoordinatorLayout.LayoutParams).behavior
|
||||
as NeoBottomSheetBehavior
|
||||
|
||||
val offset = behavior.calculateSlideOffset()
|
||||
if (behavior.peekHeight < 0 || offset == Float.MIN_VALUE) {
|
||||
return@setOnApplyWindowInsetsListener insets
|
||||
}
|
||||
|
||||
val adjustedBottomInset =
|
||||
(bars.bottom - behavior.calculateConsumedByBar()).coerceAtLeast(0)
|
||||
|
||||
insets.replaceSystemBarInsetsCompat(
|
||||
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 NeoBottomSheetBehavior
|
||||
|
||||
val offset = behavior.calculateSlideOffset()
|
||||
if (behavior.peekHeight < 0 || offset == Float.MIN_VALUE) {
|
||||
return false
|
||||
}
|
||||
|
||||
val contentWidthSpec =
|
||||
View.MeasureSpec.makeMeasureSpec(parent.measuredWidth, View.MeasureSpec.EXACTLY)
|
||||
val contentHeightSpec =
|
||||
View.MeasureSpec.makeMeasureSpec(
|
||||
parent.measuredHeight - behavior.calculateConsumedByBar(), View.MeasureSpec.EXACTLY)
|
||||
|
||||
child.measure(contentWidthSpec, contentHeightSpec)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun NeoBottomSheetBehavior<*>.calculateConsumedByBar(): Int {
|
||||
val offset = calculateSlideOffset()
|
||||
return if (offset >= 0) {
|
||||
peekHeight
|
||||
} else {
|
||||
(peekHeight * (1 - abs(offset))).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
override fun layoutDependsOn(parent: CoordinatorLayout, child: V, dependency: View): Boolean {
|
||||
if ((dependency.layoutParams as CoordinatorLayout.LayoutParams).behavior
|
||||
is NeoBottomSheetBehavior) {
|
||||
dep = dependency
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onDependentViewChanged(
|
||||
parent: CoordinatorLayout,
|
||||
child: V,
|
||||
dependency: View
|
||||
): Boolean {
|
||||
lastInsets?.let(child::dispatchApplyWindowInsets)
|
||||
return measureContent(parent, child, dependency) &&
|
||||
onLayoutChild(parent, child, parent.layoutDirection)
|
||||
}
|
||||
}
|
|
@ -32,6 +32,7 @@ import android.widget.TextView
|
|||
import androidx.activity.viewModels
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
|
@ -153,6 +154,9 @@ fun RecyclerView.applySpans(shouldBeFullWidth: ((Int) -> Boolean)? = null) {
|
|||
val RecyclerView.canScroll: Boolean
|
||||
get() = computeVerticalScrollRange() > height
|
||||
|
||||
val View.coordinatorLayoutBehavior: CoordinatorLayout.Behavior<*>?
|
||||
get() = (layoutParams as CoordinatorLayout.LayoutParams).behavior
|
||||
|
||||
/** Converts this color to a single-color [ColorStateList]. */
|
||||
val @receiver:ColorRes Int.stateList
|
||||
get() = ColorStateList.valueOf(this)
|
||||
|
|
|
@ -1,23 +1,26 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<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"
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<org.oxycblt.auxio.playback.BottomSheetLayout
|
||||
android:id="@+id/bottom_sheet_layout"
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/explore_nav_host"
|
||||
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentViewBehavior"
|
||||
app:navGraph="@navigation/nav_explore"
|
||||
tools:layout="@layout/fragment_home" />
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/explore_nav_host"
|
||||
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:navGraph="@navigation/nav_explore"
|
||||
tools:layout="@layout/fragment_home" />
|
||||
<FrameLayout
|
||||
android:id="@+id/playback_sheet"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="org.oxycblt.auxio.playback.PlaybackSheetBehavior">
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/playback_bar_fragment"
|
||||
|
@ -31,33 +34,6 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
</org.oxycblt.auxio.playback.BottomSheetLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/layout_too_small"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/colorSurface"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:alpha="0.275"
|
||||
android:contentDescription="@string/desc_auxio_icon"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/ic_auxio_24" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:padding="@dimen/spacing_medium"
|
||||
android:text="@string/err_too_small"
|
||||
android:textAppearance="@style/TextAppearance.Auxio.TitleMediumLowEmphasis"
|
||||
android:textColor="?android:attr/textColorPrimary"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</FrameLayout>
|
||||
</FrameLayout>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
|
||||
<!-- Misc -->
|
||||
<dimen name="elevation_small">2dp</dimen>
|
||||
<dimen name="elevation_normal">4dp</dimen>
|
||||
<dimen name="elevation_normal">3dp</dimen>
|
||||
|
||||
<dimen name="fast_scroll_popup_min_width">78dp</dimen>
|
||||
<dimen name="fast_scroll_popup_min_height">64dp</dimen>
|
||||
|
|
Loading…
Reference in a new issue