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:
parent
9fc56c1c3d
commit
2d5c438c58
10 changed files with 137 additions and 81 deletions
|
@ -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() {
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue