/* * Copyright (C) 2012 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.dreams.phototable; import android.content.Context; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.PointF; import android.graphics.PorterDuff; import android.graphics.Rect; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.LayerDrawable; import android.os.AsyncTask; import android.service.dreams.DreamService; import android.util.AttributeSet; import android.util.Log; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; import android.view.ViewPropertyAnimator; import android.view.animation.DecelerateInterpolator; import android.view.animation.Interpolator; import android.widget.FrameLayout; import android.widget.ImageView; import java.util.ArrayList; import java.util.Formatter; import java.util.HashSet; import java.util.LinkedList; import java.util.Random; import java.util.Set; /** * A surface where photos sit. */ public class PhotoTable extends FrameLayout { private static final String TAG = "PhotoTable"; private static final boolean DEBUG = false; class Launcher implements Runnable { @Override public void run() { PhotoTable.this.scheduleNext(mDropPeriod); PhotoTable.this.launch(); } } class FocusReaper implements Runnable { @Override public void run() { PhotoTable.this.clearFocus(); } } class SelectionReaper implements Runnable { @Override public void run() { PhotoTable.this.clearSelection(); } } private static final int NEXT = 1; private static final int PREV = 0; private static Random sRNG = new Random(); private final Launcher mLauncher; private final FocusReaper mFocusReaper; private final SelectionReaper mSelectionReaper; private final LinkedList mOnTable; private final int mDropPeriod; private final int mFastDropPeriod; private final int mNowDropDelay; private final float mImageRatio; private final float mTableRatio; private final float mImageRotationLimit; private final float mThrowRotation; private final float mThrowSpeed; private final boolean mTapToExit; private final int mTableCapacity; private final int mRedealCount; private final int mInset; private final PhotoSource mPhotoSource; private final Resources mResources; private final Interpolator mThrowInterpolator; private final Interpolator mDropInterpolator; private final DragGestureDetector mDragGestureDetector; private final EdgeSwipeDetector mEdgeSwipeDetector; private final KeyboardInterpreter mKeyboardInterpreter; private final boolean mStoryModeEnabled; private final boolean mBackgroudOptimization; private final long mPickUpDuration; private final int mMaxSelectionTime; private final int mMaxFocusTime; private DreamService mDream; private PhotoLaunchTask mPhotoLaunchTask; private LoadNaturalSiblingTask mLoadOnDeckTasks[]; private boolean mStarted; private boolean mIsLandscape; private int mLongSide; private int mShortSide; private int mWidth; private int mHeight; private View mSelection; private View mOnDeck[]; private View mFocus; private int mHighlightColor; private ViewGroup mBackground; private ViewGroup mStageLeft; private View mScrim; private final Set mWaitingToJoinBackground; public PhotoTable(Context context, AttributeSet as) { super(context, as); mResources = getResources(); mInset = mResources.getDimensionPixelSize(R.dimen.photo_inset); mDropPeriod = mResources.getInteger(R.integer.table_drop_period); mFastDropPeriod = mResources.getInteger(R.integer.fast_drop); mNowDropDelay = mResources.getInteger(R.integer.now_drop); mImageRatio = mResources.getInteger(R.integer.image_ratio) / 1000000f; mTableRatio = mResources.getInteger(R.integer.table_ratio) / 1000000f; mImageRotationLimit = (float) mResources.getInteger(R.integer.max_image_rotation); mThrowSpeed = mResources.getDimension(R.dimen.image_throw_speed); mPickUpDuration = mResources.getInteger(R.integer.photo_pickup_duration); mThrowRotation = (float) mResources.getInteger(R.integer.image_throw_rotatioan); mTableCapacity = mResources.getInteger(R.integer.table_capacity); mRedealCount = mResources.getInteger(R.integer.redeal_count); mTapToExit = mResources.getBoolean(R.bool.enable_tap_to_exit); mStoryModeEnabled = mResources.getBoolean(R.bool.enable_story_mode); mBackgroudOptimization = mResources.getBoolean(R.bool.enable_background_optimization); mHighlightColor = mResources.getColor(R.color.highlight_color); mMaxSelectionTime = mResources.getInteger(R.integer.max_selection_time); mMaxFocusTime = mResources.getInteger(R.integer.max_focus_time); mThrowInterpolator = new SoftLandingInterpolator( mResources.getInteger(R.integer.soft_landing_time) / 1000000f, mResources.getInteger(R.integer.soft_landing_distance) / 1000000f); mDropInterpolator = new DecelerateInterpolator( (float) mResources.getInteger(R.integer.drop_deceleration_exponent)); mOnTable = new LinkedList(); mPhotoSource = new PhotoSourcePlexor(getContext(), getContext().getSharedPreferences(PhotoTableDreamSettings.PREFS_NAME, 0)); mWaitingToJoinBackground = new HashSet(); mLauncher = new Launcher(); mFocusReaper = new FocusReaper(); mSelectionReaper = new SelectionReaper(); mDragGestureDetector = new DragGestureDetector(context, this); mEdgeSwipeDetector = new EdgeSwipeDetector(context, this); mKeyboardInterpreter = new KeyboardInterpreter(this); mLoadOnDeckTasks = new LoadNaturalSiblingTask[2]; mOnDeck = new View[2]; mStarted = false; } @Override public void onFinishInflate() { mBackground = (ViewGroup) findViewById(R.id.background); mStageLeft = (ViewGroup) findViewById(R.id.stageleft); mScrim = findViewById(R.id.scrim); } public void setDream(DreamService dream) { mDream = dream; } public boolean hasSelection() { return mSelection != null; } public View getSelection() { return mSelection; } public void clearSelection() { if (hasSelection()) { dropOnTable(mSelection); mPhotoSource.donePaging(getBitmap(mSelection)); if (mStoryModeEnabled) { fadeInBackground(mSelection); } mSelection = null; } for (int slot = 0; slot < mOnDeck.length; slot++) { if (mOnDeck[slot] != null) { fadeAway(mOnDeck[slot], false); mOnDeck[slot] = null; } if (mLoadOnDeckTasks[slot] != null && mLoadOnDeckTasks[slot].getStatus() != AsyncTask.Status.FINISHED) { mLoadOnDeckTasks[slot].cancel(true); mLoadOnDeckTasks[slot] = null; } } } public void setSelection(View selected) { if (selected != null) { clearSelection(); mSelection = selected; promoteSelection(); if (mStoryModeEnabled) { fadeOutBackground(mSelection); } } } public void selectNext() { if (mStoryModeEnabled) { log("selectNext"); if (hasSelection() && mOnDeck[NEXT] != null) { placeOnDeck(mSelection, PREV); mSelection = mOnDeck[NEXT]; mOnDeck[NEXT] = null; promoteSelection(); } } else { clearSelection(); } } public void selectPrevious() { if (mStoryModeEnabled) { log("selectPrevious"); if (hasSelection() && mOnDeck[PREV] != null) { placeOnDeck(mSelection, NEXT); mSelection = mOnDeck[PREV]; mOnDeck[PREV] = null; promoteSelection(); } } else { clearSelection(); } } private void promoteSelection() { if (hasSelection()) { scheduleSelectionReaper(mMaxSelectionTime); mSelection.animate().cancel(); mSelection.setAlpha(1f); moveToTopOfPile(mSelection); pickUp(mSelection); if (mStoryModeEnabled) { for (int slot = 0; slot < mOnDeck.length; slot++) { if (mLoadOnDeckTasks[slot] != null && mLoadOnDeckTasks[slot].getStatus() != AsyncTask.Status.FINISHED) { mLoadOnDeckTasks[slot].cancel(true); } if (mOnDeck[slot] == null) { mLoadOnDeckTasks[slot] = new LoadNaturalSiblingTask(slot); mLoadOnDeckTasks[slot].execute(mSelection); } } } } } public boolean hasFocus() { return mFocus != null; } public View getFocus() { return mFocus; } public void clearFocus() { if (hasFocus()) { setHighlight(getFocus(), false); } mFocus = null; } public void setDefaultFocus() { if (mOnTable.size() > 0) { setFocus(mOnTable.getLast()); } } public void setFocus(View focus) { assert(focus != null); clearFocus(); mFocus = focus; moveToTopOfPile(focus); setHighlight(focus, true); scheduleFocusReaper(mMaxFocusTime); } static float lerp(float a, float b, float f) { return (b-a)*f + a; } static float randfrange(float a, float b) { return lerp(a, b, sRNG.nextFloat()); } static PointF randFromCurve(float t, PointF[] v) { PointF p = new PointF(); if (v.length == 4 && t >= 0f && t <= 1f) { float a = (float) Math.pow(1f-t, 3f); float b = (float) Math.pow(1f-t, 2f) * t; float c = (1f-t) * (float) Math.pow(t, 2f); float d = (float) Math.pow(t, 3f); p.x = a * v[0].x + 3 * b * v[1].x + 3 * c * v[2].x + d * v[3].x; p.y = a * v[0].y + 3 * b * v[1].y + 3 * c * v[2].y + d * v[3].y; } return p; } private static PointF randMultiDrop(int n, float i, float j, int width, int height) { log("randMultiDrop (%d, %f, %f, %d, %d)", n, i, j, width, height); final float[] cx = {0.3f, 0.3f, 0.5f, 0.7f, 0.7f}; final float[] cy = {0.3f, 0.7f, 0.5f, 0.3f, 0.7f}; n = Math.abs(n); float x = cx[n % cx.length]; float y = cy[n % cx.length]; PointF p = new PointF(); p.x = x * width + 0.05f * width * i; p.y = y * height + 0.05f * height * j; log("randInCenter returning %f, %f", p.x, p.y); return p; } private double cross(double[] a, double[] b) { return a[0] * b[1] - a[1] * b[0]; } private double norm(double[] a) { return Math.hypot(a[0], a[1]); } private double[] getCenter(View photo) { float width = (float) ((Integer) photo.getTag(R.id.photo_width)).intValue(); float height = (float) ((Integer) photo.getTag(R.id.photo_height)).intValue(); double[] center = { photo.getX() + width / 2f, - (photo.getY() + height / 2f) }; return center; } public View moveFocus(View focus, float direction) { return moveFocus(focus, direction, 90f); } public View moveFocus(View focus, float direction, float angle) { if (focus == null) { if (mOnTable.size() > 0) { setFocus(mOnTable.getLast()); } } else { final double alpha = Math.toRadians(direction); final double beta = Math.toRadians(Math.min(angle, 180f) / 2f); final double[] left = { Math.sin(alpha - beta), Math.cos(alpha - beta) }; final double[] right = { Math.sin(alpha + beta), Math.cos(alpha + beta) }; final double[] a = getCenter(focus); View bestFocus = null; double bestDistance = Double.MAX_VALUE; for (View candidate: mOnTable) { if (candidate != focus) { final double[] b = getCenter(candidate); final double[] delta = { b[0] - a[0], b[1] - a[1] }; if (cross(delta, left) > 0.0 && cross(delta, right) < 0.0) { final double distance = norm(delta); if (bestDistance > distance) { bestDistance = distance; bestFocus = candidate; } } } } if (bestFocus == null) { if (angle < 180f) { return moveFocus(focus, direction, 180f); } } else { setFocus(bestFocus); } } return getFocus(); } @Override public boolean onKeyDown(int keyCode, KeyEvent event) { return mKeyboardInterpreter.onKeyDown(keyCode, event); } @Override public boolean onGenericMotionEvent(MotionEvent event) { return mEdgeSwipeDetector.onTouchEvent(event) || mDragGestureDetector.onTouchEvent(event); } @Override public boolean onTouchEvent(MotionEvent event) { if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { if (hasSelection()) { clearSelection(); } else { if (mTapToExit && mDream != null) { mDream.finish(); } } return true; } return false; } @Override public void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); log("onLayout (%d, %d, %d, %d)", left, top, right, bottom); mHeight = bottom - top; mWidth = right - left; mLongSide = (int) (mImageRatio * Math.max(mWidth, mHeight)); mShortSide = (int) (mImageRatio * Math.min(mWidth, mHeight)); boolean isLandscape = mWidth > mHeight; if (mIsLandscape != isLandscape) { for (View photo: mOnTable) { if (photo != getSelection()) { dropOnTable(photo); } } if (hasSelection()) { pickUp(getSelection()); for (int slot = 0; slot < mOnDeck.length; slot++) { if (mOnDeck[slot] != null) { placeOnDeck(mOnDeck[slot], slot); } } } mIsLandscape = isLandscape; } start(); } @Override public boolean isOpaque() { return true; } /** Put a nice border on the bitmap. */ private static View applyFrame(final PhotoTable table, final BitmapFactory.Options options, Bitmap decodedPhoto) { LayoutInflater inflater = (LayoutInflater) table.getContext() .getSystemService(Context.LAYOUT_INFLATER_SERVICE); View photo = inflater.inflate(R.layout.photo, null); ImageView image = (ImageView) photo; Drawable[] layers = new Drawable[2]; int photoWidth = options.outWidth; int photoHeight = options.outHeight; if (decodedPhoto == null || options.outWidth <= 0 || options.outHeight <= 0) { photo = null; } else { decodedPhoto.setHasMipMap(true); layers[0] = new BitmapDrawable(table.mResources, decodedPhoto); layers[1] = table.mResources.getDrawable(R.drawable.frame); LayerDrawable layerList = new LayerDrawable(layers); layerList.setLayerInset(0, table.mInset, table.mInset, table.mInset, table.mInset); image.setImageDrawable(layerList); photo.setTag(R.id.photo_width, Integer.valueOf(photoWidth)); photo.setTag(R.id.photo_height, Integer.valueOf(photoHeight)); photo.setOnTouchListener(new PhotoTouchListener(table.getContext(), table)); } return photo; } private class LoadNaturalSiblingTask extends AsyncTask { private final BitmapFactory.Options mOptions; private final int mSlot; private View mParent; public LoadNaturalSiblingTask (int slot) { mOptions = new BitmapFactory.Options(); mOptions.inTempStorage = new byte[32768]; mSlot = slot; } @Override public View doInBackground(View... views) { log("load natural %s", (mSlot == NEXT ? "next" : "previous")); final PhotoTable table = PhotoTable.this; mParent = views[0]; final Bitmap current = getBitmap(mParent); Bitmap decodedPhoto; if (mSlot == NEXT) { decodedPhoto = table.mPhotoSource.naturalNext(current, mOptions, table.mLongSide, table.mShortSide); } else { decodedPhoto = table.mPhotoSource.naturalPrevious(current, mOptions, table.mLongSide, table.mShortSide); } return applyFrame(PhotoTable.this, mOptions, decodedPhoto); } @Override public void onPostExecute(View photo) { if (photo != null) { if (hasSelection() && getSelection() == mParent) { log("natural %s being rendered", (mSlot == NEXT ? "next" : "previous")); PhotoTable.this.addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); PhotoTable.this.mOnDeck[mSlot] = photo; float width = (float) ((Integer) photo.getTag(R.id.photo_width)).intValue(); float height = (float) ((Integer) photo.getTag(R.id.photo_height)).intValue(); photo.setX(mSlot == PREV ? -2 * width : mWidth + 2 * width); photo.setY((mHeight - height) / 2); photo.addOnLayoutChangeListener(new OnLayoutChangeListener() { @Override public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { PhotoTable.this.placeOnDeck(v, mSlot); v.removeOnLayoutChangeListener(this); } }); } else { recycle(photo); } } else { log("natural, %s was null!", (mSlot == NEXT ? "next" : "previous")); } } }; private class PhotoLaunchTask extends AsyncTask { private final BitmapFactory.Options mOptions; public PhotoLaunchTask () { mOptions = new BitmapFactory.Options(); mOptions.inTempStorage = new byte[32768]; } @Override public View doInBackground(Void... unused) { log("load a new photo"); final PhotoTable table = PhotoTable.this; return applyFrame(PhotoTable.this, mOptions, table.mPhotoSource.next(mOptions, table.mLongSide, table.mShortSide)); } @Override public void onPostExecute(View photo) { if (photo != null) { final PhotoTable table = PhotoTable.this; table.addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); if (table.hasSelection()) { for (int slot = 0; slot < mOnDeck.length; slot++) { if (mOnDeck[slot] != null) { table.moveToTopOfPile(mOnDeck[slot]); } } table.moveToTopOfPile(table.getSelection()); } log("drop it"); table.throwOnTable(photo); if (mOnTable.size() > mTableCapacity) { int targetSize = Math.max(0, mOnTable.size() - mRedealCount); while (mOnTable.size() > targetSize) { fadeAway(mOnTable.poll(), false); } } if(table.mOnTable.size() < table.mTableCapacity) { table.scheduleNext(table.mFastDropPeriod); } } } }; /** Bring a new photo onto the table. */ public void launch() { log("launching"); setSystemUiVisibility(View.SYSTEM_UI_FLAG_LOW_PROFILE); if (!hasSelection()) { log("inflate it"); if (mPhotoLaunchTask == null || mPhotoLaunchTask.getStatus() == AsyncTask.Status.FINISHED) { mPhotoLaunchTask = new PhotoLaunchTask(); mPhotoLaunchTask.execute(); } } } /** De-emphasize the other photos on the table. */ public void fadeOutBackground(final View photo) { resolveBackgroundQueue(); if (mBackgroudOptimization) { mBackground.animate() .withLayer() .setDuration(mPickUpDuration) .alpha(0f); } else { mScrim.setAlpha(0f); mScrim.setVisibility(View.VISIBLE); bringChildToFront(mScrim); bringChildToFront(photo); mScrim.animate() .withLayer() .setDuration(mPickUpDuration) .alpha(1f); } } /** Return the other photos to foreground status. */ public void fadeInBackground(final View photo) { if (mBackgroudOptimization) { mWaitingToJoinBackground.add(photo); mBackground.animate() .withLayer() .setDuration(mPickUpDuration) .alpha(1f) .withEndAction(new Runnable() { @Override public void run() { resolveBackgroundQueue(); } }); } else { bringChildToFront(mScrim); bringChildToFront(photo); mScrim.animate() .withLayer() .setDuration(mPickUpDuration) .alpha(0f) .withEndAction(new Runnable() { @Override public void run() { mScrim.setVisibility(View.GONE); } }); } } private void resolveBackgroundQueue() { for(View photo: mWaitingToJoinBackground) { moveToBackground(photo); } mWaitingToJoinBackground.clear(); } /** Dispose of the photo gracefully, in case we can see some of it. */ public void fadeAway(final View photo, final boolean replace) { // fade out of view mOnTable.remove(photo); exitStageLeft(photo); photo.setOnTouchListener(null); photo.animate().cancel(); photo.animate() .withLayer() .alpha(0f) .setDuration(mPickUpDuration) .withEndAction(new Runnable() { @Override public void run() { if (photo == getFocus()) { clearFocus(); } mStageLeft.removeView(photo); recycle(photo); if (replace) { scheduleNext(mNowDropDelay); } } }); } /** Visually on top, and also freshest, for the purposes of timeouts. */ public void moveToTopOfPile(View photo) { // make this photo the last to be removed. if (isInBackground(photo)) { mBackground.bringChildToFront(photo); } else { bringChildToFront(photo); } invalidate(); mOnTable.remove(photo); mOnTable.offer(photo); } /** On deck is to the left or right of the selected photo. */ private void placeOnDeck(final View photo, final int slot ) { if (slot < mOnDeck.length) { if (mOnDeck[slot] != null && mOnDeck[slot] != photo) { fadeAway(mOnDeck[slot], false); } mOnDeck[slot] = photo; float photoWidth = photo.getWidth(); float photoHeight = photo.getHeight(); float scale = Math.min(getHeight() / photoHeight, getWidth() / photoWidth); float x = (getWidth() - photoWidth) / 2f; float y = (getHeight() - photoHeight) / 2f; float offset = (((float) mWidth + scale * (photoWidth - 2f * mInset)) / 2f); x += (slot == NEXT? 1f : -1f) * offset; photo.animate() .withLayer() .rotation(0f) .rotationY(0f) .scaleX(scale) .scaleY(scale) .x(x) .y(y) .setDuration(mPickUpDuration) .setInterpolator(new DecelerateInterpolator(2f)); } } /** Move in response to touch. */ public void move(final View photo, float x, float y, float a) { photo.animate().cancel(); photo.setAlpha(1f); photo.setX((int) x); photo.setY((int) y); photo.setRotation((int) a); } /** Wind up off screen, so we can animate in. */ private void throwOnTable(final View photo) { mOnTable.offer(photo); log("start offscreen"); photo.setRotation(mThrowRotation); photo.setX(-mLongSide); photo.setY(-mLongSide); dropOnTable(photo, mThrowInterpolator); } public void move(final View photo, float dx, float dy, boolean drop) { if (photo != null) { final float x = photo.getX() + dx; final float y = photo.getY() + dy; photo.setX(x); photo.setY(y); Log.d(TAG, "[" + photo.getX() + ", " + photo.getY() + "] + (" + dx + "," + dy + ")"); if (drop && photoOffTable(photo)) { fadeAway(photo, true); } } } /** Fling with no touch hints, then land off screen. */ public void fling(final View photo) { final float[] o = { mWidth + mLongSide / 2f, mHeight + mLongSide / 2f }; final float[] a = { photo.getX(), photo.getY() }; final float[] b = { o[0], a[1] + o[0] - a[0] }; final float[] c = { a[0] + o[1] - a[1], o[1] }; float[] delta = { 0f, 0f }; if (Math.hypot(b[0] - a[0], b[1] - a[1]) < Math.hypot(c[0] - a[0], c[1] - a[1])) { delta[0] = b[0] - a[0]; delta[1] = b[1] - a[1]; } else { delta[0] = c[0] - a[0]; delta[1] = c[1] - a[1]; } final float dist = (float) Math.hypot(delta[0], delta[1]); final int duration = (int) (1000f * dist / mThrowSpeed); fling(photo, delta[0], delta[1], duration, true); } /** Continue dynamically after a fling gesture, possibly off the screen. */ public void fling(final View photo, float dx, float dy, int duration, boolean spin) { if (photo == getFocus()) { if (moveFocus(photo, 0f) == null) { moveFocus(photo, 180f); } } moveToForeground(photo); ViewPropertyAnimator animator = photo.animate() .withLayer() .xBy(dx) .yBy(dy) .setDuration(duration) .setInterpolator(new DecelerateInterpolator(2f)); if (spin) { animator.rotation(mThrowRotation); } if (photoOffTable(photo, (int) dx, (int) dy)) { log("fling away"); animator.withEndAction(new Runnable() { @Override public void run() { fadeAway(photo, true); } }); } } public boolean photoOffTable(final View photo) { return photoOffTable(photo, 0, 0); } public boolean photoOffTable(final View photo, final int dx, final int dy) { Rect hit = new Rect(); photo.getHitRect(hit); hit.offset(dx, dy); return (hit.bottom < 0f || hit.top > getHeight() || hit.right < 0f || hit.left > getWidth()); } /** Animate to a random place and orientation, down on the table (visually small). */ public void dropOnTable(final View photo) { dropOnTable(photo, mDropInterpolator); } /** Animate to a random place and orientation, down on the table (visually small). */ public void dropOnTable(final View photo, final Interpolator interpolator) { float angle = randfrange(-mImageRotationLimit, mImageRotationLimit); PointF p = randMultiDrop(sRNG.nextInt(), (float) sRNG.nextGaussian(), (float) sRNG.nextGaussian(), mWidth, mHeight); float x = p.x; float y = p.y; log("drop it at %f, %f", x, y); float x0 = photo.getX(); float y0 = photo.getY(); x -= mLongSide / 2f; y -= mShortSide / 2f; log("fixed offset is %f, %f ", x, y); float dx = x - x0; float dy = y - y0; float dist = (float) Math.hypot(dx, dy); int duration = (int) (1000f * dist / mThrowSpeed); duration = Math.max(duration, 1000); log("animate it"); // toss onto table resolveBackgroundQueue(); photo.animate() .withLayer() .scaleX(mTableRatio / mImageRatio) .scaleY(mTableRatio / mImageRatio) .rotation(angle) .x(x) .y(y) .setDuration(duration) .setInterpolator(interpolator) .withEndAction(new Runnable() { @Override public void run() { mWaitingToJoinBackground.add(photo); } }); } private void moveToBackground(View photo) { if (mBackgroudOptimization && !isInBackground(photo)) { removeViewFromParent(photo); mBackground.addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); } } private void exitStageLeft(View photo) { removeViewFromParent(photo); mStageLeft.addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); } private void removeViewFromParent(View photo) { ViewParent parent = photo.getParent(); if (parent != null) { // should never be null, just being paranoid ((ViewGroup) parent).removeView(photo); } } private void moveToForeground(View photo) { if (mBackgroudOptimization && isInBackground(photo)) { mBackground.removeView(photo); addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); } } private boolean isInBackground(View photo) { return mBackgroudOptimization && mBackground.indexOfChild(photo) != -1; } /** wrap all orientations to the interval [-180, 180). */ private float wrapAngle(float angle) { float result = angle + 180; result = ((result % 360) + 360) % 360; // catch negative numbers result -= 180; return result; } /** Animate the selected photo to the foreground: zooming in to bring it forward. */ private void pickUp(final View photo) { float photoWidth = photo.getWidth(); float photoHeight = photo.getHeight(); float scale = Math.min(getHeight() / photoHeight, getWidth() / photoWidth); log("scale is %f", scale); log("target it"); float x = (getWidth() - photoWidth) / 2f; float y = (getHeight() - photoHeight) / 2f; photo.setRotation(wrapAngle(photo.getRotation())); log("animate it"); // lift up to the glass for a good look mWaitingToJoinBackground.remove(photo); moveToForeground(photo); photo.animate() .withLayer() .rotation(0f) .rotationY(0f) .alpha(1f) .scaleX(scale) .scaleY(scale) .x(x) .y(y) .setDuration(mPickUpDuration) .setInterpolator(new DecelerateInterpolator(2f)) .withEndAction(new Runnable() { @Override public void run() { log("endtimes: %f", photo.getX()); } }); } private Bitmap getBitmap(View photo) { if (photo == null) { return null; } ImageView image = (ImageView) photo; LayerDrawable layers = (LayerDrawable) image.getDrawable(); if (layers == null) { return null; } BitmapDrawable bitmap = (BitmapDrawable) layers.getDrawable(0); if (bitmap == null) { return null; } return bitmap.getBitmap(); } private void recycle(View photo) { if (photo != null) { removeViewFromParent(photo); mPhotoSource.recycle(getBitmap(photo)); } } public void setHighlight(View photo, boolean highlighted) { ImageView image = (ImageView) photo; LayerDrawable layers = (LayerDrawable) image.getDrawable(); if (highlighted) { layers.getDrawable(1).setColorFilter(mHighlightColor, PorterDuff.Mode.SRC_IN); } else { layers.getDrawable(1).clearColorFilter(); } } /** Schedule the first launch. Idempotent. */ public void start() { if (!mStarted) { log("kick it"); mStarted = true; scheduleNext(0); } } public void refreshSelection() { scheduleSelectionReaper(mMaxFocusTime); } public void scheduleSelectionReaper(int delay) { removeCallbacks(mSelectionReaper); postDelayed(mSelectionReaper, delay); } public void refreshFocus() { scheduleFocusReaper(mMaxFocusTime); } public void scheduleFocusReaper(int delay) { removeCallbacks(mFocusReaper); postDelayed(mFocusReaper, delay); } public void scheduleNext(int delay) { removeCallbacks(mLauncher); postDelayed(mLauncher, delay); } private static void log(String message, Object... args) { if (DEBUG) { Formatter formatter = new Formatter(); formatter.format(message, args); Log.i(TAG, formatter.toString()); } } }