Skip to content

Instantly share code, notes, and snippets.

@diousk
Created August 7, 2025 22:55
Show Gist options
  • Save diousk/8d309aa3b2220a8992918b028b679333 to your computer and use it in GitHub Desktop.
Save diousk/8d309aa3b2220a8992918b028b679333 to your computer and use it in GitHub Desktop.
nav-result
Here’s a step-by-step plan to improve your in-app navigation result system for Jetpack Compose, focusing on robustness, type safety, and simplicity—without over-engineering.
1. Plan
1. Type Safety:
▫ Avoid unchecked casts by associating result types with result keys.
▫ Use a sealed class or inline class for result keys to prevent accidental collisions.
2. Result Delivery:
▫ Ensure results are delivered only once.
▫ Handle the case where a result is set but no callback is registered (e.g., after process death).
3. API Simplicity:
▫ Keep the API similar to rememberLauncherForActivityResult for familiarity.
▫ Hide internal details from the user.
4. Error Handling:
▫ Log or throw if a result is set for an unknown key or with a mismatched type.
5. Documentation:
▫ Add KDoc for public APIs.
2. Improved Implementation
Below is a single Kotlin file with improvements, following Jetpack Compose and Kotlin best practices.// AppNavResultController.kt
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* Represents a unique key for navigation results.
*/
@JvmInline
value class NavResultKey(val value: String)
/**
* Base interface for navigation keys.
*/
@Serializable
sealed interface AppNavKey
/**
* Navigation key for screens that expect a result.
*/
@Serializable
sealed class AppResultNavKey : AppNavKey {
var resultKey: NavResultKey = NavResultKey("-1")
}
/**
* Controller for navigation and result handling.
*/
open class AppNavController(
defaultBackStack: MutableList<AppNavKey>,
initialPendingResults: Map<NavResultKey, Any> = emptyMap(),
) {
private val mutableNavBackStack: MutableList<AppNavKey> = defaultBackStack
val backStack: List<AppNavKey> get() = mutableNavBackStack
// In-memory callbacks (not persisted)
private val resultCallbacks = mutableMapOf<NavResultKey, (Any) -> Unit>()
// Persisted pending results
val pendingResults = mutableStateMapOf<NavResultKey, Any>().apply {
putAll(initialPendingResults)
}
internal fun registerResultCallback(
key: NavResultKey,
callback: (Any?) -> Unit,
) {
resultCallbacks[key] = callback
}
internal fun unregisterResultCallback(key: NavResultKey) {
resultCallbacks.remove(key)
}
internal fun <T> consumePendingResult(key: NavResultKey, clazz: Class<T>): T? {
val result = pendingResults.remove(key)
return if (clazz.isInstance(result)) {
clazz.cast(result)
} else {
null
}
}
internal fun launchForResult(
resultNavKey: AppResultNavKey,
resultKey: NavResultKey,
) {
resultNavKey.resultKey = resultKey
push(resultNavKey)
}
/**
* Set a result for a given key. If a callback is registered, deliver immediately.
* Otherwise, store in pendingResults for later delivery.
*/
fun setResult(
resultKey: NavResultKey,
result: Any,
) {
val callback = resultCallbacks[resultKey]
if (callback != null) {
callback(result)
unregisterResultCallback(resultKey)
} else {
pendingResults[resultKey] = result
}
}
fun pop() {
if (mutableNavBackStack.isNotEmpty()) {
mutableNavBackStack.removeLastOrNull()
}
}
fun popIf(navKey: AppNavKey) {
if (getLastNavKey() == navKey) {
pop()
}
}
inline fun <reified T : AppNavKey> popIs() {
if (getLastNavKey() is T) {
pop()
}
}
fun getLastNavKey() = mutableNavBackStack.lastOrNull()
open fun push(navKey: AppNavKey) {
mutableNavBackStack.add(navKey)
}
}
/**
* Launcher for navigation with result.
*/
class AppLauncher<I : AppResultNavKey> internal constructor(
private val resultKey: NavResultKey,
private val controller: AppNavController,
) {
fun launch(input: I) {
controller.launchForResult(input, resultKey)
}
}
/**
* Composable to create a launcher for navigation with result.
*
* @param onResult Callback when the result is delivered.
*/
@Composable
inline fun <reified I : AppResultNavKey, reified O> AppNavController.rememberLauncherForResult(
noinline onResult: (O) -> Unit
): AppLauncher<I> {
val resultKey = rememberSaveable { NavResultKey(UUID.randomUUID().toString()) }
val latestOnResult by rememberUpdatedState(onResult)
// Deliver pending result if present
LaunchedEffect(Unit) {
val pendingResult = consumePendingResult(resultKey, O::class.java)
if (pendingResult != null) {
latestOnResult(pendingResult)
}
}
DisposableEffect(resultKey) {
registerResultCallback(resultKey) { result ->
if (result is O) {
latestOnResult(result)
} else {
// Optionally log or throw for type mismatch
}
}
onDispose {
unregisterResultCallback(resultKey)
}
}
return remember { AppLauncher(resultKey, this) }
}
3. Usage Exampleval avatarLauncher = rootNavController.rememberLauncherForResult<Route.MediaSelector, List<MediaItem>> { result ->
if (result.isNotEmpty()) {
loading.show(scope) {
viewModel.updateAvatar(result.single().uriString.toUri())
}
}
}
And in your destination:controller.setResult(resultKey, selectedMedia)
controller.popIs<Route.MediaSelector>()
4. Improvements Summary
• Type safety: Uses NavResultKey and class checks to avoid unchecked casts.
• Result delivery: Ensures results are delivered only once, and only to the correct callback.
• API simplicity: Usage remains familiar and concise.
• Error handling: Type mismatches are detected (optionally log/throw).
• No over-engineering: No unnecessary abstractions or complexity.
Let me know if you want further refinements or have specific edge cases in mind!
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment