State hoisting in Kotlin onSearchChange: (String) -> Unit - android

I am trying to use state hoisting in android
I am new to android development using jetpack compose
onSearchChange: (String) -> Unit,
onCategoryChange: (Category) -> Unit,
onProductSelect: (Product) -> Unit,
composable(Screen.Home.route) { MainPage(navController = navController, searchQuery = "",
productCategories = categories, selectedCategory = Category("","",0),
products = pros, /* what do I write here for the 3 lines above?? :( the onSearch,etc I have an error bc of them */
)}

In addition to the answer, apologies, this is a bit long, as Ill try to share how I design my "state hoisting"
Lets simply start first with the following:
A: First based on the Official Docs
State in an app is any value that can change over time. This is a very
broad definition and encompasses everything from a Room database to a
variable on a class.
All Android apps display state to the user. A few examples of state in
Android apps:
A Snackbar that shows when a network connection can't be established.
A blog post and associated comments.
Ripple animations on buttons that
play when a user clicks them.
Stickers that a user can draw on top of
an image.
B: And personally, for me
"State Hoisting" is part of "State Management"
Now consider a very simple scenario, We have a LoginForm with 2 input fields, and have its basic states like the following
Input will be received from the user and will be stored in a mutableState variable named userName
Input will be received from the user and will be stored in a mutableState variable named password
We have defined 2 requirements above, without doing them, our LoginForm would be stateless
#Composable
fun LoginForm() {
var userName by remember { mutableStateOf("")}
var password by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize()
) {
TextField(
value = userName,
onValueChange = {
userName = it
}
)
TextField(
value = password,
onValueChange = {
password = it
},
visualTransformation = PasswordVisualTransformation()
)
}
}
So far, everything is working but nothing is "Hoisted", their states are handled inside the LoginForm composable.
State Hoisting Part 1: a LoginState class
Now apart from the 2 requirements above, lets add one additional requirement.
Validate user name and password
if login is invalid, show Toast "Sorry invalid login"
if login is valid, show Toast "Hello and Welcome to compose world"
This can be done inside the LoginForm composable, but its better to do the logic handling or any business logic in a separate class, leaving your UI intact independent of it
class LoginState {
var userName by mutableStateOf("")
var password by mutableStateOf("")
fun validateAction() {
if (userName == "Stack" && password == "Overflow") {
// tell the ui to show Toast
} else {
// tell the ui to show Toast
}
}
}
#Composable
fun LoginForm() {
val loginState = remember { LoginState() }
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize()
) {
TextField(
value = loginState.userName,
onValueChange = {
loginState.userName = it
}
)
TextField(
value = loginState.password,
onValueChange = {
loginState.password = it
},
visualTransformation = PasswordVisualTransformation()
)
}
}
Now everything is still working and with additional class where we hoisted our userName and password, and we included a validation functionality, nothing fancy, it will simply call something that will show Toast with a string message depending if the login is valid or not.
State Hoisting Part 2: a LoginViewModel class
Now apart from the 3 requirements above, lets add some more realistic requirements
Validate user name and password
if login is invalid, show Toast "Sorry invalid login"
if login is valid, call a Post login network call and update your database
if Login is success from backend sever show a Toast "Welcome To World"
But when the app is minimized you have to dispose any current network call, no Toast should be shown.
Take note that the codes below won't simply work and not how you would define it in a real situation though.
val viewModel = LoginViewModel()
data class UserLogin(
val userName : String = "",
val password : String = ""
)
class LoginViewModel (
val loginRepository: LoginRepository
) {
private val _loginFlow = MutableStateFlow(UserLogin())
val loginFlow : StateFlow<UserLogin> = _loginFlow
fun validateAction() {
// ommited codes
}
fun onUserNameInput(userName: String) {
}
fun onPasswordInput(password: String) {
}
}
#Composable
fun LoginForm() {
val loginState by viewModel.loginFlow.collectAsStateWithLifecycle()
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize()
) {
TextField(
value = loginState.userName,
onValueChange = {
viewModel.onUserNameInput(it)
}
)
TextField(
value = loginState.password,
onValueChange = {
viewModel.onPasswordInput(it)
},
visualTransformation = PasswordVisualTransformation()
)
}
}
But that's the most top level state hoisting you can do where you would deal with network calls and database.
To summarize:
You don't need to consider hoisting up your mutableStates if its just a simple composable doing simple thing.
But If the logic gets bigger consider using a State Class like the LoginState class to make your UI independent of the business logic.
If you have to perform some network calls, database updates and making sure such use-cases are bound to a LifeCycle, consider using a ViewModel
Another thing to mention but out of topic is when you are hoisting states, there is a thing called scoped re-composition where you want a specific composable to get updated without affecting the others around, it is where you will think your composable designs on how you would handle mutableStates.

To put it into simple terms, state hoisting is having your state variables in the outer most composable possible, this gives you access to said states in multiple functions, better performance, less mess and code reusability!
Hoisting is one of the fundamentals of using Jetpack Compose, example below:
#Composable
fun OuterComposable(
modifier: Modifier = Modifier
) {
// This is your state variable
var input by remember { mutabelStateOf("") }
InnerComposable(
modifier = Modifier,
text = input,
onType = { input = it } // This will asign the string returned by said function to the "input" state variable
)
}
#Composable
fun InnerComposable(
modifier: Modifier = Modifier
text: String,
onType: (String) -> Unit
) {
TextField(
modifier = modifier,
value = text,
onValueChange = { onType(it) } // This returns what the user typed (function mentioned in the previous comment)
)
}
With the code above, you have a text field in the "InnerComposable" function which becomes usable in multiple places with different values.
You can keep adding layers of composables, important thing is to keep the state variable in the outermost function possible.
Hope the explanation was clear! :)

Related

Jetpack Compose: Provide initial value for TextField

I want to achieve the following use case: A payment flow where you start with a screen to enter the amount (AmountScreen) to pay and some other screens to enter other values for the payment. At the end of the flow, a summary screen (SummaryScreen) is shown where you can modify the values inline. For the sake of simplicity we will assume there is only AmountScreen followed by SummaryScreen.
Now the following requirements should be realized:
on AmountScreen you don't loose your input on configuration change
when changing a value in SummaryScreen and go back to AmountScreen (using system back), the input is set to the changed value
AmountScreen and SummaryScreen must not know about the viewModel of the payment flow (PaymentFlowViewModel, see below)
So the general problem is: we have a screen with an initial value for an input field. The initial value can be changed on another (later) screen and when navigating back to the first screen, the initial value should be set to the changed value.
I tried various approaches to achieve this without reverting to Kotlin flows (or LiveData). Is there an approach without flows to achieve this (I am quite new to compose so I might be overlooking something obvious). If flows is the correct approach, would I keep a MutableStateFlow inside the PaymentFlowViewModel for amount instead of a simple string?
Here is the approach I tried (stripped and simplified from the real world example).
General setup:
internal class PaymentFlowViewModel : ViewModel() {
var amount: String = ""
}
#Composable
internal fun NavigationGraph(viewModel: PaymentFlowViewModel = viewModel()) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "AMOUNT_INPUT_SCREEN"
) {
composable("AMOUNT_INPUT_SCREEN") {
AmountInputRoute(
// called when the Continue button is clicked
onAmountConfirmed = {
viewModel.amount = it
navController.navigate("SUMMARY_SCREEN")
},
// apply the entered amount as the initial value for the input text
initialAmount = viewModel.amount
)
}
composable("SUMMARY_SCREEN") {
SummaryRoute(
// called when the amount is changed inline
onAmountChanged = {
viewModel.amount = it
},
// apply the entered amount as the initial value for the input text
amount = viewModel.amount
)
}
}
}
The classes of the AmountScreen look like this:
#Composable
internal fun AmountInputRoute(
initialAmount: String,
onAmountConfirmed: (String) -> Unit
) {
// without the "LaunchedEffect" statement below this fulfils all requirements
// except that the changed value from the SummaryScreen is not applied
val amountInputState: MutableState<String> = rememberSaveable { mutableStateOf(initialAmount) }
// inserting this fulfils the req. that the changed value from SummaryScreen is
// applied, but breaks keeping the entered value on configuration change
LaunchedEffect(Unit) {
amountInputState.value = initialAmount
}
Column {
AmountInputView(
amountInput = amountInputState.value,
onAmountChange = { amountInput ->
amountInputState.value = amountInput
}
)
Button(onClick = { onAmountConfirmed(amountInputState.value) }) {
Text(text = "Continue")
}
}
}
```
I achieved the goal with a quite complicated approach - I would think there are better alternatives out there.
What I tried that did not work: using rememberSaveable passing initialAmount as parameter for inputs. Theoretically rememberSaveable would reinitialize its value when inputs changes, but apparently this does not happen when the composable is only on the back stack and also is not executed when it gets restored from the back stack.
What I implemented that did work:
#Composable
internal fun AmountInputRoute(
initialAmount:String,
onAmountConfirmed: (String) -> Unit
) {
var changedAmount by rememberSaveable {
mutableStateOf<String?>(null)
}
val amountInput by derivedStateOf {
if (changedAmount != null)
changedAmount
else
initialAmount
}
AmountInputView(
amountInput = amountInput,
onContinueClicked = {
onAmountConfirmed(amountInput)
changedAmount = null
},
validAmountChanged = {
changedAmount = it
}
)
}
Any better ideas?

How to start new activity in jetpack compose

I want to start new activity in jetpack compose. So I want to know what is the idiomatic way of doing in jetpack compose. Is any side effect api need to be use or not when opening.
ClickableItemContainer.kt
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun ClickableItemContainer(
rippleColor: Color = TealLight,
content: #Composable (MutableInteractionSource) -> Unit,
clickAction: () -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
CompositionLocalProvider(
LocalRippleTheme provides RippleTheme(rippleColor),
content = {
Surface(
onClick = { clickAction() },
interactionSource = interactionSource,
indication = rememberRipple(true),
color = White
) {
content(interactionSource)
}
}
)
}
MaterialButton.kt
#Composable
fun MaterialButton(
text: String,
spacerHeight: Dp,
onActionClick: () -> Unit
) {
Spacer(modifier = Modifier.height(spacerHeight))
ClickableItemContainer(rippleColor = AquaDarker, content = {
Box(
modifier = Modifier
.background(Aqua)
.fillMaxWidth(),
) {
Text(
text = text,
modifier = Modifier
.align(Alignment.Center),
style = WhiteTypography.h5
)
}
}) {
onActionClick()
}
}
OpenPermissionSetting.kt
#Composable
fun OpenPermissionSetting(router: Router = get()) {
val activity = LocalContext.current as Activity
MaterialButton(
text = "Open Setting",
spacerHeight = 10.dp
) {
activity.startActivity(Intent(this#CurrentClassName,RequiredClassName::class.java)
}
}
So my question is this intent should be use in any Side-effect i.e. LaunchEffect?
LaunchedEffect(key1 = true){
activity.startActivity(Intent(this#CurrentClassName,RequiredClassName::class.java)
}
Thanks
#Composable
fun OpenPermissionSetting(router: Router = get()) {
val activity = LocalContext.current as Activity
MaterialButton(
text = "Open Setting",
spacerHeight = 10.dp
) {
activity.startActivity(Intent(this#CurrentClassName,RequiredClassName::class.java)
}
}
And this one opens new Activity when Button is clicked
var startNewActivity by remember {mutabelStateOf(false)}
#Composable
fun OpenPermissionSetting(router: Router = get()) {
MaterialButton(
text = "Open Setting",
spacerHeight = 10.dp
) {
startActivity = true
}
}
LaunchedEffect(key1 = startActivity){
if(startActivity) {
activity.startActivity(Intent(this#CurrentClassName,RequiredClassName::class.java)
}
}
This one opens activity as soon as your Composable enters composition .Setting a true, false, Unit key or any key doesn't change the fact that code inside LaunchedEffect will be invoked in when it enters composition. You can however change when that code will be run again using key or keys and a conditional statement inside LaunchedEffect.
LaunchedEffect(key1 = true){
activity.startActivity(Intent(this#CurrentClassName,RequiredClassName::class.java)
}
You should understand use cases SideEffect api how they work and ask yourself if this applies to my situation.
SideEffect is good for situations when you only want an action to happen if composition happens successfully. If, even if small chance your state changes fast and current composition is ignored then you shouldn't invoke that action for instance logging composition count is a very good use case for SideEffect function.
Recomposition starts whenever Compose thinks that the parameters of a
composable might have changed. Recomposition is optimistic, which
means Compose expects to finish recomposition before the parameters
change again. If a parameter does change before recomposition
finishes, Compose might cancel the recomposition and restart it with
the new parameter.
When recomposition is canceled, Compose discards the UI tree from the
recomposition. If you have any side-effects that depend on the UI
being displayed, the side-effect will be applied even if composition
is canceled. This can lead to inconsistent app state.
Ensure that all composable functions and lambdas are idempotent and
side-effect free to handle optimistic recomposition.
LaunchedEffect is good for when you wish to have a coroutineScope for animations, scrolling or calling other suspending functions. Another use case for LaunchedEffect is triggering one time actions when the key or keys you set changes.
As in sample above if you set key for LaunchedEffect and check if it's true in a code block you can trigger action only condition is true. LaunchedEffect is also useful when actions that don't require user interactions but a state change happens and only needs to be triggered once.
Executing a callback only when reaching a certain state without user interactions
DisposableEffect is required when you wish to check when your Composable enters and exits composition. onDispose function is also useful for clearing resources or callbacks, sensor register, or anything that needs to be cleared when your Composable exits recomposition.
I would use just LaunchedEffect with the flag let's say val showActivity: Boolean, and the LaunchedEffect function would look like:
#Composable
fun OpenPermissionSetting(viewModel: ViewModel) {
val uiState = viewModel.uiState
MaterialButton(
text = "Open Setting",
spacerHeight = 10.dp
) {
viewModel.onShowActivity()
}
}
LaunchedEffect(showActivity){
if (uiState.showActivity) {
activity.startActivity(...)
viewModel.onShowActivityDone()
}
}
Remember to avoid leaving the flag on true, because its may cause some problems if you have more recompositions :)
Composables are designed to only propagate states down the hierarchy, and propagate actions up the hierarchy. So, no, you shouldn't be launching activities from within the composable. You need to trigger a callback, like your onActionClick: () -> Unit, to the original ComponentActivity where your composables reside (and if this has to go through several nested composables, you'll need to propagste that action all the way up). Then, in your activity, you can direct it to process the actions that were selected. Something like this:
in ComponentActivity:
ClickableItemContainer(
rippleColor = ...,
content = ...,
clickAction = {
startActivity(...)
}
)

reveal parts of text without causing a recomposition?

I'm basically writing a small quiz game with android jetpack compose in which you've got a question displayed and a text field below, I wanted to implement a "hint" (which appears under the text field and shows more and more of the correct answer for every bad answer). After implementing it, it causes a recomposition which selects a new question. Thus a question arrives, is there a way to update just the "hint" part of the screen or is that impossible ? (this might sound stupid but maybe I've missed something and there is a way)
Thanks in advance for every comment :)
You can recompose any part of a UI (without recomposing the entire screen or unrelated parts of the screen) as long as you isolate that section and only apply state changes to those parts that you want updated. The value you apply to your hint text should come from a viewmodel that contains a mutable state variable containing the text you want to update the hint with:
class MyViewModel : ViewModel() {
var hintText = mutableStateOf("")
fun onTextFieldChange(answer: String) {
// Retrieve your hint text from whatever api handles the user's response.
hintText.value = processAnswer(answer)
}
}
#Composable
fun Question() {
val vm = MyViewModel()
var text by remember { mutableStateOf("") }
Column(modifier = Modifier.fillMaxWidth()) {
TextField(
value = text,
onValueChange = {
text = it
vm.onTextFieldChange(it)
},
label = { Text("Label") },
singleLine = true
)
HintText(vm = vm)
}
}
#Composable
fun HintText(
vm: MyViewModel
) {
Text(text = vm.hintText.value)
}

Using viewModel as the single source of truth in jetpack compose

let's say we have a viewModel that has a value called apiKey inside. Contents of this value is received from DataStore in form of a Flow and then, it is exposed as LiveData.
On the other hand we have a Fragment called SettingsFragment, and we are trying to display that apiKey inside a TextField, let the user modify it and save it in DataStore right away.
The solution that I'm currently using is down below, but the issue is that the UI gets very laggy and slow when changes are being made to the text.
My question is that what is the best way to implement this and still have a single source of truth for our apiKey?
class SettingsViewModel() : ViewModel() {
val apiKey = readOutFromDataStore.asLiveData()
fun saveApiKey(apiKey: String) {
viewModelScope.launch(Dispatchers.IO) {
saveToDataStore("KEY", apiKey)
}
}
}
/** SettingsFragment **/
...
#Composable
fun ContentView() {
var text = mViewModel.apiKey.observeAsState().value?.apiKey ?: ""
Column() {
OutlinedTextField(
label = { Text(text = "API Key") },
value = text,
onValueChange = {
text = it
mViewModel.saveApiKey(it)
})
}
}
Don't save the TextField's value in the onValueChange event to the data store on every key press - which is almost certainly slowing you down - especially if you are using the same thread. Use a local state variable and only update the data store when the user either moves the focus elsewhere or they save what's on the screen through some button press. You also need to avoid mixing UI threading with data storage threading which should be on the IO thread. Here is one possible solution:
#Composable
fun ContentViewHandler() {
ContentView(
initialText = viewmodel.getApiKey(),
onTextChange = { text ->
viewmodel.updateApiKey(text)
}
)
}
#Composable
fun ContentView(
initialText: String,
onTextChange: (text: String) -> Unit
) {
var text by remember { mutableStateOf(initialText) }
Column() {
OutlinedTextField(
label = { Text(text = "API Key") },
value = text,
onValueChange = {
text = it
},
modifier = Modifier.onFocusChanged {
onTextChange(text)
}
)
// Or add a button and save the text when clicked.
}
}

Jetpack Compose: Not able to show text in TextField

Recently I'm playing with Jetpack Compose and I noticed that the text may not show up in TextField.
So I have a ViewModel with Flow of ViewState.
In my Compose file, I have something similar to this:
#Composable
internal fun TestScreen() {
val state by viewModel.state.collectAsState()
TestScreen {
viewState = state,
actioner = { ... }
}
}
#Composable
private fun TestScreen(viewState: ViewState, actioner: () -> Unit) {
var name by remember {
mutableStateOf(
TextFieldValue(viewState.name)
)
}
Surface {
....
Column {
....
OutlinedTextField(
...
value = name,
onValueChange = { textFieldValue ->
name = textFieldValue
actioner(...)
}
)
}
}
}
the OutlineTextField will never show what's already inside viewState.name
However, if I change this:
var name by remember {
mutableStateOf(
TextFieldValue(viewState.name)
)
}
To this:
var name = TextFieldValue(viewState.name)
Obviously it could show the value in viewState.name.
According to the Documentation (https://developer.android.com/jetpack/compose/state#state-in-composables) in which it recommends using remember & mutableStateOf to handle the changes.
I'll be very grateful if someone could help me to explain why the code with remember doesn't work but the directly assigned value worked?
EDIT
viewState.name is a String
and I "partially solved" this issue by doing the following:
var name by remember {
mutableStateOf(
TextFieldValue("")
)
}
name = TextFieldValue(viewState.name)
then the name can be shown. But it doesn't look quite right?
remember is used just to ensure that upon recomposition, the value of the mutableStateOf object does not get re-initialised to the initial value.
For example,
#Composable
fun Test1(){
var text by mutableStateOf ("Prev Text")
Text(text)
Button(onClick = { text = "Updated Text" }){
Text("Update The Text")
}
}
would not update the text on button click. This is because button click will change the mutableStateOf text, which will trigger a recomposition. However, when the control reaches the first line of the Composable, it will re-initialise the variable text to "Prev Text".
This is where remember comes in.
If you change the initialisation above to
var text by remember { mutableStateOf ("Prev Text") },
It wil tell compose to track this variable, and "remember" its value, and use it again on recomposition, when the control reaches the initialisation logic again. Hence, remember over there acts as a "guard" that does not let the control reach into the initialisation logic, and returns that latest remembered value of the variable it currently has in store.

Categories

Resources