playback: improve queue ui

Improve the queue UI some more:
- Fixed an issue where clearing the user queue by clearing all items would
result in it bugging out
- Queue items now show a Material-y background when they are swiped
away. This was way harder than you might think it was.
This commit is contained in:
OxygenCobalt 2021-10-11 20:32:23 -06:00
parent 9fc56c1c3d
commit 2d5c438c58
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
10 changed files with 137 additions and 81 deletions

View file

@ -60,7 +60,6 @@ import org.oxycblt.auxio.util.makeScrollingViewFade
* - Edge-to-edge is borked still, unsure how to really fix this aside from making some * - Edge-to-edge is borked still, unsure how to really fix this aside from making some
* magic layout like Material Files, but even then it might not work since the scrolling * magic layout like Material Files, but even then it might not work since the scrolling
* views are not laid side-by-side to the layout itself. * views are not laid side-by-side to the layout itself.
* So excited to have enough time to get to these in like...november.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class HomeFragment : Fragment() { class HomeFragment : Fragment() {

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.playback.queue
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
@ -35,6 +36,7 @@ import org.oxycblt.auxio.ui.DiffCallback
import org.oxycblt.auxio.ui.HeaderViewHolder import org.oxycblt.auxio.ui.HeaderViewHolder
import org.oxycblt.auxio.util.applyMaterialDrawable import org.oxycblt.auxio.util.applyMaterialDrawable
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
/** /**
@ -113,6 +115,8 @@ class QueueAdapter(
fun removeItem(adapterIndex: Int) { fun removeItem(adapterIndex: Int) {
data.removeAt(adapterIndex) data.removeAt(adapterIndex)
logD(data)
/* /*
* If the data from the next queue is now entirely empty [Signified by a header at the * If the data from the next queue is now entirely empty [Signified by a header at the
* end, remove the next queue header as notify as such. * end, remove the next queue header as notify as such.
@ -123,14 +127,15 @@ class QueueAdapter(
* Otherwise just remove the item as usual. * Otherwise just remove the item as usual.
*/ */
if (data[data.lastIndex] is Header) { if (data[data.lastIndex] is Header) {
logD("Queue is empty, removing header")
val lastIndex = data.lastIndex val lastIndex = data.lastIndex
data.removeAt(lastIndex) data.removeAt(lastIndex)
notifyItemRangeRemoved(lastIndex, 2) notifyItemRangeRemoved(lastIndex, 2)
} else if (data.lastIndex >= 1 && data[0] is Header && data[1] is Header) { } else if (data.lastIndex >= 1 && data[0] is ActionHeader && data[1] is Header) {
data.removeAt(0) logD("User queue is empty, removing header")
data.removeAt(0)
notifyItemRangeRemoved(0, 2) notifyItemRangeRemoved(0, 2)
} else { } else {
notifyItemRemoved(adapterIndex) notifyItemRemoved(adapterIndex)
@ -143,9 +148,11 @@ class QueueAdapter(
inner class QueueSongViewHolder( inner class QueueSongViewHolder(
private val binding: ItemQueueSongBinding, private val binding: ItemQueueSongBinding,
) : BaseViewHolder<Song>(binding) { ) : BaseViewHolder<Song>(binding) {
val bodyView: View get() = binding.body
val backgroundView: View get() = binding.background
init { init {
binding.root.applyMaterialDrawable() binding.body.applyMaterialDrawable()
} }
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.playback.queue
import android.graphics.Canvas import android.graphics.Canvas
import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AccelerateDecelerateInterpolator
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
@ -31,8 +32,9 @@ import kotlin.math.min
import kotlin.math.sign import kotlin.math.sign
/** /**
* A highly customized [ItemTouchHelper.Callback] that handles queue item moving, removal, and some * A highly customized [ItemTouchHelper.Callback] that handles the queue system while basically
* of the UI magic that makes up the queue UI. * rebuilding most the "Material-y" aspects of an editable list because Google's implementations
* are hot garbage. This shouldn't have *too many* UI bugs. I hope.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouchHelper.Callback() { class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouchHelper.Callback() {
@ -88,48 +90,63 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
// themselves when being dragged. Too bad google's implementation of this doesn't even // themselves when being dragged. Too bad google's implementation of this doesn't even
// work! To emulate it on my own, I check if this child is in a drag state and then animate // work! To emulate it on my own, I check if this child is in a drag state and then animate
// an elevation change. // an elevation change.
// TODO: Maybe restrict the item from being drawn over the recycler bounds? // TODO: Some other enhancements I could make maybe
// Seems like its possible with enough UI magic // - Maybe stopping dragged items from extending beyond their specific part of the queue?
// TODO: Add an accented BG to the removal action
val view = viewHolder.itemView val holder = viewHolder as QueueAdapter.QueueSongViewHolder
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) { if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
val bg = view.background as MaterialShapeDrawable val bg = holder.bodyView.background as MaterialShapeDrawable
val elevation = recyclerView.resources.getDimension(R.dimen.elevation_small)
view.animate() holder.itemView.animate()
.translationZ(view.resources.getDimension(R.dimen.elevation_small)) .translationZ(elevation)
.setDuration(100) .setDuration(100)
.setUpdateListener { bg.elevation = view.translationZ } .setUpdateListener {
bg.elevation = holder.itemView.translationZ
}
.setInterpolator(AccelerateDecelerateInterpolator()) .setInterpolator(AccelerateDecelerateInterpolator())
.start() .start()
shouldLift = false shouldLift = false
} }
view.translationX = dX // We show a background with a clear icon behind the queue song each time one is swiped
view.translationY = dY // away. To avoid any canvas shenanigans, we just place a custom background view behind the
// main "body" layout of the queue item and then translate that.
//
// That comes with a couple of problems, however. For one, the background view will always
// lag behind the body view, resulting in a noticeable pixel offset when dragging. To fix
// this, we make this a separate view and make this view invisible whenever the item is
// not being swiped. We cannot merge this view with the FrameLayout, as that will cause
// another weird pixel desync issue that is less visible but still incredibly annoying.
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
holder.backgroundView.isInvisible = dX == 0f
}
holder.bodyView.translationX = dX
holder.itemView.translationY = dY
} }
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
// When an elevated item is cleared, we reset the elevation using another animation. // When an elevated item is cleared, we reset the elevation using another animation.
val view = viewHolder.itemView val holder = viewHolder as QueueAdapter.QueueSongViewHolder
if (view.translationZ != 0.0f) { if (holder.itemView.translationZ != 0.0f) {
val bg = view.background as MaterialShapeDrawable val bg = holder.bodyView.background as MaterialShapeDrawable
view.animate() holder.itemView.animate()
.translationZ(0.0f) .translationZ(0.0f)
.setDuration(100) .setDuration(100)
.setUpdateListener { bg.elevation = view.translationZ } .setUpdateListener { bg.elevation = holder.itemView.translationZ }
.setInterpolator(AccelerateDecelerateInterpolator()) .setInterpolator(AccelerateDecelerateInterpolator())
.start() .start()
} }
shouldLift = true shouldLift = true
view.translationX = 0f holder.bodyView.translationX = 0f
view.translationY = 0f holder.itemView.translationY = 0f
} }
override fun onMove( override fun onMove(

View file

@ -34,8 +34,6 @@ import org.oxycblt.auxio.util.logE
* will not properly respond to RecyclerView events. * will not properly respond to RecyclerView events.
* **Note:** This layout relies on [AppBarLayout.liftOnScrollTargetViewId] to figure out what * **Note:** This layout relies on [AppBarLayout.liftOnScrollTargetViewId] to figure out what
* scrolling view to use. Failure to specify this will result in the layout not working. * scrolling view to use. Failure to specify this will result in the layout not working.
* FIXME: Fix issue where elevation change will always animate
* FIXME: Fix issue where expanded state does not work correctly when switching orientations
*/ */
class LiftAppBarLayout @JvmOverloads constructor( class LiftAppBarLayout @JvmOverloads constructor(
context: Context, context: Context,

View file

@ -30,6 +30,7 @@ import android.view.WindowInsets
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.ColorRes import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -111,6 +112,15 @@ fun @receiver:ColorRes Int.resolveColor(context: Context): Int {
fun @receiver:ColorRes Int.resolveStateList(context: Context) = fun @receiver:ColorRes Int.resolveStateList(context: Context) =
ContextCompat.getColorStateList(context, this) ContextCompat.getColorStateList(context, this)
/*
* Resolve a color and turn it into a [ColorStateList]
* @param context [Context] required
* @return The resolved color as a [ColorStateList]
* @see resolveColor
*/
fun @receiver:DrawableRes Int.resolveDrawable(context: Context) =
requireNotNull(ContextCompat.getDrawable(context, this))
/** /**
* Resolve this int into a color as if it was an attribute * Resolve this int into a color as if it was an attribute
*/ */

View file

@ -6,5 +6,6 @@
android:viewportHeight="108"> android:viewportHeight="108">
<path <path
android:fillColor="#111111" android:fillColor="#111111"
android:strokeColor="#00000000"
android:pathData="M0 0h108v108H0z" /> android:pathData="M0 0h108v108H0z" />
</vector> </vector>

View file

@ -6,7 +6,7 @@
android:viewportHeight="108"> android:viewportHeight="108">
<path <path
android:pathData="M54 29.83v28.333c-1.585-0.913-3.41-1.477-5.371-1.477-5.935 0-10.743 4.807-10.743 10.743 0 5.935 4.807 10.742 10.743 10.742 5.935 0 10.743-4.807 10.743-10.742V40.573h10.742V29.831z" android:pathData="M54 29.83v28.333c-1.585-0.913-3.41-1.477-5.371-1.477-5.935 0-10.743 4.807-10.743 10.743 0 5.935 4.807 10.742 10.743 10.742 5.935 0 10.743-4.807 10.743-10.742V40.573h10.742V29.831z"
android:strokeColor="#ffffff" android:strokeColor="#00000000"
android:fillColor="@color/fill_icon_fg"> android:fillColor="@color/fill_icon_fg">
</path> </path>
</vector> </vector>

View file

@ -11,7 +11,29 @@
type="org.oxycblt.auxio.music.Song" /> type="org.oxycblt.auxio.music.Song" />
</data> </data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface">
<View
android:id="@+id/background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorPrimary"
android:visibility="invisible"/>
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_clear"
android:layout_gravity="end|center_vertical"
android:padding="@dimen/spacing_medium"
app:tint="?attr/colorSurface"
android:contentDescription="@string/desc_clear_queue_item"/>
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/body"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?attr/colorSurface"> android:background="?attr/colorSurface">
@ -58,13 +80,13 @@
android:id="@+id/song_drag_handle" android:id="@+id/song_drag_handle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:clickable="true"
android:contentDescription="@string/desc_queue_handle"
android:focusable="true"
android:minWidth="@dimen/size_btn_small" android:minWidth="@dimen/size_btn_small"
android:minHeight="@dimen/size_btn_small" android:minHeight="@dimen/size_btn_small"
android:paddingStart="@dimen/spacing_medium" android:paddingStart="@dimen/spacing_medium"
android:paddingEnd="@dimen/spacing_medium" android:paddingEnd="@dimen/spacing_medium"
android:clickable="true"
android:contentDescription="@string/desc_queue_handle"
android:focusable="true"
android:scaleType="center" android:scaleType="center"
android:src="@drawable/ic_handle" android:src="@drawable/ic_handle"
app:layout_constraintBottom_toBottomOf="@+id/album_cover" app:layout_constraintBottom_toBottomOf="@+id/album_cover"
@ -72,4 +94,5 @@
app:layout_constraintTop_toTopOf="@+id/song_name" /> app:layout_constraintTop_toTopOf="@+id/song_name" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
</layout> </layout>

View file

@ -119,6 +119,7 @@
<string name="desc_shuffle">Turn shuffle on or off</string> <string name="desc_shuffle">Turn shuffle on or off</string>
<string name="desc_clear_user_queue">Clear queue</string> <string name="desc_clear_user_queue">Clear queue</string>
<string name="desc_clear_queue_item">Remove this queue item</string>
<string name="desc_queue_handle">Move queue song</string> <string name="desc_queue_handle">Move queue song</string>
<string name="desc_clear_search">Clear search query</string> <string name="desc_clear_search">Clear search query</string>
<string name="desc_blacklist_delete">Remove excluded directory</string> <string name="desc_blacklist_delete">Remove excluded directory</string>

View file

@ -9,7 +9,7 @@ buildscript {
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:7.0.2' classpath 'com.android.tools.build:gradle:7.0.3'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version" classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version"