/* * 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.widget; import android.animation.Animator; import android.animation.AnimatorSet; import android.animation.TimeInterpolator; import android.animation.ValueAnimator; import android.content.Context; import android.graphics.Canvas; import android.graphics.Point; import android.graphics.Rect; import android.graphics.RectF; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.SystemClock; import android.util.AttributeSet; import android.util.DisplayMetrics; import android.util.SparseArray; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.accessibility.AccessibilityNodeInfo; import android.view.animation.DecelerateInterpolator; import android.widget.Scroller; import com.android.camera.CameraActivity; import com.android.camera.data.FilmstripItem; import com.android.camera.data.FilmstripItem.VideoClickedCallback; import com.android.camera.debug.Log; import com.android.camera.filmstrip.FilmstripController; import com.android.camera.filmstrip.FilmstripDataAdapter; import com.android.camera.ui.FilmstripGestureRecognizer; import com.android.camera.ui.ZoomView; import com.android.camera.util.CameraUtil; import com.android.camera2.R; import java.lang.ref.WeakReference; import java.util.ArrayDeque; import java.util.Arrays; import java.util.Queue; public class FilmstripView extends ViewGroup { /** * An action callback to be used for actions on the local media data items. */ public static class PlayVideoIntent implements VideoClickedCallback { private final WeakReference mActivity; /** * The given activity is used to start intents. It is wrapped in a weak * reference to prevent leaks. */ public PlayVideoIntent(CameraActivity activity) { mActivity = new WeakReference(activity); } /** * Fires an intent to play the video with the given URI and title. */ @Override public void playVideo(Uri uri, String title) { CameraActivity activity = mActivity.get(); if (activity != null) { CameraUtil.playVideo(activity, uri, title); } } } private static final Log.Tag TAG = new Log.Tag("FilmstripView"); private static final int BUFFER_SIZE = 5; private static final int BUFFER_CENTER = (BUFFER_SIZE - 1) / 2; private static final int GEOMETRY_ADJUST_TIME_MS = 400; private static final int SNAP_IN_CENTER_TIME_MS = 600; private static final float FLING_COASTING_DURATION_S = 0.05f; private static final int ZOOM_ANIMATION_DURATION_MS = 200; private static final int CAMERA_PREVIEW_SWIPE_THRESHOLD = 300; private static final float FILM_STRIP_SCALE = 0.7f; private static final float FULL_SCREEN_SCALE = 1f; // The min velocity at which the user must have moved their finger in // pixels per millisecond to count a vertical gesture as a promote/demote // at short vertical distances. private static final float PROMOTE_VELOCITY = 3.5f; // The min distance relative to this view's height the user must have // moved their finger to count a vertical gesture as a promote/demote if // they moved their finger at least at PROMOTE_VELOCITY. private static final float VELOCITY_PROMOTE_HEIGHT_RATIO = 1/10f; // The min distance relative to this view's height the user must have // moved their finger to count a vertical gesture as a promote/demote if // they moved their finger at less than PROMOTE_VELOCITY. private static final float PROMOTE_HEIGHT_RATIO = 1/2f; private static final float TOLERANCE = 0.1f; // Only check for intercepting touch events within first 500ms private static final int SWIPE_TIME_OUT = 500; private static final int DECELERATION_FACTOR = 4; private static final float MOUSE_SCROLL_FACTOR = 128f; private CameraActivity mActivity; private VideoClickedCallback mVideoClickedCallback; private FilmstripGestureRecognizer mGestureRecognizer; private FilmstripGestureRecognizer.Listener mGestureListener; private FilmstripDataAdapter mDataAdapter; private int mViewGapInPixel; private final Rect mDrawArea = new Rect(); private float mScale; private FilmstripControllerImpl mController; private int mCenterX = -1; private final ViewItem[] mViewItems = new ViewItem[BUFFER_SIZE]; private FilmstripController.FilmstripListener mListener; private ZoomView mZoomView = null; private MotionEvent mDown; private boolean mCheckToIntercept = true; private int mSlop; private TimeInterpolator mViewAnimInterpolator; // This is true if and only if the user is scrolling, private boolean mIsUserScrolling; private int mAdapterIndexUserIsScrollingOver; private float mOverScaleFactor = 1f; private boolean mFullScreenUIHidden = false; private final SparseArray> recycledViews = new SparseArray<>(); /** * A helper class to tract and calculate the view coordination. */ private static class ViewItem { private static enum RenderSize { TINY, THUMBNAIL, FULL_RES } private final FilmstripView mFilmstrip; private final View mView; private final RectF mViewArea; private int mIndex; /** The position of the left of the view in the whole filmstrip. */ private int mLeftPosition; private FilmstripItem mData; private RenderSize mRenderSize; private ValueAnimator mTranslationXAnimator; private ValueAnimator mTranslationYAnimator; private ValueAnimator mAlphaAnimator; private boolean mLockAtFullOpacity; /** * Constructor. * * @param index The index of the data from * {@link com.android.camera.filmstrip.FilmstripDataAdapter}. * @param v The {@code View} representing the data. */ public ViewItem(int index, View v, FilmstripItem data, FilmstripView filmstrip) { mFilmstrip = filmstrip; mView = v; mViewArea = new RectF(); mIndex = index; mData = data; mLeftPosition = -1; mRenderSize = RenderSize.TINY; mLockAtFullOpacity = false; mView.setPivotX(0f); mView.setPivotY(0f); } public FilmstripItem getData() { return mData; } public void setData(FilmstripItem item) { mData = item; renderTiny(); } public void renderTiny() { if (mRenderSize != RenderSize.TINY) { mRenderSize = RenderSize.TINY; Log.i(TAG, "[ViewItem:" + mIndex + "] mData.renderTiny()"); mData.renderTiny(mView); } } public void renderThumbnail() { if (mRenderSize != RenderSize.THUMBNAIL) { mRenderSize = RenderSize.THUMBNAIL; Log.i(TAG, "[ViewItem:" + mIndex + "] mData.renderThumbnail()"); mData.renderThumbnail(mView); } } public void renderFullRes() { if (mRenderSize != RenderSize.FULL_RES) { mRenderSize = RenderSize.FULL_RES; Log.i(TAG, "[ViewItem:" + mIndex + "] mData.renderFullRes()"); mData.renderFullRes(mView); } } public void lockAtFullOpacity() { if (!mLockAtFullOpacity) { mLockAtFullOpacity = true; mView.setAlpha(1.0f); } } public void unlockOpacity() { mLockAtFullOpacity = false; } /** * Returns the index from * {@link com.android.camera.filmstrip.FilmstripDataAdapter}. */ public int getAdapterIndex() { return mIndex; } /** * Sets the index used in the * {@link com.android.camera.filmstrip.FilmstripDataAdapter}. */ public void setIndex(int index) { mIndex = index; } /** Sets the left position of the view in the whole filmstrip. */ public void setLeftPosition(int pos) { mLeftPosition = pos; } /** Returns the left position of the view in the whole filmstrip. */ public int getLeftPosition() { return mLeftPosition; } /** Returns the translation of Y regarding the view scale. */ public float getTranslationY() { return mView.getTranslationY() / mFilmstrip.mScale; } /** Returns the translation of X regarding the view scale. */ public float getTranslationX() { return mView.getTranslationX() / mFilmstrip.mScale; } /** Sets the translation of Y regarding the view scale. */ public void setTranslationY(float transY) { mView.setTranslationY(transY * mFilmstrip.mScale); } /** Sets the translation of X regarding the view scale. */ public void setTranslationX(float transX) { mView.setTranslationX(transX * mFilmstrip.mScale); } /** Forwarding of {@link android.view.View#setAlpha(float)}. */ public void setAlpha(float alpha) { if (!mLockAtFullOpacity) { mView.setAlpha(alpha); } } /** Forwarding of {@link android.view.View#getAlpha()}. */ public float getAlpha() { return mView.getAlpha(); } /** Forwarding of {@link android.view.View#getMeasuredWidth()}. */ public int getMeasuredWidth() { return mView.getMeasuredWidth(); } /** * Animates the X translation of the view. Note: the animated value is * not set directly by {@link android.view.View#setTranslationX(float)} * because the value might be changed during in {@code onLayout()}. * The animated value of X translation is specially handled in {@code * layoutIn()}. * * @param targetX The final value. * @param duration_ms The duration of the animation. * @param interpolator Time interpolator. */ public void animateTranslationX( float targetX, long duration_ms, TimeInterpolator interpolator) { if (mTranslationXAnimator == null) { mTranslationXAnimator = new ValueAnimator(); mTranslationXAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { // We invalidate the filmstrip view instead of setting the // translation X because the translation X of the view is // touched in onLayout(). See the documentation of // animateTranslationX(). mFilmstrip.invalidate(); } }); } runAnimation(mTranslationXAnimator, getTranslationX(), targetX, duration_ms, interpolator); } /** * Animates the Y translation of the view. * * @param targetY The final value. * @param duration_ms The duration of the animation. * @param interpolator Time interpolator. */ public void animateTranslationY( float targetY, long duration_ms, TimeInterpolator interpolator) { if (mTranslationYAnimator == null) { mTranslationYAnimator = new ValueAnimator(); mTranslationYAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { setTranslationY((Float) valueAnimator.getAnimatedValue()); } }); } runAnimation(mTranslationYAnimator, getTranslationY(), targetY, duration_ms, interpolator); } /** * Animates the alpha value of the view. * * @param targetAlpha The final value. * @param duration_ms The duration of the animation. * @param interpolator Time interpolator. */ public void animateAlpha(float targetAlpha, long duration_ms, TimeInterpolator interpolator) { if (mAlphaAnimator == null) { mAlphaAnimator = new ValueAnimator(); mAlphaAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { ViewItem.this.setAlpha((Float) valueAnimator.getAnimatedValue()); } }); } runAnimation(mAlphaAnimator, getAlpha(), targetAlpha, duration_ms, interpolator); } private void runAnimation(final ValueAnimator animator, final float startValue, final float targetValue, final long duration_ms, final TimeInterpolator interpolator) { if (startValue == targetValue) { return; } animator.setInterpolator(interpolator); animator.setDuration(duration_ms); animator.setFloatValues(startValue, targetValue); animator.start(); } /** Adjusts the translation of X regarding the view scale. */ public void translateXScaledBy(float transX) { setTranslationX(getTranslationX() + transX * mFilmstrip.mScale); } /** * Forwarding of {@link android.view.View#getHitRect(android.graphics.Rect)}. */ public void getHitRect(Rect rect) { mView.getHitRect(rect); } public int getCenterX() { return mLeftPosition + mView.getMeasuredWidth() / 2; } /** Forwarding of {@link android.view.View#getVisibility()}. */ public int getVisibility() { return mView.getVisibility(); } /** Forwarding of {@link android.view.View#setVisibility(int)}. */ public void setVisibility(int visibility) { mView.setVisibility(visibility); } /** * Adds the view of the data to the view hierarchy if necessary. */ public void addViewToHierarchy() { if (mFilmstrip.indexOfChild(mView) < 0) { mFilmstrip.addView(mView); } // all new views added should not display until layout positions // them and sets them visible setVisibility(View.INVISIBLE); setAlpha(1f); setTranslationX(0); setTranslationY(0); } /** * Removes from the hierarchy. */ public void removeViewFromHierarchy() { mFilmstrip.removeView(mView); mData.recycle(mView); mFilmstrip.recycleView(mView, mIndex); } /** * Brings the view to front by * {@link #bringChildToFront(android.view.View)} */ public void bringViewToFront() { mFilmstrip.bringChildToFront(mView); } /** * The visual x position of this view, in pixels. */ public float getX() { return mView.getX(); } /** * The visual y position of this view, in pixels. */ public float getY() { return mView.getY(); } /** * Forwarding of {@link android.view.View#measure(int, int)}. */ public void measure(int widthSpec, int heightSpec) { mView.measure(widthSpec, heightSpec); } private void layoutAt(int left, int top) { mView.layout(left, top, left + mView.getMeasuredWidth(), top + mView.getMeasuredHeight()); } /** * The bounding rect of the view. */ public RectF getViewRect() { RectF r = new RectF(); r.left = mView.getX(); r.top = mView.getY(); r.right = r.left + mView.getWidth() * mView.getScaleX(); r.bottom = r.top + mView.getHeight() * mView.getScaleY(); return r; } private View getView() { return mView; } /** * Layouts the view in the area assuming the center of the area is at a * specific point of the whole filmstrip. * * @param drawArea The area when filmstrip will show in. * @param refCenter The absolute X coordination in the whole filmstrip * of the center of {@code drawArea}. * @param scale The scale of the view on the filmstrip. */ public void layoutWithTranslationX(Rect drawArea, int refCenter, float scale) { final float translationX = ((mTranslationXAnimator != null && mTranslationXAnimator.isRunning()) ? (Float) mTranslationXAnimator.getAnimatedValue() : 0); int left = (int) (drawArea.centerX() + (mLeftPosition - refCenter + translationX) * 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.getMeasuredWidth() * scale, t + mView.getMeasuredHeight() * scale); } /** Returns true if the point is in the view. */ public boolean areaContains(float x, float y) { return mViewArea.contains(x, y); } /** * Return the width of the view. */ public int getWidth() { return mView.getWidth(); } /** * Returns the position of the left edge of the view area content is drawn in. */ public int getDrawAreaLeft() { return Math.round(mViewArea.left); } /** * Apply a scale factor (i.e. {@code postScale}) on top of current scale at * pivot point ({@code focusX}, {@code focusY}). Visually it should be the * same as post concatenating current view's matrix with specified scale. */ void postScale(float focusX, float focusY, float postScale, int viewportWidth, int viewportHeight) { float transX = mView.getTranslationX(); float transY = mView.getTranslationY(); // Pivot point is top left of the view, so we need to translate // to scale around focus point transX -= (focusX - getX()) * (postScale - 1f); transY -= (focusY - getY()) * (postScale - 1f); float scaleX = mView.getScaleX() * postScale; float scaleY = mView.getScaleY() * postScale; updateTransform(transX, transY, scaleX, scaleY, viewportWidth, viewportHeight); } void updateTransform(float transX, float transY, float scaleX, float scaleY, int viewportWidth, int viewportHeight) { float left = transX + mView.getLeft(); float top = transY + mView.getTop(); RectF r = ZoomView.adjustToFitInBounds(new RectF(left, top, left + mView.getWidth() * scaleX, top + mView.getHeight() * scaleY), viewportWidth, viewportHeight); mView.setScaleX(scaleX); mView.setScaleY(scaleY); transX = r.left - mView.getLeft(); transY = r.top - mView.getTop(); mView.setTranslationX(transX); mView.setTranslationY(transY); } void resetTransform() { mView.setScaleX(FULL_SCREEN_SCALE); mView.setScaleY(FULL_SCREEN_SCALE); mView.setTranslationX(0f); mView.setTranslationY(0f); } @Override public String toString() { return "AdapterIndex = " + mIndex + "\n\t left = " + mLeftPosition + "\n\t viewArea = " + mViewArea + "\n\t centerX = " + getCenterX() + "\n\t view MeasuredSize = " + mView.getMeasuredWidth() + ',' + mView.getMeasuredHeight() + "\n\t view Size = " + mView.getWidth() + ',' + mView.getHeight() + "\n\t view scale = " + mView.getScaleX(); } } /** Constructor. */ public FilmstripView(Context context) { super(context); init((CameraActivity) context); } /** Constructor. */ public FilmstripView(Context context, AttributeSet attrs) { super(context, attrs); init((CameraActivity) context); } /** Constructor. */ public FilmstripView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init((CameraActivity) context); } private void init(CameraActivity cameraActivity) { setWillNotDraw(false); mActivity = cameraActivity; mVideoClickedCallback = new PlayVideoIntent(mActivity); mScale = 1.0f; mAdapterIndexUserIsScrollingOver = 0; mController = new FilmstripControllerImpl(); mViewAnimInterpolator = new DecelerateInterpolator(); mZoomView = new ZoomView(cameraActivity); mZoomView.setVisibility(GONE); addView(mZoomView); mGestureListener = new FilmstripGestures(); mGestureRecognizer = new FilmstripGestureRecognizer(cameraActivity, mGestureListener); mSlop = (int) getContext().getResources().getDimension(R.dimen.pie_touch_slop); DisplayMetrics metrics = new DisplayMetrics(); mActivity.getWindowManager().getDefaultDisplay().getMetrics(metrics); // Allow over scaling because on high density screens, pixels are too // tiny to clearly see the details at 1:1 zoom. We should not scale // beyond what 1:1 would look like on a medium density screen, as // scaling beyond that would only yield blur. mOverScaleFactor = (float) metrics.densityDpi / (float) DisplayMetrics.DENSITY_HIGH; if (mOverScaleFactor < 1f) { mOverScaleFactor = 1f; } setAccessibilityDelegate(new AccessibilityDelegate() { @Override public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { super.onInitializeAccessibilityNodeInfo(host, info); info.setClassName(FilmstripView.class.getName()); info.setScrollable(true); info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD); info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD); } @Override public boolean performAccessibilityAction(View host, int action, Bundle args) { if (!mController.isScrolling()) { switch (action) { case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: { mController.goToNextItem(); return true; } case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD: { boolean wentToPrevious = mController.goToPreviousItem(); if (!wentToPrevious) { // at beginning of filmstrip, hide and go back to preview mActivity.getCameraAppUI().hideFilmstrip(); } return true; } case AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS: { // Prevent the view group itself from being selected. // Instead, select the item in the center final ViewItem currentItem = mViewItems[BUFFER_CENTER]; currentItem.getView().performAccessibilityAction(action, args); return true; } } } return super.performAccessibilityAction(host, action, args); } }); } private void recycleView(View view, int index) { Log.v(TAG, "recycleView"); final int viewType = (Integer) view.getTag(R.id.mediadata_tag_viewtype); if (viewType > 0) { Queue recycledViewsForType = recycledViews.get(viewType); if (recycledViewsForType == null) { recycledViewsForType = new ArrayDeque(); recycledViews.put(viewType, recycledViewsForType); } recycledViewsForType.offer(view); } } private View getRecycledView(int index) { final int viewType = mDataAdapter.getItemViewType(index); Queue recycledViewsForType = recycledViews.get(viewType); View view = null; if (recycledViewsForType != null) { view = recycledViewsForType.poll(); } if (view != null) { view.setVisibility(View.GONE); } Log.v(TAG, "getRecycledView, recycled=" + (view != null)); return view; } /** * Returns the controller. * * @return The {@code Controller}. */ public FilmstripController getController() { return mController; } /** * Returns the draw area width of the current item. */ public int getCurrentItemLeft() { return mViewItems[BUFFER_CENTER].getDrawAreaLeft(); } private void setListener(FilmstripController.FilmstripListener l) { mListener = l; } private void setViewGap(int viewGap) { mViewGapInPixel = viewGap; } /** * Called after current item or zoom level has changed. */ public void zoomAtIndexChanged() { if (mViewItems[BUFFER_CENTER] == null) { return; } int index = mViewItems[BUFFER_CENTER].getAdapterIndex(); mListener.onZoomAtIndexChanged(index, mScale); } /** * Checks if the data is at the center. * * @param index The index of the item in the data adapter to check. * @return {@code True} if the data is currently at the center. */ private boolean isItemAtIndexCentered(int index) { if (mViewItems[BUFFER_CENTER] == null) { return false; } if (mViewItems[BUFFER_CENTER].getAdapterIndex() == index && isCurrentItemCentered()) { return true; } return false; } private void measureViewItem(ViewItem item, int boundWidth, int boundHeight) { int index = item.getAdapterIndex(); FilmstripItem imageData = mDataAdapter.getFilmstripItemAt(index); if (imageData == null) { Log.w(TAG, "measureViewItem() - Trying to measure a null item!"); return; } Point dim = CameraUtil.resizeToFill( imageData.getDimensions().getWidth(), imageData.getDimensions().getHeight(), imageData.getOrientation(), boundWidth, boundHeight); item.measure(MeasureSpec.makeMeasureSpec(dim.x, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(dim.y, MeasureSpec.EXACTLY)); } @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; } for (ViewItem item : mViewItems) { if (item != null) { measureViewItem(item, boundWidth, boundHeight); } } clampCenterX(); // Measure zoom view mZoomView.measure(MeasureSpec.makeMeasureSpec(widthMeasureSpec, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(heightMeasureSpec, MeasureSpec.EXACTLY)); } private int findTheNearestView(int viewX) { int nearest = 0; // Find the first non-null ViewItem. while (nearest < BUFFER_SIZE && (mViewItems[nearest] == null || mViewItems[nearest].getLeftPosition() == -1)) { nearest++; } // No existing available ViewItem if (nearest == BUFFER_SIZE) { return -1; } int min = Math.abs(viewX - mViewItems[nearest].getCenterX()); for (int i = nearest + 1; i < BUFFER_SIZE && mViewItems[i] != null; i++) { // Not measured yet. if (mViewItems[i].getLeftPosition() == -1) { continue; } int centerX = mViewItems[i].getCenterX(); int dist = Math.abs(viewX - centerX); if (dist < min) { min = dist; nearest = i; } } return nearest; } private ViewItem buildViewItemAt(int index) { if (mActivity.isDestroyed()) { // Loading item data is call from multiple AsyncTasks and the // activity may be finished when buildViewItemAt is called. Log.d(TAG, "Activity destroyed, don't load data"); return null; } FilmstripItem data = mDataAdapter.getFilmstripItemAt(index); if (data == null) { return null; } // Always scale by fixed filmstrip scale, since we only show items when // in filmstrip. Preloading images with a different scale and bounds // interferes with caching. int width = Math.round(FULL_SCREEN_SCALE * getWidth()); int height = Math.round(FULL_SCREEN_SCALE * getHeight()); Log.v(TAG, "suggesting item bounds: " + width + "x" + height); mDataAdapter.suggestViewSizeBound(width, height); View recycled = getRecycledView(index); View v = mDataAdapter.getView(recycled, index, mVideoClickedCallback); if (v == null) { return null; } ViewItem item = new ViewItem(index, v, data, this); item.addViewToHierarchy(); return item; } private void renderFullRes(int bufferIndex) { ViewItem item = mViewItems[bufferIndex]; if (item == null) { return; } item.renderFullRes(); } private void renderThumbnail(int bufferIndex) { ViewItem item = mViewItems[bufferIndex]; if (item == null) { return; } item.renderThumbnail(); } private void renderAllThumbnails() { for(int i = 0; i < BUFFER_SIZE; i++) { renderThumbnail(i); } } private void removeItem(int bufferIndex) { if (bufferIndex >= mViewItems.length || mViewItems[bufferIndex] == null) { return; } FilmstripItem data = mDataAdapter.getFilmstripItemAt( mViewItems[bufferIndex].getAdapterIndex()); if (data == null) { Log.w(TAG, "removeItem() - Trying to remove a null item!"); return; } mViewItems[bufferIndex].removeViewFromHierarchy(); mViewItems[bufferIndex] = null; } /** * We try to keep the one closest to the center of the screen at position * BUFFER_CENTER. */ private void stepIfNeeded() { if (!inFilmstrip() && !inFullScreen()) { // The good timing to step to the next view is when everything is // not in transition. return; } final int nearestBufferIndex = findTheNearestView(mCenterX); // if the nearest view is the current view, or there is no nearest // view, then we do not need to adjust the view buffers. if (nearestBufferIndex == -1 || nearestBufferIndex == BUFFER_CENTER) { return; } int prevIndex = (mViewItems[BUFFER_CENTER] == null ? -1 : mViewItems[BUFFER_CENTER].getAdapterIndex()); final int adjust = nearestBufferIndex - BUFFER_CENTER; if (adjust > 0) { // Remove from beginning of the buffer. for (int k = 0; k < adjust; k++) { removeItem(k); } // Shift items inside the buffer for (int k = 0; k + adjust < BUFFER_SIZE; k++) { mViewItems[k] = mViewItems[k + adjust]; } // Fill the end with new items. for (int k = BUFFER_SIZE - adjust; k < BUFFER_SIZE; k++) { mViewItems[k] = null; if (mViewItems[k - 1] != null) { mViewItems[k] = buildViewItemAt(mViewItems[k - 1].getAdapterIndex() + 1); } } adjustChildZOrder(); } else { // Remove from the end of the buffer for (int k = BUFFER_SIZE - 1; k >= BUFFER_SIZE + adjust; k--) { removeItem(k); } // Shift items inside the buffer for (int k = BUFFER_SIZE - 1; k + adjust >= 0; k--) { mViewItems[k] = mViewItems[k + adjust]; } // Fill the beginning with new items. for (int k = -1 - adjust; k >= 0; k--) { mViewItems[k] = null; if (mViewItems[k + 1] != null) { mViewItems[k] = buildViewItemAt(mViewItems[k + 1].getAdapterIndex() - 1); } } } invalidate(); if (mListener != null) { mListener.onDataFocusChanged(prevIndex, mViewItems[BUFFER_CENTER] .getAdapterIndex()); final int firstVisible = mViewItems[BUFFER_CENTER].getAdapterIndex() - 2; final int visibleItemCount = firstVisible + BUFFER_SIZE; final int totalItemCount = mDataAdapter.getTotalNumber(); mListener.onScroll(firstVisible, visibleItemCount, totalItemCount); } zoomAtIndexChanged(); } /** * Check the bounds of {@code mCenterX}. Always call this function after: 1. * Any changes to {@code mCenterX}. 2. Any size change of the view items. * * @return Whether clamp happened. */ private boolean clampCenterX() { ViewItem currentItem = mViewItems[BUFFER_CENTER]; if (currentItem == null) { return false; } boolean stopScroll = false; if (currentItem.getAdapterIndex() == 0 && mCenterX < currentItem.getCenterX()) { // Stop at the first ViewItem. stopScroll = true; } else if (currentItem.getAdapterIndex() == mDataAdapter.getTotalNumber() - 1 && mCenterX > currentItem.getCenterX()) { // Stop at the end. stopScroll = true; } if (stopScroll) { mCenterX = currentItem.getCenterX(); } return stopScroll; } /** * Reorders the child views to be consistent with their index. This method * should be called after adding/removing views. */ private void adjustChildZOrder() { for (int i = BUFFER_SIZE - 1; i >= 0; i--) { if (mViewItems[i] == null) { continue; } mViewItems[i].bringViewToFront(); } // ZoomView is a special case to always be in the front. bringChildToFront(mZoomView); } /** * Returns the index of the current item, or -1 if there is no data. */ private int getCurrentItemAdapterIndex() { ViewItem current = mViewItems[BUFFER_CENTER]; if (current == null) { return -1; } return current.getAdapterIndex(); } /** * Keep the current item in the center. This functions does not check if the * current item is null. */ private void scrollCurrentItemToCenter() { final ViewItem currItem = mViewItems[BUFFER_CENTER]; if (currItem == null) { return; } final int currentViewCenter = currItem.getCenterX(); if (mController.isScrolling() || mIsUserScrolling || isCurrentItemCentered()) { Log.d(TAG, "[fling] mController.isScrolling() - " + mController.isScrolling()); return; } int snapInTime = (int) (SNAP_IN_CENTER_TIME_MS * ((float) Math.abs(mCenterX - currentViewCenter)) / mDrawArea.width()); Log.d(TAG, "[fling] Scroll to center."); mController.scrollToPosition(currentViewCenter, snapInTime, false); } /** * Translates the {@link ViewItem} on the left of the current one to match * the full-screen layout. In full-screen, we show only one {@link ViewItem} * which occupies the whole screen. The other left ones are put on the left * side in full scales. Does nothing if there's no next item. * * @param index The index of the current one to be translated. * @param drawAreaWidth The width of the current draw area. * @param scaleFraction A {@code float} between 0 and 1. 0 if the current * scale is {@code FILM_STRIP_SCALE}. 1 if the current scale is * {@code FULL_SCREEN_SCALE}. */ private void translateLeftViewItem( int index, int drawAreaWidth, float scaleFraction) { if (index < 0 || index > BUFFER_SIZE - 1) { Log.w(TAG, "translateLeftViewItem() - Index out of bound!"); return; } final ViewItem curr = mViewItems[index]; final ViewItem next = mViewItems[index + 1]; if (curr == null || next == null) { Log.w(TAG, "translateLeftViewItem() - Invalid view item (curr or next == null). curr = " + index); return; } final int currCenterX = curr.getCenterX(); final int nextCenterX = next.getCenterX(); final int translate = (int) ((nextCenterX - drawAreaWidth - currCenterX) * scaleFraction); curr.layoutWithTranslationX(mDrawArea, mCenterX, mScale); curr.setAlpha(1f); curr.setVisibility(VISIBLE); if (inFullScreen()) { curr.setTranslationX(translate * (mCenterX - currCenterX) / (nextCenterX - currCenterX)); } else { curr.setTranslationX(translate); } } /** * Fade out the {@link ViewItem} on the right of the current one in * full-screen layout. Does nothing if there's no previous item. * * @param bufferIndex The index of the item in the buffer to fade. */ private void fadeAndScaleRightViewItem(int bufferIndex) { if (bufferIndex < 1 || bufferIndex > BUFFER_SIZE) { Log.w(TAG, "fadeAndScaleRightViewItem() - bufferIndex out of bound!"); return; } final ViewItem item = mViewItems[bufferIndex]; final ViewItem previousItem = mViewItems[bufferIndex - 1]; if (item == null || previousItem == null) { Log.w(TAG, "fadeAndScaleRightViewItem() - Invalid view item (curr or prev == null)." + "curr = " + bufferIndex); return; } if (bufferIndex > BUFFER_CENTER + 1) { // Every item not right next to the BUFFER_CENTER is invisible. item.setVisibility(INVISIBLE); return; } final int prevCenterX = previousItem.getCenterX(); if (mCenterX <= prevCenterX) { // Shortcut. If the position is at the center of the previous one, // set to invisible too. item.setVisibility(INVISIBLE); return; } final int currCenterX = item.getCenterX(); final float fadeDownFraction = ((float) mCenterX - prevCenterX) / (currCenterX - prevCenterX); item.layoutWithTranslationX(mDrawArea, currCenterX, FILM_STRIP_SCALE + (1f - FILM_STRIP_SCALE) * fadeDownFraction); item.setAlpha(fadeDownFraction); item.setTranslationX(0); item.setVisibility(VISIBLE); } private void layoutViewItems(boolean layoutChanged) { if (mViewItems[BUFFER_CENTER] == null || mDrawArea.width() == 0 || mDrawArea.height() == 0) { return; } // If the layout changed, we need to adjust the current position so // that if an item is centered before the change, it's still centered. if (layoutChanged) { mViewItems[BUFFER_CENTER].setLeftPosition( mCenterX - mViewItems[BUFFER_CENTER].getMeasuredWidth() / 2); } if (inZoomView()) { return; } /** * Transformed scale fraction between 0 and 1. 0 if the scale is * {@link FILM_STRIP_SCALE}. 1 if the scale is {@link FULL_SCREEN_SCALE} * . */ final float scaleFraction = mViewAnimInterpolator.getInterpolation( (mScale - FILM_STRIP_SCALE) / (FULL_SCREEN_SCALE - FILM_STRIP_SCALE)); final int fullScreenWidth = mDrawArea.width() + mViewGapInPixel; // Decide the position for all view items on the left and the right // first. // Left items. for (int i = BUFFER_CENTER - 1; i >= 0; i--) { final ViewItem curr = mViewItems[i]; if (curr == null) { break; } // First, layout relatively to the next one. final int currLeft = mViewItems[i + 1].getLeftPosition() - curr.getMeasuredWidth() - mViewGapInPixel; curr.setLeftPosition(currLeft); } // Right items. for (int i = BUFFER_CENTER + 1; i < BUFFER_SIZE; i++) { final ViewItem curr = mViewItems[i]; if (curr == null) { break; } // First, layout relatively to the previous one. final ViewItem prev = mViewItems[i - 1]; final int currLeft = prev.getLeftPosition() + prev.getMeasuredWidth() + mViewGapInPixel; curr.setLeftPosition(currLeft); } if (scaleFraction == 1f) { final ViewItem currItem = mViewItems[BUFFER_CENTER]; final int currCenterX = currItem.getCenterX(); if (mCenterX < currCenterX) { // In full-screen and mCenterX is on the left of the center, // we draw the current one to "fade down". fadeAndScaleRightViewItem(BUFFER_CENTER); } else if (mCenterX > currCenterX) { // In full-screen and mCenterX is on the right of the center, // we draw the current one translated. translateLeftViewItem(BUFFER_CENTER, fullScreenWidth, scaleFraction); } else { currItem.layoutWithTranslationX(mDrawArea, mCenterX, mScale); currItem.setTranslationX(0f); currItem.setAlpha(1f); } } else { final ViewItem currItem = mViewItems[BUFFER_CENTER]; currItem.setVisibility(View.VISIBLE); // The normal filmstrip has no translation for the current item. If // it has translation before, gradually set it to zero. currItem.setTranslationX(currItem.getTranslationX() * scaleFraction); currItem.layoutWithTranslationX(mDrawArea, mCenterX, mScale); if (mViewItems[BUFFER_CENTER - 1] == null) { currItem.setAlpha(1f); } else { final int currCenterX = currItem.getCenterX(); final int prevCenterX = mViewItems[BUFFER_CENTER - 1].getCenterX(); final float fadeDownFraction = ((float) mCenterX - prevCenterX) / (currCenterX - prevCenterX); currItem.setAlpha( (1 - fadeDownFraction) * (1 - scaleFraction) + fadeDownFraction); } } // Layout the rest dependent on the current scale. // Items on the left for (int i = BUFFER_CENTER - 1; i >= 0; i--) { final ViewItem curr = mViewItems[i]; if (curr == null) { break; } translateLeftViewItem(i, fullScreenWidth, scaleFraction); } // Items on the right for (int i = BUFFER_CENTER + 1; i < BUFFER_SIZE; i++) { final ViewItem curr = mViewItems[i]; if (curr == null) { break; } curr.layoutWithTranslationX(mDrawArea, mCenterX, mScale); if (scaleFraction == 1) { // It's in full-screen mode. fadeAndScaleRightViewItem(i); } else { boolean isVisible = (curr.getVisibility() == VISIBLE); boolean setToVisible = !isVisible; if (i == BUFFER_CENTER + 1) { // right hand neighbor needs to fade based on scale of // center curr.setAlpha(1f - scaleFraction); } else { if (scaleFraction == 0f) { curr.setAlpha(1f); } else { // further right items should not display when center // is being scaled setToVisible = false; if (isVisible) { curr.setVisibility(INVISIBLE); } } } if (setToVisible && !isVisible) { curr.setVisibility(VISIBLE); } curr.setTranslationX((mViewItems[BUFFER_CENTER].getLeftPosition() - curr.getLeftPosition()) * scaleFraction); } } stepIfNeeded(); } @Override public void onDraw(Canvas c) { // TODO: remove layoutViewItems() here. layoutViewItems(false); super.onDraw(c); } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { mDrawArea.left = 0; mDrawArea.top = 0; mDrawArea.right = r - l; mDrawArea.bottom = b - t; mZoomView.layout(mDrawArea.left, mDrawArea.top, mDrawArea.right, mDrawArea.bottom); // TODO: Need a more robust solution to decide when to re-layout // If in the middle of zooming, only re-layout when the layout has // changed. if (!inZoomView() || changed) { resetZoomView(); layoutViewItems(changed); } } /** * Clears the translation and scale that has been set on the view, cancels * any loading request for image partial decoding, and hides zoom view. This * is needed for when there is a layout change (e.g. when users re-enter the * app, or rotate the device, etc). */ private void resetZoomView() { if (!inZoomView()) { return; } ViewItem current = mViewItems[BUFFER_CENTER]; if (current == null) { return; } mScale = FULL_SCREEN_SCALE; mController.cancelZoomAnimation(); mController.cancelFlingAnimation(); current.resetTransform(); mController.cancelLoadingZoomedImage(); mZoomView.setVisibility(GONE); mController.setSurroundingViewsVisible(true); } private void hideZoomView() { if (inZoomView()) { mController.cancelLoadingZoomedImage(); mZoomView.setVisibility(GONE); } } private void slideViewBack(ViewItem item) { item.animateTranslationX(0, GEOMETRY_ADJUST_TIME_MS, mViewAnimInterpolator); item.animateTranslationY(0, GEOMETRY_ADJUST_TIME_MS, mViewAnimInterpolator); item.animateAlpha(1f, GEOMETRY_ADJUST_TIME_MS, mViewAnimInterpolator); } private void animateItemRemoval(int index) { if (mScale > FULL_SCREEN_SCALE) { resetZoomView(); } int removeAtBufferIndex = findItemInBufferByAdapterIndex(index); // adjust the buffer to be consistent for (int i = 0; i < BUFFER_SIZE; i++) { if (mViewItems[i] == null || mViewItems[i].getAdapterIndex() <= index) { continue; } mViewItems[i].setIndex(mViewItems[i].getAdapterIndex() - 1); } if (removeAtBufferIndex == -1) { return; } final ViewItem removedItem = mViewItems[removeAtBufferIndex]; final int offsetX = removedItem.getMeasuredWidth() + mViewGapInPixel; for (int i = removeAtBufferIndex + 1; i < BUFFER_SIZE; i++) { if (mViewItems[i] != null) { mViewItems[i].setLeftPosition(mViewItems[i].getLeftPosition() - offsetX); } } if (removeAtBufferIndex >= BUFFER_CENTER && mViewItems[removeAtBufferIndex].getAdapterIndex() < mDataAdapter.getTotalNumber()) { // Fill the removed item 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 = removeAtBufferIndex; i < BUFFER_SIZE - 1; i++) { mViewItems[i] = mViewItems[i + 1]; } // pull data out from the DataAdapter for the last one. int curr = BUFFER_SIZE - 1; int prev = curr - 1; if (mViewItems[prev] != null) { mViewItems[curr] = buildViewItemAt(mViewItems[prev].getAdapterIndex() + 1); } // The animation part. if (inFullScreen()) { mViewItems[BUFFER_CENTER].setVisibility(VISIBLE); ViewItem nextItem = mViewItems[BUFFER_CENTER + 1]; if (nextItem != null) { nextItem.setVisibility(INVISIBLE); } } // Translate the views to their original places. for (int i = removeAtBufferIndex; i < BUFFER_SIZE; i++) { if (mViewItems[i] != null) { mViewItems[i].setTranslationX(offsetX); } } // The end of the filmstrip might have been changed. // The mCenterX might be out of the bound. ViewItem currItem = mViewItems[BUFFER_CENTER]; if (currItem!=null) { if (currItem.getAdapterIndex() == mDataAdapter.getTotalNumber() - 1 && mCenterX > currItem.getCenterX()) { int adjustDiff = currItem.getCenterX() - mCenterX; mCenterX = currItem.getCenterX(); for (int i = 0; i < BUFFER_SIZE; i++) { if (mViewItems[i] != null) { mViewItems[i].translateXScaledBy(adjustDiff); } } } } else { // CurrItem should NOT be NULL, but if is, at least don't crash. Log.w(TAG,"Caught invalid update in removal animation."); } } else { // fill the removed place by right shift mCenterX -= offsetX; for (int i = removeAtBufferIndex; i > 0; i--) { mViewItems[i] = mViewItems[i - 1]; } // pull data out from the DataAdapter for the first one. int curr = 0; int next = curr + 1; if (mViewItems[next] != null) { mViewItems[curr] = buildViewItemAt(mViewItems[next].getAdapterIndex() - 1); } // Translate the views to their original places. for (int i = removeAtBufferIndex; i >= 0; i--) { if (mViewItems[i] != null) { mViewItems[i].setTranslationX(-offsetX); } } } int transY = getHeight() / 8; if (removedItem.getTranslationY() < 0) { transY = -transY; } removedItem.animateTranslationY(removedItem.getTranslationY() + transY, GEOMETRY_ADJUST_TIME_MS, mViewAnimInterpolator); removedItem.animateAlpha(0f, GEOMETRY_ADJUST_TIME_MS, mViewAnimInterpolator); postDelayed(new Runnable() { @Override public void run() { removedItem.removeViewFromHierarchy(); } }, GEOMETRY_ADJUST_TIME_MS); adjustChildZOrder(); invalidate(); // Now, slide every one back. if (mViewItems[BUFFER_CENTER] == null) { return; } for (int i = 0; i < BUFFER_SIZE; i++) { if (mViewItems[i] != null && mViewItems[i].getTranslationX() != 0f) { slideViewBack(mViewItems[i]); } } } // returns -1 on failure. private int findItemInBufferByAdapterIndex(int index) { for (int i = 0; i < BUFFER_SIZE; i++) { if (mViewItems[i] != null && mViewItems[i].getAdapterIndex() == index) { return i; } } return -1; } private void updateInsertion(int index) { int bufferIndex = findItemInBufferByAdapterIndex(index); if (bufferIndex == -1) { // Not in the current item buffers. Check if it's inserted // at the end. if (index == mDataAdapter.getTotalNumber() - 1) { int prev = findItemInBufferByAdapterIndex(index - 1); if (prev >= 0 && prev < BUFFER_SIZE - 1) { // The previous data is in the buffer and we still // have room for the inserted data. bufferIndex = prev + 1; } } } // adjust the indexes to be consistent for (int i = 0; i < BUFFER_SIZE; i++) { if (mViewItems[i] == null || mViewItems[i].getAdapterIndex() < index) { continue; } mViewItems[i].setIndex(mViewItems[i].getAdapterIndex() + 1); } if (bufferIndex == -1) { return; } final FilmstripItem data = mDataAdapter.getFilmstripItemAt(index); Point dim = CameraUtil .resizeToFill( data.getDimensions().getWidth(), data.getDimensions().getHeight(), data.getOrientation(), getMeasuredWidth(), getMeasuredHeight()); final int offsetX = dim.x + mViewGapInPixel; ViewItem viewItem = buildViewItemAt(index); if (viewItem == null) { Log.w(TAG, "unable to build inserted item from data"); return; } if (bufferIndex >= BUFFER_CENTER) { if (bufferIndex == BUFFER_CENTER) { viewItem.setLeftPosition(mViewItems[BUFFER_CENTER].getLeftPosition()); } // Shift right to make rooms for newly inserted item. removeItem(BUFFER_SIZE - 1); for (int i = BUFFER_SIZE - 1; i > bufferIndex; i--) { mViewItems[i] = mViewItems[i - 1]; if (mViewItems[i] != null) { mViewItems[i].setTranslationX(-offsetX); slideViewBack(mViewItems[i]); } } } else { // Shift left. Put the inserted data on the left instead of the // found position. --bufferIndex; if (bufferIndex < 0) { return; } removeItem(0); for (int i = 1; i <= bufferIndex; i++) { if (mViewItems[i] != null) { mViewItems[i].setTranslationX(offsetX); slideViewBack(mViewItems[i]); mViewItems[i - 1] = mViewItems[i]; } } } mViewItems[bufferIndex] = viewItem; renderThumbnail(bufferIndex); viewItem.setAlpha(0f); viewItem.setTranslationY(getHeight() / 8); slideViewBack(viewItem); adjustChildZOrder(); invalidate(); } private void setDataAdapter(FilmstripDataAdapter adapter) { mDataAdapter = adapter; int maxEdge = (int) (Math.max(this.getHeight(), this.getWidth()) * FILM_STRIP_SCALE); mDataAdapter.suggestViewSizeBound(maxEdge, maxEdge); mDataAdapter.setListener(new FilmstripDataAdapter.Listener() { @Override public void onFilmstripItemLoaded() { reload(); } @Override public void onFilmstripItemUpdated(FilmstripDataAdapter.UpdateReporter reporter) { update(reporter); } @Override public void onFilmstripItemInserted(int index, FilmstripItem item) { if (mViewItems[BUFFER_CENTER] == null) { // empty now, simply do a reload. reload(); } else { updateInsertion(index); } if (mListener != null) { mListener.onDataFocusChanged(index, getCurrentItemAdapterIndex()); } Log.d(TAG, "onFilmstripItemInserted()"); renderAllThumbnails(); } @Override public void onFilmstripItemRemoved(int index, FilmstripItem item) { animateItemRemoval(index); if (mListener != null) { mListener.onDataFocusChanged(index, getCurrentItemAdapterIndex()); } Log.d(TAG, "onFilmstripItemRemoved()"); renderAllThumbnails(); } }); } private boolean inFilmstrip() { return (mScale == FILM_STRIP_SCALE); } private boolean inFullScreen() { return (mScale == FULL_SCREEN_SCALE); } private boolean inZoomView() { return (mScale > FULL_SCREEN_SCALE); } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { if (mController.isScrolling()) { return true; } if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) { mCheckToIntercept = true; mDown = MotionEvent.obtain(ev); 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) { return mGestureRecognizer.onTouchEvent(ev); } @Override public boolean onGenericMotionEvent(MotionEvent ev) { mGestureRecognizer.onGenericMotionEvent(ev); return true; } FilmstripGestureRecognizer.Listener getGestureListener() { return mGestureListener; } private void updateViewItem(int bufferIndex) { ViewItem item = mViewItems[bufferIndex]; if (item == null) { Log.w(TAG, "updateViewItem() - Trying to update an null item!"); return; } int adapterIndex = item.getAdapterIndex(); FilmstripItem filmstripItem = mDataAdapter.getFilmstripItemAt(adapterIndex); if (filmstripItem == null) { Log.w(TAG, "updateViewItem() - Trying to update item with null FilmstripItem!"); return; } FilmstripItem oldFilmstripItem = item.getData(); // In case the underlying data item is changed (commonly from // SessionItem to PhotoItem for an image requiring processing), set the // new FilmstripItem on the ViewItem if (!filmstripItem.equals(oldFilmstripItem)) { oldFilmstripItem.recycle(item.getView()); item.setData(filmstripItem); Log.v(TAG, "updateViewItem() - recycling old data item and setting new"); } else { Log.v(TAG, "updateViewItem() - updating data with the same item"); } // In case state changed from a new FilmStripItem or the existing one, // redraw the View contents. We call getView here as it will refill the // view contents, but it is not clear as we are not using the documented // method intent to get a View, we know that this always uses the view // passed in to populate it. // TODO: refactor 'getView' to more explicitly just update view contents mDataAdapter.getView(item.getView(), adapterIndex, mVideoClickedCallback); mZoomView.resetDecoder(); boolean stopScroll = clampCenterX(); if (stopScroll) { mController.stopScrolling(true); } Log.d(TAG, "updateViewItem(bufferIndex: " + bufferIndex + ")"); Log.d(TAG, "updateViewItem() - mIsUserScrolling: " + mIsUserScrolling); Log.d(TAG, "updateViewItem() - mController.isScrolling() - " + mController.isScrolling()); // Relying on only isScrolling or isUserScrolling independently // is unreliable. Load the full resolution if either value // reports that the item is not scrolling. if (!mController.isScrolling() || !mIsUserScrolling) { renderThumbnail(bufferIndex); } adjustChildZOrder(); invalidate(); if (mListener != null) { mListener.onDataUpdated(adapterIndex); } } /** Some of the data is changed. */ private void update(FilmstripDataAdapter.UpdateReporter reporter) { // No data yet. if (mViewItems[BUFFER_CENTER] == null) { reload(); return; } // Check the current one. ViewItem curr = mViewItems[BUFFER_CENTER]; int index = curr.getAdapterIndex(); if (reporter.isDataRemoved(index)) { reload(); return; } if (reporter.isDataUpdated(index)) { updateViewItem(BUFFER_CENTER); final FilmstripItem data = mDataAdapter.getFilmstripItemAt(index); if (!mIsUserScrolling && !mController.isScrolling()) { // If there is no scrolling at all, adjust mCenterX to place // the current item at the center. Point dim = CameraUtil.resizeToFill( data.getDimensions().getWidth(), data.getDimensions().getHeight(), data.getOrientation(), getMeasuredWidth(), getMeasuredHeight()); mCenterX = curr.getLeftPosition() + dim.x / 2; } } // Check left for (int i = BUFFER_CENTER - 1; i >= 0; i--) { curr = mViewItems[i]; if (curr != null) { index = curr.getAdapterIndex(); if (reporter.isDataRemoved(index) || reporter.isDataUpdated(index)) { updateViewItem(i); } } else { ViewItem next = mViewItems[i + 1]; if (next != null) { mViewItems[i] = buildViewItemAt(next.getAdapterIndex() - 1); } } } // Check right for (int i = BUFFER_CENTER + 1; i < BUFFER_SIZE; i++) { curr = mViewItems[i]; if (curr != null) { index = curr.getAdapterIndex(); if (reporter.isDataRemoved(index) || reporter.isDataUpdated(index)) { updateViewItem(i); } } else { ViewItem prev = mViewItems[i - 1]; if (prev != null) { mViewItems[i] = buildViewItemAt(prev.getAdapterIndex() + 1); } } } adjustChildZOrder(); // Request a layout to find the measured width/height of the view first. requestLayout(); } /** * The whole data might be totally different. Flush all and load from the * start. Filmstrip will be centered on the first item, i.e. the camera * preview. */ private void reload() { mController.stopScrolling(true); mController.stopScale(); mAdapterIndexUserIsScrollingOver = 0; int prevId = -1; if (mViewItems[BUFFER_CENTER] != null) { prevId = mViewItems[BUFFER_CENTER].getAdapterIndex(); } // Remove all views from the mViewItems buffer, except the camera view. for (int i = 0; i < mViewItems.length; i++) { if (mViewItems[i] == null) { continue; } mViewItems[i].removeViewFromHierarchy(); } // Clear out the mViewItems and rebuild with camera in the center. Arrays.fill(mViewItems, null); int dataNumber = mDataAdapter.getTotalNumber(); if (dataNumber == 0) { return; } mViewItems[BUFFER_CENTER] = buildViewItemAt(0); if (mViewItems[BUFFER_CENTER] == null) { return; } mViewItems[BUFFER_CENTER].setLeftPosition(0); for (int i = BUFFER_CENTER + 1; i < BUFFER_SIZE; i++) { mViewItems[i] = buildViewItemAt(mViewItems[i - 1].getAdapterIndex() + 1); if (mViewItems[i] == null) { break; } } // Ensure that the views in mViewItems will layout the first in the // center of the display upon a reload. mCenterX = -1; mScale = FILM_STRIP_SCALE; adjustChildZOrder(); Log.d(TAG, "reload() - Ensure all items are loaded at max size."); renderAllThumbnails(); invalidate(); if (mListener != null) { mListener.onDataReloaded(); mListener.onDataFocusChanged(prevId, mViewItems[BUFFER_CENTER].getAdapterIndex()); } } private void promoteData(int index) { if (mListener != null) { mListener.onFocusedDataPromoted(index); } } private void demoteData(int index) { if (mListener != null) { mListener.onFocusedDataDemoted(index); } } private void onEnterFilmstrip() { Log.d(TAG, "onEnterFilmstrip()"); if (mListener != null) { mListener.onEnterFilmstrip(getCurrentItemAdapterIndex()); } } private void onLeaveFilmstrip() { if (mListener != null) { mListener.onLeaveFilmstrip(getCurrentItemAdapterIndex()); } } private void onEnterFullScreen() { mFullScreenUIHidden = false; if (mListener != null) { mListener.onEnterFullScreenUiShown(getCurrentItemAdapterIndex()); } } private void onLeaveFullScreen() { if (mListener != null) { mListener.onLeaveFullScreenUiShown(getCurrentItemAdapterIndex()); } } private void onEnterFullScreenUiHidden() { mFullScreenUIHidden = true; if (mListener != null) { mListener.onEnterFullScreenUiHidden(getCurrentItemAdapterIndex()); } } private void onLeaveFullScreenUiHidden() { mFullScreenUIHidden = false; if (mListener != null) { mListener.onLeaveFullScreenUiHidden(getCurrentItemAdapterIndex()); } } private void onEnterZoomView() { if (mListener != null) { mListener.onEnterZoomView(getCurrentItemAdapterIndex()); } } private void onLeaveZoomView() { mController.setSurroundingViewsVisible(true); } /** * MyController controls all the geometry animations. It passively tells the * geometry information on demand. */ private class FilmstripControllerImpl implements FilmstripController { private final ValueAnimator mScaleAnimator; private ValueAnimator mZoomAnimator; private AnimatorSet mFlingAnimator; private final FilmstripScrollGesture mScrollGesture; private boolean mCanStopScroll; private final FilmstripScrollGesture.Listener mScrollListener = new FilmstripScrollGesture.Listener() { @Override public void onScrollUpdate(int currX, int currY) { mCenterX = currX; boolean stopScroll = clampCenterX(); if (stopScroll) { Log.d(TAG, "[fling] onScrollUpdate() - stopScrolling!"); mController.stopScrolling(true); } invalidate(); } @Override public void onScrollEnd() { mCanStopScroll = true; Log.d(TAG, "[fling] onScrollEnd() - onScrollEnd"); if (mViewItems[BUFFER_CENTER] == null) { return; } scrollCurrentItemToCenter(); // onScrollEnd will get called twice, once when // the fling part ends, and once when the manual // scroll center animation finishes. Once everything // stops moving ensure that the items are loaded at // full resolution. if (isCurrentItemCentered()) { // Since these are getting pushed into a queue, // we want to ensure the item that is "most in view" is // the first one rendered at max size. Log.d(TAG, "[fling] onScrollEnd() - Ensuring that items are at" + " full resolution."); renderThumbnail(BUFFER_CENTER); renderThumbnail(BUFFER_CENTER + 1); renderThumbnail(BUFFER_CENTER - 1); renderThumbnail(BUFFER_CENTER + 2); } } }; private final ValueAnimator.AnimatorUpdateListener mScaleAnimatorUpdateListener = new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { if (mViewItems[BUFFER_CENTER] == null) { return; } mScale = (Float) animation.getAnimatedValue(); invalidate(); } }; FilmstripControllerImpl() { TimeInterpolator decelerateInterpolator = new DecelerateInterpolator(1.5f); mScrollGesture = new FilmstripScrollGesture(mActivity.getAndroidContext(), new Handler(mActivity.getMainLooper()), mScrollListener, decelerateInterpolator); mCanStopScroll = true; mScaleAnimator = new ValueAnimator(); mScaleAnimator.addUpdateListener(mScaleAnimatorUpdateListener); mScaleAnimator.setInterpolator(decelerateInterpolator); mScaleAnimator.addListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animator) { if (mScale == FULL_SCREEN_SCALE) { onLeaveFullScreen(); } else { if (mScale == FILM_STRIP_SCALE) { onLeaveFilmstrip(); } } } @Override public void onAnimationEnd(Animator animator) { if (mScale == FULL_SCREEN_SCALE) { onEnterFullScreen(); } else { if (mScale == FILM_STRIP_SCALE) { onEnterFilmstrip(); } } zoomAtIndexChanged(); } @Override public void onAnimationCancel(Animator animator) { } @Override public void onAnimationRepeat(Animator animator) { } }); } @Override public void setImageGap(int imageGap) { FilmstripView.this.setViewGap(imageGap); } @Override public int getCurrentAdapterIndex() { return FilmstripView.this.getCurrentItemAdapterIndex(); } @Override public void setDataAdapter(FilmstripDataAdapter adapter) { FilmstripView.this.setDataAdapter(adapter); } @Override public boolean inFilmstrip() { return FilmstripView.this.inFilmstrip(); } @Override public boolean inFullScreen() { return FilmstripView.this.inFullScreen(); } @Override public void setListener(FilmstripListener listener) { FilmstripView.this.setListener(listener); } @Override public boolean isScrolling() { return !mScrollGesture.isFinished(); } @Override public boolean isScaling() { return mScaleAnimator.isRunning(); } private int estimateMinX(int index, int leftPos, int viewWidth) { return leftPos - (index + 100) * (viewWidth + mViewGapInPixel); } private int estimateMaxX(int index, int leftPos, int viewWidth) { return leftPos + (mDataAdapter.getTotalNumber() - index + 100) * (viewWidth + mViewGapInPixel); } /** Zoom all the way in or out on the image at the given pivot point. */ private void zoomAt(final ViewItem current, final float focusX, final float focusY) { // End previous zoom animation, if any if (mZoomAnimator != null) { mZoomAnimator.end(); } // Calculate end scale final float maxScale = getCurrentDataMaxScale(false); final float endScale = mScale < maxScale - maxScale * TOLERANCE ? maxScale : FULL_SCREEN_SCALE; mZoomAnimator = new ValueAnimator(); mZoomAnimator.setFloatValues(mScale, endScale); mZoomAnimator.setDuration(ZOOM_ANIMATION_DURATION_MS); mZoomAnimator.addListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animation) { if (mScale == FULL_SCREEN_SCALE) { if (mFullScreenUIHidden) { onLeaveFullScreenUiHidden(); } else { onLeaveFullScreen(); } setSurroundingViewsVisible(false); } else if (inZoomView()) { onLeaveZoomView(); } cancelLoadingZoomedImage(); } @Override public void onAnimationEnd(Animator animation) { // Make sure animation ends up having the correct scale even // if it is cancelled before it finishes if (mScale != endScale) { current.postScale(focusX, focusY, endScale / mScale, mDrawArea.width(), mDrawArea.height()); mScale = endScale; } if (inFullScreen()) { setSurroundingViewsVisible(true); mZoomView.setVisibility(GONE); current.resetTransform(); onEnterFullScreenUiHidden(); } else { mController.loadZoomedImage(); onEnterZoomView(); } mZoomAnimator = null; zoomAtIndexChanged(); } @Override public void onAnimationCancel(Animator animation) { // Do nothing. } @Override public void onAnimationRepeat(Animator animation) { // Do nothing. } }); mZoomAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float newScale = (Float) animation.getAnimatedValue(); float postScale = newScale / mScale; mScale = newScale; current.postScale(focusX, focusY, postScale, mDrawArea.width(), mDrawArea.height()); } }); mZoomAnimator.start(); } @Override public void scroll(float deltaX) { if (!stopScrolling(false)) { return; } mCenterX += deltaX; boolean stopScroll = clampCenterX(); if (stopScroll) { mController.stopScrolling(true); } invalidate(); } @Override public void fling(float velocityX) { if (!stopScrolling(false)) { return; } final ViewItem item = mViewItems[BUFFER_CENTER]; if (item == null) { return; } float scaledVelocityX = velocityX / mScale; if (inFullScreen() && 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(item.getAdapterIndex(), item.getLeftPosition(), w); // Estimation of possible length on the right. Likewise, exaggerate // the possible maximum too. int maxX = estimateMaxX(item.getAdapterIndex(), item.getLeftPosition(), w); mScrollGesture.fling(mCenterX, 0, (int) -velocityX, 0, minX, maxX, 0, 0); } void flingInsideZoomView(float velocityX, float velocityY) { if (!inZoomView()) { return; } final ViewItem current = mViewItems[BUFFER_CENTER]; if (current == null) { return; } final int factor = DECELERATION_FACTOR; // Deceleration curve for distance: // S(t) = s + (e - s) * (1 - (1 - t/T) ^ factor) // Need to find the ending distance (e), so that the starting // velocity is the velocity of fling. // Velocity is the derivative of distance // V(t) = (e - s) * factor * (-1) * (1 - t/T) ^ (factor - 1) * (-1/T) // = (e - s) * factor * (1 - t/T) ^ (factor - 1) / T // Since V(0) = V0, we have e = T / factor * V0 + s // Duration T should be long enough so that at the end of the fling, // image moves at 1 pixel/s for about P = 50ms = 0.05s // i.e. V(T - P) = 1 // V(T - P) = V0 * (1 - (T -P) /T) ^ (factor - 1) = 1 // T = P * V0 ^ (1 / (factor -1)) final float velocity = Math.max(Math.abs(velocityX), Math.abs(velocityY)); // Dynamically calculate duration final float duration = (float) (FLING_COASTING_DURATION_S * Math.pow(velocity, (1f / (factor - 1f)))); final float translationX = current.getTranslationX() * mScale; final float translationY = current.getTranslationY() * mScale; final ValueAnimator decelerationX = ValueAnimator.ofFloat(translationX, translationX + duration / factor * velocityX); final ValueAnimator decelerationY = ValueAnimator.ofFloat(translationY, translationY + duration / factor * velocityY); decelerationY.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float transX = (Float) decelerationX.getAnimatedValue(); float transY = (Float) decelerationY.getAnimatedValue(); current.updateTransform(transX, transY, mScale, mScale, mDrawArea.width(), mDrawArea.height()); } }); mFlingAnimator = new AnimatorSet(); mFlingAnimator.play(decelerationX).with(decelerationY); mFlingAnimator.setDuration((int) (duration * 1000)); mFlingAnimator.setInterpolator(new TimeInterpolator() { @Override public float getInterpolation(float input) { return (float) (1.0f - Math.pow((1.0f - input), factor)); } }); mFlingAnimator.addListener(new Animator.AnimatorListener() { private boolean mCancelled = false; @Override public void onAnimationStart(Animator animation) { } @Override public void onAnimationEnd(Animator animation) { if (!mCancelled) { loadZoomedImage(); } mFlingAnimator = null; } @Override public void onAnimationCancel(Animator animation) { mCancelled = true; } @Override public void onAnimationRepeat(Animator animation) { } }); mFlingAnimator.start(); } @Override public boolean stopScrolling(boolean forced) { if (!isScrolling()) { return true; } else if (!mCanStopScroll && !forced) { return false; } mScrollGesture.forceFinished(true); return true; } private void stopScale() { mScaleAnimator.cancel(); } @Override public void scrollToPosition(int position, int duration, boolean interruptible) { if (mViewItems[BUFFER_CENTER] == null) { return; } mCanStopScroll = interruptible; mScrollGesture.startScroll(mCenterX, position - mCenterX, duration); } @Override public boolean goToNextItem() { return goToItem(BUFFER_CENTER + 1); } @Override public boolean goToPreviousItem() { return goToItem(BUFFER_CENTER - 1); } private boolean goToItem(int itemIndex) { final ViewItem nextItem = mViewItems[itemIndex]; if (nextItem == null) { return false; } stopScrolling(true); scrollToPosition(nextItem.getCenterX(), GEOMETRY_ADJUST_TIME_MS * 2, false); return true; } private void scaleTo(float scale, int duration) { if (mViewItems[BUFFER_CENTER] == null) { return; } stopScale(); mScaleAnimator.setDuration(duration); mScaleAnimator.setFloatValues(mScale, scale); mScaleAnimator.start(); } @Override public void goToFilmstrip() { if (mViewItems[BUFFER_CENTER] == null) { return; } if (mScale == FILM_STRIP_SCALE) { return; } scaleTo(FILM_STRIP_SCALE, GEOMETRY_ADJUST_TIME_MS); final ViewItem currItem = mViewItems[BUFFER_CENTER]; final ViewItem nextItem = mViewItems[BUFFER_CENTER + 1]; if (mScale == FILM_STRIP_SCALE) { onLeaveFilmstrip(); } } @Override public void goToFullScreen() { if (inFullScreen()) { return; } scaleTo(FULL_SCREEN_SCALE, GEOMETRY_ADJUST_TIME_MS); } private void cancelFlingAnimation() { // Cancels flinging for zoomed images if (isFlingAnimationRunning()) { mFlingAnimator.cancel(); } } private void cancelZoomAnimation() { if (isZoomAnimationRunning()) { mZoomAnimator.cancel(); } } private void setSurroundingViewsVisible(boolean visible) { // Hide everything on the left // TODO: Need to find a better way to toggle the visibility of views // around the current view. for (int i = 0; i < BUFFER_CENTER; i++) { if (mViewItems[i] != null) { mViewItems[i].setVisibility(visible ? VISIBLE : INVISIBLE); } } } private Uri getCurrentUri() { ViewItem curr = mViewItems[BUFFER_CENTER]; if (curr == null) { return Uri.EMPTY; } return mDataAdapter.getFilmstripItemAt(curr.getAdapterIndex()).getData().getUri(); } /** * Here we only support up to 1:1 image zoom (i.e. a 100% view of the * actual pixels). The max scale that we can apply on the view should * make the view same size as the image, in pixels. */ private float getCurrentDataMaxScale(boolean allowOverScale) { ViewItem curr = mViewItems[BUFFER_CENTER]; if (curr == null) { return FULL_SCREEN_SCALE; } FilmstripItem imageData = mDataAdapter.getFilmstripItemAt(curr.getAdapterIndex()); if (imageData == null || !imageData.getAttributes().canZoomInPlace()) { return FULL_SCREEN_SCALE; } float imageWidth = imageData.getDimensions().getWidth(); if (imageData.getOrientation() == 90 || imageData.getOrientation() == 270) { imageWidth = imageData.getDimensions().getHeight(); } float scale = imageWidth / curr.getWidth(); if (allowOverScale) { // In addition to the scale we apply to the view for 100% view // (i.e. each pixel on screen corresponds to a pixel in image) // we allow scaling beyond that for better detail viewing. scale *= mOverScaleFactor; } return scale; } private void loadZoomedImage() { if (!inZoomView()) { return; } ViewItem curr = mViewItems[BUFFER_CENTER]; if (curr == null) { return; } FilmstripItem imageData = mDataAdapter.getFilmstripItemAt(curr.getAdapterIndex()); if (!imageData.getAttributes().canZoomInPlace()) { return; } Uri uri = getCurrentUri(); RectF viewRect = curr.getViewRect(); if (uri == null || uri == Uri.EMPTY) { return; } int orientation = imageData.getOrientation(); mZoomView.loadBitmap(uri, orientation, viewRect); } private void cancelLoadingZoomedImage() { mZoomView.cancelPartialDecodingTask(); } @Override public void goToFirstItem() { if (mViewItems[BUFFER_CENTER] == null) { return; } resetZoomView(); // TODO: animate to camera if it is still in the mViewItems buffer // versus a full reload which will perform an immediate transition reload(); } public boolean inZoomView() { return FilmstripView.this.inZoomView(); } public boolean isFlingAnimationRunning() { return mFlingAnimator != null && mFlingAnimator.isRunning(); } public boolean isZoomAnimationRunning() { return mZoomAnimator != null && mZoomAnimator.isRunning(); } @Override public boolean isVisible(FilmstripItem data) { for (ViewItem viewItem : mViewItems) { if (data != null && viewItem != null && viewItem.getVisibility() == VISIBLE && data.equals(viewItem.mData)) { return true; } } return false; } } private boolean isCurrentItemCentered() { return mViewItems[BUFFER_CENTER].getCenterX() == mCenterX; } private static class FilmstripScrollGesture { public interface Listener { public void onScrollUpdate(int currX, int currY); public void onScrollEnd(); } private final Handler mHandler; private final Listener mListener; private final Scroller mScroller; private final ValueAnimator mXScrollAnimator; private final Runnable mScrollChecker = new Runnable() { @Override public void run() { boolean newPosition = mScroller.computeScrollOffset(); if (!newPosition) { Log.d(TAG, "[fling] onScrollEnd from computeScrollOffset"); mListener.onScrollEnd(); return; } mListener.onScrollUpdate(mScroller.getCurrX(), mScroller.getCurrY()); mHandler.removeCallbacks(this); mHandler.post(this); } }; private final ValueAnimator.AnimatorUpdateListener mXScrollAnimatorUpdateListener = new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { mListener.onScrollUpdate((Integer) animation.getAnimatedValue(), 0); } }; private final Animator.AnimatorListener mXScrollAnimatorListener = new Animator.AnimatorListener() { @Override public void onAnimationCancel(Animator animation) { Log.d(TAG, "[fling] mXScrollAnimatorListener.onAnimationCancel"); // Do nothing. } @Override public void onAnimationEnd(Animator animation) { Log.d(TAG, "[fling] onScrollEnd from mXScrollAnimatorListener.onAnimationEnd"); mListener.onScrollEnd(); } @Override public void onAnimationRepeat(Animator animation) { Log.d(TAG, "[fling] mXScrollAnimatorListener.onAnimationRepeat"); // Do nothing. } @Override public void onAnimationStart(Animator animation) { Log.d(TAG, "[fling] mXScrollAnimatorListener.onAnimationStart"); // Do nothing. } }; public FilmstripScrollGesture(Context ctx, Handler handler, Listener listener, TimeInterpolator interpolator) { mHandler = handler; mListener = listener; mScroller = new Scroller(ctx); mXScrollAnimator = new ValueAnimator(); mXScrollAnimator.addUpdateListener(mXScrollAnimatorUpdateListener); mXScrollAnimator.addListener(mXScrollAnimatorListener); mXScrollAnimator.setInterpolator(interpolator); } public void fling( int startX, int startY, int velocityX, int velocityY, int minX, int maxX, int minY, int maxY) { mScroller.fling(startX, startY, velocityX, velocityY, minX, maxX, minY, maxY); runChecker(); } public void startScroll(int startX, int startY, int dx, int dy) { mScroller.startScroll(startX, startY, dx, dy); runChecker(); } /** Only starts and updates scroll in x-axis. */ public void startScroll(int startX, int dx, int duration) { mXScrollAnimator.cancel(); mXScrollAnimator.setDuration(duration); mXScrollAnimator.setIntValues(startX, startX + dx); mXScrollAnimator.start(); } public boolean isFinished() { return (mScroller.isFinished() && !mXScrollAnimator.isRunning()); } public void forceFinished(boolean finished) { mScroller.forceFinished(finished); if (finished) { mXScrollAnimator.cancel(); } } private void runChecker() { if (mHandler == null || mListener == null) { return; } mHandler.removeCallbacks(mScrollChecker); mHandler.post(mScrollChecker); } } private class FilmstripGestures implements FilmstripGestureRecognizer.Listener { private static final int SCROLL_DIR_NONE = 0; private static final int SCROLL_DIR_VERTICAL = 1; private static final int SCROLL_DIR_HORIZONTAL = 2; // Indicating the current trend of scaling is up (>1) or down (<1). private float mScaleTrend; private float mMaxScale; private int mScrollingDirection = SCROLL_DIR_NONE; private long mLastDownTime; private float mLastDownY; private ViewItem mCurrentlyScalingItem; @Override public boolean onSingleTapUp(float x, float y) { ViewItem centerItem = mViewItems[BUFFER_CENTER]; if (inFilmstrip()) { if (centerItem != null && centerItem.areaContains(x, y)) { mController.goToFullScreen(); return true; } } else if (inFullScreen()) { if (mFullScreenUIHidden) { onLeaveFullScreenUiHidden(); onEnterFullScreen(); } else { onLeaveFullScreen(); onEnterFullScreenUiHidden(); } return true; } return false; } @Override public boolean onDoubleTap(float x, float y) { ViewItem current = mViewItems[BUFFER_CENTER]; if (current == null) { return false; } if (inFilmstrip()) { mController.goToFullScreen(); return true; } else if (mScale < FULL_SCREEN_SCALE) { return false; } if (!mController.stopScrolling(false)) { return false; } if (inFullScreen()) { mController.zoomAt(current, x, y); renderFullRes(BUFFER_CENTER); return true; } else if (mScale > FULL_SCREEN_SCALE) { // In zoom view. mController.zoomAt(current, x, y); } return false; } @Override public boolean onDown(float x, float y) { mLastDownTime = SystemClock.uptimeMillis(); mLastDownY = y; mController.cancelFlingAnimation(); if (!mController.stopScrolling(false)) { return false; } return true; } @Override public boolean onUp(float x, float y) { ViewItem currItem = mViewItems[BUFFER_CENTER]; if (currItem == null) { return false; } if (mController.isZoomAnimationRunning() || mController.isFlingAnimationRunning()) { return false; } if (inZoomView()) { mController.loadZoomedImage(); return true; } float promoteHeight = getHeight() * PROMOTE_HEIGHT_RATIO; float velocityPromoteHeight = getHeight() * VELOCITY_PROMOTE_HEIGHT_RATIO; mIsUserScrolling = false; mScrollingDirection = SCROLL_DIR_NONE; // Finds items promoted/demoted. float speedY = Math.abs(y - mLastDownY) / (SystemClock.uptimeMillis() - mLastDownTime); for (int i = 0; i < BUFFER_SIZE; i++) { if (mViewItems[i] == null) { continue; } float transY = mViewItems[i].getTranslationY(); if (transY == 0) { continue; } int index = mViewItems[i].getAdapterIndex(); if (mDataAdapter.getFilmstripItemAt(index).getAttributes().canSwipeAway() && ((transY > promoteHeight) || (transY > velocityPromoteHeight && speedY > PROMOTE_VELOCITY))) { demoteData(index); } else if (mDataAdapter.getFilmstripItemAt(index).getAttributes().canSwipeAway() && (transY < -promoteHeight || (transY < -velocityPromoteHeight && speedY > PROMOTE_VELOCITY))) { promoteData(index); } else { // put the view back. slideViewBack(mViewItems[i]); } } // The data might be changed. Re-check. currItem = mViewItems[BUFFER_CENTER]; if (currItem == null) { return true; } int currId = currItem.getAdapterIndex(); if (mAdapterIndexUserIsScrollingOver == 0 && currId != 0) { // Special case to go to filmstrip when the user scroll away // from the camera preview and the current one is not the // preview anymore. mController.goToFilmstrip(); mAdapterIndexUserIsScrollingOver = currId; } scrollCurrentItemToCenter(); return false; } @Override public void onLongPress(float x, float y) { final int index = getCurrentItemAdapterIndex(); if (index == -1) { return; } mListener.onFocusedDataLongPressed(index); } @Override public boolean onScroll(float x, float y, float dx, float dy) { final ViewItem currItem = mViewItems[BUFFER_CENTER]; if (currItem == null) { return false; } hideZoomView(); // When image is zoomed in to be bigger than the screen if (inZoomView()) { ViewItem curr = mViewItems[BUFFER_CENTER]; float transX = curr.getTranslationX() * mScale - dx; float transY = curr.getTranslationY() * mScale - dy; curr.updateTransform(transX, transY, mScale, mScale, mDrawArea.width(), mDrawArea.height()); return true; } int deltaX = (int) (dx / mScale); // Forces the current scrolling to stop. mController.stopScrolling(true); if (!mIsUserScrolling) { mIsUserScrolling = true; mAdapterIndexUserIsScrollingOver = mViewItems[BUFFER_CENTER].getAdapterIndex(); } if (inFilmstrip()) { // Disambiguate horizontal/vertical first. if (mScrollingDirection == SCROLL_DIR_NONE) { mScrollingDirection = (Math.abs(dx) > Math.abs(dy)) ? SCROLL_DIR_HORIZONTAL : SCROLL_DIR_VERTICAL; } if (mScrollingDirection == SCROLL_DIR_HORIZONTAL) { if (mCenterX == currItem.getCenterX() && currItem.getAdapterIndex() == 0 && dx < 0) { // Already at the beginning, don't process the swipe. mIsUserScrolling = false; mScrollingDirection = SCROLL_DIR_NONE; return false; } mController.scroll(deltaX); } else { // Vertical part. Promote or demote. int hit = 0; Rect hitRect = new Rect(); for (; hit < BUFFER_SIZE; hit++) { if (mViewItems[hit] == null) { continue; } mViewItems[hit].getHitRect(hitRect); if (hitRect.contains((int) x, (int) y)) { break; } } if (hit == BUFFER_SIZE) { // Hit none. return true; } FilmstripItem data = mDataAdapter.getFilmstripItemAt( mViewItems[hit].getAdapterIndex()); float transY = mViewItems[hit].getTranslationY() - dy / mScale; if (!data.getAttributes().canSwipeAway() && transY > 0f) { transY = 0f; } if (!data.getAttributes().canSwipeAway() && transY < 0f) { transY = 0f; } mViewItems[hit].setTranslationY(transY); } } else if (inFullScreen()) { if (mViewItems[BUFFER_CENTER] == null || (deltaX < 0 && mCenterX <= currItem.getCenterX() && currItem.getAdapterIndex() == 0)) { return false; } // Multiplied by 1.2 to make it more easy to swipe. mController.scroll((int) (deltaX * 1.2)); } invalidate(); return true; } @Override public boolean onMouseScroll(float hscroll, float vscroll) { final float scroll; hscroll *= MOUSE_SCROLL_FACTOR; vscroll *= MOUSE_SCROLL_FACTOR; if (vscroll != 0f) { scroll = vscroll; } else { scroll = hscroll; } if (inFullScreen()) { onFling(-scroll, 0f); } else if (inZoomView()) { onScroll(0f, 0f, hscroll, vscroll); } else { onScroll(0f, 0f, scroll, 0f); } return true; } @Override public boolean onFling(float velocityX, float velocityY) { final ViewItem currItem = mViewItems[BUFFER_CENTER]; if (currItem == null) { return false; } if (inZoomView()) { // Fling within the zoomed image mController.flingInsideZoomView(velocityX, velocityY); return true; } if (Math.abs(velocityX) < Math.abs(velocityY)) { // ignore vertical fling. return true; } // In full-screen, fling of a velocity above a threshold should go // to the next/prev photos if (mScale == FULL_SCREEN_SCALE) { int currItemCenterX = currItem.getCenterX(); if (velocityX > 0) { // left if (mCenterX > currItemCenterX) { // The visually previous item is actually the current // item. mController.scrollToPosition( currItemCenterX, GEOMETRY_ADJUST_TIME_MS, true); return true; } ViewItem prevItem = mViewItems[BUFFER_CENTER - 1]; if (prevItem == null) { return false; } mController.scrollToPosition( prevItem.getCenterX(), GEOMETRY_ADJUST_TIME_MS, true); } else { // right if (mController.stopScrolling(false)) { if (mCenterX < currItemCenterX) { // The visually next item is actually the current // item. mController.scrollToPosition( currItemCenterX, GEOMETRY_ADJUST_TIME_MS, true); return true; } final ViewItem nextItem = mViewItems[BUFFER_CENTER + 1]; if (nextItem == null) { return false; } mController.scrollToPosition( nextItem.getCenterX(), GEOMETRY_ADJUST_TIME_MS, true); } } } if (mScale == FILM_STRIP_SCALE) { mController.fling(velocityX); } return true; } @Override public boolean onScaleBegin(float focusX, float focusY) { hideZoomView(); // This ensures that the item currently being manipulated // is locked at full opacity. mCurrentlyScalingItem = mViewItems[BUFFER_CENTER]; if (mCurrentlyScalingItem != null) { mCurrentlyScalingItem.lockAtFullOpacity(); } mScaleTrend = 1f; // If the image is smaller than screen size, we should allow to zoom // in to full screen size mMaxScale = Math.max(mController.getCurrentDataMaxScale(true), FULL_SCREEN_SCALE); return true; } @Override public boolean onScale(float focusX, float focusY, float scale) { mScaleTrend = mScaleTrend * 0.3f + scale * 0.7f; float newScale = mScale * scale; if (mScale < FULL_SCREEN_SCALE && newScale < FULL_SCREEN_SCALE) { if (newScale <= FILM_STRIP_SCALE) { newScale = FILM_STRIP_SCALE; } // Scaled view is smaller than or equal to screen size both // before and after scaling if (mScale != newScale) { if (mScale == FILM_STRIP_SCALE) { onLeaveFilmstrip(); } if (newScale == FILM_STRIP_SCALE) { onEnterFilmstrip(); } } mScale = newScale; invalidate(); } else if (mScale < FULL_SCREEN_SCALE && newScale >= FULL_SCREEN_SCALE) { // Going from smaller than screen size to bigger than or equal // to screen size if (mScale == FILM_STRIP_SCALE) { onLeaveFilmstrip(); } mScale = FULL_SCREEN_SCALE; onEnterFullScreen(); mController.setSurroundingViewsVisible(false); invalidate(); } else if (mScale >= FULL_SCREEN_SCALE && newScale < FULL_SCREEN_SCALE) { // Going from bigger than or equal to screen size to smaller // than screen size if (inFullScreen()) { if (mFullScreenUIHidden) { onLeaveFullScreenUiHidden(); } else { onLeaveFullScreen(); } } else { onLeaveZoomView(); } mScale = newScale; renderThumbnail(BUFFER_CENTER); onEnterFilmstrip(); invalidate(); } else { // Scaled view bigger than or equal to screen size both before // and after scaling if (!inZoomView()) { mController.setSurroundingViewsVisible(false); } ViewItem curr = mViewItems[BUFFER_CENTER]; // Make sure the image is not overly scaled newScale = Math.min(newScale, mMaxScale); if (newScale == mScale) { return true; } float postScale = newScale / mScale; curr.postScale(focusX, focusY, postScale, mDrawArea.width(), mDrawArea.height()); mScale = newScale; if (mScale == FULL_SCREEN_SCALE) { onEnterFullScreen(); } else { onEnterZoomView(); } renderFullRes(BUFFER_CENTER); } return true; } @Override public void onScaleEnd() { // Once the item is no longer under direct manipulation, unlock // the opacity so it can be set by other parts of the layout code. if (mCurrentlyScalingItem != null) { mCurrentlyScalingItem.unlockOpacity(); } zoomAtIndexChanged(); if (mScale > FULL_SCREEN_SCALE + TOLERANCE) { return; } mController.setSurroundingViewsVisible(true); if (mScale <= FILM_STRIP_SCALE + TOLERANCE) { mController.goToFilmstrip(); } else if (mScaleTrend > 1f || mScale > FULL_SCREEN_SCALE - TOLERANCE) { if (inZoomView()) { mScale = FULL_SCREEN_SCALE; resetZoomView(); } mController.goToFullScreen(); } else { mController.goToFilmstrip(); } mScaleTrend = 1f; } } }