hoc081098 / solivagant

🔆 Compose Multiplatform Navigation library - 🌸 Pragmatic, type safety navigation for Compose Multiplatform. Based on Freeletics Khonshu Navigation. ♥️ ViewModel, SavedStateHandle, Lifecycle, Multi-Backstacks, Transitions, Back-press handling, and more...
https://hoc081098.github.io/solivagant/docs/0.x
Apache License 2.0
74 stars 4 forks source link
compose-library compose-multiplatform compose-multiplatform-desktop compose-multiplatform-library compose-multiplatform-navigation compose-navigation jetbrains-compose kmm kmm-library kmm-mvvm kmm-navigation kmp-compose-navigation kmp-library kmp-mvvm kmp-navigation kmp-viewmodel multiplatform-navigation

solivagant 🔆

[🟢 ACTIVE] 🔆 Compose Multiplatform Navigation library - 🌸 Pragmatic, type safety navigation for Compose Multiplatform. Based on Freeletics Khonshu Navigation. ♥️ ViewModel, SavedStateHandle, Lifecycle, Multi-Backstacks, Transitions, Back-press handling, and more...

maven-central codecov Build and publish snapshot Build sample Publish Release GitHub license Kotlin version KotlinX Coroutines version Compose Multiplatform version Hits

badge badge badge badge badge badge badge badge badge badge badge badge badge

[!NOTE] This library is still in alpha, so the API may change in the future.

Credits

Author: Petrus Nguyễn Thái Học

Liked some of my work? Buy me a coffee (or more likely a beer)

Buy Me A Coffee

Docs & Installation

0.x release docs: https://hoc081098.github.io/solivagant/docs/0.x

Snapshot docs: https://hoc081098.github.io/solivagant/docs/latest

Installation

allprojects {
  repositories {
    [...]
    mavenCentral()
  }
}
implementation("io.github.hoc081098:solivagant-navigation:0.5.0")

Snapshot

Snapshots of the development version are available in Sonatype's snapshots repository. ```kotlin allprojects { repositories { ... maven(url = "https://s01.oss.sonatype.org/content/repositories/snapshots/") } } dependencies { implementation("io.github.hoc081098:solivagant-navigation:0.5.1-SNAPSHOT") } ```

Getting started

The library is ported from Freeletics Khonshu Navigation library, so the concepts is similar. You can read the Freeletics Khonshu Navigation to understand the concepts.

👉 Full samples are available here.

1. Create NavRoots, NavRoutes

@Immutable
@Parcelize
data object StartScreenRoute : NavRoot

@Immutable
@Parcelize
data object SearchProductScreenRoute : NavRoute

[!NOTE] @Parcelize is provided by kmp-viewmodel-savedstate library. See kmp-viewmodel-savedstate for more details.

2. Create NavDestinations along with Composables and ViewModels

StartScreen.kt

@JvmField
val StartScreenDestination: NavDestination =
  ScreenDestination<StartScreenRoute> { StartScreen() }

@Composable
internal fun StartScreen(
  modifier: Modifier = Modifier,
  // kmpViewModel or kojectKmpViewModel can be used instead.
  viewModel: StartViewModel = koinKmpViewModel(),
) {
  // UI Composable
}

internal class StartViewModel(
  // used to trigger navigation actions from outside the view layer (e.g. from a ViewModel).
  // Usually, it is singleton object, or the host Activity retained scope.
  private val navigator: NavEventNavigator,
) : ViewModel() {
  internal fun navigateToProductsScreen() = navigator.navigateTo(ProductsScreenRoute)
  internal fun navigateToSearchProductScreen() = navigator.navigateTo(SearchProductScreenRoute)
}
SearchProductScreen.kt
@JvmField
val SearchProductScreenDestination: NavDestination =
  ScreenDestination<SearchProductScreenRoute> { SearchProductsScreen() }

@Composable
internal fun SearchProductsScreen(
  modifier: Modifier = Modifier,
  // kmpViewModel or kojectKmpViewModel can be used instead.
  viewModel: SearchProductsViewModel = koinKmpViewModel<SearchProductsViewModel>(),
) {
  // UI Composable
}

internal class SearchProductsViewModel(
  private val searchProducts: SearchProducts,
  private val savedStateHandle: SavedStateHandle,
  // used to trigger navigation actions from outside the view layer (e.g. from a ViewModel).
  // Usually, it is singleton object, or the host Activity retained scope.
  private val navigator: NavEventNavigator,
) : ViewModel() {
  fun navigateToProductDetail(id: Int) {
    navigator.navigateTo(ProductDetailScreenRoute(id))
  }
}

3. Setup

3.1. NavHost

Gather all NavDestinations in a set and use NavEventNavigator to trigger navigation actions.

MyAwesomeApp.kt
@Stable
private val AllDestinations: ImmutableSet<NavDestination> = persistentSetOf(
  StartScreenDestination,
  SearchProductScreenDestination,
  // and more ...
)

@Composable
fun MyAwesomeApp(
  // used to trigger navigation actions from outside the view layer (e.g. from a ViewModel).
  // Usually, it is singleton object, or the host Activity retained scope.
  navigator: NavEventNavigator = koinInject(),
  modifier: Modifier = Modifier,
) {
  // BaseRoute is the parent interface of NavRoute and NavRoot.
  // It implements Parcelable so that it can be used with rememberSavable.
  var currentRoute: BaseRoute? by rememberSavable { mutableStateOf(null) }

  NavHost(
    modifier = modifier,
    // route to the screen that should be shown initially
    startRoute = StartScreenRoute,
    // should contain all destinations that can be navigated to
    destinations = AllDestinations,
    // when passing a NavEventNavigator to NavHost, NavHost will take care of setting up the navigator by calling `NavigationSetup(navigator)`
    navEventNavigator = navigator,
    destinationChangedCallback = { currentRoute = it },
  )
}

[!IMPORTANT] When passing a NavEventNavigator to NavHost composable, the NavHost will take care of setting up the navigator by calling NavigationSetup(navigator).

If you don't pass a "global" NavEventNavigator to NavHost composable, make sure there are property calls to NavigationSetup(navigator). For example, we can call NavigationSetup(navigator) in each destination composable.

@JvmField
val StartScreenDestination: NavDestination = ScreenDestination<StartScreenRoute> {
  NavigationSetup(navigator = koinInject())
  StartScreen()
}

@JvmField
val SearchProductScreenDestination: NavDestination = ScreenDestination<SearchProductScreenRoute> {
  NavigationSetup(navigator = koinInject())
  SearchProductsScreen()
}

👉 Check out scoped navigator sample for more information.

3.2. Android

To display MyAwesomeApp on Android, use setContent in Activity / Fragment.

MainActivity.kt
class MainActivity : ComponentActivity() {
  override fun onCreate(savedInstanceState: Bundle) {
    super.onCreate()

    // navigator can be retrieved from the DI container, such as Koin, Dagger Hilt, etc...
    setContent {
      MyAwesomeApp()
    }
  }
}

3.3. Desktop

To display MyAwesomeApp on Desktop, use androidx.compose.ui.window.application and Window composable:

main.kt
fun main() {
  val lifecycleRegistry = LifecycleRegistry()
  val savedStateSupport = SavedStateSupport()

  application {
    val windowState = rememberWindowState()
    val lifecycleOwner = rememberLifecycleOwner(lifecycleRegistry)
    LifecycleControllerEffect(
      lifecycleRegistry = lifecycleRegistry,
      windowState = windowState,
    )

    savedStateSupport.ClearOnDispose()

    Window(
      onCloseRequest = ::exitApplication,
      title = "Solivagant sample",
      state = windowState,
    ) {
      LifecycleOwnerProvider(lifecycleOwner) {
        // navigator can be retrieved from the DI container, such as Koin, Koject, etc...
        savedStateSupport.ProvideCompositionLocals { MyAwesomeApp() }
      }
    }
  }
}

[!TIP] For more information please check out Desktop sample main.kt

3.4. iOS / tvOS / watchOS

To display MyAwesomeApp on iOS/tvOS/watchOS, use ComposeUIViewController (Kotlin - iosMain SourceSet) and UIViewControllerRepresentable (Swift - native code):

MainViewController.kt
val AppLifecycleOwner by lazy { AppLifecycleOwner() }

fun MainViewController(savedStateSupport: SavedStateSupport): UIViewController {
  val lifecycleOwnerUIVcDelegate =
    LifecycleOwnerComposeUIViewControllerDelegate(hostLifecycleOwner = AppLifecycleOwner)
      .apply { bindTo(savedStateSupport) }
      .apply { lifecycle.subscribe(LifecycleObserver) }

  return ComposeUIViewController(
    configure = { delegate = lifecycleOwnerUIVcDelegate },
  ) {
    LifecycleOwnerProvider(lifecycleOwnerUIVcDelegate) {
      savedStateSupport.ProvideCompositionLocals { MyAwesomeApp() }
    }
  }
}
ComposeView.swift
private struct ComposeView: UIViewControllerRepresentable {
  let savedStateSupport: NavigationSavedStateSupport

  func makeUIViewController(context: Context) -> UIViewController {
    MainViewControllerKt.MainViewController(savedStateSupport: savedStateSupport)
  }

  func updateUIViewController(_ uiViewController: UIViewController, context: Context) { }
}

private class ComposeViewViewModel: ObservableObject {
  let savedStateSupport = NavigationSavedStateSupport()
  deinit {
    self.savedStateSupport.clear()
  }
}

struct ComposeViewContainer: View {
  @StateObject private var viewModel = ComposeViewViewModel()

  var body: some View {
    ComposeView(savedStateSupport: viewModel.savedStateSupport)
      .ignoresSafeArea(.keyboard) // Compose has own keyboard handler
  }
}

[!TIP] For more information please check out iOS sample MainViewController.kt and iosApp sample ComposeView.swift

4. Use NavEventNavigator in ViewModel s / @Composable s to trigger navigation actions

// navigate to the destination that the given route leads to
navigator.navigateTo(DetailScreenRoute("some-id"))
// navigate up in the hierarchy
navigator.navigateUp()
// navigate to the previous destination in the backstack
navigator.navigateBack()
// navigate back to the destination belonging to the referenced route and remove all destinations
// in between from the back stack, depending on inclusive the destination
navigator.navigateBackTo<MainScreenRoute>(inclusive = false)

Samples

Roadmap

🟢 Active status

This library is actively maintained and updated with new features and bug fixes.

License

                                 Apache License
                           Version 2.0, January 2004
                        http://www.apache.org/licenses/