package com.marshalchen.common.uimodule.tileView.layouts;
import android.content.Context;
import android.graphics.Point;
import android.graphics.Rect;
import android.os.Handler;
import android.os.Message;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewGroup;
import com.marshalchen.common.uimodule.tileView.animation.Tween;
import com.marshalchen.common.uimodule.tileView.animation.TweenListener;
import com.marshalchen.common.uimodule.tileView.animation.easing.Strong;
import com.marshalchen.common.uimodule.tileView.widgets.Scroller;
import java.lang.ref.WeakReference;
import java.util.HashSet;
/**
* ZoomPanLayout extends ViewGroup to provide support for scrolling and zooming. Fling, drag, pinch and
* double-tap events are supported natively.
*
* ZoomPanLayout does not support direct insertion of child Views, and manages positioning through an intermediary View.
* the addChild method provides an interface to add layouts to that intermediary view. Each of these children are provided
* with LayoutParams of MATCH_PARENT for both axes, and will always be positioned at 0,0, so should generally be ViewGroups
* themselves (RelativeLayouts or FrameLayouts are generally appropriate).
*/
public class ZoomPanLayout extends ViewGroup {
private static final int MINIMUM_VELOCITY = 50;
private static final int ZOOM_ANIMATION_DURATION = 500;
private static final int SLIDE_DURATION = 500;
private static final int VELOCITY_UNITS = 1000;
private static final int DOUBLE_TAP_TIME_THRESHOLD = 250;
private static final int SINGLE_TAP_DISTANCE_THRESHOLD = 50;
private static final double MINIMUM_PINCH_SCALE = 0.0;
private static final float FRICTION = 0.99f;
private int baseWidth;
private int baseHeight;
private int scaledWidth;
private int scaledHeight;
private double scale = 1;
private double historicalScale = 1;
private double minScale = 0;
private double maxScale = 1;
private boolean scaleToFit = true;
private Point pinchStartScroll = new Point();
private Point pinchStartOffset = new Point();
private double pinchStartDistance;
private Point doubleTapStartScroll = new Point();
private Point doubleTapStartOffset = new Point();
private double doubleTapDestinationScale;
private Point firstFinger = new Point();
private Point secondFinger = new Point();
private Point lastFirstFinger = new Point();
private Point lastSecondFinger = new Point();
private Point scrollPosition = new Point();
private Point singleTapHistory = new Point();
private Point doubleTapHistory = new Point();
private Point firstFingerLastDown = new Point();
private Point secondFingerLastDown = new Point();
private Point actualPoint = new Point();
private Point destinationScroll = new Point();
private boolean secondFingerIsDown = false;
private boolean firstFingerIsDown = false;
private boolean isTapInterrupted = false;
private boolean isBeingFlung = false;
private boolean isDragging = false;
private boolean isPinching = false;
private int dragStartThreshold = 30;
private int pinchStartThreshold = 30;
private long lastTouchedAt;
private ScrollActionHandler scrollActionHandler;
private Scroller scroller;
private VelocityTracker velocity;
private HashSet<GestureListener> gestureListeners = new HashSet<GestureListener>();
private HashSet<ZoomPanListener> zoomPanListeners = new HashSet<ZoomPanListener>();
private StaticLayout clip;
private TweenListener tweenListener = new TweenListener() {
@Override
public void onTweenComplete() {
isTweening = false;
for ( ZoomPanListener listener : zoomPanListeners ) {
listener.onZoomComplete( scale );
listener.onZoomPanEvent();
}
}
@Override
public void onTweenProgress( double progress, double eased ) {
double originalChange = doubleTapDestinationScale - historicalScale;
double updatedChange = originalChange * eased;
double currentScale = historicalScale + updatedChange;
setScale( currentScale );
maintainScrollDuringScaleTween();
}
@Override
public void onTweenStart() {
saveHistoricalScale();
isTweening = true;
for ( ZoomPanListener listener : zoomPanListeners ) {
listener.onZoomStart( scale );
listener.onZoomPanEvent();
}
}
};
private boolean isTweening;
private Tween tween = new Tween();
{
tween.setAnimationEase( Strong.EaseOut );
tween.addTweenListener( tweenListener );
}
/**
* Constructor to use when creating a ZoomPanLayout from code. Inflating from XML is not currently supported.
* @param context (Context) The Context the ZoomPanLayout is running in, through which it can access the current theme, resources, etc.
*/
public ZoomPanLayout( Context context ) {
super( context );
setWillNotDraw( false );
scrollActionHandler = new ScrollActionHandler( this );
scroller = new Scroller( context );
scroller.setFriction( FRICTION );
clip = new StaticLayout( context );
super.addView( clip, -1, new LayoutParams( -1, -1 ) );
updateClip();
}
//------------------------------------------------------------------------------------
// PUBLIC API
//------------------------------------------------------------------------------------
/**
* Determines whether the ZoomPanLayout should limit it's minimum scale to no less than what would be required to fill it's container
* @param shouldScaleToFit (boolean) True to limit minimum scale, false to allow arbitrary minimum scale (see {@link setScaleLimits})
*/
public void setScaleToFit( boolean shouldScaleToFit ) {
scaleToFit = shouldScaleToFit;
calculateMinimumScaleToFit();
}
/**
* Set minimum and maximum scale values for this ZoomPanLayout.
* Note that if {@link } is set to true, the minimum value set here will be ignored
* Default values are 0 and 1.
* @param min
* @param max
*/
public void setScaleLimits( double min, double max ) {
// if scaleToFit is set, don't allow overwrite
if ( !scaleToFit ) {
minScale = min;
}
maxScale = max;
setScale( scale );
}
/**
* Sets the size (width and height) of the ZoomPanLayout as it should be rendered at a scale of 1f (100%)
* @param wide width
* @param tall height
*/
public void setSize( int wide, int tall ) {
baseWidth = wide;
baseHeight = tall;
scaledWidth = (int) ( baseWidth * scale );
scaledHeight = (int) ( baseHeight * scale );
updateClip();
}
/**
* Returns the base (un-scaled) width
* @return (int) base width
*/
public int getBaseWidth() {
return baseWidth;
}
/**
* Returns the base (un-scaled) height
* @return (int) base height
*/
public int getBaseHeight() {
return baseHeight;
}
/**
* Returns the scaled width
* @return (int) scaled width
*/
public int getScaledWidth() {
return scaledWidth;
}
/**
* Returns the scaled height
* @return (int) scaled height
*/
public int getScaledHeight() {
return scaledHeight;
}
/**
* Sets the scale (0-1) of the ZoomPanLayout
* @param scale (double) The new value of the ZoomPanLayout scale
*/
public void setScale( double d ) {
d = Math.max( d, minScale );
d = Math.min( d, maxScale );
if ( scale != d ) {
scale = d;
scaledWidth = (int) ( baseWidth * scale );
scaledHeight = (int) ( baseHeight * scale );
updateClip();
postInvalidate();
for ( ZoomPanListener listener : zoomPanListeners ) {
listener.onScaleChanged( scale );
listener.onZoomPanEvent();
}
}
}
/**
* Requests a redraw
*/
public void redraw() {
updateClip();
postInvalidate();
}
/**
* Retrieves the current scale of the ZoomPanLayout
* @return (double) the current scale of the ZoomPanLayout
*/
public double getScale() {
return scale;
}
/**
* Returns whether the ZoomPanLayout is currently being flung
* @return (boolean) true if the ZoomPanLayout is currently flinging, false otherwise
*/
public boolean isFlinging() {
return isBeingFlung;
}
/**
* Returns the single child of the ZoomPanLayout, a ViewGroup that serves as an intermediary container
* @return (View) The child view of the ZoomPanLayout that manages all contained views
*/
public View getClip() {
return clip;
}
/**
* Returns the minimum distance required to start a drag operation, in pixels.
* @return (int) Pixel threshold required to start a drag.
*/
public int getDragStartThreshold(){
return dragStartThreshold;
}
/**
* Returns the minimum distance required to start a drag operation, in pixels.
* @param threshold (int) Pixel threshold required to start a drag.
*/
public void setDragStartThreshold( int threshold ){
dragStartThreshold = threshold;
}
/**
* Returns the minimum distance required to start a pinch operation, in pixels.
* @return (int) Pixel threshold required to start a pinch.
*/
public int getPinchStartThreshold(){
return pinchStartThreshold;
}
/**
* Returns the minimum distance required to start a pinch operation, in pixels.
* @param threshold (int) Pixel threshold required to start a pinch.
*/
public void setPinchStartThreshold( int threshold ){
pinchStartThreshold = threshold;
}
/**
* Adds a GestureListener to the ZoomPanLayout, which will receive gesture events
* @param listener (GestureListener) Listener to add
* @return (boolean) true when the listener set did not already contain the Listener, false otherwise
*/
public boolean addGestureListener( GestureListener listener ) {
return gestureListeners.add( listener );
}
/**
* Removes a GestureListener from the ZoomPanLayout
* @param listener (GestureListener) Listener to remove
* @return (boolean) if the Listener was removed, false otherwise
*/
public boolean removeGestureListener( GestureListener listener ) {
return gestureListeners.remove( listener );
}
/**
* Adds a ZoomPanListener to the ZoomPanLayout, which will receive events relating to zoom and pan actions
* @param listener (ZoomPanListener) Listener to add
* @return (boolean) true when the listener set did not already contain the Listener, false otherwise
*/
public boolean addZoomPanListener( ZoomPanListener listener ) {
return zoomPanListeners.add( listener );
}
/**
* Removes a ZoomPanListener from the ZoomPanLayout
* @param listener (ZoomPanListener) Listener to remove
* @return (boolean) if the Listener was removed, false otherwise
*/
public boolean removeZoomPanListener( ZoomPanListener listener ) {
return zoomPanListeners.remove( listener );
}
/**
* Scrolls the ZoomPanLayout to the x and y values specified by {@param point} Point
* @param point (Point) Point instance containing the destination x and y values
*/
public void scrollToPoint( Point point ) {
constrainPoint( point );
int ox = getScrollX();
int oy = getScrollY();
int nx = (int) point.x;
int ny = (int) point.y;
scrollTo( nx, ny );
if ( ox != nx || oy != ny ) {
for ( ZoomPanListener listener : zoomPanListeners ) {
listener.onScrollChanged( nx, ny );
listener.onZoomPanEvent();
}
}
}
/**
* Scrolls and centers the ZoomPanLayout to the x and y values specified by {@param point} Point
* @param point (Point) Point instance containing the destination x and y values
*/
public void scrollToAndCenter( Point point ) { // TODO:
int x = (int) -( getWidth() * 0.5 );
int y = (int) -( getHeight() * 0.5 );
point.offset( x, y );
scrollToPoint( point );
}
/**
* Scrolls the ZoomPanLayout to the x and y values specified by {@param point} Point using scrolling animation
* @param point (Point) Point instance containing the destination x and y values
*/
public void slideToPoint( Point point ) { // TODO:
constrainPoint( point );
int startX = getScrollX();
int startY = getScrollY();
int dx = point.x - startX;
int dy = point.y - startY;
scroller.startScroll( startX, startY, dx, dy, SLIDE_DURATION );
invalidate(); // we're posting invalidate in computeScroll, yet both are required
}
/**
* Scrolls and centers the ZoomPanLayout to the x and y values specified by {@param point} Point using scrolling animation
* @param point (Point) Point instance containing the destination x and y values
*/
public void slideToAndCenter( Point point ) { // TODO:
int x = (int) -( getWidth() * 0.5 );
int y = (int) -( getHeight() * 0.5 );
point.offset( x, y );
slideToPoint( point );
}
/**
* <i>This method is experimental</i>
* Scroll and scale to match passed Rect as closely as possible.
* The widget will attempt to frame the Rectangle, so that it's contained
* within the viewport, if possible.
* @param rect (Rect) rectangle to frame
*/
public void frameViewport( Rect rect ) {
// position it
scrollToPoint( new Point( rect.left, rect.top ) ); // TODO: center the axis that's smaller?
// scale it
double scaleX = getWidth() / (double) rect.width();
double scaleY = getHeight() / (double) rect.height();
double minimumScale = Math.min( scaleX, scaleY );
smoothScaleTo( minimumScale, SLIDE_DURATION );
}
/**
* Set the scale of the ZoomPanLayout while maintaining the current center point
* @param scale (double) The new value of the ZoomPanLayout scale
*/
public void setScaleFromCenter( double s ) {
int centerOffsetX = (int) ( getWidth() * 0.5f );
int centerOffsetY = (int) ( getHeight() * 0.5f );
Point offset = new Point( centerOffsetX, centerOffsetY );
Point scroll = new Point( getScrollX(), getScrollY() );
scroll.offset( offset.x, offset.y );
double deltaScale = s / getScale();
int x = (int) ( scroll.x * deltaScale ) - offset.x;
int y = (int) ( scroll.y * deltaScale ) - offset.y;
Point destination = new Point( x, y );
setScale( s );
scrollToPoint( destination );
}
/**
* Adds a View to the intermediary ViewGroup that manages layout for the ZoomPanLayout.
* This View will be laid out at the width and height specified by {@setSize} at 0, 0
* All ViewGroup.addView signatures are routed through this signature, so the only parameters
* considered are child and index.
* @param child (View) The View to be added to the ZoomPanLayout view tree
* @param index (int) The position at which to add the child View
*/
@Override
public void addView( View child, int index, LayoutParams params ) {
LayoutParams lp = new LayoutParams( scaledWidth, scaledHeight );
clip.addView( child, index, lp );
}
@Override
public void removeAllViews() {
clip.removeAllViews();
}
@Override
public void removeViewAt( int index ) {
clip.removeViewAt( index );
}
@Override
public void removeViews( int start, int count ) {
clip.removeViews( start, count );
}
/**
* Scales the ZoomPanLayout with animated progress
* @param destination (double) The final scale to animate to
* @param duration (int) The duration (in milliseconds) of the animation
*/
public void smoothScaleTo( double destination, int duration ) {
if ( isTweening ) {
return;
}
saveHistoricalScale();
int x = (int) ( ( getWidth() * 0.5 ) + 0.5 );
int y = (int) ( ( getHeight() * 0.5 ) + 0.5 );
doubleTapStartOffset.set( x, y );
doubleTapStartScroll.set( getScrollX(), getScrollY() );
doubleTapStartScroll.offset( x, y );
startSmoothScaleTo( destination, duration );
}
//------------------------------------------------------------------------------------
// PRIVATE/PROTECTED
//------------------------------------------------------------------------------------
@Override
protected void onMeasure( int widthMeasureSpec, int heightMeasureSpec ) {
measureChildren( widthMeasureSpec, heightMeasureSpec );
int w = clip.getMeasuredWidth();
int h = clip.getMeasuredHeight();
w = Math.max( w, getSuggestedMinimumWidth() );
h = Math.max( h, getSuggestedMinimumHeight() );
w = resolveSize( w, widthMeasureSpec );
h = resolveSize( h, heightMeasureSpec );
setMeasuredDimension( w, h );
}
@Override
protected void onLayout( boolean changed, int l, int t, int r, int b ) {
clip.layout( 0, 0, clip.getMeasuredWidth(), clip.getMeasuredHeight() );
constrainScroll();
if ( changed ) {
calculateMinimumScaleToFit();
}
}
private void calculateMinimumScaleToFit() {
if ( scaleToFit ) {
double minimumScaleX = getWidth() / (double) baseWidth;
double minimumScaleY = getHeight() / (double) baseHeight;
double recalculatedMinScale = Math.max( minimumScaleX, minimumScaleY );
if ( recalculatedMinScale != minScale ) {
minScale = recalculatedMinScale;
setScale( scale );
}
}
}
private void updateClip() {
updateViewClip( clip );
for ( int i = 0; i < clip.getChildCount(); i++ ) {
View child = clip.getChildAt( i );
updateViewClip( child );
}
constrainScroll();
}
private void updateViewClip( View v ) {
LayoutParams lp = v.getLayoutParams();
lp.width = scaledWidth;
lp.height = scaledHeight;
v.setLayoutParams( lp );
}
@Override
public void computeScroll() {
if ( scroller.computeScrollOffset() ) {
Point destination = new Point( scroller.getCurrX(), scroller.getCurrY() );
scrollToPoint( destination );
dispatchScrollActionNotification();
postInvalidate(); // should not be necessary but is...
}
}
private void dispatchScrollActionNotification() {
if ( scrollActionHandler.hasMessages( 0 ) ) {
scrollActionHandler.removeMessages( 0 );
}
scrollActionHandler.sendEmptyMessageDelayed( 0, 100 );
}
private void handleScrollerAction() {
Point point = new Point();
point.x = getScrollX();
point.y = getScrollY();
for ( GestureListener listener : gestureListeners ) {
listener.onScrollComplete( point );
}
if ( isBeingFlung ) {
isBeingFlung = false;
for ( GestureListener listener : gestureListeners ) {
listener.onFlingComplete( point );
}
}
}
private void constrainPoint( Point point ) {
int x = point.x;
int y = point.y;
int mx = Math.max( 0, Math.min( x, getLimitX() ) );
int my = Math.max( 0, Math.min( y, getLimitY() ) );
if ( x != mx || y != my ) {
point.set( mx, my );
}
}
private void constrainScroll() { // TODO:
Point currentScroll = new Point( getScrollX(), getScrollY() );
Point limitScroll = new Point( currentScroll );
constrainPoint( limitScroll );
if ( !currentScroll.equals( limitScroll ) ) {
scrollToPoint( limitScroll );
}
}
private int getLimitX() {
return scaledWidth - getWidth();
}
private int getLimitY() {
return scaledHeight - getHeight();
}
private void saveHistoricalScale() {
historicalScale = scale;
}
private void savePinchHistory() {
int x = (int) ( ( firstFinger.x + secondFinger.x ) * 0.5 );
int y = (int) ( ( firstFinger.y + secondFinger.y ) * 0.5 );
pinchStartOffset.set( x, y );
pinchStartScroll.set( getScrollX(), getScrollY() );
pinchStartScroll.offset( x, y );
}
private void maintainScrollDuringPinchOperation() {
double deltaScale = scale / historicalScale;
int x = (int) ( pinchStartScroll.x * deltaScale ) - pinchStartOffset.x;
int y = (int) ( pinchStartScroll.y * deltaScale ) - pinchStartOffset.y;
destinationScroll.set( x, y );
scrollToPoint( destinationScroll );
}
private void saveDoubleTapHistory() {
doubleTapStartOffset.set( firstFinger.x, firstFinger.y );
doubleTapStartScroll.set( getScrollX(), getScrollY() );
doubleTapStartScroll.offset( doubleTapStartOffset.x, doubleTapStartOffset.y );
}
private void maintainScrollDuringScaleTween() {
double deltaScale = scale / historicalScale;
int x = (int) ( doubleTapStartScroll.x * deltaScale ) - doubleTapStartOffset.x;
int y = (int) ( doubleTapStartScroll.y * deltaScale ) - doubleTapStartOffset.y;
destinationScroll.set( x, y );
scrollToPoint( destinationScroll );
}
private void saveHistoricalPinchDistance() {
int dx = firstFinger.x - secondFinger.x;
int dy = firstFinger.y - secondFinger.y;
pinchStartDistance = Math.sqrt( dx * dx + dy * dy );
}
private void setScaleFromPinch() {
int dx = firstFinger.x - secondFinger.x;
int dy = firstFinger.y - secondFinger.y;
double pinchCurrentDistance = Math.sqrt( dx * dx + dy * dy );
double currentScale = pinchCurrentDistance / pinchStartDistance;
currentScale = Math.max( currentScale, MINIMUM_PINCH_SCALE );
currentScale = historicalScale * currentScale;
setScale( currentScale );
}
private void performDrag() {
Point delta = new Point();
if ( secondFingerIsDown && !firstFingerIsDown ) {
delta.set( lastSecondFinger.x, lastSecondFinger.y );
delta.offset( -secondFinger.x, -secondFinger.y );
} else {
delta.set( lastFirstFinger.x, lastFirstFinger.y );
delta.offset( -firstFinger.x, -firstFinger.y );
}
scrollPosition.offset( delta.x, delta.y );
scrollToPoint( scrollPosition );
}
private boolean performFling() {
if ( secondFingerIsDown ) {
return false;
}
velocity.computeCurrentVelocity( VELOCITY_UNITS );
double xv = velocity.getXVelocity();
double yv = velocity.getYVelocity();
double totalVelocity = Math.abs( xv ) + Math.abs( yv );
if ( totalVelocity > MINIMUM_VELOCITY ) {
scroller.fling( getScrollX(), getScrollY(), (int) -xv, (int) -yv, 0, getLimitX(), 0, getLimitY() );
postInvalidate();
return true;
}
return false;
}
// if the taps occurred within threshold, it's a double tap
private boolean determineIfQualifiedDoubleTap() {
long now = System.currentTimeMillis();
long ellapsed = now - lastTouchedAt;
lastTouchedAt = now;
return ( ellapsed <= DOUBLE_TAP_TIME_THRESHOLD ) && ( Math.abs( firstFinger.x - doubleTapHistory.x ) <= SINGLE_TAP_DISTANCE_THRESHOLD )
&& ( Math.abs( firstFinger.y - doubleTapHistory.y ) <= SINGLE_TAP_DISTANCE_THRESHOLD );
}
private void saveTapActionOrigination() {
singleTapHistory.set( firstFinger.x, firstFinger.y );
}
private void saveDoubleTapOrigination() {
doubleTapHistory.set( firstFinger.x, firstFinger.y );
}
private void saveFirstFingerDown() {
firstFingerLastDown.set ( firstFinger.x, firstFinger.y );
}
private void saveSecondFingerDown() {
secondFingerLastDown.set ( secondFinger.x, secondFinger.y );
}
private void setTapInterrupted( boolean v ) {
isTapInterrupted = v;
}
// if the touch event has traveled past threshold since the finger first when down, it's not a tap
private boolean determineIfQualifiedSingleTap() {
return !isTapInterrupted && ( Math.abs( firstFinger.x - singleTapHistory.x ) <= SINGLE_TAP_DISTANCE_THRESHOLD )
&& ( Math.abs( firstFinger.y - singleTapHistory.y ) <= SINGLE_TAP_DISTANCE_THRESHOLD );
}
private void startSmoothScaleTo( double destination, int duration ){
if ( isTweening ) {
return;
}
doubleTapDestinationScale = destination;
tween.setDuration( duration );
tween.start();
}
private void processEvent( MotionEvent event ) {
// copy for history
lastFirstFinger.set( firstFinger.x, firstFinger.y );
lastSecondFinger.set( secondFinger.x, secondFinger.y );
// set false for now
firstFingerIsDown = false;
secondFingerIsDown = false;
// determine which finger is down and populate the appropriate points
for ( int i = 0; i < event.getPointerCount(); i++ ) {
int id = event.getPointerId( i );
int x = (int) event.getX( i );
int y = (int) event.getY( i );
switch ( id ) {
case 0:
firstFingerIsDown = true;
firstFinger.set( x, y );
actualPoint.set( x, y );
break;
case 1:
secondFingerIsDown = true;
secondFinger.set( x, y );
actualPoint.set( x, y );
break;
}
}
// record scroll position and adjust finger point to account for scroll offset
scrollPosition.set( getScrollX(), getScrollY() );
actualPoint.offset( scrollPosition.x, scrollPosition.y );
// update velocity for flinging
// TODO: this can probably be moved to the ACTION_MOVE switch
if ( velocity == null ) {
velocity = VelocityTracker.obtain();
}
velocity.addMovement( event );
}
@Override
public boolean onTouchEvent( MotionEvent event ) {
// update positions
processEvent( event );
// get the type of action
final int action = event.getAction() & MotionEvent.ACTION_MASK;
// react based on nature of touch event
switch ( action ) {
// first finger goes down
case MotionEvent.ACTION_DOWN:
if ( !scroller.isFinished() ) {
scroller.abortAnimation();
}
isBeingFlung = false;
isDragging = false;
setTapInterrupted( false );
saveFirstFingerDown();
saveTapActionOrigination();
for ( GestureListener listener : gestureListeners ) {
listener.onFingerDown( actualPoint );
}
break;
// second finger goes down
case MotionEvent.ACTION_POINTER_DOWN:
isPinching = false;
saveSecondFingerDown();
setTapInterrupted( true );
for ( GestureListener listener : gestureListeners ) {
listener.onFingerDown( actualPoint );
}
break;
// either finger moves
case MotionEvent.ACTION_MOVE:
// if both fingers are down, that means it's a pinch
if ( firstFingerIsDown && secondFingerIsDown ) {
if ( !isPinching ) {
double firstFingerDistance = getDistance( firstFinger, firstFingerLastDown );
double secondFingerDistance = getDistance( secondFinger, secondFingerLastDown );
double distance = ( firstFingerDistance + secondFingerDistance ) * 0.5;
isPinching = distance >= pinchStartThreshold;
// are we starting a pinch action?
if ( isPinching ) {
saveHistoricalPinchDistance();
saveHistoricalScale();
savePinchHistory();
for ( GestureListener listener : gestureListeners ) {
listener.onPinchStart( pinchStartOffset );
}
for ( ZoomPanListener listener : zoomPanListeners ) {
listener.onZoomStart( scale );
listener.onZoomPanEvent();
}
}
}
if ( isPinching ) {
setScaleFromPinch();
maintainScrollDuringPinchOperation();
for ( GestureListener listener : gestureListeners ) {
listener.onPinch( pinchStartOffset );
}
}
// otherwise it's a drag
} else {
if ( !isDragging ) {
double distance = getDistance( firstFinger, firstFingerLastDown );
isDragging = distance >= dragStartThreshold;
}
if ( isDragging ) {
performDrag();
for ( GestureListener listener : gestureListeners ) {
listener.onDrag( actualPoint );
}
}
}
break;
// first finger goes up
case MotionEvent.ACTION_UP:
if ( performFling() ) {
isBeingFlung = true;
Point startPoint = new Point( getScrollX(), getScrollY() );
Point finalPoint = new Point( scroller.getFinalX(), scroller.getFinalY() );
for ( GestureListener listener : gestureListeners ) {
listener.onFling( startPoint, finalPoint );
}
}
if ( velocity != null ) {
velocity.recycle();
velocity = null;
}
// could be a single tap...
if ( determineIfQualifiedSingleTap() ) {
for ( GestureListener listener : gestureListeners ) {
listener.onTap( actualPoint );
}
}
// or a double tap
if ( determineIfQualifiedDoubleTap() ) {
scroller.forceFinished( true );
saveHistoricalScale();
saveDoubleTapHistory();
double destination;
if ( scale >= maxScale ) {
destination = minScale;
} else {
destination = Math.min( maxScale, scale * 2 );
}
startSmoothScaleTo( destination, ZOOM_ANIMATION_DURATION );
for ( GestureListener listener : gestureListeners ) {
listener.onDoubleTap( actualPoint );
}
}
// either way it's a finger up event
for ( GestureListener listener : gestureListeners ) {
listener.onFingerUp( actualPoint );
}
// save coordinates to measure against the next double tap
saveDoubleTapOrigination();
isDragging = false;
isPinching = false;
break;
// second finger goes up
case MotionEvent.ACTION_POINTER_UP:
isPinching = false;
setTapInterrupted( true );
for ( GestureListener listener : gestureListeners ) {
listener.onFingerUp( actualPoint );
}
for ( GestureListener listener : gestureListeners ) {
listener.onPinchComplete( pinchStartOffset );
}
for ( ZoomPanListener listener : zoomPanListeners ) {
listener.onZoomComplete( scale );
listener.onZoomPanEvent();
}
break;
}
return true;
}
// sugar to calculate distance between 2 Points, because android.graphics.Point is horrible
private static double getDistance( Point p1, Point p2 ) {
int x = p1.x - p2.x;
int y = p1.y - p2.y;
return Math.sqrt( x * x + y * y );
}
private static class ScrollActionHandler extends Handler {
private final WeakReference<ZoomPanLayout> reference;
public ScrollActionHandler( ZoomPanLayout zoomPanLayout ) {
super();
reference = new WeakReference<ZoomPanLayout>( zoomPanLayout );
}
@Override
public void handleMessage( Message msg ) {
ZoomPanLayout zoomPanLayout = reference.get();
if ( zoomPanLayout != null ) {
zoomPanLayout.handleScrollerAction();
}
}
}
//------------------------------------------------------------------------------------
// Public static interfaces and classes
//------------------------------------------------------------------------------------
public static interface ZoomPanListener {
public void onScaleChanged(double scale);
public void onScrollChanged(int x, int y);
public void onZoomStart(double scale);
public void onZoomComplete(double scale);
public void onZoomPanEvent();
}
public static interface GestureListener {
public void onFingerDown(Point point);
public void onScrollComplete(Point point);
public void onFingerUp(Point point);
public void onDrag(Point point);
public void onDoubleTap(Point point);
public void onTap(Point point);
public void onPinch(Point point);
public void onPinchStart(Point point);
public void onPinchComplete(Point point);
public void onFling(Point startPoint, Point finalPoint);
public void onFlingComplete(Point point);
}
}