I'm currently trying to recreate a behavior, upon adding a new element to a LazyColumn the items start shifting to the right, in order to represent a Tree and make the elements easier to read.
The mockup in question:
Documentation
Reading through the documentation of Jetpack Compose in Lists and grids I found the following.
Keep in mind that cases where you’re nesting different direction layouts, for example, a scrollable parent Row and a child LazyColumn, are allowed:
Row(
modifier = Modifier.horizontalScroll(scrollState)
) {
LazyColumn {
// ...
}
}
My implementation
Box(Modifier.padding(start = 10.dp)) {
Row(
modifier = Modifier
.horizontalScroll(scrollState)
.border(border = BorderStroke(1.dp, Color.Black))
) {
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
for (i in 0..25) {
item {
OptionItem(Modifier.padding(start = (i*20).dp))
}
item {
TaskItem(Modifier.padding(start = (i*10).dp))
}
}
}
}
.
.
.
}
OptionItem represents the element with the dot at the beginning, and TaskItem the other one.
When testing the LazyColumn, it appears as if instead of having a fixed size, the size of the column starts growing just after the elements have gone outside the screen, this causes a strange effect.
As you can see in the GIF, the width of the column starts increasing after the elements no longer fit in the screen.
The Question
I want to prevent this effect from happening, so is there any way I could maintain the width of the column to the maximum all the time?
The reason that applying a simple fillMaxWidth will not work because you are telling a composable to stretch to max, but that is impossible because the view itself can stretch indefinitely since it can be horizontally scrollable. I'm not sure why do you want to prevent this behavior but perhaps maybe you want your views to have some initial width then apply the padding, while maintaining the same width. what you can do in such case is simply give your composables a specific width, or what you can do is to get the width of the box and apply them to your composables by width (i used a text in this case)
val localDensity = LocalDensity.current
var lazyRowWidthDp by remember { mutableStateOf(0.dp) }
Box(
Modifier
.padding(start = 10.dp)
.onGloballyPositioned { layoutCoordinates -> // This function will get called once the layout has been positioned
lazyRowWidthDp =
with(localDensity) { layoutCoordinates.size.width.toDp() } // with Density is required to convert to correct Dp
}
) {
val scrollState = rememberScrollState()
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(scrollState)
) {
items(25) { i ->
Text(
text = "Hello",
modifier = Modifier
.padding(start = (i * 20).dp)
.width(lazyRowWidthDp)
.border(1.dp, Color.Green)
)
}
items(25) { i ->
Text(
text = "World",
modifier = Modifier
.padding(start = (i * 10).dp)
.width(lazyRowWidthDp)
.border(1.dp, Color.Green)
)
}
}
}
Edit:
you can apply horizontal scroll to the lazy column itself and it will scroll in both directions
I'm starting to learn about Jetpack Compose. I put together this app where I explore different day-to-day use cases, each of the feature modules within this project is supposed to tackle different scenarios.
One of this feature modules – the chatexample feature module, tries to implement a simple ViewPager where each of the pages is a Fragment, the first page "Messages" is supposed to display a paginated RecyclerView wrapped around a SwipeRefreshLayout. Now, the goal is to implement all this using Jetpack Compose. This is the issue I'm having right now:
The PullRefreshIndicator that I'm using to implement the Pull-To-Refresh action works as expected and everything seems pretty straightforward so far, but I cannot figure out why the ProgresBar stays there on top.
So far I've tried; Carrying on the Modifier from the parent Scaffold all the way through. Making sure I explicitly set the sizes to fit the max height and width. Add an empty Box in the when statement - but nothing has worked so far, I'm guessing I could just remove the PullRefreshIndicator if I see that the ViewModel isn't supposed to be refreshing, but I don't think that's the right thing to do.
To quickly explain the Composables that I'm using here I have:
<Surface>
<Scaffold> // Set with a topBar
<Column>
<ScrollableTabRow>
<Tab/> // Set for the first "Messages" tab
<Tab/> // Set for the second "Dashboard" tab
</ScrollableTabRow>
<HorizontalPager>
// ChatExampleScreen
<Box> // A Box set with the pullRefresh modifier
// Depending on the ChatExamleViewModel we might pull different composables here
</PullRefreshIndicator>
</Box>
// Another ChatExampleScreen for the second tab
</HorizontalPager>
</Column>
<Scaffold>
</Surface>
Honestly, I don't get how the PullRefreshIndicator that is in a completely different Composable (ChatExampleScreen) gets to overlap with the ScrollableTabRow that is outside.
Hope this makes digesting the UI a bit easier. Any tip, advice, or recommendation is appreciated. Thanks! 🙇
Edit: Just to be completely clear, what I'm trying to achieve here is to have a PullRefreshIndicator on each page. Something like this:
On each page, you pull down, see the ProgressBar appear, and when it is done, it goes away, within the same page. Not overlapping with the tabs above.
A comparatively easier solution in my case was to simply give the Box that contains my vertically scrollable Composable and my PullRefreshIndicator a zIndex of -1f:
Box(Modifier.fillMaxSize().zIndex(-1f)) {
LazyColumn(...)
PullRefreshIndicator(...)
}
And that already did the trick for me. I have a very similar setup to the OP, a Scaffold containing a ScrollableTabRow and a HorizontalPager with refreshable lists on the individual tabs.
I want to leave my first answer as I feel it will still be useful to future readers, so heres another one you might consider.
One of the Box in the tabs has a scroll modifier though, because according to the Accompanist Docs and the actual functionality.
… The content needs to be 'vertically scrollable' for SwipeRefresh()
to be able to react to swipe gestures. Layouts such as LazyColumn are
automatically vertically scrollable, but others such as Column or
LazyRow are not. In those instances, you can provide a
Modifier.verticalScroll modifier…
It's from accompanist documentation about the migration of the API but it still applies to this current one in compose framework.
The way I understand it is a scroll event should be present for the PullRefresh to get activated manually (i.e a layout/container with a vertical scroll modifier or a LazyColumn), something that will consume a drag/swipe event in the screen.
Here's the short working sample. All of these are copy-and-paste-able.
Activity:
class PullRefreshActivity: ComponentActivity() {
private val viewModel: MyViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyAppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = { TopAppBarSample() }
) {
MyScreen(
modifier = Modifier.padding(it),
viewModel = viewModel
)
}
}
}
}
}
}
Some data classes:
data class MessageItems(
val message: String = "",
val author: String = ""
)
data class DashboardBanner(
val bannerMessage: String = "",
val content: String = ""
)
ViewModel:
class MyViewModel: ViewModel() {
var isLoading by mutableStateOf(false)
private val _messageState = MutableStateFlow(mutableStateListOf<MessageItems>())
val messageState = _messageState.asStateFlow()
private val _dashboardState = MutableStateFlow(DashboardBanner())
val dashboardState = _dashboardState.asStateFlow()
fun fetchMessages() {
viewModelScope.launch {
isLoading = true
delay(2000L)
_messageState.update {
it.add(
MessageItems(
message = "Hello First Message",
author = "Author 1"
),
)
it.add(
MessageItems(
message = "Hello Second Message",
author = "Author 2"
)
)
it
}
isLoading = false
}
}
fun fetchDashboard() {
viewModelScope.launch {
isLoading = true
delay(2000L)
_dashboardState.update {
it.copy(
bannerMessage = "Hello World!!",
content = "Welcome to Pull Refresh Content!"
)
}
isLoading = false
}
}
}
Tab Screen Composables:
#Composable
fun MessageTab(
myViewModel : MyViewModel
) {
val messages by myViewModel.messageState.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
items(messages) { item ->
Column(
modifier = Modifier
.fillMaxWidth()
.border(BorderStroke(Dp.Hairline, Color.DarkGray)),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = item.message)
Text(text = item.author)
}
}
}
}
#Composable
fun DashboardTab(
myViewModel: MyViewModel
) {
val banner by myViewModel.dashboardState.collectAsState()
Box(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
contentAlignment = Alignment.Center
) {
Column {
Text(
text = banner.bannerMessage,
fontSize = 52.sp
)
Text(
text = banner.content,
fontSize = 16.sp
)
}
}
}
Finally, the composable that contains the PullRefresh and the Pager/Tab components, and all of them are direct children of a ConstraintLayout. So to achieve a PullRefresh behind the Tabs but still on top of the HorizontalPager, first I had to put the HorizontalPager as the first child, the PullRefresh as the second and the Tabs as the last one, constraining them accordingly to preserve the visual arrangement of a Tab Pager.
#OptIn(ExperimentalMaterialApi::class, ExperimentalPagerApi::class)
#Composable
fun MyScreen(
modifier : Modifier = Modifier,
viewModel: MyViewModel
) {
val refreshing = viewModel.isLoading
val pagerState = rememberPagerState()
val pullRefreshState = rememberPullRefreshState(
refreshing = refreshing,
onRefresh = {
when (pagerState.currentPage) {
0 -> {
viewModel.fetchMessages()
}
1 -> {
viewModel.fetchDashboard()
}
}
},
refreshingOffset = 100.dp // just an arbitrary offset where the refresh will animate
)
ConstraintLayout(
modifier = modifier
.fillMaxSize()
.pullRefresh(pullRefreshState)
) {
val (pager, pullRefresh, tabs) = createRefs()
HorizontalPager(
count = 2,
state = pagerState,
modifier = Modifier.constrainAs(pager) {
top.linkTo(tabs.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
height = Dimension.fillToConstraints
}
) { page ->
when (page) {
0 -> {
MessageTab(
myViewModel = viewModel
)
}
1 -> {
DashboardTab(
myViewModel = viewModel
)
}
}
}
PullRefreshIndicator(
modifier = Modifier.constrainAs(pullRefresh) {
top.linkTo(parent.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
},
refreshing = refreshing,
state = pullRefreshState,
)
ScrollableTabRow(
modifier = Modifier.constrainAs(tabs) {
top.linkTo(parent.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
},
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
modifier = Modifier.tabIndicatorOffset(
currentTabPosition = tabPositions[pagerState.currentPage],
)
)
},
) {
Tab(
selected = pagerState.currentPage == 0,
onClick = {},
text = {
Text(
text = "Messages"
)
}
)
Tab(
selected = pagerState.currentPage == 1,
onClick = {},
text = {
Text(
text = "Dashboard"
)
}
)
}
}
}
output:
<Surface>
<Scaffold>
<ConstraintLayout>
// top to ScrollableTabRow's bottom
// start, end, bottom to parent's start, end and bottom
// 0.dp (view), fillToConstraints (compose)
<HorizontalPager>
<PagerScreens/>
</HorizontalPager>
// top, start, end of parent
<PullRefreshIndicator/>
// top, start and end of parent
<ScrollableTabRow>
<Tab/> // Set for the first "Messages" tab
<Tab/> // Set for the second "Dashboard" tab
</ScrollableTabRow>
</ConstraintLayout>
<Scaffold>
</Surface>
I think there's nothing wrong with the PullRefresh api and the Compose/Accompanist Tab/Pager api being used together, it seems like the PullRefresh is just respecting the placement structure of the layout/container it is put into.
Consider this code, no tabs, no pager, just a simple set-up of widgets that is identical to your set-up
Column(
modifier = Modifier.padding(it)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.background(Color.Blue)
)
val pullRefreshState = rememberPullRefreshState(
refreshing = false,
onRefresh = { viewModel.fetchMessages() }
)
Box(
modifier = Modifier.pullRefresh(pullRefreshState)
) {
PullRefreshIndicator(
modifier = Modifier.align(Alignment.TopCenter),
refreshing = false,
state = pullRefreshState,
)
}
}
What it looks like.
The PullRefresh is placed inside a component(Box) that is placed below another component in a Column vertical placement, and since it's below another widget, its initial position will not be hidden like the image sample.
With your set-up, since I noticed that the ViewModel is being shared by the tabs and also the reason why I was confirming if you are decided with your architecture is because the only fix I can think of is moving the PullRefresh up in the sequence of the composable widgets.
First changes I made is in your ChatExampleScreen composable, which ended up like this, all PullRefresh components are removed.
#Composable
fun ChatExampleScreen(
chatexampleViewModel: ChatExampleViewModel,
modifier: Modifier = Modifier
) {
val chatexampleViewModelState by chatexampleViewModel.state.observeAsState()
Box(
modifier = modifier
.fillMaxSize()
) {
when (val result = chatexampleViewModelState) {
is ChatExampleViewModel.State.SuccessfullyLoadedMessages -> {
ChatExampleScreenSuccessfullyLoadedMessages(
chatexampleMessages = result.list,
modifier = modifier,
)
}
is ChatExampleViewModel.State.NoMessagesFetched -> {
ChatExampleScreenEmptyState(
modifier = modifier
)
}
is ChatExampleViewModel.State.NoInternetConnectivity -> {
NoInternetConnectivityScreen(
modifier = modifier
)
}
else -> {
// Agus - Do nothing???
Box(modifier = modifier.fillMaxSize())
}
}
}
}
and in your Activity I moved all the setContent{…} scope into another function named ChatTabsContent and placed everything inside it including the PullRefresh components.
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun ChatTabsContent(
modifier : Modifier = Modifier,
viewModel : ChatExampleViewModel
) {
val chatexampleViewModelIsLoadingState by viewModel.isLoading.observeAsState()
val pullRefreshState = rememberPullRefreshState(
refreshing = chatexampleViewModelIsLoadingState == true,
onRefresh = { viewModel.fetchMessages() }
)
Box(
modifier = modifier
.pullRefresh(pullRefreshState)
) {
Column(
Modifier
.fillMaxSize()
) {
val pagerState = rememberPagerState()
ScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
modifier = Modifier.tabIndicatorOffset(
currentTabPosition = tabPositions[pagerState.currentPage],
)
)
}
) {
Tab(
selected = pagerState.currentPage == 0,
onClick = { },
text = {
Text(
text = "Messages"
)
}
)
Tab(
selected = pagerState.currentPage == 1,
onClick = { },
text = {
Text(
text = "Dashboard"
)
}
)
}
HorizontalPager(
count = 2,
state = pagerState,
modifier = Modifier.fillMaxWidth(),
) { page ->
when (page) {
0 -> {
ChatExampleScreen(
chatexampleViewModel = viewModel,
modifier = Modifier.fillMaxSize()
)
}
1 -> {
ChatExampleScreen(
chatexampleViewModel = viewModel,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
PullRefreshIndicator(
modifier = Modifier.align(Alignment.TopCenter),
refreshing = chatexampleViewModelIsLoadingState == true,
state = pullRefreshState,
)
}
}
which ended up like this
setContent {
TheOneAppTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = { TopAppBarSample() }
) {
ChatTabsContent(
modifier = Modifier.padding(it),
viewModel = viewModel
)
}
}
}
}
Result:
Structural changes.
<Surface>
<Scaffold> // Set with a topBar
<Box>
<Column>
<ScrollableTabRow>
<Tab/> // Set for the first "Messages" tab
<Tab/> // Set for the second "Dashboard" tab
</ScrollableTabRow>
<HorizontalPager>
<Box/>
</HorizontalPager>
</Column>
// pull refresh is now at the most "z" index of the
// box, overlapping the content (tabs/pager)
<PullRefreshIndicator/>
</Box>
<Scaffold>
</Surface>
I haven't explored this API yet, but it looks like it should be used directly in a z-oriented layout/container parent such as Box as the last child.
I just want to share more details about the issue here and what the solution is. I appreciate a lot the solutions shared above and these were definitely key to figuring the problem out.
The bare-minimum solution here is to replace the Box with a ConstraintLayout in the ChatScreenExample composable:
Why? Because as #z.y shared above the PullRefreshIndicator needs to be contained on a "vertically scrollable" composable, and while the Box composable can be set with the vericalScroll() modifier we need to make sure we constraint the height of the content, that's why we had to change to a ConstraintLayout.
Feel free to correct me if I'm missing something.
There is yet another solution to this problem, which is using a .clipToBounds() modifier over the tab content container.
How to make the parent layout - Box wrap its content in Jetpack compose?
The current implementation below fills the entire screen, I only want the Box to wrap around its child - Switch. How do I define wrap content for the Box?
#Composable
fun TestScreen(modifier: Modifier = Modifier) {
Box(modifier = Modifier.background(Color.Yellow)){
val switchState = remember { mutableStateOf(true) }
Switch(
checked = switchState.value,
enabled= true,
onCheckedChange = { switchState.value = it }
)
}
}
What you see on screen is not about Modifier.wrapContentSize. Modifier.wrapContentSize and having no size Modifier returns same constraints. You can check out Constraints section of this answer.
Why your Box covers entire screen is probably something more sneaky you have a Surface with Modifier.fillMaxSize() as direct parent of TestScreen.
Surface is a Box with propagateMinConstraints: Boolean = true which forces minimum width and height to its direct descendent. This answer explains with examples how it works.
Your Composable is actually as this when parent is not Surface as i mentioned above.
Surface(
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(10.dp)
) {
TestScreen()
}
}
Since you haven't specified on what container/parent composable your posted composable is being called, I can only suggest using Modifier's wrapContentSize.
Box(
modifier = Modifier
.background(Color.Yellow)
.wrapContentSize() // wrap content height and width
){
val switchState = remember { mutableStateOf(true) }
Switch(
checked = switchState.value,
enabled= true,
onCheckedChange = { switchState.value = it }
)
}
In one of my composables, a Lazycolumn is nested inside a Column composable. I want to be able to scroll the entire Column along with the Lazycolumn. But, specifying the verticalScroll modifier property on Column results in the following exception causing the app to crash. How can I fix this?
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()).
Composable
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = 100.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
) {
items(
items = allItems!!,
key = { item ->
item.id
}
) { item ->
ShoppingListScreenItem(
navController = navController,
item = item,
sharedViewModel = sharedViewModel
) { isChecked ->
scope.launch {
shoppingListScreenViewModel.changeItemChecked(item!!, isChecked)
}
}
}
}
...
Button(
modifier = Modifier.padding(vertical = 24.dp),
onClick = {
navController.navigate(NavScreens.AddItemScreen.route) {
popUpTo(NavScreens.AddItemScreen.route) {
inclusive = true
}
}
}
) {
Text("Go to add item screen")
}
}
This happens when you wish to measure your LazyColumn with Constraints with Constraints.Infinity for the maxHeight which is not permitted as described in error log. There should be a fixed height or you shouldn't have another Scrollable with same orientation.
Column(
modifier = Modifier
// This is the cause
.verticalScroll(rememberScrollState())
) {
LazyColumn(
// and not having a Modifier that could return non-infinite max height contraint
modifier = Modifier
.fillMaxWidth()
) {
}
If you don't know exact height you can assign Modifier.weight(1f) to LazyColumn.
Contraints
This section is extra about `Constraints` and measurement you can skip this part if you are not interested in how it works.
What i mean by measuring with with Constraints.Infinity is when you create a Layout in Compose you use
Layout(modifier=modifier, content=content){
measurables: List<Measurable>, constraints: Constraints ->
}
You get child Composables as List<Measurable> which you can measure with Constraints provided by parent or the one you see fit by updating existing one with Constraints.copy or fixed one when you build a custom Composable with Layout.
val placeable = measurable.measure(constraints)
Constraints min/max width/height changes based on size modifier or scroll. When there is a scroll and you don't use any size modifier, Constraints return
minHeight =0, maxHeight= Int.MAX_VALUE as Constraints.Infinity
Modifier.fillMaxWidth()
Modifier.fillMaxWidth().weight(1f)
Easiest way to check Constraints with different Modifiers or with scroll is getting it from BoxWithConstraints
I am trying to make a short content center-aligned on the screen inside a Scaffold content. Using fillMaxSize() on the scaffold contents seems like having no impact at all. How can I make this content full size?
Scaffold(
topBar = { },
floatingActionButtonPosition = FabPosition.End,
floatingActionButton = { },
bottomBar = { }
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(paddingValues)
) {
NavHost(
navController = navController,
startDestination = Screen.Running.route
) {
composable(Screen.Business.route) {
BusinessScreen(onSetAppTitle = { appTitle = it })
}
...
}
#Composable
fun BusinessScreen(onSetAppTitle: (String) -> Unit) {
LaunchedEffect(Unit) {
onSetAppTitle("Business Dashboard")
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.background(Color.White)
.padding(16.dp)
) {
Text("Business Dashboard")
}
}
verticalScroll wraps content, and can be stretched to very long. That's why .fillMaxHeight (which is a part of fillMaxSize) doesn't work inside verticalScroll: it's ambiguous.
You need to set height explicitly. This is the case when you can pass it from onSizeChanged modifier added on the top Column.
But I believe that your composition is generally not really good: applying verticalScroll to a view on top of the layout tree is not the best solution.
Try adding verticalScroll inside each route only to those views that are really bigger than the screen.