home: fix more speed dial touch problems

Handle back presses gracefully without finicky behavior when doing back
gestures.

I've spent far too much time trying to make this sensible. I'm going to
take a break.
This commit is contained in:
Alexander Capehart 2024-01-02 17:16:13 -07:00
parent 7537d135f2
commit 2b55caadd1
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
5 changed files with 102 additions and 12 deletions

View file

@ -76,6 +76,7 @@ class MainFragment :
private var sheetBackCallback: SheetBackPressedCallback? = null private var sheetBackCallback: SheetBackPressedCallback? = null
private var detailBackCallback: DetailBackPressedCallback? = null private var detailBackCallback: DetailBackPressedCallback? = null
private var selectionBackCallback: SelectionBackPressedCallback? = null private var selectionBackCallback: SelectionBackPressedCallback? = null
private var speedDialBackCallback: SpeedDialBackPressedCallback? = null
private var selectionNavigationListener: DialogAwareNavigationListener? = null private var selectionNavigationListener: DialogAwareNavigationListener? = null
private var lastInsets: WindowInsets? = null private var lastInsets: WindowInsets? = null
private var elevationNormal = 0f private var elevationNormal = 0f
@ -109,6 +110,8 @@ class MainFragment :
DetailBackPressedCallback(detailModel).also { detailBackCallback = it } DetailBackPressedCallback(detailModel).also { detailBackCallback = it }
val selectionBackCallback = val selectionBackCallback =
SelectionBackPressedCallback(listModel).also { selectionBackCallback = it } SelectionBackPressedCallback(listModel).also { selectionBackCallback = it }
val speedDialBackCallback =
SpeedDialBackPressedCallback(homeModel).also { speedDialBackCallback = it }
selectionNavigationListener = DialogAwareNavigationListener(listModel::dropSelection) selectionNavigationListener = DialogAwareNavigationListener(listModel::dropSelection)
@ -158,6 +161,7 @@ class MainFragment :
collect(detailModel.toShow.flow, ::handleShow) collect(detailModel.toShow.flow, ::handleShow)
collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled) collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled)
collectImmediately(homeModel.showOuter.flow, ::handleShowOuter) collectImmediately(homeModel.showOuter.flow, ::handleShowOuter)
collectImmediately(homeModel.speedDialOpen, speedDialBackCallback::invalidateEnabled)
collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled) collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled)
collectImmediately(playbackModel.song, ::updateSong) collectImmediately(playbackModel.song, ::updateSong)
collectImmediately(playbackModel.openPanel.flow, ::handlePanel) collectImmediately(playbackModel.openPanel.flow, ::handlePanel)
@ -181,6 +185,7 @@ class MainFragment :
// navigation, navigation out of detail views, etc. We have to do this here in // navigation, navigation out of detail views, etc. We have to do this here in
// onResume or otherwise the FragmentManager will have precedence. // onResume or otherwise the FragmentManager will have precedence.
requireActivity().onBackPressedDispatcher.apply { requireActivity().onBackPressedDispatcher.apply {
addCallback(viewLifecycleOwner, requireNotNull(speedDialBackCallback))
addCallback(viewLifecycleOwner, requireNotNull(selectionBackCallback)) addCallback(viewLifecycleOwner, requireNotNull(selectionBackCallback))
addCallback(viewLifecycleOwner, requireNotNull(detailBackCallback)) addCallback(viewLifecycleOwner, requireNotNull(detailBackCallback))
addCallback(viewLifecycleOwner, requireNotNull(sheetBackCallback)) addCallback(viewLifecycleOwner, requireNotNull(sheetBackCallback))
@ -197,6 +202,7 @@ class MainFragment :
override fun onDestroyBinding(binding: FragmentMainBinding) { override fun onDestroyBinding(binding: FragmentMainBinding) {
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
speedDialBackCallback = null
sheetBackCallback = null sheetBackCallback = null
detailBackCallback = null detailBackCallback = null
selectionBackCallback = null selectionBackCallback = null
@ -218,6 +224,13 @@ class MainFragment :
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
val playbackRatio = max(playbackSheetBehavior.calculateSlideOffset(), 0f) val playbackRatio = max(playbackSheetBehavior.calculateSlideOffset(), 0f)
if (playbackRatio > 0f && homeModel.speedDialOpen.value) {
// Stupid hack to prevent you from sliding the sheet up without closing the speed
// dial. Filtering out ACTION_MOVE events will cause back gestures to close the speed
// dial, which is super finicky behavior.
homeModel.setSpeedDialOpen(false)
}
val outPlaybackRatio = 1 - playbackRatio val outPlaybackRatio = 1 - playbackRatio
val halfOutRatio = min(playbackRatio * 2, 1f) val halfOutRatio = min(playbackRatio * 2, 1f)
val halfInPlaybackRatio = max(playbackRatio - 0.5f, 0f) * 2 val halfInPlaybackRatio = max(playbackRatio - 0.5f, 0f) * 2
@ -493,4 +506,17 @@ class MainFragment :
isEnabled = selection.isNotEmpty() isEnabled = selection.isNotEmpty()
} }
} }
private inner class SpeedDialBackPressedCallback(private val homeModel: HomeViewModel) :
OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (homeModel.speedDialOpen.value) {
homeModel.setSpeedDialOpen(false)
}
}
fun invalidateEnabled(open: Boolean) {
isEnabled = open
}
}
} }

View file

@ -149,14 +149,20 @@ class HomeFragment :
binding.root.rootView.apply { binding.root.rootView.apply {
// Stock bottom sheet overlay won't work with our nested UI setup, have to replicate // Stock bottom sheet overlay won't work with our nested UI setup, have to replicate
// it ourselves. // it ourselves.
findViewById<View>(R.id.main_scrim).setOnClickListener {
homeModel.setSpeedDialOpen(false)
}
findViewById<View>(R.id.main_scrim).setOnTouchListener { _, event -> findViewById<View>(R.id.main_scrim).setOnTouchListener { _, event ->
handleSpeedDialBoundaryTouch(event) handleSpeedDialBoundaryTouch(event)
false }
findViewById<View>(R.id.sheet_scrim).setOnClickListener {
homeModel.setSpeedDialOpen(false)
} }
findViewById<View>(R.id.sheet_scrim).setOnTouchListener { _, event -> findViewById<View>(R.id.sheet_scrim).setOnTouchListener { _, event ->
handleSpeedDialBoundaryTouch(event) handleSpeedDialBoundaryTouch(event)
false
} }
} }
@ -212,6 +218,7 @@ class HomeFragment :
binding.homeNewPlaylistFab.apply { binding.homeNewPlaylistFab.apply {
inflate(R.menu.new_playlist_actions) inflate(R.menu.new_playlist_actions)
setOnActionSelectedListener(this@HomeFragment) setOnActionSelectedListener(this@HomeFragment)
setChangeListener(homeModel::setSpeedDialOpen)
} }
hideAllFabs() hideAllFabs()
@ -224,6 +231,7 @@ class HomeFragment :
collect(homeModel.recreateTabs.flow, ::handleRecreate) collect(homeModel.recreateTabs.flow, ::handleRecreate)
collectImmediately(homeModel.currentTabType, ::updateCurrentTab) collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab) collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab)
collect(homeModel.speedDialOpen, ::updateSpeedDial)
collect(listModel.menu.flow, ::handleMenu) collect(listModel.menu.flow, ::handleMenu)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collectImmediately(musicModel.indexingState, ::updateIndexerState) collectImmediately(musicModel.indexingState, ::updateIndexerState)
@ -246,6 +254,7 @@ class HomeFragment :
storagePermissionLauncher = null storagePermissionLauncher = null
binding.homeAppbar.removeOnOffsetChangedListener(this) binding.homeAppbar.removeOnOffsetChangedListener(this)
binding.homeNormalToolbar.setOnMenuItemClickListener(null) binding.homeNormalToolbar.setOnMenuItemClickListener(null)
binding.homeNewPlaylistFab.setChangeListener(null)
binding.homeNewPlaylistFab.setOnActionSelectedListener(null) binding.homeNewPlaylistFab.setOnActionSelectedListener(null)
} }
@ -577,8 +586,6 @@ class HomeFragment :
return return
} }
logD(binding.homeShuffleFab.isOrWillBeShown)
if (binding.homeShuffleFab.isOrWillBeShown) { if (binding.homeShuffleFab.isOrWillBeShown) {
logD("Animating transition") logD("Animating transition")
binding.homeShuffleFab.hide( binding.homeShuffleFab.hide(
@ -606,12 +613,51 @@ class HomeFragment :
} }
} }
private fun handleSpeedDialBoundaryTouch(event: MotionEvent) { private fun updateSpeedDial(open: Boolean) {
val binding = requireBinding() val binding = requireBinding()
if (binding.homeNewPlaylistFab.isOpen &&
!binding.homeNewPlaylistFab.isUnder(event.x, event.y)) { binding.root.rootView.apply {
binding.homeNewPlaylistFab.close() // Stock bottom sheet overlay won't work with our nested UI setup, have to replicate
// it ourselves.
findViewById<View>(R.id.main_scrim).isClickable = open
findViewById<View>(R.id.sheet_scrim).isClickable = open
} }
if (open) {
binding.homeNewPlaylistFab.open(true)
} else {
binding.homeNewPlaylistFab.close(true)
}
}
private fun handleSpeedDialBoundaryTouch(event: MotionEvent): Boolean {
val binding = binding ?: return false
if (binding.homeNewPlaylistFab.isUnder(event.x, event.y)) {
// Convert absolute coordinates to relative coordinates
val offsetX = event.x - binding.homeNewPlaylistFab.x
val offsetY = event.y - binding.homeNewPlaylistFab.y
// Create a new MotionEvent with relative coordinates
val relativeEvent =
MotionEvent.obtain(
event.downTime,
event.eventTime,
event.action,
offsetX,
offsetY,
event.metaState)
// Dispatch the relative MotionEvent to the target child view
val handled = binding.homeNewPlaylistFab.dispatchTouchEvent(relativeEvent)
// Recycle the relative MotionEvent
relativeEvent.recycle()
return handled
}
return false
} }
private fun handleShow(show: Show?) { private fun handleShow(show: Show?) {

View file

@ -156,6 +156,10 @@ constructor(
/** A marker for whether the user is fast-scrolling in the home view or not. */ /** A marker for whether the user is fast-scrolling in the home view or not. */
val isFastScrolling: StateFlow<Boolean> = _isFastScrolling val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
private val _speedDialOpen = MutableStateFlow(false)
/** A marker for whether the speed dial is open or not. */
val speedDialOpen: StateFlow<Boolean> = _speedDialOpen
private val _showOuter = MutableEvent<Outer>() private val _showOuter = MutableEvent<Outer>()
val showOuter: Event<Outer> val showOuter: Event<Outer>
get() = _showOuter get() = _showOuter
@ -293,6 +297,16 @@ constructor(
_isFastScrolling.value = isFastScrolling _isFastScrolling.value = isFastScrolling
} }
/**
* Update whether the speed dial is open or not.
*
* @param speedDialOpen true if the speed dial is open, false otherwise.
*/
fun setSpeedDialOpen(speedDialOpen: Boolean) {
logD("Updating speed dial state: $speedDialOpen")
_speedDialOpen.value = speedDialOpen
}
fun showSettings() { fun showSettings() {
_showOuter.put(Outer.Settings) _showOuter.put(Outer.Settings)
} }

View file

@ -66,6 +66,7 @@ import org.oxycblt.auxio.util.getDimenPixels
class ThemedSpeedDialView : SpeedDialView { class ThemedSpeedDialView : SpeedDialView {
private var mainFabAnimator: Animator? = null private var mainFabAnimator: Animator? = null
private val spacingSmall = context.getDimenPixels(R.dimen.spacing_small) private val spacingSmall = context.getDimenPixels(R.dimen.spacing_small)
private var innerChangeListener: ((Boolean) -> Unit)? = null
constructor(context: Context) : super(context) constructor(context: Context) : super(context)
@ -126,6 +127,7 @@ class ThemedSpeedDialView : SpeedDialView {
}) })
start() start()
} }
innerChangeListener?.invoke(isOpen)
} }
}) })
} }
@ -178,8 +180,6 @@ class ThemedSpeedDialView : SpeedDialView {
val labelColor = context.getAttrColorCompat(android.R.attr.textColorSecondary) val labelColor = context.getAttrColorCompat(android.R.attr.textColorSecondary)
val labelBackgroundColor = val labelBackgroundColor =
context.getAttrColorCompat(com.google.android.material.R.attr.colorSurface) context.getAttrColorCompat(com.google.android.material.R.attr.colorSurface)
val labelStroke =
context.getAttrColorCompat(com.google.android.material.R.attr.colorOutline)
val labelElevation = val labelElevation =
context.getDimen(com.google.android.material.R.dimen.m3_card_elevated_elevation) context.getDimen(com.google.android.material.R.dimen.m3_card_elevated_elevation)
val cornerRadius = context.getDimenPixels(R.dimen.spacing_medium) val cornerRadius = context.getDimenPixels(R.dimen.spacing_medium)
@ -237,6 +237,10 @@ class ThemedSpeedDialView : SpeedDialView {
} }
} }
fun setChangeListener(listener: ((Boolean) -> Unit)?) {
innerChangeListener = listener
}
companion object { companion object {
private val VIEW_PROPERTY_BACKGROUND_TINT = private val VIEW_PROPERTY_BACKGROUND_TINT =
object : Property<View, Int>(Int::class.java, "backgroundTint") { object : Property<View, Int>(Int::class.java, "backgroundTint") {

View file

@ -224,9 +224,9 @@ constructor(
* @param playlist The [Playlist] to rename, * @param playlist The [Playlist] to rename,
* @param name The new name of the [Playlist]. If null, the user will be prompted for a name. * @param name The new name of the [Playlist]. If null, the user will be prompted for a name.
* @param applySongs The songs to apply to the playlist after renaming. If empty, no songs will * @param applySongs The songs to apply to the playlist after renaming. If empty, no songs will
* be applied. This argument is internal and does not need to be specified in normal use. * be applied. This argument is internal and does not need to be specified in normal use.
* @param reason The reason why the playlist is being renamed. This argument is internal and * @param reason The reason why the playlist is being renamed. This argument is internal and
* does not need to be specified in normal use. * does not need to be specified in normal use.
*/ */
fun renamePlaylist( fun renamePlaylist(
playlist: Playlist, playlist: Playlist,