/* * Copyright (C) 2013 Ian G. Clifton * Code featured in Android User Interface Design: Turning Ideas and * Sketches into Beautifully Designed Apps (ISBN-10: 0321886739; * ISBN-13: 978-0321886736). * * 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.iangclifton.auid.horizontaliconview; import java.util.ArrayList; import java.util.List; import android.content.Context; import android.content.res.Resources; import android.graphics.Canvas; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.support.v4.view.MotionEventCompat; import android.support.v4.widget.EdgeEffectCompat; import android.util.AttributeSet; import android.util.Log; import android.view.MotionEvent; import android.view.VelocityTracker; import android.view.View; import android.view.ViewConfiguration; import android.widget.OverScroller; import android.widget.Toast; /** * View that displays {@link Drawable}s horizontally and can be scrolled. * * The displayed icons are intended to all be the same size. If one is * tapped, a simple {@link Toast} will appear. * * @author Ian G. Clifton */ public class HorizontalIconView extends View { private static final String TAG = "HorizontalIconView"; private static final int INVALID_POINTER = MotionEvent.INVALID_POINTER_ID; /** * int to track the ID of the pointer that is being tracked */ private int mActivePointerId = INVALID_POINTER; /** * The List of Drawables that will be shown */ private List<Drawable> mDrawables; /** * EdgeEffect or "glow" when scrolled too far left */ private EdgeEffectCompat mEdgeEffectLeft; /** * EdgeEffect or "glow" when scrolled too far right */ private EdgeEffectCompat mEdgeEffectRight; /** * List of Rects for each visible icon to calculate touches */ private final List<Rect> mIconPositions = new ArrayList<Rect>(); /** * Width and height of icons in pixels */ private int mIconSize; /** * Space between each icon in pixels */ private int mIconSpacing; /** * Whether a pointer/finger is currently on screen that is being tracked */ private boolean mIsBeingDragged; /** * Maximum fling velocity in pixels per second */ private int mMaximumVelocity; /** * Minimum fling velocity in pixels per second */ private int mMinimumVelocity; /** * How far to fling beyond the bounds of the view */ private int mOverflingDistance; /** * How far to scroll beyond the bounds of the view */ private int mOverscrollDistance; /** * The X coordinate of the last down touch, used to determine when a drag starts */ private float mPreviousX = 0; /** * Number of pixels this view can scroll (basically width - visible width) */ private int mScrollRange; /** * Number of pixels of movement required before a touch is "moving" */ private int mTouchSlop; /** * VelocityTracker to simplify tracking MotionEvents */ private VelocityTracker mVelocityTracker; /** * Scroller to do the hard work of scrolling smoothly */ private OverScroller mScroller; /** * The number of icons that are left of the view and therefore not drawn */ private int mSkippedIconCount = 0; public HorizontalIconView(Context context) { super(context); init(context); } public HorizontalIconView(Context context, AttributeSet attrs) { super(context, attrs); init(context); } public HorizontalIconView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(context); } @Override public void computeScroll() { if (mScroller.computeScrollOffset()) { int oldX = getScrollX(); int x = mScroller.getCurrX(); if (oldX != x) { overScrollBy(x - oldX, 0, oldX, 0, mScrollRange, 0, mOverflingDistance, 0, false); onScrollChanged(x, 0, oldX, 0); if (x < 0 && oldX >= 0) { mEdgeEffectLeft.onAbsorb((int) mScroller.getCurrVelocity()); } else if (x > mScrollRange && oldX <= mScrollRange) { mEdgeEffectRight.onAbsorb((int) mScroller.getCurrVelocity()); } } } } @Override public boolean onTouchEvent(MotionEvent ev) { if (mVelocityTracker == null) { mVelocityTracker = VelocityTracker.obtain(); } mVelocityTracker.addMovement(ev); final int action = MotionEventCompat.getActionMasked(ev); switch (action) { case MotionEvent.ACTION_DOWN: { if (!mScroller.isFinished()) { mScroller.abortAnimation(); } // Remember where the motion event started mPreviousX = (int) MotionEventCompat.getX(ev, 0); mActivePointerId = MotionEventCompat.getPointerId(ev, 0); break; } case MotionEvent.ACTION_MOVE: { final int activePointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId); if (activePointerIndex == INVALID_POINTER) { Log.e(TAG, "Invalid pointerId=" + mActivePointerId + " in onTouchEvent"); break; } final int x = (int) MotionEventCompat.getX(ev, 0); int deltaX = (int) (mPreviousX - x); if (!mIsBeingDragged && Math.abs(deltaX) > mTouchSlop) { mIsBeingDragged = true; if (deltaX > 0) { deltaX -= mTouchSlop; } else { deltaX += mTouchSlop; } } if (mIsBeingDragged) { // Scroll to follow the motion event mPreviousX = x; final int oldX = getScrollX(); final int range = mScrollRange; if (overScrollBy(deltaX, 0, oldX, 0, range, 0, mOverscrollDistance, 0, true)) { // Break our velocity if we hit a scroll barrier. mVelocityTracker.clear(); } if (mEdgeEffectLeft != null) { final int pulledToX = oldX + deltaX; if (pulledToX < 0) { mEdgeEffectLeft.onPull((float) deltaX / getWidth()); if (!mEdgeEffectRight.isFinished()) { mEdgeEffectRight.onRelease(); } } else if (pulledToX > range) { mEdgeEffectRight.onPull((float) deltaX / getWidth()); if (!mEdgeEffectLeft.isFinished()) { mEdgeEffectLeft.onRelease(); } } if (!mEdgeEffectLeft.isFinished() || !mEdgeEffectRight.isFinished()) { postInvalidateOnAnimation(); } } } break; } case MotionEvent.ACTION_UP: { if (mIsBeingDragged) { mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity); int initialVelocity = (int) mVelocityTracker.getXVelocity(mActivePointerId); if ((Math.abs(initialVelocity) > mMinimumVelocity)) { fling(-initialVelocity); } else { if (mScroller.springBack(getScrollX(), 0, 0, mScrollRange, 0, 0)) { postInvalidateOnAnimation(); } } mActivePointerId = INVALID_POINTER; mIsBeingDragged = false; mVelocityTracker.recycle(); mVelocityTracker = null; if (mEdgeEffectLeft != null) { mEdgeEffectLeft.onRelease(); mEdgeEffectRight.onRelease(); } } else { // Was not being dragged, was this a press on an icon? final int activePointerIndex = ev.findPointerIndex(mActivePointerId); if (activePointerIndex == INVALID_POINTER) { return false; } final int x = (int) ev.getX(activePointerIndex) + getScrollX(); final int y = (int) ev.getY(activePointerIndex); int i = 0; for (Rect rect : mIconPositions) { if (rect.contains(x, y)) { final int position = i + mSkippedIconCount; Toast.makeText(getContext(), "Pressed icon " + position + "; rect count: " + mIconPositions.size(), Toast.LENGTH_SHORT).show(); break; } i++; } } break; } case MotionEvent.ACTION_CANCEL: { if (mIsBeingDragged) { if (mScroller.springBack(getScrollX(), 0, 0, mScrollRange, 0, 0)) { postInvalidateOnAnimation(); } mActivePointerId = INVALID_POINTER; mIsBeingDragged = false; if (mVelocityTracker != null) { mVelocityTracker.recycle(); mVelocityTracker = null; } if (mEdgeEffectLeft != null) { mEdgeEffectLeft.onRelease(); mEdgeEffectRight.onRelease(); } } break; } case MotionEvent.ACTION_POINTER_UP: { onSecondaryPointerUp(ev); break; } } return true; } /** * Sets the List of Drawables to display * * @param drawables List of Drawables; can be null */ public void setDrawables(List<Drawable> drawables) { if (mDrawables == null) { if (drawables == null) { return; } requestLayout(); } else if (drawables == null) { requestLayout(); mDrawables = null; return; } else if (mDrawables.size() == drawables.size()) { invalidate(); } else { requestLayout(); } mDrawables = new ArrayList<Drawable>(drawables); mIconPositions.clear(); } @Override protected void onDraw(Canvas canvas) { if (mDrawables == null || mDrawables.isEmpty()) { return; } final int width = getWidth(); final int paddingBottom = getPaddingBottom(); final int paddingLeft = getPaddingLeft(); final int paddingTop = getPaddingTop(); // Determine edges of visible content final int leftEdge = getScrollX(); final int rightEdge = leftEdge + width; int left = paddingLeft; int top = paddingTop; mSkippedIconCount = 0; final int iconCount = mDrawables.size(); for (int i = 0; i < iconCount; i++) { if (left + mIconSize < leftEdge) { // Icon is too far left to be seen left = left + mIconSize + mIconSpacing; mSkippedIconCount++; continue; } if (left > rightEdge) { // All remaining icons are right of the view break; } // Get a reference to the icon to be drawn final Drawable icon = mDrawables.get(i); icon.setBounds(left, top, left + mIconSize, top + mIconSize); icon.draw(canvas); // Icon was drawn, so track position final int drawnPosition = i - mSkippedIconCount; if (drawnPosition + 1 > mIconPositions.size()) { final Rect rect = icon.copyBounds(); mIconPositions.add(rect); } else { final Rect rect = mIconPositions.get(drawnPosition); icon.copyBounds(rect); } // Update left position left = left + mIconSize + mIconSpacing; } if (mEdgeEffectLeft != null) { if (!mEdgeEffectLeft.isFinished()) { final int restoreCount = canvas.save(); final int height = getHeight() - paddingTop - paddingBottom; canvas.rotate(270); canvas.translate(-height + paddingTop, Math.min(0, leftEdge)); mEdgeEffectLeft.setSize(height, getWidth()); if (mEdgeEffectLeft.draw(canvas)) { postInvalidateOnAnimation(); } canvas.restoreToCount(restoreCount); } if (!mEdgeEffectRight.isFinished()) { final int restoreCount = canvas.save(); final int height = getHeight() - paddingTop - paddingBottom; canvas.rotate(90); canvas.translate(-paddingTop, -(Math.max(mScrollRange, leftEdge) + width)); mEdgeEffectRight.setSize(height, width); if (mEdgeEffectRight.draw(canvas)) { postInvalidateOnAnimation(); } canvas.restoreToCount(restoreCount); } } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { setMeasuredDimension(measureWidth(widthMeasureSpec), measureHeight(heightMeasureSpec)); } @Override protected void onOverScrolled(int scrollX, int scrollY, boolean clampedX, boolean clampedY) { if (mScroller.isFinished()) { super.scrollTo(scrollX, scrollY); } else { setScrollX(scrollX); if (clampedX) { mScroller.springBack(scrollX, 0, 0, mScrollRange, 0, 0); } } } /** * Flings the view horizontally with the specified velocity * * @param velocity int pixels per second along X axis */ private void fling(int velocity) { if (mScrollRange == 0) { return; } final int halfWidth = (getWidth() - getPaddingLeft() - getPaddingRight()) / 2; mScroller.fling(getScrollX(), 0, velocity, 0, 0, mScrollRange, 0, 0, halfWidth, 0); invalidate(); } /** * Perform one-time initialization * * @param context Context to load Resources and ViewConfiguration data */ private void init(Context context) { final Resources res = context.getResources(); mIconSize = res.getDimensionPixelSize(R.dimen.icon_size); mIconSpacing = res.getDimensionPixelSize(R.dimen.icon_spacing); // Cache ViewConfiguration values final ViewConfiguration config = ViewConfiguration.get(context); mTouchSlop = config.getScaledTouchSlop(); mMinimumVelocity = config.getScaledMinimumFlingVelocity(); mMaximumVelocity = config.getScaledMaximumFlingVelocity(); mOverflingDistance = config.getScaledOverflingDistance(); mOverscrollDistance = config.getScaledOverscrollDistance(); // Verify this View will be drawn setWillNotDraw(false); // Other setup mEdgeEffectLeft = new EdgeEffectCompat(context); mEdgeEffectRight = new EdgeEffectCompat(context); mScroller = new OverScroller(context); setFocusable(true); } /** * Measures height according to the passed measure spec * * @param measureSpec * int measure spec to use * @return int pixel size */ private int measureHeight(int measureSpec) { int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); int result = 0; if (specMode == MeasureSpec.EXACTLY) { result = specSize; } else { result = mIconSize + getPaddingTop() + getPaddingBottom(); if (specMode == MeasureSpec.AT_MOST) { result = Math.min(result, specSize); } } return result; } /** * Measures width according to the passed measure spec * * @param measureSpec * int measure spec to use * @return int pixel size */ private int measureWidth(int measureSpec) { int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); // Calculate maximum size final int icons = (mDrawables == null) ? 0 : mDrawables.size(); final int iconSpace = mIconSize * icons; final int dividerSpace; if (icons <= 1) { dividerSpace = 0; } else { dividerSpace = (icons - 1) * mIconSpacing; } final int maxSize = dividerSpace + iconSpace + getPaddingLeft() + getPaddingRight(); // Calculate actual size int result = 0; if (specMode == MeasureSpec.EXACTLY) { result = specSize; } else { if (specMode == MeasureSpec.AT_MOST) { result = Math.min(maxSize, specSize); } else { result = maxSize; } } if (maxSize > result) { mScrollRange = maxSize - result; } else { mScrollRange = 0; } return result; } private void onSecondaryPointerUp(MotionEvent ev) { final int pointerIndex = MotionEventCompat.getActionIndex(ev); final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex); if (pointerId == mActivePointerId) { final int newPointerIndex = pointerIndex == 0 ? 1 : 0; mPreviousX = ev.getX(newPointerIndex); mActivePointerId = ev.getPointerId(newPointerIndex); if (mVelocityTracker != null) { mVelocityTracker.clear(); } } } }