diff --git a/demo/src/main/kotlin/dev/hotwire/demo/main/MainActivity.kt b/demo/src/main/kotlin/dev/hotwire/demo/main/MainActivity.kt index 5cc2d222..3e1f57ca 100644 --- a/demo/src/main/kotlin/dev/hotwire/demo/main/MainActivity.kt +++ b/demo/src/main/kotlin/dev/hotwire/demo/main/MainActivity.kt @@ -35,7 +35,11 @@ class MainActivity : HotwireActivity() { private fun initializeBottomTabs() { val bottomNavigationView = findViewById(R.id.bottom_nav) - bottomNavigationController = HotwireBottomNavigationController(this, bottomNavigationView) + bottomNavigationController = HotwireBottomNavigationController( + activity = this, + view = bottomNavigationView, + lazyLoadTabs = true + ) bottomNavigationController.load(mainTabs, viewModel.selectedTabIndex) bottomNavigationController.setOnTabSelectedListener { index, _ -> viewModel.selectedTabIndex = index diff --git a/navigation-fragments/src/main/java/dev/hotwire/navigation/activities/HotwireActivityDelegate.kt b/navigation-fragments/src/main/java/dev/hotwire/navigation/activities/HotwireActivityDelegate.kt index 0bd3710c..b451040e 100644 --- a/navigation-fragments/src/main/java/dev/hotwire/navigation/activities/HotwireActivityDelegate.kt +++ b/navigation-fragments/src/main/java/dev/hotwire/navigation/activities/HotwireActivityDelegate.kt @@ -17,6 +17,7 @@ import dev.hotwire.navigation.observers.HotwireActivityObserver @Suppress("unused", "MemberVisibilityCanBePrivate") class HotwireActivityDelegate(val activity: HotwireActivity) { private val navigatorHosts = mutableMapOf() + private val lazyNavigatorHostIds = mutableSetOf() private val onBackPressedCallback = object : OnBackPressedCallback(enabled = true) { override fun handleOnBackPressed() { @@ -66,10 +67,22 @@ class HotwireActivityDelegate(val activity: HotwireActivity) { val navigatorHost = navigatorHosts[currentNavigatorHostId] if (navigatorHost != null) { + navigatorHost.initControllerGraphIfNeeded() updateOnBackPressedCallback(navigatorHost) } } + /** + * Sets the given navigator hosts as lazy, meaning their start destination + * won't be loaded until the host first becomes the current navigator (e.g. + * when its bottom tab is first selected). Any host not marked as lazy is + * loaded eagerly as soon as its view is created. + */ + internal fun setLazyNavigatorHosts(navigatorHostIds: Collection) { + lazyNavigatorHostIds.clear() + lazyNavigatorHostIds.addAll(navigatorHostIds) + } + internal fun registerNavigatorHost(host: NavigatorHost) { logEvent("navigatorRegistered", listOf("navigator" to host.navigator.configuration.name)) @@ -80,6 +93,13 @@ class HotwireActivityDelegate(val activity: HotwireActivity) { if (currentNavigatorHostId == host.id) { updateOnBackPressedCallback(host) } + + // Load the host's start destination unless it's a lazy host that + // isn't currently selected. Lazy hosts are loaded when they first + // become the current navigator. + if (host.id !in lazyNavigatorHostIds || currentNavigatorHostId == host.id) { + host.initControllerGraphIfNeeded() + } } } @@ -106,16 +126,22 @@ class HotwireActivityDelegate(val activity: HotwireActivity) { /** * Resets the sessions associated with all registered navigator hosts. + * Hosts that haven't loaded their start destination yet are skipped. */ fun resetSessions() { - navigatorHosts.forEach { it.value.navigator.session.reset() } + navigatorHosts.values + .filter { it.isGraphInitialized } + .forEach { it.navigator.session.reset() } } /** * Resets all registered navigators via [Navigator.reset]. + * Hosts that haven't loaded their start destination yet are skipped. */ fun resetNavigators() { - navigatorHosts.forEach { it.value.navigator.reset() } + navigatorHosts.values + .filter { it.isGraphInitialized } + .forEach { it.navigator.reset() } } private fun listenToDestinationChanges(host: NavigatorHost) { diff --git a/navigation-fragments/src/main/java/dev/hotwire/navigation/navigator/Navigator.kt b/navigation-fragments/src/main/java/dev/hotwire/navigation/navigator/Navigator.kt index e63b7856..fd76e56c 100644 --- a/navigation-fragments/src/main/java/dev/hotwire/navigation/navigator/Navigator.kt +++ b/navigation-fragments/src/main/java/dev/hotwire/navigation/navigator/Navigator.kt @@ -211,7 +211,7 @@ class Navigator( navigateWhenReady { clearAll { session.reset() - host.initControllerGraph() + host.resetControllerGraph() if (host.view == null) { onReset() diff --git a/navigation-fragments/src/main/java/dev/hotwire/navigation/navigator/NavigatorHost.kt b/navigation-fragments/src/main/java/dev/hotwire/navigation/navigator/NavigatorHost.kt index 25291b7b..9c9af51e 100644 --- a/navigation-fragments/src/main/java/dev/hotwire/navigation/navigator/NavigatorHost.kt +++ b/navigation-fragments/src/main/java/dev/hotwire/navigation/navigator/NavigatorHost.kt @@ -22,14 +22,19 @@ open class NavigatorHost : NavHostFragment(), FragmentOnAttachListener { lateinit var navigator: Navigator private set + /** + * Whether the navigation graph has been built and the start destination + * loaded. See [initControllerGraphIfNeeded]. + */ + internal var isGraphInitialized = false + private set + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) activity = requireActivity() as HotwireActivity navigator = Navigator(this, configuration, activity) childFragmentManager.addFragmentOnAttachListener(this) - - initControllerGraph() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -53,10 +58,29 @@ open class NavigatorHost : NavHostFragment(), FragmentOnAttachListener { * has not been created yet. */ fun isReady(): Boolean { - return isAdded && !isDetached && childFragmentManager.primaryNavigationFragment != null + return isGraphInitialized + && isAdded + && !isDetached + && childFragmentManager.primaryNavigationFragment != null + } + + /** + * Builds the navigation graph and loads the start destination if it hasn't + * been built already. This is idempotent, so it's safe to call whenever the + * host may need to become ready for navigation (e.g. when its tab is selected). + */ + internal fun initControllerGraphIfNeeded() { + if (!isGraphInitialized) { + initControllerGraph() + } + } + + internal fun resetControllerGraph() { + initControllerGraph() } - internal fun initControllerGraph() { + private fun initControllerGraph() { + isGraphInitialized = true ensureDeeplinkStartLocationValid() navController.apply { diff --git a/navigation-fragments/src/main/java/dev/hotwire/navigation/tabs/HotwireBottomNavigationController.kt b/navigation-fragments/src/main/java/dev/hotwire/navigation/tabs/HotwireBottomNavigationController.kt index faff460e..b784a931 100644 --- a/navigation-fragments/src/main/java/dev/hotwire/navigation/tabs/HotwireBottomNavigationController.kt +++ b/navigation-fragments/src/main/java/dev/hotwire/navigation/tabs/HotwireBottomNavigationController.kt @@ -21,13 +21,28 @@ import dev.hotwire.navigation.navigator.presentationContext /** * A [BottomNavigationView] controller that manages multiple [HotwireBottomTab]s, each associated * with its own [NavigatorHost] instance in the Activity layout. + * + * @param activity The [HotwireActivity] that hosts the [BottomNavigationView] and its tabs. + * @param view The [BottomNavigationView] in your layout to manage. + * @param initialVisibility The initial [Visibility] mode for the [BottomNavigationView]. Defaults + * to [Visibility.DEFAULT]. + * @param clearNavigationOnTabReselection When `true` (the default), reselecting the already-active + * tab clears its navigation backstack to the start destination. When `false`, reselecting a tab + * has no effect. + * @param animateVisibilityChanges When `true` (the default), the [BottomNavigationView] animates in + * and out when its visibility changes. When `false`, visibility changes are applied instantly. + * @param lazyLoadTabs When `false` (the default), every tab's [NavigatorHost] loads its + * start location as soon as its view is created, so all tabs load up front. When `true`, a tab's + * start location is not loaded until that tab is first selected, avoiding loading (and creating a + * web view visit for) every tab at once. The initially selected tab still loads immediately. */ class HotwireBottomNavigationController( val activity: HotwireActivity, val view: BottomNavigationView, val initialVisibility: Visibility = Visibility.DEFAULT, val clearNavigationOnTabReselection: Boolean = true, - val animateVisibilityChanges: Boolean = true + val animateVisibilityChanges: Boolean = true, + val lazyLoadTabs: Boolean = false ) : NavController.OnDestinationChangedListener { /** @@ -100,6 +115,13 @@ class HotwireBottomNavigationController( this.tabs = tabs + // Mark the tab hosts as lazy before their views are created and + // registered with the delegate. The initially selected tab is still + // loaded immediately when it becomes the current navigator. + if (lazyLoadTabs) { + activity.delegate.setLazyNavigatorHosts(tabs.map { it.configuration.navigatorHostId }) + } + val initialIndex = selectedTabIndex.coerceIn(0, tabs.lastIndex) val initialTab = tabs[initialIndex]