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