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  */
16 package android.transition;
17 
18 import android.content.Context;
19 import android.content.res.TypedArray;
20 import android.graphics.Path;
21 import android.util.AttributeSet;
22 
23 import 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  */
47 public 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 
ArcMotion()61     public ArcMotion() {}
62 
ArcMotion(Context context, AttributeSet attrs)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      */
setMinimumHorizontalAngle(float angleInDegrees)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      */
getMinimumHorizontalAngle()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      */
setMinimumVerticalAngle(float angleInDegrees)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      */
getMinimumVerticalAngle()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      */
setMaximumAngle(float angleInDegrees)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      */
getMaximumAngle()168     public float getMaximumAngle() {
169         return mMaximumAngle;
170     }
171 
toTangent(float arcInDegrees)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
getPath(float startX, float startY, float endX, float endY)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 = 0;
216 
217         boolean isMovingUpwards = startY > endY;
218 
219         if (deltaY == 0) {
220             ex = dx;
221             ey = dy + (Math.abs(deltaX) * 0.5f * mMinimumHorizontalTangent);
222         } else if (deltaX == 0) {
223             ex = dx + (Math.abs(deltaY) * 0.5f * mMinimumVerticalTangent);
224             ey = dy;
225         } else if ((Math.abs(deltaX) < Math.abs(deltaY))) {
226             // Similar triangles bfa and bde mean that (ab/fb = eb/bd)
227             // Therefore, eb = ab * bd / fb
228             // ab = hypotenuse
229             // bd = hypotenuse/2
230             // fb = deltaY
231             float eDistY = Math.abs(h2 / (2 * deltaY));
232             if (isMovingUpwards) {
233                 ey = endY + eDistY;
234                 ex = endX;
235             } else {
236                 ey = startY + eDistY;
237                 ex = startX;
238             }
239 
240             minimumArcDist2 = midDist2 * mMinimumVerticalTangent
241                     * mMinimumVerticalTangent;
242         } else {
243             // Same as above, but flip X & Y and account for negative eDist
244             float eDistX = h2 / (2 * deltaX);
245             if (isMovingUpwards) {
246                 ex = startX + eDistX;
247                 ey = startY;
248             } else {
249                 ex = endX - eDistX;
250                 ey = endY;
251             }
252 
253             minimumArcDist2 = midDist2 * mMinimumHorizontalTangent
254                     * mMinimumHorizontalTangent;
255         }
256         float arcDistX = dx - ex;
257         float arcDistY = dy - ey;
258         float arcDist2 = arcDistX * arcDistX + arcDistY * arcDistY;
259 
260         float maximumArcDist2 = midDist2 * mMaximumTangent * mMaximumTangent;
261 
262         float newArcDistance2 = 0;
263         if (arcDist2 != 0 && arcDist2 < minimumArcDist2) {
264             newArcDistance2 = minimumArcDist2;
265         } else if (arcDist2 > maximumArcDist2) {
266             newArcDistance2 = maximumArcDist2;
267         }
268         if (newArcDistance2 != 0) {
269             float ratio2 = newArcDistance2 / arcDist2;
270             float ratio = (float) Math.sqrt(ratio2);
271             ex = dx + (ratio * (ex - dx));
272             ey = dy + (ratio * (ey - dy));
273         }
274         float control1X = (startX + ex) / 2;
275         float control1Y = (startY + ey) / 2;
276         float control2X = (ex + endX) / 2;
277         float control2Y = (ey + endY) / 2;
278         path.cubicTo(control1X, control1Y, control2X, control2Y, endX, endY);
279         return path;
280     }
281 }
282