ui: remove memberBinding [#80]

Remove all usages of memberBinding from the app.

For some reason, certain devices running Android 10 and lower will
have a lifecycle race condition whenever the theme is mis-matched.
This ends up resulting in an invalid state whenever memberBinder was
used. Since we can't replace memberBinder with a better solution,
just dumpster the whole thing. This platform is so god damn broken,
jesus christ.

Resolves #80.
This commit is contained in:
OxygenCobalt 2022-02-24 19:31:48 -07:00
parent fc4c7714a2
commit c5be39774a
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
13 changed files with 66 additions and 132 deletions

View file

@ -6,6 +6,9 @@
- Rounded images are more nuanced
- Shuffle and Repeat mode buttons now have more contrast when they are turned on
#### What's Fixed
- Fixed crash on certain devices running Android 10 and lower when a differing theme from the system theme was used.
#### What's Changed
- All cover art is now cropped to a 1:1 aspect ratio
@ -14,6 +17,7 @@
- Switches now have a disabled state
- Reworked dynamic color usage
- Reworked logging
- Upgrade ExoPlayer to v2.17.0 [Eliminates custom fork]
## v2.2.1
#### What's Improved

View file

@ -57,6 +57,7 @@ class AlbumDetailFragment : DetailFragment() {
): View {
detailModel.setAlbum(args.albumId)
val binding = FragmentDetailBinding.inflate(layoutInflater)
val detailAdapter = AlbumDetailAdapter(
playbackModel, detailModel,
doOnClick = { playbackModel.playSong(it, PlaybackMode.IN_ALBUM) },
@ -67,7 +68,7 @@ class AlbumDetailFragment : DetailFragment() {
binding.lifecycleOwner = viewLifecycleOwner
setupToolbar(detailModel.curAlbum.value!!, R.menu.menu_album_detail) { itemId ->
setupToolbar(detailModel.curAlbum.value!!, binding, R.menu.menu_album_detail) { itemId ->
when (itemId) {
R.id.action_play_next -> {
playbackModel.playNext(detailModel.curAlbum.value!!)
@ -85,7 +86,7 @@ class AlbumDetailFragment : DetailFragment() {
}
}
setupRecycler(detailAdapter) { pos ->
setupRecycler(binding, detailAdapter) { pos ->
val item = detailAdapter.currentList[pos]
item is Header || item is ActionHeader || item is Album
}
@ -113,7 +114,7 @@ class AlbumDetailFragment : DetailFragment() {
is Song -> {
if (detailModel.curAlbum.value!!.id == item.album.id) {
logD("Navigating to a song in this album")
scrollToItem(item.id, detailAdapter)
scrollToItem(item.id, binding, detailAdapter)
detailModel.finishNavToItem()
} else {
logD("Navigating to another album")
@ -185,7 +186,11 @@ class AlbumDetailFragment : DetailFragment() {
/**
* Scroll to an song using its [id].
*/
private fun scrollToItem(id: Long, adapter: AlbumDetailAdapter) {
private fun scrollToItem(
id: Long,
binding: FragmentDetailBinding,
adapter: AlbumDetailAdapter
) {
// Calculate where the item for the currently played song is
val pos = adapter.currentList.indexOfFirst { it.id == id && it is Song }

View file

@ -25,6 +25,7 @@ import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album
@ -51,6 +52,7 @@ class ArtistDetailFragment : DetailFragment() {
): View {
detailModel.setArtist(args.artistId)
val binding = FragmentDetailBinding.inflate(layoutInflater)
val detailAdapter = ArtistDetailAdapter(
playbackModel,
doOnClick = { data ->
@ -74,8 +76,8 @@ class ArtistDetailFragment : DetailFragment() {
binding.lifecycleOwner = viewLifecycleOwner
setupToolbar(detailModel.curArtist.value!!)
setupRecycler(detailAdapter) { pos ->
setupToolbar(detailModel.curArtist.value!!, binding)
setupRecycler(binding, detailAdapter) { pos ->
// If the item is an ActionHeader we need to also make the item full-width
val item = detailAdapter.currentList[pos]
item is Header || item is ActionHeader || item is Artist

View file

@ -23,13 +23,13 @@ import androidx.appcompat.widget.PopupMenu
import androidx.core.view.children
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.Navigation.findNavController
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.memberBinding
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.logD
@ -40,7 +40,6 @@ import org.oxycblt.auxio.util.logD
abstract class DetailFragment : Fragment() {
protected val detailModel: DetailViewModel by activityViewModels()
protected val playbackModel: PlaybackViewModel by activityViewModels()
protected val binding by memberBinding(FragmentDetailBinding::inflate)
override fun onResume() {
super.onResume()
@ -61,6 +60,7 @@ abstract class DetailFragment : Fragment() {
*/
protected fun setupToolbar(
data: MusicParent,
binding: FragmentDetailBinding,
@MenuRes menuId: Int = -1,
onMenuClick: ((itemId: Int) -> Boolean)? = null
) {
@ -87,6 +87,7 @@ abstract class DetailFragment : Fragment() {
* Shortcut method for recyclerview setup
*/
protected fun setupRecycler(
binding: FragmentDetailBinding,
detailAdapter: RecyclerView.Adapter<RecyclerView.ViewHolder>,
gridLookup: (Int) -> Boolean
) {

View file

@ -24,6 +24,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter
import org.oxycblt.auxio.music.ActionHeader
import org.oxycblt.auxio.music.Album
@ -51,6 +52,7 @@ class GenreDetailFragment : DetailFragment() {
): View {
detailModel.setGenre(args.genreId)
val binding = FragmentDetailBinding.inflate(inflater)
val detailAdapter = GenreDetailAdapter(
playbackModel,
doOnClick = { song ->
@ -65,8 +67,8 @@ class GenreDetailFragment : DetailFragment() {
binding.lifecycleOwner = viewLifecycleOwner
setupToolbar(detailModel.curGenre.value!!)
setupRecycler(detailAdapter) { pos ->
setupToolbar(detailModel.curGenre.value!!, binding)
setupRecycler(binding, detailAdapter) { pos ->
val item = detailAdapter.currentList[pos]
item is Header || item is ActionHeader || item is Genre
}

View file

@ -24,6 +24,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeFragmentDirections
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.toDate
@ -43,6 +44,10 @@ class AlbumListFragment : HomeListFragment() {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentHomeListBinding.inflate(layoutInflater)
// / --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
val adapter = AlbumAdapter(
@ -54,7 +59,7 @@ class AlbumListFragment : HomeListFragment() {
::newMenu
)
setupRecycler(R.id.home_album_list, adapter, homeModel.albums)
setupRecycler(R.id.home_album_list, binding, adapter, homeModel.albums)
return binding.root
}

View file

@ -24,6 +24,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeFragmentDirections
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.ui.ArtistViewHolder
@ -40,6 +41,10 @@ class ArtistListFragment : HomeListFragment() {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentHomeListBinding.inflate(layoutInflater)
// / --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
val adapter = ArtistAdapter(
@ -51,7 +56,7 @@ class ArtistListFragment : HomeListFragment() {
::newMenu
)
setupRecycler(R.id.home_artist_list, adapter, homeModel.artists)
setupRecycler(R.id.home_artist_list, binding, adapter, homeModel.artists)
return binding.root
}

View file

@ -24,6 +24,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeFragmentDirections
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.ui.GenreViewHolder
@ -40,6 +41,10 @@ class GenreListFragment : HomeListFragment() {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentHomeListBinding.inflate(layoutInflater)
// / --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
val adapter = GenreAdapter(
@ -51,7 +56,7 @@ class GenreListFragment : HomeListFragment() {
::newMenu
)
setupRecycler(R.id.home_genre_list, adapter, homeModel.genres)
setupRecycler(R.id.home_genre_list, binding, adapter, homeModel.genres)
return binding.root
}

View file

@ -28,7 +28,6 @@ import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.memberBinding
import org.oxycblt.auxio.util.applySpans
/**
@ -36,10 +35,6 @@ import org.oxycblt.auxio.util.applySpans
* @author OxygenCobalt
*/
abstract class HomeListFragment : Fragment() {
protected val binding: FragmentHomeListBinding by memberBinding(
FragmentHomeListBinding::inflate
)
protected val homeModel: HomeViewModel by activityViewModels()
protected val playbackModel: PlaybackViewModel by activityViewModels()
@ -50,6 +45,7 @@ abstract class HomeListFragment : Fragment() {
protected fun <T : Item, VH : RecyclerView.ViewHolder> setupRecycler(
@IdRes uniqueId: Int,
binding: FragmentHomeListBinding,
homeAdapter: HomeAdapter<T, VH>,
homeData: LiveData<List<T>>,
) {

View file

@ -23,6 +23,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.toDate
import org.oxycblt.auxio.ui.DisplayMode
@ -41,6 +42,10 @@ class SongListFragment : HomeListFragment() {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentHomeListBinding.inflate(layoutInflater)
// / --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
val adapter = SongsAdapter(
@ -50,7 +55,7 @@ class SongListFragment : HomeListFragment() {
::newMenu
)
setupRecycler(R.id.home_song_list, adapter, homeModel.songs)
setupRecycler(R.id.home_song_list, binding, adapter, homeModel.songs)
return binding.root
}

View file

@ -32,7 +32,6 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentPlaybackBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.playback.state.LoopMode
import org.oxycblt.auxio.ui.memberBinding
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.systemBarInsetsCompat
@ -44,17 +43,19 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
class PlaybackFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels()
private val binding by memberBinding(FragmentPlaybackBinding::inflate) {
playbackSong.isSelected = false // Clear marquee to prevent a memory leak
}
private var mLastBinding: FragmentPlaybackBinding? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentPlaybackBinding.inflate(layoutInflater)
val queueItem: MenuItem
// See onDestroyView for why we do this
mLastBinding = binding
// --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
@ -92,6 +93,8 @@ class PlaybackFragment : Fragment() {
// Make marquee of song title work
binding.playbackSong.isSelected = true
binding.playbackSeekBar.onConfirmListener = playbackModel::setPosition
// Abuse the play/pause FAB (see style definition for more info)
binding.playbackPlayPause.post {
binding.playbackPlayPause.stateListAnimator = null
}
@ -156,6 +159,15 @@ class PlaybackFragment : Fragment() {
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
// playbackSong will leak if we don't disable marquee, keep the binding around
// so that we can turn it off when we destroy the view.
mLastBinding?.playbackSong?.isSelected = false
mLastBinding = null
}
private fun navigateUp() {
// This is a dumb and fragile hack but this fragment isn't part of the navigation stack
// so we can't really do much

View file

@ -1,105 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
* MemberBinder.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.view.LayoutInflater
import androidx.databinding.ViewDataBinding
import androidx.fragment.app.Fragment
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import org.oxycblt.auxio.util.assertMainThread
import org.oxycblt.auxio.util.inflater
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
/**
* A delegate that creates a binding that can be used as a member variable without nullability or
* memory leaks.
* @param inflate The ViewBinding inflation method that should be used
* @param onDestroy What to do when the binding is destroyed
*/
fun <T : ViewDataBinding> Fragment.memberBinding(
inflate: (LayoutInflater) -> T,
onDestroy: T.() -> Unit = {}
) = MemberBinder(this, inflate, onDestroy)
/**
* The delegate for the [memberBinding] shortcut function.
* Adapted from KAHelpers (https://github.com/FunkyMuse/KAHelpers/tree/master/viewbinding)
* @author OxygenCobalt
*/
class MemberBinder<T : ViewDataBinding>(
private val fragment: Fragment,
private val inflate: (LayoutInflater) -> T,
private val onDestroy: T.() -> Unit
) : ReadOnlyProperty<Fragment, T>, LifecycleObserver, LifecycleEventObserver {
private var fragmentBinding: T? = null
init {
fragment.observeOwnerThroughCreation {
lifecycle.addObserver(this@MemberBinder)
}
}
override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
assertMainThread()
val binding = fragmentBinding
// If the fragment is already initialized, then just return that.
if (binding != null) {
return binding
}
val lifecycle = fragment.viewLifecycleOwner.lifecycle
check(lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
"Fragment views are destroyed"
}
// Otherwise create the binding and return that.
return inflate(thisRef.requireContext().inflater).also {
fragmentBinding = it
}
}
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_DESTROY) {
fragmentBinding?.onDestroy()
fragmentBinding = null
}
}
private inline fun Fragment.observeOwnerThroughCreation(
crossinline viewOwner: LifecycleOwner.() -> Unit
) {
lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onCreate(owner: LifecycleOwner) {
super.onCreate(owner)
viewLifecycleOwnerLiveData.observe(this@observeOwnerThroughCreation) {
it.viewOwner()
}
}
})
}
}

View file

@ -53,9 +53,7 @@ is separated into three phases:
- Set up ViewModel instances and LiveData observers
`findViewById` is to **only** be used when interfacing with non-Auxio views. Otherwise, view-binding should be
used in all cases. If one needs to keep track of a view-binding outside of `onCreateView`, then one can declare
a binding `by memberBinding(BindingClass::inflate)` in order to have a binding that properly disposes itself
on lifecycle events.
used in all cases. Avoid usages of databinding outside of the `onCreateView` step unless absolutely necessary.
At times it may be more appropriate to use a `View` instead of a full blown fragment. This is okay as long as
view-binding is still used.
@ -290,7 +288,6 @@ Shared views and view configuration models. This contains:
- Customized views such as `EdgeAppBarLayout` and `EdgeRecyclerView`, which add some extra functionality not provided by default
- Configuration models like `DisplayMode` and `Sort`, which are used in many places but aren't tied to a specific feature.
- `newMenu` and `ActionMenu`, which automates menu creation for most data types
- `memberBinding` and `MemberBinder`, which allows for ViewBindings to be used as a member variable without memory leaks or nullability issues.
#### `.util`
Shared utilities. This is primarily for QoL when developing Auxio. Documentation is provided on each method.