I came across something that is confusing me while working with the experimental WindowInsets API. First here is the sample code in my main activity. The manifest also has android:windowSoftInputMode="adjustResize". I am using compose 1.4.0-beta01.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
ImeVisibilityExample()
ImeInsetsExample()
TextFieldWithText()
}
}
}
In ImeVisibilityExample, I test the difference between remember and remember + derivedStateOf using WindowInsets.isImeVisible (a boolean).
#OptIn(ExperimentalLayoutApi::class)
#Composable
fun ImeVisibilityExample() {
val isImeVisible: Boolean = WindowInsets.isImeVisible
val imeVisibleDerived by remember { derivedStateOf { isImeVisible } }
val imeVisibleRemember = remember(isImeVisible) { isImeVisible }
LaunchedEffect(imeVisibleDerived) { Timber.d("derivedVisible: $imeVisibleDerived") }
LaunchedEffect(imeVisibleRemember) { Timber.d("rememberVisible: $imeVisibleRemember") }
}
When I click on the text field and dismiss the IME over and over, I get the following logs.
D rememberVisible: true
D rememberVisible: false
D rememberVisible: true
D rememberVisible: false
D rememberVisible: true
D rememberVisible: false
There are no logs due to changes from derivedStateOf. From this I assume WindowInsets.isImeVisible does not come from some kind of State that works with derivedStateOf.
Next, in ImeInsetsExample, I test the difference between remember and remember + derivedStateOf using WindowInsets.imeAnimationTarget (a WindowInsets).
#OptIn(ExperimentalLayoutApi::class)
#Composable
fun ImeInsetsExample() {
val localDensity = LocalDensity.current
val ime: WindowInsets = WindowInsets.imeAnimationTarget
val imeHeightDerived by remember { derivedStateOf { ime.getBottom(localDensity) } }
val imeHeightRemember = remember(ime, localDensity) { ime.getBottom(localDensity) }
LaunchedEffect(imeHeightDerived) { Timber.d("derivedHeight: $imeHeightDerived") }
LaunchedEffect(imeHeightRemember) { Timber.d("rememberHeight: $imeHeightRemember") }
}
When I click on the text field and dismiss the IME over and over, I get the following logs.
D derivedHeight: 867
D derivedHeight: 0
D derivedHeight: 867
D derivedHeight: 0
D derivedHeight: 867
This time it's the opposite. There are no logs due to changes from remember. So my question is, should I be able to tell based on the API documentation what works or doesn't work with remember and remember + derivedStateOf, or is this something you just need to figure out yourself by writing code or following the source code?
Related
I'm using Compose to build my Android UI.
I have a screen where I want to be able to search for stocks and show them in a LazyColumn. For triggering the API call I'm using a LaunchedEffect like this.
val stocks = remember { mutableStateListOf<Stock>() }
var searchText by remember { mutableStateOf("") }
val hasSearchEnoughChars = searchText.length >= 3
...
if(hasSearchEnoughChars) {
LaunchedEffect(key1 = searchText) {
delay(500)
searchStocksForText(searchText) {
isSearching = false
wereStocksFound = it.isNotEmpty()
stocks.clear()
stocks.addAll(it)
}
}
} else {
stocks.clear()
}
...
SearchField(
onValueChanged = {
searchText = it
}
)
...
private fun SearchField(
onValueChanged: (String) -> Unit,
modifier: Modifier = Modifier,
isError: Boolean = false
) {
var inputText by remember { mutableStateOf("") }
OutlinedTextField(
value = inputText,
onValueChange = {
inputText = it
onValueChanged(it)
},
...
)
}
This is how searchText is updated.
fun searchStocksForText(searchText: String, onDataReceived: (List<Stock>) -> Unit) {
StockApiConnection().getStocksViaSearch(
query = searchText,
onSuccess = { onDataReceived(it) },
onFailure = { onDataReceived(emptyList()) }
)
}
This is the async function which is build on top of a retrofit callback.
So far so good, but I'm experiencing a weird behavior of LaunchedEffect in an edgecase.
When having typed 4 Chars into the Textfield (represented by searchText) and erasing 2 of them with a slight delay (probably the delay(500) from LaunchedEffect) the stocks will still be fetched for the 3-char-sized searchText and therefore shown in the LazyColumn.
I also already tried using a CoroutineScope, having the if(hasSearchEnoughChars) statement inside of the LaunchedEffect and also aborting the LaunchedEffect / Scope in the else Branch but nothing seems to work. Curiously the API is not called when typing fast, except the last one after 500ms, as intended.
For my understanding LaunchedEffect should cancel the current Coroutine
when the Key changes and
when the Composable leaves the composition
which should booth be the case but the callback is still triggered.
Is there something I'm missing when handling async callbacks in LaunchedEffect or is my understanding of LaunchedEffect wrong?
searchStocksForText() is an asynchronous function with callback instead of a suspend function, so if the coroutine is cancelled after it has already been fired, it cannot be cancelled and it's callback will still be run. You need to convert it into a suspend function:
suspend fun searchStocksForText(searchText: String): List<Stock> = suspendCancellableCoroutine { cont ->
StockApiConnection().getStocksViaSearch(
query = searchText,
onSuccess = { cont.resume(it) },
onFailure = { cont.resume(emptyList()) }
)
}
Then you can call the code synchronously in your coroutine, and it will be cancellable appropriately:
if(hasSearchEnoughChars) {
LaunchedEffect(key1 = searchText) {
delay(500)
val stocks = searchStocksForText(searchText)
isSearching = false
wereStocksFound = it.isNotEmpty()
stocks.clear()
stocks.addAll(it)
}
} else {
stocks.clear()
}
However, I think using a launched effect for this is kind of convoluted. You might try doing it with a Flow and using debounce(). I didn't test this, so beware. Still a newbie to Compose myself, and I'm not sure if the cold flow needs to be stored in a remember parameter before you call collectAsStateWithLifecycle() on it.
val searchText = remember { MutableStateFlow("") }
val stocks: State<List<Stock>> = searchText
.debounce(500)
.onEach { isSearching = true }
.map { if (it.length >= 3) searchStocksForText(searchText) else emptyList() }
.onEach { isSearching = false }
.collectAsStateWithLifecycle()
val wereStocksFound = stocks.isNotEmpty()
Side note, beware of using length >= 3 on your search string. That is completely ignoring code point size.
I have a simple complete Composable Code below (which you can put in your MainActivity.kt verbatim and run it)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
var number by rememberSaveable { mutableStateOf(0) }
MyComposableFun("$number")
LaunchedEffect(Unit) {
while(true) {
delay(500)
number++
}
}
}
}
}
#Composable
fun MyComposableFun(textValue: String) {
var myText by rememberSaveable(textValue) { mutableStateOf(textValue) }
var checkedState by rememberSaveable { mutableStateOf(false) }
Row (verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = checkedState,
onCheckedChange = {
myText = if (it) "Internal Change" else "Internal Change Again"
checkedState = it
}
)
Text("Title: $myText")
}
}
When I run it, it will just auto increment the number.
If I put it in the background (and kill the process, using "Don't Keep Activity" setting), and put it back in the foreground, it will try to restore the state. At that time it will crash with
java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Boolean
at com.example.learnabstractcomposeview.MainActivityKt.MyComposableFun$lambda-3(MainActivity.kt:109)
at com.example.learnabstractcomposeview.MainActivityKt.MyComposableFun(MainActivity.kt:42)
at com.example.learnabstractcomposeview.ComposableSingletons$MainActivityKt$lambda-1$1.invoke(MainActivity.kt:22)
at com.example.learnabstractcomposeview.ComposableSingletons$MainActivityKt$lambda-1$1.invoke(MainActivity.kt:20)
complaining wrong casting in the line of checked = checkedState, in
Checkbox(
checked = checkedState,
onCheckedChange = {
myText = if (it) "Internal Change" else "Internal Change Again"
checkedState = it
}
)
To fix this issue, I just need to swap the
var myText by rememberSaveable(textValue) { mutableStateOf(textValue) }
var checkedState by rememberSaveable { mutableStateOf(false) }
to
var checkedState by rememberSaveable { mutableStateOf(false) }
var myText by rememberSaveable(textValue) { mutableStateOf(textValue) }
It looks like the rememberSaveable value is restoration is done incorrectly, and influenced by the order of the code (which is odd). Feels like a bug in Google Jetpack Compose code, but ask here in case I miss anything obvious
Looks like the issue was because I'm using Compose version 1.1.1.
It has been fixed in compose version 1.2.0
In a Composable function, I can pass as parameter the State, or the value of the State. Any reason for preferring to pass the value of the State, instead of the State?
In both cases, the composable is stateless, so why should I distinguish both cases?
It's possible to pass state's value. For example:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val isLoading = mutableStateOf(false)
val onClickAtButton = {
lifecycleScope.launch(Dispatchers.Main) {
isLoading.value = true
withContext(Dispatchers.IO) {
//Do some heavy operation live REST call
}
isLoading.value = false
}
}
setContent {
MyComposable(isLoading.value, onClickAtButton)
}
}
}
#Composable
fun MyComposable(
isLoading: Boolean = false,
onClickAtButton: () -> Unit = {}
){
Box(modifier = Modifier.fillMaxSize(){
Button(onClick = onClickAtButton)
if(isLoading){
CircularProgressIndicator()
}
}
}
Hope it helps somebody.
There is a slight difference between passing State or just the value of a State regarding recomposition.
Let's start with passing State:
#Composable
fun Example1(text: State<String>) {
SideEffect { Log.d("Example", "Example1 recomposition") }
Example2(text)
}
#Composable
fun Example2(text: State<String>) {
SideEffect { Log.d("Example", "Example2 recomposition") }
Text(text.value)
}
#Composable
fun Screen() {
val text = remember { mutableStateOf("hello") } }
Example1(text)
Button(
onClick = { text.value = "world" }
) {
Text("Click me")
}
}
On first start you will see the log output
Example1 recomposition
Example2 recomposition
However when you click the button, you will only see an additional
Example2 recomposition
Because you're passing down State and only Example2 is reading the state, Example1 does not need to be recomposed.
Let's change the parameters to a plain type:
#Composable
fun Example1(text: String) {
SideEffect { Log.d("Example", "Example1 recomposition") }
Example2(text)
}
#Composable
fun Example2(text: String) {
SideEffect { Log.d("Example", "Example2 recomposition") }
Text(text)
}
#Composable
fun Screen() {
val text = remember { mutableStateOf("hello") } }
Example1(text.value)
Button(
onClick = { text.value = "world" }
) {
Text("Click me")
}
}
When you click the button now, you will see two additional lines in the log output
Example1 recomposition
Example2 recomposition
Since text is now a plain type of the function signatures of both composables, both need to be recomposed when the value changes.
However always passing down State can become quite cumbersome. Compose is quite good at detecting what needs to be recomposed so this should be considered a micro optimization. I just wanted to point out that there is a slight difference which every developer using Compose should know about.
I have a Composable that uses a Handler to slowly update the alpha of an image inside a composable.
However, I'm seeing that the screen turns off before the animation could complete.
In XML layouts, we could keep it alive using
android:keepScreenOn
or
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
Is there a way to do this using compose without using the wake lock permission?
You can use LocalContext to get activity, and it has a window on which you can apply needed flags.
In such cases, when you need to run some code on both view appearance and disappearance, DisposableEffect can be used:
#Composable
fun KeepScreenOn() {
val context = LocalContext.current
DisposableEffect(Unit) {
val window = context.findActivity()?.window
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
onDispose {
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
fun Context.findActivity(): Activity? {
var context = this
while (context is ContextWrapper) {
if (context is Activity) return context
context = context.baseContext
}
return null
}
Usage: when screen appears flag is set to on, and when disappears - it's cleared.
#Composable
fun Screen() {
KeepScreenOn()
}
As #Louis CAD correctly pointed out, you can have problems if you use this "view" in many views: if one view appears that uses it, and then disappears previous views that also used it, it will reset the flag.
I haven't found a way of tracking flags state to update the view, I think #Louis CAD solution is OK until Compose have some system support.
This one should be safe from any interference if you have multiple usages in the same composition:
#Composable
fun KeepScreenOn() = AndroidView({ View(it).apply { keepScreenOn = true } })
Usage is then as simple as that:
if (screenShallBeKeptOn) {
KeepScreenOn()
}
In a more Compose way:
#Composable
fun KeepScreenOn() {
val currentView = LocalView.current
DisposableEffect(Unit) {
currentView.keepScreenOn = true
onDispose {
currentView.keepScreenOn = false
}
}
}
This will be disposed of as soon as views disappear from the composition.
Usage is as simple as:
#Composable
fun Screen() {
KeepScreenOn()
}
This is how I implemented mine
In my Composable function I have a button to activate the FLAG_KEEP_SCREEN_ON or clear FLAG_KEEP_SCREEN_ON
#Composable
fun MyButton() {
var state by rememberSaveable {
mutableStateOf(false)
}
val context = LocalContext.current
Button(
...
modifier = Modifier
.clickable {
state = !state
keepScreen(state, context)
}
...
)
}
fun keepScreen(state: Boolean, context : Context) {
val activity = context as Activity
if(state) {
activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}else {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
The code below works best for me.
#Composable
fun ScreenOnKeeper() {
val activity = LocalContext.current as Activity
DisposableEffect(Unit) {
activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
onDispose {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
The code below didn't work when I toggle its presence (conditionally add, remove, add the component).
#Composable
fun ScreenOnKeeper() {
val view = LocalView.current
DisposableEffect(Unit) {
view.keepScreenOn = true
onDispose {
view.keepScreenOn = false
}
}
}
_displayCheckBox is a MutableLiveData<Boolean>, I hope to set it as adverse.
But It seems that _displayCheckBox.value = !_displayCheckBox.value!! can't work well, how can I fix it?
Code A
private val _displayCheckBox = MutableLiveData<Boolean>(true)
val displayCheckBox : LiveData<Boolean> = _displayCheckBox
fun switchCheckBox(){
_displayCheckBox.value = !_displayCheckBox.value!! //It seems that it can't work well.
}
If you wrap the set value with a scope function such as let, you'd be able to negate the value only if it is not null, otherwise, the negation would be ignored.
fun switchCheckBox() {
_displayCheckBox.value?.let {
_displayCheckBox.value = !it
}
}
This will transform the live data inverting the liveData value, it will observe _displayCheckBox and change its appling the {!it} operation to its value:
private val _displayCheckBox = MutableLiveData<Boolean>(true)
val displayCheckBox = Transformations.map(_displayCheckBox) { !it }
Note that you have to observe the value to trigger the updates:
SomeActivity.kt
displayCheckBox.observe(this, Observer {value ->
// Do something with the value
})
Here is the docs:
https://developer.android.com/reference/androidx/lifecycle/Transformations#map(androidx.lifecycle.LiveData%3CX%3E,%20androidx.arch.core.util.Function%3CX,%20Y%3E)
you can do something like this
fun switchCheckBox() = _displayCheckBox.value?.let { _displayCheckBox.postValue(!it) }
postValue will trigger the observer for displayCheckBox
I am not a fan of using a .let in this scenario because that would preserve the null value of the LiveData which is obviously something you are intending to avoid. I would use the following:
fun toggleDisplayCheckBox() {
_displayCheckBox.run { value = value == false }
}
This adheres to the following Boolean? mapping:
When the value is...
true -> false
false -> true
null -> false
In the case where you want the value to be set to true instead of false when it is null, the following could be used instead:
fun toggleDisplayCheckBox() {
_displayCheckBox.run { value = value != true }
}