ScalingLazyColumnMeasure.kt

/*
 * Copyright 2021 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.wear.compose.material

import androidx.compose.animation.core.Easing
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.unit.Constraints
import kotlin.math.min
import kotlin.math.roundToInt

/**
 * Parameters to control the scaling of the contents of a [ScalingLazyColumn]. The contents
 * of a [ScalingLazyColumn] are scaled down (made smaller) as they move further from the center
 * of the viewport. This scaling gives a "fisheye" effect with the contents in the center being
 * larger and therefore more prominent.
 *
 * Items in the center of the component's viewport will be full sized and with normal transparency.
 * As items move further from the center of the viewport they get smaller and become transparent.
 *
 * The scaling parameters allow for larger items to start being scaled closer to the center than
 * smaller items. This allows for larger items to scale over a bigger transition area giving a more
 * visually pleasing effect.
 *
 * Scaling transitions take place between a transition line and the edge of the screen. The trigger
 * for an item to start being scaled is when its most central edge, the item's edge that is furthest
 * from the screen edge, passing across the item's transition line. The amount of scaling to apply is
 * a function of the how far the item has moved through its transition area. An interpolator is
 * applied to allow for the scaling to follow a bezier curve.
 *
 * There are 4 properties that are used to determine an item's scaling transition point.
 *
 * [maxTransitionArea] and [minTransitionArea] define the range in which all item scaling lines sit.
 * The largest items will start to scale at the [maxTransitionArea] and the smallest items will
 * start to scale at the [minTransitionArea].
 *
 * The [minTransitionArea] and [maxTransitionArea] apply either side of the center line of the
 * viewport creating 2 transition areas one at the top/start the other at the bottom/end.
 * So a [maxTransitionArea] value of 0.6f on a Viewport of size 320 dp will give start transition
 * line for scaling at (320 / 2) * 0.6 = 96 dp from the top/start and bottom/end edges of the
 * viewport. Similarly [minTransitionArea] gives the point at which the scaling transition area
 * ends, e.g. a value of 0.2 with the same 320 dp screen gives an min scaling transition area line
 * of (320 / 2) * 0.2 = 32 dp from top/start and bottom/end. So in this example we have two
 * transition areas in the ranges [32.dp,96.dp] and [224.dp (320.dp-96.d),288.dp (320.dp-32.dp)].
 *
 * Deciding for a specific content item exactly where its transition line will be within the
 * ([minTransitionArea], [maxTransitionArea]) transition area is determined by its height as a
 * fraction of the viewport height and the properties [minElementHeight] and [maxElementHeight],
 * also defined as a fraction of the viewport height.
 *
 * If an item is smaller than [minElementHeight] it is treated as is [minElementHeight] and if
 * larger than [maxElementHeight] then it is treated as if [maxElementHeight].
 *
 * Given the size of an item where it sits between [minElementHeight] and [maxElementHeight] is used
 * to determine what fraction of the transition area to use. For example if [minElementHeight] is
 * 0.2 and [maxElementHeight] is 0.8 then a component item that is 0.4 (40%) of the size of the
 * viewport would start to scale when it was 0.333 (33.3%) of the way through the transition area,
 * (0.4 - 0.2) / (0.8 - 0.2) = 0.2 / 0.6 = 0.333.
 *
 * Taking the example transition area above that means that the scaling line for the item would be a
 * third of the way between 32.dp and 96.dp. 32.dp + ((96.dp-32.dp) * 0.333) = 53.dp. So this item
 * would start to scale when it moved from the center across the 53.dp line and its scaling would be
 * between 53.dp and 0.dp.
 */
public interface ScalingParams {
    /**
     * What fraction of the full size of the item to scale it by when most
     * scaled, e.g. at the viewport edge. A value between [0.0,1.0], so a value of 0.2f means
     * to scale an item to 20% of its normal size.
     */
//    @FloatRange(
//        fromInclusive = true, from = 0.0, toInclusive = true, to = 1.0
//    )
    val edgeScale: Float

    /**
     * What fraction of the full transparency of the item to scale it by when
     * most scaled, e.g. at the viewport edge. A value between [0.0,1.0], so a value of 0.2f
     * means to set the alpha of an item to 20% of its normal value.
     */
//    @FloatRange(
//        fromInclusive = true, from = 0.0, toInclusive = true, to = 1.0
//    )
    val edgeAlpha: Float

    /**
     * The minimum element height as a fraction of the viewport size to use
     * for determining the transition point within ([minTransitionArea], [maxTransitionArea]) that a
     * given content item will start to be scaled. Items smaller than [minElementHeight] will be
     * treated as if [minElementHeight]. Must be less than or equal to [maxElementHeight].
     */
//    @FloatRange(
//        fromInclusive = true, from = 0.0, toInclusive = true, to = 1.0
//    )
    val minElementHeight: Float

    /**
     * The minimum element height as a fraction of the viewport size to use
     * for determining the transition point within ([minTransitionArea], [maxTransitionArea]) that a
     * given content item will start to be scaled. Items smaller than [minElementHeight] will be
     * treated as if [minElementHeight]. Must be less than or equal to [maxElementHeight].
     */
//    @FloatRange(
//        fromInclusive = true, from = 0.0, toInclusive = true, to = 1.0
//    )
    val maxElementHeight: Float

    /**
     * The lower bound of the scaling transition area, closest to the edge
     * of the component. Defined as a fraction of the distance between the viewport center line and
     * viewport edge of the component. Must be less than or equal to [maxTransitionArea].
     */
//    @FloatRange(
//        fromInclusive = true, from = 0.0, toInclusive = true, to = 1.0
//    )
    val minTransitionArea: Float

    /**
     * The upper bound of the scaling transition area, closest to the center
     * of the component. Defined as a fraction of the distance between the viewport center line and
     * viewport edge of the component. Must be greater
     * than or equal to [minTransitionArea].
     */
//    @FloatRange(
//        fromInclusive = true, from = 0.0, toInclusive = true, to = 1.0
//    )
    val maxTransitionArea: Float

    /**
     * An interpolator to use to determine how to apply scaling as a item
     * transitions across the scaling transition area.
     */
    val scaleInterpolator: Easing

    /**
     * Determine the offset/extra padding (in pixels) that is used to define a space for additional
     * items that should be considered for drawing on the screen as the scaling of the visible
     * items in viewport is calculated. This additional padding area means that more items will
     * materialized and therefore be in scope for being drawn in the viewport if the scaling of
     * other elements means that there is additional space at the top and bottom of the viewport
     * that can be used. The default value is a fifth of the viewport height allowing an
     * additional 20% of the viewport height above and below the viewport.
     *
     * @param viewportConstraints the viewports constraints
     */
    public fun resolveViewportVerticalOffset(viewportConstraints: Constraints): Int
}

@Stable
internal class DefaultScalingParams(
    override val edgeScale: Float,
    override val edgeAlpha: Float,
    override val minElementHeight: Float,
    override val maxElementHeight: Float,
    override val minTransitionArea: Float,
    override val maxTransitionArea: Float,
    override val scaleInterpolator: Easing,
    val viewportVerticalOffsetResolver: (Constraints) -> Int,
) : ScalingParams {

    init {
        check(
            minElementHeight <= maxElementHeight,
            { "minElementHeight must be less than or equal to maxElementHeight" }
        )
        check(
            minTransitionArea <= maxTransitionArea,
            { "minTransitionArea must be less than or equal to maxTransitionArea" }
        )
    }

    override fun resolveViewportVerticalOffset(viewportConstraints: Constraints): Int {
        return viewportVerticalOffsetResolver(viewportConstraints)
    }

    override fun toString(): String {
        return "ScalingParams(edgeScale=$edgeScale, edgeAlpha=$edgeAlpha, " +
            "minElementHeight=$minElementHeight, maxElementHeight=$maxElementHeight, " +
            "minTransitionArea=$minTransitionArea, maxTransitionArea=$maxTransitionArea)"
    }

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

        other as DefaultScalingParams

        if (edgeScale != other.edgeScale) return false
        if (edgeAlpha != other.edgeAlpha) return false
        if (minElementHeight != other.minElementHeight) return false
        if (maxElementHeight != other.maxElementHeight) return false
        if (minTransitionArea != other.minTransitionArea) return false
        if (maxTransitionArea != other.maxTransitionArea) return false
        if (scaleInterpolator != other.scaleInterpolator) return false
        if (viewportVerticalOffsetResolver != other.viewportVerticalOffsetResolver) return false

        return true
    }

    override fun hashCode(): Int {
        var result = edgeScale.hashCode()
        result = 31 * result + edgeAlpha.hashCode()
        result = 31 * result + minElementHeight.hashCode()
        result = 31 * result + maxElementHeight.hashCode()
        result = 31 * result + minTransitionArea.hashCode()
        result = 31 * result + maxTransitionArea.hashCode()
        result = 31 * result + scaleInterpolator.hashCode()
        result = 31 * result + viewportVerticalOffsetResolver.hashCode()
        return result
    }
}

/**
 * Calculate the scale and alpha to apply for an item based on the start and end position of the
 * component's viewport in pixels and top and bottom position of the item, also in pixels.
 *
 * Firstly worked out if the component is above or below the viewport's center-line which determines
 * whether the item's top or bottom will be used as the trigger for scaling/alpha.
 *
 * Uses the scalingParams to determine where the scaling transition line is for the component.
 *
 * Then determines if the component is inside the scaling area, and if so what scaling/alpha effects
 * to apply.
 *
 * @param viewPortStartPx The start position of the component's viewport in pixels
 * @param viewPortEndPx The end position of the component's viewport in pixels
 * @param itemTopPx The top of the content item in pixels.
 * @param itemBottomPx The bottom of the content item in pixels.
 * @param scalingParams The parameters that determine where the item's scaling transition line
 * is, how scaling and transparency to apply.
 */
internal fun calculateScaleAndAlpha(
    viewPortStartPx: Int,
    viewPortEndPx: Int,
    itemTopPx: Int,
    itemBottomPx: Int,
    scalingParams: ScalingParams,
): ScaleAndAlpha {
    var scaleToApply = 1.0f
    var alphaToApply = 1.0f

    val viewPortHeightPx = (viewPortEndPx - viewPortStartPx).toFloat()
    val itemHeightPx = (itemBottomPx - itemTopPx).toFloat()

    /*
     * Calculate the position of the edge of the item closest to the center line of the viewport as
     * a fraction of the viewport. The [itemEdgePx] and [scrollPositionPx] values are in pixels.
     */
    val itemEdgeAsFractionOfViewport =
        min(itemBottomPx - viewPortStartPx, viewPortEndPx - itemTopPx) / viewPortHeightPx

    val heightAsFractionOfViewPort = itemHeightPx / viewPortHeightPx
    if (itemEdgeAsFractionOfViewport > 0.0f && itemEdgeAsFractionOfViewport < 1.0f) {
        // Work out the scaling line based on size, this is a value between 0.0..1.0
        val sizeRatio: Float =
            (
                (heightAsFractionOfViewPort - scalingParams.minElementHeight) /
                    (scalingParams.maxElementHeight - scalingParams.minElementHeight)
                ).coerceIn(0f, 1f)

        val scalingLineAsFractionOfViewPort =
            scalingParams.minTransitionArea +
                (scalingParams.maxTransitionArea - scalingParams.minTransitionArea) *
                sizeRatio

        if (itemEdgeAsFractionOfViewport < scalingLineAsFractionOfViewPort) {
            // We are scaling
            val fractionOfDiffToApplyRaw =
                (scalingLineAsFractionOfViewPort - itemEdgeAsFractionOfViewport) /
                    scalingLineAsFractionOfViewPort
            val fractionOfDiffToApplyInterpolated =
                scalingParams.scaleInterpolator.transform(fractionOfDiffToApplyRaw)

            scaleToApply =
                scalingParams.edgeScale +
                (1.0f - scalingParams.edgeScale) *
                (1.0f - fractionOfDiffToApplyInterpolated)
            alphaToApply =
                scalingParams.edgeAlpha +
                (1.0f - scalingParams.edgeAlpha) *
                (1.0f - fractionOfDiffToApplyInterpolated)
        }
    } else {
        scaleToApply = scalingParams.edgeScale
        alphaToApply = scalingParams.edgeAlpha
    }

    return ScaleAndAlpha(scaleToApply, alphaToApply)
}

/**
 * Create a [ScalingLazyColumnItemInfo] given an unscaled start and end position for an item.
 *
 * @param itemStart the x-axis position of a list item. The x-axis position takes into account
 * any adjustment to the original position based on the scaling of other list items.
 * @param item the original item info used to provide the pre-scaling position and size
 * information for the item.
 * @param verticalAdjustment the amount of vertical adjustment to apply to item positions to
 * allow for content padding in order to determine the adjusted position of the item within the
 * viewport in order to correctly calculate the scaling to apply.
 * @param viewportHeightPx the height of the viewport in pixels
 * @param scalingParams the scaling params to use for determining the scaled size of the item
 */
internal fun createItemInfo(
    itemStart: Int,
    item: LazyListItemInfo,
    verticalAdjustment: Int,
    viewportHeightPx: Int,
    scalingParams: ScalingParams,
): ScalingLazyColumnItemInfo {
    val adjustedItemStart = itemStart - verticalAdjustment
    val adjustedItemEnd = itemStart + item.size - verticalAdjustment

    val scaleAndAlpha = calculateScaleAndAlpha(
        viewPortStartPx = 0, viewPortEndPx = viewportHeightPx, itemTopPx = adjustedItemStart,
        itemBottomPx = adjustedItemEnd, scalingParams = scalingParams
    )

    val isAboveLine = (adjustedItemEnd + adjustedItemStart) < viewportHeightPx
    val scaledHeight = (item.size * scaleAndAlpha.scale).roundToInt()
    val scaledItemTop = if (!isAboveLine) {
        itemStart
    } else {
        itemStart + item.size - scaledHeight
    }

    return DefaultScalingLazyColumnItemInfo(
        index = item.index,
        unadjustedOffset = item.offset,
        offset = scaledItemTop,
        size = scaledHeight,
        scale = scaleAndAlpha.scale,
        alpha = scaleAndAlpha.alpha
    )
}

internal class DefaultScalingLazyColumnLayoutInfo(
    override val visibleItemsInfo: List<ScalingLazyColumnItemInfo>,
    override val viewportStartOffset: Int,
    override val viewportEndOffset: Int,
    override val totalItemsCount: Int
) : ScalingLazyColumnLayoutInfo

internal class DefaultScalingLazyColumnItemInfo(
    override val index: Int,
    override val unadjustedOffset: Int,
    override val offset: Int,
    override val size: Int,
    override val scale: Float,
    override val alpha: Float
) : ScalingLazyColumnItemInfo

@Immutable
internal data class ScaleAndAlpha(
    val scale: Float,
    val alpha: Float
)