I had the same issue with my WearOS app, I started to get declined with "show scrollbars" out of nowhere. I went through the whole app and found one screen that had no scrollbars, but it still got rejected. I also tried to ensure enough elements are in each list so that it can actually scroll, but it still got rejected. I tried to "wiggle" the scroll position when the screen first appeared so that the scrollbar shows initially and then fades out, still rejected. I also tried to appeal the decision with no luck.
The only thing that helped in the end was to modify PositionIndicator
so that it would always show up and never automatically hide. I copied the code from androidx.wear.compose:compose-material:1.0.1
and modified it so the scrollbars always show. There is still one check for canScroll
, reading what other people say you might need to remove this check as well to really ALWAYS show the scrollbars? I had success with this solution where the scrollbars are hidden when there is not enough content to scroll.
This is for ScalingLazyColumn
but you should be able to adapt this setup to other scroll setups as well by copying out the code from PositionIndicator
and then making sure that visibility()
returns PositionIndicatorVisibility.Show
and not PositionIndicatorVisibility.AutoHide
.
import androidx.compose.runtime.State
import androidx.wear.compose.material.PositionIndicatorState
import androidx.wear.compose.material.PositionIndicatorVisibility
import androidx.wear.compose.material.ScalingLazyListAnchorType
import androidx.wear.compose.material.ScalingLazyListAnchorType.Companion.ItemCenter
import androidx.wear.compose.material.ScalingLazyListItemInfo
import androidx.wear.compose.material.ScalingLazyListState
class AlwaysShowScrollBarScalingLazyColumnStateAdapter(
private val state: ScalingLazyListState,
private val viewportHeightPx: State<Int?>,
private val anchorType: ScalingLazyListAnchorType = ItemCenter,
) : PositionIndicatorState {
override val positionFraction: Float
get() {
return if (state.layoutInfo.visibleItemsInfo.isEmpty()) {
0.0f
} else {
val decimalFirstItemIndex = decimalFirstItemIndex()
val decimalLastItemIndex = decimalLastItemIndex()
val decimalLastItemIndexDistanceFromEnd = state.layoutInfo.totalItemsCount -
decimalLastItemIndex
if (decimalFirstItemIndex + decimalLastItemIndexDistanceFromEnd == 0.0f) {
0.0f
} else {
decimalFirstItemIndex /
(decimalFirstItemIndex + decimalLastItemIndexDistanceFromEnd)
}
}
}
override fun sizeFraction(scrollableContainerSizePx: Float) =
if (state.layoutInfo.totalItemsCount == 0) {
1.0f
} else {
val decimalFirstItemIndex = decimalFirstItemIndex()
val decimalLastItemIndex = decimalLastItemIndex()
(decimalLastItemIndex - decimalFirstItemIndex) /
state.layoutInfo.totalItemsCount.toFloat()
}
override fun visibility(scrollableContainerSizePx: Float): PositionIndicatorVisibility {
val canScroll = state.layoutInfo.visibleItemsInfo.isNotEmpty() &&
(decimalFirstItemIndex() > 0 ||
decimalLastItemIndex() < state.layoutInfo.totalItemsCount)
return if (canScroll) PositionIndicatorVisibility.Show else PositionIndicatorVisibility.Hide
}
override fun hashCode(): Int {
return state.hashCode()
}
override fun equals(other: Any?): Boolean {
return (other as? AlwaysShowScrollBarScalingLazyColumnStateAdapter)?.state == state
}
/**
* Provide a float value that represents the index of the last visible list item in a scaling
* lazy column. The value should be in the range from [n,n+1] for a given index n, where n is
* the index of the last visible item and a value of n represents that only the very start|top
* of the item is visible, and n+1 means that whole of the item is visible in the viewport.
*
* Note that decimal index calculations ignore spacing between list items both for determining
* the number and the number of visible items.
*/
private fun decimalLastItemIndex(): Float {
if (state.layoutInfo.visibleItemsInfo.isEmpty()) return 0f
val lastItem = state.layoutInfo.visibleItemsInfo.last()
// This is the offset of the last item w.r.t. the ScalingLazyColumn coordinate system where
// 0 in the center of the visible viewport and +/-(state.viewportHeightPx / 2f) are the
// start and end of the viewport.
//
// Note that [ScalingLazyListAnchorType] determines how the list items are anchored to the
// center of the viewport, it does not change viewport coordinates. As a result this
// calculation needs to take the anchorType into account to calculate the correct end
// of list item offset.
val lastItemEndOffset = lastItem.startOffset(anchorType) + lastItem.size
val viewportEndOffset = viewportHeightPx.value!! / 2f
val lastItemVisibleFraction =
(1f - ((lastItemEndOffset - viewportEndOffset) / lastItem.size)).coerceAtMost(1f)
return lastItem.index.toFloat() + lastItemVisibleFraction
}
/**
* Provide a float value that represents the index of first visible list item in a scaling lazy
* column. The value should be in the range from [n,n+1] for a given index n, where n is the
* index of the first visible item and a value of n represents that all of the item is visible
* in the viewport and a value of n+1 means that only the very end|bottom of the list item is
* visible at the start|top of the viewport.
*
* Note that decimal index calculations ignore spacing between list items both for determining
* the number and the number of visible items.
*/
private fun decimalFirstItemIndex(): Float {
if (state.layoutInfo.visibleItemsInfo.isEmpty()) return 0f
val firstItem = state.layoutInfo.visibleItemsInfo.first()
val firstItemStartOffset = firstItem.startOffset(anchorType)
val viewportStartOffset = -(viewportHeightPx.value!! / 2f)
val firstItemInvisibleFraction =
((viewportStartOffset - firstItemStartOffset) / firstItem.size).coerceAtLeast(0f)
return firstItem.index.toFloat() + firstItemInvisibleFraction
}
}
internal fun ScalingLazyListItemInfo.startOffset(anchorType: ScalingLazyListAnchorType) =
offset - if (anchorType == ScalingLazyListAnchorType.ItemCenter) {
(size / 2f)
} else {
0f
}
To use:
val scalingLazyListState = rememberScalingLazyListState()
val height = remember { mutableStateOf(1) }
Scaffold(
modifier = Modifier.onGloballyPositioned { height.value = it.size.height },
positionIndicator = {
// Hack to ALWAYS show the scrollbars...Google happy now
PositionIndicator(
state = AlwaysShowScrollBarScalingLazyColumnStateAdapter(
state = scalingLazyListState,
viewportHeightPx = height,
),
//region Original values from PositionIndicator
indicatorHeight = 50.dp,
indicatorWidth = 4.dp,
paddingHorizontal = 5.dp,
reverseDirection = false,
//endregion
)
}
) {
// You ScalingLazyColumn here ...
}