From 1b79eb11e01761ec0c337d3660dd183e7aa34bec Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Sun, 7 Nov 2021 19:02:50 -0700 Subject: [PATCH] all: document custom stuff Document and clean up PlaybackBarLayout and the fast scroll views to an extent. --- .../oxycblt/auxio/excluded/ExcludedDialog.kt | 2 +- .../auxio/excluded/ExcludedViewModel.kt | 15 +- ...ckground.kt => FastScrollPopupDrawable.kt} | 156 ++++++------ .../home/fastscroll/FastScrollRecyclerView.kt | 81 +++--- .../java/org/oxycblt/auxio/music/Models.kt | 97 +++---- .../org/oxycblt/auxio/music/MusicLoader.kt | 4 +- .../auxio/playback/CompactPlaybackView.kt | 8 + .../auxio/playback/PlaybackBarLayout.kt | 239 ++++++++++++------ .../auxio/playback/queue/QueueAdapter.kt | 3 + .../auxio/playback/queue/QueueFragment.kt | 2 - .../java/org/oxycblt/auxio/util/ViewUtil.kt | 2 +- 11 files changed, 342 insertions(+), 267 deletions(-) rename app/src/main/java/org/oxycblt/auxio/home/fastscroll/{Md2PopupBackground.kt => FastScrollPopupDrawable.kt} (60%) diff --git a/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedDialog.kt b/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedDialog.kt index 5c390947c..2a7b9b210 100644 --- a/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedDialog.kt @@ -83,7 +83,7 @@ class ExcludedDialog : LifecycleDialog() { } dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener { - if (excludedModel.isModified()) { + if (excludedModel.isModified) { saveAndRestart() } else { dismiss() diff --git a/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedViewModel.kt b/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedViewModel.kt index dcd22b2aa..cb91c1fa6 100644 --- a/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedViewModel.kt @@ -33,13 +33,17 @@ import kotlinx.coroutines.withContext * of paths. Use [Factory] to instantiate this. * @author OxygenCobalt */ -class ExcludedViewModel(context: Context) : ViewModel() { +class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewModel() { private val mPaths = MutableLiveData(mutableListOf()) val paths: LiveData> get() = mPaths - private val excludedDatabase = ExcludedDatabase.getInstance(context) private var dbPaths = listOf() + /** + * Check if changes have been made to the ViewModel's paths. + */ + val isModified: Boolean get() = dbPaths != paths.value + init { loadDatabasePaths() } @@ -89,11 +93,6 @@ class ExcludedViewModel(context: Context) : ViewModel() { } } - /** - * Check if changes have been made to the ViewModel's paths. - */ - fun isModified() = dbPaths != paths.value - class Factory(private val context: Context) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { check(modelClass.isAssignableFrom(ExcludedViewModel::class.java)) { @@ -101,7 +100,7 @@ class ExcludedViewModel(context: Context) : ViewModel() { } @Suppress("UNCHECKED_CAST") - return ExcludedViewModel(context) as T + return ExcludedViewModel(ExcludedDatabase.getInstance(context)) as T } } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/Md2PopupBackground.kt b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupDrawable.kt similarity index 60% rename from app/src/main/java/org/oxycblt/auxio/home/fastscroll/Md2PopupBackground.kt rename to app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupDrawable.kt index a087a74f1..ba0fb1da6 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/Md2PopupBackground.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupDrawable.kt @@ -46,112 +46,114 @@ import kotlin.math.sqrt * !!! MODIFICATIONS !!!: * - Use modified Auxio resources instead of AFS resources * - Variable names are no longer prefixed with m - * - Suppressed deprecation warning when dealing with convexness + * - Made path management compat-friendly */ -class Md2PopupBackground(context: Context) : Drawable() { - private val paint: Paint = Paint() - private val paddingStart: Int - private val paddingEnd: Int +class FastScrollPopupDrawable(context: Context) : Drawable() { + private val paint: Paint = Paint().apply { + isAntiAlias = true + color = R.attr.colorControlActivated.resolveAttr(context) + style = Paint.Style.FILL + } + private val path = Path() - private val tempMatrix = Matrix() + private val matrix = Matrix() + + private val paddingStart = context.resources.getDimensionPixelOffset(R.dimen.spacing_medium) + private val paddingEnd = context.resources.getDimensionPixelOffset(R.dimen.popup_padding_end) override fun draw(canvas: Canvas) { canvas.drawPath(path, paint) } + override fun onBoundsChange(bounds: Rect) { + updatePath() + } + override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean { updatePath() return true } - override fun setAlpha(alpha: Int) {} - override fun setColorFilter(colorFilter: ColorFilter?) {} - override fun isAutoMirrored(): Boolean { - return true - } + @Suppress("DEPRECATION") + override fun getOutline(outline: Outline) { + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> outline.setPath(path) - private fun needMirroring(): Boolean { - return DrawableCompat.getLayoutDirection(this) == View.LAYOUT_DIRECTION_RTL - } + // Paths don't need to be convex on android Q, but the API was mislabeled and so + // we still have to use this method. + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> outline.setConvexPath(path) - override fun getOpacity(): Int { - return PixelFormat.TRANSLUCENT - } - - override fun onBoundsChange(bounds: Rect) { - updatePath() - } - - private fun updatePath() { - path.reset() - val bounds = bounds - var width = bounds.width().toFloat() - val height = bounds.height().toFloat() - val r = height / 2 - val sqrt2 = sqrt(2.0).toFloat() - // Ensure we are convex. - width = (r + sqrt2 * r).coerceAtLeast(width) - pathArcTo(path, r, r, r, 90f, 180f) - val o1X = width - sqrt2 * r - pathArcTo(path, o1X, r, r, -90f, 45f) - val r2 = r / 5 - val o2X = width - sqrt2 * r2 - pathArcTo(path, o2X, r, r2, -45f, 90f) - pathArcTo(path, o1X, r, r, 45f, 45f) - path.close() - if (needMirroring()) { - tempMatrix.setScale(-1f, 1f, width / 2, 0f) - } else { - tempMatrix.reset() + else -> if (!path.isConvex) { + // The outline path must be convex before Q, but we may run into floating point + // error caused by calculations involving sqrt(2) or OEM implementation differences, + // so in this case we just omit the shadow instead of crashing. + super.getOutline(outline) + } } - tempMatrix.postTranslate(bounds.left.toFloat(), bounds.top.toFloat()) - path.transform(tempMatrix) } override fun getPadding(padding: Rect): Boolean { - if (needMirroring()) { + if (isRtl) { padding[paddingEnd, 0, paddingStart] = 0 } else { padding[paddingStart, 0, paddingEnd] = 0 } + return true } - @Suppress("DEPRECATION") - override fun getOutline(outline: Outline) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && !path.isConvex) { - // The outline path must be convex before Q, but we may run into floating point error - // caused by calculation involving sqrt(2) or OEM implementation difference, so in this - // case we just omit the shadow instead of crashing. - super.getOutline(outline) - return + override fun isAutoMirrored(): Boolean = true + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + override fun setAlpha(alpha: Int) {} + override fun setColorFilter(colorFilter: ColorFilter?) {} + + private fun updatePath() { + path.reset() + + var width = bounds.width().toFloat() + val height = bounds.height().toFloat() + val r = height / 2 + val sqrt2 = sqrt(2.0).toFloat() + + // Ensure we are convex. + width = (r + sqrt2 * r).coerceAtLeast(width) + pathArcTo(path, r, r, r, 90f, 180f) + + val o1X = width - sqrt2 * r + pathArcTo(path, o1X, r, r, -90f, 45f) + + val r2 = r / 5 + val o2X = width - sqrt2 * r2 + pathArcTo(path, o2X, r, r2, -45f, 90f) + pathArcTo(path, o1X, r, r, 45f, 45f) + + path.close() + + if (isRtl) { + matrix.setScale(-1f, 1f, width / 2, 0f) + } else { + matrix.reset() } - outline.setConvexPath(path) + matrix.postTranslate(bounds.left.toFloat(), bounds.top.toFloat()) + + path.transform(matrix) } - companion object { - private fun pathArcTo( - path: Path, - centerX: Float, - centerY: Float, - radius: Float, - startAngle: Float, - sweepAngle: Float - ) { - path.arcTo( - centerX - radius, centerY - radius, centerX + radius, centerY + radius, - startAngle, sweepAngle, false - ) - } + private fun pathArcTo( + path: Path, + centerX: Float, + centerY: Float, + radius: Float, + startAngle: Float, + sweepAngle: Float + ) { + path.arcTo( + centerX - radius, centerY - radius, centerX + radius, centerY + radius, + startAngle, sweepAngle, false + ) } - init { - paint.isAntiAlias = true - paint.color = R.attr.colorControlActivated.resolveAttr(context) - paint.style = Paint.Style.FILL - val resources = context.resources - paddingStart = resources.getDimensionPixelOffset(R.dimen.spacing_medium) - paddingEnd = resources.getDimensionPixelOffset(R.dimen.popup_padding_end) - } + private val isRtl: Boolean get() = + DrawableCompat.getLayoutDirection(this) == View.LAYOUT_DIRECTION_RTL } diff --git a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt index f146bffcf..43d82151d 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt @@ -142,7 +142,7 @@ class FastScrollRecyclerView @JvmOverloads constructor( setLayoutParams(layoutParams) - background = Md2PopupBackground(context) + background = FastScrollPopupDrawable(context) elevation = resources.getDimensionPixelOffset(R.dimen.elevation_normal).toFloat() ellipsize = TextUtils.TruncateAt.MIDDLE gravity = Gravity.CENTER @@ -335,7 +335,7 @@ class FastScrollRecyclerView @JvmOverloads constructor( getDecoratedBoundsWithMargins(getChildAt(0), childRect) val scrollOffset = paddingTop + (firstAdapterPos * itemHeight) - childRect.top - // Finally, we can calculate the thumb position, which is just: + // Then calculate the thumb position, which is just: // [proportion of scroll position to scroll range] * [total thumb range] thumbOffset = (thumbOffsetRange.toLong() * scrollOffset / scrollOffsetRange).toInt() } @@ -396,25 +396,14 @@ class FastScrollRecyclerView @JvmOverloads constructor( } private fun isInViewTouchTarget(view: View, x: Float, y: Float): Boolean { - val scrollX = scrollX - val scrollY = scrollY - return ( - isInTouchTarget( - x, view.left - scrollX, view.right - scrollX, 0, - width - ) && - isInTouchTarget( - y, view.top - scrollY, view.bottom - scrollY, 0, - height - ) - ) + return isInTouchTarget(x, view.left - scrollX, view.right - scrollX, width) && + isInTouchTarget(y, view.top - scrollY, view.bottom - scrollY, height) } private fun isInTouchTarget( position: Float, viewStart: Int, viewEnd: Int, - parentStart: Int, parentEnd: Int ): Boolean { val viewSize = viewEnd - viewStart @@ -424,16 +413,18 @@ class FastScrollRecyclerView @JvmOverloads constructor( } var touchTargetStart = viewStart - (minTouchTargetSize - viewSize) / 2 - if (touchTargetStart < parentStart) { - touchTargetStart = parentStart + + if (touchTargetStart < 0) { + touchTargetStart = 0 } var touchTargetEnd = touchTargetStart + minTouchTargetSize if (touchTargetEnd > parentEnd) { touchTargetEnd = parentEnd touchTargetStart = touchTargetEnd - minTouchTargetSize - if (touchTargetStart < parentStart) { - touchTargetStart = parentStart + + if (touchTargetStart < 0) { + touchTargetStart = 0 } } @@ -441,10 +432,11 @@ class FastScrollRecyclerView @JvmOverloads constructor( } private fun scrollToThumbOffset(thumbOffset: Int) { - var newThumbOffset = thumbOffset - newThumbOffset = MathUtils.clamp(newThumbOffset, 0, thumbOffsetRange) - var scrollOffset = (scrollOffsetRange.toLong() * newThumbOffset / thumbOffsetRange).toInt() - scrollOffset -= paddingTop + val clampedThumbOffset = MathUtils.clamp(thumbOffset, 0, thumbOffsetRange) + + val scrollOffset = ( + scrollOffsetRange.toLong() * clampedThumbOffset / thumbOffsetRange + ).toInt() - paddingTop scrollTo(scrollOffset) } @@ -454,6 +446,7 @@ class FastScrollRecyclerView @JvmOverloads constructor( val trueOffset = offset - paddingTop val itemHeight = itemHeight + val firstItemPosition = 0.coerceAtLeast(trueOffset / itemHeight) val firstItemTop = firstItemPosition * itemHeight - trueOffset @@ -557,32 +550,11 @@ class FastScrollRecyclerView @JvmOverloads constructor( private val isRtl: Boolean get() = layoutDirection == LAYOUT_DIRECTION_RTL - private val scrollOffsetRange: Int - get() = scrollRange - height - private val thumbOffsetRange: Int get() { return height - scrollerPadding.top - scrollerPadding.bottom - thumbHeight } - private val itemCount: Int - get() = when (val mgr = layoutManager) { - is GridLayoutManager -> (mgr.itemCount - 1) / mgr.spanCount + 1 - is LinearLayoutManager -> mgr.itemCount - else -> 0 - } - - private val itemHeight: Int - get() { - if (childCount == 0) { - return 0 - } - - val itemView = getChildAt(0) - getDecoratedBoundsWithMargins(itemView, childRect) - return childRect.height() - } - private val scrollRange: Int get() { val itemCount = itemCount @@ -600,6 +572,9 @@ class FastScrollRecyclerView @JvmOverloads constructor( } } + private val scrollOffsetRange: Int + get() = scrollRange - height + private val firstAdapterPos: Int get() { if (childCount == 0) { @@ -615,6 +590,24 @@ class FastScrollRecyclerView @JvmOverloads constructor( } } + private val itemHeight: Int + get() { + if (childCount == 0) { + return 0 + } + + val itemView = getChildAt(0) + getDecoratedBoundsWithMargins(itemView, childRect) + return childRect.height() + } + + private val itemCount: Int + get() = when (val mgr = layoutManager) { + is GridLayoutManager -> (mgr.itemCount - 1) / mgr.spanCount + 1 + is LinearLayoutManager -> mgr.itemCount + else -> 0 + } + companion object { private const val ANIM_MILLIS = 150L private const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500 diff --git a/app/src/main/java/org/oxycblt/auxio/music/Models.kt b/app/src/main/java/org/oxycblt/auxio/music/Models.kt index c6d7ea2a1..34263a7ee 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Models.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Models.kt @@ -44,10 +44,10 @@ sealed class Music : BaseModel() { } /** - * [BaseModel] variant that denotes that this object is a parent of other data objects, such + * [Music] variant that denotes that this object is a parent of other data objects, such * as an [Album] or [Artist] * @property resolvedName A name resolved from it's raw form to a form suitable to be shown in - * a ui. Ex. unknown would become Unknown Artist, (124) would become its proper genre name, etc. + * a ui. Ex. "unknown" would become Unknown Artist, (124) would become its proper genre name, etc. */ sealed class MusicParent : Music() { abstract val resolvedName: String @@ -200,10 +200,55 @@ data class Genre( override val hash = name.hashCode().toLong() } +/** + * A data object used solely for the "Header" UI element. + * @see HeaderString + */ +data class Header( + override val id: Long, + val string: HeaderString +) : BaseModel() + +/** + * A data object used for an action header. Like [Header], but with a button. + * @see Header + */ +data class ActionHeader( + override val id: Long, + val string: HeaderString, + @DrawableRes val icon: Int, + @StringRes val desc: Int, + val onClick: (View) -> Unit, +) : BaseModel() { + // JVM can't into comparing lambdas, so we override equals/hashCode and exclude them. + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ActionHeader) return false + + if (id != other.id) return false + if (string != other.string) return false + if (icon != other.icon) return false + if (desc != other.desc) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + string.hashCode() + result = 31 * result + icon + result = 31 * result + desc + + return result + } +} + /** * The string used for a header instance. This class is a bit complex, mostly because it revolves - * around passing string resources that are then resolved by the view instead of passing a context - * directly. + * around passing string resources that are then resolved by the view. This is because ViewModel + * instance should preferably not have access to a Context but should still generate data, + * which at times can include [Header] instances that require string resources. * @author OxygenCobalt */ sealed class HeaderString { @@ -272,47 +317,3 @@ sealed class HeaderString { } } } - -/** - * A data object used solely for the "Header" UI element. - * @see HeaderString - */ -data class Header( - override val id: Long, - val string: HeaderString -) : BaseModel() - -/** - * A data object used for an action header. Like [Header], but with a button. - * @see HeaderString - */ -data class ActionHeader( - override val id: Long, - val string: HeaderString, - @DrawableRes val icon: Int, - @StringRes val desc: Int, - val onClick: (View) -> Unit, -) : BaseModel() { - // JVM can't into comparing lambdas, so we override equals/hashCode and exclude them. - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is ActionHeader) return false - - if (id != other.id) return false - if (string != other.string) return false - if (icon != other.icon) return false - if (desc != other.desc) return false - - return true - } - - override fun hashCode(): Int { - var result = id.hashCode() - result = 31 * result + string.hashCode() - result = 31 * result + icon - result = 31 * result + desc - - return result - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt index 24e40cca1..50617bcc3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt @@ -45,7 +45,7 @@ import org.oxycblt.auxio.util.logD * have to query for each genre, query all the songs in each genre, and then iterate through those * songs to link every song with their genre. This is not documented anywhere, and the * O(mom im scared) algorithm you have to run to get it working single-handedly DOUBLES Auxio's - * loading times. At no point have the devs considered that this column is absolutely busted, and + * loading times. At no point have the devs considered that this column is absolutely insane, and * instead focused on adding infuriat- I mean nice proprietary extensions to MediaStore for their * own Google Play Music, and we all know how great that worked out! * @@ -221,7 +221,7 @@ class MusicLoader(private val context: Context) { } songs = songs.distinctBy { - it.name to it.albumId to it.artistName to it.track to it.duration + it.name to it.albumName to it.artistName to it.track to it.duration }.toMutableList() logD("Song search finished with ${songs.size} found") diff --git a/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackView.kt b/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackView.kt index 974a90a85..fc8ae0ac3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackView.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackView.kt @@ -22,7 +22,9 @@ import android.content.Context import android.content.res.ColorStateList import android.graphics.drawable.RippleDrawable import android.util.AttributeSet +import android.view.WindowInsets import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.updatePadding import com.google.android.material.shape.MaterialShapeDrawable import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.ViewCompactPlaybackBinding @@ -30,6 +32,7 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.resolveAttr import org.oxycblt.auxio.util.resolveDrawable +import org.oxycblt.auxio.util.systemBarsCompat /** * A view displaying the playback state in a compact manner. This is only meant to be used @@ -82,6 +85,11 @@ class CompactPlaybackView @JvmOverloads constructor( } } + override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { + updatePadding(bottom = insets.systemBarsCompat.bottom) + return insets + } + fun setSong(song: Song) { binding.song = song binding.executePendingBindings() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarLayout.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarLayout.kt index b70674cdf..68d3c566e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarLayout.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarLayout.kt @@ -28,7 +28,6 @@ import android.view.WindowInsets import androidx.annotation.AttrRes import androidx.annotation.StyleRes import androidx.core.view.children -import androidx.core.view.updatePadding import androidx.customview.widget.ViewDragHelper import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.systemBarsCompat @@ -36,11 +35,12 @@ 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. + * this class was primarily written by me. * - * TODO: Explain how this thing works so that others can be spared the pain of deciphering - * this custom viewgroup + * TODO: Add a swipe-up behavior a la Phonograph. I think that would improve UX. + * TODO: Leverage this layout to make more tablet-friendly UIs + * + * @author OxygenCobalt */ class PlaybackBarLayout @JvmOverloads constructor( context: Context, @@ -49,12 +49,14 @@ class PlaybackBarLayout @JvmOverloads constructor( @StyleRes defStyleRes: Int = 0 ) : ViewGroup(context, attrs, defStyleAttr, defStyleRes) { private val playbackView = CompactPlaybackView(context) - private var barDragHelper = ViewDragHelper.create(this, ViewDragCallback()) + private var barDragHelper = ViewDragHelper.create(this, BarDragCallback()) private var lastInsets: WindowInsets? = null init { addView(playbackView) + // playbackView is special as it's the view we treat as a bottom bar. + // Mark it as such. (playbackView.layoutParams as LayoutParams).apply { width = ViewGroup.LayoutParams.MATCH_PARENT height = ViewGroup.LayoutParams.WRAP_CONTENT @@ -70,16 +72,21 @@ class PlaybackBarLayout @JvmOverloads constructor( setMeasuredDimension(widthSize, heightSize) + // Measure the bar view so that it fills the whole screen and takes up the bottom views. val barParams = playbackView.layoutParams as LayoutParams val barWidthSpec = getChildMeasureSpec(widthMeasureSpec, 0, barParams.width) val barHeightSpec = getChildMeasureSpec(heightMeasureSpec, 0, barParams.height) playbackView.measure(barWidthSpec, barHeightSpec) - updateWindowInsets() + applyContentWindowInsets() measureContent() } + /** + * Measure the content views in this layout. This is done separately as at times we want + * to relayout the content views but not relayout the bar view. + */ private fun measureContent() { val barParams = playbackView.layoutParams as LayoutParams val barHeightAdjusted = (playbackView.measuredHeight * barParams.offset).toInt() @@ -103,24 +110,22 @@ class PlaybackBarLayout @JvmOverloads constructor( override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { val barHeight = playbackView.measuredHeight - val barHeightAdjusted = (barHeight * (playbackView.layoutParams as LayoutParams).offset).toInt() + val barParams = (playbackView.layoutParams as LayoutParams) + val barHeightAdjusted = (barHeight * barParams.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) - ) - } - } + // Again, lay out our view like we measured it. + playbackView.layout( + 0, height - barHeightAdjusted, + width, height + (barHeight - barHeightAdjusted) + ) layoutContent() } + /** + * Layout the content views in this layout. This is done separately as at times we want + * to relayout the content views but not relayout the bar view. + */ private fun layoutContent() { for (child in children) { val childParams = child.layoutParams as LayoutParams @@ -132,15 +137,79 @@ class PlaybackBarLayout @JvmOverloads constructor( } override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets { - playbackView.updatePadding(bottom = insets.systemBarsCompat.bottom) + // Applying window insets is the real special sauce of this layout. The problem with + // having a bottom bar is that if you support edge-to-edge, applying insets to views + // will result in spacing being incorrect whenever the bar is shown. If you cleverly + // modify the insets however, you can make all content views remove their spacing as + // the bar enters. This function itself is unimportant, so you should probably take + // a look at applyContentWindowInsets and adjustInsets instead. + playbackView.onApplyWindowInsets(insets) lastInsets = insets - updateWindowInsets() + applyContentWindowInsets() return insets } + /** + * Apply window insets to the content views in this layouts. This is done separately as at + * times we want to relayout the content views but not relayout the bar view. + */ + private fun applyContentWindowInsets() { + val insets = lastInsets + + if (insets != null) { + val adjustedInsets = adjustInsets(insets) + + for (child in children) { + val childParams = child.layoutParams as LayoutParams + + if (!childParams.isBar) { + child.dispatchApplyWindowInsets(adjustedInsets) + } + } + } + } + + /** + * Adjust window insets to line up with the playback bar + */ + private fun adjustInsets(insets: WindowInsets): WindowInsets { + // Find how much space the bar is consuming right now. We use this to modify + // the bottom window inset so that the spacing checks out, 0 if the bar is fully + // shown and the original value if the bar is hidden. + val barParams = playbackView.layoutParams as LayoutParams + val barConsumedInset = (playbackView.measuredHeight * barParams.offset).toInt() + + val bars = insets.systemBarsCompat + val adjustedBottomInset = (bars.bottom - barConsumedInset).coerceAtLeast(0) + + return when { + // Android R. Modify insets using their new method that exists for no reason + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { + WindowInsets.Builder(insets) + .setInsets( + WindowInsets.Type.systemBars(), + Insets.of(bars.left, bars.top, bars.right, adjustedBottomInset) + ) + .build() + } + + // Android O. Modify insets using the original method + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 -> { + @Suppress("DEPRECATION") + insets.replaceSystemWindowInsets( + bars.left, bars.top, bars.right, adjustedBottomInset + ) + } + + else -> insets + } + } + override fun computeScroll() { + // Copied this from MaterialFiles. + // Don't know what this does, but it seems important so I just keep it around. if (barDragHelper.continueSettling(true)) { postInvalidateOnAnimation() } @@ -149,52 +218,15 @@ class PlaybackBarLayout @JvmOverloads constructor( override fun onDetachedFromWindow() { super.onDetachedFromWindow() + // Prevent memory leaks playbackView.clearCallback() } - private fun updateWindowInsets() { - val insets = lastInsets - - if (insets != null) { - val adjustedInsets = adjustInsets(insets) - - for (child in children) { - child.dispatchApplyWindowInsets(adjustedInsets) - } - } - } - - private fun adjustInsets(insets: WindowInsets): WindowInsets { - val barParams = playbackView.layoutParams as LayoutParams - val childConsumedInset = (playbackView.measuredHeight * barParams.offset).toInt() - - val bars = insets.systemBarsCompat - - return when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { - WindowInsets.Builder(insets) - .setInsets( - WindowInsets.Type.systemBars(), - Insets.of( - bars.left, bars.top, - bars.right, (bars.bottom - childConsumedInset).coerceAtLeast(0) - ) - ) - .build() - } - - Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 -> { - @Suppress("DEPRECATION") - insets.replaceSystemWindowInsets( - bars.left, bars.top, - bars.right, (bars.bottom - childConsumedInset).coerceAtLeast(0) - ) - } - - else -> insets - } - } - + /** + * Update the song that this layout is showing. This will be reflected in the compact view + * at the bottom of the screen. + * @param animate Whether to animate bar showing/hiding events. + */ fun setSong(song: Song?, animate: Boolean = false) { if (song != null) { showBar(animate) @@ -204,14 +236,25 @@ class PlaybackBarLayout @JvmOverloads constructor( } } + /** + * Update the playing status on this layout. This will be reflected in the compact view + * at the bottom of the screen. + */ fun setPlaying(isPlaying: Boolean) { playbackView.setPlaying(isPlaying) } + /** + * Update the playback positon on this layout. This will be reflected in the compact view + * at the bottom of the screen. + */ fun setPosition(position: Long) { playbackView.setPosition(position) } + /** + * Add a callback for actions from the compact playback view in this layout. + */ fun setActionCallback(callback: ActionCallback) { playbackView.setCallback(callback) } @@ -220,20 +263,25 @@ class PlaybackBarLayout @JvmOverloads constructor( val barParams = playbackView.layoutParams as LayoutParams if (barParams.shown || barParams.offset == 1f) { + // Already showed the bar, don't do it again. return } barParams.shown = true if (animate) { + // Animate, use our drag helper to slide the view upwards. All invalidation is done + // in the callback. barDragHelper.smoothSlideViewTo( playbackView, playbackView.left, height - playbackView.height ) } else { + // Don't animate, snap the view and invalidate the content views if we are already + // laid out. Otherwise we will do it later so don't waste time now. barParams.offset = 1f if (isLaidOut) { - updateWindowInsets() + applyContentWindowInsets() measureContent() layoutContent() } @@ -246,20 +294,25 @@ class PlaybackBarLayout @JvmOverloads constructor( val barParams = playbackView.layoutParams as LayoutParams if (barParams.shown || barParams.offset == 0f) { + // Already hid the bar, don't do it again. return } barParams.shown = false if (animate) { + // Animate, use our drag helper to slide the view upwards. All invalidation is done + // in the callback. barDragHelper.smoothSlideViewTo( playbackView, playbackView.left, height ) } else { + // Don't animate, snap the view and invalidate the content views if we are already + // laid out. Otherwise we will do it later so don't waste time now. barParams.offset = 0f if (isLaidOut) { - updateWindowInsets() + applyContentWindowInsets() measureContent() layoutContent() } @@ -289,7 +342,25 @@ class PlaybackBarLayout @JvmOverloads constructor( override fun checkLayoutParams(layoutParams: ViewGroup.LayoutParams): Boolean = layoutParams is LayoutParams && super.checkLayoutParams(layoutParams) - class LayoutParams : ViewGroup.LayoutParams { + /** + * A callback for actions done from this view. This fragment can inherit this and recieve + * updates from the compact playback view in this layout that can then be sent to the + * internal playback engine. + * + * There is no need to clear this callback when done, the view clears it itself when the + * view is detached. + */ + interface ActionCallback { + fun onPlayPauseClick() + fun onNavToItem() + fun onNavToPlayback() + } + + /** + * Layout parameters for this layout. This layout is meant to be a black box with only two + * types of views, so this implementation is kept private. + */ + private class LayoutParams : ViewGroup.LayoutParams { var isBar = false var shown = false var offset = 0f @@ -303,13 +374,11 @@ class PlaybackBarLayout @JvmOverloads constructor( constructor(source: ViewGroup.LayoutParams) : super(source) } - interface ActionCallback { - fun onPlayPauseClick() - fun onNavToItem() - fun onNavToPlayback() - } - - private inner class ViewDragCallback : ViewDragHelper.Callback() { + /** + * Internal drag callback for animating the bar view showing/hiding. + */ + private inner class BarDragCallback : ViewDragHelper.Callback() { + // We aren't actually dragging things. Ignore this. override fun tryCaptureView(child: View, pointerId: Int): Boolean = false override fun onViewPositionChanged( @@ -320,12 +389,13 @@ class PlaybackBarLayout @JvmOverloads constructor( dy: Int ) { val childRange = getViewVerticalDragRange(changedView) - val childLayoutParams = changedView.layoutParams as LayoutParams + val childParams = changedView.layoutParams as LayoutParams - val height = height - childLayoutParams.offset = (height - top).toFloat() / childRange + // Find the new offset that this view takes up after an animation frame. + childParams.offset = (height - top).toFloat() / childRange - updateWindowInsets() + // Invalidate our content views so that they accurately reflect the bar now. + applyContentWindowInsets() measureContent() layoutContent() } @@ -333,13 +403,14 @@ class PlaybackBarLayout @JvmOverloads constructor( override fun getViewVerticalDragRange(child: View): Int { val childParams = child.layoutParams as LayoutParams - return if (childParams.isBar) { - child.height - } else { - 0 - } + // Sanity check + check(childParams.isBar) { "This drag helper is only meant for content views" } + + return child.height } + // Don't really know what these do but they're needed + override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int = child.left override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt index 8f74f463c..87c415986 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt @@ -22,6 +22,7 @@ import android.annotation.SuppressLint import android.view.MotionEvent import android.view.View import android.view.ViewGroup +import androidx.core.view.isInvisible import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView @@ -157,6 +158,8 @@ class QueueAdapter( override fun onBind(data: Song) { binding.song = data + binding.background.isInvisible = true + binding.songName.requestLayout() binding.songInfo.requestLayout() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt index e7d1fd40d..6df3d3f83 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt @@ -87,6 +87,4 @@ class QueueFragment : Fragment() { return binding.root } - - // --- QUEUE DATA --- } diff --git a/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt index 76bfd2a13..134f19ed1 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt @@ -140,7 +140,7 @@ fun @receiver:AttrRes Int.resolveAttr(context: Context): Int { /** * Resolve window insets in a version-aware manner. This can be used to apply padding to - * a view that properly + * a view that properly follows all the frustrating changes that were made between 8-11. */ val WindowInsets.systemBarsCompat: Rect get() { return when {