1/*
2 * Copyright (C) 2014 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 */
16package android.transition;
17
18import android.content.Context;
19import android.content.res.TypedArray;
20import android.graphics.Path;
21import android.util.AttributeSet;
22
23import com.android.internal.R;
24
25/**
26 * A PathMotion that generates a curved path along an arc on an imaginary circle containing
27 * the two points. If the horizontal distance between the points is less than the vertical
28 * distance, then the circle's center point will be horizontally aligned with the end point. If the
29 * vertical distance is less than the horizontal distance then the circle's center point
30 * will be vertically aligned with the end point.
31 * <p>
32 * When the two points are near horizontal or vertical, the curve of the motion will be
33 * small as the center of the circle will be far from both points. To force curvature of
34 * the path, {@link #setMinimumHorizontalAngle(float)} and
35 * {@link #setMinimumVerticalAngle(float)} may be used to set the minimum angle of the
36 * arc between two points.
37 * </p>
38 * <p>This may be used in XML as an element inside a transition.</p>
39 * <pre>{@code
40 * <changeBounds>
41 *   <arcMotion android:minimumHorizontalAngle="15"
42 *              android:minimumVerticalAngle="0"
43 *              android:maximumAngle="90"/>
44 * </changeBounds>}
45 * </pre>
46 */
47public class ArcMotion extends PathMotion {
48
49    private static final float DEFAULT_MIN_ANGLE_DEGREES = 0;
50    private static final float DEFAULT_MAX_ANGLE_DEGREES = 70;
51    private static final float DEFAULT_MAX_TANGENT = (float)
52            Math.tan(Math.toRadians(DEFAULT_MAX_ANGLE_DEGREES/2));
53
54    private float mMinimumHorizontalAngle = 0;
55    private float mMinimumVerticalAngle = 0;
56    private float mMaximumAngle = DEFAULT_MAX_ANGLE_DEGREES;
57    private float mMinimumHorizontalTangent = 0;
58    private float mMinimumVerticalTangent = 0;
59    private float mMaximumTangent = DEFAULT_MAX_TANGENT;
60
61    public ArcMotion() {}
62
63    public ArcMotion(Context context, AttributeSet attrs) {
64        super(context, attrs);
65        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ArcMotion);
66        float minimumVerticalAngle = a.getFloat(R.styleable.ArcMotion_minimumVerticalAngle,
67                DEFAULT_MIN_ANGLE_DEGREES);
68        setMinimumVerticalAngle(minimumVerticalAngle);
69        float minimumHorizontalAngle = a.getFloat(R.styleable.ArcMotion_minimumHorizontalAngle,
70                DEFAULT_MIN_ANGLE_DEGREES);
71        setMinimumHorizontalAngle(minimumHorizontalAngle);
72        float maximumAngle = a.getFloat(R.styleable.ArcMotion_maximumAngle,
73                DEFAULT_MAX_ANGLE_DEGREES);
74        setMaximumAngle(maximumAngle);
75        a.recycle();
76    }
77
78    /**
79     * Sets the minimum arc along the circle between two points aligned near horizontally.
80     * When start and end points are close to horizontal, the calculated center point of the
81     * circle will be far from both points, giving a near straight path between the points.
82     * By setting a minimum angle, this forces the center point to be closer and give an
83     * exaggerated curve to the path.
84     * <p>The default value is 0.</p>
85     *
86     * @param angleInDegrees The minimum angle of the arc on a circle describing the Path
87     *                       between two nearly horizontally-separated points.
88     * @attr ref android.R.styleable#ArcMotion_minimumHorizontalAngle
89     */
90    public void setMinimumHorizontalAngle(float angleInDegrees) {
91        mMinimumHorizontalAngle = angleInDegrees;
92        mMinimumHorizontalTangent = toTangent(angleInDegrees);
93    }
94
95    /**
96     * Returns the minimum arc along the circle between two points aligned near horizontally.
97     * When start and end points are close to horizontal, the calculated center point of the
98     * circle will be far from both points, giving a near straight path between the points.
99     * By setting a minimum angle, this forces the center point to be closer and give an
100     * exaggerated curve to the path.
101     * <p>The default value is 0.</p>
102     *
103     * @return  The minimum arc along the circle between two points aligned near horizontally.
104     * @attr ref android.R.styleable#ArcMotion_minimumHorizontalAngle
105     */
106    public float getMinimumHorizontalAngle() {
107        return mMinimumHorizontalAngle;
108    }
109
110    /**
111     * Sets the minimum arc along the circle between two points aligned near vertically.
112     * When start and end points are close to vertical, the calculated center point of the
113     * circle will be far from both points, giving a near straight path between the points.
114     * By setting a minimum angle, this forces the center point to be closer and give an
115     * exaggerated curve to the path.
116     * <p>The default value is 0.</p>
117     *
118     * @param angleInDegrees The minimum angle of the arc on a circle describing the Path
119     *                       between two nearly vertically-separated points.
120     * @attr ref android.R.styleable#ArcMotion_minimumVerticalAngle
121     */
122    public void setMinimumVerticalAngle(float angleInDegrees) {
123        mMinimumVerticalAngle = angleInDegrees;
124        mMinimumVerticalTangent = toTangent(angleInDegrees);
125    }
126
127    /**
128     * Returns the minimum arc along the circle between two points aligned near vertically.
129     * When start and end points are close to vertical, the calculated center point of the
130     * circle will be far from both points, giving a near straight path between the points.
131     * By setting a minimum angle, this forces the center point to be closer and give an
132     * exaggerated curve to the path.
133     * <p>The default value is 0.</p>
134     *
135     * @return The minimum angle of the arc on a circle describing the Path
136     *         between two nearly vertically-separated points.
137     * @attr ref android.R.styleable#ArcMotion_minimumVerticalAngle
138     */
139    public float getMinimumVerticalAngle() {
140        return mMinimumVerticalAngle;
141    }
142
143    /**
144     * Sets the maximum arc along the circle between two points. When start and end points
145     * have close to equal x and y differences, the curve between them is large. This forces
146     * the curved path to have an arc of at most the given angle.
147     * <p>The default value is 70 degrees.</p>
148     *
149     * @param angleInDegrees The maximum angle of the arc on a circle describing the Path
150     *                       between the start and end points.
151     * @attr ref android.R.styleable#ArcMotion_maximumAngle
152     */
153    public void setMaximumAngle(float angleInDegrees) {
154        mMaximumAngle = angleInDegrees;
155        mMaximumTangent = toTangent(angleInDegrees);
156    }
157
158    /**
159     * Returns the maximum arc along the circle between two points. When start and end points
160     * have close to equal x and y differences, the curve between them is large. This forces
161     * the curved path to have an arc of at most the given angle.
162     * <p>The default value is 70 degrees.</p>
163     *
164     * @return The maximum angle of the arc on a circle describing the Path
165     *         between the start and end points.
166     * @attr ref android.R.styleable#ArcMotion_maximumAngle
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        if (startY == endY) {
203            ex = (startX + endX) / 2;
204            ey = startY + mMinimumHorizontalTangent * Math.abs(endX - startX) / 2;
205        } else if (startX == endX) {
206            ex = startX + mMinimumVerticalTangent * Math.abs(endY - startY) / 2;
207            ey = (startY + endY) / 2;
208        } else {
209            float deltaX = endX - startX;
210            float deltaY = endY - startY;
211            // hypotenuse squared.
212            float h2 = deltaX * deltaX + deltaY * deltaY;
213
214            // Midpoint between start and end
215            float dx = (startX + endX) / 2;
216            float dy = (startY + endY) / 2;
217
218            // Distance squared between end point and mid point is (1/2 hypotenuse)^2
219            float midDist2 = h2 * 0.25f;
220
221            float minimumArcDist2 = 0;
222            boolean isQuadrant1Or3 = (deltaX * deltaY) > 0;
223
224            if ((Math.abs(deltaX) < Math.abs(deltaY))) {
225                // Similar triangles bfa and bde mean that (ab/fb = eb/bd)
226                // Therefore, eb = ab * bd / fb
227                // ab = hypotenuse
228                // bd = hypotenuse/2
229                // fb = deltaY
230                float eDistY = h2 / (2 * deltaY);
231                if (isQuadrant1Or3) {
232                    ey = startY + eDistY;
233                    ex = startX;
234                } else {
235                    ey = endY - eDistY;
236                    ex = endX;
237                }
238
239                minimumArcDist2 = midDist2 * mMinimumVerticalTangent
240                        * mMinimumVerticalTangent;
241            } else {
242                // Same as above, but flip X & Y
243                float eDistX = h2 / (2 * deltaX);
244                if (isQuadrant1Or3) {
245                    ex = endX - eDistX;
246                    ey = endY;
247                } else {
248                    ex = startX + eDistX;
249                    ey = startY;
250                }
251
252                minimumArcDist2 = midDist2 * mMinimumHorizontalTangent
253                        * mMinimumHorizontalTangent;
254            }
255            float arcDistX = dx - ex;
256            float arcDistY = dy - ey;
257            float arcDist2 = arcDistX * arcDistX + arcDistY * arcDistY;
258
259            float maximumArcDist2 = midDist2 * mMaximumTangent * mMaximumTangent;
260
261            float newArcDistance2 = 0;
262            if (arcDist2 < minimumArcDist2) {
263                newArcDistance2 = minimumArcDist2;
264            } else if (arcDist2 > maximumArcDist2) {
265                newArcDistance2 = maximumArcDist2;
266            }
267            if (newArcDistance2 != 0) {
268                float ratio2 = newArcDistance2 / arcDist2;
269                float ratio = (float) Math.sqrt(ratio2);
270                ex = dx + (ratio * (ex - dx));
271                ey = dy + (ratio * (ey - dy));
272            }
273        }
274        float controlX1 = (startX + ex) / 2;
275        float controlY1 = (startY + ey) / 2;
276        float controlX2 = (ex + endX) / 2;
277        float controlY2 = (ey + endY) / 2;
278        path.cubicTo(controlX1, controlY1, controlX2, controlY2, endX, endY);
279        return path;
280    }
281}
282