Menampilkan Hasil (Berbasis Status yang Dapat Diserialisasi)

Resep ini menunjukkan cara menampilkan hasil dari satu layar ke layar sebelumnya menggunakan pendekatan berbasis status yang dapat bertahan dari perubahan konfigurasi dan penghentian proses dengan memanfaatkan Serialisasi Kotlin dan rememberSerializable.

Cara kerjanya

Contoh ini dibuat berdasarkan ResultEventBus dan memperkenalkan fungsi ekstensi kustom conflateAsSerializableState.

  1. ResultEventBusNavEntryDecorator: NavEntryDecorator yang menyediakan ResultEventBus melalui LocalResultEventBus.
  2. ResultEventBus: ResultEventBus dibuat dan tersedia untuk composable melalui LocalResultEventBus. EventBus ini mengirim dan menerima hasil.
  3. conflateAsSerializableState: Fungsi ekstensi kustom di ResultEventBus yang menggunakan rememberSerializable untuk membuat penampung status, dan ResultEffect untuk memproses hasil baru dan mempertahankannya.
  4. Mengirim hasil: Layar yang menghasilkan hasil memanggil resultBus.sendResult(person) untuk mengirim data kembali.
  5. Mengamati hasil: Layar yang memerlukan hasil memanggil LocalResultEventBus.current.conflateAsSerializableState<Person?>(null) untuk mendapatkan objek State. UI mengamati status ini dan merekomposisi setiap kali hasil berubah.

Pendekatan ini cocok jika hasil harus bertahan dari perubahan konfigurasi dan penghentian proses, sedangkan conflateAsState standar tidak.

Mendukung Jenis Nullable

Fungsi rememberSerializable standar memiliki batasan batas atas generik T : Any, yang mencegah pelestarian langsung jenis nullable.

Untuk mendukung jenis nullable (seperti Person?), conflateAsSerializableState menghindari batasan ini dengan menggabungkan nilai secara internal dalam class @Serializable generik, NullableWrapper:

@Serializable
private data class NullableWrapper<T>(val value: T)

Serialisasi Jenis Kustom

Karena pendekatan ini menggunakan rememberSerializable dari paket androidx.compose.runtime.saveable, class kustom apa pun yang digunakan sebagai hasil (seperti Person) harus ditandai dengan anotasi @Serializable Serialisasi Kotlin:

@Serializable
data class Person(val name: String, val favoriteColor: String)

Fungsi ekstensi conflateAsSerializableState secara otomatis mengambil KSerializer yang sesuai melalui helper serializer<T>() saat menggunakan versi reifikasi:

@Composable
inline fun <reified T> ResultEventBus.conflateAsSerializableState(
    defaultValue: T,
    vararg inputs: Any?,
    configuration: SavedStateConfiguration = SavedStateConfiguration.DEFAULT,
): State<T>
/*
 * Copyright 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.example.nav3recipes.results.common

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel

class HomeViewModel : ViewModel() {
    var person by mutableStateOf<Person?>(null)
}
/*
 * Copyright 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.example.nav3recipes.results.common

import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable

@Serializable
data object Home : NavKey

@Serializable
class PersonDetailsForm : NavKey
/*
 * Copyright 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.example.nav3recipes.results.common

import kotlinx.serialization.Serializable

@Serializable
data class Person(val name: String, val favoriteColor: String)
/*
 * Copyright 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.example.nav3recipes.results.common

import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.dropUnlessResumed
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentGreen

@Composable
fun HomeScreen(
    person: Person?,
    onNext: () -> Unit
) {
    ContentBlue("Hello ${person?.name ?: "unknown person"}") {

        if (person != null) {
            Text("Your favorite color is ${person.favoriteColor}")
        }

        Spacer(Modifier.height(16.dp))
        Button(onClick = dropUnlessResumed(block = onNext)) {
            Text("Tell us about yourself")
        }
    }
}

@Composable
fun PersonDetailsScreen(
    onSubmit: (Person) -> Unit
) {
    ContentGreen("About you") {

        val nameTextState = rememberTextFieldState()
        OutlinedTextField(
            state = nameTextState,
            label = { Text("Please enter your name") }
        )

        val favoriteColorTextState = rememberTextFieldState()
        OutlinedTextField(
            state = favoriteColorTextState,
            label = { Text("Please enter your favorite color") }
        )

        Button(
            onClick = dropUnlessResumed {
                val person = Person(
                    name = nameTextState.text.toString(),
                    favoriteColor = favoriteColorTextState.text.toString()
                )
                onSubmit(person)
            },
            enabled = nameTextState.text.isNotBlank() &&
                    favoriteColorTextState.text.isNotBlank()
        ) {
            Text("Submit")
        }
    }
}
/*
 * Copyright 2026 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.example.nav3recipes.results.serializable

import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSerializable
import androidx.navigation3.runtime.result.ResultEffect
import androidx.navigation3.runtime.result.ResultEventBus
import androidx.savedstate.serialization.SavedStateConfiguration
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.serializer

@Serializable
private data class NullableWrapper<T>(val value: T)

/**
 * Reusable extension function on [ResultEventBus] to provide a single [State] that preserves
 * its value across configuration changes and process death using [rememberSerializable].
 *
 * @param T The type of the result value.
 * @param resultKey The unique key associated with this result.
 * @param defaultValue The default initial value to be remembered and saved when no previously saved state exists.
 * @param inputs A set of inputs such that, when any of them have changed, the state will reset and re-initialize with [defaultValue].
 * @param stateSerializer A [KSerializer] used to serialize and deserialize the state value.
 * @param configuration Optional [SavedStateConfiguration] to customize how the serialization is
 *   handled, such as specifying a custom format (e.g., JSON).
 * @return A [State] containing the current value of the result, which updates dynamically when new results are received.
 */
@Composable
fun <T> ResultEventBus.conflateAsSerializableState(
    resultKey: String,
    defaultValue: T,
    vararg inputs: Any?,
    stateSerializer: KSerializer<T>,
    configuration: SavedStateConfiguration = SavedStateConfiguration.DEFAULT,
): State<T> {
    val wrapperSerializer =
        remember(stateSerializer) { NullableWrapper.serializer(stateSerializer) }

    val wrapper = rememberSerializable(
        inputs = inputs,
        stateSerializer = wrapperSerializer,
        configuration = configuration,
    ) {
        mutableStateOf(NullableWrapper(defaultValue))
    }

    // ResultEffect's internal LaunchedEffect does not restart when the onResult lambda changes.
    // If inputs change, rememberSerializable recreates the savedState instance. Using
    // rememberUpdatedState ensures that the ongoing collection coroutine inside ResultEffect
    // always writes to the latest savedState instance without needing to cancel and restart.
    // https://issuetracker.google.com/531709234
    val currentWrapper = rememberUpdatedState(wrapper)
    ResultEffect<T>(resultKey = resultKey, resultEventBus = this) { result ->
        currentWrapper.value.value = NullableWrapper(result)
    }

    // Return a custom State wrapper rather than using derivedStateOf. Since unwrapping NullableWrapper
    // is O(1), an anonymous State avoids the snapshot read-tracking overhead and extra allocations
    // of derivedStateOf while maintaining a stable reference for the caller.
    return remember(wrapper) {
        object : State<T> {
            override val value: T
                get() = wrapper.value.value
        }
    }
}

/**
 * Reified version of [conflateAsSerializableState] using the class name as the key.
 *
 * @warning Do not use this overload for generic types (e.g., [List], [Map]) because
 * JVM type erasure will cause key collisions. Instead, use the version with an explicit `resultKey`.
 */
@Composable
inline fun <reified T> ResultEventBus.conflateAsSerializableState(
    defaultValue: T,
    vararg inputs: Any?,
    configuration: SavedStateConfiguration = SavedStateConfiguration.DEFAULT,
): State<T> = conflateAsSerializableState(
    resultKey = T::class.toString(),
    defaultValue = defaultValue,
    inputs = inputs,
    stateSerializer = serializer<T>(),
    configuration = configuration,
)
/*
 * Copyright 2026 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.example.nav3recipes.results.serializable

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.runtime.result.LocalResultEventBus
import androidx.navigation3.runtime.result.rememberResultEventBusNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.results.common.Home
import com.example.nav3recipes.results.common.HomeScreen
import com.example.nav3recipes.results.common.Person
import com.example.nav3recipes.results.common.PersonDetailsForm
import com.example.nav3recipes.results.common.PersonDetailsScreen
import com.example.nav3recipes.ui.setEdgeToEdgeConfig

class ResultSerializableActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        setEdgeToEdgeConfig()
        super.onCreate(savedInstanceState)

        setContent {
            Scaffold { paddingValues ->
                val backStack = rememberNavBackStack(Home)
                NavDisplay(
                    backStack = backStack,
                    modifier = Modifier.padding(paddingValues),
                    onBack = { backStack.removeLastOrNull() },
                    entryDecorators = listOf(
                        rememberSaveableStateHolderNavEntryDecorator(),
                        rememberResultEventBusNavEntryDecorator()
                    ),
                    entryProvider = entryProvider {
                        entry<Home> {
                            val resultState = LocalResultEventBus
                                .current
                                .conflateAsSerializableState<Person?>(null)
                            val person = resultState.value
                            HomeScreen(
                                person = person,
                                onNext = { backStack.add(PersonDetailsForm()) }
                            )
                        }
                        entry<PersonDetailsForm> {
                            val resultBus = LocalResultEventBus.current
                            PersonDetailsScreen(
                                onSubmit = { person ->
                                    resultBus.sendResult(result = person)
                                    backStack.removeLastOrNull()
                                }
                            )
                        }
                    }
                )
            }
        }
    }
}