All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.qozix.layouts.ZoomPanLayout Maven / Gradle / Ivy

Go to download

The TileView widget is a subclass of ViewGroup that provides a mechanism to asynchronously display tile-based images, with additional functionality for 2D dragging, flinging, pinch or double-tap to zoom, adding overlaying Views (markers), built-in Hot Spot support, dynamic path drawing, multiple levels of detail, and support for any relative positioning or coordinate system.

The newest version!
package com.qozix.layouts;

import java.lang.ref.WeakReference;
import java.util.HashSet;

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.qozix.animation.Tween;
import com.qozix.animation.TweenListener;
import com.qozix.animation.easing.Strong;
import com.qozix.widgets.Scroller;

/**
 * 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 gestureListeners = new HashSet();
	private HashSet zoomPanListeners = new HashSet();

	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 shouldScaleToFit} 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 );
	}

	/**
	 * This method is experimental
	 * 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 reference;

		public ScrollActionHandler( ZoomPanLayout zoomPanLayout ) {
			super();
			reference = new WeakReference( 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 );
	}

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy