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