CurvedComposable.kt
/*
* 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.wear.compose.foundation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.unit.Constraints
import kotlin.math.PI
import kotlin.math.asin
import kotlin.math.cos
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Component that allows normal composables to be part of a [CurvedLayout].
*
* @param modifier The [CurvedModifier] to apply to this curved composable.
* @param radialAlignment How to align this component if it's thinner than the container.
* @param content The composable(s) that will be wrapped and laid out as part of the parent
* container. This has a [BoxScope], since it's wrapped inside a Box.
*/
public fun CurvedScope.curvedComposable(
modifier: CurvedModifier = CurvedModifier,
radialAlignment: CurvedAlignment.Radial = CurvedAlignment.Radial.Center,
content: @Composable BoxScope.() -> Unit
) = add(CurvedComposableChild(
curvedLayoutDirection.absoluteClockwise(),
radialAlignment,
content
), modifier)
internal class CurvedComposableChild(
val clockwise: Boolean,
val radialAlignment: CurvedAlignment.Radial,
val content: @Composable BoxScope.() -> Unit
) : CurvedChild() {
lateinit var placeable: Placeable
@Composable
override fun SubComposition() {
// Ensure we have a 1-1 match between CurvedComposable and composable child
Box(content = content)
}
override fun CurvedMeasureScope.initializeMeasure(
measurables: List<Measurable>,
index: Int
): Int {
// TODO: check that we actually match adding a parent data modifier to the Box in
// composeIfNeeded and verifying this measurable has it?
placeable = measurables[index].measure(Constraints())
return index + 1
}
override fun doEstimateThickness(maxRadius: Float): Float {
// Compute the annulus we need as if the child was top aligned, this gives as an upper
// bound on the thickness, but we need to recompute later, when we know the actual position.
val (innerRadius, outerRadius) = computeAnnulusRadii(maxRadius, 0f)
return outerRadius - innerRadius
}
override fun doRadialPosition(
parentOuterRadius: Float,
parentThickness: Float
): PartialLayoutInfo {
val parentInnerRadius = parentOuterRadius - parentThickness
// We know where we want it and the radial alignment, so we can compute it's positioning now
val (myInnerRadius, myOuterRadius) = computeAnnulusRadii(
lerp(parentOuterRadius, parentInnerRadius, radialAlignment.ratio),
radialAlignment.ratio
)
val sweepRadians = 2f * asin(placeable.width / 2f / myInnerRadius)
return PartialLayoutInfo(
sweepRadians,
myOuterRadius,
thickness = myOuterRadius - myInnerRadius,
measureRadius = (myInnerRadius + myOuterRadius) / 2 // !?
)
}
override fun (Placeable.PlacementScope).placeIfNeeded() {
// Distance from the center of the CurvedRow to the top left of the component.
val radiusToTopLeft = layoutInfo!!.outerRadius
// Distance from the center of the CurvedRow to the top center of the component.
val radiusToTopCenter = sqrt(pow2(radiusToTopLeft) - pow2(placeable.width / 2f))
// To position this child, we move its center rotating it around the CurvedRow's center.
val radiusToCenter = radiusToTopCenter - placeable.height / 2f
val centerAngle = layoutInfo!!.startAngleRadians + layoutInfo!!.sweepRadians / 2f
val childCenterX = layoutInfo!!.centerOffset.x + radiusToCenter * cos(centerAngle)
val childCenterY = layoutInfo!!.centerOffset.y + radiusToCenter * sin(centerAngle)
// Then compute the position of the top left corner given that center.
val positionX = (childCenterX - placeable.width / 2f).roundToInt()
val positionY = (childCenterY - placeable.height / 2f).roundToInt()
val rotationAngle = centerAngle + if (clockwise) 0f else PI.toFloat()
placeable.placeWithLayer(
x = positionX,
y = positionY,
layerBlock = {
rotationZ = rotationAngle.toDegrees() - 270f
// Should this be computed with centerOffset & size??
transformOrigin = TransformOrigin.Center
}
)
}
/**
* Compute the inner and outer radii of the annulus sector required to fit the given box.
*
* @param targetRadius The distance we want, from the center of the circle the annulus is part
* of, to a point on the side of the box (which point is determined with the radiusAlpha
* parameter.)
* @param radiusAlpha Which point on the side of the box we are measuring the radius to. 0 means
* radius is to the outer point in the box, 1 means that it's to the inner point.
* (And interpolation in-between)
*
*/
private fun computeAnnulusRadii(targetRadius: Float, radiusAlpha: Float): Pair<Float, Float> {
// The top side of the triangles we use, squared.
val topSquared = pow2(placeable.width / 2f)
// Project the radius we know to the line going from the center to the circle to the center
// of the box
val radiusInBox = sqrt(pow2(targetRadius) - topSquared)
// Move to the top/bottom of the child box, then project back
val outerRadius = sqrt(topSquared + pow2(radiusInBox + radiusAlpha * placeable.height))
val innerRadius = sqrt(topSquared +
pow2(radiusInBox - (1 - radiusAlpha) * placeable.height))
return innerRadius to outerRadius
}
private fun pow2(x: Float): Float = x * x
}