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