When make an xml endless list need create RecyclerView and add RecyclerViewOnScrollListener. How to do it in Jetpack Compose?
You can use androidx.ui.foundation.AdapterList for this.
It will only composes and lays out the currently visible items.
#Composable
fun CustomerListView(list: List<Customer>) {
AdapterList(data = list) { customer->
Text("name:${customer.name}")
}
}
You could use LazyColumnFor like explained here:
#Composable
fun LazyColumnForDemo() {
LazyColumnFor(items = listOf(
"A", "B", "C", "D"
) + ((0..100).map { it.toString() }),
modifier = Modifier,
itemContent = { item ->
Log.d("COMPOSE", "This get rendered $item")
when (item) {
"A" -> {
Text(text = item, style = TextStyle(fontSize = 80.sp))
}
"B" -> {
Button(onClick = {}) {
Text(text = item, style = TextStyle(fontSize = 80.sp))
}
}
"C" -> {
//Do Nothing
}
"D" -> {
Text(text = item)
}
else -> {
Text(text = item, style = TextStyle(fontSize = 80.sp))
}
}
})
}
Explained by google here: https://youtu.be/SMOhl9RK0BA 23:18
Similar to endless list in RecyclerView with some tunes:
#Parcelize
data class PagingController(
var loading: Boolean = false,
var itemsFromEndToLoad: Int = 5,
var lastLoadedItemsCount: Int = 0,
) : Parcelable {
fun reset() {
loading = false
itemsFromEndToLoad = 5
lastLoadedItemsCount = 0
}
}
#Composable
fun LazyGridState.endlessOnScrollListener(
pagingController: PagingController,
itemsCount: Int, // provide real collection size to not have collisions if list contains another view types
loadMore: () -> Unit
) {
if (!isScrollInProgress) return
val lastVisiblePosition = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
pagingController.run {
if (loading) {
if (itemsCount > lastLoadedItemsCount) {
loading = false
Timber.v("loaded, lastVisiblePosition: $lastVisiblePosition, itemsCount: $itemsCount")
lastLoadedItemsCount = itemsCount
}
} else {
if (itemsCount < lastLoadedItemsCount) {
Timber.v("list reset")
reset()
}
val needToLoad = lastVisiblePosition + itemsFromEndToLoad >= itemsCount
if (needToLoad) {
Timber.v("loading, lastVisiblePosition: $lastVisiblePosition, itemsCount: $itemsCount")
loading = true
loadMore()
}
}
}
}
val gridState = rememberLazyGridState()
LazyVerticalGrid(
...
state = gridState,
) { ...
}
val pagingController by rememberSaveable { mutableStateOf(PagingController()) }
gridState.endlessOnScrollListener(pagingController, dataList.size) {
... // loadNextPage
}
Related
In order to practice Jetpack Compose I wanted to create a MultiComboBox component for later use. It's basically standard ComboBox that allows to pick multiple options. Something like below:
I did prepare a piece of code that IMO should work fine and generally it does, but there's one case when it doesn't and I cannot figure it out what's wrong.
Here's my code:
data class ComboOption(
override val text: String,
val id: Int,
) : SelectableOption
interface SelectableOption {
val text: String
}
#Composable
fun MultiComboBox(
labelText: String,
options: List<ComboOption>,
onOptionsChosen: (Set<ComboOption>) -> Unit,
modifier: Modifier = Modifier,
selectedIds: Set<Int> = emptySet(),
) {
var expanded by remember { mutableStateOf(false) }
// when no options available, I want ComboBox to be disabled
val isEnabled by rememberUpdatedState { options.isNotEmpty() }
var currentlySelected by remember(options, selectedIds) {
mutableStateOf(options.filter { it.id in selectedIds }.toSet())
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
if (isEnabled()) {
expanded = !expanded
if (!expanded) {
onOptionsChosen(currentlySelected)
}
}
},
modifier = modifier,
) {
val selectedSummary = when (selectedIds.size) {
0 -> ""
1 -> options.first { it.id == selectedIds.first() }.text
else -> "Wybrano ${selectedIds.size}"
}
TextField(
enabled = isEnabled(),
modifier = Modifier.menuAnchor(),
readOnly = true,
value = selectedSummary,
onValueChange = {},
label = { Text(text = labelText) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
colors = ExposedDropdownMenuDefaults.textFieldColors(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = {
expanded = false
onOptionsChosen(currentlySelected)
},
) {
for (option in options) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = option in currentlySelected,
onCheckedChange = { newCheckedState ->
if (newCheckedState) {
currentlySelected += option
} else {
currentlySelected -= option
}
},
)
Text(text = option.text)
}
},
onClick = {
val isChecked = option in currentlySelected
if (isChecked) {
currentlySelected -= option
} else {
currentlySelected += option
}
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
)
}
}
}
}
When I pick options and then dismiss the combo by clicking somewhere outside of it - it works fine. The problem is with onExpandedChange. currentlySelected inside of that lambda is always the same as first value of selectedIds. So for example, when no options are preselected it always calls onOptionsChosen with empty set, hence regardless of what I select - it always sets empty value. Any ideas why it happens an how can it be fixed?
You can use:
#Composable
fun MultiComboBox(
labelText: String,
options: List<ComboOption>,
onOptionsChosen: (List<ComboOption>) -> Unit,
modifier: Modifier = Modifier,
selectedIds: List<Int> = emptyList(),
) {
var expanded by remember { mutableStateOf(false) }
// when no options available, I want ComboBox to be disabled
val isEnabled by rememberUpdatedState { options.isNotEmpty() }
var selectedOptionsList = remember { mutableStateListOf<Int>()}
//Initial setup of selected ids
selectedIds.forEach{
selectedOptionsList.add(it)
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
if (isEnabled()) {
expanded = !expanded
if (!expanded) {
onOptionsChosen(options.filter { it.id in selectedOptionsList }.toList())
}
}
},
modifier = modifier,
) {
val selectedSummary = when (selectedOptionsList.size) {
0 -> ""
1 -> options.first { it.id == selectedOptionsList.first() }.text
else -> "Wybrano ${selectedOptionsList.size}"
}
TextField(
enabled = isEnabled(),
modifier = Modifier.menuAnchor(),
readOnly = true,
value = selectedSummary,
onValueChange = {},
label = { Text(text = labelText) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
colors = ExposedDropdownMenuDefaults.textFieldColors(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = {
expanded = false
onOptionsChosen(options.filter { it.id in selectedOptionsList }.toList())
},
) {
for (option in options) {
//use derivedStateOf to evaluate if it is checked
var checked = remember {
derivedStateOf{option.id in selectedOptionsList}
}.value
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = checked,
onCheckedChange = { newCheckedState ->
if (newCheckedState) {
selectedOptionsList.add(option.id)
} else {
selectedOptionsList.remove(option.id)
}
},
)
Text(text = option.text)
}
},
onClick = {
if (!checked) {
selectedOptionsList.add(option.id)
} else {
selectedOptionsList.remove(option.id)
}
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
)
}
}
}
}
I'm currently trying to show a list of EventType (custom class) objects as DropdownMenuItems in a DropdownMenu.
The code I'm trying is:
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
items(plannerViewModel.eventTypeList) { eventType ->
DropdownMenuItem(onClick = { /*TODO*/ }) {
TypeOfEvent(eventType.color, eventType.name, openDialog)
}
}
}
The problem is the items() function is not being recognized and I don't know how else it could be done.
items is for LazyColumn. It doesn't exist on DropdownMenu.
You can just use a for loop in this case
plannerViewModel.eventTypeList.forEach { eventType ->
DropdownMenuItem(...)
}
Below is syntax of Drop Down menu.There is no scope to add items in syntax.
#Composable
fun DropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(0.dp, 0.dp),
properties: PopupProperties = PopupProperties(focusable = true),
content: #Composable #ExtensionFunctionType ColumnScope.() -> Unit
): Unit
If you want to add multiple items than you can use for loop like below.
#Preview
#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)
}
}
}
}
}
I am trying to do pagination in my application. First, I'm fetching 20 item from Api (limit) and every time i scroll down to the bottom of the screen, it increase this number by 20 (nextPage()). However, when this function is called, the screen goes to the top, but I want it to continue where it left off. How can I do that?
Here is my code:
CharacterListScreen:
#Composable
fun CharacterListScreen(
characterListViewModel: CharacterListViewModel = hiltViewModel()
) {
val state = characterListViewModel.state.value
val limit = characterListViewModel.limit.value
Box(modifier = Modifier.fillMaxSize()) {
val listState = rememberLazyListState()
LazyColumn(modifier = Modifier.fillMaxSize(), state = listState) {
itemsIndexed(state.characters) { index, character ->
characterListViewModel.onChangeRecipeScrollPosition(index)
if ((index + 1) >= limit) {
characterListViewModel.nextPage()
}
CharacterListItem(character = character)
}
}
if (state.error.isNotBlank()) {
Text(
text = state.error,
color = MaterialTheme.colors.error,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.align(Alignment.Center)
)
}
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
CharacterListViewModel:
#HiltViewModel
class CharacterListViewModel #Inject constructor(
private val characterRepository: CharacterRepository
) : ViewModel() {
val state = mutableStateOf(CharacterListState())
val limit = mutableStateOf(20)
var recipeListScrollPosition = 0
init {
getCharacters(limit.value, Constants.HEADER)
}
private fun getCharacters(limit : Int, header : String) {
characterRepository.getCharacters(limit, header).onEach { result ->
when(result) {
is Resource.Success -> {
state.value = CharacterListState(characters = result.data ?: emptyList())
}
is Resource.Error -> {
state.value = CharacterListState(error = result.message ?: "Unexpected Error")
}
is Resource.Loading -> {
state.value = CharacterListState(isLoading = true)
}
}
}.launchIn(viewModelScope)
}
private fun incrementLimit() {
limit.value = limit.value + 20
}
fun onChangeRecipeScrollPosition(position: Int){
recipeListScrollPosition = position
}
fun nextPage() {
if((recipeListScrollPosition + 1) >= limit.value) {
incrementLimit()
characterRepository.getCharacters(limit.value, Constants.HEADER).onEach {result ->
when(result) {
is Resource.Success -> {
state.value = CharacterListState(characters = result.data ?: emptyList())
}
is Resource.Error -> {
state.value = CharacterListState(error = result.message ?: "Unexpected Error")
}
is Resource.Loading -> {
state.value = CharacterListState(isLoading = true)
}
}
}.launchIn(viewModelScope)
}
}
}
CharacterListState:
data class CharacterListState(
val isLoading : Boolean = false,
var characters : List<Character> = emptyList(),
val error : String = ""
)
I think the issue here is that you are creating CharacterListState(isLoading = true) while loading. This creates an object with empty list of elements. So compose renders an empty LazyColumn here which resets the scroll state. The easy solution for that could be state.value = state.value.copy(isLoading = true). Then, while loading, the item list can be preserved (and so is the scroll state)
Not sure if you are using the LazyListState correctly. In your viewmodel, create an instance of LazyListState:
val lazyListState: LazyListState = LazyListState()
Pass that into your composable and use it as follows:
#Composable
fun CharacterListScreen(
characterListViewModel: CharacterListViewModel = hiltViewModel()
) {
val limit = characterListViewModel.limit.value
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(modifier = Modifier.fillMaxSize(), state = characterListViewModel.lazyListState) {
itemsIndexed(state.characters) { index, character ->
}
}
}
}
I have a LazyVerticalGrid with 2 cells.
LazyVerticalGrid(
cells = GridCells.Fixed(2),
content = {
items(moviePagingItems.itemCount) { index ->
val movie = moviePagingItems[index] ?: return#items
MovieItem(movie, Modifier.preferredHeight(320.dp))
}
renderLoading(moviePagingItems.loadState)
}
)
I am trying to show full width loading with LazyGridScope's fillParentMaxSize modifier.
fun LazyGridScope.renderLoading(loadState: CombinedLoadStates) {
when {
loadState.refresh is LoadState.Loading -> {
item {
LoadingColumn("Fetching movies", Modifier.fillParentMaxSize())
}
}
loadState.append is LoadState.Loading -> {
item {
LoadingRow(title = "Fetching more movies")
}
}
}
}
But since we have 2 cells, the loading can occupy half of the screen. Like this:
Is there a way my loading view can occupy full width?
Jetpack Compose 1.1.0-beta03 version includes horizontal span support for LazyVerticalGrid.
Here is the example usage:
private const val CELL_COUNT = 2
private val span: (LazyGridItemSpanScope) -> GridItemSpan = { GridItemSpan(CELL_COUNT) }
LazyVerticalGrid(
cells = GridCells.Fixed(CELL_COUNT),
content = {
items(moviePagingItems.itemCount) { index ->
val movie = moviePagingItems.peek(index) ?: return#items
Movie(movie)
}
renderLoading(moviePagingItems.loadState)
}
}
private fun LazyGridScope.renderLoading(loadState: CombinedLoadStates) {
if (loadState.append !is LoadState.Loading) return
item(span = span) {
val title = stringResource(R.string.fetching_more_movies)
LoadingRow(title = title)
}
}
Code examples of this answer can be found at: Jetflix/MoviesGrid.kt
LazyVerticalGrid has a span strategy built into items() and itemsIndexed()
#Composable
fun SpanLazyVerticalGrid(cols: Int, itemList: List<String>) {
val lazyGridState = rememberLazyGridState()
LazyVerticalGrid(
columns = GridCells.Fixed(cols),
state = lazyGridState
) {
items(itemList, span = { item ->
val lowercase = item.lowercase()
val span = if (lowercase.startsWith("a") || lowercase.lowercase().startsWith("b") || lowercase.lowercase().startsWith("d")) {
cols
}
else {
1
}
GridItemSpan(span)
}) { item ->
Box(modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.padding(10.dp)
.background(Color.Black)
.padding(2.dp)
.background(Color.White)
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = item,
fontSize = 18.sp
)
}
}
}
}
'
val names = listOf("Alice", "Bob", "Cindy", "Doug", "Ernie", "Fred", "George", "Harris")
SpanLazyVerticalGrid(
cols = 3,
itemList = names
)
Try something like:
var cellState by remember { mutableStateOf(2) }
LazyVerticalGrid(
cells = GridCells.Fixed(cellState),
content = {
items(moviePagingItems.itemCount) { index ->
val movie = moviePagingItems[index] ?: return#items
MovieItem(movie, Modifier.preferredHeight(320.dp))
}
renderLoading(moviePagingItems.loadState) {
cellState = it
}
}
)
The renderLoading function:
fun LazyGridScope.renderLoading(loadState: CombinedLoadStates, span: (Int) -> Unit) {
when {
loadState.refresh is LoadState.Loading -> {
item {
LoadingColumn("Fetching movies", Modifier.fillParentMaxSize())
}
span(1)
}
...
else -> span(2)
}
}
I have created an issue for it: https://issuetracker.google.com/u/1/issues/176758183
Current workaround I have is to use LazyColumn and implement items or header.
override val content: #Composable () -> Unit = {
LazyColumn(
contentPadding = PaddingValues(8.dp),
content = {
items(colors.chunked(3), itemContent = {
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
val modifier = Modifier.weight(1f)
it.forEach {
ColorItem(modifier, it)
}
for (i in 1..(3 - it.size)) {
Spacer(modifier)
}
}
})
item {
Text(
text = stringResource(R.string.themed_colors),
style = MaterialTheme.typography.h3
)
}
items(themedColors.chunked(3), itemContent = {
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
val modifier = Modifier.weight(1f)
it.forEach {
ColorItem(modifier, it)
}
for (i in 1..(3 - it.size)) {
Spacer(modifier)
}
}
})
})
}
As seen in code below I have a callback where I want to collect the VerticalScroller's position, but if I do so, the VerticalScrollers widget will not scroll anymore, because in the source code to VerticalScroller, the initial callback has a call to VerticalScroller 's scrollerPosition. This value is not reachable from the callback where I want to use it. Is there a quick way to call the scrollerPosition or make the scrollbehavior continue ? (without creating a recursive "race" condition ?)
#Composable
fun StationsScreen(deviceLocation: LocationData, openDrawer: () -> Unit)
{
var scrollPosition = ScrollPosition(0.px,0.px)
FlexColumn {
inflexible {
TopAppBar(
title = {Text(text = "Stations")},
navigationIcon = {
VectorImageButton(id = R.drawable.ic_baseline_menu_24) {
openDrawer()
}
}
)
}
inflexible {
Column (
mainAxisSize = LayoutSize.Expand,
crossAxisSize = LayoutSize.Expand
){
LocationWidget(deviceLocation)
}
}
inflexible {
Column(
mainAxisSize = LayoutSize.Expand,
crossAxisSize = LayoutSize.Expand
){
PushWidget(){
deviceLocation.lat++
deviceLocation.lng++
}
}
}
inflexible{
Column(
mainAxisSize = LayoutSize.Expand,
crossAxisSize = LayoutSize.Expand
) {
ScrollPosWidget(scrollPosition = scrollPosition)
}
}
flexible(flex = 1f)
{
VerticalScroller (onScrollPositionChanged = { px: Px, px1: Px ->
scrollPosition.posX = px
scrollPosition.maxX = px1
}){
Column {
for(i in 0..20) {
HeightSpacer(16.dp)
imageBank.forEach { imageItem: ImageItem ->
Text(text = imageItem.title ?: "<Empty>")
Divider()
}
}
}
}
}
}
}
--- definition of the VerticalScroller Widget in compose source code:
#Composable
fun VerticalScroller(
scrollerPosition: ScrollerPosition = +memo { ScrollerPosition() },
onScrollPositionChanged: (position: Px, maxPosition: Px) -> Unit = { position, _ ->
scrollerPosition.value = position
},
isScrollable: Boolean = true,
#Children child: #Composable() () -> Unit
) {
Scroller(scrollerPosition, onScrollPositionChanged, true, isScrollable, child)
}
Actually I found a solution for my problem... (I was sometime taught to think out of the box...) So...
In my #Compose function I actually need to create my own scrollerPosition instance and use that in my call to VerticalScroller to change the poisition. I was afraid that this could cause a recursive "race" call situation, but that dependends on when the onScrolledPostionChanged is called in the Scroller's logic. If onScrolledPositionChanged is called when scrollerPosition.position changes value, you may get the recursive "race" condition. But apparently not.
So my changes are basicly like this:
#Composable
fun StationsScreen(deviceLocation: LocationData, openDrawer: () -> Unit)
{
var scrollPosition = ScrollPosition(0.px,0.px)
var myScrollerPosition = +memo{ ScrollerPosition ()} //<--- make my own scrollerPosition
FlexColumn {
inflexible {
TopAppBar(
title = {Text(text = "Stations")},
navigationIcon = {
VectorImageButton(id = R.drawable.ic_baseline_menu_24) {
openDrawer()
}
}
)
}
inflexible {
Column (
mainAxisSize = LayoutSize.Expand,
crossAxisSize = LayoutSize.Expand
){
LocationWidget(deviceLocation)
}
}
inflexible {
Column(
mainAxisSize = LayoutSize.Expand,
crossAxisSize = LayoutSize.Expand
){
PushWidget(){
deviceLocation.lat++
deviceLocation.lng++
}
}
}
inflexible{
Column(
mainAxisSize = LayoutSize.Expand,
crossAxisSize = LayoutSize.Expand
) {
ScrollPosWidget(scrollPosition = scrollPosition)
}
}
flexible(flex = 1f)
{
VerticalScroller (
scrollerPosition = myScrollerPosition,
onScrollPositionChanged = { px: Px, px1: Px ->
scrollPosition.posX = px
scrollPosition.maxX = px1
myScrollerPosition.value = px //<-- remember to set the new value here
// You can now use the difference of maxX and posX to load
// new items into the list...
}){
Column {
for(i in 0..20) {
HeightSpacer(16.dp)
imageBank.forEach { imageItem: ImageItem ->
Text(text = imageItem.title ?: "<Empty>")
Divider()
}
}
}
}
}
}
}
#Composable
fun LocationWidget(locationData: LocationData){
Surface {
Padding(padding = 8.dp) {
Text(text = "${locationData.lat}, ${locationData.lng}")
}
}
}
#Composable
fun PushWidget(action: () -> Unit){
Surface {
Padding(padding = 8.dp) {
Button(text = "Click Me!", onClick = action)
}
}
}
#Composable
fun ScrollPosWidget(scrollPosition: ScrollPosition){
Surface {
Padding(padding = 8.dp) {
Text(text = "posX=${scrollPosition.posX}, maxX=${scrollPosition.maxX}")
}
}
}
RG