How to test onDismissRequest attribute of AlertDialog? - android-alertdialog

In its simplest form I have this dialog:
#Composable
fun MyDialog(
showDialogState: MutableState<Boolean>
) {
if (showDialogState.value) {
AlertDialog(onDismissRequest = { showDialogState.value = false },
// Other irrelevant attributes have been omitted
)
}
}
How can I trigger "onDismissRequest" on this composable in Robolectric?
This is usually how I build my composable tests by the way:
#Config(sdk = [Build.VERSION_CODES.O_MR1])
#RunWith(AndroidJUnit4::class)
#LooperMode(LooperMode.Mode.PAUSED)
class MyDialogTest {
#get:Rule
val composeTestRule = createComposeRule()
#Test
fun `MyDialog - when showing state and dismissed - changes showing state`() {
val state = mutableStateOf(true)
composeTestRule.setContent {
MyDialog(
showDialogState = state
)
}
// TODO: How do I trigger dismiss!?
assertFalse(state.value)
}
}
Compose version: 1.1.0-rc01
Android Gradle Plugin version: 7.0.4
Robolectric version: 4.7.3

I don't think this is possible at the moment. I have written this test to confirm:
val onButtonPressed = mock<() -> Unit>()
composeTestRule.setContent {
Scaffold(topBar = {
TopAppBar {
Text(text = "This test does not work")
}
}) {
AlertDialog(
onDismissRequest = {},
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true
),
title = { Text(text = "This is a dialog")},
confirmButton = { Button(onClick = {}) {
Text(text = "Confirm")
}}
)
Column(modifier = Modifier.fillMaxSize()) {
Spacer(modifier = Modifier.weight(1f))
Button(onClick = onButtonPressed) {
Text(text = "test")
}
}
}
}
composeTestRule.onNode(isDialog()).assertExists()
composeTestRule.onNodeWithText("test", ignoreCase = true).performClick()
verify(onButtonPressed).invoke()
composeTestRule.onNode(isDialog()).assertDoesNotExist()
Even though the button is "behind" the dialog, it receives click events without dismissing the dialog.
Manual testing has confirmed that the implementation works, so perhaps a UIAutomator test could automate this, but that seems like an overly complicated way of solving this issue.

I quote the official documentation:
Dismiss the dialog when the user clicks outside the dialog or on the
back button. If you want to disable that functionality, simply use an
empty onCloseRequest.
https://foso.github.io/Jetpack-Compose-Playground/material/alertdialog/

Related

Problem with custom dialog show in jetpack compose. Not correct background

I'm created custom dialog from common class in ini
init {
activity.setContent {
CustomDialog(viewModel)
}
}
#Composable
fun CustomDialog(viewModel: ViewModel){
Dialog(
onDismissRequest = { },
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true)
) {
}
}
But under dialog background is an empty activity, but must be a preference activity.
Not correct composable:
correct dialog via XML:
I tried, but didn't help
Surface(modifier = Modifier.background(Color.Transparent)) {
CustomDialog(viewModel)
}
```
Exampe:
Dialog(onDismissRequest = { onDismissRequest() }, properties = DialogProperties()) {
Column(
modifier = Modifier
.wrapContentSize()
.background(
color = MaterialTheme.colors.surface,
shape = RoundedCornerShape(size = 16.dp)
).clip(shape = RoundedCornerShape(size = 16.dp))
) {
//.....
}
}
Found solution:
dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
dialog?.window?.setDimAmount(0.0f)

How to refresh UI in Kotlin with Compose desktop when runBlocking?

I'm learning Kotlin and Compose Desktop and I'm trying refresh the UI before fetch data from an API.
But the request is running inside a runBlocking, thus the UI freezes until request is completed.
This is my code, everything works.
val client = HttpClient(CIO)
#OptIn(ExperimentalComposeUiApi::class)
#Composable
#Preview
fun App() {
var text by remember { mutableStateOf("Test button") }
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.padding(50.dp)
) {
Button(
onClick = {
text = "Wait..."//How to refresh UI to display this text?
runBlocking {
delay(5000)//blocking test
val response: HttpResponse = client.request("https://myapi.com/") {
// Configure request parameters exposed by HttpRequestBuilder
}
if (response.status == HttpStatusCode.OK) {
val body = response.body<String>()
println(body)
} else {
println("Error has occurred")
}
}
text = "Test button"
},
modifier = Modifier.fillMaxWidth()
) {
Text(text)
}
OutlinedTextField(
value = "",
singleLine = true,
onValueChange = { text = it }
)
}
}
}
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
state = WindowState(size = DpSize(350.dp, 500.dp)),
title = "Compose test"
) {
App()
}
}
How to achieve that?
The problem here is that you are using runBlocking at all.
The most straightforward solution for your case would be to replace your runBlocking {} with a coroutine scope. At the top of your App() function, create your scope: val scope = rememberCoroutineScope(), then instead of runBlocking you can say scope.launch {}.
New code would be:
#OptIn(ExperimentalComposeUiApi::class)
#Composable
#Preview
fun App() {
var text by remember { mutableStateOf("Test button") }
val scope = rememberCoroutineScope()
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.padding(50.dp)
) {
Button(
onClick = {
text = "Wait..."//How to refresh UI to display this text?
scope.launch {
delay(5000)//blocking test
val response: HttpResponse = client.request("https://myapi.com/") {
// Configure request parameters exposed by HttpRequestBuilder
}
if (response.status == HttpStatusCode.OK) {
val body = response.body<String>()
println(body)
} else {
println("Error has occurred")
}
}
text = "Test button"
},
modifier = Modifier.fillMaxWidth()
) {
Text(text)
}
OutlinedTextField(
value = "",
singleLine = true,
onValueChange = { text = it }
)
}
}
}
I saw one comment say to use LaunchedEffect() but this won't work in your case since you can't use that in an onClick since it's not a Composable.

Multiple different pointerInput

I'm currently trying to implement the option of switching between a composable being either zoomable, pannable (dragging the surface) or neither of those. What works so far is toggling the respective buttons, with the expected result. What does not work is toggling one button from the other - this gives the unexpected result of keeping the functionality of the first button.
For example, let's say zoom is active. When I then press the pan button, the background highlighting changes accordingly, all test-logs show the expected state - but the surface is still zoomable, NOT draggable. I first have to manually disable zoom. Any ideas as to why this might happen?
Modifier.run {
if (zoomEnabled) {
this.pointerInput(Unit) {
detectTransformGestures { _, _, zoom, _ ->
passScale(zoom)
}
}
} else if (panEnabled) {
this.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consumeAllChanges()
passOffsetX(dragAmount.x / 3)
passOffsetY(dragAmount.y / 3)
}
}
} else
this
}
Buttons:
#Composable
fun TopBarAction(
zoomEnabled: Boolean,
passZoomEnabled: (Boolean) -> Unit,
panEnabled: Boolean,
passPanEnabled: (Boolean) -> Unit
) {
IconToggleButton(
checked = zoomEnabled,
onCheckedChange = {
passPanEnabled(false)
passZoomEnabled(it)
},
modifier = Modifier
.background(
if (zoomEnabled) Color.LightGray else Color.Transparent,
shape = CircleShape
),
enabled = true
) {
Icon(...)
}
IconToggleButton(
checked = panEnabled,
onCheckedChange = {
passZoomEnabled(false)
passPanEnabled(it)
},
modifier = Modifier
.background(
if (panEnabled) Color.LightGray else Color.Transparent,
shape = CircleShape
),
enabled = true
) {
Icon(...)
}
}
Using the normal .pointerInput modifier with the condition inside instead of run does not recognize any input at all and detectTransformGesture's pan did not behave the way I need it to (though this is probably what I will use if all else fails)
First issue is PointerInput creates a closure with key or keys and uses old values unless the keys you set change.
You need to set keys accordingly.
Second issue is even if you set keys, detectDragGestures or detectTransformGestures will consume events so PointerInputChange above won't get it if first one has already consumed event.
What consume() or consumeAllChanges() does is it prevents pointerInput above it or on parent to receive events by returning PointeInputChange.positionChange() Offset.Zero, PointerInputChange.isConsumed true. Since drag, scroll or transform gestures check if PointeInputChange.isConsumed is true they will never get any event if you consume them in previous pointerInput.
Drag source code for instance
suspend fun PointerInputScope.detectDragGestures(
onDragStart: (Offset) -> Unit = { },
onDragEnd: () -> Unit = { },
onDragCancel: () -> Unit = { },
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
) {
forEachGesture {
awaitPointerEventScope {
val down = awaitFirstDown(requireUnconsumed = false)
var drag: PointerInputChange?
var overSlop = Offset.Zero
do {
drag = awaitPointerSlopOrCancellation(
down.id,
down.type
) { change, over ->
change.consume()
overSlop = over
}
// ! EVERY Default GESTURE HAS THIS CHECK
} while (drag != null && !drag.isConsumed)
if (drag != null) {
onDragStart.invoke(drag.position)
onDrag(drag, overSlop)
if (
!drag(drag.id) {
onDrag(it, it.positionChange())
it.consume()
}
) {
onDragCancel()
} else {
onDragEnd()
}
}
}
}
}
Instead of using Modifier.run you can chain Modifier.pointerInput()
Modifier
.pointerInput(keys){
// Gesture scope1
if(zoomEnabled){...}
}
.pointerInput(keys){
// Gesture scope2
if(panEnabled){
....
}
}
Events first go to gesture scope 2 then gesture scope 1
Created a small sample that you can observer how gestures change and propagate and how they reset with keys
#Composable
private fun MyComposable() {
var zoomEnabled by remember { mutableStateOf(false) }
var dragEnabled by remember { mutableStateOf(false) }
var text by remember { mutableStateOf("") }
Column() {
val modifier = Modifier
.size(400.dp)
.background(Color.Red)
.pointerInput(zoomEnabled) {
if (zoomEnabled) {
detectTransformGestures { centroid, pan, zoom, rotation ->
println("ZOOOMING")
text = "ZOOMING centroid: $centroid"
}
}
}
.pointerInput(key1 = dragEnabled, key2= zoomEnabled) {
if (dragEnabled && !zoomEnabled) {
detectDragGestures { change, dragAmount ->
println("DRAGGING")
text = "DRAGGING $dragAmount"
}
}
}
Box(modifier = modifier)
Text(text = text)
OutlinedButton(onClick = { zoomEnabled = !zoomEnabled }) {
Text("zoomEnabled: $zoomEnabled")
}
OutlinedButton(onClick = { dragEnabled = !dragEnabled }) {
Text("dragEnabled: $dragEnabled")
}
}
}
You can create your own behavior using the answer and snippet above.

LazyColumn inside Alertdialog shows error in Jetpack Compose

I'am trying to show a LazyColumn inside an alert dialog so the user can choose between a list of items, and click on it. The alert dialog will show without problem, i can click on any item which is on screen and close it, but as soon as i try to scroll between the items, it will give the following error:
E/InputEventReceiver: Exception dispatching input event.
D/AndroidRuntime: Shutting down VM
E/AndroidRuntime: FATAL EXCEPTION: main
Process: com.example.app, PID: 22418
java.lang.IllegalArgumentException: Failed requirement.
at androidx.compose.ui.node.MeasureAndLayoutDelegate.doRemeasure(MeasureAndLayoutDelegate.kt:177)
at androidx.compose.ui.node.MeasureAndLayoutDelegate.remeasureAndRelayoutIfNeeded(MeasureAndLayoutDelegate.kt:228)
at androidx.compose.ui.node.MeasureAndLayoutDelegate.access$remeasureAndRelayoutIfNeeded(MeasureAndLayoutDelegate.kt:38)
at androidx.compose.ui.node.MeasureAndLayoutDelegate.measureAndLayout(MeasureAndLayoutDelegate.kt:201)
at androidx.compose.ui.platform.AndroidComposeView.measureAndLayout(AndroidComposeView.android.kt:662)
at androidx.compose.ui.platform.AndroidComposeView.handleMotionEvent-8iAsVTc(AndroidComposeView.android.kt:1073)
at androidx.compose.ui.platform.AndroidComposeView.dispatchTouchEvent(AndroidComposeView.android.kt:1059)
at android.view.ViewGroup.dispatchTransformedTouchEvent(ViewGroup.java:3920)
at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:3594)
at android.view.ViewGroup.dispatchTransformedTouchEvent(ViewGroup.java:3920)
at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:3594)
at android.view.ViewGroup.dispatchTransformedTouchEvent(ViewGroup.java:3920)
at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:3594)
at android.view.ViewGroup.dispatchTransformedTouchEvent(ViewGroup.java:3920)
at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:3594)
at com.android.internal.policy.DecorView.superDispatchTouchEvent(DecorView.java:913)
at com.android.internal.policy.PhoneWindow.superDispatchTouchEvent(PhoneWindow.java:1957)
at android.app.Dialog.dispatchTouchEvent(Dialog.java:1162)
at com.android.internal.policy.DecorView.dispatchTouchEvent(DecorView.java:871)
at android.view.View.dispatchPointerEvent(View.java:15458)
at android.view.ViewRootImpl$ViewPostImeInputStage.processPointerEvent(ViewRootImpl.java:7457)
at android.view.ViewRootImpl$ViewPostImeInputStage.onProcess(ViewRootImpl.java:7233)
at android.view.ViewRootImpl$InputStage.deliver(ViewRootImpl.java:6595)
at android.view.ViewRootImpl$InputStage.onDeliverToNext(ViewRootImpl.java:6652)
at android.view.ViewRootImpl$InputStage.forward(ViewRootImpl.java:6618)
at android.view.ViewRootImpl$AsyncInputStage.forward(ViewRootImpl.java:6786)
at android.view.ViewRootImpl$InputStage.apply(ViewRootImpl.java:6626)
at android.view.ViewRootImpl$AsyncInputStage.apply(ViewRootImpl.java:6843)
at android.view.ViewRootImpl$InputStage.deliver(ViewRootImpl.java:6599)
at android.view.ViewRootImpl$InputStage.onDeliverToNext(ViewRootImpl.java:6652)
at android.view.ViewRootImpl$InputStage.forward(ViewRootImpl.java:6618)
at android.view.ViewRootImpl$InputStage.apply(ViewRootImpl.java:6626)
at android.view.ViewRootImpl$InputStage.deliver(ViewRootImpl.java:6599)
at android.view.ViewRootImpl.deliverInputEvent(ViewRootImpl.java:9880)
at android.view.ViewRootImpl.doProcessInputEvents(ViewRootImpl.java:9718)
at android.view.ViewRootImpl.enqueueInputEvent(ViewRootImpl.java:9671)
at android.view.ViewRootImpl$WindowInputEventReceiver.onInputEvent(ViewRootImpl.java:10014)
at android.view.InputEventReceiver.dispatchInputEvent(InputEventReceiver.java:220)
at android.view.InputEventReceiver.nativeConsumeBatchedInputEvents(Native Method)
at android.view.InputEventReceiver.consumeBatchedInputEvents(InputEventReceiver.java:200)
at android.view.ViewRootImpl.doConsumeBatchedInput(ViewRootImpl.java:9960)
at android.view.ViewRootImpl$ConsumeBatchedInputRunnable.run(ViewRootImpl.java:10056)
at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1010)
at android.view.Choreographer.doCallbacks(Choreographer.java:809)
at android.view.Choreographer.doFrame(Choreographer.java:737)
at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:995)
at android.os.Handler.handleCallback(Handler.java:938)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loop(Looper.java:246)
at android.app.ActivityThread.main(ActivityThread.java:8595)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:602)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1130)
I/Process: Sending signal. PID: 22418 SIG: 9
This is the AlertDialog code:
AlertDialog(
onDismissRequest = { showDialog.value = false },
modifier = Modifier.fillMaxHeight(.80f),
text = {
LazyColumn{
items(30){ i ->
Text(text = i.toString())
}
}
},
buttons = {
Button(onClick = { /*TODO*/ }) {
Text(text = "Cancel")
}
}
)
The same problem happens to me although I found that if I use the Material3 library scrolling is not a problem anymore:
implementation "androidx.compose.material3:material3:1.0.0-alpha03"
AlertDialog(
onDismissRequest = { },
modifier = Modifier.fillMaxHeight(0.8f),
text = {
LazyColumn() {
items(30) { i ->
Text(text = i.toString())
}
}
},
dismissButton = {
Button(onClick = { /*TODO*/ }) {
Text(text = "Cancel")
}
}
)
Make sure to import:
import androidx.compose.material3.AlertDialog
I don't know if this is the correct answer but I hope it helps in any case.
Another workaround is the usage of the base androidx.compose.ui.window.Dialog. This Dialog works without any crashes when using the LazyColumn:
#Composable
#Preview(showBackground = true)
fun SimpleDialog(onDismissRequest: () -> Unit = {}) {
Dialog(
onDismissRequest = onDismissRequest,
content = {
Surface(shape = RoundedCornerShape(8.dp)) {
Column(
modifier = Modifier.padding(8.dp),
) {
Text(text = "Simple Dialog", style = MaterialTheme.typography.h6)
LazyColumn(
modifier = Modifier.height(150.dp),
content = {
items(30){ i ->
Text(text = i.toString())
}
}
)
Button(onClick = onDismissRequest) {
Text("Cancel")
}
}
}
},
)
}

Jetpack Compose - Disable TextField long press handler

I have an IconButton in the trailingIcon of OutlinedTextField like:
OutlinedTextField(
modifier = Modifier.weight(1f),
label = { Text(text = "Label") },
value = text,
onValueChange = { text = it },
trailingIcon = {
IconButton2(onClick = {
println("onClick")
}, onLongClick = {
println("onLongClick shows TextToolbar")
}) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = null
)
}
}
)
IconButton2 is just a copy of IconButton but with combinedClickable to include onLongClick instead of clickable.
The problem is that when I long click IconButton2, it shows the TextToolbar for the TextField. Doesn't matter what I do, the text field will handle long click, show the TextToolbar and provide haptic feedback.
Even if I use pointerInput with awaitPointerEvent and consumeAllChanges (like here) it still triggers it. The TextField doesn't answer to any tap or anything but if I long click it, it answers!
The workaround I'm doing for now is wrapping the text field in a Row and add the IconButton beside it instead of "inside" but I needed to have the icon button as the trailingIcon.
Is there any way to properly do it?
Compose 1.0.3 and 1.1.0-alpha05 both behaves the same.
I ended up making a small hack that seems to be working fine, so basically I add a dummy Box as the trailingIcon to get the position of it, then I add an IconButton outside of it (both wrapped in a Box) and I also get the position of it + I offset it using the position of the dummy box. Not the ideal solution but works fine.
Here's the full source if anyone needs it:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colors.background
) {
var text by remember { mutableStateOf("") }
var trailingIconOffset by remember { mutableStateOf(Offset.Zero) }
var iconButtonOffset by remember { mutableStateOf(Offset.Zero) }
val colors = TextFieldDefaults.outlinedTextFieldColors()
Column {
//With hack
Box {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
label = { Text(text = "With hack") },
value = text,
onValueChange = { text = it },
trailingIcon = {
Box(modifier = IconButtonSizeModifier
.onGloballyPositioned {
trailingIconOffset = it.positionInRoot()
}
)
},
colors = colors
)
val contentColor by colors.trailingIconColor(
enabled = true,
isError = false
)
CompositionLocalProvider(
LocalContentColor provides contentColor,
LocalContentAlpha provides contentColor.alpha
) {
IconButton2(
modifier = Modifier
.onGloballyPositioned {
iconButtonOffset = it.positionInRoot()
}
.absoluteOffset {
IntOffset(
(trailingIconOffset.x - iconButtonOffset.x).toInt(),
(trailingIconOffset.y - iconButtonOffset.y).toInt()
)
},
onClick = {
text = "onClick"
},
onLongClick = {
text = "onLongClick"
}
) {
Icon(imageVector = Icons.Filled.Menu, contentDescription = null)
}
}
}
//Without hack
Box {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
label = { Text(text = "Without hack") },
value = text,
onValueChange = { text = it },
trailingIcon = {
IconButton2(
onClick = {
text = "onClick"
},
onLongClick = {
text = "onLongClick"
}
) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = null
)
}
},
colors = colors
)
}
}
}
}
}
}
}
private val RippleRadius = 24.dp
private val IconButtonSizeModifier = Modifier.size(48.dp)
#OptIn(ExperimentalFoundationApi::class)
#Composable
fun IconButton2(
modifier: Modifier = Modifier,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: #Composable () -> Unit
) {
Box(
modifier = modifier
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
enabled = enabled,
role = Role.Button,
interactionSource = interactionSource,
indication = rememberRipple(bounded = false, radius = RippleRadius)
)
.then(IconButtonSizeModifier),
contentAlignment = Alignment.Center
) {
val contentAlpha = if (enabled) LocalContentAlpha.current else ContentAlpha.disabled
CompositionLocalProvider(LocalContentAlpha provides contentAlpha, content = content)
}
}