Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Droidcon NYC: Navigation in a Multiplatform World

Ash Davies
September 19, 2024

Droidcon NYC: Navigation in a Multiplatform World

Navigation in mobile, desktop, and web applications is such a fundamental part of how we structure our architecture. In order to both obtain functional clarity, and abstraction from platform level implementation.

For a long time, there have been options available specific to each platform, and even options part of the platform framework itself. Though it can be difficult to find the right option for platform-agnostic code, ensuring consistency. Some go one step further, providing an opinionated guide on how to architecture your application.

In this talk, I'll evaluate the options available, how they differ, and to what type of applications they are best suited. Including how to get started with them, and the best practice guidelines on how to get the most out of them, for your application.

Ash Davies

September 19, 2024
Tweet

More Decks by Ash Davies

Other Decks in Programming

Transcript

  1. Navigation in a Multiplatform World Choosing the Right Framework for

    your App Droidcon NYC - September '24 ! Ash Davies - SumUp Android & Kotlin GDE Berlin ashdavies.dev
  2. "Once we have gotten in to this entry-point to your

    UI, we really don't care how you organise the flow inside." — Dianne Hackborn, Android Framework team, 2016 ashdavies.dev
  3. Compose UI • Declarative UI Framework • Open Source Kotlin

    • Accelerate UI development • Intuitive Idiomatic API ashdavies.dev
  4. Compose is, at its core, a general- purpose tool for

    managing a tree of nodes of any type ... a “tree of nodes” describes just about anything, and as a result Compose can target just about anything. — Jake Wharton jakewharton.com/a-jetpack-compose-by-any-other-name
  5. Jetpack Navigation Compose v2.4.0 (2021) • Build a navigation graph

    with a @Composable Kotlin DSL • Compose viewModel() scoped to navigation destination • Desintation level scope for rememberSaveable() • Automatic back handling support ashdavies.dev
  6. Jetpack Navigation Compose < v2.8.0 private const val HOME_ROUTE =

    "home" NavHost( navController = navController, startDestination = HOME_ROUTE, ) { composable(route = HOME_ROUTE) { HomeScreen( onBackClick = navController::popBackStack, /* ... */ ) } } ashdavies.dev
  7. Jetpack Navigation Compose < v2.8.0 private const val DETAIL_ID_KEY =

    "detailId" private const val DETAIL_ROUTE = "detail" NavHost( navController = navController, startDestination = DETAIL_ROUTE, ) { composable( route = DETAIL_ROUTE, arguments = listOf( navArgument(DETAIL_ID_KEY) { type = NavType.StringType defaultValue = null nullable = true } ) ) { DetailScreen(/* ... */) } } ashdavies.dev
  8. Jetpack Navigation Compose < v2.8.0 private const val DETAIL_ID_KEY =

    "detailId" fun NavController.navigateToDetail(detailId: String) { navigate("detail?$DETAIL_ID_KEY=$detailId") } savedStateHandle.getStateFlow(DETAIL_ID_KEY, null) ashdavies.dev
  9. Jetpack Navigation Compose v2.8.0 (04.09.2024) @Serializable data class DetailRoute(val id:

    String) NavHost( navController = navController, startDestination = "detail", ) { composable<DetailRoute> { DetailScreen(/* ... */) } } val route = savedStateHandle.toRoute<DetailRoute>() ashdavies.dev
  10. Navigation in a Multiplatform World Choosing the Right Framework for

    your App Android Navigation für N00bs by Some Dude ashdavies.dev
  11. Maven Group ID Latest Update Stable Release Alpha Release annotation

    04.09.2024 1.8.2 1.9.0-alpha03 collection 04.09.2024 1.4.3 1.5.0-alpha01 datastore 01.05.2024 1.1.1 - lifecycle 04.09.2024 2.8.5 2.9.0-alpha02 paging 07.08.2024 3.3.2 - room 21.08.2024 2.6.1 2.7.0-alpha07 sqlite 21.08.2024 2.4.0 2.5.0-alpha07 developer.android.com/kotlin/multiplatform | As of 15.09.2024
  12. kotlin { sourceSets.commonMain.dependencies { implementation( "androidx.lifecycle:" + "lifecycle-viewmodel-ktx:" + "2.8.5"

    ) } } // Backed by ViewModelImpl public expect abstract class ViewModel cs.android.com/androidx/platform/frameworks/support/+/androidx-main:lifecycle/lifecycle-viewmodel/src/commonMain/kotlin/androidx/lifecycle/ ViewModel.kt
  13. import com.arkivanov.decompose.ComponentContext class DefaultRootComponent( componentContext: ComponentContext, ) : RootComponent, ComponentContext

    by componentContext { init { lifecycle... // Access the Lifecycle stateKeeper... // Access the StateKeeper instanceKeeper... // Access the InstanceKeeper backHandler... // Access the BackHandler } } ashdavies.dev
  14. class RootComponent(context: ComponentContext) : Root, ComponentContext { private val navigation

    = StackNavigation<Config>() override val childStack = childStack(/* ... */) fun createChild(config: Config, context: ComponentContext): Child = when (config) { is Config.List -> Child.List(ItemListComponent(context) { navigation.push(Config.Details(itemId = it)) } ) is Config.Details -> /* ... */ } } private sealed class Config : Parcelable { @Parcelize object List : Config() @Parcelize data class Details(val itemId: Long) : Config() } ashdavies.dev
  15. Platform Stability level Android Stable iOS Stable Desktop (JVM) Stable

    Server-side (JVM) Stable Web based on Kotlin/Wasm Alpha Web based on Kotlin/JS Stable watchOS Best effort tvOS Best effort ashdavies.dev
  16. kotlin { sourceSets.commonMain.dependencies { implementation("org.jetbrains.androidx.navigation:navigation-compose:2.8.0-alpha10") } } @Serializable data object

    HomeRoute NavHost(navController, HomeRoute) { composable<HomeRoute> { HomeScreen() } } val route = savedStateHandle.toRoute<HomeRoute>() ashdavies.dev
  17. The early bird gets the worm ... but the second

    mouse gets the cheese ashdavies.dev
  18. Reactive Architecture • Push (not pull) • Unidirectional Data Flow

    • Declarative • Idempotent ashdavies.dev
  19. Architecture Observables downloadManager.downloadFile("https://.../") .flatMap { result -> fileManager.saveFile("storage/file", result) }

    .observe { success -> if (success) println("Downloaded file successfully") } ashdavies.dev
  20. Architecture Coroutines (Again) downloadManager.downloadFile("https://.../") .flatMapLatest { state -> when (state)

    { is State.Loaded -> stateFileManager.saveFile("storage/file", state.value) else -> state } } .collect { state -> when (state) { is State.Loading -> /* ... */ is State.Saved -> println("Downloaded file successfully") } } ashdavies.dev
  21. Architecture Compose val downloadState = downloadManager .downloadFile("https://.../") .collectAsState(State.Loading) val fileState

    = when(downloadState) { is State.Loaded -> stateFileManager.saveFile("storage/file", state.value) else -> state } when (fileState) { is State.Loading -> /* ... */ is State.Saved -> LaunchedEffect(fileState) { println("Downloaded file successfully") } } ashdavies.dev
  22. Molecule fun CoroutineScope.launchCounter(): StateFlow<Int> { return launchMolecule(mode = ContextClock) {

    var count by remember { mutableStateOf(0) } LaunchedEffect(Unit) { while (true) { delay(1_000) count++ } } count } } ashdavies.dev
  23. Circuit • Supports most supported KMP platforms • Compose first

    architecture • Presenter & UI separation • Unidirectional Data Flow ashdavies.dev
  24. Circuit State @Parcelize data object HomeScreen : Screen { data

    class State( val title: String, ): CircuitUiState } ashdavies.dev
  25. Circuit Presenter class HomePresenter : Presenter<HomeScreen.State> { @Composable override fun

    present(): HomeScreen.State { return HomeScreen.State("Hello World") } } ashdavies.dev
  26. Circuit UI @Composable fun HomeScreen( state: HomeScreen.State, modifier: Modifier =

    Modifier, ) { Text( text = state.title, modifier = modifier, ) } ashdavies.dev
  27. Circuit val circuit = Circuit.Builder() .addPresenter<HomeScreen, HomeScreen.State>(HomePresenter()) .addUi<LauncherScreen, LauncherScreen.State> {

    _, _ -> HomeScreen(state, modifier) } .build() CircuitCompositionLocals(circuit) { val backStack = rememberSaveableBackStack(HomeScreen) NavigableCircuitContent( navigator = rememberCircuitNavigator(backStack), backStack = backStack, ) } ashdavies.dev
  28. Circuit Navigation @Parcelize data object HomeScreen : Screen { data

    class State( val title: String, val eventSink: (Event) -> Unit ): CircuitUiState sealed interface Event { data class DetailClicked( val id: String, ): Event } ashdavies.dev
  29. Circuit Navigation class HomePresenter(private val navigator: Navigator) : Presenter<HomeScreen.State> {

    @Composable override fun present(): HomeScreen.State { return HomeScreen.State("Hello World") { event -> when (event) { is HomeScreen.Event.DetailClicked -> navigator.goTo(DetailScreen(event.id)) } } } } ashdavies.dev
  30. Circuit Examples • Chris Banes: Tivi github.com/chrisbanes/tivi • Zac Sweers:

    CatchUp github.com/ZacSweers/CatchUp • Zac Sweers: FieldSpottr github.com/zacsweers/fieldspottr • Ash Davies: Playground github.com/ashdavies/playground.ashdavies.dev ashdavies.dev
  31. Voyager class PostListScreen : Screen { @Composable override fun Content()

    { // ... } @Composable private fun PostCard(post: Post) { val navigator = LocalNavigator.currentOrThrow Card( modifier = Modifier.clickable { navigator.push(PostDetailsScreen(post.id)) } ) { // ... } } } ashdavies.dev
  32. Voyager interface ParcelableScreen : Screen, Parcelable // Compile @Parcelize data

    class Post(/*...*/) : Parcelable @Parcelize data class ValidScreen( val post: Post ) : ParcelableScreen { // ... } // Not compile data class Post(/*...*/) @Parcelize data class ValidScreen( val post: Post ) : ParcelableScreen { // ... } ashdavies.dev
  33. Comparison androidx circuit decompose voyager workflow Multiplatform ✅ ✅ ✅

    ✅ ✅ Compose 1st ❌ ✅ ❌ ✅ ❌ Documented* ❌ ✅ ✅ ✅ ❌ Ease-of-Use** ❌ ✅ / ✅ ❌ Opinionated*** ❌ ✅ ❌ ❌ ✅ * Documentation exists, but is outdated or hard to find ** Subjective, how quick to get started *** Additional API surface ashdavies.dev