home: extract fab system to home

This commit is contained in:
Alexander Capehart 2024-07-20 14:52:03 -06:00
parent 80dac7d9e9
commit f3b73a5196
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
6 changed files with 363 additions and 283 deletions

View file

@ -20,6 +20,8 @@ package org.oxycblt.auxio
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import android.view.WindowInsets import android.view.WindowInsets
import androidx.activity.BackEventCompat import androidx.activity.BackEventCompat
@ -32,10 +34,14 @@ import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.google.android.material.R as MR import com.google.android.material.R as MR
import com.google.android.material.bottomsheet.BackportBottomSheetBehavior import com.google.android.material.bottomsheet.BackportBottomSheetBehavior
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.transition.MaterialFadeThrough import com.google.android.material.transition.MaterialFadeThrough
import com.leinardi.android.speeddial.SpeedDialActionItem
import com.leinardi.android.speeddial.SpeedDialView
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.lang.reflect.Method
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentMainBinding import org.oxycblt.auxio.databinding.FragmentMainBinding
@ -44,7 +50,10 @@ import org.oxycblt.auxio.detail.Show
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.Outer import org.oxycblt.auxio.home.Outer
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.OpenPanel import org.oxycblt.auxio.playback.OpenPanel
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
@ -58,6 +67,8 @@ import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.coordinatorLayoutBehavior import org.oxycblt.auxio.util.coordinatorLayoutBehavior
import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.isUnder
import org.oxycblt.auxio.util.lazyReflectedMethod
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
@ -69,7 +80,10 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class MainFragment : class MainFragment :
ViewBindingFragment<FragmentMainBinding>(), ViewTreeObserver.OnPreDrawListener { ViewBindingFragment<FragmentMainBinding>(),
ViewTreeObserver.OnPreDrawListener,
SpeedDialView.OnActionSelectedListener {
private val musicModel: MusicViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
private val homeModel: HomeViewModel by activityViewModels() private val homeModel: HomeViewModel by activityViewModels()
private val listModel: ListViewModel by activityViewModels() private val listModel: ListViewModel by activityViewModels()
@ -78,11 +92,12 @@ class MainFragment :
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 speedDialBackCallback: SpeedDialBackPressedCallback? = null
private var selectionNavigationListener: DialogAwareNavigationListener? = null private var navigationListener: DialogAwareNavigationListener? = null
private var lastInsets: WindowInsets? = null private var lastInsets: WindowInsets? = null
private var elevationNormal = 0f private var elevationNormal = 0f
private var normalCornerSize = 0f private var normalCornerSize = 0f
private var maxScaleXDistance = 0f private var maxScaleXDistance = 0f
private var sheetRising: Boolean? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -113,10 +128,9 @@ 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 = speedDialBackCallback = SpeedDialBackPressedCallback(homeModel)
SpeedDialBackPressedCallback(homeModel).also { speedDialBackCallback = it }
selectionNavigationListener = DialogAwareNavigationListener(listModel::dropSelection) navigationListener = DialogAwareNavigationListener(::onExploreNavigate)
// --- UI SETUP --- // --- UI SETUP ---
val context = requireActivity() val context = requireActivity()
@ -162,8 +176,22 @@ class MainFragment :
binding.playbackSheet.elevation = 0f binding.playbackSheet.elevation = 0f
binding.mainScrim.setOnClickListener { homeModel.setSpeedDialOpen(false) } binding.mainScrim.setOnClickListener { binding.homeNewPlaylistFab.close() }
binding.sheetScrim.setOnClickListener { homeModel.setSpeedDialOpen(false) } binding.sheetScrim.setOnClickListener { binding.homeNewPlaylistFab.close() }
binding.homeShuffleFab.setOnClickListener { playbackModel.shuffleAll() }
binding.homeNewPlaylistFab.apply {
inflate(R.menu.new_playlist_actions)
setOnActionSelectedListener(this@MainFragment)
setChangeListener(::updateSpeedDial)
}
forceHideAllFabs()
updateSpeedDial(false)
updateFabVisibility(
binding,
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
// This has to be done here instead of the playback panel to make sure that it's prioritized // This has to be done here instead of the playback panel to make sure that it's prioritized
@ -173,7 +201,9 @@ 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, ::handleSpeedDialState) collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab)
collectImmediately(musicModel.indexingState, ::updateIndexerState)
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)
@ -184,7 +214,7 @@ class MainFragment :
val binding = requireBinding() val binding = requireBinding()
// Once we add the destination change callback, we will receive another initialization call, // Once we add the destination change callback, we will receive another initialization call,
// so handle that by resetting the flag. // so handle that by resetting the flag.
requireNotNull(selectionNavigationListener) { "NavigationListener was not available" } requireNotNull(navigationListener) { "NavigationListener was not available" }
.attach(binding.exploreNavHost.findNavController()) .attach(binding.exploreNavHost.findNavController())
// Listener could still reasonably fire even if we clear the binding, attach/detach // Listener could still reasonably fire even if we clear the binding, attach/detach
// our pre-draw listener our listener in onStart/onStop respectively. // our pre-draw listener our listener in onStart/onStop respectively.
@ -202,12 +232,23 @@ class MainFragment :
addCallback(viewLifecycleOwner, requireNotNull(detailBackCallback)) addCallback(viewLifecycleOwner, requireNotNull(detailBackCallback))
addCallback(viewLifecycleOwner, requireNotNull(sheetBackCallback)) addCallback(viewLifecycleOwner, requireNotNull(sheetBackCallback))
} }
// Stock bottom sheet overlay won't work with our nested UI setup, have to replicate
// it ourselves.
requireBinding().root.rootView.apply {
findViewById<View>(R.id.main_scrim).setOnTouchListener { _, event ->
handleSpeedDialBoundaryTouch(event)
}
findViewById<View>(R.id.sheet_scrim).setOnTouchListener { _, event ->
handleSpeedDialBoundaryTouch(event)
}
}
} }
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
val binding = requireBinding() val binding = requireBinding()
requireNotNull(selectionNavigationListener) { "NavigationListener was not available" } requireNotNull(navigationListener) { "NavigationListener was not available" }
.release(binding.exploreNavHost.findNavController()) .release(binding.exploreNavHost.findNavController())
binding.playbackSheet.viewTreeObserver.removeOnPreDrawListener(this) binding.playbackSheet.viewTreeObserver.removeOnPreDrawListener(this)
} }
@ -218,7 +259,9 @@ class MainFragment :
sheetBackCallback = null sheetBackCallback = null
detailBackCallback = null detailBackCallback = null
selectionBackCallback = null selectionBackCallback = null
selectionNavigationListener = null navigationListener = null
binding.homeNewPlaylistFab.setChangeListener(null)
binding.homeNewPlaylistFab.setOnActionSelectedListener(null)
} }
override fun onPreDraw(): Boolean { override fun onPreDraw(): Boolean {
@ -236,13 +279,18 @@ 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
// 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
// dial. Filtering out ACTION_MOVE events will cause back gestures to close the // speed dial, which is super finicky behavior.
// speed dial, which is super finicky behavior. val rising = playbackRatio > 0f
homeModel.setSpeedDialOpen(false) if (rising != sheetRising) {
sheetRising = rising
updateFabVisibility(
binding,
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
} }
homeModel.setSheetObscuresFab(playbackRatio > 0f)
val playbackOutRatio = 1 - min(playbackRatio * 2, 1f) val playbackOutRatio = 1 - min(playbackRatio * 2, 1f)
val playbackInRatio = max(playbackRatio - 0.5f, 0f) * 2 val playbackInRatio = max(playbackRatio - 0.5f, 0f) * 2
@ -330,6 +378,193 @@ class MainFragment :
return true return true
} }
override fun onActionSelected(actionItem: SpeedDialActionItem): Boolean {
when (actionItem.id) {
R.id.action_new_playlist -> {
logD("Creating playlist")
musicModel.createPlaylist()
}
R.id.action_import_playlist -> {
logD("Importing playlist")
musicModel.importPlaylist()
}
else -> {}
}
// Returning false to close the speed dial results in no animation, manually close instead.
// Adapted from Material Files: https://github.com/zhanghai/MaterialFiles
requireBinding().homeNewPlaylistFab.close()
return true
}
private fun onExploreNavigate() {
listModel.dropSelection()
updateFabVisibility(
requireBinding(),
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
}
private fun updateCurrentTab(tabType: MusicType) {
val binding = requireBinding()
updateFabVisibility(
binding, homeModel.songList.value, homeModel.isFastScrolling.value, tabType)
}
private fun updateIndexerState(state: IndexingState?) {
// TODO: Make music loading experience a bit more pleasant
// 1. Loading placeholder for item lists
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
if (state is IndexingState.Completed && state.error == null) {
logD("Received ok response")
val binding = requireBinding()
updateFabVisibility(
binding,
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
}
}
private fun updateFab(songs: List<Song>, isFastScrolling: Boolean) {
val binding = requireBinding()
updateFabVisibility(binding, songs, isFastScrolling, homeModel.currentTabType.value)
}
private fun updateFabVisibility(
binding: FragmentMainBinding,
songs: List<Song>,
isFastScrolling: Boolean,
tabType: MusicType
) {
// If there are no songs, it's likely that the library has not been loaded, so
// displaying the shuffle FAB makes no sense. We also don't want the fast scroll
// popup to overlap with the FAB, so we hide the FAB when fast scrolling too.
if (shouldHideAllFabs(binding, songs, isFastScrolling)) {
logD("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling]")
forceHideAllFabs()
} else {
if (tabType != MusicType.PLAYLISTS) {
if (binding.homeShuffleFab.isOrWillBeShown) {
return
}
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
logD("Animating transition")
binding.homeNewPlaylistFab.hide(
object : FloatingActionButton.OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
super.onHidden(fab)
if (shouldHideAllFabs(
binding,
homeModel.songList.value,
homeModel.isFastScrolling.value)) {
return
}
binding.homeShuffleFab.show()
}
})
} else {
logD("Showing immediately")
binding.homeShuffleFab.show()
}
} else {
logD("Showing playlist button")
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
return
}
if (binding.homeShuffleFab.isOrWillBeShown) {
logD("Animating transition")
binding.homeShuffleFab.hide(
object : FloatingActionButton.OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
super.onHidden(fab)
if (shouldHideAllFabs(
binding,
homeModel.songList.value,
homeModel.isFastScrolling.value)) {
return
}
binding.homeNewPlaylistFab.show()
}
})
} else {
logD("Showing immediately")
binding.homeNewPlaylistFab.show()
}
}
}
}
private fun shouldHideAllFabs(
binding: FragmentMainBinding,
songs: List<Song>,
isFastScrolling: Boolean
) =
binding.exploreNavHost.findNavController().currentDestination?.id != R.id.home_fragment ||
sheetRising == true ||
songs.isEmpty() ||
isFastScrolling
private fun forceHideAllFabs() {
val binding = requireBinding()
if (binding.homeShuffleFab.isOrWillBeShown) {
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeShuffleFab, null, false)
}
if (binding.homeNewPlaylistFab.isOpen) {
binding.homeNewPlaylistFab.close()
}
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeNewPlaylistFab.mainFab, null, false)
}
}
private fun updateSpeedDial(open: Boolean) {
requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" }
.invalidateEnabled(open)
val binding = requireBinding()
logD(open)
binding.mainScrim.isVisible = open
binding.sheetScrim.isVisible = 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.isOpen &&
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?) {
when (show) { when (show) {
is Show.SongAlbumDetails, is Show.SongAlbumDetails,
@ -355,13 +590,6 @@ class MainFragment :
homeModel.showOuter.consume() homeModel.showOuter.consume()
} }
private fun handleSpeedDialState(open: Boolean) {
requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" }
.invalidateEnabled(open)
requireBinding().mainScrim.isVisible = open
requireBinding().sheetScrim.isVisible = open
}
private fun updateSong(song: Song?) { private fun updateSong(song: Song?) {
if (song != null) { if (song != null) {
tryShowSheets() tryShowSheets()
@ -566,8 +794,9 @@ class MainFragment :
private inner class SpeedDialBackPressedCallback(private val homeModel: HomeViewModel) : private inner class SpeedDialBackPressedCallback(private val homeModel: HomeViewModel) :
OnBackPressedCallback(false) { OnBackPressedCallback(false) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
if (homeModel.speedDialOpen.value) { val binding = requireBinding()
homeModel.setSpeedDialOpen(false) if (binding.homeNewPlaylistFab.isOpen) {
binding.homeNewPlaylistFab.close()
} }
} }
@ -575,4 +804,13 @@ class MainFragment :
isEnabled = open isEnabled = open
} }
} }
private companion object {
val FAB_HIDE_FROM_USER_FIELD: Method by
lazyReflectedMethod(
FloatingActionButton::class,
"hide",
FloatingActionButton.OnVisibilityChangedListener::class,
Boolean::class)
}
} }

View file

@ -22,7 +22,6 @@ import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.MotionEvent
import android.view.View import android.view.View
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -41,8 +40,6 @@ import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import com.leinardi.android.speeddial.SpeedDialActionItem
import com.leinardi.android.speeddial.SpeedDialView
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.lang.reflect.Field import java.lang.reflect.Field
import java.lang.reflect.Method import java.lang.reflect.Method
@ -72,14 +69,12 @@ import org.oxycblt.auxio.music.PERMISSION_READ_AUDIO
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.external.M3U import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.isUnder
import org.oxycblt.auxio.util.lazyReflectedField import org.oxycblt.auxio.util.lazyReflectedField
import org.oxycblt.auxio.util.lazyReflectedMethod import org.oxycblt.auxio.util.lazyReflectedMethod
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -95,9 +90,7 @@ import org.oxycblt.auxio.util.showToast
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class HomeFragment : class HomeFragment :
SelectionFragment<FragmentHomeBinding>(), SelectionFragment<FragmentHomeBinding>(), AppBarLayout.OnOffsetChangedListener {
AppBarLayout.OnOffsetChangedListener,
SpeedDialView.OnActionSelectedListener {
override val listModel: ListViewModel by activityViewModels() override val listModel: ListViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
@ -190,27 +183,9 @@ class HomeFragment :
// re-creating the ViewPager. // re-creating the ViewPager.
setupPager(binding) setupPager(binding)
binding.homeShuffleFab.setOnClickListener { playbackModel.shuffleAll() }
binding.homeNewPlaylistFab.apply {
inflate(R.menu.new_playlist_actions)
setOnActionSelectedListener(this@HomeFragment)
setChangeListener(homeModel::setSpeedDialOpen)
}
hideAllFabs()
updateFabVisibility(
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.sheetObscuresFab.value,
homeModel.currentTabType.value)
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
collect(homeModel.recreateTabs.flow, ::handleRecreate) collect(homeModel.recreateTabs.flow, ::handleRecreate)
collectImmediately(homeModel.currentTabType, ::updateCurrentTab) collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
collectImmediately(
homeModel.songList, homeModel.isFastScrolling, homeModel.sheetObscuresFab, ::updateFab)
collect(homeModel.speedDialOpen, ::updateSpeedDial)
collect(detailModel.toShow.flow, ::handleShow) collect(detailModel.toShow.flow, ::handleShow)
collect(listModel.menu.flow, ::handleMenu) collect(listModel.menu.flow, ::handleMenu)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
@ -220,28 +195,11 @@ class HomeFragment :
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision) collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
} }
override fun onResume() {
super.onResume()
// Stock bottom sheet overlay won't work with our nested UI setup, have to replicate
// it ourselves.
requireBinding().root.rootView.apply {
findViewById<View>(R.id.main_scrim).setOnTouchListener { _, event ->
handleSpeedDialBoundaryTouch(event)
}
findViewById<View>(R.id.sheet_scrim).setOnTouchListener { _, event ->
handleSpeedDialBoundaryTouch(event)
}
}
}
override fun onDestroyBinding(binding: FragmentHomeBinding) { override fun onDestroyBinding(binding: FragmentHomeBinding) {
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
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)
} }
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
@ -299,24 +257,6 @@ class HomeFragment :
} }
} }
override fun onActionSelected(actionItem: SpeedDialActionItem): Boolean {
when (actionItem.id) {
R.id.action_new_playlist -> {
logD("Creating playlist")
musicModel.createPlaylist()
}
R.id.action_import_playlist -> {
logD("Importing playlist")
musicModel.importPlaylist()
}
else -> {}
}
// Returning false to close th speed dial results in no animation, manually close instead.
// Adapted from Material Files: https://github.com/zhanghai/MaterialFiles
requireBinding().homeNewPlaylistFab.close()
return true
}
private fun setupPager(binding: FragmentHomeBinding) { private fun setupPager(binding: FragmentHomeBinding) {
binding.homePager.adapter = binding.homePager.adapter =
HomePagerAdapter(homeModel.currentTabTypes, childFragmentManager, viewLifecycleOwner) HomePagerAdapter(homeModel.currentTabTypes, childFragmentManager, viewLifecycleOwner)
@ -358,12 +298,6 @@ class HomeFragment :
MusicType.GENRES -> R.id.home_genre_recycler MusicType.GENRES -> R.id.home_genre_recycler
MusicType.PLAYLISTS -> R.id.home_playlist_recycler MusicType.PLAYLISTS -> R.id.home_playlist_recycler
} }
updateFabVisibility(
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.sheetObscuresFab.value,
tabType)
} }
private fun handleRecreate(recreate: Unit?) { private fun handleRecreate(recreate: Unit?) {
@ -395,11 +329,6 @@ class HomeFragment :
private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) { private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) {
if (error == null) { if (error == null) {
logD("Received ok response") logD("Received ok response")
updateFabVisibility(
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.sheetObscuresFab.value,
homeModel.currentTabType.value)
binding.homeIndexingContainer.visibility = View.INVISIBLE binding.homeIndexingContainer.visibility = View.INVISIBLE
return return
} }
@ -544,118 +473,6 @@ class HomeFragment :
} }
} }
private fun updateFab(songs: List<Song>, isFastScrolling: Boolean, sheetRising: Boolean) {
updateFabVisibility(songs, isFastScrolling, sheetRising, homeModel.currentTabType.value)
}
private fun updateFabVisibility(
songs: List<Song>,
isFastScrolling: Boolean,
sheetRising: Boolean,
tabType: MusicType
) {
val binding = requireBinding()
// If there are no songs, it's likely that the library has not been loaded, so
// displaying the shuffle FAB makes no sense. We also don't want the fast scroll
// popup to overlap with the FAB, so we hide the FAB when fast scrolling too.
if (songs.isEmpty() || isFastScrolling || sheetRising) {
logD("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling]")
hideAllFabs()
} else {
if (tabType != MusicType.PLAYLISTS) {
logD("Showing shuffle button")
if (binding.homeShuffleFab.isOrWillBeShown) {
logD("Nothing to do")
return
}
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
logD("Animating transition")
binding.homeNewPlaylistFab.hide(
object : FloatingActionButton.OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
super.onHidden(fab)
binding.homeShuffleFab.show()
}
})
} else {
logD("Showing immediately")
binding.homeShuffleFab.show()
}
} else {
logD("Showing playlist button")
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
logD("Nothing to do")
return
}
if (binding.homeShuffleFab.isOrWillBeShown) {
logD("Animating transition")
binding.homeShuffleFab.hide(
object : FloatingActionButton.OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
super.onHidden(fab)
binding.homeNewPlaylistFab.show()
}
})
} else {
logD("Showing immediately")
binding.homeNewPlaylistFab.show()
}
}
}
}
private fun hideAllFabs() {
val binding = requireBinding()
if (binding.homeShuffleFab.isOrWillBeShown) {
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeShuffleFab, null, false)
}
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeNewPlaylistFab.mainFab, null, false)
}
}
private fun updateSpeedDial(open: Boolean) {
val binding = requireBinding()
if (open) {
binding.homeNewPlaylistFab.open(true)
} else {
binding.homeNewPlaylistFab.close(true)
}
}
private fun handleSpeedDialBoundaryTouch(event: MotionEvent): Boolean {
val binding = binding ?: return false
if (homeModel.speedDialOpen.value && 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?) {
when (show) { when (show) {
is Show.SongDetails -> { is Show.SongDetails -> {

View file

@ -156,13 +156,6 @@ 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 _sheetObscuresFab = MutableStateFlow(false)
val sheetObscuresFab: StateFlow<Boolean> = _sheetObscuresFab
private val _showOuter = MutableEvent<Outer>() private val _showOuter = MutableEvent<Outer>()
val showOuter: Event<Outer> val showOuter: Event<Outer>
get() = _showOuter get() = _showOuter
@ -300,20 +293,6 @@ 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 setSheetObscuresFab(sheetRising: Boolean) {
_sheetObscuresFab.value = sheetRising
}
fun showSettings() { fun showSettings() {
_showOuter.put(Outer.Settings) _showOuter.put(Outer.Settings)
} }

View file

@ -7,15 +7,55 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface"> android:background="?attr/colorSurface">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/explore_nav_host" <FrameLayout
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
app:defaultNavHost="true" app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentBehavior">
app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentBehavior"
app:navGraph="@navigation/inner" <androidx.fragment.app.FragmentContainerView
tools:layout="@layout/fragment_home" /> android:id="@+id/explore_nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/inner"
tools:layout="@layout/fragment_home" />
<org.oxycblt.auxio.home.EdgeFrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="bottom|end"
android:clipChildren="false"
android:clipToPadding="false"
app:layout_anchor="@id/home_content">
<org.oxycblt.auxio.home.ThemedSpeedDialView
android:id="@+id/home_new_playlist_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:clickable="true"
android:contentDescription="@string/lbl_new_playlist"
android:focusable="true"
android:gravity="bottom|end"
app:sdMainFabAnimationRotateAngle="135"
app:sdMainFabClosedIconColor="@android:color/white"
app:sdMainFabClosedSrc="@drawable/ic_add_24" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/home_shuffle_fab"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/spacing_medium"
android:contentDescription="@string/lbl_shuffle"
android:src="@drawable/ic_shuffle_off_24" />
</org.oxycblt.auxio.home.EdgeFrameLayout>
</FrameLayout>
<View <View
android:id="@+id/main_scrim" android:id="@+id/main_scrim"

View file

@ -148,37 +148,5 @@
</FrameLayout> </FrameLayout>
<org.oxycblt.auxio.home.EdgeFrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="bottom|end"
android:clipChildren="false"
android:clipToPadding="false"
app:layout_anchor="@id/home_content">
<org.oxycblt.auxio.home.ThemedSpeedDialView
android:id="@+id/home_new_playlist_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:clickable="true"
android:focusable="true"
android:gravity="bottom|end"
android:contentDescription="@string/lbl_new_playlist"
app:sdMainFabAnimationRotateAngle="135"
app:sdMainFabClosedIconColor="@android:color/white"
app:sdMainFabClosedSrc="@drawable/ic_add_24" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/home_shuffle_fab"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:contentDescription="@string/lbl_shuffle"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/spacing_medium"
android:src="@drawable/ic_shuffle_off_24" />
</org.oxycblt.auxio.home.EdgeFrameLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -8,15 +8,53 @@
android:background="?attr/colorSurface" android:background="?attr/colorSurface"
android:transitionGroup="true"> android:transitionGroup="true">
<androidx.fragment.app.FragmentContainerView <FrameLayout
android:id="@+id/explore_nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
app:defaultNavHost="true" app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentBehavior">
app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentBehavior"
app:navGraph="@navigation/inner" <androidx.fragment.app.FragmentContainerView
tools:layout="@layout/fragment_home" /> android:id="@+id/explore_nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/inner"
tools:layout="@layout/fragment_home" />
<org.oxycblt.auxio.home.EdgeFrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="bottom|end"
android:clipChildren="false"
android:clipToPadding="false"
app:layout_anchor="@id/home_content">
<org.oxycblt.auxio.home.ThemedSpeedDialView
android:id="@+id/home_new_playlist_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:clickable="true"
android:contentDescription="@string/lbl_new_playlist"
android:focusable="true"
android:gravity="bottom|end"
app:sdMainFabAnimationRotateAngle="135"
app:sdMainFabClosedIconColor="@android:color/white"
app:sdMainFabClosedSrc="@drawable/ic_add_24" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/home_shuffle_fab"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/spacing_medium"
android:contentDescription="@string/lbl_shuffle"
android:src="@drawable/ic_shuffle_off_24" />
</org.oxycblt.auxio.home.EdgeFrameLayout>
</FrameLayout>
<View <View
android:id="@+id/main_scrim" android:id="@+id/main_scrim"
@ -25,17 +63,17 @@
<View <View
android:id="@+id/main_sheet_scrim" android:id="@+id/main_sheet_scrim"
android:background="?attr/colorSurfaceContainerLow"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"
android:alpha="0" android:alpha="0"
android:layout_height="match_parent" /> android:background="?attr/colorSurfaceContainerLow" />
<androidx.coordinatorlayout.widget.CoordinatorLayout <androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/playback_sheet" android:id="@+id/playback_sheet"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true"
app:layout_behavior="org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior"> app:layout_behavior="org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior">
<androidx.fragment.app.FragmentContainerView <androidx.fragment.app.FragmentContainerView
@ -55,10 +93,10 @@
android:id="@+id/queue_sheet" android:id="@+id/queue_sheet"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical"
android:elevation="1dp"
android:clickable="true" android:clickable="true"
android:elevation="1dp"
android:focusable="true" android:focusable="true"
android:orientation="vertical"
app:layout_behavior="org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior"> app:layout_behavior="org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout