Skip to content

Commit

Permalink
Use custom logic for fragment back stack
Browse files Browse the repository at this point in the history
  • Loading branch information
nielsvanvelzen committed Sep 3, 2024
1 parent 86d6195 commit e460c6e
Show file tree
Hide file tree
Showing 7 changed files with 235 additions and 71 deletions.
3 changes: 2 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@
<activity
android:name=".ui.startup.StartupActivity"
android:exported="true"
android:noHistory="true"
android:launchMode="singleTask"
android:noHistory="true"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustNothing">
<intent-filter>
Expand All @@ -132,6 +132,7 @@
<!-- Main application -->
<activity
android:name=".ui.browsing.MainActivity"
android:launchMode="singleTask"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustNothing" />

Expand Down
Original file line number Diff line number Diff line change
@@ -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<out Fragment>,
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<HistoryEntry> {
@Suppress("UNCHECKED_CAST")
override fun createFromParcel(parcel: Parcel): HistoryEntry = HistoryEntry(
name = Class.forName(parcel.readString()!!) as Class<out Fragment>,
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<HistoryEntry?> = 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<HistoryEntry>()

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<Parcelable>(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<HistoryEntry>?
if (savedHistory != null) {
history.clear()
history.addAll(savedHistory)
if (history.isNotEmpty()) activateHistoryEntry(history.last())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<NavigationRepository>()
private val sessionRepository by inject<SessionRepository>()
private val userRepository by inject<UserRepository>()
private val screensaverViewModel by viewModel<ScreensaverViewModel>()
private var inTransaction = false

private lateinit var binding: ActivityMainBinding

Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,20 +60,13 @@ interface NavigationRepository {
}

class NavigationRepositoryImpl(
private val initialDestination: Destination.Fragment,
private val defaultDestination: Destination.Fragment,
) : NavigationRepository {
private val fragmentHistory = Stack<Destination.Fragment>()

private val _currentAction = MutableSharedFlow<NavigationAction>(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) {
Expand All @@ -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()
Expand All @@ -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)")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit e460c6e

Please sign in to comment.