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

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

/*
 * Copyright 2024 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.collection.IntList
import androidx.collection.emptyIntList
import androidx.collection.intListOf
import androidx.compose.ui.util.fastFilter
import androidx.compose.ui.util.fastFirstOrNull

/** Defines how sticky items should be placed in a Lazy Layout */
internal interface StickyItemsPlacement {

    /**
     * Determines which indices in [stickyItems] should be sticking, that is, have their placement
     * altered.
     *
     * @param firstVisibleItemIndex The first visible item in the layout
     * @param lastVisibleItemIndex The last visible item in the layout
     * @param stickyItems The indices of candidate items for sticking
     * @return A list of items that should be sticking.
     */
    fun getStickingIndices(
        firstVisibleItemIndex: Int,
        lastVisibleItemIndex: Int,
        stickyItems: IntList
    ): IntList

    /**
     * This method is executed for each item returned from [getStickingIndices] to determine their
     * final placement.
     *
     * @param visibleStickyItems The currently visible sticky items.
     * @param itemIndex The current sticking item index.
     * @param itemSize The current sticking item main axis size.
     * @param itemOffset The initial offset for this sticking item. If this item doesn't have an
     *   offset, that is, it has been just composed, this will be Int.MIN_VALUE.
     * @param beforeContentPadding Padding applied to the start of the layout
     * @param afterContentPadding Padding applied to the end of the layout
     * @param layoutWidth The containing layout's width
     * @param layoutHeight The containing layout's height
     */
    fun calculateStickingItemOffset(
        visibleStickyItems: List,
        itemIndex: Int,
        itemSize: Int,
        itemOffset: Int,
        beforeContentPadding: Int,
        afterContentPadding: Int,
        layoutWidth: Int,
        layoutHeight: Int
    ): Int

    companion object {

        /**
         * A [StickyItemsPlacement] that sticks a single item to the top and pushes sticky item when
         * another approaches the top of the layout.
         */
        val StickToTopPlacement =
            object : StickyItemsPlacement {
                override fun calculateStickingItemOffset(
                    visibleStickyItems: List,
                    itemIndex: Int,
                    itemSize: Int,
                    itemOffset: Int,
                    beforeContentPadding: Int,
                    afterContentPadding: Int,
                    layoutWidth: Int,
                    layoutHeight: Int
                ): Int {

                    // the next item offset
                    val nextStickyItemOffset =
                        visibleStickyItems.fastFirstOrNull { it.index != itemIndex }?.mainAxisOffset
                            ?: Int.MIN_VALUE

                    debugLog { "Next Item Offset=$nextStickyItemOffset" }

                    var updatedItemOffset =
                        if (itemOffset == Int.MIN_VALUE) {
                            -beforeContentPadding
                        } else {
                            maxOf(-beforeContentPadding, itemOffset)
                        }

                    // If there's a next item overlapping with the current item, the offset
                    // should represent that.
                    if (nextStickyItemOffset != Int.MIN_VALUE) {
                        updatedItemOffset =
                            minOf(updatedItemOffset, nextStickyItemOffset - itemSize)
                    }

                    debugLog {
                        "Item=$itemIndex Initial_Offset=$itemOffset " +
                            "Final_Offset=$updatedItemOffset"
                    }
                    return updatedItemOffset
                }

                override fun getStickingIndices(
                    firstVisibleItemIndex: Int,
                    lastVisibleItemIndex: Int,
                    stickyItems: IntList
                ): IntList {
                    // no items present
                    if ((lastVisibleItemIndex - firstVisibleItemIndex) < 0 || stickyItems.isEmpty())
                        return emptyIntList()

                    // First non sticking visible item
                    val firstVisible = firstVisibleItemIndex

                    debugLog { "First Visible Item Index=${firstVisible}" }

                    var currentHeaderIndex = -1

                    // The sticking header will be the first one after the first visible item
                    // or the first visible item itself.
                    for (index in stickyItems.indices) {
                        if (stickyItems[index] <= firstVisible) {
                            currentHeaderIndex = stickyItems[index]
                        } else {
                            break
                        }
                    }

                    return if (currentHeaderIndex == -1) {
                        // we have no headers needing special handling
                        emptyIntList()
                    } else {
                        intListOf(currentHeaderIndex)
                    }
                }
            }
    }
}

private const val Debug = false

private inline fun debugLog(generateMsg: () -> String) {
    if (Debug) {
        println("StickToTopBehavior: ${generateMsg()}")
    }
}

private val LazyLayoutMeasuredItem.mainAxisOffset
    get() = getOffset(0).let { if (isVertical) it.y else it.x }

/**
 * This glue logic is not meant to become public. In here we will use [StickyItemsPlacement] to
 * determine which items are sticking, compose (when needed) and position them. The result is a list
 * of [LazyLayoutMeasuredItem] with the sticking items and [positionedItems] with the updated list
 * of existing items.
 */
internal fun  StickyItemsPlacement?.applyStickyItems(
    positionedItems: MutableList,
    stickyItems: IntList,
    beforeContentPadding: Int,
    afterContentPadding: Int,
    layoutWidth: Int,
    layoutHeight: Int,
    getAndMeasure: (Int) -> T
): List {
    return if (this != null && positionedItems.isNotEmpty() && stickyItems.isNotEmpty()) {
        // gather sticking items
        val stickingItems =
            getStickingIndices(
                positionedItems.first().index,
                positionedItems.last().index,
                stickyItems
            )

        val positionedStickingItems = mutableListOf()
        val visibleStickyItems = positionedItems.fastFilter { stickyItems.contains(it.index) }

        // update sticking item offsets
        stickingItems.forEach { stickingIndex ->
            val itemIndex = positionedItems.indexOfFirst { it.index == stickingIndex }
            // Compose or retrieve item
            val item =
                if (itemIndex == -1) {
                    getAndMeasure(stickingIndex)
                } else {
                    positionedItems.removeAt(itemIndex)
                }
            val offset =
                calculateStickingItemOffset(
                    visibleStickyItems,
                    stickingIndex,
                    item.mainAxisSizeWithSpacings,
                    if (itemIndex == -1) Int.MIN_VALUE else item.mainAxisOffset,
                    beforeContentPadding,
                    afterContentPadding,
                    layoutWidth,
                    layoutHeight
                )
            item.nonScrollableItem = true
            item.position(offset, 0, layoutWidth, layoutHeight)
            positionedStickingItems.add(item)
        }
        positionedStickingItems
    } else {
        emptyList()
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy