State Hoisting in LazyColumn Single Selection. Jetpack Compose - android

I have a LazyColumn with expandable items. And when i click on the item he expands or collapses, but i need that when i click on closed element it opens and others in the list close.(the one that open). So first i moved state of item from remember variable in the item's composable function to the list of dataClass elements. And when i click on item he execute callback that change value of it' selement in the list, but it doesn't recompose.
#Preview
#Composable
fun ExpandableCardList() {
val list = remember { mutableStateListOf(PointsCard(0),PointsCard(1),PointsCard(2),PointsCard(3),PointsCard(4),PointsCard(5))}
val state = rememberLazyListState()
Scaffold { paddingValues ->
LazyColumn(
state = state,
modifier = Modifier
.fillMaxWidth(),
contentPadding = paddingValues
) {
items(list, {it.key}) { item ->
ExpandableCard(
expanded = item.state,
state = {
bool -> item.state = bool
}
)
}
}
}
}
#Composable
fun ExpandableCard(expanded: Boolean, state: (bool:Boolean) -> Unit ){
Card(
Modifier
.fillMaxWidth()
.clickable { state(!expanded) }
.background(Color(Color.BLACK))
) {
Column(Modifier.background(Color(Color.BLACK)))
{
Box(
Modifier
.fillMaxWidth()
.height(54.dp)
.background(Color(Color.BLACK))
)
AnimatedVisibility(expanded) {
Box(
Modifier
.fillMaxWidth()
.padding(start = 10.dp)
.heightIn(56.dp, 300.dp)
.background(Color(Color.BLACK), shape = RoundedCornerShape(bottomStart = 8.dp)
)
)
}
}
}
}
data class PointsCard(
val key: Int = 0,
var state: Boolean = false
)

If you want a single selection, you should probably consider hoisting your list in a class and perform all the structural changes via the list's iterator.
class CardListState {
val list = mutableStateListOf(PointsCard(0),PointsCard(1),PointsCard(2),PointsCard(3),PointsCard(4),PointsCard(5))
fun onSelected(isSelected: Boolean, item: PointsCard) {
val iterator = list.listIterator()
while (iterator.hasNext()) {
val obj = iterator.next()
if (obj.key != item.key) {
iterator.set(obj.copy(state = false))
} else {
iterator.set(obj.copy(state = isSelected))
}
}
}
}
#Composable
fun ExpandableCardList() {
val cardListState = remember { CardListState() }
val state = rememberLazyListState()
Scaffold { paddingValues ->
LazyColumn(
state = state,
modifier = Modifier
.fillMaxWidth(),
contentPadding = paddingValues
) {
items(cardListState.list, {it.key} ) { item ->
ExpandableCard(
expanded = item.state,
state = { bool ->
cardListState.onSelected(bool, item)
}
)
}
}
}
}
#Composable
fun ExpandableCard(
expanded: Boolean,
state: (bool:Boolean) -> Unit
) {
Card(
Modifier
.fillMaxWidth()
.clickable {
state(!expanded)
}
.background(Color.Black)
) {
Column(Modifier.background(Color.Black))
{
Box(
Modifier
.fillMaxWidth()
.height(54.dp)
.background(Color.Red)
)
AnimatedVisibility(expanded) {
Box(
Modifier
.fillMaxWidth()
.padding(start = 10.dp)
.heightIn(56.dp, 300.dp)
.background(Color.Green)
)
}
}
}
}

Related

Reset row of switches in Jetpack compose

I have a page with a row of switches. I save the state of each row inside the composable using remember. And I update the ViewModel about the status only when the user navigates back from the page.
Now, I have a reset button at the top app bar which allows the user to reset all the user selections. Since Reset is at the upper level compose, I can't enforce the state change to the rows as the values are stored using remember. What would be the best strategy here to recompose the rows to the original state when the user clicks the reset button?
Here is the screen that I am implementing,
My Switch row compose
#Composable
fun SwitchRow(
name: String,
checked: Boolean,
onToggle: (name: String, checked: Boolean) -> Unit
) {
var checkedState by remember { mutableStateOf(checked) }
Surface(
modifier = Modifier.heightIn(min = 56.dp)
)
{
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = name, modifier = Modifier.weight(1f))
Switch(
checked = checkedState,
onCheckedChange = {
onToggle(name, it)
checkedState = it
}
)
}
}
}
A top App bar with a reset button,
#Composable
fun SwitchScreen(state: UiState,
onBackClicked: () -> Unit,
onToggleChange: (String, Boolean) -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(0.dp)) {
TopAppBar(
title = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = stringResource(R.string.settings_switch_rows))
TextButton(onClick = onReset) {
Text(stringResource(R.string.settings_reset))
}
}
},
backgroundColor = MaterialTheme.colors.background,
navigationIcon = {
IconButton(onClick = onBackClicked) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = stringResource(R.string.go_back)
)
}
}
)
when (state) {
is UiState.Loading -> Loading()
is State.Success -> SwitchRowList(
state = state,
onToggle = onToggleChange,
)
}
}
}

Jetpack Compose rememberSwipeableState saves old state when the list is updated

I have a list which is showing items by category. If i choose the first category and swipe the first item to the left then change a category to another one. New list is updating but the first item remains as swiped state on new list ui which should display all swipeable items as default state. How can i set default swipe state when category is changed?
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun SwipeRevealDismissItem(
context: Context,
itemDataClass: itemDataClass,
onDelete: (itemDataClass: itemDataClass) -> Unit,
isEnabled: Boolean,
defaultState: Int = SwipeState.DEFAULT.value,
onItemSelected: ((itemDataClass?) -> Unit)? = null,
content: #Composable () -> Unit
) {
val swipeState = rememberSwipeableState(
initialValue = defaultState,
)
val scope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.swipeable(
state = swipeState,
anchors = mapOf(
0f to SwipeState.DEFAULT.value,
-Utils
.dp2px(128, context)
.toFloat() to SwipeState.SWIPED.value,
),
thresholds = { _, _ ->
FractionalThreshold(0.3f)
},
orientation = Orientation.Horizontal,
enabled = isEnabled
)
) {
Card(
modifier = Modifier
.align(Alignment.CenterEnd)
.width(96.dp)
.height(108.dp)
.padding(4.dp)
.clickable {
// onDelete(itemDataClass)
scope.launch {
swipeState.animateTo(0, tween(200, 0))
}
},
shape = RoundedCornerShape(8.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_delete_white_24dp),
modifier = Modifier
.size(16.dp)
.padding(32.dp),
tint = colorResource(id = R.color.colorRed),
contentDescription = null,
)
}
Box(modifier = Modifier
.offset {
IntOffset(swipeState.offset.value.roundToInt(), 0)
}
.clickable {
onItemSelected?.invoke(itemDataClass)
if (swipeState.currentValue == SwipeState.SWIPED.value) {
scope.launch {
swipeState.animateTo(SwipeState.DEFAULT.value)
}
}
}
.fillMaxWidth()
.wrapContentHeight()
) {
content()
}
}
}
private enum class SwipeState(val value: Int) {
DEFAULT(0),
SWIPED(1);
}

How to know when a LazyColumn (or any Composable for that matter) has been touched?

I have the following screen built in Compose -
#Composable
fun DashboardScreen(heroesViewModel: HeroesViewModel = get()) {
val searchState by heroesViewModel.searchState.collectAsState()
val uiState by heroesViewModel.uiState.collectAsState()
val focusRequester = remember { FocusRequester() }
Column(modifier = Modifier.fillMaxSize()) {
SearchBar(
searchState = searchState,
onQueryChanged = { text ->
heroesViewModel.submitEvent(HeroesViewModel.UiEvent.SearchQueryChanged(text))
},
onSearchFocusChange = { focused ->
heroesViewModel.submitEvent(HeroesViewModel.UiEvent.SearchBarFocusChanged(focused))
},
onClearQueryClicked = {
heroesViewModel.submitEvent(HeroesViewModel.UiEvent.ClearQueryClicked)
},
onBack = {},
focusRequester
)
LazyColumn {
items(uiState.modelsListResponse ?: listOf()) { model ->
if (model is HeroListSeparatorModel)
HeroesListSeparatorItem(model)
else if (model is HeroesListModel)
HeroesListItem(model) {
heroesViewModel.submitEvent(HeroesViewModel.UiEvent.ListItemClicked(model))
}
}
}
}
}
And here is my SearchBar -
#Composable
fun SearchBar(
searchState: SearchState,
onQueryChanged: (String) -> Unit,
onSearchFocusChange: (Boolean) -> Unit,
onClearQueryClicked: () -> Unit,
onBack: () -> Unit,
focusRequester : FocusRequester,
modifier: Modifier = Modifier
) {
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val focused = searchState.focused
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
AnimatedVisibility(visible = focused) {
BackButton(focusManager, keyboardController, onBack)
}
SearchTextField(
searchState,
onQueryChanged,
onSearchFocusChange,
onClearQueryClicked,
focusRequester
)
}
}
#Composable
fun SearchTextField(
searchState: SearchState,
onQueryChanged: (String) -> Unit,
onSearchFocusChanged: (Boolean) -> Unit,
onClearQueryClicked: () -> Unit,
focusRequester : FocusRequester,
modifier: Modifier = Modifier
) {
val focused = searchState.focused
var query = searchState.query
val searching = searchState.searching
Surface(
modifier = modifier
.then(
Modifier
.height(56.dp)
.padding(
top = 8.dp, bottom = 8.dp,
start = if (focused.not()) 16.dp else 0.dp,
end = 16.dp
)
),
color = Color(0xffF5F5F5),
shape = RoundedCornerShape(percent = 50)
) {
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Box(
contentAlignment = Alignment.CenterStart,
modifier = modifier
) {
if (query.isEmpty()) {
SearchHint(modifier = modifier.padding(start = 24.dp, end = 8.dp))
}
Row(verticalAlignment = Alignment.CenterVertically) {
BasicTextField(
value = query,
onValueChange = {
query = it
onQueryChanged(it)
},
modifier = Modifier
.fillMaxSize()
.weight(1f)
.onFocusChanged { focusState ->
onSearchFocusChanged(focusState.isFocused)
}
.focusRequester(focusRequester)
.padding(top = 9.dp, bottom = 8.dp, start = 24.dp, end = 8.dp),
singleLine = true
)
when {
searching -> {
CircularProgressIndicator(
modifier = Modifier
.padding(horizontal = 16.dp)
.width(25.dp)
.size(24.dp)
)
}
query.isNotEmpty() -> {
IconButton(onClick = onClearQueryClicked) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = null
)
}
}
}
}
}
}
}
}
#Composable
fun SearchHint(
modifier: Modifier = Modifier,
hint: String = "Enter a hero name"
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.then(modifier)
) {
Text(
text = hint,
color = Color(0xff757575)
)
}
}
class SearchState(
query: String,
focused: Boolean,
searching: Boolean,
) {
var query by mutableStateOf(query)
var focused by mutableStateOf(focused)
var searching by mutableStateOf(searching)
}
What I want to achieve is the ability to know when the user has tapped or click anywhere outside of my SearchBar Composable. I want to send an event to the ViewModel so that he recomposes the screen, removing the keyboard and removing the cursor I have on my SearchBar (basically just resetting the focus).
I have tried using the focusRequester like I did in my SearchBar but without success as nothing happened, tried using the clickable {} block which is not what I need (I need the tap and not the click) and tried using Modifier.pointerInput with detectTapGestures and it did not work, not at the root LazyColumn and not at the ListItem level.
What I am looking for should be something really easy.
Found the answer - I ended up using isScrollInProgress variable from LazyListState class that provides a boolean indicating if the list is currently in scrolling. When the value was true I removed the focus from where I needed to remove it and it worked :)
Attached the solution -
val listState = rememberLazyListState()
...
LazyColumn(state = listState) {
items(uiState.modelsListResponse ?: listOf()) { model ->
heroesViewModel.submitEvent(HeroesViewModel.UiEvent.ListIsScrolling(listState.isScrollInProgress))
if (model is HeroListSeparatorModel)
HeroesListSeparatorItem(model)
else if (model is HeroesListModel)
HeroesListItem(model) {
heroesViewModel.submitEvent(HeroesViewModel.UiEvent.ListItemClicked(model))
}
}
}
Use PointerInputModifier which provides access to the underlying MotionEvents originally dispatched to Compose.
Text(modifier = Modifier
.pointerInteropFilter { motionEvent ->
when (motionEvent.action) {
MotionEvent.ACTION_DOWN -> {
// When the user touches the composable
}
MotionEvent.ACTION_UP -> {
// When the user removes touch from the composable
}
}
true
},
text = "Click Me!"
)
Inside pointerInteropFilter block MotionEvent.ACTION_DOWN is triggered when user touches on the composable.

animation o a lazycolumn android

I have a list with 10 items one of them have this elements "rankingCurrentPlace", "rankingPastPlace" and "isUser:true".
What i need to do its an animation on the lazycolumn if the api esponse is like this
"isUser:true", "rankingPastPlace:3" , "rankingCurrentPlace:7"
i need to show an animation in the list where the row starts in the third place and descend to the seventh place
is there a way to do this?
this is what I actually have
LazyColumn(
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
) {
items(
items = leaderboard,
key = { leaderBoard ->
leaderBoard.rankingPlace
}
) { leaderBoard ->
RowComposable( modifier = Modifier
.fillMaxWidth(),
topicsItem = leaderBoard,)
}
This answer works except when swapping first item with any item even with basic swap function without animation. I think it would be better to ask a new question about why swapping first item doesn't work or if it is bug. Other than that works as expected. If you need to move to items that are not in screen you can lazyListState.layoutInfo.visibleItemsInfo and compare with initial item and scroll to it before animation
1.Have a SnapshotStateList of data to trigger recomposition when we swap 2 items
class MyData(val uuid: String, val value: String)
val items: SnapshotStateList<MyData> = remember {
mutableStateListOf<MyData>().apply {
repeat(20) {
add(MyData( uuid = UUID.randomUUID().toString(), "Row $it"))
}
}
}
2.Function to swap items
private fun swap(list: SnapshotStateList<MyData>, from: Int, to: Int) {
val size = list.size
if (from in 0 until size && to in 0 until size) {
val temp = list[from]
list[from] = list[to]
list[to] = temp
}
}
3.Function to swap items one by one. There is a bug with swapping first item. Even if it's with function above when swapping first item other one moves up without showing animation via Modififer.animateItemPlacement().
#Composable
private fun animatedSwap(
lazyListState: LazyListState,
items: SnapshotStateList<MyData>,
from: Int,
to: Int,
onFinish: () -> Unit
) {
LaunchedEffect(key1 = Unit) {
val difference = from - to
val increasing = difference < 0
var currentValue: Int = from
repeat(abs(difference)) {
val temp = currentValue
if (increasing) {
currentValue++
} else {
currentValue--
}
swap(items, temp, currentValue)
if (!increasing && currentValue == 0) {
delay(300)
lazyListState.scrollToItem(0)
}
delay(350)
}
onFinish()
}
}
4.List with items that have Modifier.animateItemPlacement()
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
state = lazyListState,
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(
items = items,
key = {
it.uuid
}
) {
Row(
modifier = Modifier
.animateItemPlacement(
tween(durationMillis = 200)
)
.shadow(1.dp, RoundedCornerShape(8.dp))
.background(Color.White)
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.size(50.dp),
painter = painterResource(id = R.drawable.landscape1),
contentScale = ContentScale.FillBounds,
contentDescription = null
)
Spacer(modifier = Modifier.width(10.dp))
Text(it.value, fontSize = 18.sp)
}
}
}
Demo
#OptIn(ExperimentalFoundationApi::class)
#Composable
private fun AnimatedList() {
Column(modifier = Modifier.fillMaxSize()) {
val items: SnapshotStateList<MyData> = remember {
mutableStateListOf<MyData>().apply {
repeat(20) {
add(MyData(uuid = UUID.randomUUID().toString(), "Row $it"))
}
}
}
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
state = lazyListState,
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(
items = items,
key = {
it.uuid
}
) {
Row(
modifier = Modifier
.animateItemPlacement(
tween(durationMillis = 200)
)
.shadow(1.dp, RoundedCornerShape(8.dp))
.background(Color.White)
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.size(50.dp),
painter = painterResource(id = R.drawable.landscape1),
contentScale = ContentScale.FillBounds,
contentDescription = null
)
Spacer(modifier = Modifier.width(10.dp))
Text(it.value, fontSize = 18.sp)
}
}
}
var fromString by remember {
mutableStateOf("7")
}
var toString by remember {
mutableStateOf("3")
}
var animate by remember { mutableStateOf(false) }
if (animate) {
val from = try {
Integer.parseInt(fromString)
} catch (e: Exception) {
0
}
val to = try {
Integer.parseInt(toString)
} catch (e: Exception) {
0
}
animatedSwap(
lazyListState = lazyListState,
items = items,
from = from,
to = to
) {
animate = false
}
}
Row(modifier = Modifier.fillMaxWidth()) {
TextField(
value = fromString,
onValueChange = {
fromString = it
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
TextField(
value = toString,
onValueChange = {
toString = it
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}
Button(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth(),
onClick = {
animate = true
}
) {
Text("Swap")
}
}
}
Edit: Animating with Animatable
Another method for animating is using Animatable with Integer vector.
val IntToVector: TwoWayConverter<Int, AnimationVector1D> =
TwoWayConverter({ AnimationVector1D(it.toFloat()) }, { it.value.toInt() })
val coroutineScope = rememberCoroutineScope()
val animatable = remember { Animatable(0, IntToVector) }
And can be used as
private fun alternativeAnimate(
from: Int,
to: Int,
coroutineScope: CoroutineScope,
animatable: Animatable<Int, AnimationVector1D>,
items: SnapshotStateList<MyData>
) {
val difference = from - to
var currentValue: Int = from
coroutineScope.launch {
animatable.snapTo(from)
animatable.animateTo(to,
tween(350 * abs(difference), easing = LinearEasing),
block = {
val nextValue = this.value
if (abs(currentValue -nextValue) ==1) {
swap(items, currentValue, nextValue)
currentValue = nextValue
}
}
)
}
}
on button click, i'm getting values from TextField fo i convert from String
Button(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth(),
onClick = {
val from = try {
Integer.parseInt(fromString)
} catch (e: Exception) {
0
}
val to = try {
Integer.parseInt(toString)
} catch (e: Exception) {
0
}
alternativeAnimate(from, to, coroutineScope, animatable, items)
}
) {
Text("Swap")
}
Result
I suggest you to get your items from a data class. If your other items does not contain the variables you mentioned you can make them nullable in data class and put a condition checker in your lazycolumn
Like this
data class Items(
val otherItems: Other,
val rankingCurrentPlace: Int?,
val rankingLastPlace: Int?,
val isUser: Boolean?
)
Then you can make a list from this data class and pass it to lazycolumn
LazyColumn{
items(list){
(elements with condition)
}
}

How we can use onLongClick listener for bottom sheet in jetpack compose?

I am try to learn jetpack compose, and I want to apply bottom sheet for on long click listener, I have example of code below, and I try to use bottom sheet for onLongClick listener, but I have some confused about how can I apply for row data? I had some example but I do not have an idea about this problem?
#Composable
fun BSDataScreen() {
val modalBottomSheetState = rememberModalBottomSheetState(initialValue =
ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
ModalBottomSheetLayout(
sheetContent = {
SheetScreen()
},
sheetState = modalBottomSheetState,
sheetShape = RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp),
sheetBackgroundColor = Color.White,
) {
Scaffold(
backgroundColor = Color.White,
) {
DataScreen(
scope = scope, state = modalBottomSheetState)}}}
#Composable
fun DataScreen(
scope: CoroutineScope,
state: ModalBottomSheetState
) {
val listOfData = listOf(
Data( painterResource(R.drawable.image1)),
Data(painterResource(R.drawable.image2)),
Data(painterResource(R.drawable.image3),)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) {
LazyColumn(
modifier = Modifier
) {
items(listOfData.size) { index ->
DataListItem(listOfData[index])}}}}
#Composable
fun DataListItem(data: Data) {
val context = LocalContext.current
Column(
modifier = Modifier.padding(5.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(5.dp)
.combinedClickable(
onLongClick= {
scope.launch {
state.show()
}},)
) {
Image(
painter = data.painter,
contentDescription = null,)}}}
You can create a lambda onClick: (Data) -> Unit and hoist state from DataListItem to DataScreen for each item
#Composable
fun DataListItem(data: Data, onLongClick: (Data) -> Unit) {
val context = LocalContext.current
Column(
modifier = Modifier.padding(5.dp)
) {
Row(modifier = Modifier
.fillMaxWidth()
.padding(5.dp)
.combinedClickable(
onClick = {
},
onLongClick = {
onLongClick(data)
}
)
) {
}
}
}
And in DataScreen
LazyColumn(
modifier = Modifier
) {
items(listOfData.size) { index ->
DataListItem(listOfData[index]) { data: Data->
// you can call bottom sheet or pass data if required
scope.launch {
state.show()
}
}
}
}

Categories

Resources