ui: split up back listeners

Split up the back gesture listeners into specific components.

These are still all used in MainFragment since I can't reliably set up
their priority correctly if they were used in their respective
fragments, but it should improve efficiency since most of these back
listeners don't need to be updated on every draw.
This commit is contained in:
Alexander Capehart 2023-05-30 17:10:32 -06:00
parent 841ea3620a
commit 5d51adfb0a
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47

View file

@ -26,6 +26,7 @@ import androidx.activity.OnBackPressedCallback
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.FragmentContainerView
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDestination import androidx.navigation.NavDestination
@ -80,7 +81,10 @@ class MainFragment :
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val selectionModel: SelectionViewModel by activityViewModels() private val selectionModel: SelectionViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
private val callback = DynamicBackPressedCallback() private lateinit var sheetBackCallback: SheetBackPressedCallback
private lateinit var detailBackCallback: DetailBackPressedCallback
private lateinit var selectionBackCallback: SelectionBackPressedCallback
private lateinit var exploreBackCallback: ExploreBackPressedCallback
private var lastInsets: WindowInsets? = null private var lastInsets: WindowInsets? = null
private var elevationNormal = 0f private var elevationNormal = 0f
private var initialNavDestinationChange = true private var initialNavDestinationChange = true
@ -96,13 +100,34 @@ class MainFragment :
override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
elevationNormal = binding.context.getDimen(R.dimen.elevation_normal) elevationNormal = binding.context.getDimen(R.dimen.elevation_normal)
// Currently all back press callbacks are handled in MainFragment, as it's not guaranteed
// that instantiating these callbacks in their respective fragments would result in the
// correct order.
sheetBackCallback =
SheetBackPressedCallback(
playbackSheetBehavior = playbackSheetBehavior,
queueSheetBehavior = queueSheetBehavior)
detailBackCallback = DetailBackPressedCallback(detailModel)
selectionBackCallback = SelectionBackPressedCallback(selectionModel)
exploreBackCallback = ExploreBackPressedCallback(binding.exploreNavHost)
// --- UI SETUP --- // --- UI SETUP ---
val context = requireActivity() val context = requireActivity()
// Override the back pressed listener so we can map back navigation to collapsing // Override the back pressed listener so we can map back navigation to collapsing
// navigation, navigation out of detail views, etc. // navigation, navigation out of detail views, etc.
context.onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback) context.onBackPressedDispatcher.apply {
addCallback(viewLifecycleOwner, exploreBackCallback)
addCallback(viewLifecycleOwner, selectionBackCallback)
addCallback(viewLifecycleOwner, detailBackCallback)
addCallback(viewLifecycleOwner, sheetBackCallback)
}
binding.root.setOnApplyWindowInsetsListener { _, insets -> binding.root.setOnApplyWindowInsetsListener { _, insets ->
lastInsets = insets lastInsets = insets
@ -115,13 +140,9 @@ class MainFragment :
ViewCompat.setAccessibilityPaneTitle( ViewCompat.setAccessibilityPaneTitle(
binding.queueSheet, context.getString(R.string.lbl_queue)) binding.queueSheet, context.getString(R.string.lbl_queue))
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
if (queueSheetBehavior != null) { if (queueSheetBehavior != null) {
// In portrait mode, set up click listeners on the stacked sheets. // In portrait mode, set up click listeners on the stacked sheets.
logD("Configuring stacked bottom sheets") logD("Configuring stacked bottom sheets")
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
unlikelyToBeNull(binding.queueHandleWrapper).setOnClickListener { unlikelyToBeNull(binding.queueHandleWrapper).setOnClickListener {
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED && if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED &&
queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) { queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) {
@ -148,13 +169,15 @@ class MainFragment :
} }
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
collect(navModel.mainNavigationAction.flow, ::handleMainNavigation) collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled)
collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation) collectImmediately(selectionModel.selected, selectionBackCallback::invalidateEnabled)
collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist) collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist)
collect(musicModel.playlistToRename.flow, ::handleRenamePlaylist) collect(musicModel.playlistToRename.flow, ::handleRenamePlaylist)
collect(musicModel.playlistToDelete.flow, ::handleDeletePlaylist) collect(musicModel.playlistToDelete.flow, ::handleDeletePlaylist)
collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist) collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist)
collect(navModel.mainNavigationAction.flow, ::handleMainNavigation)
collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation)
collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
collectImmediately(playbackModel.song, ::updateSong) collectImmediately(playbackModel.song, ::updateSong)
collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker) collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker)
collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker) collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker)
@ -264,7 +287,7 @@ class MainFragment :
// Since the navigation listener is also reliant on the bottom sheets, we must also update // Since the navigation listener is also reliant on the bottom sheets, we must also update
// it every frame. // it every frame.
callback.invalidateEnabled() sheetBackCallback.invalidateEnabled()
return true return true
} }
@ -277,6 +300,7 @@ class MainFragment :
// Drop the initial call by NavController that simply provides us with the current // Drop the initial call by NavController that simply provides us with the current
// destination. This would cause the selection state to be lost every time the device // destination. This would cause the selection state to be lost every time the device
// rotates. // rotates.
exploreBackCallback.invalidateEnabled()
if (!initialNavDestinationChange) { if (!initialNavDestinationChange) {
initialNavDestinationChange = true initialNavDestinationChange = true
return return
@ -400,7 +424,7 @@ class MainFragment :
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) { if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
// Playback sheet (and possibly queue) needs to be collapsed. // Playback sheet (and possibly queue) needs to be collapsed.
logD("Closing playback and queue sheets") logD("Collapsing playback and queue sheets")
val queueSheetBehavior = val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
@ -449,80 +473,82 @@ class MainFragment :
} }
} }
/** private class SheetBackPressedCallback(
* A [OnBackPressedCallback] that overrides the back button to first navigate out of internal private val playbackSheetBehavior: PlaybackBottomSheetBehavior<*>,
* app components, such as the Bottom Sheets or Explore Navigation. private val queueSheetBehavior: QueueBottomSheetBehavior<*>?
*/ ) : OnBackPressedCallback(false) {
private inner class DynamicBackPressedCallback : OnBackPressedCallback(false) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
val binding = requireBinding()
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
// If expanded, collapse the queue sheet first. // If expanded, collapse the queue sheet first.
if (queueSheetBehavior != null && if (queueSheetShown()) {
queueSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED && unlikelyToBeNull(queueSheetBehavior).state =
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) { BackportBottomSheetBehavior.STATE_COLLAPSED
logD("Hiding queue sheet") logD("Collapsed queue sheet")
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
return return
} }
// If expanded, collapse the playback sheet next. // If expanded, collapse the playback sheet next.
if (playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED && if (playbackSheetShown()) {
playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN) {
logD("Hiding playback sheet")
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
logD("Collapsed playback sheet")
return return
} }
// Clear out pending playlist edits.
if (detailModel.dropPlaylistEdit()) {
logD("Dropping playlist edits")
return
} }
// Clear out any prior selections.
if (selectionModel.drop()) {
logD("Dropping selection")
return
}
// Then try to navigate out of the explore navigation fragments (i.e Detail Views)
logD("Navigate away from explore view")
binding.exploreNavHost.findNavController().navigateUp()
}
/**
* Force this instance to update whether it's enabled or not. If there are no app components
* that the back button should close first, the instance is disabled and back navigation is
* delegated to the system.
*
* Normally, this listener would have just called the [MainActivity.onBackPressed] if there
* were no components to close, but that prevents adaptive back navigation from working on
* Android 14+, so we must do it this way.
*/
fun invalidateEnabled() { fun invalidateEnabled() {
val binding = requireBinding() isEnabled = queueSheetShown() || playbackSheetShown()
val playbackSheetBehavior = }
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
val exploreNavController = binding.exploreNavHost.findNavController()
// TODO: Chain these listeners in some way instead of keeping them all here, private fun playbackSheetShown() =
// assuming listeners added later have more priority playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED &&
playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN
isEnabled = private fun queueSheetShown() =
(queueSheetBehavior != null && queueSheetBehavior != null &&
queueSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED && queueSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED &&
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) || playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED
(playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED && }
playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN) ||
detailModel.editedPlaylist.value != null || private class DetailBackPressedCallback(private val detailModel: DetailViewModel) :
selectionModel.selected.value.isNotEmpty() || OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (detailModel.dropPlaylistEdit()) {
logD("Dropped playlist edits")
}
}
fun invalidateEnabled(playlistEdit: List<Song>?) {
isEnabled = playlistEdit != null
}
}
private inner class SelectionBackPressedCallback(
private val selectionModel: SelectionViewModel
) : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (selectionModel.drop()) {
logD("Dropped selection")
}
}
fun invalidateEnabled(selection: List<Music>) {
isEnabled = selection.isNotEmpty()
}
}
private inner class ExploreBackPressedCallback(
private val exploreNavHost: FragmentContainerView
) : OnBackPressedCallback(false) {
// Note: We cannot cache the NavController in a variable since it's current destination
// value goes stale for some reason.
override fun handleOnBackPressed() {
exploreNavHost.findNavController().navigateUp()
logD("Forwarded back navigation to explore nav host")
}
fun invalidateEnabled() {
val exploreNavController = exploreNavHost.findNavController()
isEnabled =
exploreNavController.currentDestination?.id != exploreNavController.currentDestination?.id !=
exploreNavController.graph.startDestinationId exploreNavController.graph.startDestinationId
} }