ui: create dedicated playback bar layout

Create a dedicated playback bar layout. This replaces the old janky
observer system with something that handles state better and is just
more elegant.
This commit is contained in:
OxygenCobalt 2021-10-30 18:21:30 -06:00
parent 4f4f6654c0
commit 68782fadac
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
33 changed files with 544 additions and 278 deletions

View file

@ -46,7 +46,7 @@ I primarily built Auxio for myself, but you can use it too, I guess.
- Search Functionality
- Audio Focus / Headset Management
- No internet connectivity whatsoever
- No rounded album corners
- No rounded album covers
## To possibly come in the future:

View file

@ -63,7 +63,7 @@ dependencies {
// --- SUPPORT ---
// General
implementation "androidx.core:core-ktx:1.6.0"
implementation "androidx.core:core-ktx:1.7.0"
implementation "androidx.activity:activity-ktx:1.3.1"
implementation 'androidx.fragment:fragment-ktx:1.3.6'
@ -74,7 +74,7 @@ dependencies {
implementation "androidx.viewpager2:viewpager2:1.1.0-beta01"
// Lifecycle
def lifecycle_version = "2.3.1"
def lifecycle_version = "2.4.0"
implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"

View file

@ -26,8 +26,6 @@ import android.view.ViewGroup
import android.widget.Button
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.google.android.material.snackbar.Snackbar
@ -35,8 +33,6 @@ import org.oxycblt.auxio.databinding.FragmentMainBinding
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.applyEdge
import org.oxycblt.auxio.util.applyMaterialDrawable
import org.oxycblt.auxio.util.logD
/**
@ -65,25 +61,26 @@ class MainFragment : Fragment() {
binding.lifecycleOwner = viewLifecycleOwner
binding.applyEdge { bars ->
binding.mainPlayback.updatePadding(bottom = bars.bottom)
// --- VIEWMODEL SETUP ---
if (playbackModel.song.value != null) {
binding.mainBarLayout.showBar()
} else {
binding.mainBarLayout.hideBar()
}
binding.mainPlayback.applyMaterialDrawable()
// --- VIEWMODEL SETUP ---
playbackModel.song.observe(viewLifecycleOwner) { song ->
if (song != null) {
binding.mainBarLayout.showBar()
} else {
binding.mainBarLayout.hideBar()
}
}
// Initialize music loading. Unlike MainFragment, we can not only do this here on startup
// but also show a SnackBar in a reasonable place in this fragment.
musicModel.loadMusic(requireContext())
// Change CompactPlaybackFragment's visibility here so that an animation occurs.
binding.mainPlayback.isVisible = playbackModel.song.value != null
playbackModel.song.observe(viewLifecycleOwner) { song ->
binding.mainPlayback.isVisible = song != null
}
// Handle the music loader response.
musicModel.loaderResponse.observe(viewLifecycleOwner) { response ->
// Handle the loader response.

View file

@ -24,7 +24,6 @@ import androidx.activity.OnBackPressedCallback
import androidx.annotation.MenuRes
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.forEach
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
@ -35,8 +34,6 @@ import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.SortMode
import org.oxycblt.auxio.ui.memberBinding
import org.oxycblt.auxio.util.applyEdge
import org.oxycblt.auxio.util.applyEdgeRespectingBar
import org.oxycblt.auxio.util.isLandscape
/**
@ -49,12 +46,6 @@ abstract class DetailFragment : Fragment() {
protected val binding by memberBinding(FragmentDetailBinding::inflate)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.applyEdge { bars ->
binding.detailAppbar.updatePadding(top = bars.top)
}
binding.detailRecycler.applyEdgeRespectingBar(playbackModel, viewLifecycleOwner)
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback)
}

View file

@ -95,7 +95,7 @@ class ExcludedViewModel(context: Context) : ViewModel() {
fun isModified() = dbPaths != paths.value
class Factory(private val context: Context) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
check(modelClass.isAssignableFrom(ExcludedViewModel::class.java)) {
"ExcludedViewModel.Factory does not support this class"
}

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2021 Auxio Project
* EdgeFloatingActionButton.kt is part of Auxio.
*
* 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.home
import android.content.Context
import android.util.AttributeSet
import android.view.WindowInsets
import android.widget.FrameLayout
import androidx.core.view.updatePadding
import org.oxycblt.auxio.util.systemBarsCompat
class FloatingActionButtonContainer @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = -1
) : FrameLayout(context, attrs, defStyleAttr) {
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
updatePadding(bottom = insets.systemBarsCompat.bottom)
return insets
}
}

View file

@ -25,7 +25,6 @@ import android.view.View
import android.view.ViewGroup
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.iterator
import androidx.core.view.updatePadding
import androidx.core.view.updatePaddingRelative
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@ -52,7 +51,6 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.SortMode
import org.oxycblt.auxio.util.applyEdge
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
@ -73,19 +71,12 @@ class HomeFragment : Fragment() {
savedInstanceState: Bundle?
): View {
val binding = FragmentHomeBinding.inflate(inflater)
var bottomPadding = 0
val sortItem: MenuItem
// --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
binding.applyEdge { bars ->
bottomPadding = bars.bottom
updateFabPadding(binding, bottomPadding)
binding.homeAppbar.updatePadding(top = bars.top)
}
binding.homeAppbar.apply {
// I have no idea how to clip the collapsing toolbar while still making the elevation
// overlay bleed into the status bar, so I take the easy way out and just fade the
@ -298,10 +289,6 @@ class HomeFragment : Fragment() {
}
}
playbackModel.song.observe(viewLifecycleOwner) {
updateFabPadding(binding, bottomPadding)
}
logD("Fragment Created.")
return binding.root
@ -323,23 +310,6 @@ class HomeFragment : Fragment() {
}
}
private fun updateFabPadding(
binding: FragmentHomeBinding,
bottomPadding: Int
) {
// To get our FAB to work with edge-to-edge, we need keep track of the bar view and update
// the padding based off of that. However, we can't use the shared method here since FABs
// don't respect padding, so we duplicate the code here except with the margins instead.
val fabParams = binding.homeFab.layoutParams as CoordinatorLayout.LayoutParams
val baseSpacing = resources.getDimensionPixelSize(R.dimen.spacing_medium)
if (playbackModel.song.value == null) {
fabParams.bottomMargin = baseSpacing + bottomPadding
} else {
fabParams.bottomMargin = baseSpacing
}
}
private val DisplayMode.viewId: Int get() = when (this) {
DisplayMode.SHOW_SONGS -> R.id.home_song_list
DisplayMode.SHOW_ALBUMS -> R.id.home_album_list

View file

@ -30,6 +30,7 @@ import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.view.ViewGroup
import android.view.WindowInsets
import android.widget.FrameLayout
import android.widget.TextView
import androidx.appcompat.widget.AppCompatTextView
@ -66,6 +67,7 @@ import kotlin.math.abs
* - Added drag listener
* - TODO: Added documentation
* - TODO: Popup will center itself to the thumb when possible
* - TODO: Stabilize how I'm using padding
*/
class FastScrollRecyclerView @JvmOverloads constructor(
context: Context,
@ -107,6 +109,14 @@ class FastScrollRecyclerView @JvmOverloads constructor(
hideScrollbar()
}
private val initialPadding = Rect(
paddingLeft, paddingTop, paddingRight, paddingBottom
)
private val scrollerPadding = Rect(
0, 0, 0, 0
)
init {
val thumbDrawable = R.drawable.ui_scroll_thumb.resolveDrawable(context)
@ -207,7 +217,10 @@ class FastScrollRecyclerView @JvmOverloads constructor(
width - paddingRight - thumbWidth
}
trackView.layout(trackLeft, paddingTop, trackLeft + thumbWidth, height - paddingBottom)
trackView.layout(
trackLeft, paddingTop, trackLeft + thumbWidth,
height - scrollerPadding.bottom
)
val thumbLeft = if (isRtl) {
paddingLeft
@ -237,14 +250,14 @@ class FastScrollRecyclerView @JvmOverloads constructor(
val widthMeasureSpec = ViewGroup.getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
paddingLeft + paddingRight + thumbWidth + popupLayoutParams.leftMargin +
popupLayoutParams.rightMargin,
scrollerPadding.left + scrollerPadding.right + thumbWidth +
popupLayoutParams.leftMargin + popupLayoutParams.rightMargin,
popupLayoutParams.width
)
val heightMeasureSpec = ViewGroup.getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
paddingTop + paddingBottom + popupLayoutParams.topMargin +
scrollerPadding.top + scrollerPadding.bottom + popupLayoutParams.topMargin +
popupLayoutParams.bottomMargin,
popupLayoutParams.height
)
@ -255,9 +268,9 @@ class FastScrollRecyclerView @JvmOverloads constructor(
val popupWidth = popupView.measuredWidth
val popupHeight = popupView.measuredHeight
val popupLeft = if (layoutDirection == View.LAYOUT_DIRECTION_RTL) {
paddingLeft + thumbWidth + popupLayoutParams.leftMargin
scrollerPadding.left + thumbWidth + popupLayoutParams.leftMargin
} else {
width - paddingRight - thumbWidth - popupLayoutParams.rightMargin - popupWidth
width - scrollerPadding.right - thumbWidth - popupLayoutParams.rightMargin - popupWidth
}
// We handle RTL separately, so it's okay if Gravity.RIGHT is used here
@ -280,8 +293,8 @@ class FastScrollRecyclerView @JvmOverloads constructor(
val popupTop = MathUtils.clamp(
thumbTop + thumbAnchorY - popupAnchorY,
paddingTop + popupLayoutParams.topMargin,
height - paddingBottom - popupLayoutParams.bottomMargin - popupHeight
scrollerPadding.top + popupLayoutParams.topMargin,
height - scrollerPadding.bottom - popupLayoutParams.bottomMargin - popupHeight
)
popupView.layout(
@ -310,6 +323,17 @@ class FastScrollRecyclerView @JvmOverloads constructor(
didRelayout = changed
}
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
setPadding(
initialPadding.left, initialPadding.top, initialPadding.right,
initialPadding.bottom + insets.systemWindowInsetBottom
)
scrollerPadding.bottom = insets.systemWindowInsetBottom
return insets
}
private fun updateScrollbarState() {
if (!canScroll() || childCount == 0) {
return
@ -348,7 +372,7 @@ class FastScrollRecyclerView @JvmOverloads constructor(
val scrollOffset = paddingTop + (itemPos * itemHeight) - itemTop
// The range of pixels where the thumb is not present
val thumbOffsetRange = height - paddingTop - paddingBottom - thumbHeight
val thumbOffsetRange = height - scrollerPadding.top - scrollerPadding.bottom - thumbHeight
// Finally, we can calculate the thumb position, which is just:
// [proportion of scroll position to scroll range] * [total thumb range]
@ -375,7 +399,7 @@ class FastScrollRecyclerView @JvmOverloads constructor(
if (isInViewTouchTarget(thumbView, eventX, eventY)) {
dragStartThumbOffset = thumbOffset
} else {
dragStartThumbOffset = (eventY - paddingTop - thumbHeight / 2f).toInt()
dragStartThumbOffset = (eventY - scrollerPadding.top - thumbHeight / 2f).toInt()
scrollToThumbOffset(dragStartThumbOffset)
}
@ -392,7 +416,7 @@ class FastScrollRecyclerView @JvmOverloads constructor(
dragStartThumbOffset = thumbOffset
} else {
dragStartY = eventY
dragStartThumbOffset = (eventY - paddingTop - thumbHeight / 2f).toInt()
dragStartThumbOffset = (eventY - scrollerPadding.top - thumbHeight / 2f).toInt()
scrollToThumbOffset(dragStartThumbOffset)
}
setDragging(true)
@ -577,7 +601,7 @@ class FastScrollRecyclerView @JvmOverloads constructor(
private val thumbOffsetRange: Int
get() {
return height - paddingTop - paddingBottom - thumbHeight
return height - scrollerPadding.top - scrollerPadding.bottom - thumbHeight
}
private val itemCount: Int

View file

@ -31,7 +31,6 @@ import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.memberBinding
import org.oxycblt.auxio.util.applyEdgeRespectingBar
import org.oxycblt.auxio.util.applySpans
/**
@ -67,7 +66,6 @@ abstract class HomeListFragment : Fragment() {
adapter = homeAdapter
setHasFixedSize(true)
applySpans()
applyEdgeRespectingBar(playbackModel, viewLifecycleOwner)
}
// Make sure that this RecyclerView has data before startup

View file

@ -0,0 +1,245 @@
/*
* Copyright (c) 2021 Auxio Project
* PlaybackBarLayout.kt is part of Auxio.
*
* 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.content.res.ColorStateList
import android.graphics.Insets
import android.os.Build
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.children
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.resolveAttr
import org.oxycblt.auxio.util.systemBarsCompat
/**
* A layout that manages the bottom playback bar while still enabling edge-to-edge to work
* properly. The mechanism is mostly inspired by Material Files' PersistentBarLayout, however
* this class was primarily written by me and I plan to expand this layout to become part of
* the playback navigation process.
*/
class PlaybackBarLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
@StyleRes defStyleRes: Int = 0
) : ViewGroup(context, attrs, defStyleAttr, defStyleRes) {
private val barLayout = FrameLayout(context)
private val playbackFragment = CompactPlaybackFragment()
private var lastInsets: WindowInsets? = null
init {
addView(barLayout)
barLayout.apply {
id = R.id.main_playback
elevation = resources.getDimensionPixelSize(R.dimen.elevation_normal).toFloat()
(layoutParams as LayoutParams).apply {
width = ViewGroup.LayoutParams.MATCH_PARENT
height = ViewGroup.LayoutParams.WRAP_CONTENT
isBar = true
}
background = MaterialShapeDrawable.createWithElevationOverlay(context).apply {
elevation = barLayout.elevation
fillColor = ColorStateList.valueOf(R.attr.colorSurface.resolveAttr(context))
}
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (isInEditMode) {
return
}
// By default, using a FragmentContainerView in this view will result in
// the fragment disappearing on a recreate. Who knows why.
(context as AppCompatActivity).supportFragmentManager.apply {
this
.beginTransaction()
.replace(R.id.main_playback, playbackFragment)
.commit()
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
setMeasuredDimension(widthSize, heightSize)
val barParams = barLayout.layoutParams as LayoutParams
val barWidthSpec = getChildMeasureSpec(widthMeasureSpec, 0, barParams.width)
val barHeightSpec = getChildMeasureSpec(heightMeasureSpec, 0, barParams.height)
barLayout.measure(barWidthSpec, barHeightSpec)
val barHeightAdjusted = (barLayout.measuredHeight * barParams.offset).toInt()
val contentWidth = measuredWidth
val contentHeight = measuredHeight - barHeightAdjusted
for (child in children) {
if (child.visibility == View.GONE) continue
val childParams = child.layoutParams as LayoutParams
if (!childParams.isBar) {
val childWidthSpec = MeasureSpec.makeMeasureSpec(contentWidth, MeasureSpec.EXACTLY)
val childHeightSpec = MeasureSpec.makeMeasureSpec(contentHeight, MeasureSpec.EXACTLY)
child.measure(childWidthSpec, childHeightSpec)
}
}
updateWindowInsets()
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
val barHeight = if (barLayout.isVisible) {
barLayout.measuredHeight
} else {
0
}
val barHeightAdjusted = (barHeight * (barLayout.layoutParams as LayoutParams).offset).toInt()
for (child in children) {
if (child.visibility == View.GONE) continue
val childParams = child.layoutParams as LayoutParams
if (childParams.isBar) {
child.layout(
0, height - barHeightAdjusted,
width, height + (barHeight - barHeightAdjusted)
)
} else {
child.layout(0, 0, child.measuredWidth, child.measuredHeight)
}
}
}
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
lastInsets = insets
barLayout.updatePadding(bottom = insets.systemBarsCompat.bottom)
updateWindowInsets()
return insets
}
private fun updateWindowInsets() {
val insets = lastInsets
if (insets != null) {
super.dispatchApplyWindowInsets(mutateInsets(insets))
}
}
private fun mutateInsets(insets: WindowInsets): WindowInsets {
if (barLayout.isVisible) {
val barParams = barLayout.layoutParams as LayoutParams
val childConsumedInset = (barLayout.measuredHeight * barParams.offset).toInt()
logD(childConsumedInset)
val bars = insets.systemBarsCompat
// TODO: Q support
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
return WindowInsets.Builder(insets)
.setInsets(
WindowInsets.Type.systemBars(),
Insets.of(
bars.left, bars.top,
bars.right, (bars.bottom - childConsumedInset).coerceAtLeast(0)
)
)
.build()
}
}
}
return insets
}
fun showBar() {
(barLayout.layoutParams as LayoutParams).offset = 1f
updateWindowInsets()
}
fun hideBar() {
(barLayout.layoutParams as LayoutParams).offset = 0f
updateWindowInsets()
}
// --- LAYOUT PARAMS ---
override fun generateLayoutParams(attrs: AttributeSet): ViewGroup.LayoutParams {
return LayoutParams(context, attrs)
}
override fun generateLayoutParams(
layoutParams: ViewGroup.LayoutParams
): ViewGroup.LayoutParams =
when (layoutParams) {
is LayoutParams -> LayoutParams(layoutParams)
is MarginLayoutParams -> LayoutParams(layoutParams)
else -> LayoutParams(layoutParams)
}
override fun generateDefaultLayoutParams(): ViewGroup.LayoutParams =
LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
override fun checkLayoutParams(layoutParams: ViewGroup.LayoutParams): Boolean =
layoutParams is LayoutParams && super.checkLayoutParams(layoutParams)
@Suppress("UNUSED")
class LayoutParams : ViewGroup.LayoutParams {
var isBar = false
var offset = 0f
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(width: Int, height: Int) : super(width, height)
constructor(source: LayoutParams) : super(source)
constructor(source: ViewGroup.LayoutParams) : super(source)
}
}

View file

@ -32,8 +32,8 @@ import org.oxycblt.auxio.databinding.FragmentPlaybackBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.playback.state.LoopMode
import org.oxycblt.auxio.ui.memberBinding
import org.oxycblt.auxio.util.applyEdge
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.systemBarsCompat
/**
* A [Fragment] that displays more information about the song, along with more media controls.
@ -60,11 +60,15 @@ class PlaybackFragment : Fragment() {
binding.playbackModel = playbackModel
binding.detailModel = detailModel
binding.applyEdge { bars ->
binding.root.setOnApplyWindowInsetsListener { v, insets ->
val bars = insets.systemBarsCompat
binding.root.updatePadding(
top = bars.top,
bottom = bars.bottom
)
insets
}
binding.playbackToolbar.apply {

View file

@ -22,14 +22,12 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper
import org.oxycblt.auxio.databinding.FragmentQueueBinding
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.applyEdge
/**
* A [Fragment] that contains both the user queue and the next queue, with the ability to
@ -58,11 +56,6 @@ class QueueFragment : Fragment() {
binding.lifecycleOwner = viewLifecycleOwner
binding.applyEdge { bars ->
binding.queueAppbar.updatePadding(top = bars.top)
binding.queueRecycler.updatePadding(bottom = bars.bottom)
}
binding.queueToolbar.setNavigationOnClickListener {
findNavController().navigateUp()
}

View file

@ -24,7 +24,6 @@ import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import androidx.core.view.postDelayed
import androidx.core.view.updatePadding
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@ -42,8 +41,6 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.applyEdge
import org.oxycblt.auxio.util.applyEdgeRespectingBar
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.getSystemServiceSafe
import org.oxycblt.auxio.util.logD
@ -77,10 +74,6 @@ class SearchFragment : Fragment() {
binding.lifecycleOwner = viewLifecycleOwner
binding.applyEdge { bars ->
binding.searchAppbar.updatePadding(top = bars.top)
}
binding.searchToolbar.apply {
val itemId = when (searchModel.filterMode) {
DisplayMode.SHOW_SONGS -> R.id.option_filter_songs
@ -128,8 +121,6 @@ class SearchFragment : Fragment() {
applySpans { pos ->
searchAdapter.currentList[pos] is Header
}
applyEdgeRespectingBar(playbackModel, viewLifecycleOwner)
}
// --- VIEWMODEL SETUP ---

View file

@ -36,9 +36,9 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentAboutBinding
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.util.applyEdge
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.systemBarsCompat
/**
* A [BottomSheetDialogFragment] that shows Auxio's about screen.
@ -54,9 +54,9 @@ class AboutFragment : Fragment() {
): View {
val binding = FragmentAboutBinding.inflate(layoutInflater)
binding.applyEdge { bars ->
binding.aboutAppbar.updatePadding(top = bars.top)
binding.aboutContents.updatePadding(bottom = bars.bottom)
binding.aboutContents.setOnApplyWindowInsetsListener { v, insets ->
binding.aboutContents.updatePadding(bottom = insets.systemBarsCompat.bottom)
insets
}
binding.aboutToolbar.setNavigationOnClickListener {

View file

@ -22,11 +22,9 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.databinding.FragmentSettingsBinding
import org.oxycblt.auxio.util.applyEdge
/**
* A container [Fragment] for the settings menu.
@ -46,13 +44,6 @@ class SettingsFragment : Fragment() {
}
}
binding.applyEdge { bars ->
binding.settingsAppbar.updatePadding(top = bars.top)
// The padding + clipToPadding method does not work with a
// FragmentContainerView. Do it directly in SettingsListFragment instead.
}
binding.settingsAppbar.liftOnScrollTargetViewId = androidx.preference.R.id.recycler_view
return binding.root

View file

@ -39,10 +39,10 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.settings.pref.IntListPrefDialog
import org.oxycblt.auxio.settings.pref.IntListPreference
import org.oxycblt.auxio.settings.tabs.TabCustomizeDialog
import org.oxycblt.auxio.util.applyEdge
import org.oxycblt.auxio.util.isNight
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.systemBarsCompat
/**
* The actual fragment containing the settings menu. Inherits [PreferenceFragmentCompat].
@ -65,8 +65,10 @@ class SettingsListFragment : PreferenceFragmentCompat() {
view.findViewById<RecyclerView>(androidx.preference.R.id.recycler_view).apply {
clipToPadding = false
applyEdge { bars ->
updatePadding(bottom = bars.bottom)
setOnApplyWindowInsetsListener { v, insets ->
updatePadding(bottom = insets.systemBarsCompat.bottom)
insets
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2021 Auxio Project
* EdgeRecyclerView.kt is part of Auxio.
*
* 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.WindowInsets
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.util.systemBarsCompat
/**
* A [RecyclerView] that automatically applies insets to itself.
*/
class EdgeRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = -1
) : RecyclerView(context, attrs, defStyleAttr) {
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
updatePadding(bottom = insets.systemBarsCompat.bottom)
return insets
}
}

View file

@ -23,11 +23,14 @@ import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.WindowInsets
import androidx.annotation.StyleRes
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.updatePadding
import com.google.android.material.appbar.AppBarLayout
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.systemBarsCompat
/**
* An [AppBarLayout] that fixes a bug with the default implementation where the lifted state
@ -61,6 +64,20 @@ class LiftAppBarLayout @JvmOverloads constructor(
viewTreeObserver.addOnPreDrawListener(onPreDraw)
}
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
super.dispatchApplyWindowInsets(insets)
return onApplyWindowInsets(insets)
}
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
super.onApplyWindowInsets(insets)
updatePadding(top = insets.systemBarsCompat.top)
return insets
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()

View file

@ -32,14 +32,10 @@ import androidx.annotation.ColorInt
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import androidx.core.view.updatePadding
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.playback.PlaybackViewModel
/**
* Apply a [MaterialShapeDrawable] to this view, automatically initializing the elevation overlay
@ -142,84 +138,24 @@ fun @receiver:AttrRes Int.resolveAttr(context: Context): Int {
return color.resolveColor(context)
}
/**
* Apply edge-to-edge tweaks to the root of a [ViewBinding].
* @param onApply What to do when the system bar insets are provided
*/
fun ViewBinding.applyEdge(onApply: (Rect) -> Unit) {
root.applyEdge(onApply)
}
/**
* Apply edge-to-edge tweaks to a [View].
* @param onApply What to do when the system bar insets are provided
*/
fun View.applyEdge(onApply: (Rect) -> Unit) {
when {
val WindowInsets.systemBarsCompat: Rect get() {
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
setOnApplyWindowInsetsListener { _, insets ->
val bars = insets.getInsets(WindowInsets.Type.systemBars()).run {
Rect(left, top, right, bottom)
}
onApply(bars)
insets
getInsets(WindowInsets.Type.systemBars()).run {
Rect(left, top, right, bottom)
}
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 -> {
setOnApplyWindowInsetsListener { _, insets ->
@Suppress("DEPRECATION")
val bars = Rect(
insets.systemWindowInsetLeft,
insets.systemWindowInsetTop,
insets.systemWindowInsetRight,
insets.systemWindowInsetBottom
)
onApply(bars)
insets
}
@Suppress("DEPRECATION")
Rect(
systemWindowInsetLeft,
systemWindowInsetTop,
systemWindowInsetRight,
systemWindowInsetBottom
)
}
// Not on a version that supports edge-to-edge [yet], don't do anything
}
}
/**
* Stopgap measure to make edge-to-edge work on views that also have a playback bar.
* The issue is that while we can apply padding initially, the padding will still be applied
* when the bar is shown, which is very ungood. We mitigate this by just checking the song state
* and removing the padding if there is one, which is a stupidly fragile band-aid but it
* works.
*
* TODO: Dumpster this and replace it with a dedicated layout. Only issue with that is how
* nested our layouts are, which basically forces us to do recursion magic. Hai Zhang's Material
* Files layout may help in this task.
*/
fun View.applyEdgeRespectingBar(
playbackModel: PlaybackViewModel,
viewLifecycleOwner: LifecycleOwner,
initialPadding: Int = 0
) {
var bottomPadding = 0
applyEdge {
bottomPadding = it.bottom
if (playbackModel.song.value == null) {
updatePadding(bottom = bottomPadding)
} else {
updatePadding(bottom = initialPadding)
}
}
playbackModel.song.observe(viewLifecycleOwner) { song ->
if (song == null) {
updatePadding(bottom = bottomPadding)
} else {
updatePadding(bottom = initialPadding)
}
else -> Rect(0, 0, 0, 0)
}
}

View file

@ -41,10 +41,14 @@ private fun createViews(
return views
}
private fun RemoteViews.applyMeta(context: Context, state: WidgetState) {
private fun RemoteViews.applyMeta(state: WidgetState): RemoteViews {
setTextViewText(R.id.widget_song, state.song.name)
setTextViewText(R.id.widget_artist, state.song.album.artist.resolvedName)
return this
}
private fun RemoteViews.applyCover(context: Context, state: WidgetState): RemoteViews {
if (state.albumArt != null) {
setImageViewBitmap(R.id.widget_cover, state.albumArt)
setContentDescription(
@ -54,9 +58,10 @@ private fun RemoteViews.applyMeta(context: Context, state: WidgetState) {
setImageViewResource(R.id.widget_cover, R.drawable.ic_song)
setContentDescription(R.id.widget_cover, context.getString(R.string.desc_no_cover))
}
}
private fun RemoteViews.applyControls(context: Context, state: WidgetState) {
return this
}
private fun RemoteViews.applyControls(context: Context, state: WidgetState): RemoteViews {
setOnClickPendingIntent(
R.id.widget_skip_prev,
context.newBroadcastIntent(
@ -86,53 +91,21 @@ private fun RemoteViews.applyControls(context: Context, state: WidgetState) {
R.drawable.ic_play
}
)
return this
}
fun createDefaultWidget(context: Context): RemoteViews {
return createViews(context, R.layout.widget_default)
}
private fun RemoteViews.applyFullControls(context: Context, state: WidgetState): RemoteViews {
applyControls(context, state)
fun createTinyWidget(context: Context, state: WidgetState): RemoteViews {
val views = createViews(context, R.layout.widget_tiny)
views.applyMeta(context, state)
views.applyControls(context, state)
return views
}
fun createWideWidget(context: Context, state: WidgetState): RemoteViews {
val views = createViews(context, R.layout.widget_wide)
views.applyMeta(context, state)
views.applyControls(context, state)
return views
}
fun createSmallWidget(context: Context, state: WidgetState): RemoteViews {
val views = createViews(context, R.layout.widget_small)
views.applyMeta(context, state)
views.applyControls(context, state)
return views
}
fun createMediumWidget(context: Context, state: WidgetState): RemoteViews {
val views = createViews(context, R.layout.widget_medium)
views.applyMeta(context, state)
views.applyControls(context, state)
return views
}
fun createLargeWidget(context: Context, state: WidgetState): RemoteViews {
val views = createViews(context, R.layout.widget_large)
views.applyMeta(context, state)
views.applyControls(context, state)
views.setOnClickPendingIntent(
setOnClickPendingIntent(
R.id.widget_loop,
context.newBroadcastIntent(
PlaybackService.ACTION_LOOP
)
)
views.setOnClickPendingIntent(
setOnClickPendingIntent(
R.id.widget_shuffle,
context.newBroadcastIntent(
PlaybackService.ACTION_SHUFFLE
@ -154,8 +127,46 @@ fun createLargeWidget(context: Context, state: WidgetState): RemoteViews {
LoopMode.TRACK -> R.drawable.ic_loop_one
}
views.setImageViewResource(R.id.widget_shuffle, shuffleRes)
views.setImageViewResource(R.id.widget_loop, loopRes)
setImageViewResource(R.id.widget_shuffle, shuffleRes)
setImageViewResource(R.id.widget_loop, loopRes)
return views
return this
}
fun createDefaultWidget(context: Context): RemoteViews {
return createViews(context, R.layout.widget_default)
}
fun createTinyWidget(context: Context, state: WidgetState): RemoteViews {
return createViews(context, R.layout.widget_tiny)
.applyMeta(state)
.applyCover(context, state)
.applyControls(context, state)
}
fun createWideWidget(context: Context, state: WidgetState): RemoteViews {
return createViews(context, R.layout.widget_wide)
.applyMeta(state)
.applyCover(context, state)
.applyFullControls(context, state)
}
fun createSmallWidget(context: Context, state: WidgetState): RemoteViews {
return createViews(context, R.layout.widget_small)
.applyMeta(state)
.applyControls(context, state)
}
fun createMediumWidget(context: Context, state: WidgetState): RemoteViews {
return createViews(context, R.layout.widget_medium)
.applyMeta(state)
.applyCover(context, state)
.applyControls(context, state)
}
fun createLargeWidget(context: Context, state: WidgetState): RemoteViews {
return createViews(context, R.layout.widget_large)
.applyMeta(state)
.applyCover(context, state)
.applyFullControls(context, state)
}

View file

@ -28,7 +28,6 @@
android:ellipsize="end"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium"
android:layout_marginEnd="@dimen/spacing_medium"
app:layout_constraintBottom_toTopOf="@+id/detail_subhead"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"

View file

@ -10,7 +10,7 @@
android:background="?attr/colorSurface"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
<org.oxycblt.auxio.ui.LiftAppBarLayout
android:id="@+id/about_appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -22,7 +22,7 @@
style="@style/Widget.Auxio.Toolbar.Icon.Down"
app:title="@string/lbl_about" />
</com.google.android.material.appbar.AppBarLayout>
</org.oxycblt.auxio.ui.LiftAppBarLayout>
<androidx.core.widget.NestedScrollView
android:id="@+id/about_contents"

View file

@ -4,32 +4,39 @@
xmlns:tools="http://schemas.android.com/tools"
tools:context=".detail.DetailFragment">
<androidx.coordinatorlayout.widget.CoordinatorLayout
<org.oxycblt.auxio.playback.PlaybackBarLayout
android:id="@+id/bar_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.oxycblt.auxio.ui.LiftAppBarLayout
android:id="@+id/detail_appbar"
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
app:liftOnScroll="true"
app:liftOnScrollTargetViewId="@id/detail_recycler">
android:layout_height="match_parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/detail_toolbar"
style="@style/Widget.Auxio.Toolbar.Icon" />
<org.oxycblt.auxio.ui.LiftAppBarLayout
android:id="@+id/detail_appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
app:liftOnScroll="true"
app:liftOnScrollTargetViewId="@id/detail_recycler">
</org.oxycblt.auxio.ui.LiftAppBarLayout>
<androidx.appcompat.widget.Toolbar
android:id="@+id/detail_toolbar"
style="@style/Widget.Auxio.Toolbar.Icon" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/detail_recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
tools:listitem="@layout/item_detail" />
</org.oxycblt.auxio.ui.LiftAppBarLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<org.oxycblt.auxio.ui.EdgeRecyclerView
android:id="@+id/detail_recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
app:layout_role="content"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
tools:listitem="@layout/item_detail" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</org.oxycblt.auxio.playback.PlaybackBarLayout>
</layout>

View file

@ -47,16 +47,23 @@
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
tools:layout="@layout/fragment_home_list" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/home_fab"
<org.oxycblt.auxio.home.FloatingActionButtonContainer
android:id="@+id/home_fab_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_shuffle"
android:layout_margin="@dimen/spacing_medium"
android:contentDescription="@string/desc_shuffle_all"
app:layout_anchor="@id/home_pager"
app:tint="?attr/colorControlNormal"
app:layout_anchorGravity="bottom|end" />
app:layout_anchorGravity="bottom|end">
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/home_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_shuffle"
android:layout_margin="@dimen/spacing_medium"
android:contentDescription="@string/desc_shuffle_all"
app:tint="?attr/colorOnPrimaryContainer" />
</org.oxycblt.auxio.home.FloatingActionButtonContainer>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

View file

@ -8,6 +8,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingBottom="88dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
tools:listitem="@layout/item_artist" />

View file

@ -4,8 +4,8 @@
xmlns:tools="http://schemas.android.com/tools"
tools:context=".MainFragment">
<LinearLayout
android:id="@+id/main_layout"
<org.oxycblt.auxio.playback.PlaybackBarLayout
android:id="@+id/main_bar_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
@ -15,20 +15,9 @@
android:id="@+id/explore_nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_height="match_parent"
app:navGraph="@navigation/nav_explore"
tools:layout="@layout/fragment_home" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/main_playback"
android:name="org.oxycblt.auxio.playback.CompactPlaybackFragment"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
android:clipToPadding="true"
android:elevation="@dimen/elevation_normal"
tools:layout="@layout/fragment_compact_playback" />
</LinearLayout>
</layout>
</org.oxycblt.auxio.playback.PlaybackBarLayout>
</layout>

View file

@ -29,7 +29,7 @@
</org.oxycblt.auxio.ui.LiftAppBarLayout>
<androidx.recyclerview.widget.RecyclerView
<org.oxycblt.auxio.ui.EdgeRecyclerView
android:id="@+id/queue_recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"

View file

@ -47,7 +47,7 @@
</org.oxycblt.auxio.ui.LiftAppBarLayout>
<androidx.recyclerview.widget.RecyclerView
<org.oxycblt.auxio.ui.EdgeRecyclerView
android:id="@+id/search_recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"

View file

@ -27,7 +27,6 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_medium"
android:layout_marginEnd="@dimen/spacing_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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_playback"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
android:clipToPadding="true"
android:elevation="@dimen/elevation_normal"
tools:layout="@layout/fragment_compact_playback" />

View file

@ -60,7 +60,7 @@
<item name="colorSurface">@android:color/system_accent2_800</item>
<item name="colorPrimary">?android:attr/colorAccent</item>
<item name="colorSecondary">?android:attr/colorAccent</item>
<item name="colorControlNormal">@color/m3_sys_color_dynamic_dark_inverse_surface</item>
<item name="colorControlNormal">?android:attr/colorControlNormal</item>
<item name="colorControlHighlight">?android:attr/colorControlHighlight</item>
</style>
</resources>

View file

@ -60,7 +60,7 @@
<item name="colorSurface">@android:color/system_accent1_50</item>
<item name="colorPrimary">?android:attr/colorAccent</item>
<item name="colorSecondary">?android:attr/colorAccent</item>
<item name="colorControlNormal">@color/m3_sys_color_dynamic_light_inverse_surface</item>
<item name="colorControlNormal">?android:attr/colorControlNormal</item>
<item name="colorControlHighlight">?android:attr/colorControlHighlight</item>
</style>
</resources>

View file

@ -4,4 +4,9 @@
<attr name="entries" format="reference" />
<attr name="entryValues" format="reference" />
</declare-styleable>
<attr format="enum" name="layout_role">
<enum name="content" value="0" />
<enum name="floating" value="1" />
</attr>
</resources>