Compose: 2 AppBars with different ScrollBehavior for single page - android

A "genius" designer wants a screen with 2 app-bars and different scroll behaviors. The top one should appear only when user scrolls till the begin of the screen, the second one should appear even when user scrolls back a little. I have attached a picture with illustration of 3 states I want to achieve using Jetpack-Compose.
I wanted smth like following code (here I've used androidx.compose.material3):
val topAppBarState = rememberTopAppBarState()
val scrollOnlyInTopBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState)
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState)
Scaffold(
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection)
.nestedScroll(scrollOnlyInTopBehavior.nestedScrollConnection),
topBar = {
Column {
SearchBar(...)
CenterAlignedTopAppBar(
title = { Text(text = "Slide-out only in top") },
scrollBehavior = scrollOnlyInTopBehavior,
)
CenterAlignedTopAppBar(
title = { Text(text = "Slide-out event when scrolling back a little") },
scrollBehavior = scrollBehavior,
)
}
}
) {
LazyVerticalGrid(columns = Fixed(2), ...)
}
But obviously it doesn't work because Modifier.nestedScroll of the Scaffold can have only one nestedScrollConnection. Please suggest me some workaround with Compose to implement this screen.
Update:
I was wrong, it's possible to correctly setup several `nestedScroll` modifiers for single compose container, here I just did it in wrong way.

I just found mistake in the code above. I used single TopAppBarState for different Behavior-s, that's why they act similarly, and I wasn't able to achieve desired scroll effect. The effect reaches by using different TopAppBarState for each individual Behavior. Example below should work correctly:
val scrollOnlyInTopBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
Scaffold(
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection)
.nestedScroll(scrollOnlyInTopBehavior.nestedScrollConnection),
topBar = {
Column {
SearchBar(...)
CenterAlignedTopAppBar(
title = { Text(text = "Slide-out only in top") },
scrollBehavior = scrollOnlyInTopBehavior,
)
CenterAlignedTopAppBar(
title = { Text(text = "Slide-out event when scrolling back a little") },
scrollBehavior = scrollBehavior,
)
}
}
) {
LazyVerticalGrid(columns = Fixed(2), ...)
}

Related

PullRefreshIndicator overlaps with ScrollableTabRow

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.

TabRow/Tab Recomposition Issue in Compose Accompanist Pager

I was trying to create a sample Tab View in Jetpack compose, so the structure will be like
Inside a Parent TabRow we are iterating the tab title and create Tab composable.
More precise code will be like this.
#OptIn(ExperimentalPagerApi::class)
#Composable
private fun MainApp() {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.app_name)) },
backgroundColor = MaterialTheme.colors.surface
)
},
modifier = Modifier.fillMaxSize()
) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
val tabContents = listOf(
"Home" to Icons.Filled.Home,
"Search" to Icons.Filled.Search,
"Settings" to Icons.Filled.Settings
)
HorizontalPager(
count = tabContents.size,
state = pagerState,
contentPadding = PaddingValues(horizontal = 32.dp),
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) { page ->
PagerSampleItem(
page = page
)
}
TabRow(
selectedTabIndex = pagerState.currentPage,
backgroundColor = MaterialTheme.colors.surface,
contentColor = MaterialTheme.colors.onSurface,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier
.pagerTabIndicatorOffset(pagerState, tabPositions)
.height(4.dp)
.background(
color = Color.Green,
shape = RectangleShape
)
)
}
) {
tabContents.forEachIndexed { index, pair: Pair<String, ImageVector> ->
Tab(
selected = pagerState.currentPage == index,
selectedContentColor = Color.Green,
unselectedContentColor = Color.Gray,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
text = { Text(text = pair.first) },
icon = { Icon(imageVector = pair.second, contentDescription = null) }
)
}
}
}
}
}
#Composable
internal fun PagerSampleItem(
page: Int
) {
// Displays the page index
Text(
text = page.toString(),
modifier = Modifier
.padding(16.dp)
.background(MaterialTheme.colors.surface, RoundedCornerShape(4.dp))
.sizeIn(minWidth = 40.dp, minHeight = 40.dp)
.padding(8.dp)
.wrapContentSize(Alignment.Center)
)
}
And coming to my question is whenever we click on the tab item, the inner content get recompose so weirdly. Im not able to understand why it is happens.
Am attaching an image of the recomposition counts below, please take a look that too, it would be good if you guys can help me more for understand this, also for future developers.
There are two question we have to resolve in this stage
Whether it will create any performance issue, when the view getting more complex
How to resolve this recompostion issue
Thanks alot.
… whenever we click on the tab item, the
inner content get recompose so weirdly. Im not able to understand why
it is happens...
It's hard to determine what this "weirdness" is, there could be something inside the composable your'e mentioning here.
You also didn't specify what the API is, so I copied and pasted your code and integrated accompanist view pager, then I was able to run it though not on an Android Studio with a re-composition count feature.
And since your'e only concerned about the Text and the Icon parameter of the API, I think that's something out of your control. I suspect the reason why your'e getting those number of re-composition count is because your'e animating the page switching.
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
Though 'm not able to try this on another Android Studio version with the re-composition feature, I think (though I'm not sure) scrolling to another page without animation will yield less re-composition count.
coroutineScope.launch {
pagerState.scrollToPage(index)
}
If it still bothers you, the best course of action is to ask them directly, though personally I wouldn't concerned much about this as they are part of an accepted API and its just Text and Icon being re-composed many times by an animation which is also fine IMO.
Now if you have some concerns about your PagerSampleItem stability(which you have a full control), based on the provided code and screenshot, I think your'e fine.
There's actually a feature suggested from this article to check the stability of a composable, I run it and I got this report.
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun PagerSampleItem(
stable page: Int
)
Everything about this report is within the article I linked.
Also, your Text and Icon are using String and ImageVector which is stable and immutable (marked by #Immutable) respectively.
So TLDR, IMO your code is fine, your PagerSampleItem is not re-composing in the screenshot.

Compose material3 OverScroll Shows in all 4 directions

I am using meterial3 with Compose . I found that all the Scrollable Composable Showing overscroll effect in all 4 directions regardless of scrolling direction. that goes for Column, LazyColumn, LazyVerticalGrid etc ..
I m not using anything custom to override overscroll effect. i can not figure out whats causing this behavior . i will add an example code below with LazyVerticalGrid which is causing this behavior . Since this is vertical grid it should show over scroll only vertically(to and bottom) but it also show it horizontally (left and right)..
Any direction on this will be appreciated.
#Composable
#Destination
fun FavoritesScreen(navigator: DestinationsNavigator) {
val favoritesViewModel: FavoritesViewModel = hiltViewModel()
val favoritesLiveData =
favoritesViewModel.favoritesLiveData.observeAsState(initial = null)
if (favoritesLiveData.value != null) {
if (favoritesLiveData.value!!.isEmpty()) {
// Show empty view
} else {
LazyVerticalGrid(
contentPadding = PaddingValues(
12.dp,
12.dp,
12.dp,
20.dp
),
columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item(span = { GridItemSpan(2) }) {
Spacer(modifier = Modifier.windowInsetsTopHeight(WindowInsets.statusBars))
Text(
text = stringResource(id = R.string.favorites),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 20.dp)
)
ColumnSpacer(value = 10)
}
items(favoritesLiveData.value!!) { game ->
// grid Item
}
}
}
}
}
You may need to update your Compose version. This was a known issue in Compose 1.3.0-alpha01, which was fixed subsequently.

compose LazyColumn crops the content at the bottom

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 !

How to implement BottomAppBar and BottomDrawer pattern using Android Jetpack Compose?

I'm building Android app with Jetpack Compose. Got stuck while trying to implement BottomAppBar with BottomDrawer pattern.
Bottom navigation drawers are modal drawers that are anchored to the bottom of the screen instead of the left or right edge. They are only used with bottom app bars. These drawers open upon tapping the navigation menu icon in the bottom app bar.
Description on material.io, and direct link to video.
I've tried using Scaffold, but it only supports side drawer. BottomDrawer appended to Scaffold content is displayed in content area and BottomDrawer doesn't cover BottomAppBar when open. Moving BottomDrawer after Scaffold function doesn't help either: BottomAppBar is covered by some invisible block and prevents clicking buttons.
I've also tried using BottomSheetScaffold, but it doesn't have BottomAppBar slot.
If Scaffold doesn't support this pattern, what would be correct way to implement it? Is it possible to extend Scaffold component? I fear that incorrect implementation from scratch might create issues later, when I'll try to implement navigation and snackbar.
I think the latest version of scaffold does have a bottom app bar parameter
They (Google Devs) invite you in the Jetpack Compose Layouts pathway to try adding other Material Design Components such as BottomNavigation or BottomDrawer to their respective Scaffold slots, and yet do not give you the solution.
BottomAppBar does have its own slot in Scaffold (i.e. bottomBar), but BottomDrawer does not - and seems to be designed exclusively for use with the BottomAppBar explicitly (see API documentation for the BottomDrawer).
At this point in the Jetpack Compose pathway, we've covered state hoisting, slots, modifiers, and have been thoroughly explained that from time to time we'll have to play around to see how best to stack and organize Composables - that they almost always have a naturally expressable way in which they work best that is practically intended.
Let me get us set up so that we are on the same page:
class MainActivity : ComponentActivity() {
#ExperimentalMaterialApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
LayoutsCodelabTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
LayoutsCodelab()
}
}
}
}
}
That's the main activity calling our primary/core Composable. This is just like in the codelab with the exception of the #ExperimentalMaterialApi annotation.
Next is our primary/core Composable:
#ExperimentalMaterialApi
#Composable
fun LayoutsCodelab() {
val ( gesturesEnabled, toggleGesturesEnabled ) = remember { mutableStateOf( true ) }
val scope = rememberCoroutineScope()
val drawerState = rememberBottomDrawerState( BottomDrawerValue.Closed )
// BottomDrawer has to be the true core of our layout
BottomDrawer(
gesturesEnabled = gesturesEnabled,
drawerState = drawerState,
drawerContent = {
Button(
modifier = Modifier.align( Alignment.CenterHorizontally ).padding( top = 16.dp ),
onClick = { scope.launch { drawerState.close() } },
content = { Text( "Close Drawer" ) }
)
LazyColumn {
items( 25 ) {
ListItem(
text = { Text( "Item $it" ) },
icon = {
Icon(
Icons.Default.Favorite,
contentDescription = "Localized description"
)
}
)
}
}
},
// The API describes this member as "the content of the
// rest of the UI"
content = {
// So let's place the Scaffold here
Scaffold(
topBar = {
AppBarContent()
},
//drawerContent = { BottomBar() } // <-- Will implement a side drawer
bottomBar = {
BottomBarContent(
coroutineScope = scope,
drawerState = drawerState
)
},
) {
innerPadding ->
BodyContent( Modifier.padding( innerPadding ).fillMaxHeight() )
}
}
)
}
Here, we've leveraged the Scaffold exactly as the codelab in the compose pathway suggests we should. Notice my comment that drawerContent is an auto-implementation of the side-drawer. It's a rather nifty way to bypass directly using the [respective] Composable(s) (material design's modal drawer/sheet)! However, it won't work for our BottomDrawer. I think the API is experimental for BottomDrawer, because they'll be making changes to add support for it to Composables like Scaffold in the future.
I base that on how difficult it is to use the BottomDrawer, designed for use solely with BottomAppBar, with the Scaffold - which explicitly contains a slot for BottomAppBar.
To support BottomDrawer, we have to understand that it is an underlying layout controller that wraps the entire app's UI, preventing interaction with anything but its drawerContent when the drawer is open. This requires that it encompasses Scaffold, and that requires that we delegate necessary state control - to the BottomBarContent composable which wraps our BottomAppBar implementation:
#ExperimentalMaterialApi
#Composable
fun BottomBarContent( modifier: Modifier = Modifier, coroutineScope: CoroutineScope, drawerState: BottomDrawerState ) {
BottomAppBar{
// Leading icons should typically have a high content alpha
CompositionLocalProvider( LocalContentAlpha provides ContentAlpha.high ) {
IconButton(
onClick = {
coroutineScope.launch { drawerState.open() }
}
) {
Icon( Icons.Filled.Menu, contentDescription = "Localized description" )
}
}
// The actions should be at the end of the BottomAppBar. They use the default medium
// content alpha provided by BottomAppBar
Spacer( Modifier.weight( 1f, true ) )
IconButton( onClick = { /* doSomething() */ } ) {
Icon( Icons.Filled.Favorite, contentDescription = "Localized description" )
}
IconButton( onClick = { /* doSomething() */ } ) {
Icon( Icons.Filled.Favorite, contentDescription = "Localized description" )
}
}
}
The result shows us:
The TopAppBar at top,
The BottomAppBar at bottom,
Clicking the menu icon in the BottomAppBar opens our BottomDrawer, covering the BottomAppBar and entire content space appropriately while open.
The BottomDrawer is properly hidden, until either the above referenced button click - or gesture - is utilized to open the bottom drawer.
The menu icon in the BottomAppBar opens the drawer partway.
Gesture opens the bottom drawer partway with a quick short swipe, but as far as you guide it to otherwise.
You may have to do something as shown below..
Notice how the Scaffold is called inside the BottomDrawer().
It's confusing though how the documentation says "They (BottomDrawer) are only used with bottom app bars". It made me think I have to look for a BottomDrawer() slot inside Scaffold or that I have to call BottomDrawer() inside BottomAppBar(). In both cases, I experienced weird behaviours. This is how I worked around the issue. I hope it helps someone especially if you are attempting the code lab exercise in Module 5 of Layouts in Jetpack Compose from the Jetpack Compose course.
#ExperimentalMaterialApi
#Composable
fun MyApp() {
var selectedItem by rememberSaveable { mutableStateOf(1)}
BottomDrawer(
modifier = Modifier.background(MaterialTheme.colors.onPrimary),
drawerShape = Shapes.medium,
drawerContent = {
Column(Modifier.fillMaxWidth()) {
for(i in 1..6) {
when (i) {
1 -> Row(modifier = Modifier.clickable { }.padding(16.dp)){
Icon(imageVector = Icons.Rounded.Inbox, contentDescription = null)
Text(text = "Inbox")
}
2 -> Row(modifier = Modifier.clickable { }.padding(16.dp)){
Icon(imageVector = Icons.Rounded.Outbox, contentDescription = null)
Text(text = "Outbox")
}
3 -> Row(modifier = Modifier.clickable { }.padding(16.dp)){
Icon(imageVector = Icons.Rounded.Archive, contentDescription = null)
Text(text = "Archive")
}
}
}
}
},
gesturesEnabled = true
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = "Learning Compose Layouts" )
},
actions = {
IconButton(onClick = { /*TODO*/ }) {
Icon(Icons.Filled.Favorite, contentDescription = null)
}
}
)
},
bottomBar = { BottomAppBar(cutoutShape = CircleShape, contentPadding = PaddingValues(0.dp)) {
for (item in 1..4) {
BottomNavigationItem(
modifier = Modifier.clipToBounds(),
selected = selectedItem == item ,
onClick = { selectedItem = item },
icon = {
when (item) {
1 -> { Icon(Icons.Rounded.MusicNote, contentDescription = null) }
2 -> { Icon(Icons.Rounded.BookmarkAdd, contentDescription = null) }
3 -> { Icon(Icons.Rounded.SportsBasketball, contentDescription = null) }
4 -> { Icon(Icons.Rounded.ShoppingCart, contentDescription = null) }
}
}
)
}
}
}
) { innerPadding -> BodyContent(
Modifier
.padding(innerPadding)
.padding(8.dp))
}
}
}

Categories

Resources