Jetpack Compose: Launch ActivityResultContract request from Composable function - android

As of 1.2.0-beta01 of androidx.activity:activity-ktx, one can no longer launch the request created using Activity.registerForActivityResult(), as highlighted in the above link under "Behavior Changes" and seen in the Google issue here.
How should an application launch this request via a #Composable function now? Previously, an app could pass the instance of the MainActivity down the chain via using an Ambient and then launch the request easily.
The new behavior can be worked around by, for example, passing a class registering for the activity result down the chain after being instantiated outside of the Activity's onCreate function, and then launch the request in a Composable. However, registering the a callback to be executed after completion cannot be done this way.
One could get around this by creating custom ActivityResultContract, which, at launch, take a callback. However, this would mean that virtually none of the built-in ActivityResultContracts could be used with Jetpack Compose.
TL;DR
How would an app launch an ActivityResultsContract request from a #Composable function?

As of androidx.activity:activity-compose:1.3.0-alpha06, the registerForActivityResult() API has been renamed to rememberLauncherForActivityResult() to better indicate the returned ActivityResultLauncher is a managed object that is remembered on your behalf.
val result = remember { mutableStateOf<Bitmap?>(null) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) {
result.value = it
}
Button(onClick = { launcher.launch() }) {
Text(text = "Take a picture")
}
result.value?.let { image ->
Image(image.asImageBitmap(), null, modifier = Modifier.fillMaxWidth())
}

The Activity Result has two API surfaces:
The core ActivityResultRegistry. This is what actually does the underlying work.
A convenience interface in ActivityResultCaller that ComponentActivity and Fragment implement that ties the Activity Result request to the lifecycle of the Activity or Fragment
A Composable has a different lifetime than the Activity or Fragment (e.g., if you remove the Composable from your hierarchy, it should clean up after itself) and thus using the ActivityResultCaller APIs such as registerForActivityResult() is never the right thing to do.
Instead, you should be using the ActivityResultRegistry APIs directly, calling register() and unregister() directly. This is best paired with the rememberUpdatedState() and DisposableEffect to create a version of registerForActivityResult that works with a Composable:
#Composable
fun <I, O> registerForActivityResult(
contract: ActivityResultContract<I, O>,
onResult: (O) -> Unit
) : ActivityResultLauncher<I> {
// First, find the ActivityResultRegistry by casting the Context
// (which is actually a ComponentActivity) to ActivityResultRegistryOwner
val owner = ContextAmbient.current as ActivityResultRegistryOwner
val activityResultRegistry = owner.activityResultRegistry
// Keep track of the current onResult listener
val currentOnResult = rememberUpdatedState(onResult)
// It doesn't really matter what the key is, just that it is unique
// and consistent across configuration changes
val key = rememberSavedInstanceState { UUID.randomUUID().toString() }
// Since we don't have a reference to the real ActivityResultLauncher
// until we register(), we build a layer of indirection so we can
// immediately return an ActivityResultLauncher
// (this is the same approach that Fragment.registerForActivityResult uses)
val realLauncher = mutableStateOf<ActivityResultLauncher<I>?>(null)
val returnedLauncher = remember {
object : ActivityResultLauncher<I>() {
override fun launch(input: I, options: ActivityOptionsCompat?) {
realLauncher.value?.launch(input, options)
}
override fun unregister() {
realLauncher.value?.unregister()
}
override fun getContract() = contract
}
}
// DisposableEffect ensures that we only register once
// and that we unregister when the composable is disposed
DisposableEffect(activityResultRegistry, key, contract) {
realLauncher.value = activityResultRegistry.register(key, contract) {
currentOnResult.value(it)
}
onDispose {
realLauncher.value?.unregister()
}
}
return returnedLauncher
}
Then it is possible to use this in your own Composable via code such as:
val result = remember { mutableStateOf<Bitmap?>(null) }
val launcher = registerForActivityResult(ActivityResultContracts.TakePicturePreview()) {
// Here we just update the state, but you could imagine
// pre-processing the result, or updating a MutableSharedFlow that
// your composable collects
result.value = it
}
// Now your onClick listener can call launch()
Button(onClick = { launcher.launch() } ) {
Text(text = "Take a picture")
}
// And you can use the result once it becomes available
result.value?.let { image ->
Image(image.asImageAsset(),
modifier = Modifier.fillMaxWidth())
}

As of Activity Compose 1.3.0-alpha03 and beyond, there is a new utility function registerForActivityResult() that simplifies this process.
#Composable
fun RegisterForActivityResult() {
val result = remember { mutableStateOf<Bitmap?>(null) }
val launcher = registerForActivityResult(ActivityResultContracts.TakePicturePreview()) {
result.value = it
}
Button(onClick = { launcher.launch() }) {
Text(text = "Take a picture")
}
result.value?.let { image ->
Image(image.asImageBitmap(), null, modifier = Modifier.fillMaxWidth())
}
}
(From the sample given here )

Adding in case if someone is starting a new external intent. In My case, I wanted to launch a google sign-in prompt on click on the button in jetpack compose.
declare your intent launch
val startForResult =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
val intent = result.data
//do something here
}
}
launch your new activity or any intent.
Button(
onClick = {
//important step
startForResult.launch(googleSignInClient?.signInIntent)
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp),
shape = RoundedCornerShape(6.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = Color.Black,
contentColor = Color.White
)
) {
Image(
painter = painterResource(id = R.drawable.ic_logo_google),
contentDescription = ""
)
Text(text = "Sign in with Google", modifier = Modifier.padding(6.dp))
}
#googlesignin

For those who are not getting back a result with the gist provided by #ianhanniballake in my case the returnedLauncher actually captures an already disposed value of the realLauncher.
So while removing the layer of indirection should fix the issue, it's definitely not the optimal way of doing this.
Here's the updated version, until a better solution is found:
#Composable
fun <I, O> registerForActivityResult(
contract: ActivityResultContract<I, O>,
onResult: (O) -> Unit
): ActivityResultLauncher<I> {
// First, find the ActivityResultRegistry by casting the Context
// (which is actually a ComponentActivity) to ActivityResultRegistryOwner
val owner = AmbientContext.current as ActivityResultRegistryOwner
val activityResultRegistry = owner.activityResultRegistry
// Keep track of the current onResult listener
val currentOnResult = rememberUpdatedState(onResult)
// It doesn't really matter what the key is, just that it is unique
// and consistent across configuration changes
val key = rememberSavedInstanceState { UUID.randomUUID().toString() }
// TODO a working layer of indirection would be great
val realLauncher = remember<ActivityResultLauncher<I>> {
activityResultRegistry.register(key, contract) {
currentOnResult.value(it)
}
}
onDispose {
realLauncher.unregister()
}
return realLauncher
}

Related

LaunchedEffect triggering even thought composition should have ended and key changed

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.

Jetpack Compose Navigation loads screen infinitely

I am trying to implement Navigation using single activity and
multiple Composable Screens.
This is my NavHost:
#Composable
#ExperimentalFoundationApi
fun MyNavHost(
modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController(),
startDestination: String = HOME.route,
viewModelProvider: ViewModelProvider,
speech: SpeechHelper
) = NavHost(
modifier = modifier,
navController = navController,
startDestination = startDestination
) {
composable(route = HOME.route) {
with(viewModelProvider[HomeViewModel::class.java]) {
HomeScreen(
speech = speech,
viewModel = this,
modifier = Modifier.onKeyEvent { handleKeyEvent(it, this) }
) {
navController.navigateTo(it)
}
}
}
composable(route = Destination.VOLUME_SETTINGS.route) {
VolumeSettingsScreen(
viewModelProvider[VolumeSettingsViewModel::class.java]
) { navController.navigateUp() }
}
}
fun NavHostController.navigateTo(
navigateRoute: String,
willGoBackTo: String = HOME.route
): Unit = navigate(navigateRoute) {
popUpTo(willGoBackTo) { inclusive = true }
}
My screen looks like this:
#Composable
fun HomeScreen(
speech: SpeechHelper,
viewModel: HomeViewModel,
modifier: Modifier,
onNavigationRequested: (String) -> Unit
) {
MyBlindAssistantTheme {
val requester = remember { FocusRequester() }
val uiState by viewModel.uiState.collectAsStateWithLifecycle(
initialValue = UiState.Speak(
R.string.welcome_
.withStrResPlaceholder(R.string.text_home_screen)
.toSpeechUiModel()
)
)
uiState?.let {
when (it) {
is UiState.Speak -> speech.speak(it.speechUiModel)
is UiState.SpeakRes -> speech.speak(it.speechResUiModel.speechUiModel())
is UiState.Navigate -> onNavigationRequested(it.route)
}
}
Column(
modifier
.focusRequester(requester)
.focusable(true)
.fillMaxSize()
) {
val rowModifier = Modifier.weight(1f)
Row(rowModifier) {...}
}
LaunchedEffect(Unit) {
requester.requestFocus()
}
}
}
This is the ViewModel:
class HomeViewModel : ViewModel() {
private val mutableUiState: MutableStateFlow<UiState?> = MutableStateFlow(null)
val uiState = mutableUiState.asStateFlow()
fun onNavigateButtonClicked(){
mutableUiState.tryEmit(Destination.VOLUME_SETTINGS.route.toNavigationState())
}
}
When a button is clicked the ViewModel is called and the NavigateUiState is emitted... but it keeps being emitted after the next screen is loaded and that causes infinite screen reloading. What should be done to avoid this?
I re-implemented your posted code with 2 screens, HomeScreen and SettingScreen and stripped out some part of the UiState class and its usages.
The issue is in your HomeScreen composable, not in the StateFlow emission.
You have this mutableState
val uiState by viewModel.uiState.collectAsStateWithLifecycle(
initialValue = UiState.Speak
)
that is being observed in one of your when block that executes a navigation callback.
uiState?.let {
when (it) {
is UiState.Navigate -> {
onNavigationRequested(it.route)
}
UiState.Speak -> {
Log.d("UiState", "Speaking....")
}
}
When your ViewModel function is called
fun onNavigateButtonClicked(){
mutableUiState.tryEmit(UiState.Navigate(Destination.SETTINGS_SCREEN.route))
}
it will update uiState, setting its value to Navigate, observed by HomeScreen, satisfies the when block and then triggers the callback to navigate to the next screen.
Now based on the official Docs,
You should only call navigate() as part of a callback and not as part
of your composable itself, to avoid calling navigate() on every
recomposition.
but in your case, the navigation is triggered by an observed mutableState, and the mutableState is part of your HomeScreen composable.
It seems like when the navController performs a navigation and the NavHost being a Composable
#Composable
public fun NavHost(
navController: NavHostController,
startDestination: String,
modifier: Modifier = Modifier,
route: String? = null,
builder: NavGraphBuilder.() -> Unit
) { ... }
it will execute a re-composition, because of it, it will call again the HomeScreen (HomeScreen is not re-composed, its state remains the same) and because the HomeScreen's UiState value is still set to Navigate, it satisfies the when block, triggers again the callback to navigate, and NavHost re-composes, an infinite cycle is then created.
What I did (and its very ugly) is I created a boolean flag inside the viewModel, used it to wrap the callback conditionally,
uiState?.let {
when (it) {
is UiState.Navigate -> {
if (!viewModel.navigated) {
onNavigationRequested(it.route)
viewModel.navigated = true
} else {
// dirty empty else
}
}
UiState.Speak -> {
Log.d("UiState", "Speaking....")
}
}
}
and setting it to true afterwards, preventing the cycle.
I can hardly guess your compose implementation structure but I usually don't mix my one-time event actions and UiState, instead I have a separate UiEvent sealed class that will group "one-time" events such as the following:
Snackbar
Toast
Navigation
and having them emitted as a SharedFlow emissions because these events doesn't need any initial state or initial value.
Continuing further, I created this class
sealed class UiEvent {
data class Navigate(val route: String) : UiEvent()
}
use it in the ViewModel as a type (Navigate in this case),
private val _event : MutableSharedFlow<UiEvent> = MutableSharedFlow()
val event = _event.asSharedFlow()
fun onNavigateButtonClicked(){
viewModelScope.launch {
_event.emit(UiEvent.Navigate(Destination.SETTINGS_SCREEN.route))
}
}
and observe it in HomeScreen this way via LaunchedEffect, triggering the navigation in it without the callback being bound to any observed state.
LaunchedEffect(Unit) {
viewModel.event.collectLatest {
when (it) {
is UiEvent.Navigate -> {
onNavigationRequested(it.route)
}
}
}
}
This approach doesn't introduce the infinite navigation cycle and the dirty boolean checking is not needed anymore.
Also have a look this S.O post, similar to your case

Jetpack Compose application-wide conditional TopAppBar best practice

I have an Android Jetpack Compose application that uses BottomNavigation and TopAppBar composables. From the tab opened via BottomNavigation users can navigate deeper into the navigation graph.
The problem
The TopAppBar composable must represent the current screen, e.g. display its name, implement some options that are specific to the screen opened, the back button if the screen is high-level. However, Jetpack Compose seems to have no out-of-the-box solution to that, and developers must implement it by themselves.
So, obvious ideas come with obvious drawbacks, some ideas are better than others.
The baseline for tracking navigation, as suggested by Google (at least for BottomNavigation), is a sealed class containing objects that represent the current active screen. Specifically for my project, it's like this:
sealed class AppTab(val route: String, #StringRes val resourceId: Int, val icon: ImageVector) {
object Events: AppTab("events_tab", R.string.events, Icons.Default.EventNote)
object Projects: AppTab("projects_tab", R.string.projects, Icons.Default.Widgets)
object Devices: AppTab("devices_tab", R.string.devices, Icons.Default.DevicesOther)
object Employees: AppTab("employees_tab", R.string.employees, Icons.Default.People)
object Profile: AppTab("profile_tab", R.string.profile, Icons.Default.AccountCircle)
}
Now the TopAppBar can know what tab is opened, provided we remember the AppTab object, but how does it know if a screen is opened from within a given tab?
Solution 1 - obvious and obviously wrong
We provide each screen its own TopAppBar and let it handle all the necessary logic. Aside from a lot of code duplication, each screen's TopAppBar will be recomposed on opening the screen, and, as described in this post, will flicker.
Solution 2 - not quite elegant
From now on I decided to have a single TopAppBar in my project's top level composable, that will depend on a state with current screen saved. Now we can easily implement logic for Tabs.
To solve the problem of screens opened from within a Tab, I extended Google's idea and implemented a general AppScreen class that represents every screen that can be opened:
// This class represents any screen - tabs and their subscreens.
// It is needed to appropriately change top app bar behavior
sealed class AppScreen(#StringRes val screenNameResource: Int) {
// Employee-related
object Employees: AppScreen(R.string.employees)
object EmployeeDetails: AppScreen(R.string.profile)
// Events-related
object Events: AppScreen(R.string.events)
object EventDetails: AppScreen(R.string.event)
object EventNew: AppScreen(R.string.event_new)
// Projects-related
object Projects: AppScreen(R.string.projects)
// Devices-related
object Devices: AppScreen(R.string.devices)
// Profile-related
object Profile: AppScreen(R.string.profile)
}
I then save it to a state in the top-level composable in the scope of TopAppBar and pass currentScreenHandler as an onNavigate argument to my Tab composables:
var currentScreen by remember { mutableStateOf(defaultTab.asScreen()) }
val currentScreenHandler: (AppScreen) -> Unit = {navigatedScreen -> currentScreen = navigatedScreen}
// Somewhere in the bodyContent of a Scaffold
when (currentTab) {
AppTab.Employees -> EmployeesTab(currentScreenHandler)
// And other tabs
// ...
}
And from inside the Tab composable:
val navController = rememberNavController()
NavHost(navController, startDestination = "employees") {
composable("employees") {
onNavigate(AppScreen.Employees)
Employees(it.hiltViewModel(), navController)
}
composable("employee/{userId}") {
onNavigate(AppScreen.EmployeeDetails)
Employee(it.hiltViewModel())
}
}
Now the TopAppBar in the root composable knows about higher-level screens and can implement necessary logic. But doing this for every subscreen of an app? A considerable amount of code duplication, and architecture of communication between this app bar and a composable it represents (how the composable reacts to actions performed on the app bar) is yet to be composed (pun intended).
Solution 3 - the best?
I implemented a viewModel for handling the needed logic, as it seemed like the most elegant solution:
#HiltViewModel
class AppBarViewModel #Inject constructor() : ViewModel() {
private val defaultTab = AppTab.Events
private val _currentScreen = MutableStateFlow(defaultTab.asScreen())
val currentScreen: StateFlow<AppScreen> = _currentScreen
fun onNavigate(screen: AppScreen) {
_currentScreen.value = screen
}
}
Root composable:
val currentScreen by appBarViewModel.currentScreen.collectAsState()
But it didn't solve the code duplication problem of the second solution. First of all, I had to pass this viewModel to the root composable from MainActivity, as there appears to be no other way of accessing it from inside a composable. So now, instead of passing a currentScreenHandler to Tab composables, I pass a viewModel to them, and instead of calling the handler on navigate event, I call viewModel.onNavigate(AppScreen), so there's even more code! At least, I maybe can implement a communication mechanism mentioned in the previous solution.
The question
For now the second solution seems to be the best in terms of code amount, but the third one allows for communication and more flexibility down the line for some yet to be requested features. I may be missing something obvious and elegant. Which of my implementations you consider the best, and if none, what would you do to solve this problem?
Thank you.
I use a single TopAppBar in the Scaffold and use a different title, drop-down menu, icons, etc by raising events from the Composables. That way, I can use just a single TopAppBar with different values. Here is an example:
val navController = rememberNavController()
var canPop by remember { mutableStateOf(false) }
var appTitle by remember { mutableStateOf("") }
var showFab by remember { mutableStateOf(false) }
var showDropdownMenu by remember { mutableStateOf(false) }
var dropdownMenuExpanded by remember { mutableStateOf(false) }
var dropdownMenuName by remember { mutableStateOf("") }
var topAppBarIconsName by remember { mutableStateOf("") }
val scaffoldState = rememberScaffoldState()
val scope = rememberCoroutineScope()
val tourViewModel: TourViewModel = viewModel()
val clientViewModel: ClientViewModel = viewModel()
navController.addOnDestinationChangedListener { controller, _, _ ->
canPop = controller.previousBackStackEntry != null
}
val navigationIcon: (#Composable () -> Unit)? =
if (canPop) {
{
IconButton(onClick = { navController.popBackStack() }) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "Back Arrow"
)
}
}
} else {
{
IconButton(onClick = {
scope.launch {
scaffoldState.drawerState.apply {
if (isClosed) open() else close()
}
}
}) {
Icon(Icons.Filled.Menu, contentDescription = null)
}
}
}
Scaffold(
scaffoldState = scaffoldState,
drawerContent = {
DrawerContents(
navController,
onMenuItemClick = { scope.launch { scaffoldState.drawerState.close() } })
},
topBar = {
TopAppBar(
title = { Text(appTitle) },
navigationIcon = navigationIcon,
elevation = 8.dp,
actions = {
when (topAppBarIconsName) {
"ClientDirectoryScreenIcons" -> {
// search icon on client directory screen
IconButton(onClick = {
clientViewModel.toggleSearchBar()
}) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = "Search Contacts"
)
}
}
}
if (showDropdownMenu) {
IconButton(onClick = { dropdownMenuExpanded = true }) {
Icon(imageVector = Icons.Filled.MoreVert, contentDescription = null)
DropdownMenu(
expanded = dropdownMenuExpanded,
onDismissRequest = { dropdownMenuExpanded = false }
) {
// show different dropdowns based on different screens
when (dropdownMenuName) {
"ClientDirectoryScreenDropdown" -> ClientDirectoryScreenDropdown(
onDropdownMenuExpanded = { dropdownMenuExpanded = it })
}
}
}
}
}
)
},
...
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
NavHost(
navController = navController,
startDestination = Screen.Tours.route
) {
composable(Screen.Tours.route) {
TourScreen(
tourViewModel = tourViewModel,
onSetAppTitle = { appTitle = it },
onShowDropdownMenu = { showDropdownMenu = it },
onTopAppBarIconsName = { topAppBarIconsName = it }
)
}
Then set the TopAppBar values from different screens like this:
#Composable
fun TourScreen(
tourViewModel: TourViewModel,
onSetAppTitle: (String) -> Unit,
onShowDropdownMenu: (Boolean) -> Unit,
onTopAppBarIconsName: (String) -> Unit
) {
LaunchedEffect(Unit) {
onSetAppTitle("Tours")
onShowDropdownMenu(false)
onTopAppBarIconsName("")
}
...
Not probably the perfect way of doing it, but no duplicate code.

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>

Can I replace produceState with mutableStateOf in the Compose sample project?

The following Code A is from the project.
uiState is created by the delegate produceState, can I use mutableStateOf instead of produceState? If so, how can I write code?
Why can't I use Code B in the project?
Code A
#Composable
fun DetailsScreen(
onErrorLoading: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DetailsViewModel = viewModel()
) {
val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
val cityDetailsResult = viewModel.cityDetails
value = if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
}
when {
uiState.cityDetails != null -> {
...
}
#HiltViewModel
class DetailsViewModel #Inject constructor(
private val destinationsRepository: DestinationsRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val cityName = savedStateHandle.get<String>(KEY_ARG_DETAILS_CITY_NAME)!!
val cityDetails: Result<ExploreModel>
get() {
val destination = destinationsRepository.getDestination(cityName)
return if (destination != null) {
Result.Success(destination)
} else {
Result.Error(IllegalArgumentException("City doesn't exist"))
}
}
}
data class DetailsUiState(
val cityDetails: ExploreModel? = null,
val isLoading: Boolean = false,
val throwError: Boolean = false
)
Code B
#Composable
fun DetailsScreen(
onErrorLoading: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DetailsViewModel = viewModel()
) {
val cityDetailsResult = viewModel.cityDetails
val uiState=if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
...
uiState is created by the delegate produceState, can I use mutableStateOf instead of produceState? If so, how can I write code?
No, you can't write it using the mutableStateOf (direct initialization not possible). In order to understand why it not possible we need to understand the use of produceState
According to documentation available here
produceState launches a coroutine scoped to the Composition that can
push values into a returned State. Use it to convert non-Compose state
into Compose state, for example bringing external subscription-driven
state such as Flow, LiveData, or RxJava into the Composition.
So basically it is compose way of converting non-Compose state to compose the state.
if you still want to use mutableStateOf you can do something like this
var uiState = remember { mutableStateOf(DetailsUIState())}
LaunchedEffect(key1 = someKey, block = {
uiState = if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
})
Note: here someKey might be another variable which handles the recomposition of the state
What is wrong with this approach?
As you can see it's taking another variable someKey to recomposition. and handling it is quite tough compared to produceState
Why can't I use Code B in the project?
The problem with code B is you don't know whether the data is loaded or not while displaying the result. It's not observing the viewModel's data but its just getting the currently available data and based on that it gives the composition.
Imagine if the viewModel is getting data now you will be having UiState with isLoading = true but after some time you get data after a successful API call or error if it fails, at that time the composable function in this case DetailsScreen doesn't know about it at all unless you are observing the Ui state somewhere above this composition and causing this composition to recompose based on newState available.
But in produceState the state of the ui will automatically changed once the suspended network call completes ...

Categories

Resources