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 com.android.internal.R;
19
20import android.content.Context;
21import android.content.res.TypedArray;
22import android.graphics.Path;
23import android.util.AttributeSet;
24import android.util.FloatMath;
25
26/**
27 * A PathMotion that generates a curved path along an arc on an imaginary circle containing
28 * the two points. If the horizontal distance between the points is less than the vertical
29 * distance, then the circle's center point will be horizontally aligned with the end point. If the
30 * vertical distance is less than the horizontal distance then the circle's center point
31 * will be vertically aligned with the end point.
32 * <p>
33 * When the two points are near horizontal or vertical, the curve of the motion will be
34 * small as the center of the circle will be far from both points. To force curvature of
35 * the path, {@link #setMinimumHorizontalAngle(float)} and
36 * {@link #setMinimumVerticalAngle(float)} may be used to set the minimum angle of the
37 * arc between two points.
38 * </p>
39 * <p>This may be used in XML as an element inside a transition.</p>
40 * <pre>
41 * {@code
42 * &lt;changeBounds>
43 *   &lt;arcMotion android:minimumHorizontalAngle="15"
44 *              android:minimumVerticalAngle="0"
45 *              android:maximumAngle="90"/>
46 * &lt;/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    public ArcMotion(Context context, AttributeSet attrs) {
66        super(context, attrs);
67        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ArcMotion);
68        float minimumVerticalAngle = a.getFloat(R.styleable.ArcMotion_minimumVerticalAngle,
69                DEFAULT_MIN_ANGLE_DEGREES);
70        setMinimumVerticalAngle(minimumVerticalAngle);
71        float minimumHorizontalAngle = a.getFloat(R.styleable.ArcMotion_minimumHorizontalAngle,
72                DEFAULT_MIN_ANGLE_DEGREES);
73        setMinimumHorizontalAngle(minimumHorizontalAngle);
74        float maximumAngle = a.getFloat(R.styleable.ArcMotion_maximumAngle,
75                DEFAULT_MAX_ANGLE_DEGREES);
76        setMaximumAngle(maximumAngle);
77        a.recycle();
78    }
79
80    /**
81     * Sets the minimum arc along the circle between two points aligned near horizontally.
82     * When start and end points are close to horizontal, the calculated center point of the
83     * circle will be far from both points, giving a near straight path between the points.
84     * By setting a minimum angle, this forces the center point to be closer and give an
85     * exaggerated curve to the path.
86     * <p>The default value is 0.</p>
87     *
88     * @param angleInDegrees The minimum angle of the arc on a circle describing the Path
89     *                       between two nearly horizontally-separated points.
90     * @attr ref android.R.styleable#ArcMotion_minimumHorizontalAngle
91     */
92    public void setMinimumHorizontalAngle(float angleInDegrees) {
93        mMinimumHorizontalAngle = angleInDegrees;
94        mMinimumHorizontalTangent = toTangent(angleInDegrees);
95    }
96
97    /**
98     * Returns the minimum arc along the circle between two points aligned near horizontally.
99     * When start and end points are close to horizontal, the calculated center point of the
100     * circle will be far from both points, giving a near straight path between the points.
101     * By setting a minimum angle, this forces the center point to be closer and give an
102     * exaggerated curve to the path.
103     * <p>The default value is 0.</p>
104     *
105     * @return  The minimum arc along the circle between two points aligned near horizontally.
106     * @attr ref android.R.styleable#ArcMotion_minimumHorizontalAngle
107     */
108    public float getMinimumHorizontalAngle() {
109        return mMinimumHorizontalAngle;
110    }
111
112    /**
113     * Sets the minimum arc along the circle between two points aligned near vertically.
114     * When start and end points are close to vertical, the calculated center point of the
115     * circle will be far from both points, giving a near straight path between the points.
116     * By setting a minimum angle, this forces the center point to be closer and give an
117     * exaggerated curve to the path.
118     * <p>The default value is 0.</p>
119     *
120     * @param angleInDegrees The minimum angle of the arc on a circle describing the Path
121     *                       between two nearly vertically-separated points.
122     * @attr ref android.R.styleable#ArcMotion_minimumVerticalAngle
123     */
124    public void setMinimumVerticalAngle(float angleInDegrees) {
125        mMinimumVerticalAngle = angleInDegrees;
126        mMinimumVerticalTangent = toTangent(angleInDegrees);
127    }
128
129    /**
130     * Returns the minimum arc along the circle between two points aligned near vertically.
131     * When start and end points are close to vertical, the calculated center point of the
132     * circle will be far from both points, giving a near straight path between the points.
133     * By setting a minimum angle, this forces the center point to be closer and give an
134     * exaggerated curve to the path.
135     * <p>The default value is 0.</p>
136     *
137     * @return The minimum angle of the arc on a circle describing the Path
138     *         between two nearly vertically-separated points.
139     * @attr ref android.R.styleable#ArcMotion_minimumVerticalAngle
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     * @attr ref android.R.styleable#ArcMotion_maximumAngle
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     * @attr ref android.R.styleable#ArcMotion_maximumAngle
169     */
170    public float getMaximumAngle() {
171        return mMaximumAngle;
172    }
173
174    private static float toTangent(float arcInDegrees) {
175        if (arcInDegrees < 0 || arcInDegrees > 90) {
176            throw new IllegalArgumentException("Arc must be between 0 and 90 degrees");
177        }
178        return (float) Math.tan(Math.toRadians(arcInDegrees / 2));
179    }
180
181    @Override
182    public Path getPath(float startX, float startY, float endX, float endY) {
183        // Here's a little ascii art to show how this is calculated:
184        // c---------- b
185        //  \        / |
186        //    \     d  |
187        //      \  /   e
188        //        a----f
189        // This diagram assumes that the horizontal distance is less than the vertical
190        // distance between The start point (a) and end point (b).
191        // d is the midpoint between a and b. c is the center point of the circle with
192        // This path is formed by assuming that start and end points are in
193        // an arc on a circle. The end point is centered in the circle vertically
194        // and start is a point on the circle.
195
196        // Triangles bfa and bde form similar right triangles. The control points
197        // for the cubic Bezier arc path are the midpoints between a and e and e and b.
198
199        Path path = new Path();
200        path.moveTo(startX, startY);
201
202        float ex;
203        float ey;
204        if (startY == endY) {
205            ex = (startX + endX) / 2;
206            ey = startY + mMinimumHorizontalTangent * Math.abs(endX - startX) / 2;
207        } else if (startX == endX) {
208            ex = startX + mMinimumVerticalTangent * Math.abs(endY - startY) / 2;
209            ey = (startY + endY) / 2;
210        } else {
211            float deltaX = endX - startX;
212            float deltaY = startY - endY; // Y is inverted compared to diagram above.
213            // hypotenuse squared.
214            float h2 = deltaX * deltaX + deltaY * deltaY;
215
216            // Midpoint between start and end
217            float dx = (startX + endX) / 2;
218            float dy = (startY + endY) / 2;
219
220            // Distance squared between end point and mid point is (1/2 hypotenuse)^2
221            float midDist2 = h2 * 0.25f;
222
223            float minimumArcDist2 = 0;
224
225            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 = h2 / (2 * deltaY);
232                ey = endY + eDistY;
233                ex = endX;
234
235                minimumArcDist2 = midDist2 * mMinimumVerticalTangent
236                        * mMinimumVerticalTangent;
237            } else {
238                // Same as above, but flip X & Y
239                float eDistX = h2 / (2 * deltaX);
240                ex = endX + eDistX;
241                ey = endY;
242
243                minimumArcDist2 = midDist2 * mMinimumHorizontalTangent
244                        * mMinimumHorizontalTangent;
245            }
246            float arcDistX = dx - ex;
247            float arcDistY = dy - ey;
248            float arcDist2 = arcDistX * arcDistX + arcDistY * arcDistY;
249
250            float maximumArcDist2 = midDist2 * mMaximumTangent * mMaximumTangent;
251
252            float newArcDistance2 = 0;
253            if (arcDist2 < minimumArcDist2) {
254                newArcDistance2 = minimumArcDist2;
255            } else if (arcDist2 > maximumArcDist2) {
256                newArcDistance2 = maximumArcDist2;
257            }
258            if (newArcDistance2 != 0) {
259                float ratio2 = newArcDistance2 / arcDist2;
260                float ratio = FloatMath.sqrt(ratio2);
261                ex = dx + (ratio * (ex - dx));
262                ey = dy + (ratio * (ey - dy));
263            }
264        }
265        float controlX1 = (startX + ex) / 2;
266        float controlY1 = (startY + ey) / 2;
267        float controlX2 = (ex + endX) / 2;
268        float controlY2 = (ey + endY) / 2;
269        path.cubicTo(controlX1, controlY1, controlX2, controlY2, endX, endY);
270        return path;
271    }
272}
273