Appyx Navigation

Michał Konkel
Michał Konkel
September 6, 2024 | Software development
appyx

This is a Kotlin Multiplatform project targeting Android and iOS where we will showcase the Appyx as the app navigation.

Assumptions:

  • Application should allow us to navigate from one screen to another.
  • Application should allow to pass some parameters from first to second screen.
  • Application should handle the screen rotation without loosing data.
  • Application should handle the Tab Navigation.
  • Application should handle the async operations with coroutines.

In the next posts, I will also cover the VoyagerDecompose, and Composer navigation libraries.

The project:

Base project setup as always is made with Kotlin Multiplatform Wizard, we also need to add Appyx as it is the core thing that we would like to examine. Appyx consists of three main libraries that complement itself navigationinteractions, and components this allows us to create an application that is fully customized.

libs.versions.toml

[versions]
appyx = "2.0.1"

[libraries]
appyx-navigation = { module = "com.bumble.appyx:appyx-navigation", version.ref = "appyx" }
appyx-interactions = { module = "com.bumble.appyx:appyx-interactions", version.ref = "appyx" }
appyx-components-backstack = { module = "com.bumble.appyx:backstack", version.ref = "appyx" }

Freshly added dependencies needs to be synced with the project and added to the build.gradle.kts

sourceSets {
    commonMain.dependencies {
        ...
        implementation(libs.appyx.navigation)
        implementation(libs.appyx.interactions)
        api(libs.appyx.components.backstack)
    }
}

With dependencies added we can start to create the navigation. Following the Appyx documentation we can notice one major thing, the Appyx gives us flexibility in the interpreting term “navigation”. Most modern libraries/solutions focus on gow to get from one screen to another, but Appyx gives us the possibility to create a navigation that is not only about screens but a “viewport”. It can be what you can imagine, for example spinning the carousel. Nevertheless, we will focus on traditional Stack navigation. There are some basic blocks that we need to use. The first one is Node which is a representation of the structure (in our case the screen). Each node can hold other nodes, and they are called children. The node is a standalone unit with its own:

  • AppyxComponent – in our case it will be back stack, with simple linear navigation. Element at front is consider active, other as stashed. It can never be empty. It has some basic functions helper functions as pushpopreplace and default back handler.
  • Lifecycle – it’s a multiplatform interfaces that notifies the component about the state changes on the platform. For example on he Android platform it is implemented with AndroidLifecycle.
  • State Restoration after orientation changes
  • The View, that is created with @composable
  • Business logic
  • Plugins – since Nodes should be kept lean, the plugins are used to add additional functionality, for example NodeLifecycleAware that allows to listen to the lifecycle events.

The nodes can be as small as you want to keep the complexity of your logic low and the encapsulated end is extracted to the children to compose the process. With the nodes, your navigation can work as a tree with multiple branches responsible for different processes. Some parts of the tree are active – visible on the screen, other are stashed. To change what’s currently active we will use the component, the change will look like navigation. By adding or removing nodes of the node. Such an approach creates a Scoped DI the situation where if the parent node is destroyed all of its children nodes and related objects are released. There is also ChildAwareAPI that helps with communication between parents and dynamically added children.

After that short introduction it’s time to code. First thing that we need to create is RootNode.

class RootNode(
    nodeContext: NodeContext,
) : LeafNode(nodeContext) {

    @Composable
    override fun Content(modifier: Modifier) {
        Column(
            modifier = Modifier.fillMaxSize().then(modifier),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text("Hello Appyx!")
        }
    }
}

Similarly, as in the Decompose lib we need to provide some kind of context. The NodeContext is created on the host platform (ex. Androids MainActivity) and it is passed down to all the descendants. It ensures the support of the lifecycle and keeps the structured hierarchy of children nodes. The LeafNode uses the context and handles all the lifecycle events, manages plugins, provides the coroutines scope, and manages the children creation to keep the structured nodes hierarchy mentioned in scoped DI. It also forces us to implement a @Composable function Content that will be used to create the view.

Let’s connect the RootNode with the hosts. For Android we need to use MainActivity and inherit from the NodeComponentActivity() which under the hood integrates the android with Appyx (if you don’t want to inherit from ready solutions you can implement ActivityIntegrationPoint by yourself) . Then we need to create the NodeHost (that is responsible for providing nodeContext) and provide it with lifecycle.

class MainActivity : NodeComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            MaterialTheme {
                NodeHost(
                    lifecycle = AndroidLifecycle(LocalLifecycleOwner.current.lifecycle),
                    integrationPoint = appyxIntegrationPoint
                ) { nodeContext ->
                    RootNode(nodeContext)
                }
            }
        }
    }
}

Now the iOS. As it is a compose function we just need to create proper host the IosNodeHost with default IntegrationPoint.

fun MainViewController() = ComposeUIViewController {
    IosNodeHost(
        modifier = Modifier,
        integrationPoint = MainIntegrationPoint(),
        onBackPressedEvents = backEvents.receiveAsFlow()
    ) { nodeContext ->
        RootNode(nodeContext)
    }
}

The basic setup was done, and we were able to display the initial screen with the text. Now we can add children’s screens. The screen definition needs to be defined as Parcelable it will tell the node where we want to go. The Parcelable is a part of the Appyx library and it’s expect/actual class so it has a common definition that is implemented differently on platforms. For the Android platform, we need to add the support of kotlin-parcelize plugin to use @Parcelize annotation.

plugins {
    ...
    id("kotlin-parcelize")
}
sealed class NavTarget : Parcelable {
    @Parcelize
    data object FirstScreen : NavTarget()

    @Parcelize
    data object SecondScreen : NavTarget()
}

Targets are defined, the RootNode needs to be modified. It should handle the BackStack component, and know how to navigate from one screen to another. The first change that we need to do is to change the RootNode from being just a LeafNode to be a Node<>. First one was a simple node that can’t have children and was used to display the content. The second one is a node that can have children and can be used to navigate between them.

The Node requires the NavTarget to be defined and buildChildNode function to be implemented – those two things will be responsible for handling creation of the children nodes.

The second important thing is the appyxComponent – parameter that is used to define the way how the nodes will be handled.

private fun backstack(nodeContext: NodeContext): BackStack<NavTarget> = BackStack(
    model = BackStackModel(
        initialTarget = NavTarget.FirstScreen,
        savedStateMap = nodeContext.savedStateMap,
    ),
    visualisation = { BackStackFader(it) }
)

Now we need to add some destination nodes, they would be same with different texts only.

class FirstNode(
    nodeContext: NodeContext
) : LeafNode(nodeContext = nodeContext) {
    @Composable
    override fun Content(modifier: Modifier) {
        Column(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text("Hello from the First Node!")
        }
    }
}

To be able to navigate between the screens we will ad simple lambdas, that will be called in child nodes, but handled in root node.

class FirstNode(
    private val onButtonClick: () -> Unit
) : LeafNode(nodeContext = nodeContext) {
    @Composable
    override fun Content(modifier: Modifier) {
        ...
        TextButton(onClick = onButtonClick) {
            Text("Go to Second Node")
        }
    }
}

Going back to the RoodNode

class RootNode(
    nodeContext: NodeContext,
    private val backstack: BackStack<NavTarget> = backstack(nodeContext),
) : Node<NavTarget>(
    appyxComponent = backstack,
    nodeContext = nodeContext,
) {
    override fun buildChildNode(navTarget: NavTarget, nodeContext: NodeContext): Node<*> =
        when (navTarget) {
            NavTarget.FirstScreen -> FirstNode(nodeContext) {
                backstack.push(NavTarget.SecondScreen)
            }
            NavTarget.SecondScreen -> SecondNode(nodeContext) {
                backstack.push(NavTarget.FirstScreen)
            }
        }
}

We need to add AppyxNavigationContainer that will handle the navigation and render the added nodes content.

@Composable
override fun Content(modifier: Modifier) {
    AppyxNavigationContainer(appyxComponent = backstack)
}

You can experiment with different visualizations, for example, BackStackFaderBackStackSliderBackStackParallax, or others mentioned in the documentation.

Tab Navigation

To handle the bottom navigation feature we need to use a Spotlight component, which behaves similarly to the view pager. It can hold multiple nodes at the same and keeps one of them active (visible). The rule is the same as with linear navigation, we just need to switch from backstack to spotlight.

appyx-components-spotlingh = { module = "com.bumble.appyx:spotlight", version.ref = "appyx" }
commonMain.dependencies {
    ...
    api(libs.appyx.components.spotlingh)
}

Lets create new navigation targets for tabbed navigation.

sealed class SpotlightNavTarget : Parcelable {
    @Parcelize
    data object ThirdScreen : SpotlightNavTarget()

    @Parcelize
    data object FourthScreen : SpotlightNavTarget()
}

Now we need to create a parent node that will hold the spotlight component.

class SpotlightNode(
    nodeContext: NodeContext,
    private val model: SpotlightModel<SpotlightNavTarget> = spotlightModel(nodeContext),
    private val spotlight: Spotlight<SpotlightNavTarget> = Spotlight(
        model = model,
        visualisation = { SpotlightSlider(it, model.currentState) }
    ),
) : Node<SpotlightNavTarget>(
    appyxComponent = spotlight,
    nodeContext = nodeContext,
)

We have to provide SpotlightModel and SpotlightVisualisation one will handle navigation, other the animation. The model takes list of elements available to be displayed in the carousel, and initial index of default the active tab.

private fun spotlightModel(nodeContext: NodeContext) = SpotlightModel(
    items = listOf(SpotlightNavTarget.ThirdScreen, SpotlightNavTarget.FourthScreen),
    initialActiveIndex = 0f,
    savedStateMap = nodeContext.savedStateMap
)

Last thing to do is to create a UI representation of the screen, we will use Scaffold and default buttons.

@Composable
override fun Content(modifier: Modifier) {
    Scaffold(
        bottomBar = {
            Row(Modifier.background(Color.White)) {
                TextButton(modifier = Modifier.weight(1f), onClick = { spotlight.first() }) {
                    Text(text = "Third")
                }
                TextButton(modifier = Modifier.weight(1f), onClick = { spotlight.last() }) {
                    Text(text = "Fourth")
                }
            }
        }
    ) { paddings ->
        AppyxNavigationContainer(modifier = Modifier.padding(paddings), appyxComponent = spotlight)
    }
}

The nodes will be exactly the same but with a different text and a background.

class ThirdNode(
    nodeContext: NodeContext,
) : LeafNode(nodeContext = nodeContext) {
    @Composable
    override fun Content(modifier: Modifier) {
        Column(
            modifier = Modifier.fillMaxSize().background(Color.Magenta),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text("Hello from the Third Node!")
        }
    }
}

The solution is ready to use, as it was designed to work just out of the box we need to add only the entry point to our existing navigation. We need to add TabScreenas a NavTarget in the linear navigation, and a button in FirstNode that will run the tabbed navigation feature.

Appyx also provides Material3 support with out of a box solution for bottom navigation, so we can easily create it using built-in components. The material support library uses the spotlight component) under the hood, so the dependencies we need are:

appyx-utils-material = { module = "com.bumble.appyx:utils-material3", version.ref = "appyx" }
commonMain.dependencies {
    ...
    api(libs.appyx.utils.material)
}

After syncing the project we will reach the AppyxNavItem which will be used in a bottom navigation and the AppyxMaterial3NavNode responsible for navigation. Creation of the TabNavigationItems is pretty straightforward and similar to the previously used linear navigation. We need to create destinations in our case these will be the enums that will represent the screens/nodes.

@Parcelize
enum class TabNavigationItems : Parcelable {
    FIRST_DESTINATION, SECOND_DESTINATION;
}

Now we can create the resolver that will be responsible for creating the bottom bar. It takes the defined destination and creates the proper navigation items, with texticons and lambda that will create desired nodes.

companion object {
    val resolver: (TabNavigationItems) -> AppyxNavItem = { navBarItem ->
        when (navBarItem) {
            FIRST_DESTINATION -> AppyxNavItem(
                text = "Third",
                unselectedIcon = Icons.Sharp.Home,
                selectedIcon = Icons.Filled.Home,
                node = { ThirdNode(it) }
            )

            SECOND_DESTINATION -> AppyxNavItem(
                text = "Fourth",
                unselectedIcon = Icons.Sharp.AccountBox,
                selectedIcon = Icons.Filled.AccountBox,
                node = { FourthNode(it) }
            )
        }
    }
}

The last thing to do is to create proper node.

class TabNode(
    nodeContext: NodeContext,
) : AppyxMaterial3NavNode<TabNavigationItems>(
    nodeContext = nodeContext,
    navTargets = TabNavigationItems.entries,
    navTargetResolver = TabNavigationItems.resolver,
    initialActiveElement = TabNavigationItems.FIRST_DESTINATION,
)

At the end we need to add another entrypoint in the FirstNode to be able to react freshly created screen.

Coroutines

There is no official approach how to handle coroutines inside the Nodes, but we can use for example the NodeLifecycle which provides the lifecycle and lifecycleScope and use it (as in the example). We can also use the lifecycle with PlatformLifecycleEventObserver where we create and manage the coroutineScope. We can also use the scope of the @Composable view rememberCoroutineScope() or we can mix approaches to fit all your needs.

Moving further I will extend the SecondNode with a countdown timer that will be started on the screen creation and update the text value.

private val countDownText = mutableStateOf<String>("0")

init {
    lifecycle.coroutineScope.launch {
        for (i in 10 downTo 0) {
            countDownText.value = i.toString()
            delay(1000)
        }
    }
}
@Composable
override fun Content(modifier: Modifier) {
    Column(...) {
        ...
        Spacer(modifier = Modifier.height(16.dp))
        Text("Countdown: ${countDownText.value}")
    }
}

You should think about a better place to hold your business logic than Node a place that can handle the configuration changes and recreating of the view, a place that can retain the state. The ViewModel is a perfect place for that, but it’s not a part of the Appyx library, so you need to implement it by yourself. Appyx is currently in the development phase of ViewModel support, and you can vote for it here. If you would like to survive configuration changes there is an official guide for that.

Summary

The Appyx library is a powerful tool that allows you to create a fully customized navigation in your Compose Multiplatform application. It’s a great solution for creating complex navigation structures, and it’s easy to use. The library is tightly coupled with te Jetpack Compose but doesn’t provide dedicated component to hold your business logic, you are free to use your own solutions. The library is still in the development phase waiting for example for the ViewModel support as mentioned in the post. Therefore, it doesn’t provide an out-of-the-box support for coroutines. You need to handle it by yourself, but it’s not a big deal.

Compared to Decompose it was quicker to set up the basic navigation, but it needs to use third-party for holding logic and developing the whole app. On the other side compared to Voyager I can find a lot of similarities in how it was designed to use, and how handles navigation. Nevertheless, the Voyager was a bit more intuitive for me, and I liked the way how it was designed to use. All in all every library has its pros and cons, and it’s up to you to choose the best one for your project. I think you should try them all and decide which one fits your needs the best.

I hope this post has given you a good overview of the Appyx library and how you can use it to create a navigation in your Compose Multiplatform application. If you have any questions or comments, please feel free to leave them below. I’d love to hear from you!

If you want to meet us in person, click here and we’ll get in touch!