Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Implementing Parabolic Bounce Animation with Android SurfaceView

Tech May 12 3

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

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.