Разрабатываю экран с формой для ввода данных о пассажирах с помощью Jetpack Compose. Функция формирования данных о пассажирах интегрирована в функцию экрана. При нажатии кнопки «Сохранить» ViewModel проверяет введенные данные и обновляет состояние пассажира с ошибками, если таковые обнаружены. Я хочу, чтобы ViewModel сигнализировал форме пассажира о необходимости прокрутки до первого ошибочного поля при обнаружении ошибок. Однако я не уверен, как реализовать это действие во вложенной функции формы пассажира.
Я попытался передать NeedScroll SharedFlow из ViewModel. В ViewModel я передаю значение Unit в SharedFlow после обновления состояния пассажира. Однако проблема возникает, когда значение из SharedFlow собирается PassengerForm до того, как будет получено новое состояние пассажира, содержащее ошибки.
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,
)
Прошу прощения за некомпилированный код; оно было написано просто как пример, иллюстрирующий ситуацию. Сейчас я пересмотрел его для ясности. Имейте в виду, что в реальном коде MyPassengerForm имеется множество полей, некоторые из которых могут быть за кадром. Если какое-либо поле содержит ошибку, мне нужно будет прокрутить, чтобы увидеть первое ошибочное поле.
Я понимаю, что прямая передача состояния функции не идеальна, но если я не инкапсулирую поток внутри состояния, то MyPassengerForm станет непропускаемым из-за передачи нестабильного параметра в функцию.
Появление необходимости передавать объекты State или Flow в составные функции обычно является индикатором того, что что-то не так. Compose является декларативным и ожидает передачи неизменяемого состояния и передачи событий вверх по дереву составления.
В этом случае ваша модель представления должна отображать needScroll
следующим образом:
private val _needScroll = MutableStateFlow(false)
val needScroll = _needScroll.asStateFlow()
Тогда ваш экран сможет собирать их как обычно, и логическое значение (не поток, не состояние) можно будет передать MyPassengerForm
. Теперь, поскольку все основано на состоянии, MyPassengerForm
просто нужно решить, что делать, когда needScroll
истинно:
if (needScroll) LaunchedEffect(passengerState) {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
val bringIntoView = when {
passengerState.field1.isError -> field1BringIntoView
passengerState.field2.isError -> field2BringIntoView
else -> null
}
bringIntoView?.bringIntoView()
}
}
На самом деле, выполнять LaunchedEffect каждый раз при изменении passengerState
нет необходимости, это нужно только в том случае, если bringIntoView
меняется. Поэтому это следует переместить за пределы 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()
}
}
Хотя это не было тщательно проверено, я думаю, вы даже можете бросить repeatOnLifecycle
.
Теперь методу onSave
вашей модели представления просто нужно установить _needScroll.value = true
вместо того, чтобы выдавать новое значение.
Интересно то, как это значение сбрасывается на false. Это полностью зависит от того, какое поведение вы хотите. Если вы не сбросите его, LaunchedEffect будет выполняться при каждом bringIntoViewRequester
изменении. Это может быть правдой, когда onSave
выполняется снова, и теперь в другом поле есть ошибка. Если ошибок не осталось, bringIntoViewRequester
имеет значение null, поэтому LaunchedEffect
полностью пропускается. Если такое поведение вас устраивает, вам даже не нужна переменная needScroll
и соответствующий поток, так что ее можно полностью удалить.
Если у вас есть другие требования, вы всегда можете предоставить функцию onScroll
в своей модели представления, которую можно вызывать из ваших компонуемых объектов, когда needScroll
необходимо сбросить:
fun onScroll() {
_needScroll.value = false
}
Некоторые дополнительные примечания, не связанные с вашей текущей проблемой:
Вы оборачиваете функции модели представления в несколько лямбад и вызываете Remember. Вместо этого вам следует просто передать ссылку на функцию в ваш составной объект:
MyPassengerForm(
passengerState = passengerState,
needScroll = needScroll,
onChangeField1 = viewModel::onChangeField1,
onChangeField2 = viewModel::onChangeField2,
onSave = viewModel::onSave,
)
В вашей модели представления в какой-то момент вы извлекаете текущее значение потока _passengerState
и сохраняете его в cueState
. В другой момент вы обновляете текущее значение потока и устанавливаете состояние ошибки, которое зависит от более старого состояния потока. Проблема в том, что поток между этими двумя точками можно изменить. Каждую часть потока, необходимую для определения новых значений, всегда следует помещать внутри лямбды update
, например, так:
_passengerState.update {
var newState = it
if (it.field1.value.isEmpty() || it.field2.value.isEmpty()) {
newState = it.copy(
field1 = it.field1.copy(isError = it.field1.value.isEmpty()),
field2 = it.field2.copy(isError = it.field2.value.isEmpty()),
)
_needScroll.value = true
}
newState
}
Спасибо за такой подробный ответ. Могу добавить, что передача функции по ссылке не обеспечивает стабильность этих параметров. Об этом можно прочитать здесь: habr.com/ru/companies/ozontech/articles/742854/… Статья написана на русском языке, но в выделенном абзаце есть англоязычные источники.
Я не уверен, какую мысль вы пытаетесь донести. Стабильность в Compose легко достигается, если вы передаете только неизменяемые объекты (как в моем ответе). Код из вашего вопроса не был стабильным, поскольку вы передали объект MutableState. И, как ни странно, содержимое этого объекта представляло собой Поток, который также нестабилен и может измениться в любой момент. Вот причина первого абзаца моего ответа.
Большая часть вашего кода не компилируется. Поэтому непонятно, что нужно делать. Однако следует отметить одну вещь: первое, что вы делаете в Compose с потоком модели представления, — это собираете его. НЕ передавайте его как параметр. Нечто подобное применимо и к объектам State: передайте только их значение другим компонуемым функциям. Пожалуйста, обновите код в вашем вопросе, чтобы он скомпилировался, и убедитесь, что параметры имеют правильный тип.