/* * Copyright (C) 2013 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.camera.ui; import com.android.gallery3d.R; import android.animation.Animator; import android.animation.TimeInterpolator; import android.animation.ValueAnimator; import android.content.Context; import android.graphics.Canvas; import android.graphics.Rect; import android.graphics.RectF; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.animation.DecelerateInterpolator; import android.widget.Scroller; public class FilmStripView extends ViewGroup { private static final String TAG = FilmStripView.class.getSimpleName(); private static final int BUFFER_SIZE = 5; // Horizontal padding of children. // Duration to go back to the first. private static final int DURATION_BACK_ANIM = 500; private static final int DURATION_SCROLL_TO_FILMSTRIP = 350; private static final int DURATION_GEOMETRY_ADJUST = 200; private static final float FILM_STRIP_SCALE = 0.6f; private static final float FULLSCREEN_SCALE = 1f; // Only check for intercepting touch events within first 500ms private static final int SWIPE_TIME_OUT = 500; private Context mContext; private FilmStripGestureRecognizer mGestureRecognizer; private DataAdapter mDataAdapter; private int mViewGap; private final Rect mDrawArea = new Rect(); private final int mCurrentInfo = (BUFFER_SIZE - 1) / 2; private float mScale; private MyController mController; private int mCenterX = -1; private ViewInfo[] mViewInfo = new ViewInfo[BUFFER_SIZE]; private Listener mListener; private MotionEvent mDown; private boolean mCheckToIntercept = true; private View mCameraView; private ImageData mCameraData; private int mSlop; private TimeInterpolator mViewAnimInterpolator; // This is used to resolve the misalignment problem when the device // orientation is changed. If the current item is in fullscreen, it might // be shifted because mCenterX is not adjusted with the orientation. // Set this to true when onSizeChanged is called to make sure we adjust // mCenterX accordingly. private boolean mAnchorPending; public interface ImageData { public static final int TYPE_NONE = 0; public static final int TYPE_CAMERA_PREVIEW = 1; public static final int TYPE_PHOTO = 2; public static final int TYPE_VIDEO = 3; public static final int TYPE_PHOTOSPHERE = 4; // The actions are defined bit-wise so we can use bit operations like // | and &. public static final int ACTION_NONE = 0; public static final int ACTION_PROMOTE = 1; public static final int ACTION_DEMOTE = (1 << 1); // SIZE_FULL means disgard the width or height when deciding the view size // of this ImageData, just use full screen size. public static final int SIZE_FULL = -2; // The values returned by getWidth() and getHeight() will be used for layout. public int getWidth(); public int getHeight(); public int getType(); public boolean isUIActionSupported(int action); // prepare() should be called first time before using it. public void prepare(); // recycle() should be called before we nullify the reference to this // data. public void recycle(); } public interface DataAdapter { public interface UpdateReporter { public boolean isDataRemoved(int id); public boolean isDataUpdated(int id); } public interface Listener { // Called when the whole data loading is done. No any assumption // on previous data. public void onDataLoaded(); // Only some of the data is changed. The listener should check // if any thing needs to be updated. public void onDataUpdated(UpdateReporter reporter); public void onDataInserted(int dataID, ImageData data); public void onDataRemoved(int dataID, ImageData data); } public int getTotalNumber(); public View getView(Context context, int id); public ImageData getImageData(int id); public void suggestDecodeSize(int w, int h); public void setListener(Listener listener); // true if the view of the data can be moved when in fullscreen. public boolean canSwipeInFullScreen(int id); } public interface Listener { public void onDataPromoted(int dataID); public void onDataDemoted(int dataID); public void onDataFullScreenChange(int dataID, boolean full); public void onSwitchMode(boolean toCamera); } public interface Controller { public boolean isScalling(); public void scroll(float deltaX); public void fling(float velocity); public void scrollTo(int position, int duration, boolean interruptible); public boolean stopScrolling(); public boolean isScrolling(); public void lockAtCurrentView(); public void unlockPosition(); public void gotoCameraFullScreen(); public void gotoFilmStrip(); public void gotoFullScreen(); } // A helper class to tract and calculate the view coordination. private static class ViewInfo { private int mDataID; // the position of the left of the view in the whole filmstrip. private int mLeftPosition; private View mView; private RectF mViewArea; public ViewInfo(int id, View v) { v.setPivotX(0f); v.setPivotY(0f); mDataID = id; mView = v; mLeftPosition = -1; mViewArea = new RectF(); } public int getID() { return mDataID; } public void setID(int id) { mDataID = id; } public void setLeftPosition(int pos) { mLeftPosition = pos; } public int getLeftPosition() { return mLeftPosition; } public float getTranslationY(float scale) { return mView.getTranslationY() / scale; } public float getTranslationX(float scale) { return mView.getTranslationX(); } public void setTranslationY(float transY, float scale) { mView.setTranslationY(transY * scale); } public void setTranslationX(float transX, float scale) { mView.setTranslationX(transX * scale); } public void translateXBy(float transX, float scale) { mView.setTranslationX(mView.getTranslationX() + transX * scale); } public int getCenterX() { return mLeftPosition + mView.getWidth() / 2; } public int getMeasuredCenterX(float scale) { return mLeftPosition + (int) (mView.getMeasuredWidth() * scale / 2); } public View getView() { return mView; } private void layoutAt(int left, int top) { mView.layout(left, top, left + mView.getMeasuredWidth(), top + mView.getMeasuredHeight()); } public void layoutIn(Rect drawArea, int refCenter, float scale) { // drawArea is where to layout in. // refCenter is the absolute horizontal position of the center of drawArea. int left = (int) (drawArea.centerX() + (mLeftPosition - refCenter) * scale); int top = (int) (drawArea.centerY() - (mView.getMeasuredHeight() / 2) * scale); layoutAt(left, top); mView.setScaleX(scale); mView.setScaleY(scale); // update mViewArea for touch detection. int l = mView.getLeft(); int t = mView.getTop(); mViewArea.set(l, t, l + mView.getWidth() * scale, t + mView.getHeight() * scale); } public boolean areaContains(float x, float y) { return mViewArea.contains(x, y); } } public FilmStripView(Context context) { super(context); init(context); } public FilmStripView(Context context, AttributeSet attrs) { super(context, attrs); init(context); } public FilmStripView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(context); } private void init(Context context) { // This is for positioning camera controller at the same place in // different orientations. setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION); setWillNotDraw(false); mContext = context; mScale = 1.0f; mController = new MyController(context); mViewAnimInterpolator = new DecelerateInterpolator(); mGestureRecognizer = new FilmStripGestureRecognizer(context, new MyGestureReceiver()); mSlop = (int) getContext().getResources().getDimension(R.dimen.pie_touch_slop); } public Controller getController() { return mController; } public void setListener(Listener l) { mListener = l; } public void setViewGap(int viewGap) { mViewGap = viewGap; } public float getScale() { return mScale; } public boolean isAnchoredTo(int id) { if (mViewInfo[mCurrentInfo].getID() == id && mViewInfo[mCurrentInfo].getCenterX() == mCenterX) { return true; } return false; } public int getCurrentType() { if (mDataAdapter == null) return ImageData.TYPE_NONE; ViewInfo curr = mViewInfo[mCurrentInfo]; if (curr == null) return ImageData.TYPE_NONE; return mDataAdapter.getImageData(curr.getID()).getType(); } @Override public void onDraw(Canvas c) { if (mController.hasNewGeometry()) { layoutChildren(); } } // returns [width, height] preserving image aspect ratio private int[] calculateChildDimension( int imageWidth, int imageHeight, int boundWidth, int boundHeight) { if (imageWidth == ImageData.SIZE_FULL || imageHeight == ImageData.SIZE_FULL) { imageWidth = boundWidth; imageHeight = boundHeight; } int[] ret = new int[2]; ret[0] = boundWidth; ret[1] = boundHeight; if (imageWidth * ret[1] > ret[0] * imageHeight) { ret[1] = imageHeight * ret[0] / imageWidth; } else { ret[0] = imageWidth * ret[1] / imageHeight; } return ret; } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int boundWidth = MeasureSpec.getSize(widthMeasureSpec); int boundHeight = MeasureSpec.getSize(heightMeasureSpec); if (mDataAdapter != null) { mDataAdapter.suggestDecodeSize(boundWidth / 2, boundHeight / 2); } for (int i = 0; i < mViewInfo.length; i++) { ViewInfo info = mViewInfo[i]; if (mViewInfo[i] == null) continue; int id = info.getID(); int[] dim = calculateChildDimension( mDataAdapter.getImageData(id).getWidth(), mDataAdapter.getImageData(id).getHeight(), boundWidth, boundHeight); mViewInfo[i].getView().measure( View.MeasureSpec.makeMeasureSpec( dim[0], View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec( dim[1], View.MeasureSpec.EXACTLY)); } setMeasuredDimension(boundWidth, boundHeight); } private int findTheNearestView(int pointX) { int nearest = 0; // find the first non-null ViewInfo. for (; nearest < BUFFER_SIZE && (mViewInfo[nearest] == null || mViewInfo[nearest].getLeftPosition() == -1); nearest++); // no existing available ViewInfo if (nearest == BUFFER_SIZE) return -1; int min = Math.abs(pointX - mViewInfo[nearest].getCenterX()); for (int infoID = nearest + 1; infoID < BUFFER_SIZE && mViewInfo[infoID] != null; infoID++) { // not measured yet. if (mViewInfo[infoID].getLeftPosition() == -1) continue; int c = mViewInfo[infoID].getCenterX(); int dist = Math.abs(pointX - c); if (dist < min) { min = dist; nearest = infoID; } } return nearest; } private ViewInfo buildInfoFromData(int dataID) { ImageData data = mDataAdapter.getImageData(dataID); if (data == null) return null; data.prepare(); View v = mDataAdapter.getView(mContext, dataID); if (v == null) return null; ViewInfo info = new ViewInfo(dataID, v); v = info.getView(); if (v != mCameraView) { addView(info.getView()); } else { v.setVisibility(View.VISIBLE); } return info; } private void removeInfo(int infoID) { if (infoID >= mViewInfo.length || mViewInfo[infoID] == null) return; ImageData data = mDataAdapter.getImageData(mViewInfo[infoID].getID()); checkForRemoval(data, mViewInfo[infoID].getView()); mViewInfo[infoID] = null; } // We try to keep the one closest to the center of the screen at position mCurrentInfo. private void stepIfNeeded() { if (!inFilmStrip() && !inFullScreen()) { // The good timing to step to the next view is when everything is not in // transition. return; } int nearest = findTheNearestView(mCenterX); // no change made. if (nearest == -1 || nearest == mCurrentInfo) return; int adjust = nearest - mCurrentInfo; if (adjust > 0) { for (int k = 0; k < adjust; k++) { removeInfo(k); } for (int k = 0; k + adjust < BUFFER_SIZE; k++) { mViewInfo[k] = mViewInfo[k + adjust]; } for (int k = BUFFER_SIZE - adjust; k < BUFFER_SIZE; k++) { mViewInfo[k] = null; if (mViewInfo[k - 1] != null) { mViewInfo[k] = buildInfoFromData(mViewInfo[k - 1].getID() + 1); } } } else { for (int k = BUFFER_SIZE - 1; k >= BUFFER_SIZE + adjust; k--) { removeInfo(k); } for (int k = BUFFER_SIZE - 1; k + adjust >= 0; k--) { mViewInfo[k] = mViewInfo[k + adjust]; } for (int k = -1 - adjust; k >= 0; k--) { mViewInfo[k] = null; if (mViewInfo[k + 1] != null) { mViewInfo[k] = buildInfoFromData(mViewInfo[k + 1].getID() - 1); } } } } // Don't go beyond the bound. private void clampCenterX() { ViewInfo curr = mViewInfo[mCurrentInfo]; if (curr == null) return; if (curr.getID() == 0 && mCenterX < curr.getCenterX()) { mCenterX = curr.getCenterX(); if (mController.isScrolling()) { mController.stopScrolling(); } if (getCurrentType() == ImageData.TYPE_CAMERA_PREVIEW && !mController.isScalling() && mScale != FULLSCREEN_SCALE) { mController.gotoFullScreen(); } } if (curr.getID() == mDataAdapter.getTotalNumber() - 1 && mCenterX > curr.getCenterX()) { mCenterX = curr.getCenterX(); if (!mController.isScrolling()) { mController.stopScrolling(); } } } private void adjustChildZOrder() { for (int i = BUFFER_SIZE - 1; i >= 0; i--) { if (mViewInfo[i] == null) continue; bringChildToFront(mViewInfo[i].getView()); } } private void layoutChildren() { if (mAnchorPending) { mCenterX = mViewInfo[mCurrentInfo].getCenterX(); mAnchorPending = false; } if (mController.hasNewGeometry()) { mCenterX = mController.getNewPosition(); mScale = mController.getNewScale(); } clampCenterX(); mViewInfo[mCurrentInfo].layoutIn(mDrawArea, mCenterX, mScale); int currentViewLeft = mViewInfo[mCurrentInfo].getLeftPosition(); int currentViewCenter = mViewInfo[mCurrentInfo].getCenterX(); int fullScreenWidth = mDrawArea.width() + mViewGap; float scaleFraction = mViewAnimInterpolator.getInterpolation( (mScale - FILM_STRIP_SCALE) / (FULLSCREEN_SCALE - FILM_STRIP_SCALE)); // images on the left for (int infoID = mCurrentInfo - 1; infoID >= 0; infoID--) { ViewInfo curr = mViewInfo[infoID]; if (curr == null) continue; ViewInfo next = mViewInfo[infoID + 1]; int myLeft = next.getLeftPosition() - curr.getView().getMeasuredWidth() - mViewGap; curr.setLeftPosition(myLeft); curr.layoutIn(mDrawArea, mCenterX, mScale); curr.getView().setAlpha(1f); int infoDiff = mCurrentInfo - infoID; curr.setTranslationX( (currentViewCenter - fullScreenWidth * infoDiff - curr.getCenterX()) * scaleFraction, mScale); } // images on the right for (int infoID = mCurrentInfo + 1; infoID < BUFFER_SIZE; infoID++) { ViewInfo curr = mViewInfo[infoID]; if (curr == null) continue; ViewInfo prev = mViewInfo[infoID - 1]; int myLeft = prev.getLeftPosition() + prev.getView().getMeasuredWidth() + mViewGap; curr.setLeftPosition(myLeft); curr.layoutIn(mDrawArea, mCenterX, mScale); if (infoID == mCurrentInfo + 1) { curr.getView().setAlpha(1f - scaleFraction); } else { if (scaleFraction == 0f) { curr.getView().setAlpha(1f); } else { curr.getView().setAlpha(0f); } } curr.setTranslationX((currentViewLeft - myLeft) * scaleFraction, mScale); } stepIfNeeded(); adjustChildZOrder(); invalidate(); } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { if (mViewInfo[mCurrentInfo] == null) return; mDrawArea.left = l; mDrawArea.top = t; mDrawArea.right = r; mDrawArea.bottom = b; layoutChildren(); } // Keeps the view in the view hierarchy if it's camera preview. // Remove from the hierarchy otherwise. private void checkForRemoval(ImageData data, View v) { if (data.getType() != ImageData.TYPE_CAMERA_PREVIEW) { removeView(v); data.recycle(); } else { v.setVisibility(View.INVISIBLE); if (mCameraView != null && mCameraView != v) { removeView(mCameraView); mCameraData = null; } mCameraView = v; mCameraData = data; } } private void slideViewBack(View v) { v.animate() .translationX(0) .alpha(1f) .setDuration(DURATION_GEOMETRY_ADJUST) .setInterpolator(mViewAnimInterpolator) .start(); } private void updateRemoval(int dataID, final ImageData data) { int removedInfo = findInfoByDataID(dataID); // adjust the data id to be consistent for (int i = 0; i < BUFFER_SIZE; i++) { if (mViewInfo[i] == null || mViewInfo[i].getID() <= dataID) continue; mViewInfo[i].setID(mViewInfo[i].getID() - 1); } if (removedInfo == -1) return; final View removedView = mViewInfo[removedInfo].getView(); final int offsetX = (int) (removedView.getMeasuredWidth() + mViewGap); for (int i = removedInfo + 1; i < BUFFER_SIZE; i++) { if (mViewInfo[i] != null) { mViewInfo[i].setLeftPosition(mViewInfo[i].getLeftPosition() - offsetX); } } if (removedInfo >= mCurrentInfo && mViewInfo[removedInfo].getID() < mDataAdapter.getTotalNumber()) { // fill the removed info by left shift when the current one or anyone on the // right is removed, and there's more data on the right available. for (int i = removedInfo; i < BUFFER_SIZE - 1; i++) { mViewInfo[i] = mViewInfo[i + 1]; } // pull data out from the DataAdapter for the last one. int curr = BUFFER_SIZE - 1; int prev = curr - 1; if (mViewInfo[prev] != null) { mViewInfo[curr] = buildInfoFromData(mViewInfo[prev].getID() + 1); } // Translate the views to their original places. for (int i = removedInfo; i < BUFFER_SIZE; i++) { if (mViewInfo[i] != null) { mViewInfo[i].setTranslationX(offsetX, mScale); } } // The end of the filmstrip might have been changed. // The mCenterX might be out of the bound. ViewInfo currInfo = mViewInfo[mCurrentInfo]; if (currInfo.getID() == mDataAdapter.getTotalNumber() - 1 && mCenterX > currInfo.getCenterX()) { int adjustDiff = currInfo.getCenterX() - mCenterX; mCenterX = currInfo.getCenterX(); for (int i = 0; i < BUFFER_SIZE; i++) { if (mViewInfo[i] != null) { mViewInfo[i].translateXBy(adjustDiff, mScale); } } } } else { // fill the removed place by right shift mCenterX -= offsetX; for (int i = removedInfo; i > 0; i--) { mViewInfo[i] = mViewInfo[i - 1]; } // pull data out from the DataAdapter for the first one. int curr = 0; int next = curr + 1; if (mViewInfo[next] != null) { mViewInfo[curr] = buildInfoFromData(mViewInfo[next].getID() - 1); } // Translate the views to their original places. for (int i = removedInfo; i >= 0; i--) { if (mViewInfo[i] != null) { mViewInfo[i].setTranslationX(-offsetX, mScale); } } } // Now, slide every one back. for (int i = 0; i < BUFFER_SIZE; i++) { if (mViewInfo[i] != null && mViewInfo[i].getTranslationX(mScale) != 0f) { slideViewBack(mViewInfo[i].getView()); } } int transY = getHeight() / 8; if (removedView.getTranslationY() < 0) { transY = -transY; } removedView.animate() .alpha(0f) .translationYBy(transY) .setInterpolator(mViewAnimInterpolator) .setDuration(DURATION_GEOMETRY_ADJUST) .withEndAction(new Runnable() { @Override public void run() { checkForRemoval(data, removedView); } }) .start(); layoutChildren(); } // returns -1 on failure. private int findInfoByDataID(int dataID) { for (int i = 0; i < BUFFER_SIZE; i++) { if (mViewInfo[i] != null && mViewInfo[i].getID() == dataID) return i; } return -1; } private void updateInsertion(int dataID) { int insertedInfo = findInfoByDataID(dataID); if (insertedInfo == -1) { // Not in the current info buffers. Check if it's inserted // at the end. if (dataID == mDataAdapter.getTotalNumber() - 1) { int prev = findInfoByDataID(dataID - 1); if (prev >= 0 && prev < BUFFER_SIZE - 1) { // The previous data is in the buffer and we still // have room for the inserted data. insertedInfo = prev + 1; } } } // adjust the data id to be consistent for (int i = 0; i < BUFFER_SIZE; i++) { if (mViewInfo[i] == null || mViewInfo[i].getID() < dataID) continue; mViewInfo[i].setID(mViewInfo[i].getID() + 1); } if (insertedInfo == -1) return; final ImageData data = mDataAdapter.getImageData(dataID); int[] dim = calculateChildDimension( data.getWidth(), data.getHeight(), getMeasuredWidth(), getMeasuredHeight()); final int offsetX = dim[0] + mViewGap; ViewInfo viewInfo = buildInfoFromData(dataID); if (insertedInfo >= mCurrentInfo) { if (insertedInfo == mCurrentInfo) { viewInfo.setLeftPosition(mViewInfo[mCurrentInfo].getLeftPosition()); } // Shift right to make rooms for newly inserted item. removeInfo(BUFFER_SIZE - 1); for (int i = BUFFER_SIZE - 1; i > insertedInfo; i--) { mViewInfo[i] = mViewInfo[i - 1]; if (mViewInfo[i] != null) { mViewInfo[i].setTranslationX(-offsetX, mScale); slideViewBack(mViewInfo[i].getView()); } } } else { // Shift left. Put the inserted data on the left instead of the found position. --insertedInfo; if (insertedInfo < 0) return; removeInfo(0); for (int i = 1; i <= insertedInfo; i++) { if (mViewInfo[i] != null) { mViewInfo[i].setTranslationX(offsetX, mScale); slideViewBack(mViewInfo[i].getView()); mViewInfo[i - 1] = mViewInfo[i]; } } } mViewInfo[insertedInfo] = viewInfo; View insertedView = mViewInfo[insertedInfo].getView(); insertedView.setAlpha(0f); insertedView.setTranslationY(getHeight() / 8); insertedView.animate() .alpha(1f) .translationY(0f) .setInterpolator(mViewAnimInterpolator) .setDuration(DURATION_GEOMETRY_ADJUST) .start(); invalidate(); } public void setDataAdapter(DataAdapter adapter) { mDataAdapter = adapter; mDataAdapter.suggestDecodeSize(getMeasuredWidth(), getMeasuredHeight()); mDataAdapter.setListener(new DataAdapter.Listener() { @Override public void onDataLoaded() { reload(); } @Override public void onDataUpdated(DataAdapter.UpdateReporter reporter) { update(reporter); } @Override public void onDataInserted(int dataID, ImageData data) { if (mViewInfo[mCurrentInfo] == null) { // empty now, simply do a reload. reload(); return; } updateInsertion(dataID); } @Override public void onDataRemoved(int dataID, ImageData data) { updateRemoval(dataID, data); } }); } public boolean inFilmStrip() { return (mScale == FILM_STRIP_SCALE); } public boolean inFullScreen() { return (mScale == FULLSCREEN_SCALE); } public boolean inCameraFullscreen() { return (isAnchoredTo(0) && inFullScreen() && getCurrentType() == ImageData.TYPE_CAMERA_PREVIEW); } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { if (inFilmStrip()) return true; if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) { mCheckToIntercept = true; mDown = MotionEvent.obtain(ev); ViewInfo viewInfo = mViewInfo[mCurrentInfo]; // Do not intercept touch if swipe is not enabled if (viewInfo != null && !mDataAdapter.canSwipeInFullScreen(viewInfo.getID())) { mCheckToIntercept = false; } return false; } else if (ev.getActionMasked() == MotionEvent.ACTION_POINTER_DOWN) { // Do not intercept touch once child is in zoom mode mCheckToIntercept = false; return false; } else { if (!mCheckToIntercept) return false; if (ev.getEventTime() - ev.getDownTime() > SWIPE_TIME_OUT) return false; int deltaX = (int) (ev.getX() - mDown.getX()); int deltaY = (int) (ev.getY() - mDown.getY()); if (ev.getActionMasked() == MotionEvent.ACTION_MOVE && deltaX < mSlop * (-1)) { // intercept left swipe if (Math.abs(deltaX) >= Math.abs(deltaY) * 2) { return true; } } } return false; } @Override public boolean onTouchEvent(MotionEvent ev) { mGestureRecognizer.onTouchEvent(ev); return true; } private void updateViewInfo(int infoID) { ViewInfo info = mViewInfo[infoID]; removeView(info.getView()); mViewInfo[infoID] = buildInfoFromData(info.getID()); } // Some of the data is changed. private void update(DataAdapter.UpdateReporter reporter) { // No data yet. if (mViewInfo[mCurrentInfo] == null) { reload(); return; } // Check the current one. ViewInfo curr = mViewInfo[mCurrentInfo]; int dataID = curr.getID(); if (reporter.isDataRemoved(dataID)) { mCenterX = -1; reload(); return; } if (reporter.isDataUpdated(dataID)) { updateViewInfo(mCurrentInfo); } // Check left for (int i = mCurrentInfo - 1; i >= 0; i--) { curr = mViewInfo[i]; if (curr != null) { dataID = curr.getID(); if (reporter.isDataRemoved(dataID) || reporter.isDataUpdated(dataID)) { updateViewInfo(i); } } else { ViewInfo next = mViewInfo[i + 1]; if (next != null) { mViewInfo[i] = buildInfoFromData(next.getID() - 1); } } } // Check right for (int i = mCurrentInfo + 1; i < BUFFER_SIZE; i++) { curr = mViewInfo[i]; if (curr != null) { dataID = curr.getID(); if (reporter.isDataRemoved(dataID) || reporter.isDataUpdated(dataID)) { updateViewInfo(i); } } else { ViewInfo prev = mViewInfo[i - 1]; if (prev != null) { mViewInfo[i] = buildInfoFromData(prev.getID() + 1); } } } } // The whole data might be totally different. Flush all and load from the start. private void reload() { removeAllViews(); int dataNumber = mDataAdapter.getTotalNumber(); if (dataNumber == 0) return; mViewInfo[mCurrentInfo] = buildInfoFromData(0); mViewInfo[mCurrentInfo].setLeftPosition(0); if (getCurrentType() == ImageData.TYPE_CAMERA_PREVIEW) { // we are in camera mode by default. mController.lockAtCurrentView(); } for (int i = 1; mCurrentInfo + i < BUFFER_SIZE || mCurrentInfo - i >= 0; i++) { int infoID = mCurrentInfo + i; if (infoID < BUFFER_SIZE && mViewInfo[infoID - 1] != null) { mViewInfo[infoID] = buildInfoFromData(mViewInfo[infoID - 1].getID() + 1); } infoID = mCurrentInfo - i; if (infoID >= 0 && mViewInfo[infoID + 1] != null) { mViewInfo[infoID] = buildInfoFromData(mViewInfo[infoID + 1].getID() - 1); } } layoutChildren(); } private void promoteData(int infoID, int dataID) { if (mListener != null) { mListener.onDataPromoted(dataID); } } private void demoteData(int infoID, int dataID) { if (mListener != null) { mListener.onDataDemoted(dataID); } } // MyController controls all the geometry animations. It passively // tells the geometry information on demand. private class MyController implements Controller, ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener { private ValueAnimator mScaleAnimator; private boolean mHasNewScale; private float mNewScale; private Scroller mScroller; private boolean mHasNewPosition; private DecelerateInterpolator mDecelerateInterpolator; private boolean mCanStopScroll; private boolean mIsPositionLocked; private int mLockedViewInfo; MyController(Context context) { mScroller = new Scroller(context); mHasNewPosition = false; mScaleAnimator = new ValueAnimator(); mScaleAnimator.addUpdateListener(MyController.this); mScaleAnimator.addListener(MyController.this); mDecelerateInterpolator = new DecelerateInterpolator(); mCanStopScroll = true; mHasNewScale = false; } @Override public boolean isScrolling() { return !mScroller.isFinished(); } @Override public boolean isScalling() { return mScaleAnimator.isRunning(); } boolean hasNewGeometry() { mHasNewPosition = mScroller.computeScrollOffset(); if (!mHasNewPosition) { mCanStopScroll = true; } // If the position is locked, then we always return true to force // the position value to use the locked value. return (mHasNewPosition || mHasNewScale || mIsPositionLocked); } // Always call hasNewGeometry() before getting the new scale value. float getNewScale() { if (!mHasNewScale) return mScale; mHasNewScale = false; return mNewScale; } // Always call hasNewGeometry() before getting the new position value. int getNewPosition() { if (mIsPositionLocked) { if (mViewInfo[mLockedViewInfo] == null) return mCenterX; return mViewInfo[mLockedViewInfo].getCenterX(); } if (!mHasNewPosition) return mCenterX; return mScroller.getCurrX(); } @Override public void lockAtCurrentView() { mIsPositionLocked = true; mLockedViewInfo = mCurrentInfo; } @Override public void unlockPosition() { if (mIsPositionLocked) { // only when the position is previously locked we set the current // position to make it consistent. if (mViewInfo[mLockedViewInfo] != null) { mCenterX = mViewInfo[mLockedViewInfo].getCenterX(); } mIsPositionLocked = false; } } private int estimateMinX(int dataID, int leftPos, int viewWidth) { return (leftPos - (dataID + 100) * (viewWidth + mViewGap)); } private int estimateMaxX(int dataID, int leftPos, int viewWidth) { return (leftPos + (mDataAdapter.getTotalNumber() - dataID + 100) * (viewWidth + mViewGap)); } @Override public void scroll(float deltaX) { if (mController.isScrolling()) { return; } mCenterX += deltaX; } @Override public void fling(float velocityX) { if (!stopScrolling() || mIsPositionLocked) return; ViewInfo info = mViewInfo[mCurrentInfo]; if (info == null) return; float scaledVelocityX = velocityX / mScale; if (inCameraFullscreen() && scaledVelocityX < 0) { // Swipe left in camera preview. gotoFilmStrip(); } int w = getWidth(); // Estimation of possible length on the left. To ensure the // velocity doesn't become too slow eventually, we add a huge number // to the estimated maximum. int minX = estimateMinX(info.getID(), info.getLeftPosition(), w); // Estimation of possible length on the right. Likewise, exaggerate // the possible maximum too. int maxX = estimateMaxX(info.getID(), info.getLeftPosition(), w); mScroller.fling(mCenterX, 0, (int) -velocityX, 0, minX, maxX, 0, 0); layoutChildren(); } @Override public boolean stopScrolling() { if (!mCanStopScroll) return false; mScroller.forceFinished(true); mHasNewPosition = false; return true; } private void stopScale() { mScaleAnimator.cancel(); mHasNewScale = false; } @Override public void scrollTo(int position, int duration, boolean interruptible) { if (!stopScrolling() || mIsPositionLocked) return; mCanStopScroll = interruptible; stopScrolling(); mScroller.startScroll(mCenterX, 0, position - mCenterX, 0, duration); invalidate(); } void scrollTo(int position, int duration) { scrollTo(position, duration, true); } private void scaleTo(float scale, int duration) { stopScale(); mScaleAnimator.setDuration(duration); mScaleAnimator.setFloatValues(mScale, scale); mScaleAnimator.setInterpolator(mDecelerateInterpolator); mScaleAnimator.start(); mHasNewScale = true; layoutChildren(); } @Override public void gotoFilmStrip() { unlockPosition(); scaleTo(FILM_STRIP_SCALE, DURATION_GEOMETRY_ADJUST); if (mListener != null) { mListener.onSwitchMode(false); } } @Override public void gotoFullScreen() { if (mViewInfo[mCurrentInfo] != null) { mController.scrollTo(mViewInfo[mCurrentInfo].getCenterX(), DURATION_GEOMETRY_ADJUST, false); } enterFullScreen(); } private void enterFullScreen() { if (mListener != null) { // TODO: After full size images snapping to fill the screen at the // end of a scroll/fling is implemented, we should only make // this call when the view on the center of the screen is // camera preview mListener.onSwitchMode(true); } if (inFullScreen()) return; scaleTo(1f, DURATION_GEOMETRY_ADJUST); } @Override public void gotoCameraFullScreen() { if (mDataAdapter.getImageData(0).getType() != ImageData.TYPE_CAMERA_PREVIEW) { return; } gotoFullScreen(); scrollTo( estimateMinX(mViewInfo[mCurrentInfo].getID(), mViewInfo[mCurrentInfo].getLeftPosition(), getWidth()), DURATION_GEOMETRY_ADJUST, false); } @Override public void onAnimationUpdate(ValueAnimator animation) { mHasNewScale = true; mNewScale = (Float) animation.getAnimatedValue(); layoutChildren(); } @Override public void onAnimationStart(Animator anim) { } @Override public void onAnimationEnd(Animator anim) { ViewInfo info = mViewInfo[mCurrentInfo]; if (info != null && mCenterX == info.getCenterX()) { if (inFullScreen()) { lockAtCurrentView(); } else if (inFilmStrip()) { unlockPosition(); } } } @Override public void onAnimationCancel(Animator anim) { } @Override public void onAnimationRepeat(Animator anim) { } } private class MyGestureReceiver implements FilmStripGestureRecognizer.Listener { // Indicating the current trend of scaling is up (>1) or down (<1). private float mScaleTrend; @Override public boolean onSingleTapUp(float x, float y) { if (inFilmStrip()) { for (int i = 0; i < BUFFER_SIZE; i++) { if (mViewInfo[i] == null) continue; if (mViewInfo[i].areaContains(x, y)) { mController.scrollTo(mViewInfo[i].getCenterX(), DURATION_GEOMETRY_ADJUST, false); return true; } } } return false; } @Override public boolean onDoubleTap(float x, float y) { if (inFilmStrip()) { ViewInfo centerInfo = mViewInfo[mCurrentInfo]; if (centerInfo != null && centerInfo.areaContains(x, y)) { mController.gotoFullScreen(); return true; } } else if (inFullScreen()) { mController.gotoFilmStrip(); return true; } return false; } @Override public boolean onDown(float x, float y) { if (mController.isScrolling()) { mController.stopScrolling(); } return true; } @Override public boolean onUp(float x, float y) { float halfH = getHeight() / 2; for (int i = 0; i < BUFFER_SIZE; i++) { if (mViewInfo[i] == null) continue; float transY = mViewInfo[i].getTranslationY(mScale); if (transY == 0) continue; int id = mViewInfo[i].getID(); if (mDataAdapter.getImageData(id) .isUIActionSupported(ImageData.ACTION_DEMOTE) && transY > halfH) { demoteData(i, id); } else if (mDataAdapter.getImageData(id) .isUIActionSupported(ImageData.ACTION_PROMOTE) && transY < -halfH) { promoteData(i, id); } else { // put the view back. mViewInfo[i].getView().animate() .translationY(0f) .alpha(1f) .setDuration(DURATION_GEOMETRY_ADJUST) .start(); } } return false; } @Override public boolean onScroll(float x, float y, float dx, float dy) { int deltaX = (int) (dx / mScale); if (inFilmStrip()) { if (Math.abs(dx) > Math.abs(dy)) { if (deltaX > 0 && inCameraFullscreen()) { mController.gotoFilmStrip(); } mController.scroll(deltaX); } else { // Vertical part. Promote or demote. //int scaledDeltaY = (int) (dy * mScale); int hit = 0; Rect hitRect = new Rect(); for (; hit < BUFFER_SIZE; hit++) { if (mViewInfo[hit] == null) continue; mViewInfo[hit].getView().getHitRect(hitRect); if (hitRect.contains((int) x, (int) y)) break; } if (hit == BUFFER_SIZE) return false; ImageData data = mDataAdapter.getImageData(mViewInfo[hit].getID()); float transY = mViewInfo[hit].getTranslationY(mScale) - dy / mScale; if (!data.isUIActionSupported(ImageData.ACTION_DEMOTE) && transY > 0f) { transY = 0f; } if (!data.isUIActionSupported(ImageData.ACTION_PROMOTE) && transY < 0f) { transY = 0f; } mViewInfo[hit].setTranslationY(transY, mScale); } } else if (inFullScreen()) { if (deltaX > 0 && inCameraFullscreen()) { mController.gotoFilmStrip(); } mController.scroll(deltaX); } layoutChildren(); return true; } @Override public boolean onFling(float velocityX, float velocityY) { if (Math.abs(velocityX) > Math.abs(velocityY)) { mController.fling(velocityX); } else { // ignore vertical fling. } return true; } @Override public boolean onScaleBegin(float focusX, float focusY) { if (inCameraFullscreen()) return false; mScaleTrend = 1f; return true; } @Override public boolean onScale(float focusX, float focusY, float scale) { if (inCameraFullscreen()) return false; mScaleTrend = mScaleTrend * 0.3f + scale * 0.7f; mScale *= scale; if (mScale <= FILM_STRIP_SCALE) { mScale = FILM_STRIP_SCALE; } if (mScale >= FULLSCREEN_SCALE) { mScale = FULLSCREEN_SCALE; } layoutChildren(); return true; } @Override public void onScaleEnd() { if (mScaleTrend >= 1f) { mController.gotoFullScreen(); } else { mController.gotoFilmStrip(); } mScaleTrend = 1f; } } }