How to implement a 3D flip animation and 3D page flipping in Android

Although my site is in german, I write this text in english, because it is coding related.
This example should be compatible with Android 2.2 and higher.

Inspired by this post I extended the example by a Flip3DAnimator which tracks user touches and allows to animate without instantiating new objects. Therefore it avoids garbage collection.

Let’s begin with the Flip3DAnimation which I modified slightly. To be usable in the onTouch() method without instantiating a new object I added the update() method. I also added a callback interface to implement the page flipping effect.

import android.graphics.Camera;
import android.graphics.Matrix;
import android.view.animation.Animation;
import android.view.animation.Transformation;

public class Flip3dAnimation extends Animation {
	private float mFromDegrees;
	private float mToDegrees;
	private float mCenterX;
	private float mCenterY;
	private Camera mCamera;
	private Flip3DAnimationCallback mCallback;

	public Flip3dAnimation(float fromDegrees, float toDegrees, float centerX,
			float centerY, Flip3DAnimationCallback callback) {
		mFromDegrees = fromDegrees;
		mToDegrees = toDegrees;
		mCenterX = centerX;
		mCenterY = centerY;
		mCallback = callback;
	}

	public void update(float fromDegrees, float toDegrees, float centerX,
			float centerY) {
		mFromDegrees = fromDegrees;
		mToDegrees = toDegrees;
		mCenterX = centerX;
		mCenterY = centerY;
	}

	@Override
	public void initialize(int width, int height, int parentWidth,
			int parentHeight) {
		super.initialize(width, height, parentWidth, parentHeight);
		mCamera = new Camera();
	}

	@Override
	protected void applyTransformation(float interpolatedTime, Transformation t) {
		final float fromDegrees = mFromDegrees;
		float degrees = fromDegrees + ((mToDegrees - fromDegrees) * interpolatedTime);

		final float centerX = mCenterX;
		final float centerY = mCenterY;
		final Camera camera = mCamera;

		final Matrix matrix = t.getMatrix();

		camera.save();

		camera.rotateY(degrees);

		camera.getMatrix(matrix);
		camera.restore();

		matrix.preTranslate(-centerX, -centerY);
		matrix.postTranslate(centerX, centerY);

		if (mCallback != null) {
			mCallback.didApplyDegrees(degrees);
		}
	}

	public interface Flip3DAnimationCallback {

		public void didApplyDegrees(float degrees);

	}

}

Look here for a detailed explanation of this class.

Now the complicated part – the Flip3DAnimator which sets an OnTouchListener to the desired view and takes care of the touch events. The animator implements the Flip3DAnimationCallback an handles it appropriatly. You can uncomment the Log lines to see the process and understand the behavior. I will not explain everything in detail, but there are some helpfull javadocs. Mainly the OnTouchListener analyses the touch events and calculates the angle of the view dependent on the touch events. Then it applies a Flip3DAnimation to the desired view. When the touch ends a final animation is applied to the view, flipping it with the resulting velocity to 0 degrees. In case the user didn’t flip over 90 degrees the animation is applied in two stages. the first stage animated to 90 degrees (reports this to the Flip3DAnimatorCallback) and the second to 0. The Flip3DAnimatorCallback reports important events for handling page flipping.

import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnTouchListener;
import android.view.animation.Animation;
import android.view.animation.Animation.AnimationListener;
import android.view.animation.LinearInterpolator;
import de.cybergen.animation.Flip3dAnimation.Flip3DAnimationCallback;

public class Flip3dAnimator implements Flip3DAnimationCallback {

	public enum PageSide {
		PAGE_SIDE_NONE,   // e.g. dead zone touched
		PAGE_SIDE_LEFT,
		PAGE_SIDE_RIGHT
	}

	private final static float MIN_VELOCITY = 5;
	private final static float MAX_VELOCITY = 15;
	private final static int ASSUMED_FPS = 60;

	private float mLastDegrees = 0;
	private float mLastDegreesM1 = 0;
	private float mMotionStartFraction = 0;
	private float mCurrentMotionFraction = 0;
	private float mDeadZoneFraction = 0;
	private PageSide mCurrentPageSide = PageSide.PAGE_SIDE_NONE;
	private PageSide mRestrictToPageSide = PageSide.PAGE_SIDE_NONE;
	private Flip3dAnimatorCallback mCallback;
	private View mView;
	private Flip3dAnimation mFlip3DAnimation;
	private boolean mDidMove;
	private boolean mDidReportBeginFlip;

	private static final String TAG = "Flip3dAnimator";

	public Flip3dAnimator(View view, Flip3dAnimatorCallback callback, PageSide restrictToPageSide, float deadZoneFraction) {
		super();

		mCallback = callback;
		mRestrictToPageSide = restrictToPageSide;
		mView = view;
		mDeadZoneFraction = deadZoneFraction;
		mFlip3DAnimation = new Flip3dAnimation(0, 0, 0, 0, this);
		mFlip3DAnimation.setDuration(0);
		mFlip3DAnimation.setFillEnabled(true);
		mFlip3DAnimation.setFillBefore(true);
		mFlip3DAnimation.setFillAfter(true);
		mFlip3DAnimation.setInterpolator(new LinearInterpolator());

		makeViewFlipable();
	}

	private void makeViewFlipable() {
		mView.setEnabled(true);
//		Log.d(TAG, "left: " + R.idBookPages.FlipViewLeft + " | right: " + R.idBookPages.FlipViewRight + " | touched: " + view.getId());
		mView.setOnTouchListener(new OnTouchListener() {

			public boolean onTouch(final View v, MotionEvent event) {
				if (mCallback == null) {
					Log.w(TAG, "please implement Flip3dAnimatorCallback");
//					Log.i(TAG, " not consumed click event: 00");
					return false;
				}

				final float centerX = v.getWidth()/2;
				final float centerY = v.getHeight()/2;
				float x = event.getX();

				if (event.getAction() == MotionEvent.ACTION_DOWN) {
					mDidMove = false;
					mDidReportBeginFlip = false;

//					Log.d(TAG, "event: ACTION_DOWN");
					mLastDegrees = 0;
					mLastDegreesM1 = 0;
					float deadZoneX = centerX * mDeadZoneFraction;
					mCurrentPageSide = x < centerX - deadZoneX ? PageSide.PAGE_SIDE_LEFT :
						(x > centerX + deadZoneX ? PageSide.PAGE_SIDE_RIGHT : PageSide.PAGE_SIDE_NONE);
					PageSide currentPageSideIgnoringDeadZone = x < centerX ? PageSide.PAGE_SIDE_LEFT :
						(x > centerX ? PageSide.PAGE_SIDE_RIGHT : PageSide.PAGE_SIDE_NONE);

					Log.d(TAG, "deadZoneX: " + deadZoneX);
					Log.d(TAG, "mCurrentPageSide: " + mCurrentPageSide);

					// digest, if in dead zone or not in restricted page side
					if ((mRestrictToPageSide != PageSide.PAGE_SIDE_NONE
							&& mRestrictToPageSide != currentPageSideIgnoringDeadZone)
							|| (mCurrentPageSide == PageSide.PAGE_SIDE_LEFT && !mCallback.shouldFlipView(v, true))
							|| (mCurrentPageSide == PageSide.PAGE_SIDE_RIGHT && !mCallback.shouldFlipView(v, false))) {
//						Log.i(TAG, "not consumed click event: 01");
						return false;
					} else if (mCurrentPageSide == PageSide.PAGE_SIDE_NONE) {
//						Log.i(TAG, "consumed click event: 00");
						return true;
					}

					mMotionStartFraction = (mCurrentPageSide == PageSide.PAGE_SIDE_LEFT ? ((centerX - x) / centerX) :
						((x - centerX) / centerX));

//					Log.i(TAG, "consumed click event: 01");
					return true;
				} else if (event.getAction() == MotionEvent.ACTION_MOVE) {
					if (mCurrentPageSide == PageSide.PAGE_SIDE_NONE) {
//						Log.i(TAG, "consumed click event: 02");
						return true;
					}

					mDidMove = true;
//					Log.d(TAG, "event: ACTION_MOVE");
					mCurrentMotionFraction = (mCurrentPageSide == PageSide.PAGE_SIDE_LEFT ?
							((centerX - x) / centerX / mMotionStartFraction) :
								((x - centerX) / centerX / mMotionStartFraction));
//					Log.d(TAG, "mCurrentMotionFraction: " + mCurrentMotionFraction);
					mCurrentMotionFraction = Math.min(Math.max(mCurrentMotionFraction, -1), 1);
//					Log.d(TAG, "mCurrentMotionFraction 2: " + mCurrentMotionFraction);
					float angleFactor = mCurrentMotionFraction > 0 ? 90 : -90;
					float degrees = mCurrentPageSide == PageSide.PAGE_SIDE_LEFT ?
							angleFactor * (float)Math.sin((1-mCurrentMotionFraction)*Math.PI/2) :
								angleFactor * (float)Math.sin((mCurrentMotionFraction-1)*Math.PI/2);
//					Log.d(TAG, "degrees: " + degrees);
					degrees = Math.max(Math.min(degrees, 90), -90);
//					Log.d(TAG, "corrected: " + degrees);
					degrees = Math.abs(degrees) < 0.001f ? 0.0f : degrees;
//					Log.d(TAG, "corrected 2: " + degrees);

					if (mLastDegrees < 0 && degrees > 0) {
						viewFlipped50Percent(v, false);
					} else if (mLastDegrees > 0 && degrees < 0) {
						viewFlipped50Percent(v, true);
					}

//					Log.i(TAG, "events: " + event.getHistorySize() + " - fraction: " + mCurrentMotionFraction + " - degrees: " + Math.sin((1-mCurrentMotionFraction)*Math.PI/2));

					mFlip3DAnimation.update(degrees, degrees, centerX, centerY);

					v.startAnimation(mFlip3DAnimation);

					// update the temporary variables only if they change to keep the velocity != 0
					mLastDegreesM1 = mLastDegrees != mLastDegreesM1 ? mLastDegrees : mLastDegreesM1;
					mLastDegrees = degrees != mLastDegrees ? degrees : mLastDegrees;

					if (!mDidReportBeginFlip) {
						mCallback.didBeginFlippingView(v);
						mDidReportBeginFlip = true;
					}

//					Log.i(TAG, "consumed click event: 03");
					return true;
				} else if (event.getAction() == MotionEvent.ACTION_CANCEL || event.getAction() == MotionEvent.ACTION_UP) {
					if (!mDidMove) {
//						Log.i(TAG, "consumed click event: 04");
						return true;
					}

//					Log.d(TAG, "event: " + (event.getAction() == MotionEvent.ACTION_CANCEL ? "ACTION_CANCEL" : "ACTION_UP"));
					float flipTo2 = 0;
					final float velocity = Math.max(Math.min(mLastDegrees - mLastDegreesM1, MAX_VELOCITY), -MAX_VELOCITY);
					float minVelocityTemp = velocity < 0 ? Math.min(velocity, -MIN_VELOCITY) :
						Math.max(velocity, MIN_VELOCITY);
					if (velocity == 0) {
						if (mCurrentMotionFraction < 0)
							minVelocityTemp = mCurrentPageSide == PageSide.PAGE_SIDE_LEFT ? MIN_VELOCITY : -MIN_VELOCITY;
						else
							minVelocityTemp = mCurrentPageSide == PageSide.PAGE_SIDE_LEFT ? -MIN_VELOCITY : MIN_VELOCITY;
					}
					final float minVelocity = minVelocityTemp;
					if (mLastDegrees * minVelocity > 0) {
						flipTo2 = mLastDegrees > 0 ? 90 : -90;
					}
//					Log.d(TAG, "swiped " + (velocity > 0 ? "right" : "left") + " with velocity " + velocity + " (last degrees: " + mLastDegrees + ")");
//					Log.d(TAG, "   --> flipping to " + flipTo2);

//					Log.d(TAG, "velocity = " + velocity + " - minVelocity = " + minVelocity);
					final boolean toBeFlipped = (mCurrentPageSide == PageSide.PAGE_SIDE_LEFT && minVelocity > 0)
							|| (mCurrentPageSide == PageSide.PAGE_SIDE_RIGHT && minVelocity < 0);

					flipView(v, minVelocity, mLastDegrees, flipTo2, centerX, centerY, toBeFlipped);
				}

//				Log.i(TAG, "consumed click event: 05");
				return true;
			}

		});
	}

	/**
	 * Animates the view with the given velocity in one or two stages depending on the toBeFlipped variables.
	 * The 'flipped' variables must match the velocity and the given flipFrom degrees.
	 * Positive velocity turns left - negative right.
	 * 
	 * If toBeFlipped is true, the animator reports the change in flipping to the delegate.
	 * HINT: didEndFlippingAnimationForView() is called anyway.
	 * 
	 * 2 stages:
	 * If necessary and if flipTo is not 0 (zero), then the animation consists of two stages.
	 * First stage animates the view to 90 or -90 degrees depending on the velocity.
	 * Second stage animates the view from the resulting angle of the first stage
	 * 		back to 0 (zero) in the direction of the first stage.
	 * 
	 * @param view the view to animate
	 * @param velocity the velocity of the animation [degrees per frame]
	 * @param flipFrom the starting angle
	 * @param flipTo the angle at the end of the first animation stage
	 * @param centerX the x center of the rotation animation (width/2 in most cases)
	 * @param centerY the y center of the rotation animation (height/2 in most cases)
	 * @param toBeFlipped if the callback has to be informed about the changes in flipping
	 */
	private void flipView(final View view, final float velocity, float flipFrom, final float flipTo,
			final float centerX, final float centerY, final boolean toBeFlipped) {
		final Flip3dAnimator self = this;

		final boolean flipped = flipTo == 0 && toBeFlipped;
		final boolean flippedInSecondStage = flipTo != 0 && toBeFlipped;
//		Log.d(TAG, flipped ? "FLIPPED" : "NOT FLIPPED");
		long animationDuration = (long)((Math.abs(Math.abs(flipTo) - Math.abs(flipFrom))
				/ Math.abs(velocity)) * ASSUMED_FPS);
//		Log.d(TAG, "animation duration: " + animationDuration);
		float flipFromInternal = (flipFrom + velocity) * flipFrom < 0 && Math.abs(flipFrom) < MAX_VELOCITY ?
				flipFrom : flipFrom + velocity;
		Flip3dAnimation flipAnimation = new Flip3dAnimation(flipFromInternal, flipTo, centerX, centerY, this);
		flipAnimation.setDuration(animationDuration);
		flipAnimation.setFillAfter(true);
		flipAnimation.setInterpolator(new LinearInterpolator());
		flipAnimation.setAnimationListener(new AnimationListener() {
			public void onAnimationStart(Animation animation) {
			}
			public void onAnimationRepeat(Animation animation) {
			}
			public void onAnimationEnd(Animation animation) {
				if (flipped) {
					mCallback.viewFlipped(view);
				} else if (!flipped && flipTo != 0) {
					float flipFrom = flipTo > 0 ? -90 : 90;
					viewFlipped50Percent(view, flipFrom < 0);

					long animationDurationSecondStage = (long)((90 / Math.abs(velocity)) * ASSUMED_FPS);
					Flip3dAnimation flipAnimationSecondPart =
							new Flip3dAnimation(flipFrom + velocity, 0, centerX, centerY, self);
					flipAnimationSecondPart.setDuration(animationDurationSecondStage);
					flipAnimationSecondPart.setFillAfter(true);
					flipAnimationSecondPart.setInterpolator(new LinearInterpolator());
					flipAnimationSecondPart.setAnimationListener(new AnimationListener() {
						public void onAnimationStart(Animation animation) {
						}
						public void onAnimationRepeat(Animation animation) {
						}
						public void onAnimationEnd(Animation animation) {
							if (flippedInSecondStage)
								mCallback.viewFlipped(view);

							mCallback.didEndFlippingAnimationForView(view);
						}
					});
					view.startAnimation(flipAnimationSecondPart);
				}

				if (flipTo == 0)
					mCallback.didEndFlippingAnimationForView(view);
			}
		});
		mCallback.didBeginFlippingAnimationForView(view);
		view.startAnimation(flipAnimation);

		mLastDegrees = flipTo;
	}

	/**
	 * @param pageSide the PageSide to flip
	 * @param velocity the velocity in degrees per frame (sign doesn't matter)
	 */
	public void performFullPageFlip(PageSide pageSide, float velocity) {
		if (pageSide == PageSide.PAGE_SIDE_NONE)
			return;

		mCurrentPageSide = pageSide;

		float velocityInternal = pageSide == PageSide.PAGE_SIDE_LEFT ?
				Math.max(Math.min(Math.abs(velocity), 15), 5) :
					-Math.max(Math.min(Math.abs(velocity), 15), 5);
		float flipTo = pageSide == PageSide.PAGE_SIDE_LEFT ? 90 : -90;
		flipView(mView, velocityInternal, 0, flipTo, mView.getWidth()/2, mView.getHeight()/2, true);
	}

	private void viewFlipped50Percent(View view, boolean back) {
		if (mCallback == null)
			return;

		mCallback.viewFlipped50Percent(view, back);
	}

	public void didApplyDegrees(float degrees) {
//		Log.d(TAG, "raw degrees: " + degrees);
		float percentage = 0;

		if (degrees < 0) {
			percentage = degrees / -90.0f * 0.5f;
		} else if (degrees > 0) {
			percentage = ((1 - (degrees / 90.0f)) * 0.5f) + 0.5f;
		}

//		Log.d(TAG, "calculated percentage: " + percentage);

		percentage = mCurrentPageSide == PageSide.PAGE_SIDE_LEFT ? 1 - percentage : percentage;
		// TODO: fix bug: percentage is is reported wrong (1.0 instead 0) if the right page didn't turn.
//		Log.d(TAG, "dependent percentage: " + percentage);

		mCallback.viewFlippedToPercentage(mView, percentage);
	}

	public interface Flip3dAnimatorCallback {

		public boolean shouldFlipView(View view, boolean back);

		public void viewFlipped50Percent(View view, boolean back);

		public void viewFlippedToPercentage(View view, float percentage);

		public void viewFlipped(View view);

		/**
		 * called on the first move event on the flipping view
		 * @param view the flipping view
		 */
		public void didBeginFlippingView(View view);

		/**
		 * called when the user released his finger (touch up) and the automatic flip animation will begin
		 * @param view
		 */
		public void didBeginFlippingAnimationForView(View view);

		/**
		 * called when the automatic flip animation ends
		 * @param view
		 */
		public void didEndFlippingAnimationForView(View view);

	}

}

In a test project I use this technique and a ton of more code with layout XMLs to achieve a book filling up the whole display. After each full flip the new pages are loaded (left covered page, left page, right page and right covered page).

Finally make a view flippable by instantiating the animator. Call for example mFlipAnimatorLeft = new Flip3dAnimator(mFlipViewLeft, this, PageSide.PAGE_SIDE_LEFT, 0.9f); in you onCreate() or onCreateView() if you use fragments.

Dont‘ forget to implement the callback. It could look like this.

	public boolean shouldFlipView(View view, boolean back) {
		return true;
	}

	public void viewFlippedToPercentage(View view, float percentage) {
		// apply an additional shadow animation which looks really great
	}

	public void viewFlipped50Percent(View view, boolean back) {
		// replace an image or change the visibility of some views laying on top of the flipping view
	}

	public void viewFlipped(View view) {
		// load new pages or show another fragment
	}

	public void didBeginFlippingView(View view) {
		// Bring the flipping view to front in case it is covered by another view.
		// It's a good idea to leave this line as is.
		view.bringToFront();

		// play a sound
	}

	public void didBeginFlippingAnimationForView(View view) {
		// disable the flipping views to avoid double animations
		if (mFlipViewLeft != null)
			mFlipViewLeft.setEnabled(false);
		if (mFlipViewRight != null)
			mFlipViewRight.setEnabled(false);
	}

	public void didEndFlippingAnimationForView(View view) {
		// reenable the previously disabled flipping views
		if (mFlipViewLeft != null)
			mFlipViewLeft.setEnabled(true);
		if (mFlipViewRight != null)
			mFlipViewRight.setEnabled(true);

		// play a sound
	}

If you have any questions or critics leave a comment below. Maybe I’ll answer it…

Schreibe einen Kommentar