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:
OxygenCobalt 2022-07-29 12:18:26 -06:00
parent 62ae5e5cb2
commit cc3cb343b0
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
12 changed files with 2560 additions and 745 deletions

3
.gitignore vendored
View file

@ -15,3 +15,6 @@ captures/
.externalNativeBuild
*.iml
.cxx
# Patched material
app/src/main/com/google/android/material

View file

@ -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

View file

@ -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 ==

View file

@ -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"
}
}

View file

@ -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

View file

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

View 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
}
}

View file

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

View file

@ -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)

View file

@ -1,24 +1,27 @@
<?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"
android:layout_width="match_parent"
android:layout_height="match_parent">
<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:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentViewBehavior"
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"
android:name="org.oxycblt.auxio.playback.PlaybackBarFragment"
@ -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>

View file

@ -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>