detail: move editing state to toolbar

Move the music editing state to the toolbar.

This should be signifigantly clearer than prior, at the cost of it's
"universality" implying that renaming should be available when it
actually won't be.
This commit is contained in:
Alexander Capehart 2023-05-19 19:54:36 -06:00
parent cee92c8087
commit 1fd6795b0d
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
20 changed files with 317 additions and 286 deletions

View file

@ -38,6 +38,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.max
import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentMainBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicViewModel
@ -66,6 +67,7 @@ class MainFragment :
private val musicModel: MusicViewModel by activityViewModels()
private val playbackModel: PlaybackViewModel by activityViewModels()
private val selectionModel: SelectionViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels()
private val callback = DynamicBackPressedCallback()
private var lastInsets: WindowInsets? = null
private var elevationNormal = 0f
@ -458,6 +460,11 @@ class MainFragment :
return
}
// Clear out pending playlist edits.
if (detailModel.dropPlaylistEdit()) {
return
}
// Clear out any prior selections.
if (selectionModel.drop()) {
return
@ -487,6 +494,7 @@ class MainFragment :
isEnabled =
queueSheetBehavior?.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
detailModel.editedPlaylist.value != null ||
selectionModel.selected.value.isNotEmpty() ||
exploreNavController.currentDestination?.id !=
exploreNavController.graph.startDestinationId

View file

@ -93,7 +93,7 @@ class AlbumDetailFragment :
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP --
binding.detailToolbar.apply {
binding.detailNormalToolbar.apply {
inflateMenu(R.menu.menu_album_detail)
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@AlbumDetailFragment)
@ -124,7 +124,7 @@ class AlbumDetailFragment :
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null)
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
@ -214,7 +214,7 @@ class AlbumDetailFragment :
findNavController().navigateUp()
return
}
requireBinding().detailToolbar.title = album.name.resolve(requireContext())
requireBinding().detailNormalToolbar.title = album.name.resolve(requireContext())
albumHeaderAdapter.setParent(album)
}
@ -313,6 +313,13 @@ class AlbumDetailFragment :
private fun updateSelection(selected: List<Music>) {
albumListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
val binding = requireBinding()
if (selected.isNotEmpty()) {
binding.detailSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
binding.detailToolbar.setVisible(R.id.detail_selection_toolbar)
} else {
binding.detailToolbar.setVisible(R.id.detail_normal_toolbar)
}
}
}

View file

@ -91,7 +91,7 @@ class ArtistDetailFragment :
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.detailToolbar.apply {
binding.detailNormalToolbar.apply {
inflateMenu(R.menu.menu_parent_detail)
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@ArtistDetailFragment)
@ -122,7 +122,7 @@ class ArtistDetailFragment :
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null)
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
@ -223,7 +223,7 @@ class ArtistDetailFragment :
findNavController().navigateUp()
return
}
requireBinding().detailToolbar.title = artist.name.resolve(requireContext())
requireBinding().detailNormalToolbar.title = artist.name.resolve(requireContext())
artistHeaderAdapter.setParent(artist)
}
@ -283,6 +283,13 @@ class ArtistDetailFragment :
private fun updateSelection(selected: List<Music>) {
artistListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
val binding = requireBinding()
if (selected.isNotEmpty()) {
binding.detailSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
binding.detailToolbar.setVisible(R.id.detail_selection_toolbar)
} else {
binding.detailToolbar.setVisible(R.id.detail_normal_toolbar)
}
}
}

View file

@ -69,7 +69,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Assume that we have a Toolbar with a detail_toolbar ID, as this view is only
// used within the detail layouts.
val toolbar = findViewById<Toolbar>(R.id.detail_toolbar)
val toolbar = findViewById<Toolbar>(R.id.detail_normal_toolbar)
// The Toolbar's title view is actually hidden. To avoid having to create our own
// title view, we just reflect into Toolbar and grab the hidden field.

View file

@ -22,7 +22,6 @@ import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import java.lang.Exception
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -298,7 +297,7 @@ constructor(
* End a playlist editing session and commits it to the database. Does nothing if there was no
* prior editing session.
*/
fun confirmPlaylistEdit() {
fun savePlaylistEdit() {
val playlist = _currentPlaylist.value ?: return
val editedPlaylist = (_editedPlaylist.value ?: return).also { _editedPlaylist.value = null }
musicRepository.rewritePlaylist(playlist, editedPlaylist)
@ -307,11 +306,18 @@ constructor(
/**
* End a playlist editing session and keep the prior state. Does nothing if there was no prior
* editing session.
*
* @return true if the session was ended, false otherwise.
*/
fun dropPlaylistEdit() {
val playlist = _currentPlaylist.value ?: return
fun dropPlaylistEdit(): Boolean {
val playlist = _currentPlaylist.value ?: return false
if (_editedPlaylist.value == null) {
// Nothing to do.
return false
}
_editedPlaylist.value = null
refreshPlaylistList(playlist)
return true
}
/**
@ -319,8 +325,10 @@ constructor(
*
* @param from The start position, in the list adapter data.
* @param to The destination position, in the list adapter data.
* @return true if the song was moved, false otherwise.
*/
fun movePlaylistSongs(from: Int, to: Int): Boolean {
// TODO: Song re-sorting
val playlist = _currentPlaylist.value ?: return false
val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList()
val realFrom = from - 2
@ -340,6 +348,7 @@ constructor(
* @param at The position of the item to remove, in the list adapter data.
*/
fun removePlaylistSong(at: Int) {
// TODO: Remove header when empty
val playlist = _currentPlaylist.value ?: return
val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList()
val realAt = at - 2
@ -478,7 +487,6 @@ constructor(
playlist: Playlist,
instructions: UpdateInstructions = UpdateInstructions.Diff
) {
logD(Exception().stackTraceToString())
logD("Refreshing playlist list")
val list = mutableListOf<Item>()

View file

@ -84,7 +84,7 @@ class GenreDetailFragment :
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.detailToolbar.apply {
binding.detailNormalToolbar.apply {
inflateMenu(R.menu.menu_parent_detail)
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@GenreDetailFragment)
@ -115,7 +115,7 @@ class GenreDetailFragment :
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null)
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
@ -214,7 +214,7 @@ class GenreDetailFragment :
findNavController().navigateUp()
return
}
requireBinding().detailToolbar.title = genre.name.resolve(requireContext())
requireBinding().detailNormalToolbar.title = genre.name.resolve(requireContext())
genreHeaderAdapter.setParent(genre)
}
@ -260,6 +260,13 @@ class GenreDetailFragment :
private fun updateSelection(selected: List<Music>) {
genreListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
val binding = requireBinding()
if (selected.isNotEmpty()) {
binding.detailSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
binding.detailToolbar.setVisible(R.id.detail_selection_toolbar)
} else {
binding.detailToolbar.setVisible(R.id.detail_normal_toolbar)
}
}
}

View file

@ -90,12 +90,17 @@ class PlaylistDetailFragment :
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.detailToolbar.apply {
binding.detailNormalToolbar.apply {
inflateMenu(R.menu.menu_playlist_detail)
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@PlaylistDetailFragment)
}
binding.detailEditToolbar.apply {
setNavigationOnClickListener { detailModel.dropPlaylistEdit() }
setOnMenuItemClickListener(this@PlaylistDetailFragment)
}
binding.detailRecycler.apply {
adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter)
touchHelper =
@ -139,7 +144,7 @@ class PlaylistDetailFragment :
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null)
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
touchHelper = null
binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
@ -159,7 +164,8 @@ class PlaylistDetailFragment :
initialNavDestinationChange = true
return
}
// Drop any pending playlist edits when navigating away.
// Drop any pending playlist edits when navigating away. This could actually happen
// if the user is quick enough.
detailModel.dropPlaylistEdit()
}
@ -188,6 +194,10 @@ class PlaylistDetailFragment :
musicModel.deletePlaylist(currentPlaylist)
true
}
R.id.action_save -> {
detailModel.savePlaylistEdit()
true
}
else -> false
}
}
@ -214,21 +224,10 @@ class PlaylistDetailFragment :
}
override fun onStartEdit() {
selectionModel.drop()
detailModel.startPlaylistEdit()
}
override fun onConfirmEdit() {
detailModel.confirmPlaylistEdit()
}
override fun onDropEdit() {
detailModel.dropPlaylistEdit()
}
override fun onOpenSortMenu(anchor: View) {
throw IllegalStateException()
}
override fun onOpenSortMenu(anchor: View) {}
private fun updatePlaylist(playlist: Playlist?) {
if (playlist == null) {
@ -236,7 +235,9 @@ class PlaylistDetailFragment :
findNavController().navigateUp()
return
}
requireBinding().detailToolbar.title = playlist.name.resolve(requireContext())
val binding = requireBinding()
binding.detailNormalToolbar.title = playlist.name.resolve(requireContext())
binding.detailEditToolbar.title = "Editing ${playlist.name.resolve(requireContext())}"
playlistHeaderAdapter.setParent(playlist)
}
@ -279,18 +280,35 @@ class PlaylistDetailFragment :
}
private fun updateEditedPlaylist(editedPlaylist: List<Song>?) {
// TODO: Disable check item when no edits have been made
// TODO: Massively improve how this UI is indicated:
// - Add an additional toolbar to indicate editing
// - Header should flip to re-sort button eventually
playlistListAdapter.setEditing(editedPlaylist != null)
playlistHeaderAdapter.setEditedPlaylist(editedPlaylist)
selectionModel.drop()
logD(editedPlaylist == detailModel.currentPlaylist.value?.songs)
requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).isEnabled =
editedPlaylist != detailModel.currentPlaylist.value?.songs
updateMultiToolbar()
}
private fun updateSelection(selected: List<Music>) {
playlistListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
val binding = requireBinding()
if (selected.isNotEmpty()) {
binding.detailSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
}
updateMultiToolbar()
}
private fun updateMultiToolbar() {
val id =
when {
detailModel.editedPlaylist.value != null -> R.id.detail_edit_toolbar
selectionModel.selected.value.isNotEmpty() -> R.id.detail_selection_toolbar
else -> R.id.detail_normal_toolbar
}
requireBinding().detailToolbar.setVisible(id)
}
}

View file

@ -106,10 +106,6 @@ class PlaylistDetailListAdapter(private val listener: Listener) :
interface Listener : DetailListAdapter.Listener<Song>, EditableListListener {
/** Called when the "edit" option is selected in the edit header. */
fun onStartEdit()
/** Called when the "confirm" option is selected in the edit header. */
fun onConfirmEdit()
/** Called when the "cancel" option is selected in the edit header. */
fun onDropEdit()
}
/**
@ -169,13 +165,9 @@ private class EditHeaderViewHolder private constructor(private val binding: Item
TooltipCompat.setTooltipText(this, contentDescription)
setOnClickListener { listener.onStartEdit() }
}
binding.headerConfirm.apply {
binding.headerSort.apply {
TooltipCompat.setTooltipText(this, contentDescription)
setOnClickListener { listener.onConfirmEdit() }
}
binding.headerCancel.apply {
TooltipCompat.setTooltipText(this, contentDescription)
setOnClickListener { listener.onDropEdit() }
setOnClickListener(listener::onOpenSortMenu)
}
}
@ -184,12 +176,8 @@ private class EditHeaderViewHolder private constructor(private val binding: Item
isGone = editing
jumpDrawablesToCurrentState()
}
binding.headerConfirm.apply {
isVisible = editing
jumpDrawablesToCurrentState()
}
binding.headerCancel.apply {
isVisible = editing
binding.headerSort.apply {
isGone = !editing
jumpDrawablesToCurrentState()
}
}
@ -238,6 +226,7 @@ private constructor(private val binding: ItemEditableSongBinding) :
elevation = binding.context.getDimen(R.dimen.elevation_normal)
alpha = 0
}
init {
binding.body.background =
LayerDrawable(

View file

@ -102,7 +102,7 @@ class HomeFragment :
// --- UI SETUP ---
binding.homeAppbar.addOnOffsetChangedListener(this)
binding.homeToolbar.apply {
binding.homeNormalToolbar.apply {
setOnMenuItemClickListener(this@HomeFragment)
MenuCompat.setGroupDividerEnabled(menu, true)
}
@ -169,7 +169,7 @@ class HomeFragment :
super.onDestroyBinding(binding)
storagePermissionLauncher = null
binding.homeAppbar.removeOnOffsetChangedListener(this)
binding.homeToolbar.setOnMenuItemClickListener(null)
binding.homeNormalToolbar.setOnMenuItemClickListener(null)
}
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
@ -178,8 +178,7 @@ class HomeFragment :
// Fade out the toolbar as the AppBarLayout collapses. To prevent status bar overlap,
// the alpha transition is shifted such that the Toolbar becomes fully transparent
// when the AppBarLayout is only at half-collapsed.
binding.homeSelectionToolbar.alpha =
1f - (abs(verticalOffset.toFloat()) / (range.toFloat() / 2))
binding.homeToolbar.alpha = 1f - (abs(verticalOffset.toFloat()) / (range.toFloat() / 2))
binding.homeContent.updatePadding(
bottom = binding.homeAppbar.totalScrollRange + verticalOffset)
}
@ -243,7 +242,7 @@ class HomeFragment :
binding.homePager.adapter =
HomePagerAdapter(homeModel.currentTabModes, childFragmentManager, viewLifecycleOwner)
val toolbarParams = binding.homeSelectionToolbar.layoutParams as AppBarLayout.LayoutParams
val toolbarParams = binding.homeToolbar.layoutParams as AppBarLayout.LayoutParams
if (homeModel.currentTabModes.size == 1) {
// A single tab makes the tab layout redundant, hide it and disable the collapsing
// behavior.
@ -285,7 +284,7 @@ class HomeFragment :
}
val sortMenu =
unlikelyToBeNull(binding.homeToolbar.menu.findItem(R.id.submenu_sorting).subMenu)
unlikelyToBeNull(binding.homeNormalToolbar.menu.findItem(R.id.submenu_sorting).subMenu)
val toHighlight = homeModel.getSortForTab(tabMode)
for (option in sortMenu) {
@ -456,11 +455,15 @@ class HomeFragment :
private fun updateSelection(selected: List<Music>) {
val binding = requireBinding()
if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) &&
selected.isNotEmpty()) {
// New selection started, show the AppBarLayout to indicate the new state.
logD("Significant selection occurred, expanding AppBar")
binding.homeAppbar.expandWithScrollingRecycler()
if (selected.isNotEmpty()) {
binding.homeSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
if (binding.homeToolbar.setVisible(R.id.home_selection_toolbar)) {
// New selection started, show the AppBarLayout to indicate the new state.
logD("Significant selection occurred, expanding AppBar")
binding.homeAppbar.expandWithScrollingRecycler()
}
} else {
binding.homeToolbar.setVisible(R.id.home_normal_toolbar)
}
}

View file

@ -50,6 +50,7 @@ import okio.buffer
import okio.source
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logD
@ -66,10 +67,10 @@ constructor(
val albums = computeAlbumOrdering(songs)
val streams = mutableListOf<InputStream>()
for (album in albums) {
openInputStream(album)?.let(streams::add)
if (streams.size == 4) {
return createMosaic(streams, size)
}
openInputStream(album)?.let(streams::add)
}
return streams.firstOrNull()?.let { stream ->
@ -81,7 +82,7 @@ constructor(
}
fun computeAlbumOrdering(songs: List<Song>): Collection<Album> =
songs.groupByTo(sortedMapOf(compareByDescending { it.songs.size })) { it.album }.keys
Sort(Sort.Mode.ByCount, Sort.Direction.DESCENDING).albums(songs.groupBy { it.album }.keys)
private suspend fun openInputStream(album: Album): InputStream? =
try {

View file

@ -39,20 +39,13 @@ abstract class SelectionFragment<VB : ViewBinding> :
protected abstract val musicModel: MusicViewModel
protected abstract val playbackModel: PlaybackViewModel
/**
* Get the [SelectionToolbarOverlay] of the concrete Fragment to be automatically managed by
* [SelectionFragment].
*
* @return The [SelectionToolbarOverlay] of the concrete [SelectionFragment]'s [VB], or null if
* there is not one.
*/
open fun getSelectionToolbar(binding: VB): SelectionToolbarOverlay? = null
open fun getSelectionToolbar(binding: VB): Toolbar? = null
override fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
getSelectionToolbar(binding)?.apply {
// Add cancel and menu item listeners to manage what occurs with the selection.
setOnSelectionCancelListener { selectionModel.drop() }
setNavigationOnClickListener { selectionModel.drop() }
setOnMenuItemClickListener(this@SelectionFragment)
}
}

View file

@ -1,178 +0,0 @@
/*
* Copyright (c) 2022 Auxio Project
* SelectionToolbarOverlay.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.list.selection
import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.appcompat.widget.Toolbar.OnMenuItemClickListener
import androidx.core.view.isInvisible
import com.google.android.material.appbar.MaterialToolbar
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.logD
/**
* A wrapper around a [MaterialToolbar] that adds an additional [MaterialToolbar] showing the
* current selection state.
*
* @author Alexander Capehart (OxygenCobalt)
*
* TODO: Generalize this into a "view flipper" class and then derive it through other means?
*/
class SelectionToolbarOverlay
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
FrameLayout(context, attrs, defStyleAttr) {
private lateinit var innerToolbar: MaterialToolbar
private val selectionToolbar =
MaterialToolbar(context).apply {
setNavigationIcon(R.drawable.ic_close_24)
inflateMenu(R.menu.menu_selection_actions)
if (isInEditMode) {
isInvisible = true
}
}
private var fadeThroughAnimator: ValueAnimator? = null
override fun onFinishInflate() {
super.onFinishInflate()
// Sanity check: Avoid incorrect views from being included in this layout.
check(childCount == 1 && getChildAt(0) is MaterialToolbar) {
"SelectionToolbarOverlay Must have only one MaterialToolbar child"
}
// The inner toolbar should be the first child.
innerToolbar = getChildAt(0) as MaterialToolbar
// Selection toolbar should appear on top of the inner toolbar.
addView(selectionToolbar)
}
/**
* Set an OnClickListener for when the "cancel" button in the selection [MaterialToolbar] is
* pressed.
*
* @param listener The OnClickListener to respond to this interaction.
* @see MaterialToolbar.setNavigationOnClickListener
*/
fun setOnSelectionCancelListener(listener: OnClickListener) {
selectionToolbar.setNavigationOnClickListener(listener)
}
/**
* Set an [OnMenuItemClickListener] for when a MenuItem is selected from the selection
* [MaterialToolbar].
*
* @param listener The [OnMenuItemClickListener] to respond to this interaction.
* @see MaterialToolbar.setOnMenuItemClickListener
*/
fun setOnMenuItemClickListener(listener: OnMenuItemClickListener?) {
selectionToolbar.setOnMenuItemClickListener(listener)
}
/**
* Update the selection [MaterialToolbar] to reflect the current selection amount.
*
* @param amount The amount of items that are currently selected.
* @return true if the selection [MaterialToolbar] changes, false otherwise.
*/
fun updateSelectionAmount(amount: Int): Boolean {
logD("Updating selection amount to $amount")
return if (amount > 0) {
// Only update the selected amount when it's non-zero to prevent a strange
// title text.
selectionToolbar.title = context.getString(R.string.fmt_selected, amount)
animateToolbarsVisibility(true)
} else {
animateToolbarsVisibility(false)
}
}
/**
* Animate the visibility of the inner and selection [MaterialToolbar]s to the given state.
*
* @param selectionVisible Whether the selection [MaterialToolbar] should be visible or not.
* @return true if the toolbars have changed, false otherwise.
*/
private fun animateToolbarsVisibility(selectionVisible: Boolean): Boolean {
// TODO: Animate nicer Material Fade transitions using animators (Normal transitions
// don't work due to translation)
// Set up the target transitions for both the inner and selection toolbars.
val targetInnerAlpha: Float
val targetSelectionAlpha: Float
val targetDuration: Long
if (selectionVisible) {
targetInnerAlpha = 0f
targetSelectionAlpha = 1f
targetDuration = context.getInteger(R.integer.anim_fade_enter_duration).toLong()
} else {
targetInnerAlpha = 1f
targetSelectionAlpha = 0f
targetDuration = context.getInteger(R.integer.anim_fade_exit_duration).toLong()
}
if (innerToolbar.alpha == targetInnerAlpha &&
selectionToolbar.alpha == targetSelectionAlpha) {
// Nothing to do.
return false
}
if (!isLaidOut) {
// Not laid out, just change it immediately while are not shown to the user.
// This is an initialization, so we return false despite changing.
setToolbarsAlpha(targetInnerAlpha)
return false
}
if (fadeThroughAnimator != null) {
fadeThroughAnimator?.cancel()
fadeThroughAnimator = null
}
fadeThroughAnimator =
ValueAnimator.ofFloat(innerToolbar.alpha, targetInnerAlpha).apply {
duration = targetDuration
addUpdateListener { setToolbarsAlpha(it.animatedValue as Float) }
start()
}
return true
}
/**
* Update the alpha of the inner and selection [MaterialToolbar]s.
*
* @param innerAlpha The opacity of the inner [MaterialToolbar]. This will map to the inverse
* opacity of the selection [MaterialToolbar].
*/
private fun setToolbarsAlpha(innerAlpha: Float) {
innerToolbar.apply {
alpha = innerAlpha
isInvisible = innerAlpha == 0f
}
selectionToolbar.apply {
alpha = 1 - innerAlpha
isInvisible = innerAlpha == 1f
}
}
}

View file

@ -81,7 +81,7 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
imm = binding.context.getSystemServiceCompat(InputMethodManager::class)
binding.searchToolbar.apply {
binding.searchNormalToolbar.apply {
// Initialize the current filtering mode.
menu.findItem(searchModel.getFilterOptionId()).isChecked = true
@ -126,7 +126,7 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
override fun onDestroyBinding(binding: FragmentSearchBinding) {
super.onDestroyBinding(binding)
binding.searchToolbar.setOnMenuItemClickListener(null)
binding.searchNormalToolbar.setOnMenuItemClickListener(null)
binding.searchRecycler.adapter = null
}
@ -198,10 +198,16 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
private fun updateSelection(selected: List<Music>) {
searchAdapter.setSelected(selected.toSet())
if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) &&
selected.isNotEmpty()) {
// Make selection of obscured items easier by hiding the keyboard.
hideKeyboard()
val binding = requireBinding()
if (selected.isNotEmpty()) {
binding.searchSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
if (binding.searchToolbar.setVisible(R.id.search_selection_toolbar)) {
// New selection started, show the keyboard to make selection easier.
logD("Significant selection occurred, hiding keyboard")
hideKeyboard()
}
} else {
binding.searchToolbar.setVisible(R.id.search_normal_toolbar)
}
}

View file

@ -0,0 +1,114 @@
/*
* Copyright (c) 2023 Auxio Project
* MultiToolbar.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.ui
import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.annotation.IdRes
import androidx.appcompat.widget.Toolbar
import androidx.core.view.children
import androidx.core.view.isInvisible
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.logD
class MultiToolbar
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
FrameLayout(context, attrs, defStyleAttr) {
private var fadeThroughAnimator: ValueAnimator? = null
private var currentlyVisible = 0
override fun onFinishInflate() {
super.onFinishInflate()
for (i in 1 until childCount) {
getChildAt(i).apply {
alpha = 0f
isInvisible = true
}
}
}
fun setVisible(@IdRes viewId: Int): Boolean {
val index = children.indexOfFirst { it.id == viewId }
if (index == currentlyVisible) return false
return animateToolbarsVisibility(currentlyVisible, index).also { currentlyVisible = index }
}
private fun animateToolbarsVisibility(from: Int, to: Int): Boolean {
// TODO: Animate nicer Material Fade transitions using animators (Normal transitions
// don't work due to translation)
// Set up the target transitions for both the inner and selection toolbars.
val targetFromAlpha = 0f
val targetToAlpha = 1f
val targetDuration =
if (from < to) {
context.getInteger(R.integer.anim_fade_enter_duration).toLong()
} else {
context.getInteger(R.integer.anim_fade_exit_duration).toLong()
}
logD(targetDuration)
val fromView = getChildAt(from) as Toolbar
val toView = getChildAt(to) as Toolbar
if (fromView.alpha == targetFromAlpha && toView.alpha == targetToAlpha) {
// Nothing to do.
return false
}
if (!isLaidOut) {
// Not laid out, just change it immediately while are not shown to the user.
// This is an initialization, so we return false despite changing.
setToolbarsAlpha(fromView, toView, targetFromAlpha)
return false
}
if (fadeThroughAnimator != null) {
fadeThroughAnimator?.cancel()
fadeThroughAnimator = null
}
fadeThroughAnimator =
ValueAnimator.ofFloat(fromView.alpha, targetFromAlpha).apply {
duration = targetDuration
addUpdateListener { setToolbarsAlpha(fromView, toView, it.animatedValue as Float) }
start()
}
return true
}
private fun setToolbarsAlpha(from: Toolbar, to: Toolbar, innerAlpha: Float) {
logD("${to.id == R.id.detail_edit_toolbar} ${1 - innerAlpha}")
from.apply {
alpha = innerAlpha
isInvisible = innerAlpha == 0f
}
to.apply {
alpha = 1 - innerAlpha
isInvisible = innerAlpha == 1f
}
}
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M840,280L840,840L120,840L120,120L680,120L840,280ZM760,314L646,200L200,200L200,760L760,760L760,314ZM480,720Q530,720 565,685Q600,650 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720ZM240,400L600,400L600,240L240,240L240,400ZM200,314L200,760L200,760L200,200L200,200L200,314Z"/>
</vector>

View file

@ -13,19 +13,38 @@
app:liftOnScroll="true"
app:liftOnScrollTargetViewId="@id/detail_recycler">
<org.oxycblt.auxio.list.selection.SelectionToolbarOverlay
android:id="@+id/detail_selection_toolbar"
<org.oxycblt.auxio.ui.MultiToolbar
android:id="@+id/detail_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/detail_toolbar"
android:id="@+id/detail_normal_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
app:navigationIcon="@drawable/ic_back_24" />
</org.oxycblt.auxio.list.selection.SelectionToolbarOverlay>
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/detail_selection_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
app:navigationIcon="@drawable/ic_close_24"
app:menu="@menu/menu_selection_actions" />
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/detail_edit_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
app:navigationIcon="@drawable/ic_close_24"
app:menu="@menu/menu_edit_actions" />
</org.oxycblt.auxio.ui.MultiToolbar>
</org.oxycblt.auxio.detail.DetailAppBarLayout>

View file

@ -12,20 +12,29 @@
android:id="@+id/home_appbar"
style="@style/Widget.Auxio.AppBarLayout">
<org.oxycblt.auxio.list.selection.SelectionToolbarOverlay
android:id="@+id/home_selection_toolbar"
<org.oxycblt.auxio.ui.MultiToolbar
android:id="@+id/home_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/home_toolbar"
android:id="@+id/home_normal_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_scrollFlags="scroll|enterAlways"
app:menu="@menu/menu_home"
app:title="@string/info_app_name" />
</org.oxycblt.auxio.list.selection.SelectionToolbarOverlay>
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/home_selection_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
app:navigationIcon="@drawable/ic_close_24"
app:menu="@menu/menu_selection_actions" />
</org.oxycblt.auxio.ui.MultiToolbar>
<com.google.android.material.tabs.TabLayout
android:id="@+id/home_tabs"

View file

@ -12,13 +12,13 @@
app:liftOnScroll="true"
app:liftOnScrollTargetViewId="@id/search_recycler">
<org.oxycblt.auxio.list.selection.SelectionToolbarOverlay
android:id="@+id/search_selection_toolbar"
<org.oxycblt.auxio.ui.MultiToolbar
android:id="@+id/search_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/search_toolbar"
android:id="@+id/search_normal_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:menu="@menu/menu_search"
@ -49,7 +49,16 @@
</com.google.android.material.appbar.MaterialToolbar>
</org.oxycblt.auxio.list.selection.SelectionToolbarOverlay>
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/search_selection_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
app:navigationIcon="@drawable/ic_close_24"
app:menu="@menu/menu_selection_actions" />
</org.oxycblt.auxio.ui.MultiToolbar>
</org.oxycblt.auxio.ui.CoordinatorAppBarLayout>

View file

@ -29,22 +29,13 @@
app:layout_constraintEnd_toEndOf="parent" />
<org.oxycblt.auxio.ui.RippleFixMaterialButton
android:id="@+id/header_confirm"
style="@style/Widget.Auxio.Button.Icon.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/lbl_ok"
app:icon="@drawable/ic_check_24"
app:layout_constraintEnd_toEndOf="parent" />
<org.oxycblt.auxio.ui.RippleFixMaterialButton
android:id="@+id/header_cancel"
android:id="@+id/header_sort"
style="@style/Widget.Auxio.Button.Icon.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_mid_medium"
android:contentDescription="@string/lbl_cancel"
app:icon="@drawable/ic_close_24"
app:icon="@drawable/ic_sort_24"
app:layout_constraintEnd_toEndOf="parent" />
</LinearLayout>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_save"
android:title="@string/lbl_save"
app:showAsAction="always"
android:icon="@drawable/ic_save_24" />
</menu>