I am using Jetpack Compose (version 1.1.1) and I am trying to show an snackbar-style alert coming from the top of the screen using AnimatedVisibility. However, when I do so the alert escapes its container and overlaps the content above it (in this case, the action bar). Also, the content below it (the button) waits until the animation is finished before adjusting its position. This results in a jumpy effect. I would like it to slide down in sync with the alert.
#Composable
fun HomeScreen() {
val showAlert = remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Column(Modifier.fillMaxHeight()) {
ActionBar("Home Screen")
Column {
Alert(visible = showAlert.value)
Button(modifier = Modifier.padding(16.dp), onClick = {
scope.launch {
showAlert.value = true
delay(1000)
showAlert.value = false
}
}) {
Text("Show Alert")
}
}
}
}
#Composable
private fun ActionBar(title: String) {
TopAppBar(backgroundColor = Color.Blue, contentColor = Color.White) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp, end = 8.dp)) {
Text(title)
}
}
}
#Composable
fun Alert(visible: Boolean) {
AnimatedVisibility(visible = visible, enter = slideInVertically(), exit = slideOutVertically()) {
Column(Modifier.fillMaxWidth().background(Color.Red)) {
Text("An error has occurred", Modifier.padding(16.dp), style = TextStyle(Color.White))
}
}
}
#Preview
#Composable
fun HomeScreenPreview() {
Box(Modifier.fillMaxSize().background(Color.White)) {
HomeScreen()
}
}
For the overlap issue, you can use the modifier: Modifier.clipToBounds()
For the 'jumpy effect' you can put the alert text and the page content in a single column and animate the offset.
enum class AlertState {
Collapsed,
Expanded
}
#Preview
#Composable
fun HomeScreen() {
val scope = rememberCoroutineScope()
val alertHeight = 40.dp
var currentState by remember { mutableStateOf(AlertState.Collapsed) }
val transition = updateTransition(currentState, label = "")
val alertOffset by transition.animateDp(label = "") {
when (it) {
AlertState.Collapsed -> -alertHeight
AlertState.Expanded -> 0.dp
}
}
Column(Modifier.fillMaxHeight()) {
TopAppBar(backgroundColor = Color.Blue, contentColor = Color.White) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 8.dp, end = 8.dp)
) {
Text("Home Screen")
}
}
Column(
modifier = Modifier.clipToBounds().offset(y = alertOffset)
) {
Row(
Modifier.height(alertHeight).fillMaxWidth().background(Color.Red).padding(start = 16.dp)
) {
Text(
"An error has occurred", style = TextStyle(Color.White),
modifier = Modifier.align(Alignment.CenterVertically)
)
}
Button(modifier = Modifier.padding(16.dp), onClick = {
scope.launch {
currentState = AlertState.Expanded
delay(1000)
currentState = AlertState.Collapsed
}
}) {
Text("Show Alert")
}
}
}
}
If your alert height is not fix, you can get it using the modifier Modifier.onGloballyPositioned
Related
I've tried to implement the SwipeToDismiss behavior in my app and it works perfectly when I try to delete the last or the only item in the LazyColumn. However, if I try to delete an item that is not the last one the next one will swipe to start but not entirely off the screen.
https://streamable.com/4v2i0d
#OptIn(ExperimentalMaterial3Api::class)
#Composable
fun TasksListLayout(
modifier: Modifier = Modifier,
onNavigate: (UiEvent.Navigate) -> Unit,
viewModel: TaskViewModel = hiltViewModel()
) {
val tasks = viewModel.tasks.collectAsState(initial = emptyList())
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = true) {
viewModel.uiEvent.collect { event ->
when (event) {
is UiEvent.ShowSnackbar -> {
val result = scope.launch {
snackbarHostState.showSnackbar(
message = event.message, actionLabel = event.action, duration = SnackbarDuration.Long
)
}
if (result.equals(SnackbarResult.ActionPerformed)) {
viewModel.onEvent(TaskListEvent.OnUndoDeleteTask)
}
}
is UiEvent.Navigate -> onNavigate(event)
else -> Unit
}
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = {
FloatingActionButton(
onClick = { viewModel.onEvent(TaskListEvent.OnAddTask) },
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.background
) {
Icon(imageVector = Icons.Default.Add, contentDescription = "Add a task")
}
},
topBar = {
TopAppBar(
title = { Text("Planner") }, colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.background
)
)
},
) { padding ->
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 12.dp),
verticalArrangement = spacedBy(12.dp),
contentPadding = PaddingValues(vertical = 12.dp)
) {
items(tasks.value) { 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 = {
TaskCard(
task = task, onEvent = viewModel::onEvent, modifier = modifier
)
})
}
}
}
}
In order to fix the issue I needed to add a key to the LazyColumn.
Changed from this:
items(tasks.value) {}
To this:
items(items = tasks.value, key = { task -> task.hashCode()}) {}
I have a custom search bar will not allow me to place a cursor and type text into it. Before adding the viewmodel and state, I was able to have the TextField work and allow typing text into it.
I've tried using state variables within the composable instead of separating the logic into the view model and unfortunatly recieved the same result. I have a feeling it's something simple that I'm missing but can't quite find it.
Custom Search Bar:
#Composable
fun SearchBar(
modifier: Modifier,
viewModel: ToolSetListViewModel = hiltViewModel()
){
Surface (
modifier = modifier
.fillMaxWidth()
.height(74.dp)
.padding(20.dp, 15.dp, 20.dp, 0.dp),
elevation = 10.dp,
color = MaterialTheme.colors.primary,
shape = RoundedCornerShape(25)
){
TextField(
modifier = modifier
.fillMaxWidth(),
value = viewModel.searchText,
onValueChange = {
viewModel.onEvent(ToolSetListEvent.OnSearchToolSet(it))
},
placeholder = {
Text(
modifier = modifier
.alpha(ContentAlpha.medium),
text = "Search...",
color = White
)
},
textStyle = TextStyle(
fontSize = MaterialTheme.typography.subtitle1.fontSize,
),
singleLine = true,
colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.Transparent,
cursorColor = White
)
)
}
Screen:
#Composable
fun ToolSetListScreen(
onNavigate: (UiEvent.Navigate) -> Unit,
viewModel: ToolSetListViewModel = hiltViewModel()
) {
val toolSets = viewModel.toolSets.collectAsState(initial = emptyList())
LaunchedEffect(key1 = true) {
viewModel.uiEvent.collect { event ->
when(event) {
is UiEvent.Navigate -> onNavigate(event)
}
}
}
Scaffold (
floatingActionButton = {
FloatingActionButton(onClick = {
viewModel.onEvent(ToolSetListEvent.OnAddToolSetClick)
}) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add")
}
}
) {
SearchBar(
modifier = Modifier,
)
LazyColumn(
modifier = Modifier.fillMaxSize()
.padding(0.dp, 20.dp)
) {
items(toolSets.value) { toolset ->
ToolSetItem(
toolSet = toolset,
onEvent = viewModel::onEvent,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.clickable {
viewModel.onEvent(ToolSetListEvent.OnToolSetClick(toolset))
}
)
}
}
}
}
ViewModel:
#HiltViewModel
class ToolSetListViewModel #Inject constructor(
private val repository: ToolSetRepository
): ViewModel() {
val toolSets = repository.getAllToolSets()
private val _uiEvent = Channel<UiEvent>()
val uiEvent = _uiEvent.receiveAsFlow()
var searchText by mutableStateOf("")
private set
fun onEvent(event: ToolSetListEvent) {
when(event) {
is ToolSetListEvent.OnToolSetClick -> {
sendUiEvent(UiEvent.Navigate(Routes.ADD_EDIT_TOOL_SET + "?PONumber=${event.toolSet.PONumber}"))
}
is ToolSetListEvent.OnDeleteToolSetClick -> {
viewModelScope.launch {
repository.deleteToolSet(event.toolset)
}
}
is ToolSetListEvent.OnAddToolSetClick -> {
sendUiEvent(UiEvent.Navigate(Routes.ADD_EDIT_TOOL_SET))
}
is ToolSetListEvent.OnSearchToolSet -> {
viewModelScope.launch {
if (event.searchText.isNotBlank()) {
searchText = event.searchText
repository.getToolSetByPO(event.searchText)
}
}
}
}
}
private fun sendUiEvent(event: UiEvent) {
viewModelScope.launch {
_uiEvent.send(event)
}
}
MainActivity:
#AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
PunchesManagerTheme {
val navController = rememberNavController()
Scaffold (
content = {
Navigation(navController)
},
bottomBar = { BottomNavigationBar(navController = navController) },
)
}
}
}
}
#Composable
fun BottomNavigationBar(navController: NavHostController) {
BottomNavigation {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
NavBarItems.bottomNavItem.forEach { navItem ->
BottomNavigationItem(selected = currentRoute == navItem.route, onClick = {
navController.navigate(navItem.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = false
}
launchSingleTop = true
restoreState = true
}
},
icon = {
Icon(imageVector = navItem.icon,
contentDescription = navItem.name)
},
label = {
Text(text = navItem.name)
},
)
}
}
}
Any suggestions on what the issue may be?
I think I found the fix!
I adjusted the layout of my scaffoled in my ToolSetListScreen:
Scaffold (
content = {
SearchBar(
modifier = Modifier,
)
LazyColumn(
modifier = Modifier.fillMaxSize()
.padding(0.dp, 75.dp)
) {
items(toolSets.value) { toolset ->
ToolSetItem(
toolSet = toolset,
onEvent = viewModel::onEvent,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.clickable {
viewModel.onEvent(ToolSetListEvent.OnToolSetClick(toolset))
}
)
}
}
},
floatingActionButton = {
FloatingActionButton(onClick = {
viewModel.onEvent(ToolSetListEvent.OnAddToolSetClick)
}) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add")
}
}
)
}
and this seemed to work.
I am trying to show a section within LazyColumn which has a list of Rows that are shown horizontally using LazyRow. I would like to have a button which displays show/hide so that I can show a minimal list in this section instead of full list. I would like to animate the expand/collapse part and currently expanding on button click is working as expected but when collapsing, the LazyCloumn scrolls up which seems to push this section out of screen (as shown in the video below). Is there any way we can collapse so that the button at least gets snapped to the top and the remaining section is removed? This way, user can still expand the list if required rather than scrolling up to find the button.
I have tried the following but none of them seem to work:
Using AnimatedVisibility
Using animate*AsState low level API's
Also tried to just remove the contents from list allowing LazyColumn to re-order based on the list content
val RandomColor
get() = Color(Random.nextInt(256), Random.nextInt(256), Random.nextInt(256))
typealias ClickHandler = (Boolean) -> Unit
#Composable
fun DemoLayout(demoDataList: List<DemoData>, isExpanded: Boolean, clickHandler: ClickHandler) {
LazyColumn {
demoDataList.forEachIndexed { index, it ->
when (it) {
is DemoData.Header -> item(key = "cell_$index") { HeaderComposable(header = it) }
is DemoData.BigCard -> item(key = "hero_$index") { BigCardComposable(bigCard = it) }
is DemoData.Card -> item(key = "banner_$index") { CardComposable(card = it) }
is DemoData.ExpandableSection -> {
items(count = 2, key = { indexInner: Int -> "categories_first_half_$index$indexInner" }) { index ->
Section(
sectionInfo = it.sectionInfo[index]
)
}
//Comment below and try another approach
item(key = "first_approach_$index") {
FirstApproach(
expandableSection = DemoData.ExpandableSection(
it.sectionInfo.subList(
3,
5
)
)
)
}
//Second approach
/*if (isExpanded)
items(count = 3, key = { indexInner -> "categories_second_half_$index$indexInner" }) { index ->
Section(
sectionInfo = it.sectionInfo[index + 2]
)
}
item(key = "button_$index") {
ShowHideButton(isExpanded, clickHandler)
}*/
}
}
}
}
}
#Composable
fun FirstApproach(expandableSection: DemoData.ExpandableSection) {
var expanded by remember { mutableStateOf(false) }
val density = LocalDensity.current
Column {
AnimatedVisibility(
visible = expanded,
enter = slideInVertically() +
expandVertically(
// Expand from the top.
expandFrom = Alignment.Top,
animationSpec = tween(durationMillis = 350, easing = FastOutLinearInEasing)
) + fadeIn(
// Fade in with the initial alpha of 0.3f.
initialAlpha = 0.3f
),
exit = slideOutVertically(
animationSpec = tween(durationMillis = 350, easing = FastOutLinearInEasing)
) + shrinkVertically(
shrinkTowards = Alignment.Bottom,
animationSpec = tween(durationMillis = 350, easing = FastOutLinearInEasing)
) + fadeOut(
animationSpec = tween(durationMillis = 350, easing = FastOutLinearInEasing),
targetAlpha = 0f
)
) {
Column {
for (i in 0 until expandableSection.sectionInfo.size) {
HeaderComposable(header = expandableSection.sectionInfo[i].header)
InfoCardsComposable(expandableSection.sectionInfo[i].infoCard)
DetailsCardComposable(expandableSection.sectionInfo[i].detailCard)
}
}
}
Button(
modifier = Modifier
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
.fillMaxWidth(),
onClick = {
expanded = !expanded
}) {
Text(text = if (expanded) "Hide" else "Show")
}
}
}
#Composable
fun HeaderComposable(header: DemoData.Header) {
Row(
modifier = Modifier
.padding(top = 16.dp)
.fillMaxWidth()
.height(64.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = header.title, modifier = Modifier.padding(horizontal = 16.dp))
}
}
#Composable
fun CardComposable(card: DemoData.Card) {
Card(
modifier = Modifier
.padding(top = 16.dp)
.size(164.dp),
backgroundColor = RandomColor
) {
Text(text = card.cardText, modifier = Modifier.padding(horizontal = 16.dp))
}
}
#Composable
fun BigCardComposable(bigCard: DemoData.BigCard) {
Card(
modifier = Modifier
.padding(top = 16.dp)
.size(172.dp),
backgroundColor = RandomColor
) {
Text(text = bigCard.bigCardText, modifier = Modifier.padding(horizontal = 16.dp))
}
}
#Composable
fun Section(sectionInfo: SectionInfo) {
Column(
modifier = Modifier.animateContentSize()
) {
HeaderComposable(header = sectionInfo.header)
InfoCardsComposable(sectionInfo.infoCard)
DetailsCardComposable(sectionInfo.detailCard)
}
}
#Composable
private fun ShowHideButton(isExpanded: Boolean, clickHandler: ClickHandler) {
Button(
modifier = Modifier
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
.fillMaxWidth(),
onClick = {
clickHandler.invoke(
!isExpanded
)
}) {
Text(text = if (isExpanded) "Hide" else "Show")
}
}
#Composable
fun DetailsCardComposable(detailCardsList: List<DetailCard>) {
LazyRow(
modifier = Modifier.padding(top = 16.dp)
) {
items(detailCardsList) {
DetailCardComposable(detailCard = it)
}
}
}
#Composable
fun InfoCardsComposable(infoCardsList: List<InfoCard>) {
LazyRow(
modifier = Modifier.padding(top = 16.dp)
) {
items(infoCardsList) {
InfoCardComposable(infoCard = it)
}
}
}
#Composable
fun InfoCardComposable(infoCard: InfoCard) {
Card(
modifier = Modifier
.size(136.dp),
backgroundColor = RandomColor
) {
Text(text = infoCard.infoText, modifier = Modifier.padding(horizontal = 16.dp))
}
}
#Composable
fun DetailCardComposable(detailCard: DetailCard) {
Card(
modifier = Modifier
.size(156.dp),
backgroundColor = RandomColor
) {
Text(text = detailCard.detailText, modifier = Modifier.padding(horizontal = 16.dp))
}
}
Complete code to try out is available here: https://github.com/DirajHS/ComposeAnimation/tree/master
I would like to know if this is the expected behavior or am I doing something wrong?
Any suggestions on snapping the button to the top during collapse would be much appreciated.
My composable is recomposing endlessly after flow collect and navigating to a new screen.
I can't understand why.
I'm using Firebase for Auth with Email and Password.
I had to put some Log.i to test my function and my composable, and yes, my Main composable (SignUp) is recomposing endlessly after navigating.
ViewModel
// Firebase auth
private val _signUpState = mutableStateOf<Resources<Any>>(Resources.success(false))
val signUpState: State<Resources<Any>> = _signUpState
fun firebaseSignUp(email: String, password: String) {
job = viewModelScope.launch(Dispatchers.IO) {
firebaseAuth.firebaseSignUp(email = email, password = password).collect {
_signUpState.value = it
Log.i("balito", "polipop")
}
}
}
fun stop() {
job?.cancel()
}
SignUp
#Composable
fun SignUp(
navController: NavController,
signUpViewModel: SignUpViewModel = hiltViewModel()
) {
val localFocusManager = LocalFocusManager.current
Log.i("salut", "salut toi")
Column(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.padding(16.dp)
.background(color = PrimaryColor)
) {
BackButton(navController = navController)
Spacer(modifier = Modifier.height(30.dp))
Text(
text = stringResource(id = R.string.sinscrire),
fontFamily = visby,
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
color = Color.White
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.prenez_votre_sante_en_main),
fontFamily = visby,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
color = Grey
)
Spacer(modifier = Modifier.height(20.dp))
Email(signUpViewModel = signUpViewModel, localFocusManager = localFocusManager)
Spacer(modifier = Modifier.height(16.dp))
Password(signUpViewModel = signUpViewModel, localFocusManager = localFocusManager)
Spacer(modifier = Modifier.height(30.dp))
Button(value = stringResource(R.string.continuer), type = Type.Valid.name) {
localFocusManager.clearFocus()
signUpViewModel.firebaseSignUp(signUpViewModel.emailInput.value, signUpViewModel.passwordInput.value)
}
Spacer(modifier = Modifier.height(16.dp))
Button(value = stringResource(R.string.inscription_avec_google), type = Type.Other.name) {
}
Spacer(modifier = Modifier.weight(1f))
Box(
modifier = Modifier
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
ClickableTextInfo(stringResource(id = R.string.deja_un_compte_se_connecter), onClick = {})
}
}
Response(navController = navController, signUpViewModel = signUpViewModel)
DisposableEffect(key1 = signUpViewModel.signUpState.value == Resources.success(true)) {
onDispose {
signUpViewModel.stop()
Log.i("fin", "fin")
}
}
}
#Composable
private fun Response(
navController: NavController,
signUpViewModel: SignUpViewModel
) {
when (val response = signUpViewModel.signUpState.value) {
is Resources.Loading<*> -> {
//WaitingLoaderProgress(loading = true)
}
is Resources.Success<*> -> {
response.data.also {
Log.i("lolipop", "lolipopi")
if (it == true) {
navController.navigate(Screen.SignUpConfirmation.route)
}
}
}
is Resources.Failure<*> -> {
// response.throwable.also {
// Log.d(TAG, it)
// }
}
}
}
During navigation transition recomposition happens multiple times because of animations, and you call navController.navigate on each recomposition.
You should not cause side effects or change the state directly from the composable builder, because this will be performed on each recomposition, which is not expected in cases like animation.
Instead you should use side effects. In your case, LaunchedEffect should be used.
if (response.data) {
LaunchedEffect(Unit) {
Log.i("lolipop", "lolipopi")
navController.navigate(Screen.SignUpConfirmation.route)
}
}
I have an image and I want to show a dropdownMenuItem when user click in the image. I was debugging the app and I can see that the code go through the DropdownDemo method but is not showing anything.
Am I doing something wrong?
Click code:
#Composable
fun Header(currentItem: CartListItems) {
var showDialog by remember { mutableStateOf(false) }
Box(Modifier.fillMaxWidth()) {
Text(modifier = Modifier.align(Alignment.TopStart),
text = currentItem.type,
color = colorResource(id = R.color.app_grey_dark),
fontSize = 12.sp)
Image(painter = painterResource(R.drawable.three_dots),
contentDescription = "more options button",
Modifier
.align(Alignment.CenterEnd)
.width(24.dp)
.height(6.75.dp)
.clickable(indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = {
showDialog = true
}))
if(showDialog) {
DropdownDemo()
showDialog = false
}
}
}
Dropmenu:
#Composable
fun DropdownDemo() {
var expanded by remember { mutableStateOf(false) }
val items = listOf("A", "B", "C", "D", "E", "F")
val disabledValue = "B"
var selectedIndex by remember { mutableStateOf(0) }
Box(modifier = Modifier
.fillMaxSize()
.wrapContentSize(Alignment.TopStart)) {
Text(items[selectedIndex],modifier = Modifier
.fillMaxWidth()
.clickable(onClick = { expanded = true })
.background(
Color.Gray
))
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier
.fillMaxWidth()
.background(
Color.Red
)
) {
items.forEachIndexed { index, s ->
DropdownMenuItem(onClick = {
selectedIndex = index
expanded = false
}) {
val disabledText = if (s == disabledValue) {
" (Disabled)"
} else {
""
}
Text(text = s + disabledText)
}
}
}
}
}
showDialog appears to be a MutableState object. Hence, when the image is clicked, it becomes true, and a recomposition is triggered, after which the conditional block is executed, displaying the DropDownMenu. However, in the very next line. You equate showDialog to false, re-trigerring a recomposition, and rendering the DropDownMenu collapsed.