From 233166eb10c8116ac050b2d111757f78ee37fdbd Mon Sep 17 00:00:00 2001 From: Niels van Velzen Date: Mon, 2 Sep 2024 18:52:08 +0200 Subject: [PATCH] Use custom logic for fragment back stack --- app/src/main/AndroidManifest.xml | 3 +- .../ui/browsing/DestinationFragmentView.kt | 184 ++++++++++++++++++ .../androidtv/ui/browsing/MainActivity.kt | 65 ++----- .../ui/navigation/NavigationRepository.kt | 15 +- .../androidtv/ui/startup/StartupActivity.kt | 8 +- app/src/main/res/layout/activity_main.xml | 5 +- app/src/main/res/layout/activity_startup.xml | 26 +++ 7 files changed, 235 insertions(+), 71 deletions(-) create mode 100644 app/src/main/java/org/jellyfin/androidtv/ui/browsing/DestinationFragmentView.kt create mode 100644 app/src/main/res/layout/activity_startup.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0379ddd451..96422743a8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -108,8 +108,8 @@ @@ -132,6 +132,7 @@ diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/browsing/DestinationFragmentView.kt b/app/src/main/java/org/jellyfin/androidtv/ui/browsing/DestinationFragmentView.kt new file mode 100644 index 0000000000..f70c1985ca --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/ui/browsing/DestinationFragmentView.kt @@ -0,0 +1,184 @@ +package org.jellyfin.androidtv.ui.browsing + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import android.os.Parcel +import android.os.Parcelable +import android.util.AttributeSet +import android.widget.FrameLayout +import androidx.core.os.BundleCompat +import androidx.core.os.ParcelCompat +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentContainerView +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentTransaction +import org.jellyfin.androidtv.R +import org.jellyfin.androidtv.ui.navigation.NavigationAction +import java.util.Stack + +private class HistoryEntry( + val name: Class, + val arguments: Bundle = bundleOf(), + + var fragment: Fragment? = null, + var savedState: Fragment.SavedState? = null, +) : Parcelable { + override fun describeContents(): Int = 0 + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeString(name.name) + dest.writeBundle(arguments) + dest.writeParcelable(savedState, 0) + } + + companion object CREATOR : Parcelable.Creator { + @Suppress("UNCHECKED_CAST") + override fun createFromParcel(parcel: Parcel): HistoryEntry = HistoryEntry( + name = Class.forName(parcel.readString()!!) as Class, + arguments = parcel.readBundle(this::class.java.classLoader)!!, + fragment = null, + savedState = ParcelCompat.readParcelable(parcel, this::class.java.classLoader, Fragment.SavedState::class.java)!!, + ) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } +} + +class DestinationFragmentView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, +) : FrameLayout(context, attrs) { + private companion object { + private const val FRAGMENT_TAG_CONTENT = "content" + private const val BUNDLE_SUPER = "super" + private const val BUNDLE_HISTORY = "history" + } + + private val fragmentManager by lazy { + FragmentManager.findFragmentManager(this) + } + + private val container by lazy { + FragmentContainerView(context).also { view -> + view.id = R.id.container + addView(view) + } + } + + private val history = Stack() + + fun navigate(action: NavigationAction.NavigateFragment) { + val entry = HistoryEntry(action.destination.fragment.java, action.destination.arguments) + + // Create the base transaction so we can mutate everything at once + val transaction = fragmentManager.beginTransaction() + + if (action.clear) { + // Clear all current fragments from the history before adding the new entry + history.mapNotNull { it.fragment }.forEach { transaction.remove(it) } + history.clear() + history.push(entry) + } else if (action.replace && action.addToBackStack && history.isNotEmpty()) { + // Remove the top-most entry before replacing it with the next + val currentFragment = history[history.size - 1].fragment + if (currentFragment != null) transaction.remove(currentFragment) + history[history.size - 1] = entry + } else { + // Add to the end of the history + saveCurrentFragmentState() + history.push(entry) + } + + activateHistoryEntry(entry, transaction) + } + + fun goBack(): Boolean { + // Require at least 2 items (current & previous) to go back + if (history.size < 2) return false + + // Remove current entry + history.pop() + + // Read & set previous entry + val entry = history.last() + activateHistoryEntry(entry) + + return true + } + + private fun saveCurrentFragmentState() { + if (history.isEmpty()) return + + // Update the top-most history entry with state from current fragment + val fragment = requireNotNull(fragmentManager.findFragmentByTag(FRAGMENT_TAG_CONTENT)) + history[history.size - 1].savedState = fragmentManager.saveFragmentInstanceState(fragment) + } + + @SuppressLint("CommitTransaction") + private fun activateHistoryEntry( + entry: HistoryEntry, + transaction: FragmentTransaction = fragmentManager.beginTransaction(), + ) { + var fragment = entry.fragment + + // Create if there is no existing fragment + if (fragment == null) { + fragment = fragmentManager.fragmentFactory.instantiate(context.classLoader, entry.name.name).apply { + setInitialSavedState(entry.savedState) + } + entry.fragment = fragment + } + + // Update arguments + fragment.arguments = entry.arguments + + transaction.apply { + // Set options + setReorderingAllowed(true) + setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out) + + // Detach current fragment + fragmentManager.findFragmentByTag(FRAGMENT_TAG_CONTENT)?.let(::detach) + + // Attach or add next fragment + if (fragment.isDetached) attach(fragment) + else add(container.id, fragment, FRAGMENT_TAG_CONTENT) + } + + // Commit + if (fragmentManager.isStateSaved) transaction.commitNowAllowingStateLoss() + else transaction.commitNow() + } + + override fun onSaveInstanceState(): Parcelable { + // Always retrieve current state before writing + saveCurrentFragmentState() + + // Save state + return bundleOf( + BUNDLE_SUPER to super.onSaveInstanceState(), + BUNDLE_HISTORY to history.toTypedArray() + ) + } + + override fun onRestoreInstanceState(state: Parcelable?) { + // Ignore if not a bundle + if (state !is Bundle) return super.onRestoreInstanceState(state) + + // Call parent + @Suppress("DEPRECATION") + val parent = state.getParcelable(BUNDLE_SUPER) + if (parent != null) super.onRestoreInstanceState(parent) + + // Restore history + @Suppress("UNCHECKED_CAST") + val savedHistory = BundleCompat.getParcelableArray(state, BUNDLE_HISTORY, HistoryEntry::class.java) as Array? + if (savedHistory != null) { + history.clear() + history.addAll(savedHistory) + if (history.isNotEmpty()) activateHistoryEntry(history.last()) + } + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/browsing/MainActivity.kt b/app/src/main/java/org/jellyfin/androidtv/ui/browsing/MainActivity.kt index d61a1f393f..df90ad9e7f 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/browsing/MainActivity.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/browsing/MainActivity.kt @@ -9,15 +9,12 @@ import android.view.WindowManager import androidx.activity.OnBackPressedCallback import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.commit import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import org.jellyfin.androidtv.R import org.jellyfin.androidtv.auth.repository.SessionRepository import org.jellyfin.androidtv.auth.repository.UserRepository import org.jellyfin.androidtv.databinding.ActivityMainBinding @@ -34,15 +31,10 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import timber.log.Timber class MainActivity : FragmentActivity() { - companion object { - private const val FRAGMENT_TAG_CONTENT = "content" - } - private val navigationRepository by inject() private val sessionRepository by inject() private val userRepository by inject() private val screensaverViewModel by viewModel() - private var inTransaction = false private lateinit var binding: ActivityMainBinding @@ -66,13 +58,7 @@ class MainActivity : FragmentActivity() { }.launchIn(lifecycleScope) onBackPressedDispatcher.addCallback(this, backPressedCallback) - - supportFragmentManager.addOnBackStackChangedListener { - if (!inTransaction && supportFragmentManager.backStackEntryCount == 0) - navigationRepository.reset() - } - - if (savedInstanceState == null && navigationRepository.canGoBack) navigationRepository.reset() + if (savedInstanceState == null && navigationRepository.canGoBack) navigationRepository.reset(clearHistory = true) navigationRepository.currentAction .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) @@ -124,48 +110,23 @@ class MainActivity : FragmentActivity() { } } - private fun handleNavigationAction(action: NavigationAction) = when (action) { - is NavigationAction.NavigateFragment -> { - if (action.clear) { - // Clear the current back stack - val firstBackStackEntry = supportFragmentManager.getBackStackEntryAt(0) - supportFragmentManager.popBackStack(firstBackStackEntry.id, FragmentManager.POP_BACK_STACK_INCLUSIVE) - } else if (action.replace) { - // Prevent back stack changed listener from resetting when popping to - // the initial destination - inTransaction = true - supportFragmentManager.popBackStack() - inTransaction = false - } + private fun handleNavigationAction(action: NavigationAction) { + when (action) { + // DestinationFragmentView actions + is NavigationAction.NavigateFragment -> binding.contentView.navigate(action) + NavigationAction.GoBack -> binding.contentView.goBack() - supportFragmentManager.commit { + // Others + is NavigationAction.NavigateActivity -> { val destination = action.destination - val currentFragment = supportFragmentManager.findFragmentByTag(FRAGMENT_TAG_CONTENT) - val isSameFragment = currentFragment != null && - destination.fragment.isInstance(currentFragment) && - currentFragment.arguments == destination.arguments - - if (!isSameFragment) { - setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out) - - replace(R.id.content_view, destination.fragment.java, destination.arguments, FRAGMENT_TAG_CONTENT) - } - - if (action.addToBackStack) addToBackStack(null) + val intent = Intent(this@MainActivity, destination.activity.java) + intent.putExtras(destination.extras) + startActivity(intent) + action.onOpened() } - } - is NavigationAction.NavigateActivity -> { - val destination = action.destination - val intent = Intent(this@MainActivity, destination.activity.java) - intent.putExtras(destination.extras) - startActivity(intent) - action.onOpened() + NavigationAction.Nothing -> Unit } - - NavigationAction.GoBack -> supportFragmentManager.popBackStack() - - NavigationAction.Nothing -> Unit } // Forward key events to fragments diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/navigation/NavigationRepository.kt b/app/src/main/java/org/jellyfin/androidtv/ui/navigation/NavigationRepository.kt index c33985abc6..987a6af37c 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/navigation/NavigationRepository.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/navigation/NavigationRepository.kt @@ -60,20 +60,13 @@ interface NavigationRepository { } class NavigationRepositoryImpl( - private val initialDestination: Destination.Fragment, + private val defaultDestination: Destination.Fragment, ) : NavigationRepository { private val fragmentHistory = Stack() private val _currentAction = MutableSharedFlow(1, onBufferOverflow = BufferOverflow.DROP_OLDEST) override val currentAction = _currentAction.asSharedFlow() - init { - // Never add the initial destination to the history to prevent an empty screen when the user - // uses the "back" button to close the app - _currentAction.tryEmit(NavigationAction.NavigateFragment(initialDestination, false, false, false)) - Timber.d("Navigating to $initialDestination (via init)") - } - override fun navigate(destination: Destination, replace: Boolean) { Timber.d("Navigating to $destination (via navigate function)") val action = when (destination) { @@ -83,11 +76,11 @@ class NavigationRepositoryImpl( _currentAction.tryEmit(NavigationAction.Nothing) } } - _currentAction.tryEmit(action) if (destination is Destination.Fragment) { if (replace && fragmentHistory.isNotEmpty()) fragmentHistory[fragmentHistory.lastIndex] = destination else fragmentHistory.push(destination) } + _currentAction.tryEmit(action) } override val canGoBack: Boolean get() = fragmentHistory.isNotEmpty() @@ -103,8 +96,8 @@ class NavigationRepositoryImpl( override fun reset(destination: Destination.Fragment?, clearHistory: Boolean) { fragmentHistory.clear() - val actualDestination = destination ?: initialDestination - _currentAction.tryEmit(NavigationAction.NavigateFragment(actualDestination, false, false, clearHistory)) + val actualDestination = destination ?: defaultDestination + _currentAction.tryEmit(NavigationAction.NavigateFragment(actualDestination, true, false, clearHistory)) Timber.d("Navigating to $actualDestination (via reset, clearHistory=$clearHistory)") } } diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/startup/StartupActivity.kt b/app/src/main/java/org/jellyfin/androidtv/ui/startup/StartupActivity.kt index 21c5c15aa7..38bd45e5d7 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/startup/StartupActivity.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/startup/StartupActivity.kt @@ -27,7 +27,7 @@ import org.jellyfin.androidtv.R import org.jellyfin.androidtv.auth.repository.SessionRepository import org.jellyfin.androidtv.auth.repository.SessionRepositoryState import org.jellyfin.androidtv.auth.repository.UserRepository -import org.jellyfin.androidtv.databinding.ActivityMainBinding +import org.jellyfin.androidtv.databinding.ActivityStartupBinding import org.jellyfin.androidtv.ui.background.AppBackground import org.jellyfin.androidtv.ui.browsing.MainActivity import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher @@ -62,7 +62,7 @@ class StartupActivity : FragmentActivity() { private val navigationRepository: NavigationRepository by inject() private val itemLauncher: ItemLauncher by inject() - private lateinit var binding: ActivityMainBinding + private lateinit var binding: ActivityStartupBinding private val networkPermissionsRequester = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() @@ -83,7 +83,7 @@ class StartupActivity : FragmentActivity() { super.onCreate(savedInstanceState) - binding = ActivityMainBinding.inflate(layoutInflater) + binding = ActivityStartupBinding.inflate(layoutInflater) binding.background.setContent { AppBackground() } binding.screensaver.isVisible = false setContentView(binding.root) @@ -156,7 +156,7 @@ class StartupActivity : FragmentActivity() { else -> null } - navigationRepository.reset(destination) + navigationRepository.reset(destination, true) val intent = Intent(this, MainActivity::class.java) // Clear navigation history diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 2b935f1195..be90a610a4 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -13,11 +13,10 @@ android:descendantFocusability="blocksDescendants" android:focusable="false" /> - + android:layout_height="match_parent" /> + + + + + + + +