WearArcLayout.kt

/*
 * Copyright 2020 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.widget

import android.content.Context
import android.graphics.Canvas
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import androidx.annotation.IntDef
import androidx.annotation.UiThread
import androidx.wear.R
import kotlin.math.asin
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt

/**
 * Container which will lay its elements out on an arc. Elements will be relative to a given
 * anchor angle (where 0 degrees = 12 o clock), where the layout relative to the anchor angle is
 * controlled using `anchorPositionDegrees`. The thickness of the arc is calculated based on the
 * child element with the greatest height (in the case of Android widgets), or greatest thickness
 * (for curved widgets). By default, the container lays its children one by one in clockwise
 * direction. The attribute 'clockwise' can be set to false to make the layout direction as
 * anti-clockwise. These two types of widgets will be drawn as follows.
 *
 *
 * Standard Android Widgets:
 *
 *
 * These widgets will be drawn as usual, but placed at the correct position on the arc, with the
 * correct amount of rotation applied. As an example, for an Android Text widget, the text
 * baseline would be drawn at a tangent to the arc. The arc length of a widget is obtained by
 * measuring the width of the widget, and transforming that to the length of an arc on a circle.
 *
 *
 * A standard Android widget will be measured as usual, but the maximum height constraint will be
 * capped at the minimum radius of the arc (i.e. width / 2).
 *
 *
 * "Curved" widgets:
 *
 *
 * Widgets which implement ArcLayoutWidget are expected to draw themselves within an arc
 * automatically. These widgets will be measured with the full dimensions of the arc container.
 * They are also expected to provide their thickness (used when calculating the thickness of the
 * arc) and the current sweep angle (used for laying out when drawing). Note that the
 * WearArcLayout will apply a rotation transform to the canvas before drawing this child; the
 * inner child need not perform any rotations itself.
 *
 *
 * An example of a widget which implements this interface is WearCurvedTextView, which will lay
 * itself out along the arc.
 */
@UiThread
public class WearArcLayout @JvmOverloads constructor (
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0,
    defStyleRes: Int = 0
) : ViewGroup(context, attrs, defStyleAttr, defStyleRes) {

    /**
     * Interface for a widget which knows it is being rendered inside an arc, and will draw
     * itself accordingly. Any widget implementing this interface will receive the full-sized
     * canvas pre-rotated, in its draw call.
     */
    public interface ArcLayoutWidget {
        /** Returns the sweep angle that this widget is drawn with.  */
        public fun getSweepAngleDegrees(): Float

        /** Returns the thickness of this widget inside the arc.  */
        public fun getThicknessPx(): Int

        /** Check whether the widget contains invalid attributes as a child of WearArcLayout  */
        public fun checkInvalidAttributeAsChild(clockwise: Boolean)

        /** Return whether the widget will handle the layout rotation requested by the container
         *  If return true, make sure that the layout rotation is done inside the widget since the
         *  container will skip this process.
         */
        public fun handleLayoutRotate(angle: Float): Boolean
    }

    /**
     * Layout parameters for a widget added to an arc. This allows each element to specify
     * whether or not it should be rotated (around the center of the child) when drawn inside the
     * arc. For example, when the child is put at the center-bottom of the arc, whether the
     * parent layout is responsible to rotate it 180 degree to draw it upside down.
     *
     * Note that the `rotate` parameter is ignored when drawing "Fullscreen" elements.
     */
    public class LayoutParams : ViewGroup.LayoutParams {

        internal companion object {
            /** Align to the outer edge of the parent ArcContainer.  */
            const val VALIGN_OUTER = 0

            /** Align to the center of the parent ArcContainer.  */
            const val VALIGN_CENTER = 1

            /** Align to the inner edge of the parent ArcContainer.  */
            const val VALIGN_INNER = 2

            /** Vertical alignment of elements within the arc.  */
            @IntDef(VALIGN_OUTER, VALIGN_CENTER, VALIGN_INNER)
            internal annotation class VerticalAlignment
        }

        @VerticalAlignment
        public var verticalAlignment: Int = VALIGN_CENTER
        public var rotate: Boolean = true

        /**
         * Creates a new set of layout parameters. The values are extracted from the supplied
         * attributes set and context.
         *
         * @param c the application environment
         * @param attrs the set of attributes from which to extract the layout parameters' values
         */
        public constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) {
            val a = c.obtainStyledAttributes(attrs, R.styleable.WearArcLayout)
            rotate = a.getBoolean(R.styleable.WearArcLayout_Layout_layout_rotate, true)
            verticalAlignment = a.getInt(
                R.styleable.WearArcLayout_Layout_layout_valign,
                VALIGN_CENTER
            )
            a.recycle()
        }

        /**
         * Creates a new set of layout parameters with specified width and height
         *
         * @param width the width, either WRAP_CONTENT, MATCH_PARENT or a fixed size in pixels
         * @param height, the height, either WRAP_CONTENT, MATCH_PARENT or a fixed size in pixels
         */
        public constructor(width: Int, height: Int) : super(width, height) {}

        /** Copy constructor */
        public constructor(source: ViewGroup.LayoutParams?) : super(source) {}
    }

    private var thicknessPx = 0
    public var clockwise: Boolean = true
        set(value) {
            field = value
            invalidate()
        }

    @AnchorType
    public var anchorType: Int = DEFAULT_ANCHOR_TYPE
        set(value) {
            require(value in ANCHOR_START..ANCHOR_END) { "Unknown anchor type" }
            field = value
            invalidate()
        }

    public var anchorAngleDegrees: Float = DEFAULT_START_ANGLE_DEGREES
        set(value) {
            field = value
            invalidate()
        }

    private var currentCumulativeAngle = 0f

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // Need to derive the thickness of the curve from the children. We're a curve, so the
        // children can only be sized up to (width or height)/2 units. This currently only
        // supports fitting to a circle.
        //
        // No matter what, fit to the given size, be it a maximum or a fixed size. It doesn't make
        // sense for this container to wrap its children.
        var actualWidthPx = MeasureSpec.getSize(widthMeasureSpec)
        var actualHeightPx = MeasureSpec.getSize(heightMeasureSpec)
        if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED &&
            MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.UNSPECIFIED
        ) {
            // We can't actually resolve this.
            // Let's fit to the screen dimensions, for need of anything better...
            val displayMetrics = context.resources.displayMetrics
            actualWidthPx = (displayMetrics.widthPixels / displayMetrics.density).toInt()
            actualHeightPx = (displayMetrics.heightPixels / displayMetrics.density).toInt()
        }
        val maxChildDimension = min(actualHeightPx, actualWidthPx) / 2

        // Measure all children in the new measurespec, and cache the largest.
        val childMeasureSpec = MeasureSpec.makeMeasureSpec(maxChildDimension, MeasureSpec.AT_MOST)

        // We need to do two measure passes. First, we need to measure all "normal" children, and
        // get the thickness of all "WearArcLayout" children. Once we have that, we know the
        // maximum thickness, and we can lay out the "WearArcLayout" children, taking into
        // account their vertical alignment.
        var maxChildHeightPx = 0
        var childState = 0
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            if (child.visibility == GONE) {
                continue
            }

            // ArcLayoutWidget is a special case. Because of how it draws, fit it to the size of
            // the whole widget.
            when (child) {
                is ArcLayoutWidget -> {
                    maxChildHeightPx =
                        max(maxChildHeightPx, (child as ArcLayoutWidget).getThicknessPx())
                }
                else -> {
                    // Measure the child.
                    measureChild(child, childMeasureSpec, childMeasureSpec)
                    maxChildHeightPx = max(maxChildHeightPx, child.measuredHeight)
                    childState = combineMeasuredStates(childState, child.measuredState)
                }
            }
        }
        thicknessPx = maxChildHeightPx

        // And now do the pass for the ArcLayoutWidgets
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            if (child.visibility == GONE) {
                continue
            }

            if (child is ArcLayoutWidget) {
                val arcLayoutChild: ArcLayoutWidget = child
                val childLayoutParams = child.layoutParams as LayoutParams

                val childThicknessPx: Int = arcLayoutChild.getThicknessPx()
                val thicknessDiffPx = thicknessPx - childThicknessPx

                var insetPx = when (childLayoutParams.verticalAlignment) {
                    LayoutParams.VALIGN_CENTER -> thicknessDiffPx / 2f
                    LayoutParams.VALIGN_INNER -> thicknessDiffPx.toFloat()
                    else -> 0f
                }

                val innerChildMeasureSpec = MeasureSpec.makeMeasureSpec(
                    maxChildDimension * 2 - (insetPx * 2).roundToInt(),
                    MeasureSpec.EXACTLY
                )
                measureChild(
                    child,
                    getChildMeasureSpec(
                        innerChildMeasureSpec, /* padding = */ 0, childLayoutParams.width
                    ),
                    getChildMeasureSpec(
                        innerChildMeasureSpec, /* padding = */ 0, childLayoutParams.height
                    )
                )

                childState = combineMeasuredStates(childState, child.measuredState)
            }
        }

        setMeasuredDimension(
            resolveSizeAndState(actualWidthPx, widthMeasureSpec, childState),
            resolveSizeAndState(actualHeightPx, heightMeasureSpec, childState)
        )
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            if (child.visibility == GONE) {
                continue
            }

            // Curved container widgets have been measured so that the "arc" inside their widget
            // will touch the outside of the box they have been measured in, taking into account
            // the vertical alignment. Just grow them from the center.
            when (child) {
                is ArcLayoutWidget -> {
                    val leftPx: Int =
                        (this.measuredWidth / 2f - child.measuredWidth / 2f).roundToInt()
                    val topPx: Int =
                        (this.measuredHeight / 2f - child.measuredHeight / 2f).roundToInt()
                    child.layout(
                        leftPx, topPx, leftPx + child.measuredWidth, topPx + child.measuredHeight
                    )
                }
                else -> {
                    // Normal widgets need to be placed on their canvas, taking into account
                    // their vertical position.
                    val leftPx: Int =
                        (this.measuredWidth / 2f - child.measuredWidth / 2f).roundToInt()
                    val topPx: Int = getChildTopInset(child)
                    child.layout(
                        leftPx,
                        topPx,
                        leftPx + child.measuredWidth,
                        topPx + child.measuredHeight
                    )
                }
            }
        }
    }

    override fun dispatchDraw(canvas: Canvas) {
        currentCumulativeAngle = calculateInitialRotation()
        super.dispatchDraw(canvas)
    }

    override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean {
        // Rotate the canvas to make the children render in the right place.
        canvas.save()
        val arcAngle = calculateArcAngle(child)
        val preRotation = arcAngle / 2f
        val multiplier = if (clockwise) 1f else -1f
        if (child is ArcLayoutWidget) {
            (child as ArcLayoutWidget).checkInvalidAttributeAsChild(clockwise)

            // Special case for ArcLayoutWidget. This doesn't need pre-rotating to get the center
            // of canvas lines up, as it should already know how to draw itself correctly from
            // the "current" rotation. The layout rotation is always passed to the child widget,
            // if the child has not handled this rotation by itself, the parent will have to
            // rotate the canvas to apply this layout.
            if (!(child as ArcLayoutWidget).handleLayoutRotate(
                    multiplier * currentCumulativeAngle
                )
            ) {
                canvas.rotate(
                    multiplier * currentCumulativeAngle, measuredWidth / 2f, measuredHeight / 2f
                )
            }
        } else {
            canvas.rotate(
                multiplier * (currentCumulativeAngle + preRotation),
                measuredWidth / 2f,
                measuredHeight / 2f
            )
            val delta = measuredHeight - measuredWidth
            if (delta > 0) {
                canvas.translate(0f, delta / 2.toFloat())
            } else {
                canvas.translate(delta / 2.toFloat(), 0f)
            }

            // Do we need to do some counter rotation?
            val layoutParams = child.layoutParams as LayoutParams

            // For anti-clockwise layout, especially when mixing standard Android widget with
            // ArcLayoutWidget as children, we might need to rotate the standard widget to make
            // them with the same upwards direction. Note that the strange rotation center is
            // because the child view is not x-centered but at the top of this container.
            canvas.rotate(
                if (clockwise || !layoutParams.rotate) 0f else 180f,
                measuredWidth / 2f,
                child.measuredHeight / 2f
            )
            if (!layoutParams.rotate) {
                // Re-rotate about the top of the canvas, around the center of the actual child.
                val childInset: Int = getChildTopInset(child)
                canvas.rotate(
                    -multiplier * (currentCumulativeAngle + preRotation),
                    measuredWidth / 2f,
                    (child.measuredHeight / 2f) + childInset
                )
            }
        }
        currentCumulativeAngle += arcAngle
        return super.drawChild(canvas, child, drawingTime).also { canvas.restore() }
    }

    private fun calculateInitialRotation(): Float {
        val multiplier = if (clockwise) 1f else -1f
        if (anchorType == ANCHOR_START) {
            return multiplier * anchorAngleDegrees
        }
        var totalArcAngle = (0 until childCount).map { calculateArcAngle(getChildAt(it)) }.sum()
        return when (anchorType) {
            ANCHOR_CENTER -> multiplier * anchorAngleDegrees - totalArcAngle / 2f
            ANCHOR_END -> multiplier * anchorAngleDegrees - totalArcAngle
            else -> 0f
        }
    }

    private fun calculateArcAngle(view: View): Float {
        if (view.visibility == GONE) {
            return 0f
        }
        return (view as? ArcLayoutWidget) ?.getSweepAngleDegrees() ?: let {
            val radiusPx = measuredWidth / 2f - thicknessPx
            Math.toDegrees(2 * asin(view.measuredWidth / radiusPx / 2.0)).toFloat()
        }
    }

    private fun getChildTopInset(child: View): Int {
        val childLayoutParams: LayoutParams = child.getLayoutParams() as LayoutParams

        val thicknessDiffPx: Int = thicknessPx - child.getMeasuredHeight()

        return when (childLayoutParams.verticalAlignment) {
            LayoutParams.VALIGN_OUTER -> 0
            LayoutParams.VALIGN_CENTER -> (thicknessDiffPx / 2f).roundToInt()
            LayoutParams.VALIGN_INNER -> thicknessDiffPx
            else -> 0 // Normally unreachable
        }
    }

    override fun checkLayoutParams(p: ViewGroup.LayoutParams): Boolean = p is LayoutParams
    override fun generateLayoutParams(p: ViewGroup.LayoutParams): LayoutParams = LayoutParams(p)
    override fun generateLayoutParams(attrs: AttributeSet): LayoutParams = LayoutParams(
        context, attrs
    )
    override fun generateDefaultLayoutParams(): LayoutParams = LayoutParams(
        ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
    )

    public companion object {
        /**
         * Anchor at the start of the set of elements drawn within this container. This causes
         * the first child to be drawn from `anchorAngle` degrees, to the right.
         *
         * As an example, if this container contains two arcs, one having 10 degrees of sweep and
         * the other having 20 degrees of sweep, the first will be drawn between 0-10 degrees,
         * and the second between 10-30 degrees.
         */
        public const val ANCHOR_START: Int = 0

        /**
         * Anchor at the center of the set of elements drawn within this container.
         *
         *
         * As an example, if this container contains two arcs, one having 10 degrees of sweep and
         * the other having 20 degrees of sweep, the first will be drawn between -15 and -5
         * degrees, and the second between -5 and 15 degrees.
         */
        public const val ANCHOR_CENTER: Int = 1

        /**
         * Anchor at the end of the set of elements drawn within this container. This causes the
         * last element to end at `anchorAngle` degrees, with the other elements swept to the left.
         *
         *
         * As an example, if this container contains two arcs, one having 10 degrees of sweep and
         * the other having 20 degrees of sweep, the first will be drawn between -30 and -20
         * degrees, and the second between -20 and 0 degrees.
         */
        public const val ANCHOR_END: Int = 2

        /** Annotation for anchor types.  */
        @IntDef(ANCHOR_START, ANCHOR_CENTER, ANCHOR_END)
        internal annotation class AnchorType

        private const val DEFAULT_LAYOUT_DIRECTION_IS_CLOCKWISE = true // clockwise
        private const val DEFAULT_START_ANGLE_DEGREES = 0f

        @AnchorType
        private val DEFAULT_ANCHOR_TYPE = ANCHOR_START
    }

    init {
        val a = context.obtainStyledAttributes(
            attrs, R.styleable.WearArcLayout, defStyleAttr, defStyleRes
        )
        anchorType = a.getInt(R.styleable.WearArcLayout_anchorPosition, DEFAULT_ANCHOR_TYPE)
        anchorAngleDegrees =
            a.getFloat(R.styleable.WearArcLayout_anchorAngleDegrees, DEFAULT_START_ANGLE_DEGREES)
        clockwise = a.getBoolean(
            R.styleable.WearArcLayout_clockwise, DEFAULT_LAYOUT_DIRECTION_IS_CLOCKWISE
        )
        a.recycle()
    }
}