diff options
-rw-r--r-- | src/com/android/camera/data/CameraDataAdapter.java | 604 | ||||
-rw-r--r-- | src/com/android/camera/ui/FilmStripGestureRecognizer.java | 107 | ||||
-rw-r--r-- | src/com/android/camera/ui/FilmStripView.java | 830 |
3 files changed, 1541 insertions, 0 deletions
diff --git a/src/com/android/camera/data/CameraDataAdapter.java b/src/com/android/camera/data/CameraDataAdapter.java new file mode 100644 index 000000000..cb67aacd7 --- /dev/null +++ b/src/com/android/camera/data/CameraDataAdapter.java @@ -0,0 +1,604 @@ +/* + * 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.data; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Matrix; +import android.graphics.drawable.Drawable; +import android.media.MediaMetadataRetriever; +import android.os.AsyncTask; +import android.provider.MediaStore; +import android.provider.MediaStore.Images; +import android.provider.MediaStore.Images.ImageColumns; +import android.provider.MediaStore.Video; +import android.provider.MediaStore.Video.VideoColumns; +import android.util.Log; +import android.view.View; +import android.widget.ImageView; + +import com.android.camera.Storage; +import com.android.camera.ui.FilmStripView; +import com.android.camera.ui.FilmStripView.ImageData; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +/** + * A FilmStripDataProvider that provide data in the camera folder. + * + * The given view for camera preview won't be added until the preview info + * has been set by setCameraPreviewInfo(int, int). + */ +public class CameraDataAdapter implements FilmStripView.DataAdapter { + private static final String TAG = CameraDataAdapter.class.getSimpleName(); + + private static final int DEFAULT_DECODE_SIZE = 3000; + private static final String[] CAMERA_PATH = { Storage.DIRECTORY + "%" }; + + private List<LocalData> mImages; + + private Listener mListener; + private View mCameraPreviewView; + private Drawable mPlaceHolder; + + private int mSuggestedWidth = DEFAULT_DECODE_SIZE; + private int mSuggestedHeight = DEFAULT_DECODE_SIZE; + + public CameraDataAdapter(Drawable placeHolder) { + mPlaceHolder = placeHolder; + } + + public void setCameraPreviewInfo(View cameraPreview, int width, int height) { + mCameraPreviewView = cameraPreview; + addOrReplaceCameraData(buildCameraImageData(width, height)); + } + + public void requestLoad(ContentResolver resolver) { + QueryTask qtask = new QueryTask(); + qtask.execute(resolver); + } + + @Override + public int getTotalNumber() { + if (mImages == null) return 0; + return mImages.size(); + } + + @Override + public ImageData getImageData(int id) { + if (mImages == null || id >= mImages.size()) return null; + return mImages.get(id); + } + + @Override + public void suggestSize(int w, int h) { + if (w <= 0 || h <= 0) { + mSuggestedWidth = mSuggestedHeight = DEFAULT_DECODE_SIZE; + } else { + mSuggestedWidth = (w < DEFAULT_DECODE_SIZE ? w : DEFAULT_DECODE_SIZE); + mSuggestedHeight = (h < DEFAULT_DECODE_SIZE ? h : DEFAULT_DECODE_SIZE); + } + } + + @Override + public View getView(Context c, int dataID) { + if (mImages == null) return null; + if (dataID >= mImages.size() || dataID < 0) { + return null; + } + + return mImages.get(dataID).getView( + c, mSuggestedWidth, mSuggestedHeight, mPlaceHolder); + } + + @Override + public void setListener(Listener listener) { + mListener = listener; + if (mImages != null) mListener.onDataLoaded(); + } + + private LocalData buildCameraImageData(int width, int height) { + LocalData d = new CameraPreviewData(width, height); + return d; + } + + private void addOrReplaceCameraData(LocalData data) { + if (mImages == null) mImages = new ArrayList<LocalData>(); + if (mImages.size() == 0) { + // No data at all. + mImages.add(0, data); + if (mListener != null) mListener.onDataLoaded(); + return; + } + + LocalData first = mImages.get(0); + if (first.getType() == ImageData.TYPE_CAMERA_PREVIEW) { + // Replace the old camera data. + mImages.set(0, data); + if (mListener != null) { + mListener.onDataUpdated(new UpdateReporter() { + @Override + public boolean isDataRemoved(int id) { + return false; + } + + @Override + public boolean isDataUpdated(int id) { + if (id == 0) return true; + return false; + } + }); + } + } else { + // Add a new camera data. + mImages.add(0, data); + if (mListener != null) { + mListener.onDataLoaded(); + } + } + } + + private class QueryTask extends AsyncTask<ContentResolver, Void, List<LocalData>> { + @Override + protected List<LocalData> doInBackground(ContentResolver... resolver) { + List<LocalData> l = new ArrayList<LocalData>(); + // Photos + Cursor c = resolver[0].query( + Images.Media.EXTERNAL_CONTENT_URI, + LocalPhotoData.QUERY_PROJECTION, + MediaStore.Images.Media.DATA + " like ? ", CAMERA_PATH, + LocalPhotoData.QUERY_ORDER); + if (c != null && c.moveToFirst()) { + // build up the list. + while (true) { + LocalData data = LocalPhotoData.buildFromCursor(c); + if (data != null) { + l.add(data); + } else { + Log.e(TAG, "Error loading data:" + + c.getString(LocalPhotoData.COL_DATA)); + } + if (c.isLast()) break; + c.moveToNext(); + } + } + if (c != null) c.close(); + + c = resolver[0].query( + Video.Media.EXTERNAL_CONTENT_URI, + LocalVideoData.QUERY_PROJECTION, + MediaStore.Video.Media.DATA + " like ? ", CAMERA_PATH, + LocalVideoData.QUERY_ORDER); + if (c != null && c.moveToFirst()) { + // build up the list. + c.moveToFirst(); + while (true) { + LocalData data = LocalVideoData.buildFromCursor(c); + if (data != null) { + l.add(data); + Log.v(TAG, "video data added:" + data); + } else { + Log.e(TAG, "Error loading data:" + + c.getString(LocalVideoData.COL_DATA)); + } + if (!c.isLast()) c.moveToNext(); + else break; + } + } + if (c != null) c.close(); + + if (l.size() == 0) return null; + + Collections.sort(l); + return l; + } + + @Override + protected void onPostExecute(List<LocalData> l) { + boolean changed = (l != mImages); + LocalData cameraData = null; + if (mImages != null && mImages.size() > 0) { + cameraData = mImages.get(0); + if (cameraData.getType() != ImageData.TYPE_CAMERA_PREVIEW) { + cameraData = null; + } + } + + mImages = l; + if (cameraData != null) { + // camera view exists, so we make sure at least have 1 data in the list. + if (mImages == null) mImages = new ArrayList<LocalData>(); + mImages.add(0, cameraData); + if (mListener != null) { + // Only the camera data is not changed, everything else is changed. + mListener.onDataUpdated(new UpdateReporter() { + @Override + public boolean isDataRemoved(int id) { + return false; + } + + @Override + public boolean isDataUpdated(int id) { + if (id == 0) return false; + return true; + } + }); + } + } else { + // both might be null. + if (changed) mListener.onDataLoaded(); + } + } + } + + private abstract static class LocalData implements + FilmStripView.ImageData, + Comparable<LocalData> { + public long id; + public String title; + public String mimeType; + public long dateTaken; + public long dateModified; + public String path; + // width and height should be adjusted according to orientation. + public int width; + public int height; + + @Override + public int getWidth() { + return width; + } + + @Override + public int getHeight() { + return height; + } + + @Override + public boolean isActionSupported(int action) { + return false; + } + + private int compare(long v1, long v2) { + return ((v1 > v2) ? 1 : ((v1 < v2) ? -1 : 0)); + } + + @Override + public int compareTo(LocalData d) { + int cmp = compare(d.dateTaken, dateTaken); + if (cmp != 0) return cmp; + cmp = compare(d.dateModified, dateModified); + if (cmp != 0) return cmp; + cmp = d.title.compareTo(title); + if (cmp != 0) return cmp; + return compare(d.id, id); + } + + @Override + public abstract int getType(); + + abstract View getView(Context c, int width, int height, Drawable placeHolder); + } + + private class CameraPreviewData extends LocalData { + CameraPreviewData(int w, int h) { + width = w; + height = h; + } + + @Override + public int getWidth() { + return width; + } + + @Override + public int getHeight() { + return height; + } + + @Override + public int getType() { + return ImageData.TYPE_CAMERA_PREVIEW; + } + + @Override + View getView(Context c, int width, int height, Drawable placeHolder) { + return mCameraPreviewView; + } + } + + private static class LocalPhotoData extends LocalData { + static final String QUERY_ORDER = ImageColumns.DATE_TAKEN + " DESC, " + + ImageColumns._ID + " DESC"; + static final String[] QUERY_PROJECTION = { + ImageColumns._ID, // 0, int + ImageColumns.TITLE, // 1, string + ImageColumns.MIME_TYPE, // 2, string + ImageColumns.DATE_TAKEN, // 3, int + ImageColumns.DATE_MODIFIED, // 4, int + ImageColumns.DATA, // 5, string + ImageColumns.ORIENTATION, // 6, int, 0, 90, 180, 270 + ImageColumns.WIDTH, // 7, int + ImageColumns.HEIGHT, // 8, int + }; + + private static final int COL_ID = 0; + private static final int COL_TITLE = 1; + private static final int COL_MIME_TYPE = 2; + private static final int COL_DATE_TAKEN = 3; + private static final int COL_DATE_MODIFIED = 4; + private static final int COL_DATA = 5; + private static final int COL_ORIENTATION = 6; + private static final int COL_WIDTH = 7; + private static final int COL_HEIGHT = 8; + + // 32K buffer. + private static final byte[] DECODE_TEMP_STORAGE = new byte[32 * 1024]; + + // from MediaStore, can only be 0, 90, 180, 270; + public int orientation; + + static LocalPhotoData buildFromCursor(Cursor c) { + LocalPhotoData d = new LocalPhotoData(); + d.id = c.getLong(COL_ID); + d.title = c.getString(COL_TITLE); + d.mimeType = c.getString(COL_MIME_TYPE); + d.dateTaken = c.getLong(COL_DATE_TAKEN); + d.dateModified = c.getLong(COL_DATE_MODIFIED); + d.path = c.getString(COL_DATA); + d.orientation = c.getInt(COL_ORIENTATION); + d.width = c.getInt(COL_WIDTH); + d.height = c.getInt(COL_HEIGHT); + if (d.width <= 0 || d.height <= 0) { + Log.v(TAG, "warning! zero dimension for " + + d.path + ":" + d.width + "x" + d.height); + Dimension dim = decodeDimension(d.path); + if (dim != null) { + d.width = dim.width; + d.height = dim.height; + } else { + Log.v(TAG, "warning! dimension decode failed for " + d.path); + Bitmap b = BitmapFactory.decodeFile(d.path); + if (b == null) return null; + d.width = b.getWidth(); + d.height = b.getHeight(); + } + } + if (d.orientation == 90 || d.orientation == 270) { + int b = d.width; + d.width = d.height; + d.height = b; + } + return d; + } + + @Override + View getView(Context c, + int decodeWidth, int decodeHeight, Drawable placeHolder) { + ImageView v = new ImageView(c); + v.setImageDrawable(placeHolder); + + v.setScaleType(ImageView.ScaleType.FIT_XY); + LoadBitmapTask task = new LoadBitmapTask(v, decodeWidth, decodeHeight); + task.execute(); + return v; + } + + @Override + public String toString() { + return "LocalPhotoData:" + ",data=" + path + ",mimeType=" + mimeType + + "," + width + "x" + height + ",orientation=" + orientation + + ",date=" + new Date(dateTaken); + } + + @Override + public int getType() { + return TYPE_PHOTO; + } + + private static Dimension decodeDimension(String path) { + BitmapFactory.Options opts = new BitmapFactory.Options(); + opts.inJustDecodeBounds = true; + Bitmap b = BitmapFactory.decodeFile(path, opts); + if (b == null) return null; + Dimension d = new Dimension(); + d.width = opts.outWidth; + d.height = opts.outHeight; + return d; + } + + private static class Dimension { + public int width; + public int height; + } + + private class LoadBitmapTask extends AsyncTask<Void, Void, Bitmap> { + private ImageView mView; + private int mDecodeWidth; + private int mDecodeHeight; + + public LoadBitmapTask(ImageView v, int decodeWidth, int decodeHeight) { + mView = v; + mDecodeWidth = decodeWidth; + mDecodeHeight = decodeHeight; + } + + @Override + protected Bitmap doInBackground(Void... v) { + BitmapFactory.Options opts = null; + Bitmap b; + int sample = 1; + while (mDecodeWidth * sample < width + || mDecodeHeight * sample < height) { + sample *= 2; + } + opts = new BitmapFactory.Options(); + opts.inSampleSize = sample; + opts.inTempStorage = DECODE_TEMP_STORAGE; + if (isCancelled()) return null; + b = BitmapFactory.decodeFile(path, opts); + if (orientation != 0) { + if (isCancelled()) return null; + Matrix m = new Matrix(); + m.setRotate((float) orientation); + b = Bitmap.createBitmap(b, 0, 0, b.getWidth(), b.getHeight(), m, false); + } + return b; + } + + @Override + protected void onPostExecute(Bitmap bitmap) { + if (bitmap == null) { + Log.e(TAG, "Cannot decode bitmap file:" + path); + return; + } + mView.setScaleType(ImageView.ScaleType.FIT_XY); + mView.setImageBitmap(bitmap); + } + } + } + + private static class LocalVideoData extends LocalData { + static final String QUERY_ORDER = VideoColumns.DATE_TAKEN + " DESC, " + + VideoColumns._ID + " DESC"; + static final String[] QUERY_PROJECTION = { + VideoColumns._ID, // 0, int + VideoColumns.TITLE, // 1, string + VideoColumns.MIME_TYPE, // 2, string + VideoColumns.DATE_TAKEN, // 3, int + VideoColumns.DATE_MODIFIED, // 4, int + VideoColumns.DATA, // 5, string + VideoColumns.WIDTH, // 6, int + VideoColumns.HEIGHT, // 7, int + VideoColumns.RESOLUTION + }; + + private static final int COL_ID = 0; + private static final int COL_TITLE = 1; + private static final int COL_MIME_TYPE = 2; + private static final int COL_DATE_TAKEN = 3; + private static final int COL_DATE_MODIFIED = 4; + private static final int COL_DATA = 5; + private static final int COL_WIDTH = 6; + private static final int COL_HEIGHT = 7; + + public int resolutionW; + public int resolutionH; + + static LocalVideoData buildFromCursor(Cursor c) { + LocalVideoData d = new LocalVideoData(); + d.id = c.getLong(COL_ID); + d.title = c.getString(COL_TITLE); + d.mimeType = c.getString(COL_MIME_TYPE); + d.dateTaken = c.getLong(COL_DATE_TAKEN); + d.dateModified = c.getLong(COL_DATE_MODIFIED); + d.path = c.getString(COL_DATA); + d.width = c.getInt(COL_WIDTH); + d.height = c.getInt(COL_HEIGHT); + MediaMetadataRetriever retriever = new MediaMetadataRetriever(); + retriever.setDataSource(d.path); + String rotation = retriever.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION); + if (d.width == 0 || d.height == 0) { + d.width = Integer.parseInt(retriever.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)); + d.height = Integer.parseInt(retriever.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)); + } + retriever.release(); + if (rotation.equals("90") || rotation.equals("270")) { + int b = d.width; + d.width = d.height; + d.height = b; + } + return d; + } + + @Override + View getView(Context c, + int decodeWidth, int decodeHeight, Drawable placeHolder) { + ImageView v = new ImageView(c); + v.setImageDrawable(placeHolder); + + v.setScaleType(ImageView.ScaleType.FIT_XY); + LoadBitmapTask task = new LoadBitmapTask(v); + task.execute(); + return v; + } + + + @Override + public String toString() { + return "LocalVideoData:" + ",data=" + path + ",mimeType=" + mimeType + + "," + width + "x" + height + ",date=" + new Date(dateTaken); + } + + @Override + public int getType() { + return TYPE_PHOTO; + } + + private static Dimension decodeDimension(String path) { + Dimension d = new Dimension(); + return d; + } + + private static class Dimension { + public int width; + public int height; + } + + private class LoadBitmapTask extends AsyncTask<Void, Void, Bitmap> { + private ImageView mView; + + public LoadBitmapTask(ImageView v) { + mView = v; + } + + @Override + protected Bitmap doInBackground(Void... v) { + android.media.MediaMetadataRetriever retriever = new MediaMetadataRetriever(); + retriever.setDataSource(path); + byte[] data = retriever.getEmbeddedPicture(); + Bitmap bitmap = null; + if (data != null) { + bitmap = BitmapFactory.decodeByteArray(data, 0, data.length); + } + if (bitmap == null) { + bitmap = (Bitmap) retriever.getFrameAtTime(); + } + retriever.release(); + return bitmap; + } + + @Override + protected void onPostExecute(Bitmap bitmap) { + if (bitmap == null) { + Log.e(TAG, "Cannot decode video file:" + path); + return; + } + mView.setImageBitmap(bitmap); + } + } + } +} diff --git a/src/com/android/camera/ui/FilmStripGestureRecognizer.java b/src/com/android/camera/ui/FilmStripGestureRecognizer.java new file mode 100644 index 000000000..f0e2534d3 --- /dev/null +++ b/src/com/android/camera/ui/FilmStripGestureRecognizer.java @@ -0,0 +1,107 @@ +/* + * 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.content.Context; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; + +// This class aggregates three gesture detectors: GestureDetector, +// ScaleGestureDetector. +public class FilmStripGestureRecognizer { + @SuppressWarnings("unused") + private static final String TAG = "FilmStripGestureRecognizer"; + + public interface Listener { + boolean onSingleTapUp(float x, float y); + boolean onDoubleTap(float x, float y); + boolean onScroll(float x, float y, float dx, float dy); + boolean onFling(float velocityX, float velocityY); + boolean onScaleBegin(float focusX, float focusY); + boolean onScale(float focusX, float focusY, float scale); + boolean onDown(float x, float y); + void onScaleEnd(); + } + + private final GestureDetector mGestureDetector; + private final ScaleGestureDetector mScaleDetector; + private final Listener mListener; + + public FilmStripGestureRecognizer(Context context, Listener listener) { + mListener = listener; + mGestureDetector = new GestureDetector(context, new MyGestureListener(), + null, true /* ignoreMultitouch */); + mScaleDetector = new ScaleGestureDetector( + context, new MyScaleListener()); + } + + public boolean onTouchEvent(MotionEvent event) { + return mGestureDetector.onTouchEvent(event) || mScaleDetector.onTouchEvent(event); + } + + private class MyGestureListener + extends GestureDetector.SimpleOnGestureListener { + @Override + public boolean onSingleTapUp(MotionEvent e) { + return mListener.onSingleTapUp(e.getX(), e.getY()); + } + + @Override + public boolean onDoubleTap(MotionEvent e) { + return mListener.onDoubleTap(e.getX(), e.getY()); + } + + @Override + public boolean onScroll( + MotionEvent e1, MotionEvent e2, float dx, float dy) { + return mListener.onScroll(e2.getX(), e2.getY(), dx, dy); + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, + float velocityY) { + return mListener.onFling(velocityX, velocityY); + } + + @Override + public boolean onDown(MotionEvent e) { + mListener.onDown(e.getX(), e.getY()); + return super.onDown(e); + } + } + + private class MyScaleListener + extends ScaleGestureDetector.SimpleOnScaleGestureListener { + @Override + public boolean onScaleBegin(ScaleGestureDetector detector) { + return mListener.onScaleBegin( + detector.getFocusX(), detector.getFocusY()); + } + + @Override + public boolean onScale(ScaleGestureDetector detector) { + return mListener.onScale(detector.getFocusX(), + detector.getFocusY(), detector.getScaleFactor()); + } + + @Override + public void onScaleEnd(ScaleGestureDetector detector) { + mListener.onScaleEnd(); + } + } +} diff --git a/src/com/android/camera/ui/FilmStripView.java b/src/com/android/camera/ui/FilmStripView.java new file mode 100644 index 000000000..9aeb96ffd --- /dev/null +++ b/src/com/android/camera/ui/FilmStripView.java @@ -0,0 +1,830 @@ +/* + * 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.ValueAnimator; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.DecelerateInterpolator; +import android.widget.Scroller; + +public class FilmStripView extends ViewGroup { + private static final String TAG = FilmStripView.class.getSimpleName(); + private static final int BUFFER_SIZE = 5; + // Horizontal padding of children. + private static final int H_PADDING = 50; + // Duration to go back to the first. + private static final int DURATION_BACK_ANIM = 500; + private static final int DURATION_SCROLL_TO_FILMSTRIP = 350; + private static final int DURATION_GEOMETRY_ADJUST = 200; + private static final float FILM_STRIP_SCALE = 0.6f; + private static final float MAX_SCALE = 1f; + + private Context mContext; + private FilmStripGestureRecognizer mGestureRecognizer; + private DataAdapter mDataAdapter; + private final Rect mDrawArea = new Rect(); + + private int mCurrentInfo; + private float mScale; + private GeometryAnimator mGeometryAnimator; + private int mCenterPosition = -1; + private ViewInfo[] mViewInfo = new ViewInfo[BUFFER_SIZE]; + + // 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 mCenterPosition is not adjusted with the orientation. + // Set this to true when onSizeChanged is called to make sure we adjust + // mCenterPosition accordingly. + private boolean mAnchorPending; + + public interface ImageData { + public static final int TYPE_NONE = 0; + public static final int TYPE_CAMERA_PREVIEW = 1; + public static final int TYPE_PHOTO = 2; + public static final int TYPE_VIDEO = 3; + public static final int TYPE_PHOTOSPHERE = 4; + + // The actions are defined bit-wise so we can use bit operations like + // | and &. + public static final int ACTION_NONE = 0; + public static final int ACTION_PROMOTE = 1; + public static final int ACTION_DEMOTE = 2; + + // SIZE_FULL means disgard the width or height when deciding the view size + // of this ImageData, just use full screen size. + public static final int SIZE_FULL = -2; + + // The values returned by getWidth() and getHeight() will be used for layout. + public int getWidth(); + public int getHeight(); + public int getType(); + public boolean isActionSupported(int action); + } + + public interface DataAdapter { + public interface UpdateReporter { + public boolean isDataRemoved(int id); + public boolean isDataUpdated(int id); + } + + public interface Listener { + // Called when the whole data loading is done. No any assumption + // on previous data. + public void onDataLoaded(); + // Only some of the data is changed. The listener should check + // if any thing needs to be updated. + public void onDataUpdated(UpdateReporter reporter); + public void onDataInserted(int dataID); + public void onDataRemoved(int dataID); + } + + public int getTotalNumber(); + public View getView(Context context, int id); + public ImageData getImageData(int id); + public void suggestSize(int w, int h); + + public void setListener(Listener listener); + } + + // 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 float mOffsetY; + + public ViewInfo(int id, View v) { + v.setPivotX(0f); + v.setPivotY(0f); + mDataID = id; + mView = v; + mLeftPosition = -1; + mOffsetY = 0; + } + + public int getID() { + return mDataID; + } + + public void setLeftPosition(int pos) { + mLeftPosition = pos; + } + + public int getLeftPosition() { + return mLeftPosition; + } + + public float getOffsetY() { + return mOffsetY; + } + + public void setOffsetY(float offset) { + mOffsetY = offset; + } + + 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 + + mOffsetY); + layoutAt(left, top); + mView.setScaleX(scale); + mView.setScaleY(scale); + } + } + + public FilmStripView(Context context) { + super(context); + init(context); + } + + public FilmStripView(Context context, AttributeSet attrs) { + super(context, attrs); + init(context); + } + + public FilmStripView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(context); + } + + private void init(Context context) { + mCurrentInfo = (BUFFER_SIZE - 1) / 2; + // 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; + mGeometryAnimator = new GeometryAnimator(context); + mGestureRecognizer = + new FilmStripGestureRecognizer(context, new MyGestureReceiver()); + } + + public float getScale() { + return mScale; + } + + public boolean isAnchoredTo(int id) { + if (mViewInfo[mCurrentInfo].getID() == id + && mViewInfo[mCurrentInfo].getCenterX() == mCenterPosition) { + 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 (mGeometryAnimator.hasNewGeometry()) { + layoutChildren(); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + int boundWidth = MeasureSpec.getSize(widthMeasureSpec); + int boundHeight = MeasureSpec.getSize(heightMeasureSpec); + if (mDataAdapter != null) { + mDataAdapter.suggestSize(boundWidth / 2, boundHeight / 2); + } + + int wMode = View.MeasureSpec.EXACTLY; + int hMode = View.MeasureSpec.EXACTLY; + + for (int i = 0; i < mViewInfo.length; i++) { + ViewInfo info = mViewInfo[i]; + if (mViewInfo[i] == null) continue; + + int imageWidth = mDataAdapter.getImageData(info.getID()).getWidth(); + int imageHeight = mDataAdapter.getImageData(info.getID()).getHeight(); + if (imageWidth == ImageData.SIZE_FULL) imageWidth = boundWidth; + if (imageHeight == ImageData.SIZE_FULL) imageHeight = boundHeight; + + int scaledWidth = boundWidth; + int scaledHeight = boundHeight; + + if (imageWidth * scaledHeight > scaledWidth * imageHeight) { + scaledHeight = imageHeight * scaledWidth / imageWidth; + } else { + scaledWidth = imageWidth * scaledHeight / imageHeight; + } + scaledWidth += H_PADDING * 2; + mViewInfo[i].getView().measure( + View.MeasureSpec.makeMeasureSpec(scaledWidth, wMode) + , View.MeasureSpec.makeMeasureSpec(scaledHeight, hMode)); + } + setMeasuredDimension(boundWidth, boundHeight); + } + + private int findTheNearestView(int pointX) { + + int nearest = 0; + // find the first non-null ViewInfo. + for (; nearest < BUFFER_SIZE + && (mViewInfo[nearest] == null || mViewInfo[nearest].getLeftPosition() == -1); + nearest++); + // no existing available ViewInfo + if (nearest == BUFFER_SIZE) return -1; + int min = Math.abs(pointX - mViewInfo[nearest].getCenterX()); + + for (int infoID = nearest + 1; + infoID < BUFFER_SIZE && mViewInfo[infoID] != null; infoID++) { + // not measured yet. + if (mViewInfo[infoID].getLeftPosition() == -1) continue; + + int c = mViewInfo[infoID].getCenterX(); + int dist = Math.abs(pointX - c); + if (dist < min) { + min = dist; + nearest = infoID; + } + } + return nearest; + } + + private ViewInfo buildInfoFromData(int dataID) { + View v = mDataAdapter.getView(mContext, dataID); + if (v == null) return null; + v.setPadding(H_PADDING, 0, H_PADDING, 0); + ViewInfo info = new ViewInfo(dataID, v); + addView(info.getView()); + return info; + } + + // We try to keep the one closest to the center of the screen at position mCurrentInfo. + private void stepIfNeeded() { + int nearest = findTheNearestView(mCenterPosition); + // no change made. + if (nearest == -1 || nearest == mCurrentInfo) return; + + int adjust = nearest - mCurrentInfo; + if (adjust > 0) { + for (int k = 0; k < adjust; k++) { + if (mViewInfo[k] != null) { + removeView(mViewInfo[k].getView()); + } + } + 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--) { + if (mViewInfo[k] != null) { + removeView(mViewInfo[k].getView()); + } + } + 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 out of bound. + private void adjustCenterPosition() { + ViewInfo curr = mViewInfo[mCurrentInfo]; + if (curr == null) return; + + if (curr.getID() == 0 && mCenterPosition < curr.getCenterX()) { + mCenterPosition = curr.getCenterX(); + mGeometryAnimator.stopScroll(); + } + if (curr.getID() == mDataAdapter.getTotalNumber() - 1 + && mCenterPosition > curr.getCenterX()) { + mCenterPosition = curr.getCenterX(); + mGeometryAnimator.stopScroll(); + } + } + + private void layoutChildren() { + if (mAnchorPending) { + mCenterPosition = mViewInfo[mCurrentInfo].getCenterX(); + mAnchorPending = false; + } + + if (mGeometryAnimator.hasNewGeometry()) { + mCenterPosition = mGeometryAnimator.getNewPosition(); + mScale = mGeometryAnimator.getNewScale(); + } + + adjustCenterPosition(); + + mViewInfo[mCurrentInfo].layoutIn(mDrawArea, mCenterPosition, mScale); + + // images on the left + for (int infoID = mCurrentInfo - 1; infoID >= 0; infoID--) { + ViewInfo curr = mViewInfo[infoID]; + if (curr != null) { + ViewInfo next = mViewInfo[infoID + 1]; + curr.setLeftPosition( + next.getLeftPosition() - curr.getView().getMeasuredWidth()); + curr.layoutIn(mDrawArea, mCenterPosition, mScale); + } + } + + // images on the right + for (int infoID = mCurrentInfo + 1; infoID < BUFFER_SIZE; infoID++) { + ViewInfo curr = mViewInfo[infoID]; + if (curr != null) { + ViewInfo prev = mViewInfo[infoID - 1]; + curr.setLeftPosition( + prev.getLeftPosition() + prev.getView().getMeasuredWidth()); + curr.layoutIn(mDrawArea, mCenterPosition, mScale); + } + } + + stepIfNeeded(); + invalidate(); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + if (mViewInfo[mCurrentInfo] == null) return; + + mDrawArea.left = l; + mDrawArea.top = t; + mDrawArea.right = r; + mDrawArea.bottom = b; + + layoutChildren(); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + if (w == oldw && h == oldh) return; + if (mViewInfo[mCurrentInfo] != null && mScale == 1f + && isAnchoredTo(mViewInfo[mCurrentInfo].getID())) { + mAnchorPending = true; + } + } + + public void setDataAdapter(DataAdapter adapter) { + mDataAdapter = adapter; + mDataAdapter.suggestSize(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) { + } + + @Override + public void onDataRemoved(int dataID) { + } + }); + } + + public boolean isInCameraFullscreen() { + return (isAnchoredTo(0) && mScale == 1f + && getCurrentType() == ImageData.TYPE_CAMERA_PREVIEW); + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + if (isInCameraFullscreen()) return false; + return true; + } + + @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)) { + mCenterPosition = -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; + + int currentData = 0; + int currentLeft = 0; + mViewInfo[mCurrentInfo] = buildInfoFromData(currentData); + mViewInfo[mCurrentInfo].setLeftPosition(currentLeft); + if (getCurrentType() == ImageData.TYPE_CAMERA_PREVIEW + && currentLeft == 0) { + // we are in camera mode by default. + mGeometryAnimator.lockPosition(currentLeft); + } + 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(); + } + + // GeometryAnimator controls all the geometry animations. It passively + // tells the geometry information on demand. + private class GeometryAnimator implements + 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 mCanStopScale; + + private boolean mIsPositionLocked; + private int mLockedPosition; + + private Runnable mPostAction; + + GeometryAnimator(Context context) { + mScroller = new Scroller(context); + mHasNewPosition = false; + mScaleAnimator = new ValueAnimator(); + mScaleAnimator.addUpdateListener(GeometryAnimator.this); + mScaleAnimator.addListener(GeometryAnimator.this); + mDecelerateInterpolator = new DecelerateInterpolator(); + mCanStopScroll = true; + mCanStopScale = true; + mHasNewScale = false; + } + + boolean hasNewGeometry() { + mHasNewPosition = mScroller.computeScrollOffset(); + if (!mHasNewPosition) { + mCanStopScroll = true; + } + // If the position is locked, then we always return true to force + // the position value to use the locked value. + return (mHasNewPosition || mHasNewScale || mIsPositionLocked); + } + + // Always call hasNewGeometry() before getting the new scale value. + float getNewScale() { + if (!mHasNewScale) return mScale; + mHasNewScale = false; + return mNewScale; + } + + // Always call hasNewGeometry() before getting the new position value. + int getNewPosition() { + if (mIsPositionLocked) return mLockedPosition; + if (!mHasNewPosition) return mCenterPosition; + return mScroller.getCurrX(); + } + + void lockPosition(int pos) { + mIsPositionLocked = true; + mLockedPosition = pos; + } + + void unlockPosition() { + if (mIsPositionLocked) { + // only when the position is previously locked we set the current + // position to make it consistent. + mCenterPosition = mLockedPosition; + mIsPositionLocked = false; + } + } + + void fling(int velocityX, int minX, int maxX) { + if (!stopScroll() || mIsPositionLocked) return; + mScroller.fling(mCenterPosition, 0, velocityX, 0, minX, maxX, 0, 0); + } + + boolean stopScroll() { + if (!mCanStopScroll) return false; + mScroller.forceFinished(true); + mHasNewPosition = false; + return true; + } + + boolean stopScale() { + if (!mCanStopScale) return false; + mScaleAnimator.cancel(); + mHasNewScale = false; + return true; + } + + void stop() { + stopScroll(); + stopScale(); + } + + void scrollTo(int position, int duration, boolean interruptible) { + if (!stopScroll() || mIsPositionLocked) return; + mCanStopScroll = interruptible; + stopScroll(); + mScroller.startScroll(mCenterPosition, 0, position - mCenterPosition, + 0, duration); + } + + void scrollTo(int position, int duration) { + scrollTo(position, duration, true); + } + + void scaleTo(float scale, int duration, boolean interruptible) { + if (!stopScale()) return; + mCanStopScale = interruptible; + mScaleAnimator.setDuration(duration); + mScaleAnimator.setFloatValues(mScale, scale); + mScaleAnimator.setInterpolator(mDecelerateInterpolator); + mScaleAnimator.start(); + mHasNewScale = true; + } + + void scaleTo(float scale, int duration) { + scaleTo(scale, duration, true); + } + + void setPostAction(Runnable act) { + mPostAction = act; + } + + @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) { + if (mPostAction != null) { + mPostAction.run(); + mPostAction = null; + } + mCanStopScale = true; + } + + @Override + public void onAnimationCancel(Animator anim) { + mPostAction = null; + } + + @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) { + return false; + } + + @Override + public boolean onDoubleTap(float x, float y) { + return false; + } + + @Override + public boolean onDown(float x, float y) { + mGeometryAnimator.stop(); + return true; + } + + @Override + public boolean onScroll(float x, float y, float dx, float dy) { + int deltaX = (int) (dx / mScale); + if (deltaX > 0 && isInCameraFullscreen()) { + mGeometryAnimator.unlockPosition(); + mGeometryAnimator.scaleTo(FILM_STRIP_SCALE, DURATION_GEOMETRY_ADJUST, false); + } + + mCenterPosition += deltaX; + + // Vertical part. Promote or demote. + int scaledDeltaY = (int) (dy / mScale); + + for (int i = 0; i < BUFFER_SIZE; i++) { + if (mViewInfo[i] == null) continue; + Rect hitRect = new Rect(); + View v = mViewInfo[i].getView(); + v.getHitRect(hitRect); + if (hitRect.contains((int) x, (int) y)) { + ImageData data = mDataAdapter.getImageData(mViewInfo[i].getID()); + if ((data.isActionSupported(ImageData.ACTION_DEMOTE) && dy > 0) + || (data.isActionSupported(ImageData.ACTION_PROMOTE) && dy < 0)) { + mViewInfo[i].setOffsetY(mViewInfo[i].getOffsetY() - dy); + } + break; + } + } + + layoutChildren(); + return true; + } + + @Override + public boolean onFling(float velocityX, float velocityY) { + float scaledVelocityX = velocityX / mScale; + if (isInCameraFullscreen() && scaledVelocityX < 0) { + mGeometryAnimator.unlockPosition(); + mGeometryAnimator.scaleTo(FILM_STRIP_SCALE, DURATION_GEOMETRY_ADJUST, false); + } + ViewInfo info = mViewInfo[mCurrentInfo]; + int w = getWidth(); + if (info == null) return true; + mGeometryAnimator.fling((int) -scaledVelocityX, + // estimation of possible length on the left + info.getLeftPosition() - info.getID() * w * 2, + // estimation of possible length on the right + info.getLeftPosition() + + (mDataAdapter.getTotalNumber() - info.getID()) * w * 2); + layoutChildren(); + return true; + } + + @Override + public boolean onScaleBegin(float focusX, float focusY) { + if (isInCameraFullscreen()) return false; + mScaleTrend = 1f; + return true; + } + + @Override + public boolean onScale(float focusX, float focusY, float scale) { + if (isInCameraFullscreen()) return false; + + mScaleTrend = mScaleTrend * 0.5f + scale * 0.5f; + mScale *= scale; + if (mScale <= FILM_STRIP_SCALE) mScale = FILM_STRIP_SCALE; + if (mScale >= MAX_SCALE) mScale = MAX_SCALE; + layoutChildren(); + return true; + } + + @Override + public void onScaleEnd() { + if (mScaleTrend >= 1f) { + if (mScale != 1f) { + mGeometryAnimator.scaleTo(1f, DURATION_GEOMETRY_ADJUST, false); + } + + if (getCurrentType() == ImageData.TYPE_CAMERA_PREVIEW) { + if (isAnchoredTo(0)) { + mGeometryAnimator.lockPosition(mViewInfo[mCurrentInfo].getCenterX()); + } else { + mGeometryAnimator.scrollTo( + mViewInfo[mCurrentInfo].getCenterX(), + DURATION_GEOMETRY_ADJUST, false); + mGeometryAnimator.setPostAction(mLockPositionRunnable); + } + } + } else { + // Scale down to film strip mode. + if (mScale == FILM_STRIP_SCALE) { + mGeometryAnimator.unlockPosition(); + return; + } + mGeometryAnimator.scaleTo(FILM_STRIP_SCALE, DURATION_GEOMETRY_ADJUST, false); + mGeometryAnimator.setPostAction(mUnlockPositionRunnable); + } + } + + private Runnable mLockPositionRunnable = new Runnable() { + @Override + public void run() { + mGeometryAnimator.lockPosition(mViewInfo[mCurrentInfo].getCenterX()); + } + }; + + private Runnable mUnlockPositionRunnable = new Runnable() { + @Override + public void run() { + mGeometryAnimator.unlockPosition(); + } + }; + } +} |