Jetpack Compose: Mimicking spinner.setSelection() inside of a DropDownMenu - android

The use case is that you have 10s or 100s of items inside of a dropdown menu, the dropdown options have some ordering - as with number values or alphabetical listing of words - and selections are made in succession.
When the user reopens the menu, you'd like for it to open in the same region as their last selection, so that for instance you don't jump from "car" to "apple" but rather from "car" to "cat". Or if they just opted to view order number 358, they can quickly view order number 359.
Using views, you could create a Spinner and put all of your items in an ArrayAdapter and then call spinner.setSelection() to scroll directly to the index you want.
DropdownMenu doesn't have anything like HorizontalPager's scrollToPage(). So what solutions might exist to achieve this?
So far, I've tried adding verticalScroll() to the DropdownMenu's modifier and trying to do arithmetic with the scrollState. But it crashes at runtime with an error saying the component has infinite height, the same error you get if you try to nest scrollable components like a LazyColumn inside of a Column with verticalScroll.

It's a known issue.
DropdownMenu has its own vertical scroll modifier inside, and there is no API to work with it.
Until this problem is fixed by providing a suitable API, the only workaround I can think of is to create your own view - you can take the source code of DropdownMenu as reference.

I'll post a more detailed answer here because I don't want to mislead anyone with my comment above.
If you're in Android Studio, click the three dots on the mouse-hover quick documentation box and select "edit source" to open the source for DropdownMenu in AndroidMenu.android.kt. Then observe that it uses a composable called DropdownMenuItemContent. Edit source again and you're in Menu.kt.
You'll see this:
#Composable
internal fun DropdownMenuContent(
...
...
...
{
Column(
modifier = modifier
.padding(vertical = DropdownMenuVerticalPadding)
.width(IntrinsicSize.Max)
.verticalScroll(rememberScrollState()),//<-We want this
content = content
)
}
So in your custom composable just replace that rememberScrollState() with your favorite variable name for a ScrollState.
And then chain that reference all the way back up to your original view.
Getting Access to the ScrollState
#Composable
fun MyCustomDropdownMenu(
expanded:Boolean,
scrollStateProvidedByTopParent:ScrollState,
...
...
)
{...}
#Composable
fun MyCustomDropdownMenuContent(
scrollStateProvidedByTopParent:ScrollState,
...
...
)
{...}
//And now for your actual content
#Composable
fun TopParent(){
val scrollStateProvidedByTopParent=rememberScrollState()
val spinnerExpanded by remember{mutableStateOf(false)}
...
...
Box{
Row(modifier=Modifier.clickable(onClick={spinnerExpanded=!spinnerExpanded}))//<-More about this line in the sequel
{
Text("Options")
Icon(imageVector = Icons.Filled.ArrowDropDown, contentDescription = "")
MyCustomDropdownMenu(
expanded = spinnerExpanded,
scrollStateProvidedByTopParent=scrollStateProvidedByTopParent,
onDismissRequest = { spinnerExpanded = false })
{//your spinner content}
}
}
}
The above only specifies how to access the ScrollState of the DropdownMenu. But once you have the ScrollState, you'll have to do some arithmetic to get the scroll position right when it opens. Here's one way that seems alright.
Calculating the scroll distance
Even after setting the contents of the menu items explicitly, the distance was never quite right if I relied on those values. So I used an onTextLayout callback inside the Text of my menu items in order to get the true Text height at the time of rendering. Then I use that value for the arithmetic. It looks like this:
#Composable
fun TopParent(){
val scrollStateProvidedByTopParent=rememberScrollState()
val spinnerExpanded by remember{mutableStateOf(false)}
val chosenText:String by remember{mutableStateOf(myListOfSpinnerOptions[0])
val height by remember{mutableStateOf(0)}
val heightHasBeenChecked by remember{mutableStateOf(false)}
val coroutineScope=rememberCoroutineScope()
...
...
Box{
Row(modifier=Modifier.clickable(onClick={spinnerExpanded=!spinnerExpanded
coroutineScope.launch{scrollStateProvidedByTopParent.scrollTo(height*myListOfSpinnerOptions.indexOf[chosenText])}}))//<-This gets some arithmetic for scrolling distance
{
Text("Options")
Icon(imageVector = Icons.Filled.ArrowDropDown, contentDescription = "")
MyCustomDropdownMenu(
expanded = spinnerExpanded,
scrollStateProvidedByTopParent=scrollStateProvidedByTopParent,
onDismissRequest = { spinnerExpanded = false }) {
myListOfSpinnerOptions.forEach{option->
DropdownMenuItem(onClick={
chosenText=option
spinnerExpanded=false
}){
Text(option,onTextLayout={layoutResult->
if (!heightHasBeenChecked){
height=layoutResults.size.height
heightHasBeenChecked=true
}
}
)
}
}
}
}
}

Related

Compose - DropDownMenu is causing unwanted recomposition

This is the composable hierarchy in my app:
HorizontalPager
↪LazyVerticalGrid
↪PostItem
↪AsyncImage
DropdownMenu
↪DropdownMenuItem
When swiping the HorizontalPager the AsyncImage inside my PostItem's are recomposing.
Removing the DropdownMenu fixes this and the PostItem is no longe recomposing and gives the wanted behavior.
The problem is that I have huge FPS drops when swiping through the HorizontalPager.
Why is DropdownMenu causing a recomposition when swiping the HorizontalPager?
var showMenu by remember { mutableStateOf(false) }
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }) {
DropdownMenuItem(onClick = {
}) {
Text(text = "Share")
}
}
Unfortunately, without seeing more code showing the rest of the structure, it's hard to say for sure what your problem is here.
A likely answer is that you haven't split up your Composable function enough. Something the docs hardly talk about is that the content portion of a lot of the built-in Composeables are inline functions which means that if the content recomposes the parent will as well. This is the most simple example of this I can give.
#Composable
fun foo() {
println("recompose function")
Box {
println("recompose box")
Column {
println("recompose column")
Row {
println("recompose row")
var testState by mutableStateOf("my text")
Button(
onClick = { testState = "new text" }
) {}
Text(testState)
}
}
}
}
output is:
recompose function
recompose box
recompose column
recompose row
Not only does this recompose the whole function it also recreates the testState causing it to never change values.
Again not 100% that this is your problem, but I would look into it. The solution would be to split my Row and row content into it's own Composable function.

LazyColumn, Jetpack Compose. Single items() state by index

I have a problem with the state of individual elements in LazyColumn and LazyRow. If the first element is open and I want to delete it, then the second element becomes the first and also becomes open. I want it to work differently.
Screen
enter image description here
Fragment LazyColumn
items(zamList.size) { index ->
ExpandableCard()
}
Expandable Card
#Composable
fun ExpandableCard() {
//Expandable state
var expandedState by remember {
mutableStateOf(false)
}
Card(
onClick = {
expandedState = !expandedState
}
)
It's a bad idea to combine LazyLists with remember:
Try adding 20 items, opening 1, and scrolling, until the item is not visible anymore: the item will have closed.
The way i suggest to do it is:
Hold that state in a viewModel, e.g. a Map<YourItem, isOpen>
onOpen/onClose update your viewmodel-state.
Other than that, it's a good idea to provide ids if possible.
Also, you might try Modifier.animateContentSize() (which is only defined in a LazyScope(!), so your animations look better :)
You need to use unique keys with items. Using key will makes sure only the items that changed being recomposed and keeps order of items that are not changed based on their ids.
val myItems = listOf<MyItem>()
LazyColumn() {
items(
items = myItems,
key = {item: MyItem ->
// Some unique id here
item.hashCode()
}
) {
}
}

Jetpack Compose: nested LazyColumn / LazyRow

I've read through similar topics but I couldn't find satisfactory result:
What is the equivalent of NestedScrollView + RecyclerView or Nested RecyclerView (Recycler inside another recycler) in Jetpack compose
Jetpack Compose: How to put a LazyVerticalGrid inside a scrollable Column?
Use lazyColum inside the column has an error in the Jetpack Compose
Nested LazyVerticalGrid with Jetpack Compose
My use-case is: to create a comments' list (hundreds of items) with possibility to show replies to each comment (hundreds of items for each item).
Currently it's not possible to do a nested LazyColumn inside another LazyColumn because Compose will throw an exception:
java.lang.IllegalStateException: Vertically scrollable component was
measured with an infinity maximum height constraints, which is
disallowed. One of the common reasons is nesting layouts like
LazyColumn and Column(Modifier.verticalScroll()). If you want to add a
header before the list of items please add a header as a separate
item() before the main items() inside the LazyColumn scope. There are
could be other reasons for this to happen: your ComposeView was added
into a LinearLayout with some weight, you applied
Modifier.wrapContentSize(unbounded = true) or wrote a custom layout.
Please try to remove the source of infinite constraints in the
hierarchy above the scrolling container.
The solutions provided by links above (and others that came to my mind) are:
Using fixed height for internal LazyColumn - I cannot use it as each item can have different heights (for example: single vs multiline comment).
Using normal Columns (not lazy) inside LazyColumn - performance-wise it's inferior to lazy ones, when using Android Studio's Profiler and list of 500 elements, normal Column would use 350MB of RAM in my app comparing to 220-240MB using lazy Composables. So it will not recycle properly.
Using FlowColumn from Accompanist - I don't see any performance difference between this one and normal Column so see above.
Flatten the list's data source (show both comments and replies as "main" comments and only make UI changes to distinguish between them) - this is what I was currently using but when I was adding more complexity to this feature it prevents some of new feature requests to be implemented.
Disable internal LazyColumn's scrolling using newly added in Compose 1.2.0 userScrollEnabled parameter - unfortunately it throws the same error and it's an intended behaviour (see here).
Using other ways to block scrolling (also to block it programatically) - same error.
Using other LazyColumn's .height() parameters like wrapContentHeight() or using IntrinsicSize.Min - same error.
Any other ideas how to solve this? Especially considering that's doable to nest lazy components in Apple's SwiftUI without constraining heights.
I had a similar use case and I have a solution with a single LazyColumn that works quite well and performant for me, the idea is to treat your data as a large LazyColumn with different types of elements.
Because comment replies are now separate list items you have to first flatten your data so that it's a large list or multiple lists.
Now for sub-comments you just add some padding in front but otherwise they appear as separate lazy items.
I also used a LazyVerticalGrid instead of LazyColumn because I've had to show a grid of gallery pictures at the end, you may not need that, but if you do, you have to use span option everywhere else as shown below.
You'll have something like this:
LazyVerticalGrid(
modifier = Modifier
.padding(6.dp),
columns = GridCells.Fixed(3)
) {
item(span = { GridItemSpan(3) }) {
ComposableTitle()
}
items(
items = flattenedCommentList,
key = { it.commentId },
span = { GridItemSpan(3) }) { comment ->
ShowCommentComposable(comment)
//here subcomments will have extra padding in front
}
item(span = { GridItemSpan(3) }) {
ComposableGalleryTitle()
}
items(items = imageGalleryList,
key = { it.imageId }) { image ->
ShowImageInsideGrid(image) //images in 3 column grid
}
}
I sloved this problem in this function
#Composable
fun NestedLazyList(
modifier: Modifier = Modifier,
outerState: LazyListState = rememberLazyListState(),
innerState: LazyListState = rememberLazyListState(),
outerContent: LazyListScope.() -> Unit,
innerContent: LazyListScope.() -> Unit,
) {
val scope = rememberCoroutineScope()
val innerFirstVisibleItemIndex by remember {
derivedStateOf {
innerState.firstVisibleItemIndex
}
}
SideEffect {
if (outerState.layoutInfo.visibleItemsInfo.size == 2 && innerState.layoutInfo.totalItemsCount == 0)
scope.launch { outerState.scrollToItem(outerState.layoutInfo.totalItemsCount) }
println("outer ${outerState.layoutInfo.visibleItemsInfo.map { it.index }}")
println("inner ${innerState.layoutInfo.visibleItemsInfo.map { it.index }}")
}
BoxWithConstraints(
modifier = modifier
.scrollable(
state = rememberScrollableState {
scope.launch {
val toDown = it <= 0
if (toDown) {
if (outerState.run { firstVisibleItemIndex == layoutInfo.totalItemsCount - 1 }) {
Log.i(TAG, "NestedLazyList: down inner")
innerState.scrollBy(-it)
} else {
Log.i(TAG, "NestedLazyList: down outer")
outerState.scrollBy(-it)
}
} else {
if (innerFirstVisibleItemIndex == 0 && innerState.firstVisibleItemScrollOffset == 0) {
Log.i(TAG, "NestedLazyList: up outer")
outerState.scrollBy(-it)
} else {
Log.i(TAG, "NestedLazyList: up inner")
innerState.scrollBy(-it)
}
}
}
it
},
Orientation.Vertical,
)
) {
LazyColumn(
userScrollEnabled = false,
state = outerState,
modifier = Modifier
.heightIn(maxHeight)
) {
outerContent()
item {
LazyColumn(
state = innerState,
userScrollEnabled = false,
modifier = Modifier
.height(maxHeight)
) {
innerContent()
}
}
}
}
}
All what I did is that:
At first I set the height of the inner lazyList to the height of the parent view using BoxWithConstraints, this lets the inner list fill the screen without distroying the lazy concept.
Then I controlled the scrolling by disable lazy scroll and make the parent scrollable to determine when the scroll affect the parent list and when the child should scroll.
bud this still has some bugs when the parent size changed , in my case I escaped by this SideEffect
You can use rememberScrollState() for root column. Like this;
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())) {
LazyColumn {
// your code
}
LazyRow {
// your code
}
}

Why do I sometimes need key() in lists?

I have a component with some mutable state list. I pass an item of that, and a callback to delete the item, to another component.
#Composable
fun MyApp() {
val myItems = mutableStateListOf("1", "2", "3")
LazyColumn {
items(myItems) { item ->
MyComponent(item) { toDel -> myItems.remove(toDel) }
}
}
}
The component calls the delete callback in a clickable Modifier.
#Composable
fun MyComponent(item: String, delete: (String) -> Unit = {}) {
Column {
Box(
Modifier
.size(200.dp)
.background(MaterialTheme.colors.primary)
.clickable { delete(item) }
) {
Text(item, fontSize = 40.sp)
}
}
}
This works fine. But when I change the clickable for my own Modifier with pointerInput() then there's a problem.
fun Modifier.myClickable(delete: () -> Unit) =
pointerInput(Unit) {
awaitPointerEventScope { awaitFirstDown() }
delete()
}
#Composable
fun MyComponent(item: String, delete: (String) -> Unit = {}) {
Column {
Box(
Modifier
.size(200.dp)
.background(MaterialTheme.colors.primary)
.myClickable { delete(item) } // NEW
) {
Text(item, fontSize = 40.sp)
}
}
}
If I click on the first item, it removes it. Next, if I click on the newest top item, the old callback for the now deleted first item is called, despite the fact that the old component has been deleted.
I have no idea why this happens. But I can fix it. I use key():
#Composable
fun MyApp() {
val myItems = mutableStateListOf("1", "2", "3")
LazyColumn {
items(myItems) { item ->
key(item) { // NEW
MyComponent(item) { toDel -> myItems.remove(toDel) }
}
}
}
}
So why do I need key() when I use my own modifier? This is also the case in this code from jetpack, and I don't know why.
As the accepted answer says, Compose won't recalculate my custom Modifier because pointerEvent() doesn't have a unique key.
fun Modifier.myClickable(key:Any? = null, delete: () -> Unit) =
pointerInput(key) {
awaitPointerEventScope { awaitFirstDown() }
delete()
}
and
Box(
Modifier
.size(200.dp)
.background(MaterialTheme.colors.primary)
.myClickable(key = item) { delete(item) } // NEW
) {
Text(item, fontSize = 40.sp)
}
fixes it and I don't need to use key() in the outer component. I'm still unsure why I don't need to send a unique key to clickable {}, however.
Compose is trying to cache as many work as it can by localizing scopes with keys: when they haven't changes since last run - we're using cached value, otherwise we need to recalculate it.
By setting key for lazy item you're defining a scope for all remember calculations inside, and many of system functions are implemented using remember so it changes much. Item index is the default key in lazy item
So after you're removing first item, first lazy item gets reused with same context as before
And now we're coming to your myClickable. You're passing Unit as a key into pointerInput(It has a remember inside too). By doing this you're saying to recomposer: never recalculate this value until context changes. And the context of first lazy item hasn't changed, e.g. key is still same index, that's why lambda with removed item remains cached inside that function
When you're specifying lazy item key equal to item, you're changing context of all lazy items too and so pointerInput gets recalculated. If you pass your item instead of Unit you'll have the same effect
So you need to use key when you need to make use your calculations are not gonna be cached between lazy items in a bad way
Check out more about lazy column keys in the documentation
Jetpack compose optimizes the re-compose by only recomposing Widget which value has been changed.
In your Custom implementation of Modifier.myClickable when item list is changing due to deletion, only the inner Text(item, fontSize = 40.sp) will be recomposed since item has changed and it is the only one which is reading item. The outer Box() is not recomposed, hence it is holding the previous callback. But When you add key(item), the outer box will also be re-composed as the key value has changed. Hence it is working after adding the key.
So why is was working with Modifier.clickable { delete(item) }?
I think Compose kept track of change in the callback clickable { delete(item) }. So when the callback changed due to item deletion, it recomposed MyComponent, Hence is was working with clickable

Does Compose ConstraintLayout provide same handling for INVISIBLE and GONE as view based ConstraintLayout?

Disclaimer: I know that Compose just entered alpha01 and thus I do not expect that every functionality
is available. However, layout and handling of specific layout cases is IMHO an important topic
that should be addressed early 😉.
The current view based ConstraintLayout provides some specific handling in case a child view is
marked as GONE, refer to the
ConstrainLayout documentation.
I checked the Compose ConstraintLayout documentation, the available modifiers and so on, however
did not find anything that points in this direction. I also could not find any hint regarding
INVISIBLE and how/if Compose ConstraintLayout handles it like the view based ConstraintLayout.
In general, the current view based layouts (LinearLayout for example) handle INVISIBLE and GONE in a
similar fashion:
if a view is in state INVISIBLE then the view is part of the layout with its sizes, just not
shown. The overall layout of other views does not change and they stay in their positions.
if a view is in state GONE its sizes a usually treated as 0 and the layout is recomputed and
changed, other views usually change their positions.
Here a simple Compose ConstraintLayout UI, just 4 buttons in a row, chained to have them nicely
spread.
// if dontShow is 0 then show all buttons, otherwise make the button with this number
// somehow INVISIBLE. This feature is not yet implemented.
#Composable
fun fourButtonsCL(dontShow: Int) {
ConstraintLayout(Modifier.fillMaxSize()) {
val (btn1, btn2, btn3, btn4) = createRefs()
TextButton(onClick = {}, Modifier.constrainAs(btn1) {}.background(teal200)) { Text("Button1") }
TextButton(onClick = {}, Modifier.constrainAs(btn2) {}.background(teal200)) { Text("Button2") }
TextButton(onClick = {}, Modifier.constrainAs(btn3) {}.background(teal200)) { Text("Button3") }
TextButton(onClick = {}, Modifier.constrainAs(btn4) {}.background(teal200)) { Text("Button4") }
createHorizontalChain(btn1, btn2, btn3, btn4)
}
}
#Preview(showBackground = true)
#Composable
fun previewThreeButtons() {
ComposeAppTheme {
fourButtonsCL()
}
}
Assume I would like to make Button3 invisible, but keep the other 3 buttons positioned where they
are. Thus just a hole between Button2 and Button4. How to achieve this without creating yet another
Composable or adding additional logic. While the logic in this simple case may be just a view lines
of code, more complex layouts would the need some more complex logic. In veiw based ConstraintLayout
wwe just need to modify the child view.
The other assumption: make Button3 disappear completely from the layout (GONE) and re-compute the
layout, remaining buttons become wider and evenly spread out. At a first glance this looks simple,
and in this very simple example it maybe easy. However in more complex layouts this could require
some or even a lot of re-wiring of constraints of the embedded Composables.
Thus the question is:
how does compose handles these cases for Column and Row layouts (like in view based LinearLayout)
and for ConstraintLayout in particular? However with the following restriction 😉: without defining
many new Composables and/or without adding complex layout logic inside the Composables (re-wiring
constraints for example).
Did I miss some modifier? Is this even planned or possible in the Composable layouts? What would be
the preferred way to solve such layout cases in Compose?
Based on #CommonsWare comment to the question I could solve the INVISIBLE
option, see code below.
Currently (in alpha-01) the implementation of ConstraintLayout seems to be incomplete, at least a few TODO comments in the code indicate this. This
seems to include the yet missing support of the GONE feature.
I saw some of these:
// TODO(popam, b/158069248): add parameter for gone margin
Also the chain feature does not yet perform layout rendering in the same way as
in the view based ConstraintLayout.
object FourElementsNoDSL {
const val elementA = "ElementA"
const val elementB = "ElementB"
const val elementC = "ElementC"
const val elementD = "ElementD"
private val noDSLConstraintSet = ConstraintSet {
// Create references with defines ids, here using a string as id. Could be an Int as well,
// actually it's defined as 'Any'
val elemA = createRefFor(elementA)
val elemB = createRefFor(elementB)
val elemC = createRefFor(elementC)
val elemD = createRefFor(elementD)
// Simple chain only. Instead of this simple chain we can use (for example):
// constrain(elemA) {start.linkTo(parent.start) }
// to set a constraint as known in XML
// constrain(elemA) {start.linkTo(parent.start, 16.dp) }
// constrain(elemB) {start.linkTo(elemA.end) }
// constrain(elemC) {start.linkTo(elemB.end) }
// constrain(elemD) {end.linkTo(parent.end) }
createHorizontalChain(elemA, elemB, elemC, elemD)
}
#Composable
fun fourButtonsCLNoDSL(doNotShow: List<String>) {
ConstraintLayout(constraintSet = noDSLConstraintSet, modifier = Modifier.fillMaxSize()) {
// This block contains the children
Text(text = "A",
modifier = Modifier.layoutId(elementA)
.drawOpacity(if (doNotShow.contains(elementA)) 0f else 1f)
.padding(0.dp),
style = TextStyle(fontSize = 20.sp)
)
Text(text = "B",
modifier = Modifier.layoutId(elementB)
.drawOpacity(if (doNotShow.contains(elementB)) 0f else 1f)
.padding(0.dp),
style = TextStyle(fontSize = 20.sp)
)
Text(text = "C",
modifier = Modifier.layoutId(elementC)
.drawOpacity(if (doNotShow.contains(elementC)) 0f else 1f)
.padding(0.dp),
style = TextStyle(fontSize = 20.sp)
)
Text(text = "D",
modifier = Modifier.layoutId(elementD)
.drawOpacity(if (doNotShow.contains(elementD)) 0f else 1f)
.padding(0.dp),
style = TextStyle(fontSize = 20.sp))
}
}
}
#Preview(showBackground = true)
#Composable
fun previewFourFieldsNoDSL() {
val noShow = listOf(FourElementsNoDSL.elementC)
PlaygroundTheme {
FourElementsNoDSL.fourButtonsCLNoDSL(noShow)
}
}
The object FourElementsNoDSL defines the layout, provides element ids and so on.
This is roughly comparable to an XML file that contains such layout.
noDSL means, that this layout does not use the Compose ConstraintLayout's Kotlin
DSL. Currently the DSL does not provide a mechanism to setup element references
(used in layoutId) with defined ids as its done in this example.

Categories

Resources