How to prevent Jetpack Compose ExposedDropdownMenuBox from showing menu when scrolling - android

I'm trying to use Jetpack Compose's ExposedDropdownMenuBox but I can't prevent it from showing the menu when scrolling.
For example, here's the code to replicate this problem:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
Surface(color = MaterialTheme.colors.background) {
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
repeat(20){
ExposedDropdownMenuSample()
}
}
}
}
}
}
}
ExposedDropdownMenuSample was taken from the official samples.
This is a GIF showing the problem.
How can I prevent this from happening?
This code is using compose version 1.1.0-rc01.

edit: now it doesn't swallow fling-motion as reported by m.reiter šŸ˜
I was able to fix this with this ugly hack:
private fun Modifier.cancelOnExpandedChangeIfScrolling(cancelNext: () -> Unit) = pointerInput(Unit) {
forEachGesture {
coroutineScope {
awaitPointerEventScope {
var event: PointerEvent
var startPosition = Offset.Unspecified
var cancel = false
do {
event = awaitPointerEvent(PointerEventPass.Initial)
if (event.changes.any()) {
if (startPosition == Offset.Unspecified) {
startPosition = event.changes.first().position
}
val distance =
startPosition.minus(event.changes.last().position).getDistance()
cancel = distance > 10f || cancel
}
} while (!event.changes.all { it.changedToUp() })
if (cancel) {
cancelNext.invoke()
}
}
}
}
}
then add it to the ExposedDropdownMenuBox like:
var cancelNextExpandedChange by remember { mutableStateOf(false) } //this is to prevent fling motion from being swallowed
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
if (!cancelNextExpandedChange) expanded = !expanded
cancelNextExpandedChange = false
}, modifier = Modifier.cancelOnExpandedChangeIfScrolling() { cancelNextExpandedChange = true }
)
So it basically checks if there was a drag for more than 10 pixels? and if true, invokes the callback that sets cancelNextExpandedChange to true so it will skip the next onExpandedChange.
10 is just a magic number that worked well for my tests, but it seems to be too low for a high res screen device. I'm sure there's a better way to calculate this number... Maybe someone more experienced can help with this until we have a proper fix?

I found a slightly less hacky workaround:
You get the info whether a scrolling is in progress from the scroll-state of the Column.
val scrollState = rememberScrollState()
val isScrolling = scrollState.isScrollInProgress
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.verticalScroll(scrollState),
...
) ...
In the ExposedDropdownMenuBox you can then change the listener to
onExpandedChange = {
expanded = !expanded && !isScrolling
},
=> The dropdown is never opened while scrolling. It is also automatically closed as soon as you start scrolling in the main-column. However scrolling inside the dropdown is possible.
Of course you can also cahnge it to something like
expanded = if (isScrolling) expanded else !expanded
To just leave everything like it is while scrolling

This issue was reported in:
https://issuetracker.google.com/issues/212091796
and fixed in androidx.compose.material:material:1.4.0-alpha02

Related

Scroll to bottom listener Jetpack Compose

I have a page that I want to be scrollable (mostly text only, no list). At the bottom of that page I have a disabled button, but when I reach the bottom of the page I want that button to activate. How can I do this with JetpackCompose Kotlin androidx.compose.ui version 1.3.2?
Much appreciation in advance!
I can't use ScrollableColumn, Scrollable, LazyColumn, LazyRow because of the compose library.
It's not clear why you can't use these components since they are all part of the Compose library. However, this can be done using Column composable with verticalScroll modifier.
val scrollState = rememberScrollState()
Column(Modifier.verticalScroll(scrollState)) {
//Text
}
State for enabling Button, false by default:
val isButtonEnabled by remember {mutableStateOf(false)}
ScrollState has canScrollForward Boolean property, false means we cannot scroll forward (we are the end of the list). Once we have false here, button should be enabled
LaunchedEffect(scrollState.canScrollForward) {
if (!scrollState.canScrollForward) isButtonEnabled = true
}
And button:
Button(enabled = isButtonEnabled, ...)
#Composable
override fun Build() {
val scrollState = rememberScrollState()
val isButtonEnabled by remember {mutableStateOf(false)}
LaunchedEffect(scrollState.canScrollForward) {
if (!scrollState.canScrollForward) isButtonEnabled = true
}
ScreenScaffold(
header = {
TopBar(
title = R.string.insurance_choose_insurance_travel,
onBack = { sendNavEffect.invoke(Effect.Navigation.OnBack) },
actions = {},
backgroundColor = white
)
},
page = {
Column(
Modifier
.fillMaxSize()
.padding(20.dp)
.verticalScroll(scrollState)
){
repeat(100){
Text(
"Text",
color = Color.Black
)
}
}
},
footer = {
Row(modifier = Modifier.padding(20.dp)) {
PrimaryActionButton(
text = R.string.accept_terms_and_conditions_sca.getString(),
buttonState = if(isButtonEnabled) ButtonState.Active else ButtonState.Disabled,
onClick = {}
)
}
})
}
}

Jetpack Compose focus jumps erratically using D-Pad navigation on Android TV

I have been experiencing very erratic jumping when using Jetpack Composeā€™s LazyColumns on Android TV.
D-Pad navigation is supposed to be supported in Compose for a while now, but it seems simple cases are not supportedā€”or I am doing something terribly wrong when setting a custom focus overlay.
The follow code results in what is shown on this video. As you can see, I am simply navigating step by step from top to bottom but the focused item jumps very randomly in between. It feels like the number are reproducible, but I have not stopped to write them down to verify.
#Composable
fun Greeting(listItems: List<Int>) {
var currentItem by remember { mutableStateOf("None") }
val scrollState = rememberLazyListState()
val scope = rememberCoroutineScope()
Row {
Text(
text = "Current Focus = $currentItem",
modifier = Modifier.weight(1f)
)
Column(Modifier.weight(1f)) {
Text(text = "With focus changed")
LazyColumn(state = scrollState) {
itemsIndexed(listItems) { index, item ->
Item(
item,
{ currentItem = "Left $item" },
Modifier.onFocusChanged { focusState ->
scope.launch {
if (focusState.isFocused) {
val visibleItemsInfo = scrollState.layoutInfo.visibleItemsInfo
val visibleSet = visibleItemsInfo.map { it.index }.toSet()
if (index == visibleItemsInfo.last().index) {
scrollState.scrollToItem(index)
} else if (visibleSet.contains(index) && index != 0) {
scrollState.scrollToItem(index - 1)
}
}
}
}
)
}
}
}
Column(Modifier.weight(1f)) {
Text(text = "Without focus changed")
LazyColumn {
items(listItems) { item ->
Item(
item,
{ currentItem = "Right $item" }
)
}
}
}
}
}
#Composable
fun Item(
item: Int,
onFocused: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
) {
val focused by interactionSource.collectIsFocusedAsState()
Text("$item", modifier = modifier
.onFocusChanged { state ->
if (state.isFocused) {
onFocused()
}
}
.focusable(true, interactionSource)
.padding(8.dp)
.border(if (focused) 4.dp else 0.dp, MaterialTheme.colors.primary)
.padding(8.dp)
)
}
At first I thought I was doing something incorrectly and it is recomposing but different ways of checking the focus as well as just using plain buttons which already have a focus state (a very bad one for TV tbf) results in the exact same issue.
After reporting this to Google, it turns out that it actually was a bug in Jetpack Compose, which was fixed in the latest version 1.3.0-rc01.

Scroll all screen with a lazy column and paging library inside

I'm new to Jetpack Compose and I'm not quite sure how to do what I need. In the screen below, I want to scroll the whole screen and not just the list at the bottom and when the scroll reaches the end of the list below, it still applies the paging library and goes to get more elements. I managed to get the Paging Library to work and the scroll in the list below too, but I can't make the rest of the page elements scroll as well - this is because only the list has scroll and not the rest of the page. Whenever I'm trying to do that, I get the following crash:
Vertically scrollable component was measured with an infinity maximum height constraints, which is disallowed. One of the common reasons is nesting layouts like LazyColumn and Column(Modifier.verticalScroll()). If you want to add a header before the list of items please add a header as a separate item() before the main items() inside the LazyColumn scope. There are could be other reasons for this to happen: your ComposeView was added into a LinearLayout with some weight, you applied Modifier.wrapContentSize(unbounded = true) or wrote a custom layout. Please try to remove the source of infinite constraints in the hierarchy above the scrolling container.
and I don't really know why.
I leave you the code below and two screenshots: the first is the current state, where I can only scroll through the list. The second is what I intend, which is to scroll the entire page.
#Edit: I was able to implement all screen scroll with fixed height on the children lazy column, but that is not what I want.
#Composable
#ExperimentalFoundationApi
private fun MainActivityLayout(navController: NavHostController) {
LazyColumn(
modifier = Modifier
.paint(
painter = painterResource(id = R.drawable.main_background),
contentScale = ContentScale.FillBounds
)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
HeightSpacer(Dimen40)
Image(
painter = painterResource(id = R.drawable.ic_clearjobs_logo_2x),
contentDescription = null
)
HeightSpacer(Dimen47)
Navigation(navController = navController)
}
}
}
#Composable
#ExperimentalFoundationApi
fun JobOpeningsScreen(viewModel: JobOpeningsViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
Column {
ClearJobsScreenTitle(
lightTitle = stringResource(id = R.string.job_openings_light_title),
boldTitle = stringResource(id = R.string.job_openings_bold_title)
)
HeightSpacer(Dimen60)
Row {
CategoryButton()
WidthSpacer(Dimen2)
OrderByButton()
}
HeightSpacer(Dimen30)
SearchTextField()
HeightSpacer(Dimen60)
when (uiState) {
is BaseViewState.Data -> JobOpeningsContent(
viewState = uiState.cast<BaseViewState.Data<JobOpeningsViewState>>().value
)
is BaseViewState.Loading -> {
LoadingView()
}
else -> {}
}
LaunchedEffect(key1 = viewModel, block = {
viewModel.onTriggerEvent(JobOpeningsEvent.LoadJobOffers)
})
}
}
#Composable
fun JobOpeningsContent(viewState: JobOpeningsViewState) {
val pagingItems = rememberFlowWithLifecycle(viewState.pagedData).collectAsLazyPagingItems()
SwipeRefresh(
state = rememberSwipeRefreshState(
isRefreshing = pagingItems.loadState.refresh == LoadState.Loading
),
onRefresh = { pagingItems.refresh() },
indicator = { state, trigger ->
SwipeRefreshIndicator(
state = state,
refreshTriggerDistance = trigger,
scale = true
)
},
content = {
LazyColumn(
modifier = Modifier.width(Dimen320),
verticalArrangement = Arrangement.spacedBy(Dimen30)
) {
items(pagingItems.itemCount) { index ->
pagingItems[index]?.let {
JobOpeningsRow(dto = it)
}
}
if (pagingItems.loadState.append == LoadState.Loading) {
item {
Box(
Modifier
.padding(24.dp)
) {
CircularProgressIndicator(Modifier.align(Alignment.Center))
}
}
}
}
}
)
}
I found the solution to this problem, although it is not 100% and in terms of code it is not as good as I would like.
The error speaks for itself, we can't have infinite vertical scroll, Jetpack Compose doesn't allow it. I had the option of putting a fixed height on the Lazy Column of my list, but it wasn't what I wanted and it didn't work properly. The solution was to put everything inside a single LazyColumn and remove the Column from MainActivity, using a Box element and contentAlignment. I leave you below the final code that I used to solve the problem.
MainScreen function that before was MainActivityLayout function:
#Preview
#Composable
#ExperimentalFoundationApi
fun MainScreen() {
val navController = rememberNavController()
val topLevelDestinations = listOf(
NavigationItem.JobOpenings,
NavigationItem.Profile,
NavigationItem.About
)
val isTopLevelDestination =
navController
.currentBackStackEntryAsState()
.value
?.destination
?.route in topLevelDestinations.map { it.route }
val backStackEntryState = navController.currentBackStackEntryAsState()
Scaffold(
bottomBar = {
if (isTopLevelDestination) {
BottomNavBar(
navController = navController,
backStackEntryState = backStackEntryState,
bottomNavItems = topLevelDestinations
)
}
}
) {
Box(
modifier = Modifier
.paint(
painter = painterResource(id = R.drawable.main_background),
contentScale = ContentScale.FillBounds
)
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Navigation(navController = navController)
}
}
}
New JobOpenings fun that is mixed with old JobOpeningsContent function:
#Composable
#ExperimentalFoundationApi
fun JobOpeningsScreen(viewModel: JobOpeningsViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
when (uiState) {
is BaseViewState.Data -> {
val pagedData = uiState.cast<BaseViewState.Data<JobOpeningsViewState>>().value.pagedData
val pagingItems = rememberFlowWithLifecycle(pagedData).collectAsLazyPagingItems()
SwipeRefresh(
state = rememberSwipeRefreshState(
isRefreshing = pagingItems.loadState.refresh == LoadState.Loading
),
onRefresh = { pagingItems.refresh() },
indicator = { state, trigger ->
SwipeRefreshIndicator(
state = state,
refreshTriggerDistance = trigger,
scale = true
)
},
content = {
LazyColumn(
modifier = Modifier
.width(Dimen320),
verticalArrangement = Arrangement.spacedBy(Dimen30)
) {
item {
ScreenHeader(
lightTitle = stringResource(id = R.string.job_openings_light_title),
boldTitle = stringResource(id = R.string.job_openings_bold_title)
)
HeightSpacer(Dimen60)
Row {
CategoryButton()
WidthSpacer(Dimen2)
OrderByButton()
}
HeightSpacer(Dimen30)
SearchTextField()
HeightSpacer(Dimen60)
}
items(pagingItems.itemCount) { index ->
pagingItems[index]?.let {
JobOpeningsRow(dto = it)
}
}
if (pagingItems.loadState.append == LoadState.Loading) {
item {
Box(Modifier.padding(Dimen24)) {
CircularProgressIndicator(Modifier.align(Alignment.Center))
}
}
}
}
}
)
}
is BaseViewState.Loading -> LoadingView()
else -> {}
}
LaunchedEffect(key1 = viewModel, block = {
viewModel.onTriggerEvent(JobOpeningsEvent.LoadJobOffers)
})
}
#ExperimentalFoundationApi
#Preview
#Composable
fun JobOpenings() {
JobOpeningsScreen()
}
Problems that I found with this solution:
LoadingView appears at the top of the screen instead at the top of the list.
If anyone has any suggestion to improve this, I am open to it. This works perfectly with Paging Library + Swipe Refresh (Accompanist) and full page scroll.

How to scroll a LazyRow faster using the dpad?

I'm trying to implement a carousel component on Android TV with Compose, and I have a problem with fast scrolling using the dpad. NB: I want to keep the focused item as the first displayed item on the screen.
Here is a screen capture:
The first 5 items are scrolled by pressing and releasing the right key after each item. The next 15 items are scrolled by keeping the right key pressed to the end of the list.
The scrolling and focus management work well, but I would like to make it faster. On the screen capture you see that when pressing the right key, the list is scrolled then the next item gets the focus. It is really slow.
Here is the Composable function:
#Composable
private fun CustomLazyRow() {
val scrollState = rememberLazyListState()
LazyRow(
state = scrollState,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
itemsIndexed(
items = (1..20).toList()
) { index, item ->
var isFocused by remember { mutableStateOf(false) }
Text(
text = "Item $item",
modifier = Modifier
.dpadNavigation(scrollState, index)
.width(156.dp)
.aspectRatio(4 / 3F)
.onFocusChanged { isFocused = it.isFocused }
.focusable()
.border(if (isFocused) 4.dp else Dp.Hairline, Color.Black)
)
}
}
}
And the dpadNavigation Modifier function:
fun Modifier.dpadNavigation(
scrollState: LazyListState,
index: Int
) = composed {
val focusManager = LocalFocusManager.current
var focusDirectionToMove by remember { mutableStateOf<FocusDirection?>(null) }
val scope = rememberCoroutineScope()
onKeyEvent {
if (it.type == KeyEventType.KeyDown) {
when (it.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> focusDirectionToMove = FocusDirection.Left
KeyEvent.KEYCODE_DPAD_RIGHT -> focusDirectionToMove = FocusDirection.Right
}
if (focusDirectionToMove != null) {
scope.launch {
if (focusDirectionToMove == FocusDirection.Left && index > 0) {
// This does not work:
// scope.launch { scrollState.animateScrollToItem(index - 1) }
scrollState.animateScrollToItem(index - 1)
focusManager.moveFocus(FocusDirection.Left)
}
if (focusDirectionToMove == FocusDirection.Right) {
// scope.launch { scrollState.animateScrollToItem(index + 1) }
scrollState.animateScrollToItem(index + 1)
focusManager.moveFocus(FocusDirection.Right)
}
}
}
}
true
}
}
I thought it was caused by the animateScrollToItem function that had to complete before executing moveFocus.
So I tried to execute animateScrollToItem in its own launch block but it didn't work; in this case there is no scrolling at all.
You can see the complete source code in a repo at https://github.com/geekarist/perf-carousel.

Android Jetpack Compose (Composable) How to properly implement Swipe Refresh

I use the SwipeRefresh composable from the accompanist library, I checked the examples, but I could not find sample that matches my needs. I want to implement element that is hidden above the main UI, so when the user starts swiping the box is slowly shown up. I implemented this logic by setting the padding of the box that is below the hidden box. The obvious reason is that by changing the padding, all the composables are recreated and that leads to lags as seen from this report. Is there a way to fix it?
#Composable
fun HomeScreen(viewModel: CityWeatherViewModel) {
val scrollState = rememberScrollState()
val maxTopOffset = 200f
SwipeRefresh(
state = rememberSwipeRefreshState(isRefreshing = isRefreshing),
onRefresh = {
},
indicator = { state, triggerDp ->
if (state.isRefreshing) {
} else {
val triggerPx = with(LocalDensity.current) { triggerDp.toPx() }
val progress = (state.indicatorOffset / triggerPx).coerceIn(0f, 1f)
viewModel.apply {
rotateSwipeRefreshArrow(progress >= 0.9)
setSwipeRefreshTopPadding(progress * maxTopOffset)
}
}
}
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(state = scrollState, enabled = true)
.padding(top = viewModel.swipeRefreshPaddingTop.value.dp)
) {
HiddenSwipeRefreshBox(viewModel)
MainBox(viewModel)
}
}
}
#Composable
fun HiddenSwipeRefreshBox(viewModel: CityWeatherViewModel) {
}
#Composable
fun MainBox(viewModel: CityWeatherViewModel) {
}
#HiltViewModel
class CityWeatherViewModel #Inject constructor(
private val getCityWeather: GetCityWeather
) : ViewModel() {
private val _swipeRefreshPaddingTop = mutableStateOf(0f)
val swipeRefreshPaddingTop: State<Float> = _swipeRefreshPaddingTop
fun setSwipeRefreshTopPadding(padding: Float) {
_swipeRefreshPaddingTop.value = padding
}
}
I managed to fix it, by replacing the padding with offset, so the code for the Column is changed to:
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(state = scrollState, enabled = true)
.offset { IntOffset(0, viewModel.swipeRefreshPaddingTop.value.roundToInt()) }
) {
}
And now there is no more lagging, even on older devices with 2GB RAM! I have no idea how this is related to the padding lagging, but it works. I found the code from HERE

Categories

Resources