diff options
Diffstat (limited to 'src/com/android/camera/ui/FilmStripView.java')
-rw-r--r-- | src/com/android/camera/ui/FilmStripView.java | 1720 |
1 files changed, 1720 insertions, 0 deletions
diff --git a/src/com/android/camera/ui/FilmStripView.java b/src/com/android/camera/ui/FilmStripView.java new file mode 100644 index 000000000..8a1a85a55 --- /dev/null +++ b/src/com/android/camera/ui/FilmStripView.java @@ -0,0 +1,1720 @@ +/* + * 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 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.FrameLayout; +import android.widget.ImageButton; +import android.widget.Scroller; + +import com.android.camera.ui.FilmStripView.ImageData.PanoramaSupportCallback; +import com.android.gallery3d.R; +import com.android.gallery3d.util.LightCycleHelper.PanoramaViewHelper; + +public class FilmStripView extends ViewGroup { + @SuppressWarnings("unused") + private static final String TAG = "FilmStripView"; + + private static final int BUFFER_SIZE = 5; + 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 int mSlop; + private TimeInterpolator mViewAnimInterpolator; + + private ImageButton mViewPhotoSphereButton; + private PanoramaViewHelper mPanoramaViewHelper; + private long mLastItemId = -1; + + // 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; + + /** + * Common interface for all images in the filmstrip. + */ + public interface ImageData { + /** + * Interface that is used to tell the caller whether an image is a photo + * sphere. + */ + public static interface PanoramaSupportCallback { + /** + * Called then photo sphere info has been loaded. + * + * @param isPanorama whether the image is a valid photo sphere + * @param isPanorama360 whether the photo sphere is a full 360 + * degree horizontal panorama + */ + void panoramaInfoAvailable(boolean isPanorama, + boolean isPanorama360); + } + + // Image data types. + 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; + + // Actions allowed to be performed on the image data. + // 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 can be returned by {@link ImageData#getWidth()} and + * {@link ImageData#getHeight()}. + * When SIZE_FULL is returned for width/height, it means the the + * width or height will be disregarded when deciding the view size + * of this ImageData, just use full screen size. + */ + public static final int SIZE_FULL = -2; + + /** + * Returns the width of the image. The final layout of the view returned + * by {@link DataAdapter#getView(android.content.Context, int)} will + * preserve the aspect ratio of + * {@link com.android.camera.ui.FilmStripView.ImageData#getWidth()} and + * {@link com.android.camera.ui.FilmStripView.ImageData#getHeight()}. + */ + public int getWidth(); + + + /** + * Returns the width of the image. The final layout of the view returned + * by {@link DataAdapter#getView(android.content.Context, int)} will + * preserve the aspect ratio of + * {@link com.android.camera.ui.FilmStripView.ImageData#getWidth()} and + * {@link com.android.camera.ui.FilmStripView.ImageData#getHeight()}. + */ + public int getHeight(); + + /** Returns the image data type */ + public int getType(); + + /** + * Checks if the UI action is supported. + * + * @param action The UI actions to check. + * @return {@code false} if at least one of the actions is not + * supported. {@code true} otherwise. + */ + public boolean isUIActionSupported(int action); + + /** + * Gives the data a hint when its view is going to be displayed. + * {@code FilmStripView} should always call this function before + * showing its corresponding view every time. + */ + public void prepare(); + + /** + * Gives the data a hint when its view is going to be removed from the + * view hierarchy. {@code FilmStripView} should always call this + * function after its corresponding view is removed from the view + * hierarchy. + */ + public void recycle(); + + /** + * Asynchronously checks if the image is a photo sphere. Notified the + * callback when the results are available. + */ + public void isPhotoSphere(Context context, PanoramaSupportCallback callback); + + /** + * If the item is a valid photo sphere panorama, this method will launch + * the viewer. + */ + public void viewPhotoSphere(PanoramaViewHelper helper); + } + + /** + * An interfaces which defines the interactions between the + * {@link ImageData} and the {@link FilmStripView}. + */ + public interface DataAdapter { + /** + * An interface which defines the update report used to return to + * the {@link com.android.camera.ui.FilmStripView.Listener}. + */ + public interface UpdateReporter { + /** Checks if the data of dataID is removed. */ + public boolean isDataRemoved(int dataID); + + /** Checks if the data of dataID is updated. */ + public boolean isDataUpdated(int dataID); + } + + /** + * An interface which defines the listener for UI actions over + * {@link ImageData}. + */ + 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); + } + + /** Returns the total number of image data */ + public int getTotalNumber(); + + /** + * Returns the view to visually present the image data. + * + * @param context The {@link Context} to create the view. + * @param dataID The ID of the image data to be presented. + * @return The view representing the image data. Null if + * unavailable or the {@code dataID} is out of range. + */ + public View getView(Context context, int dataID); + + /** + * Returns the {@link ImageData} specified by the ID. + * + * @param dataID The ID of the {@link ImageData}. + * @return The specified {@link ImageData}. Null if not available. + */ + public ImageData getImageData(int dataID); + + /** + * Suggests the data adapter the maximum possible size of the layout + * so the {@link DataAdapter} can optimize the view returned for the + * {@link ImageData}. + * + * @param w Maximum width. + * @param h Maximum height. + */ + public void suggestViewSizeBound(int w, int h); + + /** + * Sets the listener for FilmStripView UI actions over the ImageData. + * + * @param listener The listener to use. + */ + public void setListener(Listener listener); + + /** + * The callback when the item enters/leaves full-screen. + * TODO: Call this function actually. + * + * @param dataID The ID of the image data. + * @param fullScreen {@code true} if the data is entering full-screen. + * {@code false} otherwise. + */ + public void onDataFullScreen(int dataID, boolean fullScreen); + + /** + * The callback when the item is centered/off-centered. + * TODO: Calls this function actually. + * + * @param dataID The ID of the image data. + * @param centered {@code true} if the data is centered. + * {@code false} otherwise. + */ + public void onDataCentered(int dataID, boolean centered); + + /** + * Returns {@code true} if the view of the data can be moved by swipe + * gesture when in full-screen. + * + * @param dataID The ID of the data. + * @return {@code true} if the view can be moved, + * {@code false} otherwise. + */ + public boolean canSwipeInFullScreen(int dataID); + } + + /** + * An interface which defines the FilmStripView UI action listener. + */ + public interface Listener { + /** + * Callback when the data is promoted. + * + * @param dataID The ID of the promoted data. + */ + public void onDataPromoted(int dataID); + + /** + * Callback when the data is demoted. + * + * @param dataID The ID of the demoted data. + */ + public void onDataDemoted(int dataID); + + public void onDataFullScreenChange(int dataID, boolean full); + + /** + * Callback when entering/leaving camera mode. + * + * @param toCamera {@code true} if entering camera mode. Otherwise, + * {@code false} + */ + public void onSwitchMode(boolean toCamera); + } + + /** + * An interface which defines the controller of {@link FilmStripView}. + */ + 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 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); + } + } + + /** Constructor. */ + public FilmStripView(Context context) { + super(context); + init(context); + } + + /** Constructor. */ + public FilmStripView(Context context, AttributeSet attrs) { + super(context, attrs); + init(context); + } + + /** Constructor. */ + 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); + } + + /** + * Returns the controller. + * + * @return The {@code Controller}. + */ + public Controller getController() { + return mController; + } + + public void setListener(Listener l) { + mListener = l; + } + + public void setViewGap(int viewGap) { + mViewGap = viewGap; + } + + /** + * Sets the helper that's to be used to open photo sphere panoramas. + */ + public void setPanoramaViewHelper(PanoramaViewHelper helper) { + mPanoramaViewHelper = helper; + } + + 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 (boundWidth == 0 || boundHeight == 0) { + // Either width or height is unknown, can't measure children yet. + return; + } + + if (mDataAdapter != null) { + mDataAdapter.suggestViewSizeBound(boundWidth / 2, boundHeight / 2); + } + + for (ViewInfo info : mViewInfo) { + if (info == null) continue; + + int id = info.getID(); + int[] dim = calculateChildDimension( + mDataAdapter.getImageData(id).getWidth(), + mDataAdapter.getImageData(id).getHeight(), + boundWidth, boundHeight); + + info.getView().measure( + MeasureSpec.makeMeasureSpec( + dim[0], MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec( + dim[1], MeasureSpec.EXACTLY)); + } + } + + @Override + protected boolean fitSystemWindows(Rect insets) { + if (mViewPhotoSphereButton != null) { + // Set the position of the "View Photo Sphere" button. + FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) mViewPhotoSphereButton + .getLayoutParams(); + params.bottomMargin = insets.bottom; + mViewPhotoSphereButton.setLayoutParams(params); + } + + return super.fitSystemWindows(insets); + } + + private int findTheNearestView(int pointX) { + + int nearest = 0; + // Find the first non-null ViewInfo. + while (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()); + } + } + + /** + * If the current photo is a photo sphere, this will launch the Photo Sphere + * panorama viewer. + */ + private void showPhotoSphere() { + ViewInfo curr = mViewInfo[mCurrentInfo]; + if (curr != null) { + mDataAdapter.getImageData(curr.getID()).viewPhotoSphere(mPanoramaViewHelper); + } + } + + /** + * @return The ID of the current item, or -1. + */ + private int getCurrentId() { + ViewInfo current = mViewInfo[mCurrentInfo]; + if (current == null) { + return -1; + } + return current.getID(); + } + + /** + * Updates the visibility of the View Photo Sphere button. + */ + private void updatePhotoSphereViewButton() { + if (mViewPhotoSphereButton == null) { + mViewPhotoSphereButton = (ImageButton) ((View) getParent()) + .findViewById(R.id.filmstrip_bottom_control_panorama); + mViewPhotoSphereButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + showPhotoSphere(); + } + }); + } + final int requestId = getCurrentId(); + + // Check if the item has changed since the last time we updated the + // visibility status. Only then check of the current image is a photo + // sphere. + if (requestId == mLastItemId || requestId < 0) { + return; + } + + ImageData data = mDataAdapter.getImageData(requestId); + data.isPhotoSphere(mContext, new PanoramaSupportCallback() { + @Override + public void panoramaInfoAvailable(final boolean isPanorama, + boolean isPanorama360) { + // Make sure the returned data is for the current image. + if (requestId == getCurrentId()) { + mViewPhotoSphereButton.post(new Runnable() { + @Override + public void run() { + mViewPhotoSphereButton.setVisibility(isPanorama ? View.VISIBLE + : View.GONE); + } + }); + } + } + }); + } + + 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(); + updatePhotoSphereViewButton(); + mLastItemId = getCurrentId(); + } + + @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); + } + mCameraView = v; + } + } + + 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 = 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.suggestViewSizeBound(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 {@link #hasNewGeometry()} before getting the new scale + * value. + */ + float getNewScale() { + if (!mHasNewScale) { + return mScale; + } + mHasNewScale = false; + return mNewScale; + } + + /** + * Always call {@link #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(); + } + + 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; + } + } +} |