blob: 3881c556f90180a196c39329897bb8a36adf72b9 [file] [log] [blame]
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.navigation.ui
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.annotation.IdRes
import androidx.annotation.RestrictTo
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.forEach
import androidx.customview.widget.Openable
import androidx.navigation.ActivityNavigator
import androidx.navigation.FloatingWindow
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavOptions
import com.google.android.material.appbar.CollapsingToolbarLayout
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.navigation.NavigationBarView
import com.google.android.material.navigation.NavigationView
import com.google.android.material.navigationrail.NavigationRailView
import java.lang.ref.WeakReference
/**
* Class which hooks up elements typically in the 'chrome' of your application such as global
* navigation patterns like a navigation drawer or bottom nav bar with your [NavController].
*/
public object NavigationUI {
private const val TAG = "NavigationUI"
/**
* Attempt to navigate to the [NavDestination] associated with the given MenuItem. This
* MenuItem should have been added via one of the helper methods in this class.
*
* Importantly, it assumes the [menu item id][MenuItem.getItemId] matches a valid
* [action id][NavDestination.getAction] or [destination id][NavDestination.id] to be
* navigated to.
*
* By default, the back stack will be popped back to the navigation graph's start destination.
* Menu items that have `android:menuCategory="secondary"` will not pop the back
* stack.
*
* @param item The selected MenuItem.
* @param navController The NavController that hosts the destination.
* @return True if the [NavController] was able to navigate to the destination
* associated with the given MenuItem.
*/
@JvmStatic
public fun onNavDestinationSelected(item: MenuItem, navController: NavController): Boolean {
val builder = NavOptions.Builder().setLaunchSingleTop(true).setRestoreState(true)
if (
navController.currentDestination!!.parent!!.findNode(item.itemId)
is ActivityNavigator.Destination
) {
builder.setEnterAnim(R.anim.nav_default_enter_anim)
.setExitAnim(R.anim.nav_default_exit_anim)
.setPopEnterAnim(R.anim.nav_default_pop_enter_anim)
.setPopExitAnim(R.anim.nav_default_pop_exit_anim)
} else {
builder.setEnterAnim(R.animator.nav_default_enter_anim)
.setExitAnim(R.animator.nav_default_exit_anim)
.setPopEnterAnim(R.animator.nav_default_pop_enter_anim)
.setPopExitAnim(R.animator.nav_default_pop_exit_anim)
}
if (item.order and Menu.CATEGORY_SECONDARY == 0) {
builder.setPopUpTo(
navController.graph.findStartDestination().id,
inclusive = false,
saveState = true
)
}
val options = builder.build()
return try {
// TODO provide proper API instead of using Exceptions as Control-Flow.
navController.navigate(item.itemId, null, options)
// Return true only if the destination we've navigated to matches the MenuItem
navController.currentDestination?.matchDestination(item.itemId) == true
} catch (e: IllegalArgumentException) {
val name = NavDestination.getDisplayName(navController.context, item.itemId)
Log.i(
TAG,
"Ignoring onNavDestinationSelected for MenuItem $name as it cannot be found " +
"from the current destination ${navController.currentDestination}",
e
)
false
}
}
/**
* Attempt to navigate to the [NavDestination] associated with the given MenuItem. This
* MenuItem should have been added via one of the helper methods in this class.
*
* Importantly, it assumes the [menu item id][MenuItem.getItemId] matches a valid
* [action id][NavDestination.getAction] or [destination id][NavDestination.id] to be
* navigated to.
*
* By default, the back stack will be popped back to the navigation graph's start destination.
* Menu items that have `android:menuCategory="secondary"` will not pop the back
* stack.
*
* @param item The selected MenuItem.
* @param navController The NavController that hosts the destination.
* @param saveState Whether the NavController should save the back stack state. This must
* always be `false`: leave this parameter off entirely to use the non-experimental version
* of this API, which saves the state by default.
*
* @return True if the [NavController] was able to navigate to the destination
* associated with the given MenuItem.
*/
@NavigationUiSaveStateControl
@JvmStatic
public fun onNavDestinationSelected(
item: MenuItem,
navController: NavController,
saveState: Boolean
): Boolean {
check(!saveState) {
"Leave the saveState parameter out entirely to use the non-experimental version of " +
"this API, which saves the state by default"
}
val builder = NavOptions.Builder().setLaunchSingleTop(true)
if (
navController.currentDestination!!.parent!!.findNode(item.itemId)
is ActivityNavigator.Destination
) {
builder.setEnterAnim(R.anim.nav_default_enter_anim)
.setExitAnim(R.anim.nav_default_exit_anim)
.setPopEnterAnim(R.anim.nav_default_pop_enter_anim)
.setPopExitAnim(R.anim.nav_default_pop_exit_anim)
} else {
builder.setEnterAnim(R.animator.nav_default_enter_anim)
.setExitAnim(R.animator.nav_default_exit_anim)
.setPopEnterAnim(R.animator.nav_default_pop_enter_anim)
.setPopExitAnim(R.animator.nav_default_pop_exit_anim)
}
if (item.order and Menu.CATEGORY_SECONDARY == 0) {
builder.setPopUpTo(
navController.graph.findStartDestination().id,
inclusive = false
)
}
val options = builder.build()
return try {
// TODO provide proper API instead of using Exceptions as Control-Flow.
navController.navigate(item.itemId, null, options)
// Return true only if the destination we've navigated to matches the MenuItem
navController.currentDestination?.matchDestination(item.itemId) == true
} catch (e: IllegalArgumentException) {
val name = NavDestination.getDisplayName(navController.context, item.itemId)
Log.i(
TAG,
"Ignoring onNavDestinationSelected for MenuItem $name as it cannot be found " +
"from the current destination ${navController.currentDestination}",
e
)
false
}
}
/**
* Handles the Up button by delegating its behavior to the given NavController. This should
* generally be called from [AppCompatActivity.onSupportNavigateUp].
*
* If you do not have a [Openable] layout, you should call
* [NavController.navigateUp] directly.
*
* @param navController The NavController that hosts your content.
* @param openableLayout The Openable layout that should be opened if you are on the topmost
* level of the app.
* @return True if the [NavController] was able to navigate up.
*/
@JvmStatic
public fun navigateUp(navController: NavController, openableLayout: Openable?): Boolean =
navigateUp(
navController,
AppBarConfiguration.Builder(navController.graph)
.setOpenableLayout(openableLayout)
.build()
)
/**
* Handles the Up button by delegating its behavior to the given NavController. This is
* an alternative to using [NavController.navigateUp] directly when the given
* [AppBarConfiguration] needs to be considered when determining what should happen
* when the Up button is pressed.
*
* In cases where no Up action is available, the
* [AppBarConfiguration.fallbackOnNavigateUpListener] will be called to provide
* additional control.
*
* @param navController The NavController that hosts your content.
* @param configuration Additional configuration options for determining what should happen
* when the Up button is pressed.
* @return True if the [NavController] was able to navigate up.
*/
@JvmStatic
public fun navigateUp(
navController: NavController,
configuration: AppBarConfiguration
): Boolean {
val openableLayout = configuration.openableLayout
val currentDestination = navController.currentDestination
return if (openableLayout != null && currentDestination != null &&
configuration.isTopLevelDestination(currentDestination)
) {
openableLayout.open()
true
} else {
return if (navController.navigateUp()) {
true
} else configuration.fallbackOnNavigateUpListener?.onNavigateUp() ?: false
}
}
/**
* Sets up the ActionBar returned by [AppCompatActivity.getSupportActionBar] for use
* with a [NavController].
*
* By calling this method, the title in the action bar will automatically be updated when
* the destination changes (assuming there is a valid [label][NavDestination.label]).
*
* The start destination of your navigation graph is considered the only top level
* destination. On the start destination of your navigation graph, the ActionBar will show
* the drawer icon if the given Openable layout is non null. On all other destinations,
* the ActionBar will show the Up button.
* Call [navigateUp] to handle the Up button.
*
* Destinations that implement [androidx.navigation.FloatingWindow] will be ignored.
*
* @param activity The activity hosting the action bar that should be kept in sync with changes
* to the NavController.
* @param navController The NavController whose navigation actions will be reflected
* in the title of the action bar.
* @param openableLayout The Openable layout that should be toggled from the home button
* @see NavigationUI.setupActionBarWithNavController
*/
@JvmStatic
public fun setupActionBarWithNavController(
activity: AppCompatActivity,
navController: NavController,
openableLayout: Openable?
): Unit =
setupActionBarWithNavController(
activity,
navController,
AppBarConfiguration.Builder(navController.graph)
.setOpenableLayout(openableLayout)
.build()
)
/**
* Sets up the ActionBar returned by [AppCompatActivity.getSupportActionBar] for use
* with a [NavController].
*
* By calling this method, the title in the action bar will automatically be updated when
* the destination changes (assuming there is a valid [label][NavDestination.label]).
*
* The [AppBarConfiguration] you provide controls how the Navigation button is
* displayed.
* Call [navigateUp] to handle the Up button.
*
* Destinations that implement [androidx.navigation.FloatingWindow] will be ignored.
*
* @param activity The activity hosting the action bar that should be kept in sync with changes
* to the NavController.
* @param navController The NavController whose navigation actions will be reflected
* in the title of the action bar.
* @param configuration Additional configuration options for customizing the behavior of the
* ActionBar
*/
@JvmStatic
@JvmOverloads
public fun setupActionBarWithNavController(
activity: AppCompatActivity,
navController: NavController,
configuration: AppBarConfiguration =
AppBarConfiguration.Builder(navController.graph).build()
): Unit =
navController.addOnDestinationChangedListener(
ActionBarOnDestinationChangedListener(activity, configuration)
)
/**
* Sets up a [Toolbar] for use with a [NavController].
*
* By calling this method, the title in the Toolbar will automatically be updated when
* the destination changes (assuming there is a valid [label][NavDestination.label]).
*
* The start destination of your navigation graph is considered the only top level
* destination. On the start destination of your navigation graph, the Toolbar will show
* the drawer icon if the given Openable layout is non null. On all other destinations,
* the Toolbar will show the Up button. This method will call
* [navigateUp] when the Navigation button is clicked.
*
* Destinations that implement [androidx.navigation.FloatingWindow] will be ignored.
*
* @param toolbar The Toolbar that should be kept in sync with changes to the NavController.
* @param navController The NavController whose navigation actions will be reflected
* in the title of the Toolbar.
* @param openableLayout The Openable layout that should be toggled from the Navigation button
* @see setupWithNavController
*/
@JvmStatic
public fun setupWithNavController(
toolbar: Toolbar,
navController: NavController,
openableLayout: Openable?
): Unit =
setupWithNavController(
toolbar,
navController,
AppBarConfiguration.Builder(navController.graph)
.setOpenableLayout(openableLayout)
.build()
)
/**
* Sets up a [Toolbar] for use with a [NavController].
*
* By calling this method, the title in the Toolbar will automatically be updated when
* the destination changes (assuming there is a valid [label][NavDestination.label]).
*
* The [AppBarConfiguration] you provide controls how the Navigation button is
* displayed and what action is triggered when the Navigation button is tapped. This method
* will call [navigateUp] when the Navigation button
* is clicked.
*
* Destinations that implement [androidx.navigation.FloatingWindow] will be ignored.
*
* @param toolbar The Toolbar that should be kept in sync with changes to the NavController.
* @param navController The NavController whose navigation actions will be reflected
* in the title of the Toolbar.
* @param configuration Additional configuration options for customizing the behavior of the
* Toolbar
*/
@JvmStatic
@JvmOverloads
public fun setupWithNavController(
toolbar: Toolbar,
navController: NavController,
configuration: AppBarConfiguration =
AppBarConfiguration.Builder(navController.graph).build()
) {
navController.addOnDestinationChangedListener(
ToolbarOnDestinationChangedListener(toolbar, configuration)
)
toolbar.setNavigationOnClickListener { navigateUp(navController, configuration) }
}
/**
* Sets up a [CollapsingToolbarLayout] and [Toolbar] for use with a
* [NavController].
*
* By calling this method, the title in the CollapsingToolbarLayout will automatically be
* updated when the destination changes (assuming there is a valid
* [label][NavDestination.label]).
*
* The start destination of your navigation graph is considered the only top level
* destination. On the start destination of your navigation graph, the Toolbar will show
* the drawer icon if the given Openable layout is non null. On all other destinations,
* the Toolbar will show the Up button. This method will call
* [navigateUp] when the Navigation button is clicked.
*
* Destinations that implement [androidx.navigation.FloatingWindow] will be ignored.
*
* @param collapsingToolbarLayout The CollapsingToolbarLayout that should be kept in sync with
* changes to the NavController.
* @param toolbar The Toolbar that should be kept in sync with changes to the NavController.
* @param navController The NavController whose navigation actions will be reflected
* in the title of the Toolbar.
* @param openableLayout The Openable layout that should be toggled from the Navigation button
*/
@JvmStatic
public fun setupWithNavController(
collapsingToolbarLayout: CollapsingToolbarLayout,
toolbar: Toolbar,
navController: NavController,
openableLayout: Openable?
): Unit =
setupWithNavController(
collapsingToolbarLayout, toolbar, navController,
AppBarConfiguration.Builder(navController.graph)
.setOpenableLayout(openableLayout)
.build()
)
/**
* Sets up a [CollapsingToolbarLayout] and [Toolbar] for use with a
* [NavController].
*
* By calling this method, the title in the CollapsingToolbarLayout will automatically be
* updated when the destination changes (assuming there is a valid
* [label][NavDestination.label]).
*
* The [AppBarConfiguration] you provide controls how the Navigation button is
* displayed and what action is triggered when the Navigation button is tapped. This method
* will call [navigateUp] when the Navigation button
* is clicked.
*
* Destinations that implement [androidx.navigation.FloatingWindow] will be ignored.
*
* @param collapsingToolbarLayout The CollapsingToolbarLayout that should be kept in sync with
* changes to the NavController.
* @param toolbar The Toolbar that should be kept in sync with changes to the NavController.
* @param navController The NavController whose navigation actions will be reflected
* in the title of the Toolbar.
* @param configuration Additional configuration options for customizing the behavior of the
* Toolbar
*/
@JvmStatic
@JvmOverloads
public fun setupWithNavController(
collapsingToolbarLayout: CollapsingToolbarLayout,
toolbar: Toolbar,
navController: NavController,
configuration: AppBarConfiguration =
AppBarConfiguration.Builder(navController.graph).build()
) {
navController.addOnDestinationChangedListener(
CollapsingToolbarOnDestinationChangedListener(
collapsingToolbarLayout, toolbar, configuration
)
)
toolbar.setNavigationOnClickListener { navigateUp(navController, configuration) }
}
/**
* Sets up a [NavigationView] for use with a [NavController]. This will call
* [onNavDestinationSelected] when a menu item is selected.
* The selected item in the NavigationView will automatically be updated when the destination
* changes.
*
* If the [NavigationView] is directly contained with an [Openable] layout,
* it will be closed when a menu item is selected.
*
* Similarly, if the [NavigationView] has a [BottomSheetBehavior] associated with
* it (as is the case when using a [com.google.android.material.bottomsheet.BottomSheetDialog]),
* the bottom sheet will be hidden when a menu item is selected.
*
* Destinations that implement [androidx.navigation.FloatingWindow] will be ignored.
*
* @param navigationView The NavigationView that should be kept in sync with changes to the
* NavController.
* @param navController The NavController that supplies the primary and secondary menu.
* Navigation actions on this NavController will be reflected in the
* selected item in the NavigationView.
*/
@JvmStatic
public fun setupWithNavController(
navigationView: NavigationView,
navController: NavController
) {
navigationView.setNavigationItemSelectedListener { item ->
val handled = onNavDestinationSelected(item, navController)
if (handled) {
val parent = navigationView.parent
if (parent is Openable) {
parent.close()
} else {
val bottomSheetBehavior = findBottomSheetBehavior(navigationView)
if (bottomSheetBehavior != null) {
bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
}
}
}
handled
}
val weakReference = WeakReference(navigationView)
navController.addOnDestinationChangedListener(
object : NavController.OnDestinationChangedListener {
override fun onDestinationChanged(
controller: NavController,
destination: NavDestination,
arguments: Bundle?
) {
val view = weakReference.get()
if (view == null) {
navController.removeOnDestinationChangedListener(this)
return
}
if (destination is FloatingWindow) {
return
}
view.menu.forEach { item ->
item.isChecked = destination.matchDestination(item.itemId)
}
}
})
}
/**
* Sets up a [NavigationView] for use with a [NavController]. This will call
* [onNavDestinationSelected] when a menu item is selected.
* The selected item in the NavigationView will automatically be updated when the destination
* changes.
*
* If the [NavigationView] is directly contained with an [Openable] layout,
* it will be closed when a menu item is selected.
*
* Similarly, if the [NavigationView] has a [BottomSheetBehavior] associated with
* it (as is the case when using a [com.google.android.material.bottomsheet.BottomSheetDialog]),
* the bottom sheet will be hidden when a menu item is selected.
*
* Destinations that implement [androidx.navigation.FloatingWindow] will be ignored.
*
* @param navigationView The NavigationView that should be kept in sync with changes to the
* NavController.
* @param navController The NavController that supplies the primary and secondary menu.
* @param saveState Whether the NavController should save the back stack state. This must
* always be `false`: leave this parameter off entirely to use the non-experimental version
* of this API, which saves the state by default.
*
* Navigation actions on this NavController will be reflected in the
* selected item in the NavigationView.
*/
@NavigationUiSaveStateControl
@JvmStatic
public fun setupWithNavController(
navigationView: NavigationView,
navController: NavController,
saveState: Boolean
) {
check(!saveState) {
"Leave the saveState parameter out entirely to use the non-experimental version of " +
"this API, which saves the state by default"
}
navigationView.setNavigationItemSelectedListener { item ->
val handled = onNavDestinationSelected(item, navController, saveState)
if (handled) {
val parent = navigationView.parent
if (parent is Openable) {
parent.close()
} else {
val bottomSheetBehavior = findBottomSheetBehavior(navigationView)
if (bottomSheetBehavior != null) {
bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
}
}
}
handled
}
val weakReference = WeakReference(navigationView)
navController.addOnDestinationChangedListener(
object : NavController.OnDestinationChangedListener {
override fun onDestinationChanged(
controller: NavController,
destination: NavDestination,
arguments: Bundle?
) {
val view = weakReference.get()
if (view == null) {
navController.removeOnDestinationChangedListener(this)
return
}
if (destination is FloatingWindow) {
return
}
view.menu.forEach { item ->
item.isChecked = destination.matchDestination(item.itemId)
}
}
})
}
/**
* Walks up the view hierarchy, trying to determine if the given View is contained within
* a bottom sheet.
*/
@JvmStatic
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public fun findBottomSheetBehavior(view: View): BottomSheetBehavior<*>? {
val params = view.layoutParams
if (params !is CoordinatorLayout.LayoutParams) {
val parent = view.parent
return if (parent is View) {
findBottomSheetBehavior(parent as View)
} else null
}
val behavior = params
.behavior
return if (behavior !is BottomSheetBehavior<*>) {
// We hit a CoordinatorLayout, but the View doesn't have the BottomSheetBehavior
null
} else behavior
}
/**
* Sets up a [NavigationBarView] for use with a [NavController]. This will call
* [onNavDestinationSelected] when a menu item is selected. The
* selected item in the NavigationBarView will automatically be updated when the destination
* changes.
*
* Destinations that implement [androidx.navigation.FloatingWindow] will be ignored.
*
* @param navigationBarView The NavigationBarView ([BottomNavigationView] or
* [NavigationRailView])
* that should be kept in sync with changes to the NavController.
* @param navController The NavController that supplies the primary menu.
* Navigation actions on this NavController will be reflected in the
* selected item in the NavigationBarView.
*/
@JvmStatic
public fun setupWithNavController(
navigationBarView: NavigationBarView,
navController: NavController
) {
navigationBarView.setOnItemSelectedListener { item ->
onNavDestinationSelected(
item,
navController
)
}
val weakReference = WeakReference(navigationBarView)
navController.addOnDestinationChangedListener(
object : NavController.OnDestinationChangedListener {
override fun onDestinationChanged(
controller: NavController,
destination: NavDestination,
arguments: Bundle?
) {
val view = weakReference.get()
if (view == null) {
navController.removeOnDestinationChangedListener(this)
return
}
if (destination is FloatingWindow) {
return
}
view.menu.forEach { item ->
if (destination.matchDestination(item.itemId)) {
item.isChecked = true
}
}
}
})
}
/**
* Sets up a [NavigationBarView] for use with a [NavController]. This will call
* [onNavDestinationSelected] when a menu item is selected. The
* selected item in the NavigationBarView will automatically be updated when the destination
* changes.
*
* Destinations that implement [androidx.navigation.FloatingWindow] will be ignored.
*
* @param navigationBarView The NavigationBarView ([BottomNavigationView] or
* [NavigationRailView])
* that should be kept in sync with changes to the NavController.
* @param navController The NavController that supplies the primary menu.
* @param saveState Whether the NavController should save the back stack state. This must
* always be `false`: leave this parameter off entirely to use the non-experimental version
* of this API, which saves the state by default.
*
* Navigation actions on this NavController will be reflected in the
* selected item in the NavigationBarView.
*/
@NavigationUiSaveStateControl
@JvmStatic
public fun setupWithNavController(
navigationBarView: NavigationBarView,
navController: NavController,
saveState: Boolean
) {
check(!saveState) {
"Leave the saveState parameter out entirely to use the non-experimental version of " +
"this API, which saves the state by default"
}
navigationBarView.setOnItemSelectedListener { item ->
onNavDestinationSelected(item, navController, saveState)
}
val weakReference = WeakReference(navigationBarView)
navController.addOnDestinationChangedListener(
object : NavController.OnDestinationChangedListener {
override fun onDestinationChanged(
controller: NavController,
destination: NavDestination,
arguments: Bundle?
) {
val view = weakReference.get()
if (view == null) {
navController.removeOnDestinationChangedListener(this)
return
}
if (destination is FloatingWindow) {
return
}
view.menu.forEach { item ->
if (destination.matchDestination(item.itemId)) {
item.isChecked = true
}
}
}
})
}
/**
* Determines whether the given `destId` matches the NavDestination. This handles
* both the default case (the destination's id matches the given id) and the nested case where
* the given id is a parent/grandparent/etc of the destination.
*/
@JvmStatic
internal fun NavDestination.matchDestination(@IdRes destId: Int): Boolean =
hierarchy.any { it.id == destId }
}