How to enable Compose snackbar's swipe-to-dismiss behavior - android

Simple code below for showing the Compose Snackbar
This code correctly shows the Snackbar, when onClick event occurs.
val scaffoldState = rememberScaffoldState() // this contains the `SnackbarHostState`
val coroutineScope = rememberCoroutineScope()
Scaffold(
modifier = Modifier,
scaffoldState = scaffoldState // attaching `scaffoldState` to the `Scaffold`
) {
Button(
onClick = {
coroutineScope.launch { // using the `coroutineScope` to `launch` showing the snackbar
// taking the `snackbarHostState` from the attached `scaffoldState`
val snackbarResult = scaffoldState.snackbarHostState.showSnackbar(
message = "This is your message",
actionLabel = "Do something."
)
when (snackbarResult) {
SnackbarResult.Dismissed -> Log.d("SnackbarDemo", "Dismissed")
SnackbarResult.ActionPerformed -> Log.d("SnackbarDemo", "Snackbar's button clicked")
}
}
}
) {
Text(text = "A button that shows a Snackbar")
}
}
How to dismiss snackbar on right/left-swipe?

The SnackbarHost has no such functionality. But you can extend it with the nackbarHost argument.
Also if you want the snackbar to disappear only with a swipe, you probably need to set the duration to Indefinite:
Scaffold(
modifier = Modifier,
scaffoldState = scaffoldState,
snackbarHost = { SwipeableSnackbarHost(it) } // modification 1
) {
Button(
onClick = {
coroutineScope.launch {
val snackbarResult = scaffoldState.snackbarHostState.showSnackbar(
message = "This is your message",
actionLabel = "Do something.",
duration = SnackbarDuration.Indefinite, // modification 2
)
when (snackbarResult) {
SnackbarResult.Dismissed -> Log.d("SnackbarDemo", "Dismissed")
SnackbarResult.ActionPerformed -> Log.d(
"SnackbarDemo",
"Snackbar's button clicked"
)
}
}
}
) {
Text(text = "A button that shows a Snackbar")
}
}
SwipeableSnackbarHost inspired by this answer
enum class SwipeDirection {
Left,
Initial,
Right,
}
#Composable
fun SwipeableSnackbarHost(hostState: SnackbarHostState) {
if (hostState.currentSnackbarData == null) { return }
var size by remember { mutableStateOf(Size.Zero) }
val swipeableState = rememberSwipeableState(SwipeDirection.Initial)
val width = remember(size) {
if (size.width == 0f) {
1f
} else {
size.width
}
}
if (swipeableState.isAnimationRunning) {
DisposableEffect(Unit) {
onDispose {
when (swipeableState.currentValue) {
SwipeDirection.Right,
SwipeDirection.Left -> {
hostState.currentSnackbarData?.dismiss()
}
else -> {
return#onDispose
}
}
}
}
}
val offset = with(LocalDensity.current) {
swipeableState.offset.value.toDp()
}
SnackbarHost(
hostState,
snackbar = { snackbarData ->
Snackbar(
snackbarData,
modifier = Modifier.offset(x = offset)
)
},
modifier = Modifier
.onSizeChanged { size = Size(it.width.toFloat(), it.height.toFloat()) }
.swipeable(
state = swipeableState,
anchors = mapOf(
-width to SwipeDirection.Left,
0f to SwipeDirection.Initial,
width to SwipeDirection.Right,
),
thresholds = { _, _ -> FractionalThreshold(0.3f) },
orientation = Orientation.Horizontal
)
)
}

Related

How do I cancel an event after another one has started?

I am having an issue, where after a Snackbar appears the FAB won't perform navigation for the period of the Snackbar message. If I click it multiple times when the Snackbar is displayed it will stack and open multiple instances of one screen (only after the Snackbar is gone). How do I cancel showing the Snackbar on click of the FAB and preserve the functionality of the FAB at all times?
TasksList.kt:
#OptIn(ExperimentalMaterial3Api::class)
#Composable
fun TasksListScreen(
modifier: Modifier = Modifier,
onNavigate: (UiEvent.Navigate) -> Unit,
viewModel: TaskListViewModel = hiltViewModel()
) {
val tasks = viewModel.tasks.collectAsState(initial = emptyList())
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(key1 = true) {
viewModel.uiEvent.collect { event ->
when (event) {
is UiEvent.ShowSnackbar -> {
val result = snackbarHostState.showSnackbar(
message = event.message,
actionLabel = event.action,
duration = SnackbarDuration.Long,
)
if (result == SnackbarResult.ActionPerformed) {
viewModel.onEvent(TaskListEvent.OnUndoDeleteTask)
}
}
is UiEvent.Navigate -> onNavigate(event)
else -> Unit
}
}
}
Scaffold(
snackbarHost = {
SnackbarHost(snackbarHostState) { data ->
Snackbar(
shape = RoundedShapes.medium,
actionColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.background,
snackbarData = data
)
}
},
floatingActionButton = {
FloatingActionButton(
shape = RoundedShapes.medium,
onClick = { viewModel.onEvent(TaskListEvent.OnAddTask) },
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.background
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.fab_cd)
)
}
},
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.app_name)) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.background
)
)
},
) { padding ->
LazyColumn(
state = rememberLazyListState(),
verticalArrangement = spacedBy(12.dp),
contentPadding = PaddingValues(vertical = 16.dp),
modifier = modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
) {
items(items = tasks.value, key = { task -> task.hashCode() }) { task ->
val currentTask by rememberUpdatedState(newValue = task)
val dismissState = rememberDismissState(confirmValueChange = {
if (it == DismissValue.DismissedToStart) {
viewModel.onEvent(TaskListEvent.OnDeleteTask(currentTask))
}
true
})
SwipeToDismiss(state = dismissState,
directions = setOf(DismissDirection.EndToStart),
background = { },
dismissContent = {
TaskItem(
task = task, modifier = modifier
)
})
}
}
}
}
TaskListViewModel:
#HiltViewModel
class TaskListViewModel #Inject constructor(private val repository: TaskRepositoryImplementation) :
ViewModel() {
val tasks = repository.getTasks()
private val _uiEvent = Channel<UiEvent>()
val uiEvent = _uiEvent.receiveAsFlow()
private var deletedTask: Task? = null
fun onEvent(event: TaskListEvent) {
when (event) {
is TaskListEvent.OnAddTask -> {
sendUiEvent(UiEvent.Navigate(Routes.ADD_EDIT_TASK))
}
is TaskListEvent.OnEditClick -> {
sendUiEvent(UiEvent.Navigate(Routes.ADD_EDIT_TASK + "?taskId=${event.task.id}"))
}
is TaskListEvent.OnDeleteTask -> {
val context = TaskApp.instance?.context
viewModelScope.launch(Dispatchers.IO) {
deletedTask = event.task
repository.deleteTask(event.task)
if (context != null) {
sendUiEvent(
UiEvent.ShowSnackbar(
message = context.getString(R.string.snackbar_deleted),
action = context.getString(R.string.snackbar_action)
)
)
}
}
}
is TaskListEvent.OnUndoDeleteTask -> {
deletedTask?.let { task ->
viewModelScope.launch {
repository.addTask(task)
}
}
}
}
}
private fun sendUiEvent(event: UiEvent) {
viewModelScope.launch(Dispatchers.IO) {
_uiEvent.send(event)
}
}
}

Bottom Sheet not being shown if hiding keyboard

If my keyboard is opened, then on clicking of button if i try to hide my keyboard and change sheetState to show() then my keyboard hides but my sheet is not visible.
Mimicing opening of sheetState on result from api call.
Note - Sheet is shown for first time only
setContent {
val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
val key = LocalSoftwareKeyboardController.current
ModalBottomSheetLayout(
sheetState = sheetState,
sheetContent = {
repeat(20) {
Text(text = "$it")
}
}
) {
Column {
var otpResponse by remember { mutableStateOf<Boolean?>(null) }
if (otpResponse == false) {
LaunchedEffect(key1 = Unit, block = {
delay(180)
otpResponse = true
})
}
if (otpResponse == true) {
LaunchedEffect(key1 = Unit, block = {
sheetState.show()
})
}
Column {
var string by remember { mutableStateOf("") }
TextField(value = string, onValueChange = { string = it })
Button(onClick = {
key?.hide()
otpResponse = false
}) {
Text(text = "TEST")
}
}
}
}
}
I have modified your code have look which is working without delay
#OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class)
#Preview(showBackground = true)
#Composable
fun DefaultPreview(){
val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
val key = LocalSoftwareKeyboardController.current
val scope = rememberCoroutineScope()
ModalBottomSheetLayout(
sheetState = sheetState,
sheetContent = {
repeat(20) {
Text(text = "$it")
}
}
) {
Column {
Column {
var string by remember { mutableStateOf("") }
TextField(value = string, onValueChange = { string = it })
Button(onClick = {
key?.hide()
scope.launch{
sheetState.show()
}
}) {
Text(text = "TEST")
}
}
}
}
}
thanks for my colleague to fix this

How to simulate a swipe gesture on a Card in a Composable Test?

I have an app that has a composable MyCard().
I am testing the app in myTest() and would like to simulate a swipeRight gesture on the card.
When I use performTouchInput { swipeRight() } nothing happens. The UI does not update and card stays in the same place.
How can I simulate swipe right gesture on the card? What am I missing?
Desired Result
Code
#OptIn(ExperimentalCoroutinesApi::class)
class MyTest {
#get:Rule
val composeRule = createComposeRule()
#Before
fun setUp() {
composeRule.setContent {
MyCard()
}
}
#Test
fun myTest() = runTest {
composeRule.onNodeWithTag("DraggableCard")
.performTouchInput { swipeRight() }
}
}
#SuppressLint("UnusedTransitionTargetStateParameter")
#Composable
fun MyCard() {
var swipeState by remember { mutableStateOf(false) }
val transitionState = remember {
MutableTransitionState(swipeState).apply { targetState = !swipeState }
}
val transition = updateTransition(transitionState, "cardTransition")
val offsetTransition by transition.animateFloat(
label = "cardOffsetTransition",
transitionSpec = { tween(durationMillis = 300) },
targetValueByState = { if (swipeState) 75f else 0f },)
Card(
modifier = Modifier
.testTag("DraggableCard")
.fillMaxWidth()
.height(35.dp)
.padding(horizontal = 4.dp, vertical = 1.dp)
.offset { IntOffset(offsetTransition.roundToInt(), 0) }
.pointerInput(Unit) {
detectHorizontalDragGestures { _, dragAmount ->
when {
dragAmount >= 6 -> { swipeState = true }
dragAmount < -6 -> { swipeState = false }
}
}
},
backgroundColor = Color.Gray,
content = { Text(text = "Hello") }
)
}
I had to be careful with which node I was swiping right on and how I controlled the tests autoclock.
If it is not working, try swiping right different nodes in the semantics hierarchy, not the card itself.
Below is the pseudo code that eventually worked for me.
private fun swipeRight(symbol: String) {
composeRule.waitForIdle()
composeRule.mainClock.autoAdvance = false
composeRule.onNode(hasText("DraggableCard"))
.performTouchInput { swipeRight() }
composeRule.mainClock.advanceTimeBy(ANIMATION_DURATION.toLong() + 5L) /*add 5s buffer*/
composeRule.mainClock.autoAdvance = true
composeRule.onNode(hasText("DraggableCard")).assertExists()
.performClick()
}
#SuppressLint("UnusedTransitionTargetStateParameter")
#Composable
fun DraggableCard(
isSwiped: Boolean,
cardHeight: Dp,
cardOffset: Float,
) {
val transitionState = remember {
MutableTransitionState(isSwiped).apply {
targetState = !isSwiped
}
}
val transition = updateTransition(transitionState, "cardTransition")
val offsetTransition by transition.animateFloat(
label = "cardOffsetTransition",
transitionSpec = { tween(durationMillis = 300) },
targetValueByState = { if (isSwiped) cardOffset else 0f },
)
Card(
modifier = Modifier
.semantics(mergeDescendants = false) {
testTag = "DraggableCard"
}
.fillMaxWidth()
.height(cardHeight)
.offset { IntOffset(offsetTransition.roundToInt(), 0) }
.pointerInput(Unit) {
detectHorizontalDragGestures { _, dragAmount ->
when {
dragAmount >= 6 -> {}
dragAmount < -6 -> {}
}
}
},
content =
{ Text(text = "Hello") }
)
}

Jetpack Compose Navigation calls navigate function repeatedly and freezes

Using a when function, I need to navigate to particular graph in my compose navigation tree. The issue is when navigating away from the current screen to the new one, the screen repeatedly flashes and freezes up. Checking the debugger, it shows me that the code just constantly jumps between LaunchedEffect and navController.navigate(ProDashboardScreens.ProDashboardGraph.route).
Can anyone see a problem in my set up?
composable(BasicDashboardScreens.Manager.route) {
val managerViewModel: ManagerViewModel = hiltViewModel()
val managerState by managerViewModel.state.collectAsState()
ManagerScreen(managerState, managerViewModel)
LaunchedEffect(key1 = managerViewModel.navigationEvent) {
managerViewModel.navigationEvent.collect {
when (it) {
AuthenticationScreens.Login -> {
navController.navigate(AuthenticationScreens.Login.route)
}
AuthenticationScreens.Register -> {
navController.navigate(AuthenticationScreens.Register.route)
}
ProDashboardScreens.ProDashboardGraph -> {
navController.navigate(ProDashboardScreens.ProDashboardGraph.route)
}
else -> Unit
}
}
}
}
when {
!state.userAuthenticated -> {
UnauthenticatedScreen(
title = {
Text(
text = stringResource(id = R.string.login_or_register),
style = MaterialTheme.typography.subtitle1.copy(
fontWeight = FontWeight.Bold
),
textAlign = TextAlign.Center
)
},
buttonOne = {
AppButton(
modifier = Modifier
.fillMaxWidth()
.height(64.dp),
buttonText = stringResource(id = R.string.login),
buttonColor = Color.Blue,
enabled = true,
onClick = {
events.loginClicked()
}
)
},
buttonTwo = {
AppButton(
modifier = Modifier
.fillMaxWidth()
.height(64.dp),
buttonText = stringResource(id = R.string.register),
buttonColor = Color.Blue,
enabled = true,
onClick = {
events.registerClicked()
}
)
}
)
}
state.userAuthenticated && !state.userProfile.proVersion -> {
UnauthenticatedScreen(
title = {
Text(
text = stringResource(id = R.string.purchase_pro_enable_access),
style = MaterialTheme.typography.subtitle1.copy(
fontWeight = FontWeight.Bold
),
textAlign = TextAlign.Center
)
},
buttonOne = {
AppButton(
modifier = Modifier
.fillMaxWidth()
.height(64.dp),
buttonText = stringResource(id = R.string.purchase_pro),
buttonColor = Color.Blue,
enabled = true,
onClick = {
events.purchaseProClicked()
}
)
}
)
}
else -> {
events.navigateToProGraph()
}
}
#HiltViewModel
class ManagerViewModel #Inject constructor(
private val userProfileDao: UserProfileDao,
private val authManager: AuthManager
) : ViewModel(), ManagerEvents {
private val _state = MutableStateFlow(ManagerState.defaultState)
val state: StateFlow<ManagerState> = _state
private val _navigationEvent = MutableSharedFlow<NavigationRoute>()
val navigationEvent: SharedFlow<NavigationRoute> = _navigationEvent
init {
viewModelScope.launch {
_state.value = state.value.copy(
userProfile = userProfileDao.getUserProfile().first() ?: UserProfile.defaultState,
userAuthenticated = authManager.isAuthenticated()
)
}
}
override fun loginClicked() {
viewModelScope.launch {
_navigationEvent.emit(AuthenticationScreens.Login)
}
}
override fun registerClicked() {
viewModelScope.launch {
_navigationEvent.emit(AuthenticationScreens.Register)
}
}
override fun navigateToProGraph() {
viewModelScope.launch {
_navigationEvent.emit(ProDashboardScreens.ProDashboardGraph)
}
}
}

Jetpack compose popup menu

I am trying to rewrite my project UI using Jetpack compose. Any idea to add popup menu using jetpack compose in android?
like this one
I tried to implement it using Stack() layout but the results are not up to the mark.
#Composable
fun LiveDataComponentList(productList: List<Product>) {
AdapterList(data = productList) { product ->
Stack() {
Clickable(onClick = { PopupState.toggleOwner(product) }) {
Card(...) {...}
if (PopupState.owner == product) {
Surface(color = Color.Gray,modifier = Modifier.gravity(Alignment.TopEnd) + Modifier.padding(12.dp)) {
Column() {
Text("menu 1")
Text("menu 2")
Text("menu 3")
Text("menu 4")
Text("menu 5")
}
}
}
}
}
}
and PopupState is
#Model
object PopupState
{
var owner:Product?=null
fun toggleOwner(item:Product)
{
if(owner==item)
owner=null
else
owner=item
}
}
result is
screenshot
You can use the DropdownMenu.
Something like:
var expanded by remember { mutableStateOf(false) }
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(onClick = { /* Handle refresh! */ }) {
Text("Refresh")
}
DropdownMenuItem(onClick = { /* Handle settings! */ }) {
Text("Settings")
}
Divider()
DropdownMenuItem(onClick = { /* Handle send feedback! */ }) {
Text("Send Feedback")
}
}
About the position, as explained in the documentation:
A DropdownMenu behaves similarly to a Popup, and will use the position of the parent layout to position itself on screen. Commonly a DropdownMenu will be placed in a Box with a sibling that will be used as the 'anchor'.
Example:
Box(modifier = Modifier.fillMaxSize().wrapContentSize(Alignment.TopStart)) {
IconButton(onClick = { expanded = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "Localized description")
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
){
//
}
}
Since DropDownPopup was removed, I implemented one using DropDownMenu instead like this:
PopupMenu:
#Composable
fun PopupMenu(
menuItems: List<String>,
onClickCallbacks: List<() -> Unit>,
showMenu: Boolean,
onDismiss: () -> Unit,
toggle: #Composable () -> Unit,
) {
DropdownMenu(
toggle = toggle,
expanded = showMenu,
onDismissRequest = { onDismiss() },
) {
menuItems.forEachIndexed { index, item ->
DropdownMenuItem(onClick = {
onDismiss()
onClickCallbacks[index]
}) {
Text(text = item)
}
}
}
}
Toggle (thing to long click on to trigger PopupMenu):
#Preview
#Composable
fun Toggle() {
var showMenu by remember { mutableStateOf(false) }
PopupMenu(
menuItems = listOf("Delete"),
onClickCallbacks = listOf { println("Deleted") },
showMenu = showMenu,
onDismiss = { showMenu = false }) {
Text(
modifier = Modifier.clickable(onClick = {}, onLongClick = {
showMenu = true
}),
text = "Long click here",
)
}
}
After some research I found a solution to this, the key component is DropdownPopup
#Composable
fun LiveDataComponentList(productList: List<Product>) {
AdapterList(data = productList) { product ->
Clickable(onClick = { PopupState.toggleOwner(product) }) {
Card(...) {...}
}
if (PopupState.owner == product) {
DropdownPopup(dropDownAlignment = DropDownAlignment.End)
{
Surface(
shape = RoundedCornerShape(4.dp),
elevation = 16.dp,
color = Color.White,
modifier = Modifier.gravity(Alignment.End)+ Modifier.padding(end = 10.dp)
)
{
Column(modifier = Modifier.padding(10.dp)) {
MenuItem(text ="Edit", onClick = {})
MenuItem(text = "Delete", onClick = {})
MenuItem(text = "Details", onClick = {})
}
}
}
}
}
}
#Composable
fun MenuItem(text: String, onClick: () -> Unit) {
Clickable(onClick = onClick, modifier = Modifier.padding(6.dp)) {
Text(text = text, style = MaterialTheme.typography.subtitle1)
}
}
This solution works fine with compose version dev10
For My use case I created a Icon button which has pop up menu and that can be used where pop menu is needed.
#Composable
fun PopUpMenuButton(
options: List<PopUpMenuItem>,
action: (String) -> Unit,
iconTint: Color = Color.Black,
modifier: Modifier
) {
var expanded by remember { mutableStateOf(false) }
Column {
Box(modifier = Modifier.size(24.dp)) {
IconButton(onClick = {
expanded = !expanded
}) {
Icon(
painter = painterResource(id = R.drawable.ic_dots),
contentDescription = null,
modifier = Modifier.wrapContentSize(),
tint = iconTint
)
}
}
Box(modifier = modifier) {
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier
.widthIn(min = 120.dp, max = 240.dp)
.background(MaterialTheme.colors.background)
) {
options.forEachIndexed { _, item ->
DropdownMenuItem(onClick = {
expanded = false
action(item.id)
}) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painterResource(id = item.icon),
contentDescription = null,
tint = iconTint,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = item.label,
style = MaterialTheme.typography.body1,
overflow = TextOverflow.Ellipsis
)
}
}
if (item.hasBottomDivider) {
Divider()
}
}
}
}
}
}
Then I created a simple data class for defining menu item
data class PopUpMenuItem(
val id: String,
val label: String,
val icon: Int,
val hasBottomDivider: Boolean = false,
)
Then at the calling side I simply use this button like this
PopUpMenuButton(
modifier = Modifier.wrapContentSize(),
options = PopMenuOptionsProvider.sectionCardMenu,
iconTint = MaterialTheme.extendedColor.regularGray,
action = { menuId -> onSectionMenuAction(menuId) }
)
It can be further refactored to make it more extensible, but this worked for me.

Categories

Resources