/foSho

Type-safe navigation library for Jetpack Compose on Android

Primary LanguageKotlinApache License 2.0Apache-2.0

Successfully ran in two projects with over +1M installs, archived in favor of https://developer.android.com/guide/navigation/design/type-safety

"Opinionated" Android type safe navigation for Compose's Navigation Component

A KSP library that helps you generate type safe code with minimal effort and provides out of the box solutions to bundle everything together and scale your app in easy manner.

Main features

  • Typesafe navigation arguments (special thanks for parsing the arguments to compose destinations)
  • Simple setup with minimal code
  • ViewModel type safe arguments with injection support when using Hilt
  • Screen "level" type safe arguments
  • Callback arguments
  • Bottom sheet support through Accompanist Material
  • Nested navigation support out of the box
  • Transitions enter/exit to and from screens
  • Deep links
  • Graph aggregator factory
  • Custom navigator to open screens easily
  • Global navigation
  • Multi (out of the box) and single module support

Everything you can do with the Official Jetpack Compose Navigation but in a type safe way.

Setup

The library is available through JitPack.

  1. Add JitPack to your project's settings.gradle
dependencyResolutionManagement {
    ...
    repositories {
        ...
        maven { setUrl("https://jitpack.io") }
    }
}
  1. Add the dependency in the build.gradle
[versions]
foSho = <version>
ksp = <version>

[libraries]
foSho-android = { module = "com.github.FunkyMuse.foSho:navigator-android", version.ref = "foSho" }
foSho-codegen = { module = "com.github.FunkyMuse.foSho:navigator-codegen", version.ref = "foSho" }

[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

Inside the project build.gradle make sure to add the KSP plugin

plugins {
    ...
    alias(libs.plugins.ksp).apply(false)
}

Inside the :app module

plugins {
    ....
    alias(libs.plugins.ksp)
}
dependencies {
    implementation(libs.foSho.android)
    ksp(libs.foSho.codegen)
}

If you intend to use Hilt/Dagger/Anvil with foSho and want your generated ViewModel arguments injectable, inside :app module set foSho.injectViewModelArguments to "true", default is "false"

ksp {
    arg("foSho.injectViewModelArguments", "true")
}
Single module

Inside the :app module make sure to add the KSP argument, the library is multi module by default.

ksp {
    arg("foSho.singleModule", "true")
}
  1. Make magic happen

There are three things you need to write code for and it's pretty natural

  1. Graph
  2. Destination (Argument, Callback Argument)
  3. Content

All you need to do is click build or just run ./gradlew kspDebugKotlin as a faster way to get the generated code.

  • Graph: Starting point is a @Graph, each Graph has a startingDestination and other destinations, also a Graph can be a rootGraph (only one throughout your app).
  • Destination: every destination that's part of startingDestination and destinations is marked with @Destination and you implement one of the three UI types Screen, Dialog for which you can control the properties and BottomSheet for each one you can additionally control whether to generate view model arguments or nav stack entry arguments, val generateViewModelArguments: Boolean = false, val generateScreenEntryArguments: Boolean = false, you can annotate a destination with @Argument and @CallbackArgument in order to control to/from arguments.
  • Content: everytime you click build a Destination implementation is generated for you, it's your responsibility to implement it and annotate that object with a @Content In code this will look like this
@Graph(
    startingDestination = Home::class,
    destinations = [HomeDetails::class],
    rootGraph = true
)
internal object HomeGraph

@Destination
@Argument(
    name = "hideBottomNav",
    argumentType = ArgumentType.BOOLEAN,
    defaultValue = DefaultBooleanValueFalse::class
)
internal object Home : Screen

@Destination(generateViewModelArguments = true, generateScreenEntryArguments = false)
@Argument(name = "cardId", argumentType = ArgumentType.INT)
@CallbackArgument(name = "clickedShare", argumentType = ArgumentType.BOOLEAN)
internal object HomeDetails : Screen {
    override val deepLinksList: List<DeepLinkContract>
        get() = listOf(
            DeepLinkContract(
                action = Intent.ACTION_VIEW,
                uriPattern = "custom:url/{cardId}"
            )
        )
}

@Content
internal object HomeContent : HomeDestination {
  @Composable
  override fun AnimatedContentScope.Content() {
    val argumentsFromHomeDetailsDestination = HomeDetailsCallbackArguments.rememberHomeDetailsCallbackArguments()
    argumentsFromHomeDetailsDestination.OnSingleBooleanCallbackArgument(
      key = HomeDetailsCallbackArguments.CLICKED_SHARE,
      onValue = {
        if (it == true){
          //user has clicked share
        }
      }
    )
    HomeScreen(onClick = {
      Navigator.navigateSingleTop(HomeDetailsDestination.openHomeDetails(cardId = 42))
    })
  }
}

@Content
internal object HomeDetailsContent : HomeDetailsDestination {
  @Composable
  override fun AnimatedContentScope.Content() {

  }
}

A GraphFactory is generated for you which you can use with an extension function addGraphs to have ease of use like

addGraphs(
  navigationGraphs = GraphFactory.graphs
)

you can checkout this line.

A Navigator is there for you to collect the navigation events and also to send navigation events, for a single module setup, you can check out the sample.

Multi module

You would need to create one umbrella module, usually named "navigator" or however you see fit where you would write the code for the Graphs

//:navigator module
//UserAccountGraph.kt
@Graph(
  startingDestination = UserAccountDetails::class,
  destinations = [
    EditAccountDetails::class,
    DeleteAccount::class,
    ChangePassword::class,
  ]
)
internal object UserAccountGraph

@Destination
internal object UserAccountDetails : Screen

@Destination
internal object ChangePassword : BottomSheet

@Destination
internal object DeleteAccount : Dialog

@Destination(generateScreenEntryArguments = true)
@Argument(name = "email", argumentType = ArgumentType.STRING)
@CallbackArgument(name = "isAccountUpdated", argumentType = ArgumentType.BOOLEAN)
internal object EditAccountDetails : Screen

//HomeGraph.kt
@Graph(startingDestination = Home::class, rootGraph = true)
internal object HomeGraph

@Destination
@Argument(name = "hideBottomNav", argumentType = ArgumentType.BOOLEAN, defaultValue = DefaultBooleanValueFalse::class)
internal object Home : Screen

Your :navigator module acts as the only point where you have the navigation codegen code and nothing else, here you can control the arg whether to generate injectable view model arguments through

ksp {
    arg("foSho.injectViewModelArguments", "true")
}

Then inside your feature module

// :feature:user_details
@Content
internal object UserAccountDetailsContent : UserAccountDetailsDestination {
    @Composable
    override fun AnimatedContentScope.Content() {
        
    }
}

and also make sure to add the :feature:user_details in your :app module so that it can be aggregated into the GraphFactory.

Important for Kotlin < 1.8.0

When using Kotlin version older than 1.8.0, you need to make sure the IDE looks at the generated folder. See KSP related issue.

How to do it depends on the AGP version you are using in this case:

Warning: In both cases, add this inside android block and replacing applicationVariants with libraryVariants if the module is not an application one (i.e, it uses 'com.android.library' plugin).

Since AGP (Android Gradle Plugin) version 7.4.0
  • groovy - build.gradle(:module-name)
applicationVariants.all { variant ->
    variant.addJavaSourceFoldersToModel(
            new File(buildDir, "generated/ksp/${variant.name}/kotlin")
    )
}
  • kotlin - build.gradle.kts(:module-name)
applicationVariants.all {
    addJavaSourceFoldersToModel(
        File(buildDir, "generated/ksp/$name/kotlin")
    )
}
For AGP (Android Gradle Plugin) version older than 7.4.0
  • groovy - build.gradle(:module-name)
applicationVariants.all { variant ->
    kotlin.sourceSets {
        getByName(variant.name) {
            kotlin.srcDir("build/generated/ksp/${variant.name}/kotlin")
        }
    }
}
  • kotlin - build.gradle.kts(:module-name)
applicationVariants.all {
    kotlin.sourceSets {
        getByName(name) {
            kotlin.srcDir("build/generated/ksp/$name/kotlin")
        }
    }
}

Found this repository useful? ❤️

Support it by joining stargazers for this repository. 🌠

And follow me or check out my blog for my next creations! ⭐

Contributing

Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.

License

Apache 2.0