package com.qozix.layouts; import java.lang.ref.WeakReference; import java.util.HashSet; import android.content.Context; import android.graphics.Point; 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.5; 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 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 long lastTouchedAt; private boolean shouldIntercept = false; 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() { 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 ); 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 whether the ZoomPanLayout should intercept touch events on it's child views. * If true, the ZoomPanLayout will intercept touch events, so that touch events on child views * will not consume the event, so gestures (drag, fling) on the ZoomPanLayout will not be interrupted. * If false, child views will consume touch events normally, and will interrupt gesture events on the * containing ZoomPanLayout * @param intercept (boolean) Boolean value indicating whether the ZoomPanLayout should intercept touch events */ public void setShouldIntercept( boolean intercept ){ shouldIntercept = intercept; } /** * 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(); invalidate(); for ( ZoomPanListener listener : zoomPanListeners ) { listener.onScaleChanged( scale ); listener.onZoomPanEvent(); } } } /** * 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 */ protected View getClip() { return clip; } /** * 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 ); } /** * 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 ); } /** * 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 * @param child (View) The View to be added to the ZoomPanLayout view tree */ public void addChild( View child ) { LayoutParams lp = new LayoutParams( scaledWidth, scaledHeight ); clip.addView( child, lp ); } /** * Removes a View from the intermediary ViewGroup that manages layout for this ZoomPanLayout * @param child (View) The View to be removed */ public void removeChild( View child ) { if ( clip.indexOfChild( child ) > -1 ) { clip.removeView( child ); } } /** * 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; } doubleTapDestinationScale = destination; tween.setDuration( duration ); tween.start(); } //------------------------------------------------------------------------------------ // 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() ); 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 ); postInvalidate(); // should not be necessary but is... dispatchScrollActionNotification(); } } 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( currentScroll ); } } private int getLimitX() { return scaledWidth - getWidth(); } private int getLimitY() { return scaledHeight - getHeight(); } @Override public void addView( View child ) { throw new UnsupportedOperationException( "ZoomPanLayout does not allow direct addition of child views. Use addChild() instead." ); } @Override public void removeView( View child ) { throw new UnsupportedOperationException( "ZoomPanLayout does not allow direct removal of child views. Use removeChild() instead." ); } 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() ); invalidate(); 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 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 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 onInterceptTouchEvent (MotionEvent event) { // update positions processEvent( event); // if we wan't to intercept events (and allow drag on children)... if ( shouldIntercept ) { // get the type of action final int action = event.getAction() & MotionEvent.ACTION_MASK; // if it's a move event... if ( action == MotionEvent.ACTION_MOVE ) { // and capture it (so touch listeners on the children don't consume it and prevent scrolling) return true; } // otherwise, let the child handle it } return false; } @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; setTapInterrupted( false ); saveTapActionOrigination(); for ( GestureListener listener : gestureListeners ) { listener.onFingerDown( actualPoint ); } break; // second finger goes down case MotionEvent.ACTION_POINTER_DOWN : setTapInterrupted( true ); saveHistoricalPinchDistance(); saveHistoricalScale(); savePinchHistory(); for ( GestureListener listener : gestureListeners ) { listener.onFingerDown( actualPoint ); } for ( GestureListener listener : gestureListeners ) { listener.onPinchStart( pinchStartOffset ); } for ( ZoomPanListener listener : zoomPanListeners ) { listener.onZoomStart( scale ); listener.onZoomPanEvent(); } break; // either finger moves case MotionEvent.ACTION_MOVE : // if both fingers are down, that means it's a pinch if ( firstFingerIsDown && secondFingerIsDown ) { setScaleFromPinch(); maintainScrollDuringPinchOperation(); for ( GestureListener listener : gestureListeners ) { listener.onPinch( pinchStartOffset ); } // otherwise it's a drag } else { 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() ) { saveHistoricalScale(); saveDoubleTapHistory(); double destination = Math.min( 1, scale * 2 ); smoothScaleTo( 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(); break; // second finger goes up case MotionEvent.ACTION_POINTER_UP : 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; } 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 ); } }