1 /* 2 * Copyright (C) 2012 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 com.android.dreams.phototable; 17 18 import android.content.Context; 19 import android.content.res.Resources; 20 import android.graphics.Bitmap; 21 import android.graphics.BitmapFactory; 22 import android.graphics.PointF; 23 import android.graphics.PorterDuff; 24 import android.graphics.Rect; 25 import android.graphics.drawable.BitmapDrawable; 26 import android.graphics.drawable.Drawable; 27 import android.graphics.drawable.LayerDrawable; 28 import android.os.AsyncTask; 29 import android.service.dreams.DreamService; 30 import android.util.AttributeSet; 31 import android.util.Log; 32 import android.view.KeyEvent; 33 import android.view.LayoutInflater; 34 import android.view.MotionEvent; 35 import android.view.View; 36 import android.view.ViewGroup; 37 import android.view.ViewPropertyAnimator; 38 import android.view.animation.DecelerateInterpolator; 39 import android.view.animation.Interpolator; 40 import android.widget.FrameLayout; 41 import android.widget.ImageView; 42 43 import java.util.ArrayList; 44 import java.util.Formatter; 45 import java.util.HashSet; 46 import java.util.LinkedList; 47 import java.util.List; 48 import java.util.Random; 49 import java.util.Set; 50 51 /** 52 * A surface where photos sit. 53 */ 54 public class PhotoTable extends FrameLayout { 55 private static final String TAG = "PhotoTable"; 56 private static final boolean DEBUG = false; 57 58 class Launcher implements Runnable { 59 @Override 60 public void run() { 61 PhotoTable.this.scheduleNext(mDropPeriod); 62 PhotoTable.this.launch(); 63 } 64 } 65 66 class FocusReaper implements Runnable { 67 @Override 68 public void run() { 69 PhotoTable.this.clearFocus(); 70 } 71 } 72 73 class SelectionReaper implements Runnable { 74 @Override 75 public void run() { 76 PhotoTable.this.clearSelection(); 77 } 78 } 79 80 private static final int NEXT = 1; 81 private static final int PREV = 0; 82 private static Random sRNG = new Random(); 83 84 private final Launcher mLauncher; 85 private final FocusReaper mFocusReaper; 86 private final SelectionReaper mSelectionReaper; 87 private final LinkedList<View> mOnTable; 88 private final int mDropPeriod; 89 private final int mFastDropPeriod; 90 private final int mNowDropDelay; 91 private final float mImageRatio; 92 private final float mTableRatio; 93 private final float mImageRotationLimit; 94 private final float mThrowRotation; 95 private final float mThrowSpeed; 96 private final boolean mTapToExit; 97 private final int mTableCapacity; 98 private final int mRedealCount; 99 private final int mInset; 100 private final PhotoSource mPhotoSource; 101 private final Resources mResources; 102 private final Interpolator mThrowInterpolator; 103 private final Interpolator mDropInterpolator; 104 private final DragGestureDetector mDragGestureDetector; 105 private final EdgeSwipeDetector mEdgeSwipeDetector; 106 private final KeyboardInterpreter mKeyboardInterpreter; 107 private final boolean mStoryModeEnabled; 108 private final long mPickUpDuration; 109 private final int mMaxSelectionTime; 110 private final int mMaxFocusTime; 111 private final List<View> mAnimating; 112 113 private DreamService mDream; 114 private PhotoLaunchTask mPhotoLaunchTask; 115 private LoadNaturalSiblingTask mLoadOnDeckTasks[]; 116 private boolean mStarted; 117 private boolean mIsLandscape; 118 private int mLongSide; 119 private int mShortSide; 120 private int mWidth; 121 private int mHeight; 122 private View mSelection; 123 private View mOnDeck[]; 124 private View mFocus; 125 private int mHighlightColor; 126 private ViewGroup mBackground; 127 private ViewGroup mStageLeft; 128 129 public PhotoTable(Context context, AttributeSet as) { 130 super(context, as); 131 mResources = getResources(); 132 mInset = mResources.getDimensionPixelSize(R.dimen.photo_inset); 133 mDropPeriod = mResources.getInteger(R.integer.table_drop_period); 134 mFastDropPeriod = mResources.getInteger(R.integer.fast_drop); 135 mNowDropDelay = mResources.getInteger(R.integer.now_drop); 136 mImageRatio = mResources.getInteger(R.integer.image_ratio) / 1000000f; 137 mTableRatio = mResources.getInteger(R.integer.table_ratio) / 1000000f; 138 mImageRotationLimit = (float) mResources.getInteger(R.integer.max_image_rotation); 139 mThrowSpeed = mResources.getDimension(R.dimen.image_throw_speed); 140 mPickUpDuration = mResources.getInteger(R.integer.photo_pickup_duration); 141 mThrowRotation = (float) mResources.getInteger(R.integer.image_throw_rotatioan); 142 mTableCapacity = mResources.getInteger(R.integer.table_capacity); 143 mRedealCount = mResources.getInteger(R.integer.redeal_count); 144 mTapToExit = mResources.getBoolean(R.bool.enable_tap_to_exit); 145 mStoryModeEnabled = mResources.getBoolean(R.bool.enable_story_mode); 146 mHighlightColor = mResources.getColor(R.color.highlight_color); 147 mMaxSelectionTime = mResources.getInteger(R.integer.max_selection_time); 148 mMaxFocusTime = mResources.getInteger(R.integer.max_focus_time); 149 mThrowInterpolator = new SoftLandingInterpolator( 150 mResources.getInteger(R.integer.soft_landing_time) / 1000000f, 151 mResources.getInteger(R.integer.soft_landing_distance) / 1000000f); 152 mDropInterpolator = new DecelerateInterpolator( 153 (float) mResources.getInteger(R.integer.drop_deceleration_exponent)); 154 mOnTable = new LinkedList<View>(); 155 mPhotoSource = new PhotoSourcePlexor(getContext(), 156 getContext().getSharedPreferences(PhotoTableDreamSettings.PREFS_NAME, 0)); 157 mAnimating = new ArrayList<View>(); 158 mLauncher = new Launcher(); 159 mFocusReaper = new FocusReaper(); 160 mSelectionReaper = new SelectionReaper(); 161 mDragGestureDetector = new DragGestureDetector(context, this); 162 mEdgeSwipeDetector = new EdgeSwipeDetector(context, this); 163 mKeyboardInterpreter = new KeyboardInterpreter(this); 164 mLoadOnDeckTasks = new LoadNaturalSiblingTask[2]; 165 mOnDeck = new View[2]; 166 mStarted = false; 167 } 168 169 @Override 170 public void onFinishInflate() { 171 mBackground = (ViewGroup) findViewById(R.id.background); 172 mStageLeft = (ViewGroup) findViewById(R.id.stageleft); 173 } 174 175 public void setDream(DreamService dream) { 176 mDream = dream; 177 } 178 179 public boolean hasSelection() { 180 return mSelection != null; 181 } 182 183 public View getSelection() { 184 return mSelection; 185 } 186 187 public void clearSelection() { 188 if (hasSelection()) { 189 dropOnTable(mSelection); 190 mPhotoSource.donePaging(getBitmap(mSelection)); 191 if (mStoryModeEnabled) { 192 fadeInBackground(mSelection); 193 } 194 mSelection = null; 195 } 196 for (int slot = 0; slot < mOnDeck.length; slot++) { 197 if (mOnDeck[slot] != null) { 198 fadeAway(mOnDeck[slot], false); 199 mOnDeck[slot] = null; 200 } 201 if (mLoadOnDeckTasks[slot] != null && 202 mLoadOnDeckTasks[slot].getStatus() != AsyncTask.Status.FINISHED) { 203 mLoadOnDeckTasks[slot].cancel(true); 204 mLoadOnDeckTasks[slot] = null; 205 } 206 } 207 } 208 209 public void setSelection(View selected) { 210 if (selected != null) { 211 clearSelection(); 212 mSelection = selected; 213 promoteSelection(); 214 if (mStoryModeEnabled) { 215 fadeOutBackground(mSelection); 216 } 217 } 218 } 219 220 public void selectNext() { 221 if (mStoryModeEnabled) { 222 log("selectNext"); 223 if (hasSelection() && mOnDeck[NEXT] != null) { 224 placeOnDeck(mSelection, PREV); 225 mSelection = mOnDeck[NEXT]; 226 mOnDeck[NEXT] = null; 227 promoteSelection(); 228 } 229 } else { 230 clearSelection(); 231 } 232 } 233 234 public void selectPrevious() { 235 if (mStoryModeEnabled) { 236 log("selectPrevious"); 237 if (hasSelection() && mOnDeck[PREV] != null) { 238 placeOnDeck(mSelection, NEXT); 239 mSelection = mOnDeck[PREV]; 240 mOnDeck[PREV] = null; 241 promoteSelection(); 242 } 243 } else { 244 clearSelection(); 245 } 246 } 247 248 private void promoteSelection() { 249 if (hasSelection()) { 250 scheduleSelectionReaper(mMaxSelectionTime); 251 mSelection.animate().cancel(); 252 mSelection.setAlpha(1f); 253 moveToTopOfPile(mSelection); 254 pickUp(mSelection); 255 if (mStoryModeEnabled) { 256 for (int slot = 0; slot < mOnDeck.length; slot++) { 257 if (mLoadOnDeckTasks[slot] != null && 258 mLoadOnDeckTasks[slot].getStatus() != AsyncTask.Status.FINISHED) { 259 mLoadOnDeckTasks[slot].cancel(true); 260 } 261 if (mOnDeck[slot] == null) { 262 mLoadOnDeckTasks[slot] = new LoadNaturalSiblingTask(slot); 263 mLoadOnDeckTasks[slot].execute(mSelection); 264 } 265 } 266 } 267 } 268 } 269 270 public boolean hasFocus() { 271 return mFocus != null; 272 } 273 274 public View getFocus() { 275 return mFocus; 276 } 277 278 public void clearFocus() { 279 if (hasFocus()) { 280 setHighlight(getFocus(), false); 281 } 282 mFocus = null; 283 } 284 285 public void setDefaultFocus() { 286 setFocus(mOnTable.getLast()); 287 } 288 289 public void setFocus(View focus) { 290 assert(focus != null); 291 clearFocus(); 292 mFocus = focus; 293 moveToTopOfPile(focus); 294 setHighlight(focus, true); 295 scheduleFocusReaper(mMaxFocusTime); 296 } 297 298 static float lerp(float a, float b, float f) { 299 return (b-a)*f + a; 300 } 301 302 static float randfrange(float a, float b) { 303 return lerp(a, b, sRNG.nextFloat()); 304 } 305 306 static PointF randFromCurve(float t, PointF[] v) { 307 PointF p = new PointF(); 308 if (v.length == 4 && t >= 0f && t <= 1f) { 309 float a = (float) Math.pow(1f-t, 3f); 310 float b = (float) Math.pow(1f-t, 2f) * t; 311 float c = (1f-t) * (float) Math.pow(t, 2f); 312 float d = (float) Math.pow(t, 3f); 313 314 p.x = a * v[0].x + 3 * b * v[1].x + 3 * c * v[2].x + d * v[3].x; 315 p.y = a * v[0].y + 3 * b * v[1].y + 3 * c * v[2].y + d * v[3].y; 316 } 317 return p; 318 } 319 320 private static PointF randMultiDrop(int n, float i, float j, int width, int height) { 321 log("randMultiDrop (%d, %f, %f, %d, %d)", n, i, j, width, height); 322 final float[] cx = {0.3f, 0.3f, 0.5f, 0.7f, 0.7f}; 323 final float[] cy = {0.3f, 0.7f, 0.5f, 0.3f, 0.7f}; 324 n = Math.abs(n); 325 float x = cx[n % cx.length]; 326 float y = cy[n % cx.length]; 327 PointF p = new PointF(); 328 p.x = x * width + 0.05f * width * i; 329 p.y = y * height + 0.05f * height * j; 330 log("randInCenter returning %f, %f", p.x, p.y); 331 return p; 332 } 333 334 private double cross(double[] a, double[] b) { 335 return a[0] * b[1] - a[1] * b[0]; 336 } 337 338 private double norm(double[] a) { 339 return Math.hypot(a[0], a[1]); 340 } 341 342 private double[] getCenter(View photo) { 343 float width = (float) ((Integer) photo.getTag(R.id.photo_width)).intValue(); 344 float height = (float) ((Integer) photo.getTag(R.id.photo_height)).intValue(); 345 double[] center = { photo.getX() + width / 2f, 346 - (photo.getY() + height / 2f) }; 347 return center; 348 } 349 350 public View moveFocus(View focus, float direction) { 351 return moveFocus(focus, direction, 90f); 352 } 353 354 public View moveFocus(View focus, float direction, float angle) { 355 if (focus == null) { 356 setFocus(mOnTable.getLast()); 357 } else { 358 final double alpha = Math.toRadians(direction); 359 final double beta = Math.toRadians(Math.min(angle, 180f) / 2f); 360 final double[] left = { Math.sin(alpha - beta), 361 Math.cos(alpha - beta) }; 362 final double[] right = { Math.sin(alpha + beta), 363 Math.cos(alpha + beta) }; 364 final double[] a = getCenter(focus); 365 View bestFocus = null; 366 double bestDistance = Double.MAX_VALUE; 367 for (View candidate: mOnTable) { 368 if (candidate != focus) { 369 final double[] b = getCenter(candidate); 370 final double[] delta = { b[0] - a[0], 371 b[1] - a[1] }; 372 if (cross(delta, left) > 0.0 && cross(delta, right) < 0.0) { 373 final double distance = norm(delta); 374 if (bestDistance > distance) { 375 bestDistance = distance; 376 bestFocus = candidate; 377 } 378 } 379 } 380 } 381 if (bestFocus == null) { 382 if (angle < 180f) { 383 return moveFocus(focus, direction, 180f); 384 } 385 } else { 386 setFocus(bestFocus); 387 } 388 } 389 return getFocus(); 390 } 391 392 @Override 393 public boolean onKeyDown(int keyCode, KeyEvent event) { 394 return mKeyboardInterpreter.onKeyDown(keyCode, event); 395 } 396 397 @Override 398 public boolean onGenericMotionEvent(MotionEvent event) { 399 return mEdgeSwipeDetector.onTouchEvent(event) || mDragGestureDetector.onTouchEvent(event); 400 } 401 402 @Override 403 public boolean onTouchEvent(MotionEvent event) { 404 if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { 405 if (hasSelection()) { 406 clearSelection(); 407 } else { 408 if (mTapToExit && mDream != null) { 409 mDream.finish(); 410 } 411 } 412 return true; 413 } 414 return false; 415 } 416 417 @Override 418 public void onLayout(boolean changed, int left, int top, int right, int bottom) { 419 super.onLayout(changed, left, top, right, bottom); 420 log("onLayout (%d, %d, %d, %d)", left, top, right, bottom); 421 422 mHeight = bottom - top; 423 mWidth = right - left; 424 425 mLongSide = (int) (mImageRatio * Math.max(mWidth, mHeight)); 426 mShortSide = (int) (mImageRatio * Math.min(mWidth, mHeight)); 427 428 boolean isLandscape = mWidth > mHeight; 429 if (mIsLandscape != isLandscape) { 430 for (View photo: mOnTable) { 431 if (photo != getSelection()) { 432 dropOnTable(photo); 433 } 434 } 435 if (hasSelection()) { 436 pickUp(getSelection()); 437 for (int slot = 0; slot < mOnDeck.length; slot++) { 438 if (mOnDeck[slot] != null) { 439 placeOnDeck(mOnDeck[slot], slot); 440 } 441 } 442 } 443 mIsLandscape = isLandscape; 444 } 445 start(); 446 } 447 448 @Override 449 public boolean isOpaque() { 450 return true; 451 } 452 453 /** Put a nice border on the bitmap. */ 454 private static View applyFrame(final PhotoTable table, final BitmapFactory.Options options, 455 Bitmap decodedPhoto) { 456 LayoutInflater inflater = (LayoutInflater) table.getContext() 457 .getSystemService(Context.LAYOUT_INFLATER_SERVICE); 458 View photo = inflater.inflate(R.layout.photo, null); 459 ImageView image = (ImageView) photo; 460 Drawable[] layers = new Drawable[2]; 461 int photoWidth = options.outWidth; 462 int photoHeight = options.outHeight; 463 if (decodedPhoto == null || options.outWidth <= 0 || options.outHeight <= 0) { 464 photo = null; 465 } else { 466 decodedPhoto.setHasMipMap(true); 467 layers[0] = new BitmapDrawable(table.mResources, decodedPhoto); 468 layers[1] = table.mResources.getDrawable(R.drawable.frame); 469 LayerDrawable layerList = new LayerDrawable(layers); 470 layerList.setLayerInset(0, table.mInset, table.mInset, 471 table.mInset, table.mInset); 472 image.setImageDrawable(layerList); 473 474 photo.setTag(R.id.photo_width, Integer.valueOf(photoWidth)); 475 photo.setTag(R.id.photo_height, Integer.valueOf(photoHeight)); 476 477 photo.setOnTouchListener(new PhotoTouchListener(table.getContext(), 478 table)); 479 } 480 return photo; 481 } 482 483 private class LoadNaturalSiblingTask extends AsyncTask<View, Void, View> { 484 private final BitmapFactory.Options mOptions; 485 private final int mSlot; 486 private View mParent; 487 488 public LoadNaturalSiblingTask (int slot) { 489 mOptions = new BitmapFactory.Options(); 490 mOptions.inTempStorage = new byte[32768]; 491 mSlot = slot; 492 } 493 494 @Override 495 public View doInBackground(View... views) { 496 log("load natural %s", (mSlot == NEXT ? "next" : "previous")); 497 final PhotoTable table = PhotoTable.this; 498 mParent = views[0]; 499 final Bitmap current = getBitmap(mParent); 500 Bitmap decodedPhoto; 501 if (mSlot == NEXT) { 502 decodedPhoto = table.mPhotoSource.naturalNext(current, 503 mOptions, table.mLongSide, table.mShortSide); 504 } else { 505 decodedPhoto = table.mPhotoSource.naturalPrevious(current, 506 mOptions, table.mLongSide, table.mShortSide); 507 } 508 return applyFrame(PhotoTable.this, mOptions, decodedPhoto); 509 } 510 511 @Override 512 public void onPostExecute(View photo) { 513 if (photo != null) { 514 if (hasSelection() && getSelection() == mParent) { 515 log("natural %s being rendered", (mSlot == NEXT ? "next" : "previous")); 516 PhotoTable.this.addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT, 517 LayoutParams.WRAP_CONTENT)); 518 PhotoTable.this.mOnDeck[mSlot] = photo; 519 float width = (float) ((Integer) photo.getTag(R.id.photo_width)).intValue(); 520 float height = (float) ((Integer) photo.getTag(R.id.photo_height)).intValue(); 521 photo.setX(mSlot == PREV ? -2 * width : mWidth + 2 * width); 522 photo.setY((mHeight - height) / 2); 523 photo.addOnLayoutChangeListener(new OnLayoutChangeListener() { 524 @Override 525 public void onLayoutChange(View v, int left, int top, int right, int bottom, 526 int oldLeft, int oldTop, int oldRight, int oldBottom) { 527 PhotoTable.this.placeOnDeck(v, mSlot); 528 v.removeOnLayoutChangeListener(this); 529 } 530 }); 531 } else { 532 recycle(photo); 533 } 534 } else { 535 log("natural, %s was null!", (mSlot == NEXT ? "next" : "previous")); 536 } 537 } 538 }; 539 540 private class PhotoLaunchTask extends AsyncTask<Void, Void, View> { 541 private final BitmapFactory.Options mOptions; 542 543 public PhotoLaunchTask () { 544 mOptions = new BitmapFactory.Options(); 545 mOptions.inTempStorage = new byte[32768]; 546 } 547 548 @Override 549 public View doInBackground(Void... unused) { 550 log("load a new photo"); 551 final PhotoTable table = PhotoTable.this; 552 return applyFrame(PhotoTable.this, mOptions, 553 table.mPhotoSource.next(mOptions, 554 table.mLongSide, table.mShortSide)); 555 } 556 557 @Override 558 public void onPostExecute(View photo) { 559 if (photo != null) { 560 final PhotoTable table = PhotoTable.this; 561 562 table.addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT, 563 LayoutParams.WRAP_CONTENT)); 564 if (table.hasSelection()) { 565 for (int slot = 0; slot < mOnDeck.length; slot++) { 566 if (mOnDeck[slot] != null) { 567 table.moveToTopOfPile(mOnDeck[slot]); 568 } 569 } 570 table.moveToTopOfPile(table.getSelection()); 571 } 572 573 log("drop it"); 574 table.throwOnTable(photo); 575 576 if (mOnTable.size() > mTableCapacity) { 577 int targetSize = Math.max(0, mOnTable.size() - mRedealCount); 578 while (mOnTable.size() > targetSize) { 579 fadeAway(mOnTable.poll(), false); 580 } 581 } 582 583 if(table.mOnTable.size() < table.mTableCapacity) { 584 table.scheduleNext(table.mFastDropPeriod); 585 } 586 } 587 } 588 }; 589 590 /** Bring a new photo onto the table. */ 591 public void launch() { 592 log("launching"); 593 setSystemUiVisibility(View.SYSTEM_UI_FLAG_LOW_PROFILE); 594 if (!hasSelection()) { 595 log("inflate it"); 596 if (mPhotoLaunchTask == null || 597 mPhotoLaunchTask.getStatus() == AsyncTask.Status.FINISHED) { 598 mPhotoLaunchTask = new PhotoLaunchTask(); 599 mPhotoLaunchTask.execute(); 600 } 601 } 602 } 603 604 /** De-emphasize the other photos on the table. */ 605 public void fadeOutBackground(final View photo) { 606 mBackground.animate() 607 .withLayer() 608 .setDuration(mPickUpDuration) 609 .alpha(0f); 610 } 611 612 613 /** Return the other photos to foreground status. */ 614 public void fadeInBackground(final View photo) { 615 mAnimating.add(photo); 616 mBackground.animate() 617 .withLayer() 618 .setDuration(mPickUpDuration) 619 .alpha(1f) 620 .withEndAction(new Runnable() { 621 @Override 622 public void run() { 623 mAnimating.remove(photo); 624 if (!mAnimating.contains(photo)) { 625 moveToBackground(photo); 626 } 627 } 628 }); 629 } 630 631 /** Dispose of the photo gracefully, in case we can see some of it. */ 632 public void fadeAway(final View photo, final boolean replace) { 633 // fade out of view 634 mOnTable.remove(photo); 635 exitStageLeft(photo); 636 photo.setOnTouchListener(null); 637 photo.animate().cancel(); 638 photo.animate() 639 .withLayer() 640 .alpha(0f) 641 .setDuration(mPickUpDuration) 642 .withEndAction(new Runnable() { 643 @Override 644 public void run() { 645 if (photo == getFocus()) { 646 clearFocus(); 647 } 648 mStageLeft.removeView(photo); 649 recycle(photo); 650 if (replace) { 651 scheduleNext(mNowDropDelay); 652 } 653 } 654 }); 655 } 656 657 /** Visually on top, and also freshest, for the purposes of timeouts. */ 658 public void moveToTopOfPile(View photo) { 659 // make this photo the last to be removed. 660 if (isInBackground(photo)) { 661 mBackground.bringChildToFront(photo); 662 } else { 663 bringChildToFront(photo); 664 } 665 invalidate(); 666 mOnTable.remove(photo); 667 mOnTable.offer(photo); 668 } 669 670 /** On deck is to the left or right of the selected photo. */ 671 private void placeOnDeck(final View photo, final int slot ) { 672 if (slot < mOnDeck.length) { 673 if (mOnDeck[slot] != null && mOnDeck[slot] != photo) { 674 fadeAway(mOnDeck[slot], false); 675 } 676 mOnDeck[slot] = photo; 677 float photoWidth = photo.getWidth(); 678 float photoHeight = photo.getHeight(); 679 float scale = Math.min(getHeight() / photoHeight, getWidth() / photoWidth); 680 681 float x = (getWidth() - photoWidth) / 2f; 682 float y = (getHeight() - photoHeight) / 2f; 683 684 float offset = (((float) mWidth + scale * (photoWidth - 2f * mInset)) / 2f); 685 x += (slot == NEXT? 1f : -1f) * offset; 686 687 photo.animate() 688 .withLayer() 689 .rotation(0f) 690 .rotationY(0f) 691 .scaleX(scale) 692 .scaleY(scale) 693 .x(x) 694 .y(y) 695 .setDuration(mPickUpDuration) 696 .setInterpolator(new DecelerateInterpolator(2f)); 697 } 698 } 699 700 /** Move in response to touch. */ 701 public void move(final View photo, float x, float y, float a) { 702 photo.animate().cancel(); 703 photo.setAlpha(1f); 704 photo.setX((int) x); 705 photo.setY((int) y); 706 photo.setRotation((int) a); 707 } 708 709 /** Wind up off screen, so we can animate in. */ 710 private void throwOnTable(final View photo) { 711 mOnTable.offer(photo); 712 log("start offscreen"); 713 photo.setRotation(mThrowRotation); 714 photo.setX(-mLongSide); 715 photo.setY(-mLongSide); 716 717 dropOnTable(photo, mThrowInterpolator); 718 } 719 720 public void move(final View photo, float dx, float dy, boolean drop) { 721 if (photo != null) { 722 final float x = photo.getX() + dx; 723 final float y = photo.getY() + dy; 724 photo.setX(x); 725 photo.setY(y); 726 Log.d(TAG, "[" + photo.getX() + ", " + photo.getY() + "] + (" + dx + "," + dy + ")"); 727 if (drop && photoOffTable(photo)) { 728 fadeAway(photo, true); 729 } 730 } 731 } 732 733 /** Fling with no touch hints, then land off screen. */ 734 public void fling(final View photo) { 735 final float[] o = { mWidth + mLongSide / 2f, 736 mHeight + mLongSide / 2f }; 737 final float[] a = { photo.getX(), photo.getY() }; 738 final float[] b = { o[0], a[1] + o[0] - a[0] }; 739 final float[] c = { a[0] + o[1] - a[1], o[1] }; 740 float[] delta = { 0f, 0f }; 741 if (Math.hypot(b[0] - a[0], b[1] - a[1]) < Math.hypot(c[0] - a[0], c[1] - a[1])) { 742 delta[0] = b[0] - a[0]; 743 delta[1] = b[1] - a[1]; 744 } else { 745 delta[0] = c[0] - a[0]; 746 delta[1] = c[1] - a[1]; 747 } 748 749 final float dist = (float) Math.hypot(delta[0], delta[1]); 750 final int duration = (int) (1000f * dist / mThrowSpeed); 751 fling(photo, delta[0], delta[1], duration, true); 752 } 753 754 /** Continue dynamically after a fling gesture, possibly off the screen. */ 755 public void fling(final View photo, float dx, float dy, int duration, boolean spin) { 756 if (photo == getFocus()) { 757 if (moveFocus(photo, 0f) == null) { 758 moveFocus(photo, 180f); 759 } 760 } 761 moveToForeground(photo); 762 ViewPropertyAnimator animator = photo.animate() 763 .withLayer() 764 .xBy(dx) 765 .yBy(dy) 766 .setDuration(duration) 767 .setInterpolator(new DecelerateInterpolator(2f)); 768 769 if (spin) { 770 animator.rotation(mThrowRotation); 771 } 772 773 if (photoOffTable(photo, (int) dx, (int) dy)) { 774 log("fling away"); 775 animator.withEndAction(new Runnable() { 776 @Override 777 public void run() { 778 fadeAway(photo, true); 779 } 780 }); 781 } 782 } 783 public boolean photoOffTable(final View photo) { 784 return photoOffTable(photo, 0, 0); 785 } 786 787 public boolean photoOffTable(final View photo, final int dx, final int dy) { 788 Rect hit = new Rect(); 789 photo.getHitRect(hit); 790 hit.offset(dx, dy); 791 return (hit.bottom < 0f || hit.top > getHeight() || 792 hit.right < 0f || hit.left > getWidth()); 793 } 794 795 /** Animate to a random place and orientation, down on the table (visually small). */ 796 public void dropOnTable(final View photo) { 797 dropOnTable(photo, mDropInterpolator); 798 } 799 800 /** Animate to a random place and orientation, down on the table (visually small). */ 801 public void dropOnTable(final View photo, final Interpolator interpolator) { 802 float angle = randfrange(-mImageRotationLimit, mImageRotationLimit); 803 PointF p = randMultiDrop(sRNG.nextInt(), 804 (float) sRNG.nextGaussian(), (float) sRNG.nextGaussian(), 805 mWidth, mHeight); 806 float x = p.x; 807 float y = p.y; 808 809 log("drop it at %f, %f", x, y); 810 811 float x0 = photo.getX(); 812 float y0 = photo.getY(); 813 814 x -= mLongSide / 2f; 815 y -= mShortSide / 2f; 816 log("fixed offset is %f, %f ", x, y); 817 818 float dx = x - x0; 819 float dy = y - y0; 820 821 float dist = (float) Math.hypot(dx, dy); 822 int duration = (int) (1000f * dist / mThrowSpeed); 823 duration = Math.max(duration, 1000); 824 825 log("animate it"); 826 // toss onto table 827 mAnimating.add(photo); 828 photo.animate() 829 .withLayer() 830 .scaleX(mTableRatio / mImageRatio) 831 .scaleY(mTableRatio / mImageRatio) 832 .rotation(angle) 833 .x(x) 834 .y(y) 835 .setDuration(duration) 836 .setInterpolator(interpolator) 837 .withEndAction(new Runnable() { 838 @Override 839 public void run() { 840 mAnimating.remove(photo); 841 if (!mAnimating.contains(photo)) { 842 moveToBackground(photo); 843 } 844 } 845 }); 846 } 847 848 private void moveToBackground(View photo) { 849 if (!isInBackground(photo)) { 850 removeView(photo); 851 mBackground.addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT, 852 LayoutParams.WRAP_CONTENT)); 853 } 854 } 855 856 private void exitStageLeft(View photo) { 857 if (isInBackground(photo)) { 858 mBackground.removeView(photo); 859 } else { 860 removeView(photo); 861 } 862 mStageLeft.addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT, 863 LayoutParams.WRAP_CONTENT)); 864 } 865 866 private void moveToForeground(View photo) { 867 if (isInBackground(photo)) { 868 mBackground.removeView(photo); 869 addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT, 870 LayoutParams.WRAP_CONTENT)); 871 } 872 } 873 874 private boolean isInBackground(View photo) { 875 return mBackground.indexOfChild(photo) != -1; 876 } 877 878 /** wrap all orientations to the interval [-180, 180). */ 879 private float wrapAngle(float angle) { 880 float result = angle + 180; 881 result = ((result % 360) + 360) % 360; // catch negative numbers 882 result -= 180; 883 return result; 884 } 885 886 /** Animate the selected photo to the foregound: zooming in to bring it foreward. */ 887 private void pickUp(final View photo) { 888 float photoWidth = photo.getWidth(); 889 float photoHeight = photo.getHeight(); 890 891 float scale = Math.min(getHeight() / photoHeight, getWidth() / photoWidth); 892 893 log("scale is %f", scale); 894 log("target it"); 895 float x = (getWidth() - photoWidth) / 2f; 896 float y = (getHeight() - photoHeight) / 2f; 897 898 photo.setRotation(wrapAngle(photo.getRotation())); 899 900 log("animate it"); 901 // lift up to the glass for a good look 902 moveToForeground(photo); 903 photo.animate() 904 .withLayer() 905 .rotation(0f) 906 .rotationY(0f) 907 .alpha(1f) 908 .scaleX(scale) 909 .scaleY(scale) 910 .x(x) 911 .y(y) 912 .setDuration(mPickUpDuration) 913 .setInterpolator(new DecelerateInterpolator(2f)) 914 .withEndAction(new Runnable() { 915 @Override 916 public void run() { 917 log("endtimes: %f", photo.getX()); 918 } 919 }); 920 } 921 922 private Bitmap getBitmap(View photo) { 923 if (photo == null) { 924 return null; 925 } 926 ImageView image = (ImageView) photo; 927 LayerDrawable layers = (LayerDrawable) image.getDrawable(); 928 if (layers == null) { 929 return null; 930 } 931 BitmapDrawable bitmap = (BitmapDrawable) layers.getDrawable(0); 932 if (bitmap == null) { 933 return null; 934 } 935 return bitmap.getBitmap(); 936 } 937 938 private void recycle(View photo) { 939 if (photo != null) { 940 removeView(photo); 941 mPhotoSource.recycle(getBitmap(photo)); 942 } 943 } 944 945 public void setHighlight(View photo, boolean highlighted) { 946 ImageView image = (ImageView) photo; 947 LayerDrawable layers = (LayerDrawable) image.getDrawable(); 948 if (highlighted) { 949 layers.getDrawable(1).setColorFilter(mHighlightColor, PorterDuff.Mode.SRC_IN); 950 } else { 951 layers.getDrawable(1).clearColorFilter(); 952 } 953 } 954 955 /** Schedule the first launch. Idempotent. */ 956 public void start() { 957 if (!mStarted) { 958 log("kick it"); 959 mStarted = true; 960 scheduleNext(0); 961 } 962 } 963 964 public void refreshSelection() { 965 scheduleSelectionReaper(mMaxFocusTime); 966 } 967 968 public void scheduleSelectionReaper(int delay) { 969 removeCallbacks(mSelectionReaper); 970 postDelayed(mSelectionReaper, delay); 971 } 972 973 public void refreshFocus() { 974 scheduleFocusReaper(mMaxFocusTime); 975 } 976 977 public void scheduleFocusReaper(int delay) { 978 removeCallbacks(mFocusReaper); 979 postDelayed(mFocusReaper, delay); 980 } 981 982 public void scheduleNext(int delay) { 983 removeCallbacks(mLauncher); 984 postDelayed(mLauncher, delay); 985 } 986 987 private static void log(String message, Object... args) { 988 if (DEBUG) { 989 Formatter formatter = new Formatter(); 990 formatter.format(message, args); 991 Log.i(TAG, formatter.toString()); 992 } 993 } 994 } 995