DrifterAtSea / navigation-with-animated-transitions-using-jetpack-compose

DEPRECATED - Demonstates how to create animated transitions to and from screens using Jetpack Compose.
41 stars 4 forks source link
android-animation android-compose jetpack-compose jetpack-compose-navigation

Navigation with Animated Transitions Using Jetpack Compose

This project has been deprecated and replaced with Jetmagic, available at: https://github.com/JohannBlake/Jetmagic

This app demonstrates how to use animated transitions when navigating between screens when using Jetpack Compose. It also shows how to pass any type of data to another screen.

I have also written an article on Medium that covers the code in this app in much more detail. It touches on developing with Compose beyond what you read in the official Android docs. You can find the article at:

https://proandroiddev.com/navigation-with-animated-transitions-using-jetpack-compose-daeb00d4fb45

This app does not use the Compose navigation framework due to its limitation of the data types that you can use when passing data to another screen. These are limited to the same subset you would normally use under the older View system when passing data to activities and fragments, i.e., the types restricted to bundles. Because Compose doesn't use activities or fragmenets (other than the startup activity), I personally never felt there was a need to restrict the app to these data types. You should be able to pass any data type.

Another limitation is that animated transitions are currently not supported when navigating to and from screens, although Google has mentioned in their docs that this feature is in the pipeline.

As a result, I created my own navigation framework. It is designed in such a way that you can apply animated transitions however you want and these animations are not controlled by the navigation framework. In fact, the navigation framework is so simple that it's only prupose is to manage a screen stack and provides some additional functions that make navigation easier. Referred to as the Navigation Manager, the navigation manager is the source of truth of your app's navigation history. Unlike the Compose navigation framework, there is no Navigation Host or any concept of a graph. You simply initialize the Navigation Manager with the types of screens your app will be using, optionally provide the type of class you want to use as the viewmodel for the screen and indicate which of the screens will act as your home screen. After that, it's just a matter of calling the Navigation Manager APIs to navigate to a screen, go back to a previous screen, or return to the home screen.

The code for the Navigation Manager is separate from the app's demo code and provided as an Android Library module under the navigation folder.

The data for the app is retrieved from an API I created on Wirespec: https://wirespec.dev/Wirespec/projects/apis/AdoptPets

It basically provides a list of 60 cats available for adoption. Wirespec is free. I developed it and own it. It's a great way to develop REST APIs quickly and easily with endpoints that can even match those on your production servers.


App Usage

A navigation drawer is provided and contains a large list of menu items. Only the Home and Settings menu items have their own dedicated screens when you click on them. All the other menu items use the same screen, which I call a dummy screen. The purpose of the dummy screen is to show how you can re-use the same screen for different purposes.

It should be noted that you can only open up the navigation drawer while on the home screen. Whether you would want to limit it this way or allow it to be available under other conditions is something you can easily implement as explained in the Medium article.

Clicking on a grid item, takes you to the details screen for the item you have selected. Images of the cats are loaded dynamically. If you scroll to the bottom, there is a button that you can click on to adopt the pet. It takes you to a dummy screen.

The dummy screen has a button that lets you create another instance of a dummy screen. You can click on this and keep repeating it. At some point, you can then use the Back button and navigate back to the home screen to see how the state of each screen is maintained. There is also a button on the dummy screen that will take you directly to the home screen. If you click that, you end up clearing the navigation stack. At that point, if you click the Back button again, you'll exit the app.


Navigation Manager API

  1. Create a sealed class listing all the screens your app will show. It can be called whatever you want, but naming it Screens makes it obvious. It really just serves as an enum. Example:
sealed class Screens {
    object PetsList : Screens()
    object PetDetails : Screens()
    object Settings : Screens()
    object Dummy : Screens()
}
  1. During your app's startup, use the addScreens function to add all the screens you want to use in your app. A good place to put this is in a class that inherits from Application. Optionally provide any viewmodels that each screen will use. Note, you are passing in a class reference to a viewmodel and not an instance. Navigation Manager will create the viewmodel for the screen when the screen needs to be displayed. Each screen is provided its own unique viewmodel and no two screens share the same viewmodel. The viewmodels are removed when the user navigates back to a previous screen or navigates to the home screen. Mark one (and only one) of the screens as being the home screen by setting isHomeScreen to true. The order in which you specify the screens is not important:
NavigationManager.addScreens(
    mutableListOf(
        ScreenInfo(screen = Screens.PetsList, viewmodel = PetsListViewModel::class.java, isHomeScreen = true),
        ScreenInfo(screen = Screens.PetDetails, viewmodel = PetDetailsViewModel::class.java),
        ScreenInfo(screen = Screens.Dummy),
        ScreenInfo(screen = Screens.Settings, viewmodel = SettingsViewModel::class.java)
    )
)
  1. Create a Composable that will act as a screen factory. See ScreenFactoryUI.kt for details. This is a UI composable, meaning that it will generate the actual screen. You would normally use this in a place such as a Scaffold's content, as the demo does. In essence, whenever you want to navigate to or from a screen, Navigation Manager will notifiy the factory causing it to recompose. The factory is responsible for iterating through all the screens currently in the navigation stack and recomposing all of them. It should be noted that this iteration does not mean every screen is actually recomposed. The Compose framework determines internally whether composables should be recomposed and this is often the result of parameters in the Composable function changing. Consult the Compose documentation on the specifics of how it determines when to recompose or not. The screen factory is also responsible for providing any animation transitions from one screen to another. In this demo app, a slide in/out from the left is used for all screens, but you can set up different animations for different screens. There is no animation used for the home screen when the app starts.

  2. In your main activity, add code to detect the back button and notify the Navigation Manager whenever the user navigates backward. If the goBack function returns false, it means that there are no other screens to navigate back to and the app should exit:

    override fun onBackPressed() {  
       if (!NavigationManager.goBack())  
           super.onBackPressed()  
    }



Screen Caching

When you navigate from one screen back to a previous screen, Navigation Manager removes the current screen from the navigation stack including any viewmodel that you may have optionally included with the screen when you called addScreens. The viewmodel will get garbage collected by Android provided some other part of your app is not retaining a reference to it.

There are cases however where you want the viewmodel to remain active even when the screen is no longer available. Consider the case of an app that hosts a video meeting. You might navigate to a screen that hosts the video. The user then decides to navigate back to the previous screen but you don't want to terminate the video. If the user then clicks the button to go back to the video meeting, they would expect the video to just carry on. In fact, even when they are not on the screen hosting the video, not only is the video still recording the user but the user can still talk and communicate with others in the meeting. There are many ways to accomplish this but if you decide to let the Navigation Manager manage the viewmodel and your viewmodel is responsible for starting the video meeting, you can easily accomplish this by using Navigation Manager's caching mechanism.

Navigation Manager allows you to cache the navigation item for a screen whenever you either navigate to the screen or when you navigate back to a previous sceen. To tell Navigation Manager to cache a navigation item when navigating forward to another screen, you set the cacheScreen parameter of the navigateTo function to true. You must also provide a unique id for the id parameter. If you are certain ahead of time that you will always want to cache a particular screen when you navigate to a screen, you should set cacheScreen to true.

You can also cache the screen when navigating back to the previous screen. To do this, you must implement the onNavigateBack function of the NavigationManagerHelper interface. When the user navigates back, the onNavigateBack function will be called. To cache the screen, you must return GoBackImmediatelyAndCacheScreen from the NavigateBackOptions enum:

override fun onNavigateBack(): NavigateBackOptions {
    return NavigateBackOptions.GoBackImmediatelyAndCacheScreen
}

You can choose to cache the screen either when you call navigateTo or when onNavigateBack is called, or even do it in both locations. Which method you choose is whatever makes sense to your business logic. You could have a scenario where you don't want to cache the screen when you navigate to it but then something in your business logic takes place while the screen is displaying and then you decide to cache the screen before the user returns to the previous screen. You could also have a scenario where you want to cache the screen when you navigate to it but then decide to remove it from the cache when the user returns to the previous screen. This might be the case with the video meeting example. If the user terminates the meeting while they are on the screen and then navigate back to the previous screen, there probably is no reason to keep the screen in the cache at that point. You can have the screen removed from the cache by returning GoBackImmediatelyAndRemoveCachedScreen in the onNavigateBack function. If you decide to cache when navigating to a screen with navigateTo and also cache when returning to the previous screen by returning GoBackImmediatelyAndCacheScreen, Navigation Manager just ignores the caching in onNavigateBack since it already cached it when navigateTo was called. Even if you decide to only use caching when onNavigateBack is called, you must still provide an id when you call navigateTo. The cached screen always requires a unique id.

It should be noted that the cache is not the same thing as the navigation stack. Although Navigation Manager's navigation stack is essentially a cache as well, it treats the caching of screens separate from the navigation stack. The navigation stack always reflects the true navigation path the user has taken. The cache on the other hand is just a place to store navigation items that can be re-used when returning to a screen later on.

IMPORTANT: Use screen caching only when you really need it. If you enable screen caching for a particular screen and the user navigates back to a previous screen and the screen remains cached, there is no guarantee that the user will ever return to that screen, leaving the cached screen in memory. If your viewmodel is running an endless process or taking up a lot of memory, this could be wasteful.

One important thing to keep in mind when navigating forward to a screen that has been previously cached is that the composable UI for that screen simply attaches to an existing viewmodel. This means that your viewmodel should provide checks to prevent stuff from being restarted. For instance, if your viewmodel started a video meeting, you don't want to run the same code again that starts the meeting. This is in essence what you would do anyways if there was a system change that would cause the activity to be destroyed, such as changing the orientation of the device. This is easy to overlook if you're use to tying your viewmodel's scope to an activity and when the activity gets destroyed, the viewmodel gets destroyed - in which case you have nothing to worry about since everything starts from scratch. But in a Compose app with just a single activity, there is no reason that a viewmodel needs to be scoped to an activity and no need for it to be killed if the activity gets killed. If you let the Navigation Manager handle your viewmodels, by default it will destroy them when they are no longer needed but provide you with the option of keeping them alive when you have reason for doing so.



NavigationManager API

Because this API can still be considered in the alpha phase, you should specify your parameters as named parameters when making API calls. This will ensure that your code correctly catches any breaking changes when you upgrade to future versions.

Function / Property Description
addScreens fun addScreens(allScreens: List<ScreenInfo<*>>)

Adds one or more screens that Navigation Manager will create when the user navigates to another screen. Screen instances are destroyed when the user navigates back to the previous screen.
navigateTo fun navigateTo(screen: Any, screenData: Any? = null, id: Any? = null, cacheScreen: Boolean = false)

Navigates to a screen. Specify the type of screen to navigate to and include any optional parameters you want to pass to the screen.

You can optionally provide an id for the item. The id should be globally unique throughout your app. Only one particular screen should ever be associated with this id. Setting this parameter is required if the cacheScreen parameter is set to true. This is useful if you ever need to retrieve navigation info about a particular screen from the navigation stack. When you create a screen that has a deep hierarchy of composables and some composable at a lower level needs to access the viewmodel associaled with the screen that the composable is part of, you can use the getNavInfoById function to return the navigation info associated with the id and then access the viewmodel from the navigation info. This approach simplifies how any composable on a particular screen can access the viewmodel associated with the screen without the need to pass the viewmodel as a parameter down through the screen hierarchy.

Set cacheScreen to true if you want the navigation information cached.
goBack fun goBack(): Boolean

Navigates back to the previous screen. If the user is on the home screen when this is called, this function will return false, meaning that there are no other screens to navigate back to. The app should exit when false is returned.

Before navigating to the previous screen, a check will be made to see if the current screen has a viewmodel managed by the Navigation Manager and determine if it has implemented the onNavigateBack function. If this function is implemented, it will be called. Depending on the value returned by onNavigateBack, the Navigation Manager will either proceed with navigating back or cancel the navigation. For the options you can use, see NavigateBackOptions below.
goBackImmediately fun goBackImmediately(): Boolean

This is essentially the same function as the goBack function, except no check is made for the implementation of the onNavigateBack function. If your screen calls goBack and has implemented the onNavigateBack function and returns NavigateBackOptions.Cancel, you can call goBackImmediately at a later time when your screen is ready to navigate to the previous screen.
navigateToHomeScreen fun navigateToHomeScreen()

Navigates to the home screen. The navigation history is cleared. If the user hits the Back button at this point, they would exit the app. Like the goBack function, this function will also check for the existence of the onNavigateBack function and call it if it exists. The value returned by onNavigateBack is processed the same way as it is with goBack. While calling navigateToHomeScreen may be thought of as navigating forward, it is in fact navigating back because the Navigation Manager removes all the previous screens from the stack until only the Home screen is left.
navigateToHomeScreenImmediately fun navigateToHomeScreenImmediately(): Boolean

This is essentially the same function as the navigateToHomeScreen function, except no check is made for the implementation of the onNavigateBack function. This function always returns true and the return value has no meaning.
getNavInfo fun getNavInfo(index: Int): NavigationInfo

Returns navigation information for a specific item in the navigation stack. The index of the first item in the stack starts with zero and this will always be the home page. The last item in the stack is a placeholder screen and is not visible.

The property onCloseScreenis a LiveData observable that will get updated whenever the user navigates back to the previous screen. The value of the observable will be set to true to indicate to the observer to close the screen:

navInfo?.onCloseScreen?.observeAsState(false)?.value
getNavInfoById Returns a navigation info for an item in the navigation stack that has the specified id associated with it. Set the id using the id parameter of the navigateTo function.
observeScreenChange fun observeScreenChange(callback: (screen: Any) -> Unit)

Notifies subscribers whenever the Navigation Manager navigates to another screen or navigates back to a previous one. This should only be used by non-UI code to perform any necessary tasks whenever the screen changes.
onScreenChange onScreenChange: LiveData<Int>

The Navigation Manager uses this to notify the screen factory composable to update the screens . This is an observable with a value set to a random number between 0 and 1 million. A random number is used in order to force LiveData to update its value and notify the observer (which is the screen factory). The value itself has no meaning.

NavigationManager.onScreenChange.observeAsState(0).value
navStackCount navStackCount: Int

The total number of screens on the navigation stack. This includes the hidden placeholder screen that is always placed at the end of the stack. There will always be at least two items on the stack - the first item will be the home screen and the last item will be the placeholder screen.
totalScreensDisplayed totalScreensDisplayed: Int

The total number of screens on the navigation stack, minus one. This is essentially the same as navStackCount but doesn't include the placeholder screen. This is just a convenient way of knowing how many visible screens are currently being displayed.
currentScreenNavInfo currentScreenNavInfo: NavigationInfo

Returns navigation information about the currently displayed screen.
previousScreenNavInfo previousScreenNavInfo: NavigationInfo?

Returns navigation information about the previous screen. Returns null if there is no previous screen, which would be the case if the current screen is the home screen. This is useful in a case such as when you want to return data to the previous screen before navigating back. You can access the previous screen's viewmodel and call a function on it to pass data back before returning to the previous screen.
clearScreenCache fun clearScreenCache()

Removes all items from the screen cache.
removeScreenFromCache fun removeScreenFromCache(screenId: String)

Removes the specified screen from the cache.



NavigationManagerHelper Interface

You can use these callback functions to allow the Navigation Manager to interact with your viewmodels. These will only work if the Navigation Manager manages the viewmodel for your screen and your screen implements this interface.

Function / Property Description
onNavigateBack fun onNavigateBack(): NavigateBackOptions

When the goBack function is called, the Navigation Manager will first call onNavigateBack. The value returned by onNavigateBack will determine whether to proceed with the navigation or not. See the  NavigateBackOptions below for the values you can return.



NavigateBackOptions (enum)

Option Description
GoBackImmediately The Navigation Manager navigates to the previous screen.
GoBackImmediatelyAndCacheScreen The Navigation Manager caches the current screen and then returns to the previous screen.
GoBackImmediatelyAndRemoveCachedScreen The Navigation Manager removes the screen from its cache and returns the previous screen.
Cancel The Navigation Manager will cancel navigating to the previous screen. This is useful in scenarios where you may want to delay returning to the previous screen for reasons such as performing clean up tasks or prompting the user if they really want to return to the previous screen.