I use Jetpack Compose UI to build a simple TODO app. The idea is to have a list of tasks that could be checked or unchecked, and checked tasks should go to the end of the list.
Everything is working fine except when I check the first visible item on the screen it moves down along with the scroll position.
I believe that has something to do with LazyListState, there is such function:
/**
* When the user provided custom keys for the items we can try to detect when there were
* items added or removed before our current first visible item and keep this item
* as the first visible one even given that its index has been changed.
*/
internal fun updateScrollPositionIfTheFirstItemWasMoved(itemsProvider: LazyListItemsProvider) {
scrollPosition.updateScrollPositionIfTheFirstItemWasMoved(itemsProvider)
}
So I would like to disable this kind of behavior but I didn't find a way.
Below is a simple code to reproduce the problem and a screencast. The problem in this screencast appears when I try to check "Item 0", "Item 1" and "Item 4", but it works as I expect when checking "Item 7" and "Item 8".
It also behaves the same if you check or uncheck any item that is currently the first visible item, not only if the item is first in the whole list.
class MainActivity : ComponentActivity() {
#OptIn(ExperimentalFoundationApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
val checkItems by remember { mutableStateOf(generate(20)) }
LazyColumn() {
items(
items = checkItems.sortedBy { it.checked.value },
key = { item -> item.id }
) { entry ->
Row(
modifier = Modifier.animateItemPlacement(),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = entry.checked.value,
onCheckedChange = {
checkItems.find { it == entry }
?.apply { this.checked.value = !this.checked.value }
}
)
Text(text = entry.text)
}
}
}
}
}
}
}
}
data class CheckItem(val id: String, var checked: MutableState<Boolean> = mutableStateOf(false), var text: String = "")
fun generate(count: Int): List<CheckItem> =
(0..count).map { CheckItem(it.toString(), mutableStateOf(it % 2 == 0), "Item $it") }
Since animateItemPlacement requires a unique ID/key for the Lazy item to get animated, maybe sacrificing the first item, setting its key using its index position (no animation) will prevent the issue
itemsIndexed(
items = checkItems.sortedBy { it.checked.value },
key = { index, item -> if (index == 0) index else item.id }
) { index, entry ->
...
}
Related
I have an issue with refreshing Compose Lazy List, based on changes in persistence.
The business case - I have a screen (Fragment) with MyObject list contains all objects, there is another screen with only favorites MyObjects. Both use the same Composable as a list element with name, description and "heart" icon to set/unset favorite flag.
On "all" list setting and unsetting favorite flag works well - click on IconToggleButton sets boolean in DB and then switching to Favorite screen shows new item. Unset favorite on "all" screen sets flag to false as expected and when navigates to Favorite screen removes item.
But toggling favorite icon on Favorite screen change boolean in DB - BUT does not refresh and recompose LazyList content. I have to manually switch few times between screens, then eventually both are, let's say, synchronized with DB.
Unset favorite on the last element on Favorite list does not refresh it at all - I have to "like" another object on "all" list, then the Favorite list content is recompose with replacing items.
Moreover - there are some cases, that items on both lists disappears, while they are still in DB. I need to dig it deeper and debug this case, but maybe is related.
Some code's details:
There is a simple Entity
#Entity(tableName = "my_objects")
data class MyObject(
#PrimaryKey(autoGenerate = true)
var id: Long = 0,
#ColumnInfo(name = "name")
val name: String,
#ColumnInfo(name = "favorite")
val favorite: Boolean = false
)
Then there are also DAO, Provider and Repository with Domain Model. In DAO there are methods:
#Query("SELECT * FROM my_objects")
fun getAll(): List<MyObject>
#Query("SELECT * FROM my_objects WHERE favorite = 1")
fun getFavorites(): List<MyObject>
called in Provider and then in Repository.
In MyObjectListViewModel (with mapping from DB model do domain model):
#HiltViewModel
class MyObjectListViewModel #Inject constructor(
private val updateMyObject: UpdateMyObject,
private val getOrderedMyObjectList: GetOrderedMyObjectList,
private val dispatchers: CoroutineDispatcherProvider
) : ViewModel() {
private val mutableMyObjects = MutableLiveData<List<ItemMyObjectModel>>()
val myObjects: LiveData<List<ItemMyObjectModel>> = mutableMyObjects
fun loadMyObjects() {
viewModelScope.launch(dispatchers.io) {
val myObjectListResult = getOrderedMyObjectList()
withContext(dispatchers.main) {
when (myObjectListResult) {
is MyObjectListResult.Success -> {
val viewModelList = myObjectListResult.list.map {
ItemMyObjectModel(it)
}
mutableMyObjects.postValue(viewModelList)
}
}
}
}
}
fun switchFavoriteFlag(itemMyObjectModel: ItemMyObjectModel) {
val myObject = itemMyObjectModel.itemMyObject
myObject.favorite = !myObject.favorite
viewModelScope.launch(dispatchers.io) {
val updatedObject = updateMyObject(myObject) //save via DAO
}
}
}
MyObjectFavoriteListViewModel looks exactly the same, except that load function calls loadFavoriteMyObjects() and it uses GetOrderedFavoriteMyObjectList Repository. BTW - maybe it could be aggregate to one ViewModel, but with pair of LiveData and load function - one pair for all item and one for favorites?
Last but not least - Composables:
#OptIn(ExperimentalMaterial3Api::class)
#Composable
fun MyObjectFavoriteListScreen(
viewModel: MyObjectFavoriteListViewModel,
navigator: MyObjectNavigator
) {
val list by viewModel.myObjects.observeAsState()
val lazyListState = rememberLazyListState()
Scaffold(
floatingActionButton = {
MyObjectListFloatingActionButton(
extended = lazyListState.isScrollingUp() //local extension
) { navigator.openNewMyObjectFromObjectList() }
}
) { padding ->
if (list != null) {
LazyColumn(
contentPadding = PaddingValues(
horizontal = dimensionResource(id = R.dimen.margin_normal),
vertical = dimensionResource(id = R.dimen.margin_normal)
),
state = lazyListState,
modifier = Modifier.padding(padding)
) {
items(list!!) { item ->
MyObjectListItem( // with Card() includes Text() and IconToggleButton()
item = item,
onCardClick = { myObjectId -> navigator.openMyObjectDetailsFromFavouriteList(myObjectId) },
onFavoriteClick = { itemMyObjectModel -> viewModel.switchFavoriteFlag(itemMyObjectModel) }
)
}
}
} else {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(text = stringResource(id = "No objects available"))
}
}
}
}
I think that one issue could be related with if (list != null) {} (list is observed as State<T?>).
But for sure there is something wrong with states, I am pretty sure that the list should be triggered to recompose, but there is no(?) state to do so.
Any ideas?
I need help with composables and mutableStates!
In the example below, I have a list of classes.
I want to try to manipulate the list to
a. swap the position of two items
b. change the "data" in one of the classes
c. get a copy of the classes "flattened"
I have in the main composable a text field displaying the items
a. a copy of the list from the viewModel
b. a button to move the 1st item to the 3rd position
c. a button to get the "flattened" data
d. a button to modify the data in the 3rd item.
I expected each of these actions to modify the visible contents
of the items.
When I run this I see the items on the screen.
When I click "move 2nd to 4th", the list of items does not update on the screen.
But, clicking to get the flattened layout shows the flattened data correctly.
Now, I click "change data" and the data is changed in the items on the screen!
If I click on get flat layout, the original list reappears and the
flattened list shows the original rlattened list again!
What is happening and how can I fix it? I don't expect coroutine calls are
needed here.
I also have the problem of losing data when I rotate the screen (newbie here.)
Pertinent code of MainActivity:
#Composable
fun Screen() {
val vm = MyViewModel()
val items = vm.items.collectAsState()
var flat by remember{ mutableStateOf ("")}
var newItem by remember { mutableStateOf(MyClass("",""))}
Column {
Text(text = items.toString().split("MyC").joinToString("\nMyC"))
Spacer(Modifier.height(8.dp))
Button(
onClick = {vm.moveList(1,3)} // swap 2nd and 4th items on list
) { Text( text = "move 2nd to 4th")}
Spacer(Modifier.height(8.dp))
Button(
onClick = {flat = vm.getLayout()}
) { Text( text = "get flat layout")}
Spacer(Modifier.height(8.dp))
Text(text = flat)
Spacer(Modifier.height(8.dp))
Button(
onClick = { vm.changeData(2,"NEW")
newItem = vm.items.value[2]}
) { Text( text = "get change heading on 3rd")}
Spacer(Modifier.height(8.dp))
Text(text = newItem.toString())
}
}
And here is code from the viewModel:
data class MyClass(
val key: String,
val data: String,
)
class MyViewModel: ViewModel(){
companion object {
private const val DEFAULT_LAYOUT = "0|A,1|B,2|C,3|D,4|E"
}
private val _items: MutableStateFlow<List<MyClass>> =
MutableStateFlow(
DEFAULT_LAYOUT.split(",").map{ MyClass(it.split("|").first(),it.split("|").last())})
val items: StateFlow<List<MyClass>> = _items
fun getLayout(): String {
return items.value.map { it.key + "|" + it.data }.joinToString(",")
}
fun changeData(
index: Int,
newData: String
) {
val mC : MyClass = items.value[index].copy(data = newData)
_items.update {
it.toMutableList().apply {
set(index,mC)
}
}
}
fun moveList(from: Int, to: Int) {
_items.update {
it.toMutableList().apply {
add(to, removeAt(from))
}
}
}
fun removeElement(idx: Int) {
_items.update {
it.toMutableList().apply {
removeAt(idx)
}
}
}
}
I'm trying to implement Grid Layout with 2 columns using Compose, but LazyVertical Grid does not work for me. I searched for some workarounds to fulfill the task, but nothing was rendered on a screen. Any ideas?
val state = rememberLazyListState()
LazyVerticalGrid(
cells = GridCells.Fixed(2),
state = state,
content = {
items(bookList.books){
bookList.books.map {
BookUI(book = it, onClick = {})
}
}
}
)
I tried using LazyVerticalGrid this way, but it does not render a list, while LazyColumn renders it
You don't need a map when using items.
Change
items(bookList.books){
bookList.books.map {
BookUI(book = it, onClick = {})
}
}
to
items(bookList.books){ book ->
BookUI(book = it, onClick = {})
}
Don't forget to import,
import androidx.compose.foundation.lazy.items
try to use the following code:
#OptIn(ExperimentalFoundationApi::class)
#Composable
fun MyGrid(items: List<String>) {
LazyVerticalGrid(
cells = GridCells.Fixed(count = 2)
) {
items(items) { text ->
Text(text = text)
}
}
}
Few things you should pay attention to:
the items(*) {} function need to be imported from androidx.compose.foundation.lazy.items
You added #OptIn(ExperimentalFoundationApi::class)
rememberLazyListState() is actually a default param so no need to add it.
For the above example you can use something like this:
#OptIn(ExperimentalFoundationApi::class)
#Composable
fun Content() {
MyGrid(
items = listOf(
"Item A",
"Item B",
"Item C",
"Item D",
"Item E",
"Item F"
)
)
}
And you will get this:
Implementation has changed since the time your question was created, so the following code snipped should work in your case:
val state = rememberLazyGridState()
LazyVerticalGrid(
state = state,
columns = GridCells.Fixed(2)
) {
items(bookList.books) { books ->
BookUI(book = it, onClick = {})
}
}
I am using a LazyColumn and there are several items in which one of item has a LaunchedEffect which needs to be executed only when the view is visible.
On the other hand, it gets executed as soon as the LazyColumn is rendered.
How to check whether the item is visible and only then execute the LaunchedEffect?
LazyColumn() {
item {Composable1()}
item {Composable2()}
item {Composable3()}
.
.
.
.
item {Composable19()}
item {Composable20()}
}
Lets assume that Composable19() has a Pager implementation and I want to start auto scrolling once the view is visible by using the LaunchedEffect in this way. The auto scroll is happening even though the view is not visible.
LaunchedEffect(pagerState.currentPage) {
//auto scroll logic
}
LazyScrollState has the firstVisibleItemIndex property. The last visible item can be determined by:
val lastIndex: Int? = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
Then you test to see if the list item index you are interested is within the range. For example if you want your effect to launch when list item 5 becomes visible:
val lastIndex: Int = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
LaunchedEffect((lazyListState.firstVisibleItemIndex > 5 ) && ( 5 < lastIndex)) {
Log.i("First visible item", lazyListState.firstVisibleItemIndex.toString())
// Launch your auto scrolling here...
}
LazyColumn(state = lazyListState) {
}
NOTE: For this to work, DON'T use rememberLazyListState. Instead, create an instance of LazyListState in your viewmodel and pass it to your composable.
If you want to know if an item is visible you can use the LazyListState#layoutInfo that contains information about the visible items.
Since you are reading the state you should use derivedStateOf to avoid redundant recompositions and poor performance
To know if the LazyColumn contains an item you can use:
#Composable
private fun LazyListState.containItem(index:Int): Boolean {
return remember(this) {
derivedStateOf {
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (layoutInfo.totalItemsCount == 0) {
false
} else {
visibleItemsInfo.toMutableList().map { it.index }.contains(index)
}
}
}.value
}
Then you can use:
val state = rememberLazyListState()
LazyColumn(state = state){
//items
}
//Check for a specific item
var isItem2Visible = state.containItem(index = 2)
LaunchedEffect( isItem2Visible){
if (isItem2Visible)
//... item visible do something
else
//... item not visible do something
}
If you want to know all the visible items you can use something similar:
#Composable
private fun LazyListState.visibleItems(): List<Int> {
return remember(this) {
derivedStateOf {
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (layoutInfo.totalItemsCount == 0) {
emptyList()
} else {
visibleItemsInfo.toMutableList().map { it.index }
}
}
}.value
}
I have implemented a column of buttons in jetpack compose. We realized it is possible to click multiple items at once (with multiple fingers for example), and we would like to disable this feature.
Is there an out of the box way to disable multiple simultaneous clicks on children composables by using a parent column modifier?
Here is an example of the current state of my ui, notice there are two selected items and two unselected items.
Here is some code of how it is implemented (stripped down)
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(nestedScrollParams.childScrollState),
) {
viewDataList.forEachIndexed { index, viewData ->
Row(modifier = modifier.fillMaxWidth()
.height(dimensionResource(id = 48.dp)
.background(colorResource(id = R.color.large_button_background))
.clickable { onClick(viewData) },
verticalAlignment = Alignment.CenterVertically
) {
//Internal composables, etc
}
}
Check this solution. It has similar behavior to splitMotionEvents="false" flag. Use this extension with your Column modifier
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import kotlinx.coroutines.coroutineScope
fun Modifier.disableSplitMotionEvents() =
pointerInput(Unit) {
coroutineScope {
var currentId: Long = -1L
awaitPointerEventScope {
while (true) {
awaitPointerEvent(PointerEventPass.Initial).changes.forEach { pointerInfo ->
when {
pointerInfo.pressed && currentId == -1L -> currentId = pointerInfo.id.value
pointerInfo.pressed.not() && currentId == pointerInfo.id.value -> currentId = -1
pointerInfo.id.value != currentId && currentId != -1L -> pointerInfo.consume()
else -> Unit
}
}
}
}
}
}
Here are four solutions:
Click Debounce (ViewModel)r
For this, you need to use a viewmodel. The viewmodel handles the click event. You should pass in some id (or data) that identifies the item being clicked. In your example, you could pass an id that you assign to each item (such as a button id):
// IMPORTANT: Make sure to import kotlinx.coroutines.flow.collect
class MyViewModel : ViewModel() {
val debounceState = MutableStateFlow<String?>(null)
init {
viewModelScope.launch {
debounceState
.debounce(300)
.collect { buttonId ->
if (buttonId != null) {
when (buttonId) {
ButtonIds.Support -> displaySupport()
ButtonIds.About -> displayAbout()
ButtonIds.TermsAndService -> displayTermsAndService()
ButtonIds.Privacy -> displayPrivacy()
}
}
}
}
}
fun onItemClick(buttonId: String) {
debounceState.value = buttonId
}
}
object ButtonIds {
const val Support = "support"
const val About = "about"
const val TermsAndService = "termsAndService"
const val Privacy = "privacy"
}
The debouncer ignores any clicks that come in within 500 milliseconds of the last one received. I've tested this and it works. You'll never be able to click more than one item at a time. Although you can touch two at a time and both will be highlighted, only the first one you touch will generate the click handler.
Click Debouncer (Modifier)
This is another take on the click debouncer but is designed to be used as a Modifier. This is probably the one you will want to use the most. Most apps will make the use of scrolling lists that let you tap on a list item. If you quickly tap on an item multiple times, the code in the clickable modifier will execute multiple times. This can be a nuisance. While users normally won't tap multiple times, I've seen even accidental double clicks trigger the clickable twice. Since you want to avoid this throughout your app on not just lists but buttons as well, you probably should use a custom modifier that lets you fix this issue without having to resort to the viewmodel approach shown above.
Create a custom modifier. I've named it onClick:
fun Modifier.onClick(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
) = composed(
inspectorInfo = debugInspectorInfo {
name = "clickable"
properties["enabled"] = enabled
properties["onClickLabel"] = onClickLabel
properties["role"] = role
properties["onClick"] = onClick
}
) {
Modifier.clickable(
enabled = enabled,
onClickLabel = onClickLabel,
onClick = {
App.debounceClicks {
onClick.invoke()
}
},
role = role,
indication = LocalIndication.current,
interactionSource = remember { MutableInteractionSource() }
)
}
You'll notice that in the code above, I'm using App.debounceClicks. This of course doesn't exist in your app. You need to create this function somewhere in your app where it is globally accessible. This could be a singleton object. In my code, I use a class that inherits from Application, as this is what gets instantiated when the app starts:
class App : Application() {
override fun onCreate() {
super.onCreate()
}
companion object {
private val debounceState = MutableStateFlow { }
init {
GlobalScope.launch(Dispatchers.Main) {
// IMPORTANT: Make sure to import kotlinx.coroutines.flow.collect
debounceState
.debounce(300)
.collect { onClick ->
onClick.invoke()
}
}
}
fun debounceClicks(onClick: () -> Unit) {
debounceState.value = onClick
}
}
}
Don't forget to include the name of your class in your AndroidManifest:
<application
android:name=".App"
Now instead of using clickable, use onClick instead:
Text("Do Something", modifier = Modifier.onClick { })
Globally disable multi-touch
In your main activity, override dispatchTouchEvent:
class MainActivity : AppCompatActivity() {
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return ev?.getPointerCount() == 1 && super.dispatchTouchEvent(ev)
}
}
This disables multi-touch globally. If your app has a Google Maps, you will want to add some code to to dispatchTouchEvent to make sure it remains enabled when the screen showing the map is visible. Users will use two fingers to zoom on a map and that requires multi-touch enabled.
State Managed Click Handler
Use a single click event handler that stores the state of which item is clicked. When the first item calls the click, it sets the state to indicate that the click handler is "in-use". If a second item attempts to call the click handler and "in-use" is set to true, it just returns without performing the handler's code. This is essentially the equivalent of a synchronous handler but instead of blocking, any further calls just get ignored.
The most simple approach that I found for this issue is to save the click state for each Item on the list, and update the state to 'true' if an item is clicked.
NOTE: Using this approach works properly only in a use-case where the list will be re-composed after the click handling; for example navigating to another Screen when the item click is performed.
Otherwise if you stay in the same Composable and try to click another item, the second click will be ignored and so on.
for example:
#Composable
fun MyList() {
// Save the click state in a MutableState
val isClicked = remember {
mutableStateOf(false)
}
LazyColumn {
items(10) {
ListItem(index = "$it", state = isClicked) {
// Handle the click
}
}
}
}
ListItem Composable:
#Composable
fun ListItem(
index: String,
state: MutableState<Boolean>,
onClick: () -> Unit
) {
Text(
text = "Item $index",
modifier = Modifier
.clickable {
// If the state is true, escape the function
if (state.value)
return#clickable
// else, call onClick block
onClick()
state.value = true
}
)
}
Trying to turn off multi-touch, or adding single click to the modifier, is not flexible enough. I borrowed the idea from #Johannâs code. Instead of disabling at the app level, I can call it only when I need to disable it.
Here is an Alternative solution:
class ClickHelper private constructor() {
private val now: Long
get() = System.currentTimeMillis()
private var lastEventTimeMs: Long = 0
fun clickOnce(event: () -> Unit) {
if (now - lastEventTimeMs >= 300L) {
event.invoke()
}
lastEventTimeMs = now
}
companion object {
#Volatile
private var instance: ClickHelper? = null
fun getInstance() =
instance ?: synchronized(this) {
instance ?: ClickHelper().also { instance = it }
}
}
}
then you can use it anywhere you want:
Button(onClick = { ClickHelper.getInstance().clickOnce {
// Handle the click
} } ) { }
or:
Text(modifier = Modifier.clickable { ClickHelper.getInstance().clickOnce {
// Handle the click
} } ) { }
fun singleClick(onClick: () -> Unit): () -> Unit {
var latest: Long = 0
return {
val now = System.currentTimeMillis()
if (now - latest >= 300) {
onClick()
latest = now
}
}
}
Then you can use
Button(onClick = singleClick {
// TODO
})
Here is my solution.
It's based on https://stackoverflow.com/a/69914674/7011814
by I don't use GlobalScope (here is an explanation why) and I don't use MutableStateFlow as well (because its combination with GlobalScope may cause a potential memory leak).
Here is a head stone of the solution:
#OptIn(FlowPreview::class)
#Composable
fun <T>multipleEventsCutter(
content: #Composable (MultipleEventsCutterManager) -> T
) : T {
val debounceState = remember {
MutableSharedFlow<() -> Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
}
val result = content(
object : MultipleEventsCutterManager {
override fun processEvent(event: () -> Unit) {
debounceState.tryEmit(event)
}
}
)
LaunchedEffect(true) {
debounceState
.debounce(CLICK_COLLAPSING_INTERVAL)
.collect { onClick ->
onClick.invoke()
}
}
return result
}
#OptIn(FlowPreview::class)
#Composable
fun MultipleEventsCutter(
content: #Composable (MultipleEventsCutterManager) -> Unit
) {
multipleEventsCutter(content)
}
The first function can be used as a wrapper around your code like this:
MultipleEventsCutter { multipleEventsCutterManager ->
Button(
onClick = { multipleClicksCutter.processEvent(onClick) },
...
) {
...
}
}
And you can use the second one to create your own modifier, like next one:
fun Modifier.clickableSingle(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
) = composed(
inspectorInfo = debugInspectorInfo {
name = "clickable"
properties["enabled"] = enabled
properties["onClickLabel"] = onClickLabel
properties["role"] = role
properties["onClick"] = onClick
}
) {
multipleEventsCutter { manager ->
Modifier.clickable(
enabled = enabled,
onClickLabel = onClickLabel,
onClick = { manager.processEvent { onClick() } },
role = role,
indication = LocalIndication.current,
interactionSource = remember { MutableInteractionSource() }
)
}
}
Just add two lines in your styles. This will disable multitouch in whole application:
<style name="AppTheme" parent="...">
...
<item name="android:windowEnableSplitTouch">false</item>
<item name="android:splitMotionEvents">false</item>
</style>