ArcMotion.java

/*
 * Copyright (C) 2017 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.transition;

import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Path;
import android.util.AttributeSet;

import androidx.core.content.res.TypedArrayUtils;

import org.xmlpull.v1.XmlPullParser;

/**
 * A PathMotion that generates a curved path along an arc on an imaginary circle containing
 * the two points. If the horizontal distance between the points is less than the vertical
 * distance, then the circle's center point will be horizontally aligned with the end point. If the
 * vertical distance is less than the horizontal distance then the circle's center point
 * will be vertically aligned with the end point.
 * <p>
 * When the two points are near horizontal or vertical, the curve of the motion will be
 * small as the center of the circle will be far from both points. To force curvature of
 * the path, {@link #setMinimumHorizontalAngle(float)} and
 * {@link #setMinimumVerticalAngle(float)} may be used to set the minimum angle of the
 * arc between two points.
 * </p>
 * <p>This may be used in XML as an element inside a transition.</p>
 * <pre>{@code
 * <changeBounds>
 *   <arcMotion android:minimumHorizontalAngle="15"
 *              android:minimumVerticalAngle="0"
 *              android:maximumAngle="90"/>
 * </changeBounds>}
 * </pre>
 */
public class ArcMotion extends PathMotion {

    private static final float DEFAULT_MIN_ANGLE_DEGREES = 0;
    private static final float DEFAULT_MAX_ANGLE_DEGREES = 70;
    private static final float DEFAULT_MAX_TANGENT = (float)
            Math.tan(Math.toRadians(DEFAULT_MAX_ANGLE_DEGREES / 2));

    private float mMinimumHorizontalAngle = 0;
    private float mMinimumVerticalAngle = 0;
    private float mMaximumAngle = DEFAULT_MAX_ANGLE_DEGREES;
    private float mMinimumHorizontalTangent = 0;
    private float mMinimumVerticalTangent = 0;
    private float mMaximumTangent = DEFAULT_MAX_TANGENT;

    public ArcMotion() {
    }

    @SuppressLint("RestrictedApi") // remove once core lib would be released with the new
    // LIBRARY_GROUP_PREFIX restriction. tracking in b/127286008
    public ArcMotion(Context context, AttributeSet attrs) {
        super(context, attrs);
        TypedArray a = context.obtainStyledAttributes(attrs, Styleable.ARC_MOTION);
        XmlPullParser parser = (XmlPullParser) attrs;
        float minimumVerticalAngle = TypedArrayUtils.getNamedFloat(a, parser,
                "minimumVerticalAngle", Styleable.ArcMotion.MINIMUM_VERTICAL_ANGLE,
                DEFAULT_MIN_ANGLE_DEGREES);
        setMinimumVerticalAngle(minimumVerticalAngle);
        float minimumHorizontalAngle = TypedArrayUtils.getNamedFloat(a, parser,
                "minimumHorizontalAngle", Styleable.ArcMotion.MINIMUM_HORIZONTAL_ANGLE,
                DEFAULT_MIN_ANGLE_DEGREES);
        setMinimumHorizontalAngle(minimumHorizontalAngle);
        float maximumAngle = TypedArrayUtils.getNamedFloat(a, parser, "maximumAngle",
                Styleable.ArcMotion.MAXIMUM_ANGLE, DEFAULT_MAX_ANGLE_DEGREES);
        setMaximumAngle(maximumAngle);
        a.recycle();
    }

    /**
     * Sets the minimum arc along the circle between two points aligned near horizontally.
     * When start and end points are close to horizontal, the calculated center point of the
     * circle will be far from both points, giving a near straight path between the points.
     * By setting a minimum angle, this forces the center point to be closer and give an
     * exaggerated curve to the path.
     * <p>The default value is 0.</p>
     *
     * @param angleInDegrees The minimum angle of the arc on a circle describing the Path
     *                       between two nearly horizontally-separated points.
     */
    public void setMinimumHorizontalAngle(float angleInDegrees) {
        mMinimumHorizontalAngle = angleInDegrees;
        mMinimumHorizontalTangent = toTangent(angleInDegrees);
    }

    /**
     * Returns the minimum arc along the circle between two points aligned near horizontally.
     * When start and end points are close to horizontal, the calculated center point of the
     * circle will be far from both points, giving a near straight path between the points.
     * By setting a minimum angle, this forces the center point to be closer and give an
     * exaggerated curve to the path.
     * <p>The default value is 0.</p>
     *
     * @return The minimum arc along the circle between two points aligned near horizontally.
     */
    public float getMinimumHorizontalAngle() {
        return mMinimumHorizontalAngle;
    }

    /**
     * Sets the minimum arc along the circle between two points aligned near vertically.
     * When start and end points are close to vertical, the calculated center point of the
     * circle will be far from both points, giving a near straight path between the points.
     * By setting a minimum angle, this forces the center point to be closer and give an
     * exaggerated curve to the path.
     * <p>The default value is 0.</p>
     *
     * @param angleInDegrees The minimum angle of the arc on a circle describing the Path
     *                       between two nearly vertically-separated points.
     */
    public void setMinimumVerticalAngle(float angleInDegrees) {
        mMinimumVerticalAngle = angleInDegrees;
        mMinimumVerticalTangent = toTangent(angleInDegrees);
    }

    /**
     * Returns the minimum arc along the circle between two points aligned near vertically.
     * When start and end points are close to vertical, the calculated center point of the
     * circle will be far from both points, giving a near straight path between the points.
     * By setting a minimum angle, this forces the center point to be closer and give an
     * exaggerated curve to the path.
     * <p>The default value is 0.</p>
     *
     * @return The minimum angle of the arc on a circle describing the Path
     * between two nearly vertically-separated points.
     */
    public float getMinimumVerticalAngle() {
        return mMinimumVerticalAngle;
    }

    /**
     * Sets the maximum arc along the circle between two points. When start and end points
     * have close to equal x and y differences, the curve between them is large. This forces
     * the curved path to have an arc of at most the given angle.
     * <p>The default value is 70 degrees.</p>
     *
     * @param angleInDegrees The maximum angle of the arc on a circle describing the Path
     *                       between the start and end points.
     */
    public void setMaximumAngle(float angleInDegrees) {
        mMaximumAngle = angleInDegrees;
        mMaximumTangent = toTangent(angleInDegrees);
    }

    /**
     * Returns the maximum arc along the circle between two points. When start and end points
     * have close to equal x and y differences, the curve between them is large. This forces
     * the curved path to have an arc of at most the given angle.
     * <p>The default value is 70 degrees.</p>
     *
     * @return The maximum angle of the arc on a circle describing the Path
     * between the start and end points.
     */
    public float getMaximumAngle() {
        return mMaximumAngle;
    }

    private static float toTangent(float arcInDegrees) {
        if (arcInDegrees < 0 || arcInDegrees > 90) {
            throw new IllegalArgumentException("Arc must be between 0 and 90 degrees");
        }
        return (float) Math.tan(Math.toRadians(arcInDegrees / 2));
    }

    @Override
    public Path getPath(float startX, float startY, float endX, float endY) {
        // Here's a little ascii art to show how this is calculated:
        // c---------- b
        //  \        / |
        //    \     d  |
        //      \  /   e
        //        a----f
        // This diagram assumes that the horizontal distance is less than the vertical
        // distance between The start point (a) and end point (b).
        // d is the midpoint between a and b. c is the center point of the circle with
        // This path is formed by assuming that start and end points are in
        // an arc on a circle. The end point is centered in the circle vertically
        // and start is a point on the circle.

        // Triangles bfa and bde form similar right triangles. The control points
        // for the cubic Bezier arc path are the midpoints between a and e and e and b.

        Path path = new Path();
        path.moveTo(startX, startY);

        float ex;
        float ey;
        float deltaX = endX - startX;
        float deltaY = endY - startY;

        // hypotenuse squared.
        float h2 = deltaX * deltaX + deltaY * deltaY;

        // Midpoint between start and end
        float dx = (startX + endX) / 2;
        float dy = (startY + endY) / 2;

        // Distance squared between end point and mid point is (1/2 hypotenuse)^2
        float midDist2 = h2 * 0.25f;

        float minimumArcDist2;

        boolean isMovingUpwards = startY > endY;

        if ((Math.abs(deltaX) < Math.abs(deltaY))) {
            // Similar triangles bfa and bde mean that (ab/fb = eb/bd)
            // Therefore, eb = ab * bd / fb
            // ab = hypotenuse
            // bd = hypotenuse/2
            // fb = deltaY
            float eDistY = Math.abs(h2 / (2 * deltaY));
            if (isMovingUpwards) {
                ey = endY + eDistY;
                ex = endX;
            } else {
                ey = startY + eDistY;
                ex = startX;
            }

            minimumArcDist2 = midDist2 * mMinimumVerticalTangent
                    * mMinimumVerticalTangent;
        } else {
            // Same as above, but flip X & Y and account for negative eDist
            float eDistX = h2 / (2 * deltaX);
            if (isMovingUpwards) {
                ex = startX + eDistX;
                ey = startY;
            } else {
                ex = endX - eDistX;
                ey = endY;
            }

            minimumArcDist2 = midDist2 * mMinimumHorizontalTangent
                    * mMinimumHorizontalTangent;
        }
        float arcDistX = dx - ex;
        float arcDistY = dy - ey;
        float arcDist2 = arcDistX * arcDistX + arcDistY * arcDistY;

        float maximumArcDist2 = midDist2 * mMaximumTangent * mMaximumTangent;

        float newArcDistance2 = 0;
        if (arcDist2 < minimumArcDist2) {
            newArcDistance2 = minimumArcDist2;
        } else if (arcDist2 > maximumArcDist2) {
            newArcDistance2 = maximumArcDist2;
        }
        if (newArcDistance2 != 0) {
            float ratio2 = newArcDistance2 / arcDist2;
            float ratio = (float) Math.sqrt(ratio2);
            ex = dx + (ratio * (ex - dx));
            ey = dy + (ratio * (ey - dy));
        }
        float control1X = (startX + ex) / 2;
        float control1Y = (startY + ey) / 2;
        float control2X = (ex + endX) / 2;
        float control2Y = (ey + endY) / 2;
        path.cubicTo(control1X, control1Y, control2X, control2Y, endX, endY);
        return path;
    }

}