1/*
2 * Copyright (C) 2017 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package androidx.transition;
18
19import android.content.Context;
20import android.content.res.TypedArray;
21import android.graphics.Path;
22import android.util.AttributeSet;
23
24import androidx.core.content.res.TypedArrayUtils;
25
26import org.xmlpull.v1.XmlPullParser;
27
28/**
29 * A PathMotion that generates a curved path along an arc on an imaginary circle containing
30 * the two points. If the horizontal distance between the points is less than the vertical
31 * distance, then the circle's center point will be horizontally aligned with the end point. If the
32 * vertical distance is less than the horizontal distance then the circle's center point
33 * will be vertically aligned with the end point.
34 * <p>
35 * When the two points are near horizontal or vertical, the curve of the motion will be
36 * small as the center of the circle will be far from both points. To force curvature of
37 * the path, {@link #setMinimumHorizontalAngle(float)} and
38 * {@link #setMinimumVerticalAngle(float)} may be used to set the minimum angle of the
39 * arc between two points.
40 * </p>
41 * <p>This may be used in XML as an element inside a transition.</p>
42 * <pre>{@code
43 * <changeBounds>
44 *   <arcMotion android:minimumHorizontalAngle="15"
45 *              android:minimumVerticalAngle="0"
46 *              android:maximumAngle="90"/>
47 * </changeBounds>}
48 * </pre>
49 */
50public class ArcMotion extends PathMotion {
51
52    private static final float DEFAULT_MIN_ANGLE_DEGREES = 0;
53    private static final float DEFAULT_MAX_ANGLE_DEGREES = 70;
54    private static final float DEFAULT_MAX_TANGENT = (float)
55            Math.tan(Math.toRadians(DEFAULT_MAX_ANGLE_DEGREES / 2));
56
57    private float mMinimumHorizontalAngle = 0;
58    private float mMinimumVerticalAngle = 0;
59    private float mMaximumAngle = DEFAULT_MAX_ANGLE_DEGREES;
60    private float mMinimumHorizontalTangent = 0;
61    private float mMinimumVerticalTangent = 0;
62    private float mMaximumTangent = DEFAULT_MAX_TANGENT;
63
64    public ArcMotion() {
65    }
66
67    public ArcMotion(Context context, AttributeSet attrs) {
68        super(context, attrs);
69        TypedArray a = context.obtainStyledAttributes(attrs, Styleable.ARC_MOTION);
70        XmlPullParser parser = (XmlPullParser) attrs;
71        float minimumVerticalAngle = TypedArrayUtils.getNamedFloat(a, parser,
72                "minimumVerticalAngle", Styleable.ArcMotion.MINIMUM_VERTICAL_ANGLE,
73                DEFAULT_MIN_ANGLE_DEGREES);
74        setMinimumVerticalAngle(minimumVerticalAngle);
75        float minimumHorizontalAngle = TypedArrayUtils.getNamedFloat(a, parser,
76                "minimumHorizontalAngle", Styleable.ArcMotion.MINIMUM_HORIZONTAL_ANGLE,
77                DEFAULT_MIN_ANGLE_DEGREES);
78        setMinimumHorizontalAngle(minimumHorizontalAngle);
79        float maximumAngle = TypedArrayUtils.getNamedFloat(a, parser, "maximumAngle",
80                Styleable.ArcMotion.MAXIMUM_ANGLE, DEFAULT_MAX_ANGLE_DEGREES);
81        setMaximumAngle(maximumAngle);
82        a.recycle();
83    }
84
85    /**
86     * Sets the minimum arc along the circle between two points aligned near horizontally.
87     * When start and end points are close to horizontal, the calculated center point of the
88     * circle will be far from both points, giving a near straight path between the points.
89     * By setting a minimum angle, this forces the center point to be closer and give an
90     * exaggerated curve to the path.
91     * <p>The default value is 0.</p>
92     *
93     * @param angleInDegrees The minimum angle of the arc on a circle describing the Path
94     *                       between two nearly horizontally-separated points.
95     */
96    public void setMinimumHorizontalAngle(float angleInDegrees) {
97        mMinimumHorizontalAngle = angleInDegrees;
98        mMinimumHorizontalTangent = toTangent(angleInDegrees);
99    }
100
101    /**
102     * Returns the minimum arc along the circle between two points aligned near horizontally.
103     * When start and end points are close to horizontal, the calculated center point of the
104     * circle will be far from both points, giving a near straight path between the points.
105     * By setting a minimum angle, this forces the center point to be closer and give an
106     * exaggerated curve to the path.
107     * <p>The default value is 0.</p>
108     *
109     * @return The minimum arc along the circle between two points aligned near horizontally.
110     */
111    public float getMinimumHorizontalAngle() {
112        return mMinimumHorizontalAngle;
113    }
114
115    /**
116     * Sets the minimum arc along the circle between two points aligned near vertically.
117     * When start and end points are close to vertical, the calculated center point of the
118     * circle will be far from both points, giving a near straight path between the points.
119     * By setting a minimum angle, this forces the center point to be closer and give an
120     * exaggerated curve to the path.
121     * <p>The default value is 0.</p>
122     *
123     * @param angleInDegrees The minimum angle of the arc on a circle describing the Path
124     *                       between two nearly vertically-separated points.
125     */
126    public void setMinimumVerticalAngle(float angleInDegrees) {
127        mMinimumVerticalAngle = angleInDegrees;
128        mMinimumVerticalTangent = toTangent(angleInDegrees);
129    }
130
131    /**
132     * Returns the minimum arc along the circle between two points aligned near vertically.
133     * When start and end points are close to vertical, the calculated center point of the
134     * circle will be far from both points, giving a near straight path between the points.
135     * By setting a minimum angle, this forces the center point to be closer and give an
136     * exaggerated curve to the path.
137     * <p>The default value is 0.</p>
138     *
139     * @return The minimum angle of the arc on a circle describing the Path
140     * between two nearly vertically-separated points.
141     */
142    public float getMinimumVerticalAngle() {
143        return mMinimumVerticalAngle;
144    }
145
146    /**
147     * Sets the maximum arc along the circle between two points. When start and end points
148     * have close to equal x and y differences, the curve between them is large. This forces
149     * the curved path to have an arc of at most the given angle.
150     * <p>The default value is 70 degrees.</p>
151     *
152     * @param angleInDegrees The maximum angle of the arc on a circle describing the Path
153     *                       between the start and end points.
154     */
155    public void setMaximumAngle(float angleInDegrees) {
156        mMaximumAngle = angleInDegrees;
157        mMaximumTangent = toTangent(angleInDegrees);
158    }
159
160    /**
161     * Returns the maximum arc along the circle between two points. When start and end points
162     * have close to equal x and y differences, the curve between them is large. This forces
163     * the curved path to have an arc of at most the given angle.
164     * <p>The default value is 70 degrees.</p>
165     *
166     * @return The maximum angle of the arc on a circle describing the Path
167     * between the start and end points.
168     */
169    public float getMaximumAngle() {
170        return mMaximumAngle;
171    }
172
173    private static float toTangent(float arcInDegrees) {
174        if (arcInDegrees < 0 || arcInDegrees > 90) {
175            throw new IllegalArgumentException("Arc must be between 0 and 90 degrees");
176        }
177        return (float) Math.tan(Math.toRadians(arcInDegrees / 2));
178    }
179
180    @Override
181    public Path getPath(float startX, float startY, float endX, float endY) {
182        // Here's a little ascii art to show how this is calculated:
183        // c---------- b
184        //  \        / |
185        //    \     d  |
186        //      \  /   e
187        //        a----f
188        // This diagram assumes that the horizontal distance is less than the vertical
189        // distance between The start point (a) and end point (b).
190        // d is the midpoint between a and b. c is the center point of the circle with
191        // This path is formed by assuming that start and end points are in
192        // an arc on a circle. The end point is centered in the circle vertically
193        // and start is a point on the circle.
194
195        // Triangles bfa and bde form similar right triangles. The control points
196        // for the cubic Bezier arc path are the midpoints between a and e and e and b.
197
198        Path path = new Path();
199        path.moveTo(startX, startY);
200
201        float ex;
202        float ey;
203        float deltaX = endX - startX;
204        float deltaY = endY - startY;
205
206        // hypotenuse squared.
207        float h2 = deltaX * deltaX + deltaY * deltaY;
208
209        // Midpoint between start and end
210        float dx = (startX + endX) / 2;
211        float dy = (startY + endY) / 2;
212
213        // Distance squared between end point and mid point is (1/2 hypotenuse)^2
214        float midDist2 = h2 * 0.25f;
215
216        float minimumArcDist2;
217
218        boolean isMovingUpwards = startY > endY;
219
220        if ((Math.abs(deltaX) < Math.abs(deltaY))) {
221            // Similar triangles bfa and bde mean that (ab/fb = eb/bd)
222            // Therefore, eb = ab * bd / fb
223            // ab = hypotenuse
224            // bd = hypotenuse/2
225            // fb = deltaY
226            float eDistY = Math.abs(h2 / (2 * deltaY));
227            if (isMovingUpwards) {
228                ey = endY + eDistY;
229                ex = endX;
230            } else {
231                ey = startY + eDistY;
232                ex = startX;
233            }
234
235            minimumArcDist2 = midDist2 * mMinimumVerticalTangent
236                    * mMinimumVerticalTangent;
237        } else {
238            // Same as above, but flip X & Y and account for negative eDist
239            float eDistX = h2 / (2 * deltaX);
240            if (isMovingUpwards) {
241                ex = startX + eDistX;
242                ey = startY;
243            } else {
244                ex = endX - eDistX;
245                ey = endY;
246            }
247
248            minimumArcDist2 = midDist2 * mMinimumHorizontalTangent
249                    * mMinimumHorizontalTangent;
250        }
251        float arcDistX = dx - ex;
252        float arcDistY = dy - ey;
253        float arcDist2 = arcDistX * arcDistX + arcDistY * arcDistY;
254
255        float maximumArcDist2 = midDist2 * mMaximumTangent * mMaximumTangent;
256
257        float newArcDistance2 = 0;
258        if (arcDist2 < minimumArcDist2) {
259            newArcDistance2 = minimumArcDist2;
260        } else if (arcDist2 > maximumArcDist2) {
261            newArcDistance2 = maximumArcDist2;
262        }
263        if (newArcDistance2 != 0) {
264            float ratio2 = newArcDistance2 / arcDist2;
265            float ratio = (float) Math.sqrt(ratio2);
266            ex = dx + (ratio * (ex - dx));
267            ey = dy + (ratio * (ey - dy));
268        }
269        float control1X = (startX + ex) / 2;
270        float control1Y = (startY + ey) / 2;
271        float control2X = (ex + endX) / 2;
272        float control2Y = (ey + endY) / 2;
273        path.cubicTo(control1X, control1Y, control2X, control2Y, endX, endY);
274        return path;
275    }
276
277}
278