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