How to trigger LaunchedEffect when mutableStateList is changed? - android-jetpack-compose

In Jetpack/Desktop Compose I want a coroutine to run in response to changes to a SnapshotStateList.
In this example:
import androidx.compose.foundation.layout.Column
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
#Composable
fun TestMutableList() {
val list = remember { mutableStateListOf(1, 2, 3) }
LaunchedEffect(list) {
println("List was changed.")
}
Column {
Button(onClick = { list[0] = 0 }) {
Text("Change List")
}
list.forEach { Text(it.toString()) }
}
}
the LaunchedEffect was run on the first composition. And the Composable recomposes when I click the button, so it knows that the SnapshotStateList<Int> changed. However, it was not run when clicking the button. I understand that this is because the key is the reference to the SnapshotStateList<Int> and that did not change.
How can I have the LaunchedEffect run every time that the list is modified?

You can update an integer for anytime you change list so it will trigger when that value is changed
val list = remember { mutableStateListOf(1, 2, 3) }
var changeIndex by remember {
mutableStateOf(0)
}
LaunchedEffect(list.size, changeIndex) {
// add an if here if you don't want to trigger when changeIndex is 0
println("List was changed.")
}
Column {
Button(onClick = { list[0] = 0 }) {
changeIndex ++
Text("Change List")
}
list.forEach { Text(it.toString()) }
}

I had the same problem and got it working using the list size instead of the list itself.
Like this:
val list = remember { mutableStateListOf(1, 2, 3) }
LaunchedEffect(list.size) {
println("List was changed.")
}

With convert SnapshotStateList to ImmutableList, you can achieve to aim.
#Composable
fun TestMutableList() {
val list = remember { mutableStateListOf(1, 2, 3) }
LaunchedEffect(list.toList()) {
println("List was changed.")
}
Column {
Button(onClick = { list[0] = 0 }) {
Text("Change List")
}
list.forEach { Text(it.toString()) }
}
}

Related

Jetpack Compose - how to check if keyboard is open or closed

I'm using Jetpack Compose and trying to find a way to detect if the keyboard is open.
I've tried to use the below code, but I get an error stating Unresolved reference: ime. When I click on the recommended imports (the 2 shown below), this error still remains.
import android.view.WindowInsets
import android.view.WindowInsets.Type.ime
#Composable
fun signInView() {
val isVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
}
How can I resolve this?
Add the dependencies for the artifacts you need in the build.gradle file for your app or module:
dependencies {
implementation "androidx.compose.foundation:foundation:1.3.1"
}
android {
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.3.2"
}
kotlinOptions {
jvmTarget = "1.8"
}
}
Example:
#Composable
fun signInView() {
var isVisible by remember { mutableStateOf(false) }
val ime = androidx.compose.foundation.layout.WindowInsets.ime
val navbar = androidx.compose.foundation.layout.WindowInsets.navigationBars
var keyboardHeightDp by remember { mutableStateOf(0.dp) }
val localDensity = LocalDensity.current
LaunchedEffect(localDensity.density) {
snapshotFlow {
ime.getBottom(localDensity) - navbar.getBottom(localDensity)
}.collect {
val currentKeyboardHeightDp = (it / localDensity.density).dp
keyboardHeightDp = maxOf(currentKeyboardHeightDp, keyboardHeightDp)
isVisible = currentKeyboardHeightDp == keyboardHeightDp
}
}
}

I have a composable not setting button text as expected; wondering why. Have a reproducible example

this started as a new compose project
with the following code the intent is to change the text to the picked time. The code is commented where the behavior occurs
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTestTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
TimeCardButton(id = 1, symbol ="In", enabled=true,modifier = Modifier) { entry ->
Log.d("click", "$entry result")
}
}
}
}
}
}
data class TimeCardEntry(val id: Int = -1, var entry: String = "")
#Composable
fun TimeCardButton(
id: Int,
symbol: String,
enabled: Boolean = false,
modifier: Modifier,
onValueChange: (TimeCardEntry) -> Unit = {},
) {
// Value for storing time as a string
val timeState = remember {
mutableStateOf(TimeCardEntry(id, symbol))
}
val validState = remember {
timeState.value.entry.trim().isNotEmpty()
}
val mTime = remember { mutableStateOf(symbol) }
if (enabled) {
// Fetching local context
val mContext = LocalContext.current
// Declaring and initializing a calendar
val mCalendar = Calendar.getInstance()
val mHour = mCalendar[Calendar.HOUR_OF_DAY]
val mMinute = mCalendar[Calendar.MINUTE]
// Creating a TimePicker dialog
val mTimePickerDialog = TimePickerDialog(
mContext,
{ _, mHour: Int, mMinute: Int ->
timeState.value.entry = "$mHour:$mMinute"
mTime.value = "$mHour:$mMinute"
onValueChange(timeState.value)
}, mHour, mMinute, false
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(CircleShape)
.then(modifier)
) {
TextButton(onClick = { mTimePickerDialog.show() }.also {
Log.d("click", "id $id clicked!") }) {
Column() {
// if I use just this it works [in changes to the time picked]
//Text(text = mTime.value)
// if i use both of these BOTH are set when the date picker is invoked
// if I just use the second one alone, the text never changes
Text(text = timeState.value.entry)
}
}
}
} else {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(CircleShape)
.then(modifier)
) {
Text(text = symbol, color =
MaterialTheme.colors.onBackground)
}
}
}
#Preview(showBackground = true)
#Composable
fun DefaultPreview() {
MyApplicationTestTheme {
}
}
First of all how to fix it:
Your problem basically is this. The easiest way to fix it would be to reassign the whole value of TimeState, not just entry by calling
timeState.value = timeState.value.copy(entry = "$mHour:$mMinute")
The reason it doesn't work with only the second one is that the change of a property doesn't trigger recomposition, even if the variable containing it is a mutableState. To fix (as outlined in the answers to the question linked above) this you either have to reassign the whole variable or make the parameter you want to observe observable (for example changing the String to State<String>)
PS: if you use by with mutableStateOf (i.e. val timeState = remember { mutableStateOf(TimeCardEntry(id, symbol)) }) you don't have to use .value every time. I find that a lot cleaner and more readable

How to make the first item in a lazy column clickable and make the rest unclickable

How to make the first item in a lazy column clickable and make the rest unclickable.
for example if I have a list of tasks to complete, enable first item only which is not completed
you have to complete tasks one at a time
LazyColumn(
modifier.padding(top = 40.dp),
) {
itemsIndexed(
items = todayRoute.sortedBy { it.sequence },
) { index, item ->
Row(
modifier = modifier
.fillMaxWidth()
.height(90.dp)
.padding(12.dp)
.clickable(
enabled = item.completed == "0" &&
item.arrived == "0" &&
item.missed == "0"
) {
})
{
}
}
}
Use Hashmap like this
As soon as you get all the item call replaceAll.
When the item is completed, call taskCompleted
class TaskViewModel {
private var _taskStatus = MutableStateFlow<HashMap<String, Boolean>>(hashMapOf())
val taskStatus = _taskStatus.asStateFlow()
fun taskCompleted(item: Task) {
_taskStatus.replace(item.id, true)
}
fun replaceAll(tasks: List<Task>) {
_taskStatus.value.clear()
tasks.forEach {
_taskStatus.value[it.id] = it.completed
}
}
private fun MutableStateFlow<HashMap<String, Boolean>>.replace(key: String, value: Boolean) {
val helper = HashMap<String, Boolean>(this.value)
helper[key] = value
this.value = helper
}
}
On UI, Adjust according to your need
val taskStatus by remember { viewModel.taskStatus }.collectAsState()
LazyColumn {
itemsIndexed(todayRoute) { item, index ->
Row(modifier = Modifier.clickable(
enabled = if (index > 0)
taskStatus[index - 1] ?: false
else
true
)
) {}
}
}

How to stop Surface from passing through clicks [duplicate]

The below code is for Jetbrains Desktop Compose. It shows a card with a button on it, right now if you click the card "clicked card" will be echoed to console. If you click the button it will echo "Clicked button"
However, I'm looking for a way for the card to detect the click on the button. I'd like to do this without changing the button so the button doesn't need to know about the card it's on. I wish to do this so the card knows something on it's surface is handled and for example show a differently colored border..
The desired result is that when you click on the button the log will echo both the "Card clicked" and "Button clicked" lines. I understand why mouseClickable isn't called, button declares the click handled. So I'm expecting that I'd need to use another mouse method than mouseClickable. But I can't for the life of me figure out what I should be using.
#OptIn(ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalDesktopApi::class)
#Composable
fun example() {
Card(
modifier = Modifier
.width(150.dp).height(64.dp)
.mouseClickable { println("Clicked card") }
) {
Column {
Button({ println("Clicked button")}) { Text("Click me") }
}
}
}
When button finds tap event, it marks it as consumed, which prevents other views from receiving it. This is done with consumeDownChange(), you can see detectTapAndPress method where this is done with Button here
To override the default behaviour, you had to reimplement some of gesture tracking. List of changes comparing to system detectTapAndPress:
I use awaitFirstDown(requireUnconsumed = false) instead of default requireUnconsumed = true to make sure we get even a consumed even
I use my own waitForUpOrCancellationInitial instead of waitForUpOrCancellation: here I use awaitPointerEvent(PointerEventPass.Initial) instead of awaitPointerEvent(PointerEventPass.Main), in order to get the event even if an other view will get it.
Remove up.consumeDownChange() to allow the button to process the touch.
Final code:
suspend fun PointerInputScope.detectTapAndPressUnconsumed(
onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
onTap: ((Offset) -> Unit)? = null
) {
val pressScope = PressGestureScopeImpl(this)
forEachGesture {
coroutineScope {
pressScope.reset()
awaitPointerEventScope {
val down = awaitFirstDown(requireUnconsumed = false).also { it.consumeDownChange() }
if (onPress !== NoPressGesture) {
launch { pressScope.onPress(down.position) }
}
val up = waitForUpOrCancellationInitial()
if (up == null) {
pressScope.cancel() // tap-up was canceled
} else {
pressScope.release()
onTap?.invoke(up.position)
}
}
}
}
}
suspend fun AwaitPointerEventScope.waitForUpOrCancellationInitial(): PointerInputChange? {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
if (event.changes.fastAll { it.changedToUp() }) {
// All pointers are up
return event.changes[0]
}
if (event.changes.fastAny { it.consumed.downChange || it.isOutOfBounds(size) }) {
return null // Canceled
}
// Check for cancel by position consumption. We can look on the Final pass of the
// existing pointer event because it comes after the Main pass we checked above.
val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
if (consumeCheck.changes.fastAny { it.positionChangeConsumed() }) {
return null
}
}
}
P.S. you need to add implementation("androidx.compose.ui:ui-util:$compose_version") for Android Compose or implementation(compose("org.jetbrains.compose.ui:ui-util")) for Desktop Compose into your build.gradle.kts to use fastAll/fastAny.
Usage:
Card(
modifier = Modifier
.width(150.dp).height(64.dp)
.clickable { }
.pointerInput(Unit) {
detectTapAndPressUnconsumed(onTap = {
println("tap")
})
}
) {
Column {
Button({ println("Clicked button") }) { Text("Click me") }
}
}

Jetpack Compose - Scroll to focused composable in Column

I have UI like this:
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize(1F)
.padding(horizontal = 16.dp)
.verticalScroll(scrollState)
) {
TextField(...)
// multiple textfields
TextField(
//...
modifier = Modifier.focusOrder(countryFocus).onFocusChanged {
if(it == FocusState.Active) {
// scroll to this textfield
}
},
)
}
I have multiple TextFields in this column and when one of them is focused I want to scroll Column to it. There is a method in scrollState scrollState.smoothScrollTo(0f) but I have no idea how to get a focused TextField position.
Update:
It seems that I've found a working solution. I've used onGloballyPositioned and it works. But I'm not sure if it the best way of solving this.
var scrollToPosition = 0.0F
TextField(
modifier = Modifier
.focusOrder(countryFocus)
.onGloballyPositioned { coordinates ->
scrollToPosition = scrollState.value + coordinates.positionInRoot().y
}
.onFocusChanged {
if (it == FocusState.Active) {
scope.launch {
scrollState.smoothScrollTo(scrollToPosition)
}
}
}
)
There is a new thing in compose called RelocationRequester. That solved the problem for me. I have something like this inside of my custom TextField.
val focused = source.collectIsFocusedAsState()
val relocationRequester = remember { RelocationRequester() }
val ime = LocalWindowInsets.current.ime
if (ime.isVisible && focused.value) {
relocationRequester.bringIntoView()
}
Also you can use BringIntoViewRequester
//
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val coroutineScope = rememberCoroutineScope()
//--------
TextField( ..., modifier = Modifier.bringIntoViewRequester(bringIntoViewRequester)
.onFocusEvent {
if (it.isFocused) {
coroutineScope.launch {
bringIntoViewRequester.bringIntoView()
}
}
}
It seems that using LazyColumn and LazyListState.animateScrollToItem() instead of Column could be a good option for your case.
Reference: https://developer.android.com/jetpack/compose/lists#control-scroll-position
By the way, thank you for the information about onGloballyPositioned() modifier. I was finding a solution for normal Column case. It saved me a lot of time!
Here's some code I used to make sure that the fields in my form were not cut off by the keyboard:
From: stack overflow - detect when keyboard is open
enum class Keyboard {
Opened, Closed
}
#Composable
fun keyboardAsState(): State<Keyboard> {
val keyboardState = remember { mutableStateOf(Keyboard.Closed) }
val view = LocalView.current
DisposableEffect(view) {
val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
val rect = Rect()
view.getWindowVisibleDisplayFrame(rect)
val screenHeight = view.rootView.height
val keypadHeight = screenHeight - rect.bottom
keyboardState.value = if (keypadHeight > screenHeight * 0.15) {
Keyboard.Opened
} else {
Keyboard.Closed
}
}
view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)
onDispose {
view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener)
}
}
return keyboardState
}
and then in my composable:
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
val isKeyboardOpen by keyboardAsState()
if (isKeyboardOpen == Keyboard.Opened) {
val view = LocalView.current
val screenHeight = view.rootView.height
scope.launch { scrollState.scrollTo((screenHeight * 2)) }
}
Surface(modifier = Modifier
.fillMaxHeight()
.verticalScroll(scrollState),
) {
//Rest of your Composables, Columns, Rows, TextFields, Buttons
//add this so the screen can scroll up and keyboard cannot cover the form fields - Important!
/*************************************************/
if (isKeyboardOpen == Keyboard.Opened) {
Spacer(modifier = Modifier.height(140.dp))
}
}
Hope it helps someone. I was using:
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val scope = rememberCoroutineScope()
val view = LocalView.current
DisposableEffect(view) {
val listener = ViewTreeObserver.OnGlobalLayoutListener {
scope.launch { bringIntoViewRequester.bringIntoView() }
}
view.viewTreeObserver.addOnGlobalLayoutListener(listener)
onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(listener) }
}
Surface(modifier.bringIntoViewRequester(bringIntoViewRequester)) {
///////////rest of my composables
}
But this did not work.

Resources