I am testing an app in myTest() and would like to simulate a swipeRight gesture on MyCard() composable.
When I use performTouchInput { swipeRight() } nothing happens. The card stays in the same place.
How can I simulate the swipe right gesture on the card? What am I missing?
Code
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")
.offset { IntOffset(offsetTransition.roundToInt(), 0) }
.pointerInput(Unit) {
detectHorizontalDragGestures { _, dragAmount ->
when {
dragAmount >= 6 -> { swipeState = true }
dragAmount < -6 -> { swipeState = false }
}
}
},
content = { Text(text = "Hello") }
)
}
Related
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)
}
}
}
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") }
)
}
My some view that starts from an activity shows an alert dialog when pressing the back button using the BackHandler.
#Composable
fun PostEditContent(
title: String,
uiState: BasePostEditUiState
) {
var enabledBackHandler by remember { mutableStateOf(true) }
var enabledAlertDialog by remember { mutableStateOf(false) }
val snackBarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val navigateToBack = { onBackPressedDispatcher?.onBackPressed() }
val forceNavigateToBack = {
coroutineScope.launch {
enabledBackHandler = false
awaitFrame()
navigateToBack()
}
}
if (enabledAlertDialog) {
PostEditAlertDialog(
onDismissRequest = { enabledAlertDialog = false },
onOkClick = { forceNavigateToBack() }
)
}
BackHandler(enabledBackHandler) {
enabledAlertDialog = true
}
...
It's working fine. But in the testing, it sometimes failed because the awaitFrame() is not working properly.
#Suppress("TestFunctionName")
#Composable
private fun PostEditScreenWithHomeBackStack(uiState: PostEditUiState) {
val navController = rememberNavController()
NavHost(navController, startDestination = "home") {
composable(route = "home") {
Text(HOME_STACK_TEXT)
}
composable(route = "edit") {
PostEditScreen(uiState = uiState)
}
}
SideEffect {
navController.navigate("edit")
}
}
...
#Test
fun navigateToUp_WhenSuccessSaving() {
composeTestRule.apply {
setContent {
PostEditScreenWithHomeBackStack(emptyUiState.copy(title = "a", content = "a"))
}
// The save button calls the `forceNavigateToBack()` that in the upper code block
onNodeWithContentDescription("save").performClick()
onNodeWithText(HOME_STACK_TEXT).assertIsDisplayed()
}
}
Is there a better way to show the alert dialog or fix the flaky test? Thanks.
I have a SignupScreen that holds horizontal pager. The pager has 3 tabs, Register Otp Questions . Those three tabs also update and listen to data from its parent screen's SignupViewModel
These are my code :
#Composable
fun SignupScreen(
navController: NavController
) {
SignupView(navController = navController)
}
#OptIn(ExperimentalPagerApi::class)
#Composable
fun SignupView(
navController: NavController
) {
val vm: SignupViewModel = hiltViewModel()
val signupUiState = vm.signupUiState.value
val scaffoldState = rememberScaffoldState()
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState()
val signupTabs = listOf(
SignupPagerTabs.Register,
SignupPagerTabs.Otp,
SignupPagerTabs.Questions,
)
LaunchedEffect(key1 = true) {
vm.signupUiEvent.collect {
when (it) {
SignupUiEvent.GoNextPage -> {
}
SignupUiEvent.GoPreviousPage -> {
}
SignupUiEvent.NavigateToHomeScreen -> {
navController.popBackStack(AppDestination.Home.route, inclusive = true)
navController.navigate(Routes.HOME_ROUTE)
}
SignupUiEvent.Popup -> {
navController.popBackStack()
}
is SignupUiEvent.ShowSnackBar -> {
scaffoldState.snackbarHostState.showSnackbar(
message = it.message
)
}
}
}
}
Scaffold(
scaffoldState = scaffoldState,
topBar = {
TopBar2(
navIcon = painterResource(id = R.drawable.ic_back),
title = stringResource(id = R.string.top_bar_signup),
onNavIconClicked = {
vm.onEvent(SignupUiAction.ClickPreviousPage)
}
)
},
content = {
SignupContentView(
tabs = signupTabs,
pagerState = pagerState
)
}
)
}
#OptIn(ExperimentalPagerApi::class)
#Composable
fun SignupContentView(
tabs: List<SignupPagerTabs>,
pagerState: PagerState
) {
Column(modifier = Modifier.fillMaxSize()) {
PagerIndicator(tabs.size, pagerState.currentPage)
HorizontalPager(
count = tabs.size,
state = pagerState
) { index ->
tabs[index].screenToLoad()
}
}
}
This is my ViewModel.
#HiltViewModel
class SignupViewModel #Inject constructor(
private val signupUseCase: SignupUseCase
) : ViewModel() {
private val _signupUiState: MutableState<SignupUiState> = mutableStateOf(SignupUiState())
val signupUiState: State<SignupUiState> get() = _signupUiState
private val _signupUiEvent = MutableSharedFlow<SignupUiEvent>()
val signupUiEvent = _signupUiEvent.asSharedFlow()
private val currentPage = signupUiState.value.currentPage
private val completedPage = signupUiState.value.completedPage
fun onEvent(action: SignupUiAction) {
when (action) {
is SignupUiAction.ChangeFullName -> {
_signupUiState.value = signupUiState.value.copy(
fullName = action.name
)
}
is SignupUiAction.ChangePassword -> {
_signupUiState.value = signupUiState.value.copy(
password = action.password
)
}
is SignupUiAction.ChangePasswordConfirm -> {
_signupUiState.value = signupUiState.value.copy(
passwordConfirm = action.passwordConfirm
)
}
is SignupUiAction.ChangePhoneNumber -> {
_signupUiState.value = signupUiState.value.copy(
phoneNumber = action.phone
)
}
is SignupUiAction.ChangeRegion -> {
_signupUiState.value = signupUiState.value.copy(
region = action.country
)
}
SignupUiAction.ClickNextPage -> {
when (currentPage) {
SignupPage.REGISTER_PAGE -> {
_signupUiState.value = signupUiState.value.copy(
currentPage = currentPage + 1
)
}
SignupPage.OTP_PAGE -> {
_signupUiState.value = signupUiState.value.copy(
currentPage = currentPage + 1
)
}
SignupPage.SECURITY_QUESTION_PAGE -> {
//do request to server
viewModelScope.launch {
_signupUiEvent.emit(
SignupUiEvent.NavigateToHomeScreen
)
}
}
}
}
SignupUiAction.ClickPreviousPage -> {
when (currentPage) {
SignupPage.REGISTER_PAGE -> {
viewModelScope.launch {
_signupUiEvent.emit(
SignupUiEvent.Popup
)
}
}
SignupPage.OTP_PAGE -> {
_signupUiState.value = signupUiState.value.copy(
currentPage = currentPage - 1
)
}
SignupPage.SECURITY_QUESTION_PAGE -> {
_signupUiState.value = signupUiState.value.copy(
currentPage = currentPage - 1
)
}
}
}
SignupUiAction.ClickStart -> {
}
SignupUiAction.ClearFullName -> {
_signupUiState.value = signupUiState.value.copy(
fullName = ""
)
}
SignupUiAction.ClearPassword -> {
_signupUiState.value = signupUiState.value.copy(
password = ""
)
}
SignupUiAction.ClearPasswordConfirm -> {
_signupUiState.value = signupUiState.value.copy(
passwordConfirm = ""
)
}
SignupUiAction.ClearPhoneNumber -> {
_signupUiState.value = signupUiState.value.copy(
phoneNumber = ""
)
}
is SignupUiAction.ChangeOtpCode -> {
_signupUiState.value = signupUiState.value.copy(
otpCode = action.code
)
}
SignupUiAction.ClickResend -> {
//todo resend logic
viewModelScope.launch {
}
}
}
}
private fun signup() {
val requestBody = SignupRequest(
countryCodeId = signupUiState.value.region.id,
name = signupUiState.value.fullName,
mobile = signupUiState.value.phoneNumber,
password = signupUiState.value.password,
securityQuestionUsage = listOf()
)
viewModelScope.launch {
signupUseCase(body = requestBody).collect {
when (it) {
is Resource.ErrorEvent -> {
_signupUiEvent.emit(
SignupUiEvent.ShowSnackBar(
message =
it.message ?: "Error"
)
)
}
is Resource.LoadingEvent -> {
_signupUiState.value = signupUiState.value.copy(
isLoading = true,
loadingMessageType = SignupStatus.SECURITY_QUESTION_PAGE
)
}
is Resource.SuccessEvent -> {
}
}
}
}
}
}
This is my Pager Tabs.
object SignupPage {
const val REGISTER_PAGE = 0
const val OTP_PAGE = 1
const val SECURITY_QUESTION_PAGE = 2
}
enum class SignupStatus(val index: Int) {
REGISTER_PAGE(index = SignupPage.REGISTER_PAGE),
OTP_PAGE(index = SignupPage.OTP_PAGE),
SECURITY_QUESTION_PAGE(index = SignupPage.SECURITY_QUESTION_PAGE),
}
sealed class SignupPagerTabs(
val index: Int,
val screenToLoad: #Composable () -> Unit,
) {
object Register : SignupPagerTabs(
index = 0,
screenToLoad = {
RegisterScreen(
)
}
)
object Otp : SignupPagerTabs(
index = 1,
screenToLoad = {
OtpVerificationSignupScreen(
)
}
)
object Questions : SignupPagerTabs(
index = 2,
screenToLoad = {
SecurityQuestionChooseScreen(
)
}
)
}
In Register Otp Question Screens, I want to access the SignupViewModel.
Please Help Me.
I tried to show a timestamp (or any strings that can be updated in general) inside a sheetContent of the ModalBottomSheetLayout that is shown by clicking on a floating action button. However, that timestamp is only updated once: the first time that the sheet is shown. If I close the sheet (through onSave in the code) and open it again, the timestamp stays the same instead of showing the newest timestamp. I think I maybe missing "remember" or "mutableState" somehow but I am not able to get it to work.
class MainActivity : ComponentActivity() {
#ExperimentalMaterialApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AskQuestionTheme {
val modalBottomSheetState =
rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
Scaffold(
floatingActionButton = {
FloatingActionButton(
onClick = { scope.launch { modalBottomSheetState.show() } }) {
Icon(
Icons.Rounded.Add,
contentDescription = "add"
)
}
}) {
ModalBottomSheetLayout(
sheetState = modalBottomSheetState,
sheetContent = {
var t = Date().toString()
MySheet(
str = t,
onSave = {
scope.launch {
modalBottomSheetState.hide()
}
},
onCancel = { scope.launch { modalBottomSheetState.hide() } })
}
) {
Text("hello")
}
}
}
}
}
}
#Composable
fun MySheet(str: String, onSave: () -> Unit, onCancel: () -> Unit) {
Column {
Box(modifier = Modifier.fillMaxWidth()) {
Icon(
Icons.Rounded.Close,
contentDescription = "edit",
modifier = Modifier.align(Alignment.CenterStart).clickable { onCancel() })
Button(onClick = onSave, modifier = Modifier.align(Alignment.CenterEnd)) {
Text("save")
}
}
Text(str)
Spacer(Modifier.height(100.dp))
}
}
There are two ways for me. Up to you which select)
First variant. Added a new property var t by remember { mutableStateOf(Date().toString()) } and change its before showing ModalBottomSheetLayout. It is an update code
t = Date().toString().
class MainActivity : ComponentActivity() {
#ExperimentalMaterialApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AskQuestionTheme {
val modalBottomSheetState =
rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
var t by remember { mutableStateOf(Date().toString()) }
Scaffold(
floatingActionButton = {
FloatingActionButton(
onClick = {
scope.launch {
t = Date().toString()
modalBottomSheetState.show()
}
}) {
Icon(
Icons.Rounded.Add,
contentDescription = "add"
)
}
}) {
ModalBottomSheetLayout(
sheetState = modalBottomSheetState,
sheetContent = {
MySheet(
str = t,
onSave = {
scope.launch {
modalBottomSheetState.hide()
}
},
onCancel = { scope.launch { modalBottomSheetState.hide() } })
}
) {
Text("hello")
}
}
}
}
}
}
Second variant. Now you can see LaunchedEffect. Code inside this block is triggered when this value modalBottomSheetState.direction changes. Inside this block we update a date.
class MainActivity : ComponentActivity() {
#ExperimentalMaterialApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AskQuestionTheme {
val modalBottomSheetState =
rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
Scaffold(
floatingActionButton = {
FloatingActionButton(
onClick = { scope.launch { modalBottomSheetState.show() } }) {
Icon(
Icons.Rounded.Add,
contentDescription = "add"
)
}
}) {
ModalBottomSheetLayout(
sheetState = modalBottomSheetState,
sheetContent = {
var t by remember { mutableStateOf(Date().toString()) }
LaunchedEffect(modalBottomSheetState.direction) {
if (modalBottomSheetState.direction == -1f) {
t = Date().toString()
}
}
MySheet(
str = t,
onSave = {
scope.launch {
modalBottomSheetState.hide()
}
},
onCancel = { scope.launch { modalBottomSheetState.hide() } })
}
) {
Text("hello")
}
}
}
}
}
}