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