androidkotlinandroid-jetpack-compose

Passing an action to a nested composable


I am developing a screen with a form to input passenger data using Jetpack Compose. The passenger data form function is integrated within the screen function. Upon pressing the "Save" button, the ViewModel verifies the data entered and updates the passenger state with errors if any are found. I wish to have the ViewModel signal the passenger form to scroll to the first erroneous field when errors are detected. However, I am unsure how to implement this action within the nested passenger form function.

I attempted to pass the needScroll SharedFlow from the ViewModel. In the ViewModel, I emit the Unit value to the SharedFlow after the passenger's state is updated. However, the issue arises when the value from the SharedFlow is collected by the PassengerForm before the new passenger state, containing errors, is received.

class MyViewModel : ViewModel() {
    private val _passengerState = MutableStateFlow(PassengerState())
    val passengerState = _passengerState.asStateFlow()

    private val _needScroll = MutableSharedFlow<Unit>()
    val needScroll = _needScroll.asSharedFlow()

    fun onChangeField1(value: String) {
        _passengerState.update {
            it.copy(field1 = it.field1.copy(value = value, isError = false))
        }
    }

    fun onChangeField2(value: String) {
        _passengerState.update {
            it.copy(field2 = it.field2.copy(value = value, isError = false))
        }
    }

    fun onSave() {
        val cueState = _passengerState.value
        if (cueState.field1.value.isEmpty() || cueState.field2.value.isEmpty()) {
            _passengerState.update {
                it.copy(
                    field1 = it.field1.copy(isError = cueState.field1.value.isEmpty()),
                    field2 = it.field2.copy(isError = cueState.field2.value.isEmpty()),
                )
            }
            viewModelScope.launch {
                _needScroll.emit(Unit)
            }
        }
    }
}
@Composable
fun MyScreen(
    viewModel: MyViewModel,
) {
    val passengerState by viewModel.passengerState.collectAsStateWithLifecycle()
    val needScroll = remember { mutableStateOf(viewModel.needScroll) }

    MyPassengerForm(
        passengerState = passengerState,
        needScroll = needScroll,
        onChangeField1 = remember { { viewModel.onChangeField1(it) } },
        onChangeField2 = remember { { viewModel.onChangeField2(it) } },
        onSave = remember { { viewModel.onSave() } },
    )
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MyPassengerForm(
    passengerState: PassengerState,
    needScroll: State<SharedFlow<Unit>>,
    onChangeField1: (String) -> Unit,
    onChangeField2: (String) -> Unit,
    onSave: () -> Unit,
) {
    val lifecycle = LocalLifecycleOwner.current.lifecycle
    val field1BringIntoView = remember { BringIntoViewRequester() }
    val field2BringIntoView = remember { BringIntoViewRequester() }
    LaunchedEffect(key1 = passengerState, key2 = needScroll) {
        lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            needScroll.value.collect {
                val bringIntoView = when {
                    passengerState.field1.isError -> field1BringIntoView
                    passengerState.field2.isError -> field2BringIntoView
                    else -> null
                }
                launch {
                    bringIntoView?.bringIntoView()
                }
            }
        }
    }

    Column(
        modifier = Modifier.verticalScroll(rememberScrollState())
    ) {
        TextField(
            modifier = Modifier.bringIntoViewRequester(field1BringIntoView),
            value = passengerState.field1.value,
            onValueChange = onChangeField1,
        )

        TextField(
            modifier = Modifier.bringIntoViewRequester(field2BringIntoView),
            value = passengerState.field2.value,
            onValueChange = onChangeField2,
        )

        Button(onClick = onSave) {
            Text(text = "Save")
        }
    }
}
data class PassengerState(
    val field1: FieldItem = FieldItem(),
    val field2: FieldItem = FieldItem(),
)

data class FieldItem(
    val value: String = "",
    val isError: Boolean = false,
)

Solution

  • Seeing the need to pass State or Flow objects to Composable functions is usually an indicator that something else is wrong. Compose is declarative and expects immutable state to be passed down and events to be passed up the compose tree.

    In this case your view model should expose the needScroll like so:

    private val _needScroll = MutableStateFlow(false)
    val needScroll = _needScroll.asStateFlow()
    

    Then your screen can collect it as usual and the Boolean value (not the flow, not the state) can be passed on to MyPassengerForm. Now, since everything is state-based, MyPassengerForm just needs to decide what to do when needScroll is true:

    if (needScroll) LaunchedEffect(passengerState) {
        lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            val bringIntoView = when {
                passengerState.field1.isError -> field1BringIntoView
                passengerState.field2.isError -> field2BringIntoView
                else -> null
            }
            bringIntoView?.bringIntoView()
        }
    }
    

    Actually, executing the LaunchedEffect everytime passengerState is changed isn't necessary, you only need that if bringIntoView changes. So that should be moved outside the LaunchedEffect:

    val bringIntoViewRequester = remember(needScroll, passengerState) {
        if (needScroll) when {
            passengerState.field1.isError -> field1BringIntoView
            passengerState.field2.isError -> field2BringIntoView
            else -> null
        } else
            null
    }
    
    if (bringIntoViewRequester != null) LaunchedEffect(bringIntoViewRequester) {
        lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            bringIntoViewRequester.bringIntoView()
        }
    }
    

    Although not thoroughly tested, I think you can even drop the repeatOnLifecycle.

    Your view model's onSave method now just needs to set _needScroll.value = true instead of emitting a new value.

    The interesting part is how that is reset to false. That depends entirely on what behavior you want. If you don't reset it, the LaunchedEffect will be executed everytime bringIntoViewRequester changes. That may be true when onSave is executed again and now another field has an error. When there are no errors left, bringIntoViewRequester is null so the LaunchedEffect is skipped alltogether. If this behavior is fine for you, you do not even need the needScroll variable and the correspondig flow, so that could be removed entirely.

    If you have some other requirements, you can always expose an onScroll function in your view model that can be called from your composables when needScroll should be reset:

    fun onScroll() {
        _needScroll.value = false
    }
    

    Some additional notes unrelated to your current problem: