All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.androidx.compose.foundation.lazy.layout.LazyLayoutSemantics.kt Maven / Gradle / Ivy

/*
 * Copyright 2022 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.compose.foundation.lazy.layout

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.internal.requirePrecondition
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.SemanticsModifierNode
import androidx.compose.ui.node.invalidateSemantics
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.semantics.CollectionInfo
import androidx.compose.ui.semantics.ScrollAxisRange
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.semantics.collectionInfo
import androidx.compose.ui.semantics.getScrollViewportLength
import androidx.compose.ui.semantics.horizontalScrollAxisRange
import androidx.compose.ui.semantics.indexForKey
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.scrollToIndex
import androidx.compose.ui.semantics.verticalScrollAxisRange
import kotlinx.coroutines.launch

@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun Modifier.lazyLayoutSemantics(
    itemProviderLambda: () -> LazyLayoutItemProvider,
    state: LazyLayoutSemanticState,
    orientation: Orientation,
    userScrollEnabled: Boolean,
    reverseScrolling: Boolean,
): Modifier =
    this then
        LazyLayoutSemanticsModifier(
            itemProviderLambda = itemProviderLambda,
            state = state,
            orientation = orientation,
            userScrollEnabled = userScrollEnabled,
            reverseScrolling = reverseScrolling,
        )

@OptIn(ExperimentalFoundationApi::class)
private class LazyLayoutSemanticsModifier(
    val itemProviderLambda: () -> LazyLayoutItemProvider,
    val state: LazyLayoutSemanticState,
    val orientation: Orientation,
    val userScrollEnabled: Boolean,
    val reverseScrolling: Boolean,
) : ModifierNodeElement() {
    override fun create(): LazyLayoutSemanticsModifierNode =
        LazyLayoutSemanticsModifierNode(
            itemProviderLambda = itemProviderLambda,
            state = state,
            orientation = orientation,
            userScrollEnabled = userScrollEnabled,
            reverseScrolling = reverseScrolling,
        )

    override fun update(node: LazyLayoutSemanticsModifierNode) {
        node.update(
            itemProviderLambda = itemProviderLambda,
            state = state,
            orientation = orientation,
            userScrollEnabled = userScrollEnabled,
            reverseScrolling = reverseScrolling,
        )
    }

    override fun InspectorInfo.inspectableProperties() {
        // Not a public modifier.
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is LazyLayoutSemanticsModifier) return false

        if (itemProviderLambda !== other.itemProviderLambda) return false
        if (state != other.state) return false
        if (orientation != other.orientation) return false
        if (userScrollEnabled != other.userScrollEnabled) return false
        if (reverseScrolling != other.reverseScrolling) return false

        return true
    }

    override fun hashCode(): Int {
        var result = itemProviderLambda.hashCode()
        result = 31 * result + state.hashCode()
        result = 31 * result + orientation.hashCode()
        result = 31 * result + userScrollEnabled.hashCode()
        result = 31 * result + reverseScrolling.hashCode()
        return result
    }
}

@OptIn(ExperimentalFoundationApi::class)
private class LazyLayoutSemanticsModifierNode(
    private var itemProviderLambda: () -> LazyLayoutItemProvider,
    private var state: LazyLayoutSemanticState,
    private var orientation: Orientation,
    private var userScrollEnabled: Boolean,
    private var reverseScrolling: Boolean,
) : Modifier.Node(), SemanticsModifierNode {

    override val shouldAutoInvalidate: Boolean
        get() = false

    private val isVertical
        get() = orientation == Orientation.Vertical

    private val collectionInfo
        get() = state.collectionInfo()

    private lateinit var scrollAxisRange: ScrollAxisRange

    private val indexForKeyMapping: (Any) -> Int = { needle ->
        val itemProvider = itemProviderLambda()
        var result = -1
        for (index in 0 until itemProvider.itemCount) {
            if (itemProvider.getKey(index) == needle) {
                result = index
                break
            }
        }
        result
    }

    private var scrollToIndexAction: ((Int) -> Boolean)? = null

    init {
        updateCachedSemanticsValues()
    }

    fun update(
        itemProviderLambda: () -> LazyLayoutItemProvider,
        state: LazyLayoutSemanticState,
        orientation: Orientation,
        userScrollEnabled: Boolean,
        reverseScrolling: Boolean,
    ) {
        // These properties are only read lazily, so we don't need to invalidate
        // semantics if they change.
        this.itemProviderLambda = itemProviderLambda
        this.state = state

        // These properties are read when appling semantics, but don't need to rebuild the cache.
        if (this.orientation != orientation) {
            this.orientation = orientation
            invalidateSemantics()
        }

        // These values are used to build different cached values. If they, we need to rebuild the
        // cache.
        if (
            this.userScrollEnabled != userScrollEnabled || this.reverseScrolling != reverseScrolling
        ) {
            this.userScrollEnabled = userScrollEnabled
            this.reverseScrolling = reverseScrolling
            updateCachedSemanticsValues()
            invalidateSemantics()
        }
    }

    override fun SemanticsPropertyReceiver.applySemantics() {
        isTraversalGroup = true
        indexForKey(indexForKeyMapping)

        if (isVertical) {
            verticalScrollAxisRange = scrollAxisRange
        } else {
            horizontalScrollAxisRange = scrollAxisRange
        }

        scrollToIndexAction?.let { scrollToIndex(action = it) }

        getScrollViewportLength { (state.viewport - state.contentPadding).toFloat() }

        collectionInfo = [email protected]
    }

    private fun updateCachedSemanticsValues() {
        scrollAxisRange =
            ScrollAxisRange(
                value = { state.scrollOffset },
                maxValue = { state.maxScrollOffset },
                reverseScrolling = reverseScrolling
            )

        scrollToIndexAction =
            if (userScrollEnabled) {
                { index ->
                    val itemProvider = itemProviderLambda()
                    requirePrecondition(index >= 0 && index < itemProvider.itemCount) {
                        "Can't scroll to index $index, it is out of " +
                            "bounds [0, ${itemProvider.itemCount})"
                    }
                    coroutineScope.launch { state.scrollToItem(index) }
                    true
                }
            } else {
                null
            }
    }
}

internal interface LazyLayoutSemanticState {
    val viewport: Int
    val contentPadding: Int
    val scrollOffset: Float
    val maxScrollOffset: Float

    fun collectionInfo(): CollectionInfo

    suspend fun scrollToItem(index: Int)
}

// It is impossible for lazy lists to provide an absolute scroll offset because the size of the
// items above the viewport is not known, but the AccessibilityEvent system API expects one
// anyway. So this provides a best-effort pseudo-offset that avoids breaking existing behavior.
//
// The key properties that A11y services are known to actually rely on are:
// A) each scroll change generates a TYPE_VIEW_SCROLLED AccessibilityEvent
// B) the integer offset in the AccessibilityEvent is different than the last one (note that the
// magnitude and direction of the change does not matter for the known use cases)
// C) scrollability is indicated by whether the scroll position is exactly 0 or exactly
// maxScrollOffset
//
// To preserve property B) as much as possible, the constant 500 is chosen to be larger than a
// single scroll delta would realistically be, while small enough to avoid losing precision due
// to the 24-bit float significand of ScrollAxisRange with realistic list sizes (if there are
// fewer than ~16000 items, the integer value is exactly preserved).
internal fun estimatedLazyScrollOffset(
    firstVisibleItemIndex: Int,
    firstVisibleItemScrollOffset: Int
): Float {
    return (firstVisibleItemScrollOffset + firstVisibleItemIndex * 500).toFloat()
}

internal fun estimatedLazyMaxScrollOffset(
    firstVisibleItemIndex: Int,
    firstVisibleItemScrollOffset: Int,
    canScrollForward: Boolean
): Float {
    return if (canScrollForward) {
            // If we can scroll further, indicate that by setting it slightly higher than
            // the current value
            estimatedLazyScrollOffset(firstVisibleItemIndex, firstVisibleItemScrollOffset) + 100
        } else {
            // If we can't scroll further, the current value is the max
            estimatedLazyScrollOffset(firstVisibleItemIndex, firstVisibleItemScrollOffset)
        }
        .toFloat()
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy