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