Implementing Parabolic Bounce Animation with Android SurfaceView
To animate a projectile following a parabolic trajectory with a bounce, a physical model dictates the coordinates over time, while SurfaceView handles the rapid off-UI-thread rendering. When the projectile reaches its target, a 3D flip animation updates a counter.
Trajectory Physics Model
The object drops from an initial elevation dropHeight under gravity G. Up on striking the ground, kinetic energy decreases by a fraction defined as ENERGY_LOSS_RATIO. The required horizontal displacement is horizontalDistance.
The descent duration phaseOneDuration is:
phaseOneDuration = Math.sqrt(2.0 * dropHeight / G);
The ascent and subsequent descent duration phaseTwoDuration after the initial bounce is:
phaseTwoDuration = Math.sqrt(2.0 * (1.0 - ENERGY_LOSS_RATIO) * dropHeight / G);
The total flight duration is phaseOneDuration + 2 * phaseTwoDuration. Horizontal velocity xVelocity ensures the object reaches horizontalDistance at the end of the flight:
xVelocity = horizontalDistance / (phaseOneDuration + 2 * phaseTwoDuration);
At any given elapsed time elapsed, the Y coordinate is calculated by evaluating the corresponding flight phase:
double x = xVelocity * elapsed;
if (elapsed < phaseOneDuration) {
y = dropHeight - 0.5 * G * elapsed * elapsed;
} else if (elapsed < phaseOneDuration + phaseTwoDuration) {
double remaining = phaseOneDuration + phaseTwoDuration - elapsed;
y = (1 - ENERGY_LOSS_RATIO) * dropHeight - 0.5 * G * remaining * remaining;
} else if (elapsed < phaseOneDuration + 2 * phaseTwoDuration) {
double elapsedSinceApex = elapsed - phaseOneDuration - phaseTwoDuration;
y = (1 - ENERGY_LOSS_RATIO) * dropHeight - 0.5 * G * elapsedSinceApex * elapsedSinceApex;
} else {
x = horizontalDistance;
y = 0;
isAnimating = false;
}
Since Android screen coordinates increment downwards, the calculated y must be mirrored relative to the view's center before drawing.
SurfaceView Rendering Thread
SurfaceView enables drawing on a dedicated background thread, bypassing the main UI thread's overhead. The custom view implements SurfaceHolder.Callback to control the rendering loop. Drawing operations must occur strictly between surfaceCreated and surfaceDestroyed.
Inside the rendering loop, the canvas is locked, cleared of the previous frame using a transparent color mode, and the bitmap is drawn at the computed coordinates:
Canvas canvas = holder.lockCanvas();
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
canvas.drawBitmap(projectileBitmap, (float) calculator.getX(), (float) calculator.getMirroredY(getHeight(), projectileBitmap.getHeight()), bitmapPaint);
holder.unlockCanvasAndPost(canvas);
3D Text Flip Animation
Once the trajectory concludes, a counter updates via a 3D rotation around the Y-axis using android.graphics.Camera. To prevent the text from appearing mirrored past the halfway mark, the angle is shifted by 180 degrees, and the underlying text string updates when the interpolation exceeds 0.5.
Implementation
TrajectoryCalculator.java
public class TrajectoryCalculator {
private static final float G = 400.78033f;
private static final float ENERGY_LOSS_RATIO = 0.3f;
private int dropHeight;
private int horizontalDistance;
private double xVelocity;
private double x, y;
private long startTime;
private double phaseOneDuration, phaseTwoDuration;
private boolean isAnimating;
public void configure(int height, int distance) {
dropHeight = height;
horizontalDistance = distance;
phaseOneDuration = Math.sqrt(2.0 * dropHeight / G);
phaseTwoDuration = Math.sqrt(2.0 * (1.0 - ENERGY_LOSS_RATIO) * dropHeight / G);
xVelocity = horizontalDistance / (phaseOneDuration + 2 * phaseTwoDuration);
}
public void commence() {
startTime = System.currentTimeMillis();
isAnimating = true;
}
public void calculatePosition() {
double elapsed = (System.currentTimeMillis() - startTime) / 1000.0;
x = xVelocity * elapsed;
if (elapsed < phaseOneDuration) {
y = dropHeight - 0.5 * G * elapsed * elapsed;
} else if (elapsed < phaseOneDuration + phaseTwoDuration) {
double remaining = phaseOneDuration + phaseTwoDuration - elapsed;
y = (1 - ENERGY_LOSS_RATIO) * dropHeight - 0.5 * G * remaining * remaining;
} else if (elapsed < phaseOneDuration + 2 * phaseTwoDuration) {
double elapsedSinceApex = elapsed - phaseOneDuration - phaseTwoDuration;
y = (1 - ENERGY_LOSS_RATIO) * dropHeight - 0.5 * G * elapsedSinceApex * elapsedSinceApex;
} else {
x = horizontalDistance;
y = 0;
isAnimating = false;
}
}
public double getX() { return x; }
public double getY() { return y; }
public double getMirroredY(int viewHeight, int bitmapHeight) {
double midpoint = viewHeight / 2.0;
double mirrored = midpoint + (midpoint - y) - bitmapHeight;
return mirrored;
}
public boolean isRunning() { return isAnimating; }
public void halt() { isAnimating = false; }
}
RenderWorker.java
public class RenderWorker extends Thread {
private ProjectileSurfaceView surfaceView;
public RenderWorker(ProjectileSurfaceView view) {
surfaceView = view;
}
@Override
public void run() {
if (surfaceView != null) {
surfaceView.executeRenderLoop();
}
}
}
ProjectileSurfaceView.java
public class ProjectileSurfaceView extends SurfaceView implements SurfaceHolder.Callback {
private static final long FRAME_INTERVAL = 10L;
private SurfaceHolder holder;
private Bitmap projectileBitmap;
private RenderWorker worker;
private TrajectoryCalculator calculator;
private AnimationEventListener eventListener;
private boolean isSurfaceValid = true;
public ProjectileSurfaceView(Context context, AttributeSet attrs) {
super(context, attrs);
initialize();
}
private void initialize() {
holder = getHolder();
holder.addCallback(this);
holder.setFormat(PixelFormat.TRANSPARENT);
setZOrderOnTop(true);
calculator = new TrajectoryCalculator();
}
@Override
public void surfaceCreated(SurfaceHolder h) {
isSurfaceValid = true;
}
@Override
public void surfaceChanged(SurfaceHolder h, int format, int width, int height) {}
@Override
public void surfaceDestroyed(SurfaceHolder h) {
isSurfaceValid = false;
calculator.halt();
}
public void executeRenderLoop() {
Canvas canvas;
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
calculator.commence();
if (eventListener != null) eventListener.onAnimationStart(this);
while (calculator.isRunning()) {
try {
calculator.calculatePosition();
canvas = holder.lockCanvas();
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
float drawX = (float) calculator.getX();
float drawY = (float) calculator.getMirroredY(getHeight(), projectileBitmap.getHeight());
canvas.drawBitmap(projectileBitmap, drawX, drawY, paint);
holder.unlockCanvasAndPost(canvas);
Thread.sleep(FRAME_INTERVAL);
} catch (Exception ex) {
ex.printStackTrace();
}
}
if (isSurfaceValid) {
canvas = holder.lockCanvas();
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
holder.unlockCanvasAndPost(canvas);
}
if (eventListener != null) eventListener.onAnimationEnd(this);
}
public void launchAnimation() {
if (worker == null || worker.getState() == Thread.State.TERMINATED) {
worker = new RenderWorker(this);
}
if (worker.getState() == Thread.State.NEW) {
worker.start();
}
}
public boolean isAnimating() { return calculator.isRunning(); }
public void setBitmap(Bitmap bit) { projectileBitmap = bit; }
public void setTrajectoryParams(int h, int w) { calculator.configure(h, w); }
public void setAnimationEventListener(AnimationEventListener l) { eventListener = l; }
public interface AnimationEventListener {
void onAnimationStart(ProjectileSurfaceView view);
void onAnimationEnd(ProjectileSurfaceView view);
}
}
Flip3DAnimation.java
public class Flip3DAnimation extends Animation {
public static final boolean FLIP_FORWARD = false;
public static final boolean FLIP_BACKWARD = true;
private static final float Z_AXIS_DEPTH = 310.0f;
private static final long ANIM_DURATION = 800L;
private final boolean rotationType;
private final float pivotX, pivotY;
private Camera camera;
private ProgressUpdateListener progressListener;
public Flip3DAnimation(float centerX, float centerY, boolean type) {
pivotX = centerX;
pivotY = centerY;
rotationType = type;
setDuration(ANIM_DURATION);
}
@Override
public void initialize(int width, int height, int parentWidth, int parentHeight) {
super.initialize(width, height, parentWidth, parentHeight);
camera = new Camera();
}
public void setProgressUpdateListener(ProgressUpdateListener listener) {
progressListener = listener;
}
@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
if (progressListener != null) progressListener.onProgress(interpolatedTime);
float startAngle, endAngle;
if (rotationType == FLIP_BACKWARD) {
startAngle = 0f; endAngle = 180f;
} else {
startAngle = 360f; endAngle = 180f;
}
float currentAngle = startAngle + (endAngle - startAngle) * interpolatedTime;
boolean pastHalfway = interpolatedTime > 0.5f;
if (pastHalfway) currentAngle -= 180f;
float depth = (0.5f - Math.abs(interpolatedTime - 0.5f)) * Z_AXIS_DEPTH;
Matrix matrix = t.getMatrix();
camera.save();
camera.translate(0.0f, 0.0f, depth);
camera.rotateY(currentAngle);
camera.getMatrix(matrix);
camera.restore();
matrix.preTranslate(-pivotX, -pivotY);
matrix.postTranslate(pivotX, pivotY);
}
public interface ProgressUpdateListener {
void onProgress(float progress);
}
}
ProjectileActivity.java
public class ProjectileActivity extends Activity implements View.OnClickListener,
ProjectileSurfaceView.AnimationEventListener, Handler.Callback,
Flip3DAnimation.ProgressUpdateListener {
private static final int MSG_UPDATE_COUNTER = 1;
private Button launchBtn;
private ProjectileSurfaceView surfaceView;
private TextView counterTextView;
private int itemCount;
private Handler uiHandler;
private boolean canUpdateCounter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
uiHandler = new Handler(this);
itemCount = 0;
launchBtn = findViewById(R.id.launch_btn);
launchBtn.setOnClickListener(this);
surfaceView = findViewById(R.id.projectile_view);
surfaceView.setAnimationEventListener(this);
counterTextView = findViewById(R.id.counter_text);
}
@Override
public void onClick(View v) {
if (v == launchBtn && !surfaceView.isAnimating()) {
itemCount++;
canUpdateCounter = true;
surfaceView.setBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.ic_projectile));
ViewGroup targetParent = (ViewGroup) counterTextView.getParent();
surfaceView.setTrajectoryParams(200, targetParent.getLeft());
surfaceView.launchAnimation();
}
}
@Override
public void onAnimationStart(ProjectileSurfaceView view) {}
@Override
public void onAnimationEnd(ProjectileSurfaceView view) {
uiHandler.sendEmptyMessage(MSG_UPDATE_COUNTER);
}
@Override
public boolean handleMessage(Message msg) {
if (msg.what == MSG_UPDATE_COUNTER) {
if (counterTextView.getVisibility() != View.VISIBLE) {
counterTextView.setVisibility(View.VISIBLE);
}
Flip3DAnimation flipAnim = new Flip3DAnimation(
counterTextView.getWidth() / 2.0f,
counterTextView.getHeight() / 2.0f,
Flip3DAnimation.FLIP_FORWARD);
flipAnim.setProgressUpdateListener(this);
counterTextView.startAnimation(flipAnim);
}
return false;
}
@Override
public void onProgress(float progress) {
if (canUpdateCounter && progress > 0.5f) {
counterTextView.setText(String.valueOf(itemCount));
canUpdateCounter = false;
}
}
}