Problem using LaunchedEffect scope in jetpack compose - android

I have two LaunchedEffect scopes like this :
LaunchedEffect(key1 = mainViewModel.myvlue.value){
mainViewModel.updatemyvalue(1f)
}
LaunchedEffect(key1 = mainViewModel.myvlue.value){
mainViewModel.updatemyvalue(0f)
}
Both are implemented inside this method :
#Composable
fun TabContent( pagerState: PagerState,count:Int,mainViewModel: MainViewModel) {
HorizontalPager(state = pagerState, count = count) { index ->
when (index) {
0 -> {
LaunchedEffect(key1 = mainViewModel.myvlue.value){
mainViewModel.updatemyvalue(1f)
}
SecondScreen(mainViewModel)
}
1 -> {
LaunchedEffect(key1 = mainViewModel.myvalue.value) {
mainViewModel.updatemyvalue(0f)
}
firstScreen(mainViewModel)
}
}
}
}
The problem is that when one of these scopes launches, second one will be automatically called .
I know this is normal that when the key1 value changes scopes will be relaunched . but I
just want them to be called separately when I navigate to their respective page inside HorizontalPager.
As you can see above, they will be called simultaneously when one of the executes.
what should I do ?

You should use the current index below in the way I posted in the attachment
Please test
// Page change callback
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
when (page) {
0 -> viewModel.updatemyvalue() // First page
1 -> // Second page
else -> // Other pages
}
}
}

Related

How to react to to user scrolling on Accompanist Pager?

The Accompanist Pager documentation suggests reacting to page changes as follows :
val pagerState = rememberPagerState()
LaunchedEffect(pagerState) {
// Collect from the pager state a snapshotFlow reading the currentPage
snapshotFlow { pagerState.currentPage }.collect { page ->
AnalyticsService.sendPageSelectedEvent(page)
}
}
VerticalPager(
count = 10,
state = pagerState,
) { page ->
Text(text = "Page: $page")
}
How can we distinguish scrolling to a page from user gesture, and scrolling to a page using animateScrollingToPage() / scrollingToPage() ? I would like to perform a certain action only on user scroll, not on programmatic scroll.
Did you try :
val isScrollInProgressFlow = snapshotFlow { pagerState.isScrollInProgress }
I had a similar problem and I solved it using interactionsSource. No interaction is emitted when automatic scroll happens. But it emits interactions on user actions.
You can also use other interaction source types.
LaunchedEffect(Unit) {
pagerState.interactionSource.interactions
.collect {
if (it is DragInteraction.Start) {
Log.d("test", "User interaction is started")
}
}
}
In my case I had to cancel automatic scroll on user drag. The simplified version looks like this:
LaunchedEffect(Unit) {
val autoscrollJob = launch {
while(true) {
yield() // exit the coroutine if the job is completed
delay(autoScrollDelay)
animateScrollToPage(
page = (currentPage + 1) % pageCount
)
}
}
launch {
interactionSource.interactions
.collect {
if (it is DragInteraction.Start) {
autoscrollJob.cancel()
}
}
}
}

Jetpack compose paginated list UI updation on item Click

How to make UI changes in a paginated list with jetpack compose.
Use case
I have a paginated list which has data name(string) and like(boolean). If i click on the particular item in the list, i need to place a like button in the UI. But the image is not updated in UI based on condition.
Snippet
userList -> LazyPagingItems<AllDoctorsResponse.Data.Doctor>
//viewModel
userList.itemSnapshotList.find { it?.id == user.id }?.liked = true
// Composable
items(userList.itemCount){ index ->
userList[index]?.let {
if (it.liked == true) {
UserCardWithLike(it, onClick = { userId ->
onUserCardClicked(userId)
}, onLikeChange = { isLiked, user ->
onLikeChange(isLiked, user)
})
} else {
UserCard(it, onClick = { userId ->
onUserCardClicked(userId)
}, onLikeChange = { isLiked, user ->
onLikeChange(isLiked, user)
})
}
}
}
I don't use paging3 anymore, so I didn't test this code, but I think it will work:
items(userList.itemCount){ index ->
userList[index]?.let {
var liked by rememberSaveable { mutableStateOf(it.liked) }
if (liked == true) {
UserCardWithLike(
it,
onClick = { userId -> onUserCardClicked(userId) },
onLikeChange = { isLiked, user -> liked = false }
)
} else {
UserCard(
it,
onClick = { userId -> onUserCardClicked(userId) },
onLikeChange = { isLiked, user -> liked = true }
)
}
}
}
If you want something like notifyItemChange, it's not possible in Paging3. In that case, I suggest trying to rewrite the paging library, which is surprisingly easy.
https://gist.github.com/FishHawk/6e4706646401bea20242bdfad5d86a9e

How to disable simultaneous clicks on multiple items in Jetpack Compose List / Column / Row (out of the box debounce?)

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>

Jetpack Compose Navigation - Bottom Nav Multiple Back Stack - View Model Scoping Issue

So I have two tabs, Tab A and Tab B. Each tab has its own back stack. I implemented the multiple back stack navigation using code in this google docs
val navController = rememberNavController()
Scaffold(
bottomBar = {
BottomNavigation {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
items.forEach { screen ->
BottomNavigationItem(
icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
label = { Text(stringResource(screen.resourceId)) },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}
)
}
}
}
) {
NavHost(navController, startDestination = A1.route) {
composable(A1.route) {
val viewModelA1 = hiltViewModel()
A1(viewModelA1)
}
composable(A2.route) {
val viewModelA2 = hiltViewModel()
A2(viewModelA2)
}
composable(A3.route) {
val viewModelA3 = hiltViewModel()
A3(viewModelA3)
}
}
}
Tab A has 3 screens (Screen A1 -> Screen A2 -> Screen A3). I use the hiltViewModel() function to instantiate the view model and I invoked it inside the composable() block for each screen
The issue is when I'm navigating from A1 to A2 to A3 and then when I change tab to Tab B, the view model for Screen A2 seems like it's being disposed (onCleared is called). So when I go back to Tab A displaying Screen A3 then hit back to Screen A2, the view model for A2 is instantiated again (init block is called again). What I wanted to achieve is to retain the view model for A2 for this flow until I back out of A2.
Is this even possible?
This seems like a bug when you click on the next navigation item too fast, while the current view appear transition is not yet finished. This is a known issue, please star it to bring more attention.
Meanwhile you can wait current screen transition to finish before navigating to the next one. To do so you can check visibleEntries variable and navigate only after it contains only single item.
Also I think that current documentation provide not the best example of bottom navigation, because if you're not on a start destination screen, pressing back button will bring you back to the start destination, when I expect the view to be dismissed. So I've changed how you navigate too, if you're fine with the documentation behaviour, you can replace content of fun navigate() with your own.
val navController = rememberNavController()
var waitEndAnimationJob by remember { mutableStateOf<Job?>(null)}
Scaffold(
bottomBar = {
BottomNavigation {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val scope = rememberCoroutineScope()
items.forEach { screen ->
fun navigate() {
navController.navigate(screen.route) {
val navigationRoutes = items
.map(Screen::route)
val firstBottomBarDestination = navController.backQueue
.firstOrNull { navigationRoutes.contains(it.destination.route) }
?.destination
// remove all navigation items from the stack
// so only the currently selected screen remains in the stack
if (firstBottomBarDestination != null) {
popUpTo(firstBottomBarDestination.id) {
inclusive = true
saveState = true
}
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}
BottomNavigationItem(
icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
label = { Text(stringResource(screen.resourceId)) },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
// if we're already waiting for an other screen to start appearing
// we need to cancel that job
waitEndAnimationJob?.cancel()
if (navController.visibleEntries.value.count() > 1) {
// if navController.visibleEntries has more than one item
// we need to wait animation to finish before starting next navigation
waitEndAnimationJob = scope.launch {
navController.visibleEntries
.collect { visibleEntries ->
if (visibleEntries.count() == 1) {
navigate()
waitEndAnimationJob = null
cancel()
}
}
}
} else {
// otherwise we can navigate instantly
navigate()
}
}
)
}
}
}
) { innerPadding ->
// ...
}
Found the root cause of this. I was using these dependencies and they don't seem to go together.
androidx.hilt:hilt-navigation-compose:1.0.0-alpha03
androidx.navigation:navigation-compose:2.4.0-alpha10"
I removed the navigation:navigation-compose dependency and it seemed to work fine now.

Sticky headers with paging library in Jetpack Compose

I'm currently playing around with the new Jetpack compose UI toolkit and I like it a lot. One thing I could not figure out is how to use stickyHeaders in a LazyColumn which is populated by the paging library. The non-paging example from the documentation is:
val grouped = contacts.groupBy { it.firstName[0] }
fun ContactsList(grouped: Map<Char, List<Contact>>) {
LazyColumn {
grouped.forEach { (initial, contactsForInitial) ->
stickyHeader {
CharacterHeader(initial)
}
items(contactsForInitial) { contact ->
ContactListItem(contact)
}
}
}
}
Since I'm using the paging library I cannot use the groupedBy so I tried to use the insertSeparators function on PagingData and insert/create the headers myself like this (please ignore the legacy Date code, it's just for testing):
// On my flow
.insertSeparators { before, after ->
when {
before == null -> ListItem.HeaderItem(after?.workout?.time ?: 0)
after == null -> ListItem.HeaderItem(before.workout.time)
(Date(before.workout.time).day != Date(after.workout.time).day) ->
ListItem.HeaderItem(before.workout.time)
// Return null to avoid adding a separator between two items.
else -> null
}
}
// In my composeable
LazyColumn {
items(workoutItems) {
when(it) {
is ListItem.HeaderItem -> this#LazyColumn.stickyHeader { Header(it) }
is ListItem.SongItem -> WorkoutItem(it)
}
}
}
But this produces a list of all my items and the header items are appended at the end. Any ideas what is the right way to use the stickyHeader function when using the paging library?
I got it to work by looking into the source code of the items function: You must not call stickyHeader within the items function. No need to modify the PagingData flow at all. Just use peek to get the next item without triggering a reload and then layout it:
LazyColumn {
val itemCount = workoutItems.itemCount
var lastWorkout: Workout? = null
for(index in 0 until itemCount) {
val workout = workoutItems.peek(index)
if(lastWorkout?.time != workout?.time) stickyHeader { Header(workout) }
item { WorkoutItem(workoutItems.getAsState(index).value) } // triggers reload
lastWorkout = workout
}
}
I believe the issue in your code was that you were calling this#LazyColumn from inside an LazyItemScope.
I experimented too with insertSeparators and reached this working LazyColumn code:
LazyColumn {
for (index in 0 until photos.itemCount) {
when (val peekData = photos.peek(index)) {
is String? -> stickyHeader {
Text(
text = (photos.getAsState(index).value as? String).orEmpty(),
)
}
is Photo? -> item(key = { peekData?.id }) {
val photo = photos.getAsState(index).value as? Photo
...
}
}
}
}

Categories

Resources