ui: add util for fragment objects

Add a utility to easily work with lifecycle-dependent fragment objects.

This reduces the code duplication required to maintain objects that
would leak after the destruction of a fragment. We normally would not
do this as a delegate, as that usually entails some lifecycle wizardry
that can easily break and crash the app in esoteric situations.
However, this this just extends the normal lifecycle without watching
any state, it seems to be pretty safe to use.
This commit is contained in:
OxygenCobalt 2022-06-20 12:03:57 -06:00
parent bd92ba2175
commit b041fe68d0
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
17 changed files with 146 additions and 179 deletions

View file

@ -30,7 +30,6 @@ import org.oxycblt.auxio.music.IndexerService
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.system.PlaybackService
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.settings.settings
import org.oxycblt.auxio.util.androidViewModels
import org.oxycblt.auxio.util.getSystemBarInsetsCompat
import org.oxycblt.auxio.util.isNight

View file

@ -77,18 +77,20 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
private val homeModel: HomeViewModel by androidActivityViewModels()
private val indexerModel: IndexerViewModel by activityViewModels()
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null
private var sortItem: MenuItem? = null
// lifecycleObject builds this in the creation step, so doing this is okay.
private val storagePermissionLauncher: ActivityResultLauncher<String> by lifecycleObject {
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
indexerModel.reindex()
}
}
private val sortItem: MenuItem by lifecycleObject { binding ->
binding.homeToolbar.menu.findItem(R.id.submenu_sorting)
}
override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
// Build the permission launcher here as you can only do it in onCreateView/onCreate
storagePermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
indexerModel.reindex()
}
binding.homeAppbar.apply {
addOnOffsetChangedListener { _, offset ->
val range = binding.homeAppbar.totalScrollRange
@ -100,10 +102,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
}
}
binding.homeToolbar.apply {
sortItem = menu.findItem(R.id.submenu_sorting)
setOnMenuItemClickListener(this@HomeFragment)
}
binding.homeToolbar.setOnMenuItemClickListener(this@HomeFragment)
updateTabConfiguration()
@ -151,8 +150,6 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
override fun onDestroyBinding(binding: FragmentHomeBinding) {
super.onDestroyBinding(binding)
binding.homeToolbar.setOnMenuItemClickListener(null)
storagePermissionLauncher = null
sortItem = null
}
override fun onMenuItemClick(item: MenuItem): Boolean {
@ -246,9 +243,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
}
private fun updateSortMenu(displayMode: DisplayMode, isVisible: (Int) -> Boolean) {
val sortMenu =
requireNotNull(sortItem?.subMenu) { "Cannot update sort menu while detached" }
val sortMenu = requireNotNull(sortItem.subMenu)
val toHighlight = homeModel.getSortForDisplay(displayMode)
for (option in sortMenu) {
@ -323,18 +318,14 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
}
}
is Indexer.Response.NoPerms -> {
val launcher =
requireNotNull(storagePermissionLauncher) {
"Cannot access permission launcher while detached"
}
binding.homeIndexingProgress.visibility = View.INVISIBLE
binding.homeIndexingStatus.textSafe = getString(R.string.err_no_perms)
binding.homeIndexingAction.apply {
visibility = View.VISIBLE
text = getString(R.string.lbl_grant)
setOnClickListener {
launcher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
storagePermissionLauncher.launch(
Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
}

View file

@ -23,7 +23,6 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.settings.settings
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.MenuItemListener
@ -31,6 +30,7 @@ import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.ui.SongViewHolder
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.SyncBackingData
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logEOrThrow
@ -41,7 +41,7 @@ import org.oxycblt.auxio.util.logEOrThrow
*/
class SongListFragment : HomeListFragment<Song>() {
private val homeAdapter = SongsAdapter(this)
private val settings: Settings by settings()
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)

View file

@ -26,11 +26,10 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogTabsBinding
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.settings.settings
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.requireAttached
/**
* The dialog for customizing library tabs.
@ -38,9 +37,10 @@ import org.oxycblt.auxio.util.requireAttached
*/
class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAdapter.Listener {
private val tabAdapter = TabAdapter(this)
private val settings: Settings by settings()
private var touchHelper: ItemTouchHelper? = null
private var callback: TabDragCallback? = null
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
private val touchHelper: ItemTouchHelper by lifecycleObject {
ItemTouchHelper(TabDragCallback(tabAdapter))
}
override fun onCreateBinding(inflater: LayoutInflater) = DialogTabsBinding.inflate(inflater)
@ -65,7 +65,7 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
binding.tabRecycler.apply {
adapter = tabAdapter
requireTouchHelper().attachToRecyclerView(this)
touchHelper.attachToRecyclerView(this)
}
}
@ -99,7 +99,7 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
}
override fun onPickUpTab(viewHolder: RecyclerView.ViewHolder) {
requireTouchHelper().startDrag(viewHolder)
touchHelper.startDrag(viewHolder)
}
private fun findSavedTabState(savedInstanceState: Bundle?): Array<Tab>? {
@ -111,19 +111,6 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
return null
}
private fun requireTouchHelper(): ItemTouchHelper {
requireAttached()
val instance = touchHelper
if (instance != null) {
return instance
}
val newCallback = TabDragCallback(tabAdapter)
val newInstance = ItemTouchHelper(newCallback)
callback = newCallback
touchHelper = newInstance
return newInstance
}
companion object {
const val TAG = BuildConfig.APPLICATION_ID + ".tag.TAB_CUSTOMIZE"
const val KEY_TABS = BuildConfig.APPLICATION_ID + ".key.PENDING_TABS"

View file

@ -31,9 +31,9 @@ import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
import org.oxycblt.auxio.music.Directory
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.settings.settings
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getSystemServiceSafe
import org.oxycblt.auxio.util.hardRestart
import org.oxycblt.auxio.util.logD
@ -48,7 +48,7 @@ class MusicDirsDialog :
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val dirAdapter = MusicDirAdapter(this)
private val settings: Settings by settings()
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
private var storageManager: StorageManager? = null
override fun onCreateBinding(inflater: LayoutInflater) =

View file

@ -57,7 +57,9 @@ class PlaybackPanelFragment :
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
private var queueItem: MenuItem? = null
private val queueItem: MenuItem by lifecycleObject { binding ->
binding.playbackToolbar.menu.findItem(R.id.action_queue)
}
override fun onCreateBinding(inflater: LayoutInflater) =
FragmentPlaybackPanelBinding.inflate(inflater)
@ -82,7 +84,6 @@ class PlaybackPanelFragment :
binding.playbackToolbar.apply {
setNavigationOnClickListener { navModel.mainNavigateTo(MainNavigationAction.Collapse) }
setOnMenuItemClickListener(this@PlaybackPanelFragment)
queueItem = menu.findItem(R.id.action_queue)
}
binding.playbackSong.apply {
@ -130,7 +131,6 @@ class PlaybackPanelFragment :
binding.playbackToolbar.setOnMenuItemClickListener(null)
binding.playbackSong.isSelected = false
binding.playbackSeekBar.callback = null
queueItem = null
}
override fun onMenuItemClick(item: MenuItem): Boolean {
@ -198,7 +198,6 @@ class PlaybackPanelFragment :
}
private fun updateNextUp(nextUp: List<Song>) {
requireNotNull(queueItem) { "Cannot update next up in non-view state" }.isEnabled =
nextUp.isNotEmpty()
queueItem.isEnabled = nextUp.isNotEmpty()
}
}

View file

@ -36,7 +36,6 @@ import org.oxycblt.auxio.playback.state.PlaybackStateDatabase
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.settings.settings
import org.oxycblt.auxio.util.application
import org.oxycblt.auxio.util.logE

View file

@ -29,7 +29,6 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.requireAttached
/**
* A [Fragment] that shows the queue and enables editing as well.
@ -37,9 +36,10 @@ import org.oxycblt.auxio.util.requireAttached
*/
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemListener {
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private var queueAdapter = QueueAdapter(this)
private var touchHelper: ItemTouchHelper? = null
private var callback: QueueDragCallback? = null
private val queueAdapter = QueueAdapter(this)
private val touchHelper: ItemTouchHelper by lifecycleObject {
ItemTouchHelper(QueueDragCallback(playbackModel))
}
override fun onCreateBinding(inflater: LayoutInflater) = FragmentQueueBinding.inflate(inflater)
@ -48,7 +48,7 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemList
binding.queueRecycler.apply {
adapter = queueAdapter
requireTouchHelper().attachToRecyclerView(this)
touchHelper.attachToRecyclerView(this)
}
// --- VIEWMODEL SETUP ----
@ -59,12 +59,10 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemList
override fun onDestroyBinding(binding: FragmentQueueBinding) {
super.onDestroyBinding(binding)
binding.queueRecycler.adapter = null
touchHelper = null
callback = null
}
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
requireTouchHelper().startDrag(viewHolder)
touchHelper.startDrag(viewHolder)
}
private fun updateQueue(queue: List<Song>) {
@ -75,17 +73,4 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueItemList
queueAdapter.data.submitList(queue.toMutableList())
}
private fun requireTouchHelper(): ItemTouchHelper {
requireAttached()
val instance = touchHelper
if (instance != null) {
return instance
}
val newCallback = QueueDragCallback(playbackModel)
val newInstance = ItemTouchHelper(newCallback)
callback = newCallback
touchHelper = newInstance
return newInstance
}
}

View file

@ -26,8 +26,8 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPreAmpBinding
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.settings.settings
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.textSafe
/**
@ -35,7 +35,7 @@ import org.oxycblt.auxio.util.textSafe
* @author OxygenCobalt
*/
class PreAmpCustomizeDialog : ViewBindingDialogFragment<DialogPreAmpBinding>() {
private val settings: Settings by settings()
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
override fun onCreateBinding(inflater: LayoutInflater) = DialogPreAmpBinding.inflate(inflater)

View file

@ -37,17 +37,16 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.settings.settings
import org.oxycblt.auxio.ui.Header
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.MenuFragment
import org.oxycblt.auxio.ui.MenuItemListener
import org.oxycblt.auxio.util.androidViewModels
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getSystemServiceSafe
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.requireAttached
/**
* A [Fragment] that allows for the searching of the entire music library.
@ -59,8 +58,11 @@ class SearchFragment :
private val searchModel: SearchViewModel by androidViewModels()
private val searchAdapter = SearchAdapter(this)
private val settings: Settings by settings()
private var imm: InputMethodManager? = null
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
private val imm: InputMethodManager by lifecycleObject { binding ->
binding.context.getSystemServiceSafe(InputMethodManager::class)
}
private var launchedKeyboard = false
override fun onCreateBinding(inflater: LayoutInflater) = FragmentSearchBinding.inflate(inflater)
@ -70,7 +72,7 @@ class SearchFragment :
menu.findItem(searchModel.filterMode?.itemId ?: R.id.option_filter_all).isChecked = true
setNavigationOnClickListener {
requireImm().hide()
imm.hide()
findNavController().navigateUp()
}
@ -86,9 +88,7 @@ class SearchFragment :
if (!launchedKeyboard) {
// Auto-open the keyboard when this view is shown
requestFocus()
postDelayed(200) {
requireImm().showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}
postDelayed(200) { imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) }
launchedKeyboard = true
}
@ -108,7 +108,6 @@ class SearchFragment :
override fun onDestroyBinding(binding: FragmentSearchBinding) {
binding.searchToolbar.setOnMenuItemClickListener(null)
binding.searchRecycler.adapter = null
imm = null
}
override fun onMenuItemClick(item: MenuItem): Boolean {
@ -166,18 +165,7 @@ class SearchFragment :
else -> return
})
requireImm().hide()
}
private fun requireImm(): InputMethodManager {
requireAttached()
val instance = imm
if (instance != null) {
return instance
}
val newInstance = requireContext().getSystemServiceSafe(InputMethodManager::class)
imm = newInstance
return newInstance
imm.hide()
}
private fun InputMethodManager.hide() {

View file

@ -22,12 +22,7 @@ import android.content.SharedPreferences
import android.os.storage.StorageManager
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.edit
import androidx.fragment.app.Fragment
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.preference.PreferenceManager
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
import org.oxycblt.auxio.R
import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.music.Directory
@ -39,55 +34,14 @@ import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.accent.Accent
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.requireAttached
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* Shortcut delegate in order to receive a [Settings] that will be created/destroyed
* in each lifecycle.
*
* TODO: Replace with generalized method
*/
fun Fragment.settings(): ReadOnlyProperty<Fragment, Settings> =
object : ReadOnlyProperty<Fragment, Settings>, DefaultLifecycleObserver {
private var settings: Settings? = null
init {
lifecycle.addObserver(
object : DefaultLifecycleObserver {
override fun onCreate(owner: LifecycleOwner) {
viewLifecycleOwnerLiveData.observe(this@settings) { viewLifecycleOwner ->
viewLifecycleOwner.lifecycle.addObserver(this)
}
}
})
}
override fun getValue(thisRef: Fragment, property: KProperty<*>): Settings {
requireAttached()
val currentSettings = settings
if (currentSettings != null) {
return currentSettings
}
val newSettings = Settings(requireContext())
settings = newSettings
return newSettings
}
override fun onDestroy(owner: LifecycleOwner) {
settings?.release()
settings = null
}
}
/**
* Auxio's settings.
*
* This object wraps [SharedPreferences] in a type-safe manner, allowing access to all of the
* major settings that Auxio uses. Mutability is determined by use, as some values are written
* by PreferenceManager and others are written by Auxio's code.
* This object wraps [SharedPreferences] in a type-safe manner, allowing access to all of the major
* settings that Auxio uses. Mutability is determined by use, as some values are written by
* PreferenceManager and others are written by Auxio's code.
*
* @author OxygenCobalt
*/

View file

@ -56,7 +56,6 @@ import org.oxycblt.auxio.util.showToast
@Suppress("UNUSED")
class SettingsListFragment : PreferenceFragmentCompat() {
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val settings: Settings by settings()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@ -134,6 +133,8 @@ class SettingsListFragment : PreferenceFragmentCompat() {
/** Recursively handle a preference, doing any specific actions on it. */
private fun recursivelyHandlePreference(preference: Preference) {
val settings = Settings(requireContext())
if (!preference.isVisible) return
if (preference is PreferenceCategory) {

View file

@ -22,8 +22,8 @@ import android.util.AttributeSet
import androidx.preference.DialogPreference
/**
* Wraps [DialogPreference] as to make it type-distinct from other preferences while also
* making it possible to use in a PreferenceScreen.
* Wraps [DialogPreference] as to make it type-distinct from other preferences while also making it
* possible to use in a PreferenceScreen.
*/
class WrappedDialogPreference
@JvmOverloads

View file

@ -23,53 +23,73 @@ import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* A dialog fragment enabling ViewBinding inflation and usage across the dialog fragment lifecycle.
* @author OxygenCobalt
*/
abstract class ViewBindingDialogFragment<T : ViewBinding> : DialogFragment() {
private var _binding: T? = null
/**
* Inflate the binding from the given [inflater]. This should usually be done by the binding
* implementation's inflate function.
*/
protected abstract fun onCreateBinding(inflater: LayoutInflater): T
abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
private var _binding: VB? = null
private var lifecycleObjects = mutableListOf<LifecycleObject<VB, *>>()
/** Called during [onCreateDialog]. Dialog elements should be configured here. */
protected open fun onConfigDialog(builder: AlertDialog.Builder) {}
/**
* Inflate the binding from the given [inflater]. This should usually be done by the binding
* implementation's inflate function.
*/
protected abstract fun onCreateBinding(inflater: LayoutInflater): VB
/**
* Called during [onViewCreated] when the binding was successfully inflated and set as the view.
* This is where view setup should occur.
*/
protected open fun onBindingCreated(binding: T, savedInstanceState: Bundle?) {}
protected open fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {}
/**
* Called during [onDestroyView] when the binding should be destroyed and all callbacks or
* leaking elements be released.
*/
protected open fun onDestroyBinding(binding: T) {}
protected open fun onDestroyBinding(binding: VB) {}
/** Maybe get the binding. This will be null outside of the fragment view lifecycle. */
protected val binding: T?
protected val binding: VB?
get() = _binding
/**
* Get the binding under the assumption that the fragment has a view at this state in the
* lifecycle. This will throw an exception if the fragment is not in a valid lifecycle.
*/
protected fun requireBinding(): T {
protected fun requireBinding(): VB {
return requireNotNull(_binding) {
"ViewBinding was available. Fragment should be a valid state " +
"right now, but instead it was ${lifecycle.currentState}"
}
}
fun <T> lifecycleObject(create: (VB) -> T): ReadOnlyProperty<Fragment, T> {
lifecycleObjects.add(LifecycleObject(null, create))
return object : ReadOnlyProperty<Fragment, T> {
private val objIdx = lifecycleObjects.lastIndex
@Suppress("UNCHECKED_CAST")
override fun getValue(thisRef: Fragment, property: KProperty<*>) =
requireNotNull(lifecycleObjects[objIdx].data) {
"Cannot access lifecycle object when view does not exist"
}
as T
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@ -84,6 +104,8 @@ abstract class ViewBindingDialogFragment<T : ViewBinding> : DialogFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = unlikelyToBeNull(_binding)
lifecycleObjects.forEach { it.populate(binding) }
onBindingCreated(requireBinding(), savedInstanceState)
(requireDialog() as AlertDialog).setView(view)
logD("Fragment created")
@ -91,8 +113,19 @@ abstract class ViewBindingDialogFragment<T : ViewBinding> : DialogFragment() {
override fun onDestroyView() {
super.onDestroyView()
onDestroyBinding(requireBinding())
onDestroyBinding(unlikelyToBeNull(_binding))
lifecycleObjects.forEach { it.clear() }
_binding = null
logD("Fragment destroyed")
}
private data class LifecycleObject<VB, T>(var data: T?, val create: (VB) -> T) {
fun populate(binding: VB) {
data = create(binding)
}
fun clear() {
data = null
}
}
}

View file

@ -23,48 +23,71 @@ import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* A fragment enabling ViewBinding inflation and usage across the fragment lifecycle.
* @author OxygenCobalt
*/
abstract class ViewBindingFragment<T : ViewBinding> : Fragment() {
private var _binding: T? = null
abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
private var _binding: VB? = null
private var lifecycleObjects = mutableListOf<LifecycleObject<VB, *>>()
/**
* Inflate the binding from the given [inflater]. This should usually be done by the binding
* implementation's inflate function.
*/
protected abstract fun onCreateBinding(inflater: LayoutInflater): T
protected abstract fun onCreateBinding(inflater: LayoutInflater): VB
/**
* Called during [onViewCreated] when the binding was successfully inflated and set as the view.
* This is where view setup should occur.
*/
protected open fun onBindingCreated(binding: T, savedInstanceState: Bundle?) {}
protected open fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {}
/**
* Called during [onDestroyView] when the binding should be destroyed and all callbacks or
* leaking elements be released.
*/
protected open fun onDestroyBinding(binding: T) {}
protected open fun onDestroyBinding(binding: VB) {}
/** Maybe get the binding. This will be null outside of the fragment view lifecycle. */
protected val binding: T?
protected val binding: VB?
get() = _binding
/**
* Get the binding under the assumption that the fragment has a view at this state in the
* lifecycle. This will throw an exception if the fragment is not in a valid lifecycle.
*/
protected fun requireBinding(): T {
protected fun requireBinding(): VB {
return requireNotNull(_binding) {
"ViewBinding was available. Fragment should be a valid state " +
"right now, but instead it was ${lifecycle.currentState}"
}
}
/**
* Shortcut to create a member bound to the lifecycle of this fragment. This is automatically
* populated in onBindingCreated, and destroyed in onDestroyBinding.
*/
fun <T> lifecycleObject(create: (VB) -> T): ReadOnlyProperty<Fragment, T> {
lifecycleObjects.add(LifecycleObject(null, create))
return object : ReadOnlyProperty<Fragment, T> {
private val objIdx = lifecycleObjects.lastIndex
@Suppress("UNCHECKED_CAST")
override fun getValue(thisRef: Fragment, property: KProperty<*>) =
requireNotNull(lifecycleObjects[objIdx].data) {
"Cannot access lifecycle object when view does not exist"
}
as T
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@ -73,14 +96,27 @@ abstract class ViewBindingFragment<T : ViewBinding> : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
onBindingCreated(requireBinding(), savedInstanceState)
val binding = unlikelyToBeNull(_binding)
lifecycleObjects.forEach { it.populate(binding) }
onBindingCreated(binding, savedInstanceState)
logD("Fragment created")
}
override fun onDestroyView() {
super.onDestroyView()
onDestroyBinding(requireBinding())
onDestroyBinding(unlikelyToBeNull(_binding))
lifecycleObjects.forEach { it.clear() }
_binding = null
logD("Fragment destroyed")
}
private data class LifecycleObject<VB, T>(var data: T?, val create: (VB) -> T) {
fun populate(binding: VB) {
data = create(binding)
}
fun clear() {
data = null
}
}
}

View file

@ -24,8 +24,8 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogAccentBinding
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.settings.settings
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
@ -36,7 +36,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
class AccentCustomizeDialog :
ViewBindingDialogFragment<DialogAccentBinding>(), AccentAdapter.Listener {
private var accentAdapter = AccentAdapter(this)
private val settings: Settings by settings()
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
override fun onCreateBinding(inflater: LayoutInflater) = DialogAccentBinding.inflate(inflater)

View file

@ -157,11 +157,6 @@ val RecyclerView.canScroll: Boolean
val @receiver:ColorRes Int.stateList
get() = ColorStateList.valueOf(this)
/** Require the fragment is attached to an activity. */
fun Fragment.requireAttached() {
check(!isDetached) { "Fragment is detached from activity" }
}
/**
* Launches [block] in a lifecycle-aware coroutine once [state] is reached. This is primarily a
* shortcut intended to correctly launch a co-routine on a fragment in a way that won't cause