AspectRatio.kt

/*
 * Copyright 2019 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.layout

import androidx.compose.runtime.Stable
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.IntrinsicMeasurable
import androidx.compose.ui.layout.IntrinsicMeasureScope
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.isSatisfiedBy
import kotlin.math.roundToInt

/**
 * Attempts to size the content to match a specified aspect ratio by trying to match one of the
 * incoming constraints in the following order: [Constraints.maxWidth], [Constraints.maxHeight],
 * [Constraints.minWidth], [Constraints.minHeight] if [matchHeightConstraintsFirst] is `false`
 * (which is the default), or [Constraints.maxHeight], [Constraints.maxWidth],
 * [Constraints.minHeight], [Constraints.minWidth] if [matchHeightConstraintsFirst] is `true`.
 * The size in the other dimension is determined by the aspect ratio. The combinations will be
 * tried in this order until one non-empty is found to satisfy the constraints. If no valid
 * size is obtained this way, it means that there is no non-empty size satisfying both
 * the constraints and the aspect ratio, so the constraints will not be respected
 * and the content will be sized such that the [Constraints.maxWidth] or [Constraints.maxHeight]
 * is matched (depending on [matchHeightConstraintsFirst]).
 *
 * Example usage:
 * @sample androidx.compose.foundation.layout.samples.SimpleAspectRatio
 *
 * @param ratio the desired width/height positive ratio
 */
@Stable
fun Modifier.aspectRatio(
    /*@FloatRange(from = 0.0, fromInclusive = false)*/
    ratio: Float,
    matchHeightConstraintsFirst: Boolean = false
) = this.then(
    AspectRatioElement(
        ratio,
        matchHeightConstraintsFirst,
        debugInspectorInfo {
            name = "aspectRatio"
            properties["ratio"] = ratio
            properties["matchHeightConstraintsFirst"] = matchHeightConstraintsFirst
        }
    )
)

private class AspectRatioElement(
    val aspectRatio: Float,
    val matchHeightConstraintsFirst: Boolean,
    val inspectorInfo: InspectorInfo.() -> Unit
) : ModifierNodeElement<AspectRatioNode>() {
    init {
        require(aspectRatio > 0) { "aspectRatio $aspectRatio must be > 0" }
    }

    override fun create(): AspectRatioNode {
        return AspectRatioNode(
            aspectRatio,
            matchHeightConstraintsFirst
        )
    }

    override fun update(node: AspectRatioNode) {
        node.aspectRatio = aspectRatio
        node.matchHeightConstraintsFirst = matchHeightConstraintsFirst
    }

    override fun InspectorInfo.inspectableProperties() { inspectorInfo() }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        val otherModifier = other as? AspectRatioElement ?: return false
        return aspectRatio == otherModifier.aspectRatio &&
            matchHeightConstraintsFirst == other.matchHeightConstraintsFirst
    }

    override fun hashCode(): Int =
        aspectRatio.hashCode() * 31 + matchHeightConstraintsFirst.hashCode()
}

private class AspectRatioNode(
    var aspectRatio: Float,
    var matchHeightConstraintsFirst: Boolean,
) : LayoutModifierNode, Modifier.Node() {
    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val size = constraints.findSize()
        val wrappedConstraints = if (size != IntSize.Zero) {
            Constraints.fixed(size.width, size.height)
        } else {
            constraints
        }
        val placeable = measurable.measure(wrappedConstraints)
        return layout(placeable.width, placeable.height) {
            placeable.placeRelative(0, 0)
        }
    }

    override fun IntrinsicMeasureScope.minIntrinsicWidth(
        measurable: IntrinsicMeasurable,
        height: Int
    ) = if (height != Constraints.Infinity) {
        (height * aspectRatio).roundToInt()
    } else {
        measurable.minIntrinsicWidth(height)
    }

    override fun IntrinsicMeasureScope.maxIntrinsicWidth(
        measurable: IntrinsicMeasurable,
        height: Int
    ) = if (height != Constraints.Infinity) {
        (height * aspectRatio).roundToInt()
    } else {
        measurable.maxIntrinsicWidth(height)
    }

    override fun IntrinsicMeasureScope.minIntrinsicHeight(
        measurable: IntrinsicMeasurable,
        width: Int
    ) = if (width != Constraints.Infinity) {
        (width / aspectRatio).roundToInt()
    } else {
        measurable.minIntrinsicHeight(width)
    }

    override fun IntrinsicMeasureScope.maxIntrinsicHeight(
        measurable: IntrinsicMeasurable,
        width: Int
    ) = if (width != Constraints.Infinity) {
        (width / aspectRatio).roundToInt()
    } else {
        measurable.maxIntrinsicHeight(width)
    }

    private fun Constraints.findSize(): IntSize {
        if (!matchHeightConstraintsFirst) {
            tryMaxWidth().also { if (it != IntSize.Zero) return it }
            tryMaxHeight().also { if (it != IntSize.Zero) return it }
            tryMinWidth().also { if (it != IntSize.Zero) return it }
            tryMinHeight().also { if (it != IntSize.Zero) return it }
            tryMaxWidth(enforceConstraints = false).also { if (it != IntSize.Zero) return it }
            tryMaxHeight(enforceConstraints = false).also { if (it != IntSize.Zero) return it }
            tryMinWidth(enforceConstraints = false).also { if (it != IntSize.Zero) return it }
            tryMinHeight(enforceConstraints = false).also { if (it != IntSize.Zero) return it }
        } else {
            tryMaxHeight().also { if (it != IntSize.Zero) return it }
            tryMaxWidth().also { if (it != IntSize.Zero) return it }
            tryMinHeight().also { if (it != IntSize.Zero) return it }
            tryMinWidth().also { if (it != IntSize.Zero) return it }
            tryMaxHeight(enforceConstraints = false).also { if (it != IntSize.Zero) return it }
            tryMaxWidth(enforceConstraints = false).also { if (it != IntSize.Zero) return it }
            tryMinHeight(enforceConstraints = false).also { if (it != IntSize.Zero) return it }
            tryMinWidth(enforceConstraints = false).also { if (it != IntSize.Zero) return it }
        }
        return IntSize.Zero
    }

    private fun Constraints.tryMaxWidth(enforceConstraints: Boolean = true): IntSize {
        val maxWidth = this.maxWidth
        if (maxWidth != Constraints.Infinity) {
            val height = (maxWidth / aspectRatio).roundToInt()
            if (height > 0) {
                val size = IntSize(maxWidth, height)
                if (!enforceConstraints || isSatisfiedBy(size)) {
                    return size
                }
            }
        }
        return IntSize.Zero
    }

    private fun Constraints.tryMaxHeight(enforceConstraints: Boolean = true): IntSize {
        val maxHeight = this.maxHeight
        if (maxHeight != Constraints.Infinity) {
            val width = (maxHeight * aspectRatio).roundToInt()
            if (width > 0) {
                val size = IntSize(width, maxHeight)
                if (!enforceConstraints || isSatisfiedBy(size)) {
                    return size
                }
            }
        }
        return IntSize.Zero
    }

    private fun Constraints.tryMinWidth(enforceConstraints: Boolean = true): IntSize {
        val minWidth = this.minWidth
        val height = (minWidth / aspectRatio).roundToInt()
        if (height > 0) {
            val size = IntSize(minWidth, height)
            if (!enforceConstraints || isSatisfiedBy(size)) {
                return size
            }
        }
        return IntSize.Zero
    }

    private fun Constraints.tryMinHeight(enforceConstraints: Boolean = true): IntSize {
        val minHeight = this.minHeight
        val width = (minHeight * aspectRatio).roundToInt()
        if (width > 0) {
            val size = IntSize(width, minHeight)
            if (!enforceConstraints || isSatisfiedBy(size)) {
                return size
            }
        }
        return IntSize.Zero
    }
}