forcing a recomposition (Android compose) - android

firstly, the code:
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import com.example.saveandloadusername.ui.theme.SaveAndLoadUserNameTheme
import java.io.File
import java.io.IOException
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SaveAndLoadUserNameTheme {
Surface(color = MaterialTheme.colors.background) {
MainScreen(baseContext)
}
}
}
}
}
#Composable
fun MainScreen(context: Context) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
)
{
var name by remember { mutableStateOf("")}
if(checkIfNameIsEmpty(readNameFromInternalStorage(context))) {
Text(text="Hello, give me your name :)")
} else {
Text(text="welcome back ${readNameFromInternalStorage(context)}")
}
Spacer(modifier = Modifier
.height(10.dp)
.fillMaxWidth())
OutlinedTextField(
value=name,
onValueChange={ name = it },
label={Text(text="Name")},
singleLine = true,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words)
)
Spacer(modifier = Modifier
.height(10.dp)
.fillMaxWidth())
Button(
onClick = {
name = name.replace(" ", "".replace("\n", ""))
if(name == "") {
Toast.makeText(context, "name invalid", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "name (${name}) saved :D", Toast.LENGTH_SHORT).show()
saveNameToInternalStorage(name, context)
}
},
)
{
Text(text="save")
}
Spacer(modifier = Modifier
.height(10.dp)
.fillMaxWidth())
Button(
onClick = {
if(checkIfNameIsEmpty(readNameFromInternalStorage(context))) {
Toast.makeText(context, "you need to give me a name first ;)", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(
context,
"the name is: '${readNameFromInternalStorage(context)}'",
Toast.LENGTH_SHORT
).show()
}
},
)
{
Text(text="check")
}
Spacer(modifier = Modifier
.height(10.dp)
.fillMaxWidth())
Button(onClick = { cleanNameData(context)}) {
Text("Remove name")
}
}
}
private fun saveNameToInternalStorage(name: String, context: Context): Boolean {
return try {
context.applicationContext.openFileOutput("name.txt", MODE_PRIVATE).use { stream ->
stream.flush()
stream.write(name.toByteArray())
Log.i("SAVE_STATE","name ($name) written as ${name.toByteArray()}")
}
return true
} catch(e: IOException) {
e.printStackTrace()
false
}
}
private fun readNameFromInternalStorage(context: Context): String {
val file = File(context.filesDir, "name.txt")
return if (file.exists()) {
val contentOfFile = file.readBytes().decodeToString()
Log.i("CONTENTTT", contentOfFile)
contentOfFile
} else {
""
}
}
private fun checkIfNameIsEmpty(name: String): Boolean {
return name.isEmpty()
}
private fun cleanNameData(context: Context) {
context.applicationContext.openFileOutput("name.txt", MODE_PRIVATE).use { stream ->
stream.flush()
Log.i("cleanNameData", "Name Removed from memory")
Toast.makeText(context, "Name removed", Toast.LENGTH_SHORT).show()
}
}
I've created a little app to get to know android compose and I've stumbled upon a problem that I cannot solve, the Text (that is above the TextField) doesn't update after the buttons "remove name" or "save" are pressed, the text only updates when something changes in the textBox, is there a way to force a recomposition of that text manually?
Any help appreciated :)

Firstly, if all that you need to do is display that name over there, then why take the hassle of storing it to storage, then reading back? Suggestion: You could just use the name parameter as the value of the Text, and upon modification, you could just store the new name to the storage, and if the transaction succeeds, then update the variable. It will trigger the necessary recomposition.
Next up, the reason why the text does not update is because the method you are using to get the name from storage returns a primitive type, not a LiveData. Hence, instead of manually triggering a recompostion, if you are implementing the method yourself, try reading about LiveData first. It should not be difficult to implement.
HOWEVER (not recommended), if all you want is a method to manually re-trigger a composition, then here it is.
As you might know, Compose uses MutableState objects to observe data, which you seem to be very familiar with, since you are already using that. Hence, all you need to do is just add a 'dummy' variable of type MutableState, and then modify it from the onClicks of the buttons in concern. Also, you have to give Compose a message that the dummy variable is being read in the Text, so it would trigger recompostions.
#Composable
fun MainScreen(context: Context) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
)
{
var name by remember { mutableStateOf("")}
var dummy by mutableStateOf(false) // don't even need to remember, long as it compiles
if(checkIfNameIsEmpty(readNameFromInternalStorage(context))) {
dummy
Text(text="Hello, give me your name :)")
} else {
dummy
Text(text="welcome back ${readNameFromInternalStorage(context)}")
}
Spacer(modifier = Modifier
.height(10.dp)
.fillMaxWidth())
OutlinedTextField(
value=name,
onValueChange={ name = it },
label={Text(text="Name")},
singleLine = true,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words)
)
Spacer(modifier = Modifier
.height(10.dp)
.fillMaxWidth())
Button(
onClick = {
dummy = !dummy // modify to trigger recomposition
name = name.replace(" ", "".replace("\n", ""))
if(name == "") {
Toast.makeText(context, "name invalid", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "name (${name}) saved :D", Toast.LENGTH_SHORT).show()
saveNameToInternalStorage(name, context)
}
},
)
{
Text(text="save")
}
Spacer(modifier = Modifier
.height(10.dp)
.fillMaxWidth())
Button(
onClick = {
dummy = !dummy // The same here
if(checkIfNameIsEmpty(readNameFromInternalStorage(context))) {
Toast.makeText(context, "you need to give me a name first ;)", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(
context,
"the name is: '${readNameFromInternalStorage(context)}'",
Toast.LENGTH_SHORT
).show()
}
},
)
{
Text(text="check")
}
Spacer(modifier = Modifier
.height(10.dp)
.fillMaxWidth())
Button(onClick = {
dummy = !dummy //That's all
cleanNameData(context)
}) {
Text("Remove name")
}
}
}
Yeah this oughta do it.
Hey also, you do not need to paste the entire codebase. Just provide the necessary bits and we'll ask for more if required. Thanks,

In the below code block, you're not using name variable anywhere
if(checkIfNameIsEmpty(readNameFromInternalStorage(context))) {
Text(text="Hello, give me your name :)")
} else {
Text(text="welcome back ${readNameFromInternalStorage(context)}")
}
So, changing name value won't have any effect on recomposition. If you really want, you need to (sort of) have remember{readNameFromInternalStorage(context)}
in order to have recomposition to have effect on Text.

Related

How do I use the UiState Variable to pass API Calls from 1 screen to the other?

I am currently trying to write an Android Application that makes API calls to retrieve the estimated arrival timings of the incoming buses. The ViewModel files shows me using my Bus Repository to make the API Call, where listResult contains the data I want.
How can I pass the API Call I made to one of my Android Application's screens? I'm supposed to use the UiState Variable to do so right? Thank you.
The Pastebin contains my code as well if it's easier to see there!!
AppViewModel.kt
https://pastebin.com/qPVrDF9i
package com.example.busexpress.ui.screens
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.example.busexpress.BusExpressApplication
import com.example.busexpress.data.SingaporeBusRepository
import com.example.busexpress.network.SingaporeBus
import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.io.IOException
/**
* [AppViewModel] holds information about a cupcake order in terms of quantity, flavor, and
* pickup date. It also knows how to calculate the total price based on these order details.
*/
class AppViewModel(private val singaporeBusRepository: SingaporeBusRepository): ViewModel() {
/** The mutable State that stores the status of the most recent request */
var busUiState: BusUiState by mutableStateOf(BusUiState.Loading) // Loading as Default Value
// Setter is private to protect writes to the busUiState
private set
/**
* Call init so we can display status immediately.
*/
init {
getBusTimings(null)
}
fun getBusTimings(userInput: String?) {
// Determine if UserInput is a BusStopCode
var busStopCode: String?
var busServiceNumber: String?
val userInputLength = userInput?.length ?: 0
if (userInputLength == 5) {
// Bus Stop Code
busStopCode = userInput
busServiceNumber = null
}
else {
// Bus Service Number
busStopCode = null
busServiceNumber = userInput
}
// Launch the Coroutine using a ViewModelScope
viewModelScope.launch {
busUiState = BusUiState.Loading
// Might have Connectivity Issues
busUiState = try {
// Within this Scope, use the Repository, not the Object to access the Data, abstracting the data within the Data Layer
val listResult: SingaporeBus = singaporeBusRepository.getBusTimings(
busServiceNumber = busServiceNumber,
busStopCode = busStopCode
)
// Assign results from backend server to busUiState {A mutable state object that represents the status of the most recent web request}
BusUiState.Success(timings = listResult)
}
catch (e: IOException) {
BusUiState.Error
}
catch (e: HttpException) {
BusUiState.Error
}
}
}
// Factory Object to retrieve the singaporeBusRepository and pass it to the ViewModel
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as BusExpressApplication)
val singaporeBusRepository = application.container.singaporeBusRepository
AppViewModel(singaporeBusRepository = singaporeBusRepository)
}
}
}
// private val _uiState = MutableStateFlow(AppUiState(65199))
// val uiState: StateFlow<AppUiState> = _uiState.asStateFlow()
}
// Simply saving the UiState as a Mutable State prevents us from saving the different status
// like Loading, Error, and Success
sealed interface BusUiState {
data class Success(val timings: SingaporeBus) : BusUiState
// The 2 States below need not set new data and create new objects, which is why an object is sufficient for the web response
object Error: BusUiState
object Loading: BusUiState
// Sealed Interface used instead of Interface to remove Else Branch
}
Example: DefaultScreen.kt
https://pastebin.com/UiZPwZHG
package com.example.busexpress.ui.screens
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.example.busexpress.BusExpressApp
import com.example.busexpress.BusExpressScreen
import com.example.busexpress.R
import com.example.busexpress.network.SingaporeBus
import com.example.busexpress.ui.component.BusStopComposable
#Composable
fun DefaultScreen(
busUiState: BusUiState,
modifier: Modifier = Modifier,
appViewModel: AppViewModel = viewModel(),
// navController: NavController
) {
// Mutable State for User Input
var userInput = remember {
mutableStateOf(TextFieldValue(""))
}
Column(
modifier = modifier
.fillMaxWidth()
.padding(16.dp),
) {
// Search Field for Bus Stop or Bus Numbers
SearchView(
label = R.string.search_field_instructions,
state = userInput,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Search
),
onKeyboardSearch = {
appViewModel.getBusTimings(userInput.value.text)
// navController.navigate(BusExpressScreen.Search.name)
}
)
val busArrivalsJson = appViewModel.getBusTimings(userInput.value.text)
when(busUiState) {
is BusUiState.Success -> ResultScreen(busUiState = busUiState, busArrivalsJSON = busArrivalsJson)
is BusUiState.Loading -> LoadingScreen()
is BusUiState.Error -> ErrorScreen()
else -> ErrorScreen()
}
}
}
#Composable
fun LoadingScreen(modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.fillMaxSize()
) {
Image(
modifier = Modifier.size(200.dp),
painter = painterResource(id = R.drawable.loading_img),
contentDescription = stringResource(R.string.loading_flavor_text)
)
}
}
#Composable
fun ErrorScreen(modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier.fillMaxSize()
) {
Text(text = stringResource(R.string.loading_failed_flavor_text))
}
}
/**
* The home screen displaying result of fetching photos.
*/
#Composable
fun ResultScreen(
busUiState: BusUiState,
busArrivalsJSON: SingaporeBus
modifier: Modifier = Modifier,
) {
// Results of Search
BusStopComposable(
busArrivalsJSON = busArrivalsJSON,
modifier = modifier
)
// Box(
// contentAlignment = Alignment.Center,
// modifier = modifier.fillMaxSize()
// ) {
// Text(busUiState.toString())
// }
}
#Composable
fun SearchView(
#StringRes label: Int,
state: MutableState<TextFieldValue>,
modifier: Modifier = Modifier,
keyboardOptions: KeyboardOptions,
onKeyboardSearch: () -> Unit,
) {
Column() {
TextField(
value = state.value,
onValueChange = {value ->
state.value = value
},
label = {
if (state.value == TextFieldValue("")) {
Text(
stringResource(id = label),
modifier = Modifier
.fillMaxWidth(),
style = MaterialTheme.typography.h6
)
}
},
modifier = modifier
.fillMaxWidth(),
singleLine = true,
// Search Icon at the Start for Aesthetics
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
modifier = Modifier.padding(10.dp)
)
},
// Cancel Button to delete all Input
trailingIcon = {
// Icon appears iif the Search Field is not Empty
if (state.value != TextFieldValue("")) {
IconButton(onClick = {
// Clear the Search Field
state.value = TextFieldValue("")
}) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Delete all User Input",
modifier = Modifier.padding(10.dp)
)
}
}
},
keyboardActions = KeyboardActions(
onSearch = { onKeyboardSearch() }
),
keyboardOptions = keyboardOptions,
shape = RoundedCornerShape(25)
)
Row() {
Spacer(modifier = modifier.weight(3f))
// Button for User to Click to begin Search
Button(
onClick = {
// TODO Pass the User Query to the Search Function
onKeyboardSearch()
},
modifier = modifier
.align(Alignment.CenterVertically)
.padding(2.dp)
.weight(1f),
) {
Text(text = stringResource(R.string.search_button_flavor_text))
}
}
}
}
BusApi.kt (Contains the function to make the API Call)
https://pastebin.com/miAt8x8H
package com.example.busexpress.network
import com.example.busexpress.LTA_API_SECRET_KEY
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.Query
interface BusApiService {
/**
* Function to get JSON Objects from URI by specifying Type of Request and Endpoint like "/photos" a URL of sorts
*/
// 1. Returns Bus Timings for Bus Stop and/or Service Number
#Headers(
"accept: application/json",
"AccountKey: $LTA_API_SECRET_KEY"
)
#GET("BusArrivalv2")
suspend fun getTimingsOfBusStop(
#Query("BusStopCode") BusStopCode: String? = null,
#Query("ServiceNo") ServiceNo: String? = null
): SingaporeBus
// 2. Returns the details for all the Bus Stops in Singapore
#Headers(
"accept: application/json",
"AccountKey: $LTA_API_SECRET_KEY"
)
#GET("BusStops")
suspend fun getDetailsOfBusStop(): BusStop
}
You can use SharedViewModel to share the data between the Fragments hosted by a common parent activity or between parent activity and its child fragments as per your need.
For eg, You can declare a MutableStateFlow variable in the SharedViewModel, set its value when you receive response from API call (which you trigger from one Fragment or parent activity) and then collect the value of that flow via the same SharedViewModel inside child Fragment and update the UI accordingly.
Refer https://developer.android.com/codelabs/basic-android-kotlin-training-shared-viewmodel#0

Multiple ModalBottomSheet in Compose not updated when state changes

I have implemented 2 bottom sheets in the ModalBottomSheetLayout, both bottom sheets has a list of item checkable with checkbox.
The state of the screen is managed by the viewModel and when the selection changes is invoked a function that copies the state with the new value of the selected text.
When the bottom sheet opens the selection is correct but when I click to change the selection, the bottomsheet is not recomposed and the selection does not change, but in the main screen the state change is correctly read and the value is updated.
Here my code:
MainScreen:
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
#ExperimentalMaterialApi
#Composable
fun MainScreen(
viewModel: MainViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
) {
val screenState = viewModel.screenState
val scope = rememberCoroutineScope()
val bottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden
)
var bottomSheetContent: (#Composable () -> Unit)? by remember {
mutableStateOf(null)
}
ModalBottomSheetLayout(
sheetState = bottomSheetState,
sheetContent = {
Box(
modifier = Modifier.defaultMinSize(minHeight = 1.dp)
) {
bottomSheetContent?.let { it() }
}
}
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Text(text = "First BottomSheet", style = MaterialTheme.typography.h6)
Text(
text = "Selected: ${screenState.selectedTextFromFirstBottomSheet}",
Modifier.padding(16.dp)
)
Button(onClick = {
bottomSheetContent = {
FirstBottomSheet(
selectedText = screenState.selectedTextFromFirstBottomSheet,
onSelected = { text ->
viewModel.onEvent(
MainScreenEvent.OnFirstBottomSheetSelectedTextChanged(text)
)
},
textList = screenState.firstBottomSheetTextList
)
}
scope.launch {
bottomSheetState.show()
}
}, modifier = Modifier.padding(16.dp)) {
Text(text = " Open First BottomSheet")
}
Text(text = "Second BottomSheet", style = MaterialTheme.typography.h6)
Text(
text = "Selected: ${screenState.selectedTextFromSecondBottomSheet}",
Modifier.padding(16.dp)
)
Button(
onClick = {
bottomSheetContent = {
SecondBottomSheet(
selectedText = screenState.selectedTextFromSecondBottomSheet,
onSelected = { text ->
viewModel.onEvent(
MainScreenEvent.OnSecondBottomSheetSelectedTextChanged(text)
)
},
textList = screenState.secondBottomSheetTextList
)
}
scope.launch {
bottomSheetState.show()
}
}, modifier = Modifier
.padding(16.dp)
) {
Text(text = " Open Second BottomSheet")
}
}
}
}
ViewModel:
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
class MainViewModel : ViewModel(){
var screenState by mutableStateOf(MainScreenState())
fun onEvent(event: MainScreenEvent){
when(event){
is MainScreenEvent.OnFirstBottomSheetSelectedTextChanged -> {
screenState = screenState.copy(
selectedTextFromFirstBottomSheet = event.text
)
}
is MainScreenEvent.OnSecondBottomSheetSelectedTextChanged -> {
screenState = screenState.copy(
selectedTextFromSecondBottomSheet = event.text
)
}
}
}
}
ScreenState
data class MainScreenState(
val selectedTextFromFirstBottomSheet: String = "First Text b1",
val selectedTextFromSecondBottomSheet: String = "Third Text b2",
val firstBottomSheetTextList: List<String> = listOf(
"First Text b1",
"Second Text b1",
"Third Text b1",
"Fourth Text b1",
"Five Text b1"
),
val secondBottomSheetTextList: List<String> = listOf(
"First Text b2",
"Second Text b2",
"Third Text b2",
"Fourth Text b2",
"Five Text b2"
)
)
Screen Event
sealed class MainScreenEvent(){
data class OnFirstBottomSheetSelectedTextChanged(val text: String): MainScreenEvent()
data class OnSecondBottomSheetSelectedTextChanged(val text: String): MainScreenEvent()
}
First Bottom Sheet
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.Checkbox
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
#Composable
fun FirstBottomSheet(
selectedText: String,
textList: List<String>,
onSelected: (text: String) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
textList.forEach { text ->
Row(modifier = Modifier
.fillMaxWidth()
.toggleable(
value = selectedText == text,
role = Role.Checkbox,
onValueChange = { isSelected ->
if (isSelected) {
onSelected(text)
}
}
)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = text, modifier = Modifier.weight(1f))
Checkbox(checked = selectedText == text, onCheckedChange = null)
}
}
}
}
Second Bottom Sheet
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.Checkbox
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
#Composable
fun SecondBottomSheet(
selectedText: String,
textList: List<String>,
onSelected: (text: String) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
textList.forEach { text ->
Row(modifier = Modifier
.fillMaxWidth()
.toggleable(
value = selectedText == text,
role = Role.Checkbox,
onValueChange = { isSelected ->
if (isSelected) {
onSelected(text)
}
}
)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically) {
Text(text = text, modifier = Modifier.weight(1f))
Checkbox(checked = selectedText == text, onCheckedChange = null)
}
}
}
}
Thanks for your help!
I copied and pasted your code. The only changes I made were:
Remove this line from MainScreen
val screenState = viewModel.screenState
Access the state directly.
Text(
text = "Selected: ${viewModel.screenState.selectedTextFromFirstBottomSheet}",
Modifier.padding(16.dp)
)
Button(onClick = {
bottomSheetContent = {
FirstBottomSheet(
selectedText = viewModel.screenState.selectedTextFromFirstBottomSheet,
onSelected = { text ->
viewModel.onEvent(
MainScreenEvent.OnFirstBottomSheetSelectedTextChanged(text)
)
},
textList = viewModel.screenState.firstBottomSheetTextList
)
}
scope.launch {
bottomSheetState.show()
}
}, modifier = Modifier.padding(16.dp)) {
Text(text = " Open First BottomSheet")
}
Boom! It worked :)
My understanding is: you're creating a variable containing the value of a state, but you're not listening to the state changes, so the Compose doesn't know the state has changed, therefore the recomposition doesn't happen. The by keyword in your state declaration is a property delegate which set/get the current value of state, but not register the composable to react to these changes.
There are another solutions you can use to observe the state without repeat viewModel.screenState:
Using derivedStateOf:
val screenState by remember {
derivedStateOf {
viewModel.screenState
}
}
Changing the screenState declaration.
// Using "=" instead of "by"
var screenState = mutableStateOf(MainScreenState())
and then use screenState.value to set/get the state value.
And in the screen, use like below:
val screenState = viewModel.screenState
I had the exact same use case in my app but everything was working fine until I updated some of the compose libraries.

Toast not displaying on compose activity

so probably dumb question for someone with experience with Jetpack Compose, but for some reason my Toast are not displaying. Here's the snippet for it. I'm sending 3 toasts with .show() in different parts of the onCreate method, and still nothing happens. I also tried using LocalContext.current as context inside a #Composable function but same result, nothing is displayed. I see online many different examples, even videos on youtube in which this exact same could should run. Anybody knows why?
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Toast.makeText(this, " ASDASDASD ", Toast.LENGTH_LONG).show()
setContent {
Toast.makeText(this, " ASDASDASD ", Toast.LENGTH_LONG).show()
MyApplicationTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
Toast.makeText(this, " ASDASDASD ", Toast.LENGTH_LONG).show()
Greeting("Android")
}
}
}
}
A sample code to show Toast.
setContent {
val context = LocalContext.current
MyAppTheme {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize(),
) {
TextButton(
onClick = {
Toast.makeText(context, "Toast", Toast.LENGTH_LONG).show()
},
) {
Text(text = "Show Toast")
}
}
}
}
Let me break down the important things to look into in this code.
Toast should not be part of the composable code, rather it should be part of the side effect code.
(e.g. onClick(), LaunchedEffect, DisposableEffect, etc.)
Get the context inside the composable code using LocalContext.current. Note that you have to store it in a variable as the code inside onClick() does not have composable scope. (As mentioned above).
Composables recompose very frequently depending on the UI changes, we don't want to show a Toast every time the composable recomposes.
Refer to Side Effects Docs to understand it better.
You need to use a scaffold for displaying toasts
Code:
class SimpleFormActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val scaffoldState = rememberScaffoldState()
var inputFieldState by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
Scaffold(
modifier = Modifier.fillMaxSize(),
scaffoldState = scaffoldState
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(50.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
TextField(
value = inputFieldState,
label = {
Text(text = "Enter your name")
},
onValueChange = {
inputFieldState = it
},
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(20.dp))
Button(
onClick = {
scope.launch {
scaffoldState.snackbarHostState.showSnackbar(
inputFieldState,
duration = SnackbarDuration.Short
)
}
}) {
Text(text = "Click")
}
}
}
}
}
}
Demo:

How can I request permissions in Jetpack Compose?

I'm doing an application with Jetpack Compose and Kotlin. It is an app to locate an android device. I need to implement run time permissions to follow the jetpack filosofy.
I have a menu page where there is a switch that when is activate saves the location of the device but just activate the switch it is necessary to request permissions "fine_location", "coarse_location" and "back_groundlocation". This is my menu.kt code:
LazyColumn {
item {
Row {
Box(
modifier =
Modifier.fillMaxWidth(0.8f)
)
{
Text(
color = Color.Black,
text = stringResource(R.string.location_gps),
fontSize = 30.sp,
modifier = Modifier.padding(20.dp)
)
}
Box(
modifier =
Modifier.fillMaxSize(),
contentAlignment = Alignment.CenterEnd
) {
Switch(
checked = checkedStateGps.value,
onCheckedChange = { checkedStateGps.value = it },
modifier = Modifier
.padding(20.dp),
colors= SwitchDefaults.colors(
//color of switches
checkedThumbColor = Color(0xFF00CC99),
checkedTrackColor = Color(0xFF7BB661),
uncheckedThumbColor = Color(0xFF83010B),
uncheckedTrackColor = Color(0xFFBB4C4C)
)
)
}
}
I'd want to know how can I implement accompanist permissions for this.
In Compose you can use Google's Accompanist library to request permission at runtime, just with PermissionRequired.
This is an example with camera permission but you can request any permissions you have in your manifest file as android.Manifest.permission.*
var doNotShowRationale by rememberSaveable { mutableStateOf(false) }
val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
PermissionRequired(
permissionState = cameraPermissionState,
permissionNotGrantedContent = {
if (doNotShowRationale) {
Text("Feature not available")
} else {
Column {
Text("The camera is important for this app. Please grant the permission.")
Spacer(modifier = Modifier.height(8.dp))
Row {
Button(onClick = { cameraPermissionState.launchPermissionRequest() }) {
Text("Ok!")
}
Spacer(Modifier.width(8.dp))
Button(onClick = { doNotShowRationale = true }) {
Text("Nope")
}
}
}
}
},
permissionNotAvailableContent = {
Column {
Text(
"Camera permission denied. See this FAQ with information about why we " +
"need this permission. Please, grant us access on the Settings screen."
)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = navigateToSettingsScreen) {
Text("Open Settings")
}
}
}
) {
Text("Camera permission Granted")
}
Request camera permission sample:
implementation "com.google.accompanist:accompanist-permissions:0.20.0"
The permission APIs are currently experimental and they could change
at any time. All of the APIs are marked with the
#ExperimentalPermissionsApi annotation.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionRequired
import com.google.accompanist.permissions.rememberPermissionState
import pe.edu.upc.permissionscompose.ui.theme.PermissionsComposeTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
PermissionsComposeTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
FeatureThatRequiresCameraPermission()
}
}
}
}
}
#OptIn(ExperimentalPermissionsApi::class)
#Composable
fun FeatureThatRequiresCameraPermission() {
var doNotShowRationale by rememberSaveable {
mutableStateOf(false)
}
val cameraPermissionState =
rememberPermissionState(permission = android.Manifest.permission.CAMERA)
val context = LocalContext.current
PermissionRequired(
permissionState = cameraPermissionState,
permissionNotGrantedContent = {
if (doNotShowRationale) {
Text("Feature not available")
} else {
PermissionNotGrantedUI(
onYesClick = {
cameraPermissionState.launchPermissionRequest()
}, onCancelClick = {
doNotShowRationale = true
})
}
},
permissionNotAvailableContent = {
PermissionNotAvailableContent(
onOpenSettingsClick = { context.openSettings() })
},
content = {
Text("Camera Permission Granted")
}
)
}
#Composable
fun PermissionNotAvailableContent(onOpenSettingsClick: () -> Unit) {
Column {
Text("Camera permission denied.")
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { onOpenSettingsClick() }) {
Text("Open settings")
}
}
}
#Composable
fun PermissionNotGrantedUI(onYesClick: () -> Unit, onCancelClick: () -> Unit) {
Column {
Text("Camera is important for this app. Please grant ther permission.")
Spacer(modifier = Modifier.height(8.dp))
Row {
Button(onClick = {
onYesClick()
}) {
Text("Yes")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = {
onCancelClick()
}) {
Text("Cancel")
}
}
}
}

LazyColumn is not keeping the state of items when scrolling

Following the Pathway code labs from Google about Jetpack compose, I was trying out this code
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.codelab.basics.ui.BasicsCodelabTheme
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApp {
MyScreenContent()
}
}
}
}
#Composable
fun MyApp(content: #Composable () -> Unit) {
BasicsCodelabTheme {
Surface(color = Color.Yellow) {
content()
}
}
}
#Composable
fun MyScreenContent(names: List<String> = List(1000) { "Hello Android #$it" }) {
val counterState = remember { mutableStateOf(0) }
Column(modifier = Modifier.fillMaxHeight()) {
NameList(names, Modifier.weight(1f))
Counter(
count = counterState.value,
updateCount = { newCount ->
counterState.value = newCount
}
)
}
}
#Composable
fun NameList(names: List<String>, modifier: Modifier = Modifier) {
LazyColumn(modifier = modifier) {
items(items = names) { name ->
Greeting(name = name)
Divider(color = Color.Black)
}
}
}
#Composable
fun Greeting(name: String) {
var isSelected by remember { mutableStateOf(false) }
val backgroundColor by animateColorAsState(if (isSelected) Color.Red else Color.Transparent)
Text(
text = "Hello $name!",
modifier = Modifier
.padding(24.dp)
.background(color = backgroundColor)
.clickable(onClick = { isSelected = !isSelected })
)
}
#Composable
fun Counter(count: Int, updateCount: (Int) -> Unit) {
Button(
onClick = { updateCount(count + 1) },
colors = ButtonDefaults.buttonColors(
backgroundColor = if (count > 5) Color.Green else Color.White
)
) {
Text("I've been clicked $count times")
}
}
#Preview("MyScreen preview")
#Composable
fun DefaultPreview() {
MyApp {
MyScreenContent()
}
}
I have noticed that LazyColumn will recompose the items whenever they become Visible on the screen (intended behaviour!) however, the Local state of Greeting widget is completely lost!
I believe this is a bug in Compose, Ideally the composer should consider the remember cached state. Is there an elegant way to fix this?
Thanks in advance!
[Update]
Using rememberSaveable {...} gives the power to survive the android change of configuration as well as the scrollability
Docs
Remember the value produced by init.
It behaves similarly to remember, but the stored value will survive the activity or process recreation using the saved instance state mechanism (for example it happens when the screen is rotated in the Android application).
The code now is more elegant and shorter, we don't even need to hoist the state, it can be kept internal. The only thing I am not so sure of now with using rememberSaveable is if there will be any performance penalties when the list grows bigger and bigger, say 1000 items.
#Composable
fun Greeting(name: String) {
val isSelected = rememberSaveable { mutableStateOf(false) }
val backgroundColor by animateColorAsState(if (isSelected.value) Color.Red else Color.Transparent)
Text(
text = "Hello $name! selected: ${isSelected.value}",
modifier = Modifier
.padding(24.dp)
.background(color = backgroundColor)
.clickable(onClick = {
isSelected.value = !isSelected.value
})
)
}
[Original Answer]
Based on #CommonWare's answer The LazyColumn will dispose the composables along with their states when they are off-screen this means when LazyColumn recomposes the Compsoables again it will have fresh start state. To fix this issue all that has to be done is to hoist the state to the consumer's scope, LazyColumn in this case.
Also we need to use mutableStateMapOf() instead of MutableMapOf inside the remember { ... } lambda or Compose-core engine will not be aware of this change.
So far here is the code:
#Composable
fun NameList(names: List<String>, modifier: Modifier = Modifier) {
val selectedStates = remember {
mutableStateMapOf<Int, Boolean>().apply {
names.mapIndexed { index, _ ->
index to false
}.toMap().also {
putAll(it)
}
}
}
LazyColumn(modifier = modifier) {
itemsIndexed(items = names) { index, name ->
Greeting(
name = name,
isSelected = selectedStates[index] == true,
onSelected = {
selectedStates[index] = !it
}
)
Divider(color = Color.Black)
}
}
}
Happy composing!

Categories

Resources