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.
I have a TextField in column with verticalScroll().
When adding a large number of characters, the textfield size goes beyond the keyboard and I stop seeing what I am typing
I tried to use this lib, but that's doesn't help
I think you can use BringIntoViewRequester in your TextField.
var state by rememberSaveable {
mutableStateOf("")
}
val coroutineScope = rememberCoroutineScope()
val bringIntoViewRequester = remember {
BringIntoViewRequester()
}
TextField(
value = state,
onValueChange = { text ->
state = text
// This will cause the TextField be repositioned on the screen
// while you're typing
coroutineScope.launch {
bringIntoViewRequester.bringIntoView()
}
},
modifier = Modifier
.bringIntoViewRequester(bringIntoViewRequester)
.onFocusChanged {
if (it.isFocused) {
coroutineScope.launch {
delay(400) // delay to way the keyboard shows up
bringIntoViewRequester.bringIntoView()
}
}
},
)
See the complete sample here.
you can add android:ellipsize="end" and android:maxLines="1" or whatever lines you want, in your text xml hope it would be helpful.
I have empty fragment with composable:
setContent {
Surface(
modifier = Modifier
.fillMaxWidth().fillMaxHeight().padding(bottom = 48.dp, top = 16.dp),
color = colorResource(id = R.color.usaa_white)
) {
val itemsList = (0..50).toList()
val itemsIndexedList = listOf("A", "B", "C")
LazyColumn(
) {
items(itemsList.size) {
Text("Item is $it")
}
item {
Text("Single item")
}
itemsIndexed(itemsIndexedList) { index, item ->
Text("Item at index $index is $item")
}
}
}
}
the problem is: I can only scroll the content until "Single Item" row and the rest of content is hidden. I added some padding to make sure that it wasn't bottomNavBar covering the list but it's still cropped.
Looks like the issue is caused by bottomNavBar. What's interesting is that it happens only with LazyColumn and works fine when I use Column
The fix I found is to add contentPadding to the bottom. (But hope to find better solution)
LazyColumn(contentPadding = PaddingValues(bottom = 70.dp)) { }
Use Scaffold (check documentation).
Scaffold has a generic content trailing lambda slot. The lambda receives an instance of PaddingValues that should be applied to the content root — for example, via Modifier.padding — to offset the top and bottom bars, if they exist.
setContent {
Scaffold { contentPadding ->
Box(
modifier = Modifier.padding(contentPadding)
) {
// Your code
}
}
}
Hope it helps !
I was playing around with Jetpack Compose TextField and I found one strange behaviour with Keyboard.
If my TextField is around bottom of the screen and I open keyboard, the TextField is remain hidden behind the keyboard.
I tried some solutions as well.
Modifying android:windowSoftInputMode="adjustPan" and android:windowSoftInputMode="adjustResize"
If I use adjustPan, sometimes TextField is lifted up with the Keyboard but sometimes it does not.
Here is the code and images of what is happening.
Ok I did something that worked for me, I clarify that I have only been learning compose for a few weeks so don't expect much from me, I simply did something that may not be the right way but it is functional for me and may be useful to anyone.
What I did was create a main class that inherits the ComponentActivity() with all the toys.
class Login : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
HealthAtHansClientTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
LoginLayout()
}
}
}
}
}
After that I imported my #Composable function which contained my layout with the LoginLayout, but the important part is this:
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(bottom = 100.dp, end = 40.dp, start = 40.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.SpaceAround,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 30.dp)
) {
Text(text = "Hola !", style = TextStyle(fontSize = 40.sp))
}
Row(horizontalArrangement = Arrangement.Center) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
TextField(
maxLines = 1,
modifier = Modifier.fillMaxWidth(),
value = documentNumber,
onValueChange = { documentNumber = it })
Spacer(modifier = Modifier.height(100.dp))
TextField(
maxLines = 1,
modifier = Modifier.fillMaxWidth(),
value = privateCode,
onValueChange = { privateCode = it })
}
}
}
Note that the space between the Textfields is 100 this was just to test that it is functional.
and finally declare the Login class in my main activity in the following way, since the Mainactivity came by default but it was only for this test, the idea is only that they realize the line that I added
And voilĂ that was all now my TextField is not hidden, I think it would be very feasible if you have extensive forms you create your "activity" in the manifest and add the line android:windowSoftInputMode="adjustResize"
that way the problem is over.
I hope someone with more skill and knowledge can do something more efficient, for now it works for me.
If there is already a more efficient solution for that, the comment is appreciated, if my solution is stupid, I would appreciate feedback to learn.
One other thing that happens is that if you run the emulator with 'DefaultPreview', none of the ways I try to do it work.
If you run the emulation of your application in App, it works without problem.
But I wanted to go further, so I compiled this example in a release apk version where it is supposed to look like it should and it works perfectly as you can see in the image.
Update at October 2022: The following code does the trick:
class SampleActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
WindowCompat.setDecorFitsSystemWindows(window, false)
super.onCreate(savedInstanceState)
setContent {
LazyColumn(
contentPadding = WindowInsets.navigationBars.asPaddingValues()
) {
// items
}
}
}
}
Setting Activity to fit system windows and also using contentPadding is key here.
In Jetpack compose 1.0.0-beta01, I am calling the BottomSheetScaffold like this:
BottomSheetScaffold(
scaffoldState = bottomSheetScaffoldState,
sheetContent = { Text("") },
sheetShape = Shapes.large,
backgroundColor = AppTheme.colors.uiBackground,
modifier = modifier
) { (content) }
... and getting the following error:
java.lang.IllegalArgumentException: The initial value must have an associated anchor.
Any tips on fixing this?
Don't forget to add the following atribute:
sheetPeekHeight = 0.dp
So your code should be like this:
BottomSheetScaffold(
scaffoldState = bottomSheetScaffoldState,
sheetContent = { Text("") },
sheetShape = Shapes.large,
sheetPeekHeight = 0.dp, // <--- new line
backgroundColor = AppTheme.colors.uiBackground,
modifier = modifier
) { (content) }
When bottomSheetState is expand, sheetContent must have real content to show.
You need check this.
I got the same issue when using ModalBottomSheetLayout, and my compose material version is 1.2.0-rc02
androidx.compose.material:material:1.2.0-rc02
I want to show the bottom modal when one item is selected, and if no item is selected, the modal should be empty.
ModalBottomSheetLayout(
sheetState = modalBottomSheetState,
sheetContent = {
EditProgressContent(book = book)
}
) { ... }
#Composable
fun EditProgressContent(book: Book?) {
if (book == null) return
Text(book.title)
}
When book is null, I got the same crash. There is no peekHeight parameter for ModalBottomSheetLayout, so I have to add a pixel when book is null
#Composable
fun EditProgressContent(book: Book?) {
if (book == null) {
Box(modifier = Modifier.size(1.dp)
return
}
Text(book.title)
}
The code looks silly, hope it can be fixed from Google.
In case you end up on this page because you use the BackdropScaffold, the suggested solution does the trick as well. Simply set the peekHeight.
For example like this:
BackdropScaffold(
appBar = {},
backLayerContent = {},
frontLayerContent = {},
peekHeight = 0.dp
)
Then Preview works like a charm again.
Fun fact though: Don't set it to 56.dp which is the default init value it normally should be initialised with (value of BackdropScaffoldDefaults.PeekHeight). 56.dp results in the anchor rendering problem in my current setup.
(Using compose version '1.1.1')
On new version, this error happens when the initial State is Expanded, where try to open the Modal before launched. Try launcher via ModalBottomSheetState from LaunchedEffect (maybe can need a delay)