From 9b2cfe0c1997c29baea2a9d5cd03a07d142607b9 Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Mon, 20 Apr 2026 22:55:56 +0700 Subject: [PATCH 1/3] Refactor Modo init: deprecate legacy API, unify caching via getOrPut, add unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deprecate getOrCreateRootScreen/save/onRootScreenFinished (use rememberRootScreen, removed in 1.0) - Unify all init paths through rootScreens.getOrPut for consistent in-memory caching - Rename restoreScreenCounter → restoreScreenCounterIfNeeded; extract isFragmentClosing - Add ModoLegacyIntegrationFragment + button in MainScreen for legacy fragment demo - Add ModoRootScreenCacheTest covering all 3 init scenarios (first init, backstack, bundle restore) - Add MockK 1.13.12 as test dependency --- gradle/libs.versions.toml | 2 + modo-compose/build.gradle.kts | 5 + .../java/com/github/terrakok/modo/Modo.kt | 128 +++++++------ .../com/github/terrakok/modo/ScreenKey.kt | 6 +- .../terrakok/modo/ModoRootScreenCacheTest.kt | 173 ++++++++++++++++++ .../sample/ModoLegacyIntegrationActivity.kt | 10 + .../modo/sample/SelfMadeNavigationDemo.kt | 2 + .../modo/sample/fragment/ModoFragment.kt | 5 +- .../ModoFragmentIntegrationActivity.kt | 18 +- .../fragment/ModoLegacyIntegrationFragment.kt | 66 +++++++ .../modo/sample/screens/MainScreen.kt | 21 ++- .../sample/screens/SelfMadeSampleScreen.kt | 4 + 12 files changed, 380 insertions(+), 60 deletions(-) create mode 100644 modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt create mode 100644 sample/src/main/java/com/github/terrakok/modo/sample/SelfMadeNavigationDemo.kt create mode 100644 sample/src/main/java/com/github/terrakok/modo/sample/fragment/ModoLegacyIntegrationFragment.kt create mode 100644 sample/src/main/java/com/github/terrakok/modo/sample/screens/SelfMadeSampleScreen.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc0c9d10..22246da3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ androidGradlePlugin = "8.13.0" detektComposeVersion = "0.3.20" detektVersion = "1.23.6" junit = "4.13.2" +mockk = "1.13.12" androidxComposeBomModo = "2025.11.01" androidxComposeBomApp = "2025.11.01" androidxActivityCompose = "1.8.2" @@ -46,6 +47,7 @@ detekt-composeRules = { module = "io.nlopez.compose.rules:detekt", version.ref = detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detektVersion" } leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroid" } test-junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version = "5.10.1" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } debug-logcat = { group = "com.squareup.logcat", name = "logcat", version = "0.1" } diff --git a/modo-compose/build.gradle.kts b/modo-compose/build.gradle.kts index 30dc892f..bb678bde 100644 --- a/modo-compose/build.gradle.kts +++ b/modo-compose/build.gradle.kts @@ -13,6 +13,10 @@ dependencyGuard { android { namespace = "com.github.terrakok.modo.android.compose" + + testOptions { + unitTests.isReturnDefaultValues = true + } } dependencies { @@ -31,6 +35,7 @@ dependencies { testImplementation(libs.test.junit.jupiter) testImplementation(kotlin("test")) + testImplementation(libs.mockk) } tasks.withType(Test::class) { diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/Modo.kt b/modo-compose/src/main/java/com/github/terrakok/modo/Modo.kt index bc06688b..8adf87cc 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/Modo.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/Modo.kt @@ -14,6 +14,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import com.github.terrakok.modo.Modo.rememberRootScreen +import com.github.terrakok.modo.Modo.rootScreens +import com.github.terrakok.modo.Modo.save import com.github.terrakok.modo.model.ScreenModelStore import com.github.terrakok.modo.util.getActivity import java.util.concurrent.ConcurrentHashMap @@ -26,58 +29,71 @@ object Modo { /** * Contains root screens to priovide stability of returned instance when use [rememberRootScreen] and return a same instance in per a process. */ - private val rootScreens: MutableMap> = ConcurrentHashMap() + @org.jetbrains.annotations.VisibleForTesting + internal val rootScreens: MutableMap> = ConcurrentHashMap() /** * Saves provided screen with nested graph to bundle for further restoration. */ + @Deprecated("Use rememberRootScreen, which handles saving and restoring automatically. Will be removed in 1.0.") fun save(outState: Bundle, rootScreen: Screen?) { outState.putInt(MODO_SCREEN_COUNTER_KEY, screenCounterKey.get()) outState.putParcelable(MODO_GRAPH, rootScreen) } /** - * Creates [RootScreen] provided by [rootScreenProvider], if there is no data in [savedState] or in-memory. - * Otherwise [RootScreen] is firstly taking from memoryr and then restored from savedState, if there is no RootScreen in memory. - * Returns same instance of [RootScreen] for same process. A new instance returned only after process death. - * @param savedState - container with modo state and graph - * @param rootScreenProvider invokes when [savedState] is null and [inMemoryScreen] is null and we need to provide root screen. + * Returns the [RootScreen] for this host (Activity/Fragment), creating it if necessary. + * Guarantees the same instance is returned within a single process — a new instance is only + * created after process death. + * + * There are three scenarios, resolved in priority order: + * + * **1. Bundle restore** (`savedState != null`): + * The host was recreated by the system (config change or process death). + * - Config change (same process): [rootScreens] already holds the live instance → returned from cache. + * - Process death: [rootScreens] is empty → the graph is deserialized from [savedState], + * stored in [rootScreens], and returned. [screenCounterKey] is restored to avoid key collisions. + * + * **2. In-memory hit** (`savedState == null`, [inMemoryScreen] != null): + * The host's view was destroyed without saving state (e.g. a Fragment going to the back stack). + * The Fragment is still alive and holds the previous [RootScreen] reference. + * [inMemoryScreen] provides the key to retrieve the same instance from [rootScreens]. + * + * **3. First initialization** (`savedState == null`, [inMemoryScreen] == null): + * Fresh start — [rootScreenProvider] is called to build the initial screen, a new [RootScreen] + * is created, stored in [rootScreens], and returned. + * + * @param savedState bundle produced by [save], or null on first launch / back-stack return. + * @param inMemoryScreen existing [RootScreen] held by a non-destroyed Fragment (scenario 2). + * Must be null for Activities and for the very first Fragment creation. + * @param rootScreenProvider called only in scenario 3 to construct the initial root screen. */ + @Deprecated("Use rememberRootScreen, which handles all lifecycle concerns automatically. Will be removed in 1.0.") fun getOrCreateRootScreen(savedState: Bundle?, inMemoryScreen: RootScreen?, rootScreenProvider: () -> T): RootScreen { - // taking saved state to obtain screenKey - val modoGraph = savedState?.getParcelable>(MODO_GRAPH) - return if (modoGraph != null) { - // If restoring after activity death, but not after process death, then [inMemoryScreen] is null, but we have cached object in rootScreens - // So we trying to restore it from memory and only if it is null - taking it from savedState. - val cachedRootScreen = rootScreens.get(modoGraph.screenKey)?.let { it as RootScreen } - if (cachedRootScreen != null) { - cachedRootScreen - } else { - restoreScreenCounter(savedState.getInt(MODO_SCREEN_COUNTER_KEY)) - modoGraph - } + val savedModoGraph = savedState?.getParcelable>(MODO_GRAPH) + return if (savedModoGraph != null) { + // Scenario 1: bundle restore. + // Config change → cache hit, process death → cache miss, savedModoGraph is stored. + restoreScreenCounterIfNeeded(savedState.getInt(MODO_SCREEN_COUNTER_KEY)) + @Suppress("UNCHECKED_CAST") + rootScreens.getOrPut(savedModoGraph.screenKey) { savedModoGraph } as RootScreen } else { - // saved state is going to be null after taking fragment from backstack, beckause in this case - // 1. onSaveInstaneState is not called - // 2. View is destroyed - // 3. Fragment is not destroyed and have inMemoryScreen != null if it is not a first call - inMemoryScreen ?: RootScreen(rootScreenProvider()) - }.also { rootScreen -> - rootScreens.put(rootScreen.screenKey, rootScreen) + // Scenarios 2 & 3: no saved state. + // savedState is null after back-stack return because onSaveInstanceState is not called + // when a Fragment is only stopped (not destroyed), so inMemoryScreen carries the reference. + val screen = inMemoryScreen ?: RootScreen(rootScreenProvider()) // scenario 3: first init + @Suppress("UNCHECKED_CAST") + rootScreens.getOrPut(screen.screenKey) { screen } as RootScreen } } - @Deprecated( - "Renamed it getOrCreateRootScreen to except misunderstanding. Use getOrCreateRootScreen instead.", - ReplaceWith("Modo.getOrCreateRootScreen(savedState, inMemoryScreen, rootScreenProvider)") - ) - fun init(savedState: Bundle?, inMemoryScreen: RootScreen?, rootScreenProvider: () -> T): RootScreen = - getOrCreateRootScreen(savedState, inMemoryScreen, rootScreenProvider) - /** * Must be called to clear all data from [ScreenModelStore], related with removed screens. */ - fun onRootScreenFinished(rootScreen: RootScreen?) { + @Deprecated("Use rememberRootScreen, which handles cleanup automatically. Will be removed in 1.0.") + fun onRootScreenFinished(rootScreen: RootScreen?) = finishRootScreen(rootScreen) + + private fun finishRootScreen(rootScreen: RootScreen?) { if (rootScreen != null) { Log.d("Modo", "rootScreen removing $rootScreen") rootScreens.remove(rootScreen.screenKey) @@ -101,7 +117,7 @@ object Modo { DisposableEffect(rootScreen, this) { onDispose { if (isFinishing) { - onRootScreenFinished(rootScreen) + finishRootScreen(rootScreen) } } } @@ -114,7 +130,7 @@ object Modo { key = MODO_SCREEN_COUNTER_KEY, saver = Saver( restore = { - restoreScreenCounter(it as Int) + restoreScreenCounterIfNeeded(it as Int) it }, save = { @@ -132,16 +148,16 @@ object Modo { val rootScreen = rememberSaveable( key = MODO_GRAPH, saver = Saver( - save = { rootScreen -> - rootScreens.put(rootScreen.screenKey, rootScreen) - rootScreen - }, - restore = { rootScreen -> - rootScreens.get(rootScreen.screenKey)?.let { it as RootScreen } ?: rootScreen + save = { it }, + restore = { saved -> + @Suppress("UNCHECKED_CAST") + rootScreens.getOrPut(saved.screenKey) { saved } as RootScreen } ) ) { - RootScreen(rootScreenFactory()) + RootScreen(rootScreenFactory()).also { newRoot -> + rootScreens[newRoot.screenKey] = newRoot + } } return rootScreen } @@ -169,17 +185,9 @@ object Modo { if (!hasAnyObserver) { hasAnyObserver = true val activity = context.getActivity()!! - val lifecycleObserver = LifecycleEventObserver { owner, event -> - // If parent activity is finishes - fragment also is finishing. - // But if activity is not finishes, then it can be just fragment removal from backstack. - // This check is not taking into account the case, when an activity is destroyed by system, but it is going to be restored. - - if (event == Lifecycle.Event.ON_DESTROY) { - val activityFinishing = activity.isFinishing - val fragmentRemovedFromBackStack = !activityFinishing && !activity.isChangingConfigurations && !isStateSaved - if (activityFinishing || fragmentRemovedFromBackStack) { - onRootScreenFinished(rootScreen) - } + val lifecycleObserver = LifecycleEventObserver { _, event -> + if (isFragmentClosing(event, activity)) { + finishRootScreen(rootScreen) } } lifecycle.addObserver(lifecycleObserver) @@ -196,4 +204,16 @@ object Modo { (screen as? ContainerScreen<*, *>)?.navigationState?.getChildScreens()?.forEach(::clearScreenModel) } -} \ No newline at end of file +} + +/** + * Returns true when the fragment is being permanently destroyed and its state should be cleaned up. + * Returns false when the fragment will be restored (config change or system-initiated process death). + */ +fun Fragment.isFragmentClosing(event: Lifecycle.Event, activity: Activity): Boolean = +// ON_DESTROY is the only moment when isStateSaved and isChangingConfigurations are reliable. +// Activity finishing covers user-initiated close (back press, finish()). +// The second condition covers fragment removal from backstack: + // no config change and state wasn't saved means the fragment won't be restored. + event == Lifecycle.Event.ON_DESTROY && + (activity.isFinishing || (!activity.isChangingConfigurations && !isStateSaved)) \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ScreenKey.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ScreenKey.kt index f4bfce62..cfbe71c3 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ScreenKey.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ScreenKey.kt @@ -18,10 +18,10 @@ internal val screenCounterKey = AtomicInteger(-1) fun generateScreenKey(): ScreenKey = ScreenKey("Screen#${screenCounterKey.incrementAndGet()}") /** - * Restores the screen counter to the given value. - * It's safe to call this multiple times, because it restores the value only if it's not already set. + * Restores the screen counter to [value] only if it hasn't been set yet (counter == -1). + * Logs a warning if the counter is already set to a different value. */ -internal fun restoreScreenCounter(value: Int) { +internal fun restoreScreenCounterIfNeeded(value: Int) { if (screenCounterKey.get() == -1 || screenCounterKey.get() == value) { screenCounterKey.set(value) } else { diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt new file mode 100644 index 00000000..745ec404 --- /dev/null +++ b/modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt @@ -0,0 +1,173 @@ +package com.github.terrakok.modo + +import android.os.Bundle +import com.github.terrakok.modo.model.ScreenModelStore +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame + +@Suppress("DEPRECATION") +class ModoRootScreenCacheTest { + + @BeforeEach + fun setup() { + Modo.rootScreens.clear() + screenCounterKey.set(-1) + ScreenModelStore.removedScreenKeys.clear() + ScreenModelStore.screenModels.clear() + ScreenModelStore.dependencies.clear() + ScreenModelStore.dependencyCounter.set(0L) + ScreenModelStore.lastScreenModelKey.value = null + ModoDevOptions.onIllegalScreenModelStoreAccess = ModoDevOptions.ValidationFailedStrategy { } + } + + // region Scenario 3: first initialization (savedState == null, inMemoryScreen == null) + + @Test + fun `When savedState is null and inMemoryScreen is null - Then factory creates new and caches it`() { + val mockScreen = MockScreen(ScreenKey("new")) + + val result = Modo.getOrCreateRootScreen( + savedState = null, + inMemoryScreen = null, + rootScreenProvider = { mockScreen } + ) + + assertSame(result, Modo.rootScreens[result.screenKey]) + } + + // endregion + + // region Scenario 2: in-memory hit (savedState == null, inMemoryScreen != null — fragment backstack return) + + @Test + fun `When savedState is null and inMemoryScreen is provided - Then inMemoryScreen is returned`() { + val existing = RootScreen(MockScreen()) + Modo.rootScreens[existing.screenKey] = existing + + val result = Modo.getOrCreateRootScreen( + savedState = null, + inMemoryScreen = existing, + rootScreenProvider = { error("factory must not be called") } + ) + + assertSame(existing, result) + } + + @Test + fun `When savedState is null and inMemoryScreen is provided - Then factory is not called`() { + val existing = RootScreen(MockScreen()) + Modo.rootScreens[existing.screenKey] = existing + var factoryCalled = false + + Modo.getOrCreateRootScreen( + savedState = null, + inMemoryScreen = existing, + rootScreenProvider = { + factoryCalled = true + MockScreen() + } + ) + + assertSame(false, factoryCalled) + } + + // endregion + + // region Scenario 1a: bundle restore, cache hit (config change — rootScreens has live instance) + + @Test + fun `When savedState is not null and rootScreens has instance - Then cached instance returned, not bundle one`() { + // Simulate config change: cached holds the live instance, bundle holds the parcelized one. + // In practice they'd be equal by value but different objects; here we reuse the same instance + // for the bundle to share the same screenKey, then verify the cache takes priority. + val cached = RootScreen(MockScreen()) + Modo.rootScreens[cached.screenKey] = cached + val savedState = mockBundle(cached, counter = 5) + + val result = Modo.getOrCreateRootScreen( + savedState = savedState, + inMemoryScreen = null, + rootScreenProvider = { error("factory must not be called") } + ) + + assertSame(cached, result) + } + + // endregion + + // region Scenario 1b: bundle restore, cache miss (process death — rootScreens is empty) + + @Test + fun `When savedState is not null and rootScreens is empty - Then bundle instance stored and returned`() { + val fromBundle = RootScreen(MockScreen()) + screenCounterKey.set(-1) // reset: simulates new process after death, counter not yet set + val savedState = mockBundle(fromBundle, counter = 42) + + val result = Modo.getOrCreateRootScreen( + savedState = savedState, + inMemoryScreen = null, + rootScreenProvider = { error("factory must not be called") } + ) + + assertSame(fromBundle, result) + assertSame(fromBundle, Modo.rootScreens[fromBundle.screenKey]) + } + + @Test + fun `When savedState is not null and rootScreens is empty - Then screenCounter is restored`() { + val fromBundle = RootScreen(MockScreen()) + screenCounterKey.set(-1) // reset: simulates new process after death, counter not yet set + val savedState = mockBundle(fromBundle, counter = 42) + + Modo.getOrCreateRootScreen( + savedState = savedState, + inMemoryScreen = null, + rootScreenProvider = { error("factory must not be called") } + ) + + assertSame(42, screenCounterKey.get()) + } + + // endregion + + // region getOrPut cache isolation + + @Test + fun `When screen is cached and getOrPut called again - Then cached instance returned`() { + val cached = RootScreen(MockScreen(ScreenKey("k"))) + Modo.rootScreens[ScreenKey("k")] = cached + + val newCandidate = RootScreen(MockScreen(ScreenKey("k"))) + @Suppress("UNCHECKED_CAST") + val result = Modo.rootScreens.getOrPut(ScreenKey("k")) { newCandidate } as RootScreen + + assertSame(cached, result) + assertNotSame(newCandidate, result) + } + + // endregion + + // region onRootScreenFinished cleanup + + @Test + fun `When onRootScreenFinished called - Then rootScreens entry is removed`() { + val screen = RootScreen(MockScreen(ScreenKey("fin"))) + Modo.rootScreens[screen.screenKey] = screen + + Modo.onRootScreenFinished(screen) + + assertNull(Modo.rootScreens[screen.screenKey]) + } + + // endregion + + private fun mockBundle(rootScreen: RootScreen<*>, counter: Int): Bundle = mockk { + every { getParcelable>("MODO_GRAPH") } returns rootScreen + every { getInt("MODO_SCREEN_COUNTER_KEY") } returns counter + } +} diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/ModoLegacyIntegrationActivity.kt b/sample/src/main/java/com/github/terrakok/modo/sample/ModoLegacyIntegrationActivity.kt index b494f688..5be2fa84 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/ModoLegacyIntegrationActivity.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/ModoLegacyIntegrationActivity.kt @@ -7,11 +7,21 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier import androidx.core.view.WindowCompat import com.github.terrakok.modo.Modo +import com.github.terrakok.modo.Modo.rememberRootScreen import com.github.terrakok.modo.RootScreen import com.github.terrakok.modo.sample.screens.MainScreen import com.github.terrakok.modo.sample.screens.containers.SampleStack import com.github.terrakok.modo.stack.StackScreen +/** + * Demonstrates manual (legacy) integration of Modo into an Activity without using [rememberRootScreen]. + * + * This approach requires the host to manually call [Modo.getOrCreateRootScreen], + * [Modo.save], and [Modo.onRootScreenFinished]. + * + * **This is not the recommended way to integrate Modo.** Prefer [rememberRootScreen], + * which handles all lifecycle concerns automatically. Use this only if you cannot use Compose at the Activity level. + */ class ModoLegacyIntegrationActivity : AppCompatActivity() { private var rootScreen: RootScreen? = null diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/SelfMadeNavigationDemo.kt b/sample/src/main/java/com/github/terrakok/modo/sample/SelfMadeNavigationDemo.kt new file mode 100644 index 00000000..8e2a596c --- /dev/null +++ b/sample/src/main/java/com/github/terrakok/modo/sample/SelfMadeNavigationDemo.kt @@ -0,0 +1,2 @@ +package com.github.terrakok.modo.sample + diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/fragment/ModoFragment.kt b/sample/src/main/java/com/github/terrakok/modo/sample/fragment/ModoFragment.kt index 5ebbf651..e0001d8e 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/fragment/ModoFragment.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/fragment/ModoFragment.kt @@ -17,7 +17,10 @@ import com.github.terrakok.modo.sample.screens.MainScreen import com.github.terrakok.modo.sample.screens.containers.SampleStack /** - * Sample of integration Modo into the fragment + * Demonstrates the recommended way to integrate Modo into a Fragment using [rememberRootScreen]. + * + * All lifecycle concerns (state saving, screen model cleanup) are handled automatically. + * For the manual alternative, see [ModoLegacyIntegrationFragment]. */ class ModoFragment : Fragment() { diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/fragment/ModoFragmentIntegrationActivity.kt b/sample/src/main/java/com/github/terrakok/modo/sample/fragment/ModoFragmentIntegrationActivity.kt index 46c5e085..487f1f4b 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/fragment/ModoFragmentIntegrationActivity.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/fragment/ModoFragmentIntegrationActivity.kt @@ -1,7 +1,10 @@ package com.github.terrakok.modo.sample.fragment +import android.content.Context +import android.content.Intent import android.os.Bundle import androidx.activity.addCallback +import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity class ModoFragmentIntegrationActivity : FragmentActivity() { @@ -16,9 +19,22 @@ class ModoFragmentIntegrationActivity : FragmentActivity() { } } if (savedInstanceState == null) { + val fragment: Fragment = if (intent.getBooleanExtra(EXTRA_USE_LEGACY, false)) { + ModoLegacyIntegrationFragment() + } else { + ModoFragment() + } supportFragmentManager.beginTransaction() - .replace(android.R.id.content, ModoFragment()) + .replace(android.R.id.content, fragment) .commit() } } + + companion object { + private const val EXTRA_USE_LEGACY = "EXTRA_USE_LEGACY" + + fun createIntent(context: Context, useLegacy: Boolean = false): Intent = + Intent(context, ModoFragmentIntegrationActivity::class.java) + .putExtra(EXTRA_USE_LEGACY, useLegacy) + } } \ No newline at end of file diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/fragment/ModoLegacyIntegrationFragment.kt b/sample/src/main/java/com/github/terrakok/modo/sample/fragment/ModoLegacyIntegrationFragment.kt new file mode 100644 index 00000000..daac668f --- /dev/null +++ b/sample/src/main/java/com/github/terrakok/modo/sample/fragment/ModoLegacyIntegrationFragment.kt @@ -0,0 +1,66 @@ +package com.github.terrakok.modo.sample.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import com.github.terrakok.modo.Modo +import com.github.terrakok.modo.Modo.rememberRootScreen +import com.github.terrakok.modo.RootScreen +import com.github.terrakok.modo.isFragmentClosing +import com.github.terrakok.modo.sample.screens.MainScreen +import com.github.terrakok.modo.sample.screens.containers.SampleStack +import com.github.terrakok.modo.stack.StackScreen + +/** + * Demonstrates manual (legacy) integration of Modo into a Fragment without using [rememberRootScreen]. + * + * This approach requires the host to manually call [Modo.getOrCreateRootScreen], + * [Modo.save], and [Modo.onRootScreenFinished]. + * + * **This is not the recommended way to integrate Modo.** Prefer [rememberRootScreen], + * which handles all lifecycle concerns automatically. Use this only if you cannot use Compose at the Fragment level. + */ +class ModoLegacyIntegrationFragment : Fragment() { + + private var rootScreen: RootScreen? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + ComposeView(inflater.context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + rootScreen = Modo.getOrCreateRootScreen(savedInstanceState, rootScreen) { + SampleStack(MainScreen(1)) + } + setContent { + Column { + Text(text = "ModoLegacyFragment", style = MaterialTheme.typography.h5) + val rootScreen = rememberRootScreen { + SampleStack(MainScreen(screenIndex = 1, canOpenFragment = true)) + } + rootScreen.Content(modifier = Modifier.fillMaxSize()) + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + Modo.save(outState, rootScreen) + super.onSaveInstanceState(outState) + } + + override fun onDestroy() { + super.onDestroy() + if (isFragmentClosing(Lifecycle.Event.ON_DESTROY, requireActivity())) { + Modo.onRootScreenFinished(rootScreen) + } + } + +} diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt index daf68e53..18e1bd60 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt @@ -14,6 +14,7 @@ import com.github.terrakok.modo.sample.ModoLegacyIntegrationActivity import com.github.terrakok.modo.sample.ModoSampleActivity import com.github.terrakok.modo.sample.fragment.ModoFragment import com.github.terrakok.modo.sample.fragment.ModoFragmentIntegrationActivity +import com.github.terrakok.modo.sample.fragment.ModoLegacyIntegrationFragment import com.github.terrakok.modo.sample.quickstart.QuickStartActivity import com.github.terrakok.modo.sample.screens.base.ButtonsScreenContent import com.github.terrakok.modo.sample.screens.containers.CustomStackSample @@ -199,7 +200,10 @@ private fun rememberButtons( navigation?.dispatch(OpenActivityAction(context)) }, ModoButtonSpec("Fragment integration") { - navigation?.dispatch(OpenActivityAction(context)) + context.startActivity(ModoFragmentIntegrationActivity.createIntent(context)) + }, + ModoButtonSpec("Legacy Fragment integration") { + context.startActivity(ModoFragmentIntegrationActivity.createIntent(context, useLegacy = true)) }, activity?.let { activity -> if (canOpenFragment) { @@ -213,6 +217,18 @@ private fun rememberButtons( null } }, + activity?.let { activity -> + if (canOpenFragment) { + ModoButtonSpec("New legacy fragment") { + activity.supportFragmentManager.beginTransaction() + .replace(android.R.id.content, ModoLegacyIntegrationFragment()) + .addToBackStack("ModoLegacyIntegrationFragment") + .commit() + } + } else { + null + } + }, ) ), GroupedButtonsState.Group( @@ -224,6 +240,9 @@ private fun rememberButtons( ModoButtonSpec("Animation Playground") { navigation?.forward(AnimationPlaygroundScreen()) }, + ModoButtonSpec("Self made navigation") { + navigation?.forward(SelfMadeSampleScreen()) + }, ) ), ) diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/SelfMadeSampleScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/SelfMadeSampleScreen.kt new file mode 100644 index 00000000..3c555a98 --- /dev/null +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/SelfMadeSampleScreen.kt @@ -0,0 +1,4 @@ +package com.github.terrakok.modo.sample.screens + +class SelfMadeSampleScreen { +} \ No newline at end of file From 42c5aaf3c9ce4849714f7df9fb2c4e647a8d54c0 Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Mon, 20 Apr 2026 23:38:02 +0700 Subject: [PATCH 2/3] Fixed build --- Writerside/topics/ModoOverview.md | 2 -- .../com/github/terrakok/modo/sample/SelfMadeNavigationDemo.kt | 2 -- .../com/github/terrakok/modo/sample/screens/MainScreen.kt | 3 --- .../terrakok/modo/sample/screens/SelfMadeSampleScreen.kt | 4 ---- 4 files changed, 11 deletions(-) delete mode 100644 sample/src/main/java/com/github/terrakok/modo/sample/SelfMadeNavigationDemo.kt delete mode 100644 sample/src/main/java/com/github/terrakok/modo/sample/screens/SelfMadeSampleScreen.kt diff --git a/Writerside/topics/ModoOverview.md b/Writerside/topics/ModoOverview.md index da20fad8..53614537 100644 --- a/Writerside/topics/ModoOverview.md +++ b/Writerside/topics/ModoOverview.md @@ -1,5 +1,3 @@ -Here's the improved version of your documentation text: - # Modo Overview [![Maven Central](https://img.shields.io/maven-central/v/com.github.terrakok/modo-compose)](https://repo1.maven.org/maven2/com/github/terrakok) diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/SelfMadeNavigationDemo.kt b/sample/src/main/java/com/github/terrakok/modo/sample/SelfMadeNavigationDemo.kt deleted file mode 100644 index 8e2a596c..00000000 --- a/sample/src/main/java/com/github/terrakok/modo/sample/SelfMadeNavigationDemo.kt +++ /dev/null @@ -1,2 +0,0 @@ -package com.github.terrakok.modo.sample - diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt index 6496b701..65422fb2 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt @@ -245,9 +245,6 @@ private fun rememberButtons( ModoButtonSpec("Animation Playground") { navigation?.forward(AnimationPlaygroundScreen()) }, - ModoButtonSpec("Self made navigation") { - navigation?.forward(SelfMadeSampleScreen()) - }, ) ), ) diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/SelfMadeSampleScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/SelfMadeSampleScreen.kt deleted file mode 100644 index 3c555a98..00000000 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/SelfMadeSampleScreen.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.github.terrakok.modo.sample.screens - -class SelfMadeSampleScreen { -} \ No newline at end of file From ebc1001dfb7138797fdd7527cab5e887eb1ebd8e Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Mon, 20 Apr 2026 23:57:10 +0700 Subject: [PATCH 3/3] Fix detekt --- .../java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt index 745ec404..30c7c725 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt @@ -143,6 +143,7 @@ class ModoRootScreenCacheTest { Modo.rootScreens[ScreenKey("k")] = cached val newCandidate = RootScreen(MockScreen(ScreenKey("k"))) + @Suppress("UNCHECKED_CAST") val result = Modo.rootScreens.getOrPut(ScreenKey("k")) { newCandidate } as RootScreen