1 package org.robolectric.shadows; 2 3 import static android.os.Build.VERSION_CODES.JELLY_BEAN; 4 import static android.os.Build.VERSION_CODES.KITKAT; 5 import static android.os.Build.VERSION_CODES.LOLLIPOP; 6 import static org.robolectric.shadow.api.Shadow.extract; 7 import static org.robolectric.shadows.ShadowPath.Point.Type.LINE_TO; 8 import static org.robolectric.shadows.ShadowPath.Point.Type.MOVE_TO; 9 10 import android.graphics.Matrix; 11 import android.graphics.Path; 12 import android.graphics.Path.Direction; 13 import android.graphics.RectF; 14 import android.util.Log; 15 import java.awt.geom.AffineTransform; 16 import java.awt.geom.Arc2D; 17 import java.awt.geom.Area; 18 import java.awt.geom.Ellipse2D; 19 import java.awt.geom.GeneralPath; 20 import java.awt.geom.Path2D; 21 import java.awt.geom.PathIterator; 22 import java.awt.geom.Point2D; 23 import java.awt.geom.Rectangle2D; 24 import java.awt.geom.RoundRectangle2D; 25 import java.util.ArrayList; 26 import java.util.List; 27 import org.robolectric.annotation.Implementation; 28 import org.robolectric.annotation.Implements; 29 import org.robolectric.annotation.RealObject; 30 31 /** 32 * The shadow only supports straight-line paths. 33 */ 34 @SuppressWarnings({"UnusedDeclaration"}) 35 @Implements(Path.class) 36 public class ShadowPath { 37 private static final String TAG = ShadowPath.class.getSimpleName(); 38 private static final float EPSILON = 1e-4f; 39 40 @RealObject private Path realObject; 41 42 private List<Point> points = new ArrayList<>(); 43 private Point wasMovedTo; 44 45 private float mLastX = 0; 46 private float mLastY = 0; 47 private Path2D mPath = new Path2D.Double(); 48 private boolean mCachedIsEmpty = true; 49 private Path.FillType mFillType = Path.FillType.WINDING; 50 protected boolean isSimplePath; 51 52 @Implementation 53 protected void __constructor__(Path path) { 54 ShadowPath shadowPath = extract(path); 55 points = new ArrayList<>(shadowPath.getPoints()); 56 } 57 58 Path2D getJavaShape() { 59 return mPath; 60 } 61 62 @Implementation 63 protected void moveTo(float x, float y) { 64 mPath.moveTo(mLastX = x, mLastY = y); 65 66 // Legacy recording behavior 67 Point p = new Point(x, y, MOVE_TO); 68 points.add(p); 69 } 70 71 @Implementation 72 protected void lineTo(float x, float y) { 73 if (!hasPoints()) { 74 mPath.moveTo(mLastX = 0, mLastY = 0); 75 } 76 mPath.lineTo(mLastX = x, mLastY = y); 77 78 // Legacy recording behavior 79 Point point = new Point(x, y, LINE_TO); 80 points.add(point); 81 } 82 83 @Implementation 84 protected void quadTo(float x1, float y1, float x2, float y2) { 85 isSimplePath = false; 86 if (!hasPoints()) { 87 moveTo(0, 0); 88 } 89 mPath.quadTo(x1, y1, mLastX = x2, mLastY = y2); 90 } 91 92 @Implementation 93 protected void cubicTo(float x1, float y1, float x2, float y2, float x3, float y3) { 94 if (!hasPoints()) { 95 mPath.moveTo(0, 0); 96 } 97 mPath.curveTo(x1, y1, x2, y2, mLastX = x3, mLastY = y3); 98 } 99 100 private boolean hasPoints() { 101 return !mPath.getPathIterator(null).isDone(); 102 } 103 104 @Implementation 105 protected void reset() { 106 mPath.reset(); 107 mLastX = 0; 108 mLastY = 0; 109 110 // Legacy recording behavior 111 points.clear(); 112 } 113 114 @Implementation(minSdk = LOLLIPOP) 115 protected float[] approximate(float acceptableError) { 116 PathIterator iterator = mPath.getPathIterator(null, acceptableError); 117 118 float segment[] = new float[6]; 119 float totalLength = 0; 120 ArrayList<Point2D.Float> points = new ArrayList<Point2D.Float>(); 121 Point2D.Float previousPoint = null; 122 while (!iterator.isDone()) { 123 int type = iterator.currentSegment(segment); 124 Point2D.Float currentPoint = new Point2D.Float(segment[0], segment[1]); 125 // MoveTo shouldn't affect the length 126 if (previousPoint != null && type != PathIterator.SEG_MOVETO) { 127 totalLength += (float) currentPoint.distance(previousPoint); 128 } 129 previousPoint = currentPoint; 130 points.add(currentPoint); 131 iterator.next(); 132 } 133 134 int nPoints = points.size(); 135 float[] result = new float[nPoints * 3]; 136 previousPoint = null; 137 // Distance that we've covered so far. Used to calculate the fraction of the path that 138 // we've covered up to this point. 139 float walkedDistance = .0f; 140 for (int i = 0; i < nPoints; i++) { 141 Point2D.Float point = points.get(i); 142 float distance = previousPoint != null ? (float) previousPoint.distance(point) : .0f; 143 walkedDistance += distance; 144 result[i * 3] = walkedDistance / totalLength; 145 result[i * 3 + 1] = point.x; 146 result[i * 3 + 2] = point.y; 147 148 previousPoint = point; 149 } 150 151 return result; 152 } 153 154 /** 155 * @return all the points that have been added to the {@code Path} 156 */ 157 public List<Point> getPoints() { 158 return points; 159 } 160 161 public static class Point { 162 private final float x; 163 private final float y; 164 private final Type type; 165 166 public enum Type { 167 MOVE_TO, 168 LINE_TO 169 } 170 171 public Point(float x, float y, Type type) { 172 this.x = x; 173 this.y = y; 174 this.type = type; 175 } 176 177 @Override 178 public boolean equals(Object o) { 179 if (this == o) return true; 180 if (!(o instanceof Point)) return false; 181 182 Point point = (Point) o; 183 184 if (Float.compare(point.x, x) != 0) return false; 185 if (Float.compare(point.y, y) != 0) return false; 186 if (type != point.type) return false; 187 188 return true; 189 } 190 191 @Override 192 public int hashCode() { 193 int result = (x != +0.0f ? Float.floatToIntBits(x) : 0); 194 result = 31 * result + (y != +0.0f ? Float.floatToIntBits(y) : 0); 195 result = 31 * result + (type != null ? type.hashCode() : 0); 196 return result; 197 } 198 199 @Override 200 public String toString() { 201 return "Point(" + x + "," + y + "," + type + ")"; 202 } 203 204 public float getX() { 205 return x; 206 } 207 208 public float getY() { 209 return y; 210 } 211 212 public Type getType() { 213 return type; 214 } 215 } 216 217 @Implementation 218 protected void rewind() { 219 // call out to reset since there's nothing to optimize in 220 // terms of data structs. 221 reset(); 222 } 223 224 @Implementation 225 protected void set(Path src) { 226 mPath.reset(); 227 228 ShadowPath shadowSrc = extract(src); 229 setFillType(shadowSrc.mFillType); 230 mPath.append(shadowSrc.mPath, false /*connect*/); 231 } 232 233 @Implementation(minSdk = KITKAT) 234 protected boolean op(Path path1, Path path2, Path.Op op) { 235 Log.w(TAG, "android.graphics.Path#op() not supported yet."); 236 return false; 237 } 238 239 @Implementation(minSdk = LOLLIPOP) 240 protected boolean isConvex() { 241 Log.w(TAG, "android.graphics.Path#isConvex() not supported yet."); 242 return true; 243 } 244 245 @Implementation 246 protected Path.FillType getFillType() { 247 return mFillType; 248 } 249 250 @Implementation 251 protected void setFillType(Path.FillType fillType) { 252 mFillType = fillType; 253 mPath.setWindingRule(getWindingRule(fillType)); 254 } 255 256 /** 257 * Returns the Java2D winding rules matching a given Android {@link FillType}. 258 * 259 * @param type the android fill type 260 * @return the matching java2d winding rule. 261 */ 262 private static int getWindingRule(Path.FillType type) { 263 switch (type) { 264 case WINDING: 265 case INVERSE_WINDING: 266 return GeneralPath.WIND_NON_ZERO; 267 case EVEN_ODD: 268 case INVERSE_EVEN_ODD: 269 return GeneralPath.WIND_EVEN_ODD; 270 271 default: 272 assert false; 273 return GeneralPath.WIND_NON_ZERO; 274 } 275 } 276 277 @Implementation 278 protected boolean isInverseFillType() { 279 throw new UnsupportedOperationException("isInverseFillType"); 280 } 281 282 @Implementation 283 protected void toggleInverseFillType() { 284 throw new UnsupportedOperationException("toggleInverseFillType"); 285 } 286 287 @Implementation 288 protected boolean isEmpty() { 289 if (!mCachedIsEmpty) { 290 return false; 291 } 292 293 float[] coords = new float[6]; 294 mCachedIsEmpty = Boolean.TRUE; 295 for (PathIterator it = mPath.getPathIterator(null); !it.isDone(); it.next()) { 296 int type = it.currentSegment(coords); 297 // if (type != PathIterator.SEG_MOVETO) { 298 // Once we know that the path is not empty, we do not need to check again unless 299 // Path#reset is called. 300 mCachedIsEmpty = false; 301 return false; 302 // } 303 } 304 305 return true; 306 } 307 308 @Implementation 309 protected boolean isRect(RectF rect) { 310 // create an Area that can test if the path is a rect 311 Area area = new Area(mPath); 312 if (area.isRectangular()) { 313 if (rect != null) { 314 fillBounds(rect); 315 } 316 317 return true; 318 } 319 320 return false; 321 } 322 323 @Implementation 324 protected void computeBounds(RectF bounds, boolean exact) { 325 fillBounds(bounds); 326 } 327 328 @Implementation 329 protected void incReserve(int extraPtCount) { 330 throw new UnsupportedOperationException("incReserve"); 331 } 332 333 @Implementation 334 protected void rMoveTo(float dx, float dy) { 335 dx += mLastX; 336 dy += mLastY; 337 mPath.moveTo(mLastX = dx, mLastY = dy); 338 } 339 340 @Implementation 341 protected void rLineTo(float dx, float dy) { 342 if (!hasPoints()) { 343 mPath.moveTo(mLastX = 0, mLastY = 0); 344 } 345 346 if (Math.abs(dx) < EPSILON && Math.abs(dy) < EPSILON) { 347 // The delta is so small that this shouldn't generate a line 348 return; 349 } 350 351 dx += mLastX; 352 dy += mLastY; 353 mPath.lineTo(mLastX = dx, mLastY = dy); 354 } 355 356 @Implementation 357 protected void rQuadTo(float dx1, float dy1, float dx2, float dy2) { 358 if (!hasPoints()) { 359 mPath.moveTo(mLastX = 0, mLastY = 0); 360 } 361 dx1 += mLastX; 362 dy1 += mLastY; 363 dx2 += mLastX; 364 dy2 += mLastY; 365 mPath.quadTo(dx1, dy1, mLastX = dx2, mLastY = dy2); 366 } 367 368 @Implementation 369 protected void rCubicTo(float x1, float y1, float x2, float y2, float x3, float y3) { 370 if (!hasPoints()) { 371 mPath.moveTo(mLastX = 0, mLastY = 0); 372 } 373 x1 += mLastX; 374 y1 += mLastY; 375 x2 += mLastX; 376 y2 += mLastY; 377 x3 += mLastX; 378 y3 += mLastY; 379 mPath.curveTo(x1, y1, x2, y2, mLastX = x3, mLastY = y3); 380 } 381 382 @Implementation 383 protected void arcTo(RectF oval, float startAngle, float sweepAngle) { 384 arcTo(oval.left, oval.top, oval.right, oval.bottom, startAngle, sweepAngle, false); 385 } 386 387 @Implementation 388 protected void arcTo(RectF oval, float startAngle, float sweepAngle, boolean forceMoveTo) { 389 arcTo(oval.left, oval.top, oval.right, oval.bottom, startAngle, sweepAngle, forceMoveTo); 390 } 391 392 @Implementation(minSdk = LOLLIPOP) 393 protected void arcTo( 394 float left, 395 float top, 396 float right, 397 float bottom, 398 float startAngle, 399 float sweepAngle, 400 boolean forceMoveTo) { 401 isSimplePath = false; 402 Arc2D arc = 403 new Arc2D.Float( 404 left, top, right - left, bottom - top, -startAngle, -sweepAngle, Arc2D.OPEN); 405 mPath.append(arc, true /*connect*/); 406 407 resetLastPointFromPath(); 408 } 409 410 @Implementation 411 protected void close() { 412 if (!hasPoints()) { 413 mPath.moveTo(mLastX = 0, mLastY = 0); 414 } 415 mPath.closePath(); 416 } 417 418 @Implementation 419 protected void addRect(RectF rect, Direction dir) { 420 addRect(rect.left, rect.top, rect.right, rect.bottom, dir); 421 } 422 423 @Implementation 424 protected void addRect(float left, float top, float right, float bottom, Path.Direction dir) { 425 moveTo(left, top); 426 427 switch (dir) { 428 case CW: 429 lineTo(right, top); 430 lineTo(right, bottom); 431 lineTo(left, bottom); 432 break; 433 case CCW: 434 lineTo(left, bottom); 435 lineTo(right, bottom); 436 lineTo(right, top); 437 break; 438 } 439 440 close(); 441 442 resetLastPointFromPath(); 443 } 444 445 @Implementation(minSdk = LOLLIPOP) 446 protected void addOval(float left, float top, float right, float bottom, Path.Direction dir) { 447 mPath.append(new Ellipse2D.Float(left, top, right - left, bottom - top), false); 448 } 449 450 @Implementation 451 protected void addCircle(float x, float y, float radius, Path.Direction dir) { 452 mPath.append(new Ellipse2D.Float(x - radius, y - radius, radius * 2, radius * 2), false); 453 } 454 455 @Implementation(minSdk = LOLLIPOP) 456 protected void addArc( 457 float left, float top, float right, float bottom, float startAngle, float sweepAngle) { 458 mPath.append( 459 new Arc2D.Float( 460 left, top, right - left, bottom - top, -startAngle, -sweepAngle, Arc2D.OPEN), 461 false); 462 } 463 464 @Implementation(minSdk = JELLY_BEAN) 465 protected void addRoundRect(RectF rect, float rx, float ry, Direction dir) { 466 addRoundRect(rect.left, rect.top, rect.right, rect.bottom, rx, ry, dir); 467 } 468 469 @Implementation(minSdk = JELLY_BEAN) 470 protected void addRoundRect(RectF rect, float[] radii, Direction dir) { 471 if (rect == null) { 472 throw new NullPointerException("need rect parameter"); 473 } 474 addRoundRect(rect.left, rect.top, rect.right, rect.bottom, radii, dir); 475 } 476 477 @Implementation(minSdk = LOLLIPOP) 478 protected void addRoundRect( 479 float left, float top, float right, float bottom, float rx, float ry, Path.Direction dir) { 480 mPath.append( 481 new RoundRectangle2D.Float(left, top, right - left, bottom - top, rx * 2, ry * 2), false); 482 } 483 484 @Implementation(minSdk = LOLLIPOP) 485 protected void addRoundRect( 486 float left, float top, float right, float bottom, float[] radii, Path.Direction dir) { 487 if (radii.length < 8) { 488 throw new ArrayIndexOutOfBoundsException("radii[] needs 8 values"); 489 } 490 isSimplePath = false; 491 492 float[] cornerDimensions = new float[radii.length]; 493 for (int i = 0; i < radii.length; i++) { 494 cornerDimensions[i] = 2 * radii[i]; 495 } 496 mPath.append( 497 new RoundRectangle(left, top, right - left, bottom - top, cornerDimensions), false); 498 } 499 500 @Implementation 501 protected void addPath(Path src, float dx, float dy) { 502 isSimplePath = false; 503 ShadowPath.addPath(realObject, src, AffineTransform.getTranslateInstance(dx, dy)); 504 } 505 506 @Implementation 507 protected void addPath(Path src) { 508 isSimplePath = false; 509 ShadowPath.addPath(realObject, src, null); 510 } 511 512 @Implementation 513 protected void addPath(Path src, Matrix matrix) { 514 if (matrix == null) { 515 return; 516 } 517 ShadowPath shadowSrc = extract(src); 518 if (!shadowSrc.isSimplePath) isSimplePath = false; 519 520 ShadowMatrix shadowMatrix = extract(matrix); 521 ShadowPath.addPath(realObject, src, shadowMatrix.getAffineTransform()); 522 } 523 524 private static void addPath(Path destPath, Path srcPath, AffineTransform transform) { 525 if (destPath == null) { 526 return; 527 } 528 529 if (srcPath == null) { 530 return; 531 } 532 533 ShadowPath shadowDestPath = extract(destPath); 534 ShadowPath shadowSrcPath = extract(srcPath); 535 if (transform != null) { 536 shadowDestPath.mPath.append(shadowSrcPath.mPath.getPathIterator(transform), false); 537 } else { 538 shadowDestPath.mPath.append(shadowSrcPath.mPath, false); 539 } 540 } 541 542 @Implementation 543 protected void offset(float dx, float dy, Path dst) { 544 if (dst != null) { 545 dst.set(realObject); 546 } else { 547 dst = realObject; 548 } 549 dst.offset(dx, dy); 550 } 551 552 @Implementation 553 protected void offset(float dx, float dy) { 554 GeneralPath newPath = new GeneralPath(); 555 556 PathIterator iterator = mPath.getPathIterator(new AffineTransform(0, 0, dx, 0, 0, dy)); 557 558 newPath.append(iterator, false /*connect*/); 559 mPath = newPath; 560 } 561 562 @Implementation 563 protected void setLastPoint(float dx, float dy) { 564 mLastX = dx; 565 mLastY = dy; 566 } 567 568 @Implementation 569 protected void transform(Matrix matrix, Path dst) { 570 ShadowMatrix shadowMatrix = extract(matrix); 571 572 if (shadowMatrix.hasPerspective()) { 573 Log.w(TAG, "android.graphics.Path#transform() only supports affine transformations."); 574 } 575 576 GeneralPath newPath = new GeneralPath(); 577 578 PathIterator iterator = mPath.getPathIterator(shadowMatrix.getAffineTransform()); 579 newPath.append(iterator, false /*connect*/); 580 581 if (dst != null) { 582 ShadowPath shadowPath = extract(dst); 583 shadowPath.mPath = newPath; 584 } else { 585 mPath = newPath; 586 } 587 } 588 589 @Implementation 590 protected void transform(Matrix matrix) { 591 transform(matrix, null); 592 } 593 594 /** 595 * Fills the given {@link RectF} with the path bounds. 596 * 597 * @param bounds the RectF to be filled. 598 */ 599 public void fillBounds(RectF bounds) { 600 Rectangle2D rect = mPath.getBounds2D(); 601 bounds.left = (float) rect.getMinX(); 602 bounds.right = (float) rect.getMaxX(); 603 bounds.top = (float) rect.getMinY(); 604 bounds.bottom = (float) rect.getMaxY(); 605 } 606 607 private void resetLastPointFromPath() { 608 Point2D last = mPath.getCurrentPoint(); 609 mLastX = (float) last.getX(); 610 mLastY = (float) last.getY(); 611 } 612 } 613