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

3

5

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"
Israelite answered 24/10, 2023 at 13:38 Comment(2)
google is being a bi*** with those guidelines hahaha I just got reject 2x because of the bezel input... I have no idea what they want me to do and probably will get reject a 3x and Nx ahhhhhhhhInnards
@Innards I fully agree. These guidelines don't make any sense. First, a missing scrollbar in a short list does not bother anybody. Second, this is the way THEY designed the scrollbars. How can they now complain that we developers don't show the scrollbars? Ridiculous.Moneyed
B
3

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 ...
}
Briarroot answered 25/10, 2023 at 6:17 Comment(6)
My App was accepted with this solution.Israelite
Facing same problem. I will try your solution. Curious, what is wear os compose version you are using in your project ?Lion
androidx.wear.compose:compose-material:1.0.1, but the latest version had no visible difference in scrollbarsBriarroot
I am facing the same problem. How can I implement this solution if I am not using compose?Moneyed
Hello, how are you, so far with this change does it correct the scroll problem? Do you know if there is a way to show the scroll (disappear) and show it every time you return to the page or scroll? thank'sVeneration
this worked, and also i had to change some imports because they moved some classes into different package, so use these import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState import androidx.wear.compose.foundation.lazy.ScalingLazyColumn import androidx.wear.compose.foundation.lazy.ScalingLazyListAnchorType import androidx.wear.compose.foundation.lazy.ScalingLazyListAnchorType.Companion.ItemCenter import androidx.wear.compose.foundation.lazy.ScalingLazyListItemInfoLifesize
C
3

I had the same problem and I finally found out what the issue was. Maybe it will help you. When there were very few items in the list, my list was not scrolling because I did not see any reason to make it scroll if all the items are contained within the screen. However, the app kept being rejected. So what I did was add a big space below my list, so that it scrolls even if it is empty or has very few items. Turns out the app was finally accepted after much time wasted.

Coir answered 24/10, 2023 at 20:0 Comment(3)
Last version, I tried the same solution as your answer. Then I can update my Wear OS app successfully. But, This week, I tried to update my Android app (not the Wear OS app track), but it was rejected due to a Wear OS issue (scrollbar).Israelite
I had the same. If your WearOS app is rejected you also can't publish phone app updates. Super insane.Briarroot
This particular issue with no scroll when there are very few items in the list might have been fixed in latest wear compose version 1.2.1, see this CLLinebacker
B
3

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 ...
}
Briarroot answered 25/10, 2023 at 6:17 Comment(6)
My App was accepted with this solution.Israelite
Facing same problem. I will try your solution. Curious, what is wear os compose version you are using in your project ?Lion
androidx.wear.compose:compose-material:1.0.1, but the latest version had no visible difference in scrollbarsBriarroot
I am facing the same problem. How can I implement this solution if I am not using compose?Moneyed
Hello, how are you, so far with this change does it correct the scroll problem? Do you know if there is a way to show the scroll (disappear) and show it every time you return to the page or scroll? thank'sVeneration
this worked, and also i had to change some imports because they moved some classes into different package, so use these import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState import androidx.wear.compose.foundation.lazy.ScalingLazyColumn import androidx.wear.compose.foundation.lazy.ScalingLazyListAnchorType import androidx.wear.compose.foundation.lazy.ScalingLazyListAnchorType.Companion.ItemCenter import androidx.wear.compose.foundation.lazy.ScalingLazyListItemInfoLifesize
P
1

I had a very similar problem where my position indicator would show when a touch event was doing the scrolling but not when the rotary input was doing it. The solution to my particular problem was to add the line listState.animateScrollBy(0f) into the onRotaryScrollEvent block.

    ScalingLazyColumn(
      modifier = Modifier
        .onRotaryScrollEvent { event ->
            coroutineScope.launch {
                listState.scrollBy(event.verticalScrollPixels)
                listState.animateScrollBy(0f)
            }
            false
        }
    ...

This is also shown in the Google docs but it doesn't say why that line of code is there:

https://developer.android.com/training/wearables/compose/rotary-input#scroll

Peter answered 24/1 at 1:57 Comment(2)
Thank for your solution. Was your app accepted with this solution?Renatorenaud
Yes, it was acceptedPeter

© 2022 - 2024 — McMap. All rights reserved.