/* * 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; } } }