Search code examples
androidgoogle-playandroid-jetpack-composewear-osandroid-wear-3.0

My Wear OS app always rejected because it doesn't show scrollbar


In my Wear OS app, I used a ScalingLazyColumn to implement the List. I am also showing the Scrollbar using PositionIndicator.

The reason the app is rejected is always the same. It's because the "show scrollbars" Does anyone else have the same problem as me?

Following Google's guidance documentation, I checked below emulators and Galaxy Watch 4, the Scrollbar is ALWAYS visible.

  • Wear OS small round 1.2"
  • Wear OS large round 1.39"
  • Wear OS square 1.2"

Solution

  • 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 ...
    }