I'm developing an Android game that animates rectangular views moving horizontally across the screen using View.animate().translationX(...). The animation is just a plain rectangle moving left over a few seconds, yet it consistently lags on older devices (Samsung S7 and old tablets) and even slightly on newer ones in power-saving mode (Samsung S22 Ultra).
Stripped-down version:
View rectangle = ...; // A dynamically added View to a ConstraintLayout
rectangle.animate()
.setDuration(6000)
.translationX(-widthDisplay)
.setInterpolator(new LinearInterpolator())
.start();
I’ve tried the following optimizations, but the lag persists:
- Using
View.setLayerType(View.LAYER_TYPE_HARDWARE, null). - Reducing the number of simultaneously animated views to just one.
- Avoiding any other heavy processing on the UI thread.
- Using ObjectAnimator instead.
The issue is not related to the view complexity, number of views, or background tasks. Even animating a single rectangle causes lag on older or throttled devices. Removing setAlpha() or similar calls doesn't help.
Is there a performance bottleneck with View.animate() (ViewPropertyAnimator) on older or throttled Android devices? And if so, is there a more efficient alternative or workaround for smooth, long-duration translation animations?
My code for setting up the rectangles and animating them:
for (int currentElement =0; currentElement <arrayList_GameEventRectangles.size(); currentElement++) {
//Create view and set
if (currentTimeSlot == arrayList_GameEventRectangles.get(currentElement).getStartingTimeSlot() - 15) {
arrayList_GameEventRectangles.get(currentElement).setActive(true);
//Set the parameters and the background of the view element
arrayList_GameEventRectangles.get(currentElement).setLayoutParams(new ViewGroup.LayoutParams(0, 0));
if(arrayList_GameEventRectangles.get(currentElement).getEventType().equals(VIEW_EVENT_RECTANGLE_SOLAR)) {
arrayList_GameEventRectangles.get(currentElement).setBackground(Objects.requireNonNull(ContextCompat.getDrawable(requireActivity(), R.drawable.game_event_rectangle_solar_1)).mutate());
}
if(arrayList_GameEventRectangles.get(currentElement).getEventType().equals(VIEW_EVENT_RECTANGLE_WIND)) {
arrayList_GameEventRectangles.get(currentElement).setBackground(Objects.requireNonNull(ContextCompat.getDrawable(requireActivity(), R.drawable.game_event_rectangle_wind_1)).mutate());
}
if(arrayList_GameEventRectangles.get(currentElement).getEventType().equals(VIEW_EVENT_RECTANGLE_GAS)) {
arrayList_GameEventRectangles.get(currentElement).setBackground(Objects.requireNonNull(ContextCompat.getDrawable(requireActivity(), R.drawable.game_event_rectangle_gas_1)).mutate());
}
if(arrayList_GameEventRectangles.get(currentElement).getEventType().equals(VIEW_EVENT_RECTANGLE_COAL)) {
arrayList_GameEventRectangles.get(currentElement).setBackground(Objects.requireNonNull(ContextCompat.getDrawable(requireActivity(), R.drawable.game_event_rectangle_coal_1)).mutate());
}
arrayList_GameEventRectangles.get(currentElement).setId(View.generateViewId());
//Make the view invisible (before it's appearance time)
arrayList_GameEventRectangles.get(currentElement).getBackground().setAlpha(0);
constraintLayout.bringChildToFront(binding.imageViewTargetRectangle);
// Set the ConstraintLayout programmatically for the view
View view = arrayList_GameEventRectangles.get(currentElement);
ViewGroup parent = (ViewGroup) view.getParent();
// Check if the view already has a parent
if (parent != null) {
parent.removeView(view); // Remove the view from its existing parent
}
constraintLayout.addView(view); // Now add the view to the constraintLayout
constraintSet.clone(constraintLayout);
float percentageHeightOfEventElement = 0.071f;
constraintSet.constrainPercentHeight(arrayList_GameEventRectangles.get(currentElement).getId(), percentageHeightOfEventElement);
float widthConstrainPercentage_element1 = (float)(arrayList_GameEventRectangles.get(currentElement).getDuration() / 100.0);
constraintSet.constrainPercentWidth(arrayList_GameEventRectangles.get(currentElement).getId(), widthConstrainPercentage_element1);
constraintSet.connect(arrayList_GameEventRectangles.get(currentElement).getId(),ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID,ConstraintSet.BOTTOM,0);
constraintSet.connect(arrayList_GameEventRectangles.get(currentElement).getId(),ConstraintSet.TOP,ConstraintSet.PARENT_ID ,ConstraintSet.TOP,0);
constraintSet.connect(arrayList_GameEventRectangles.get(currentElement).getId(),ConstraintSet.LEFT,ConstraintSet.PARENT_ID ,ConstraintSet.LEFT,0);
constraintSet.connect(arrayList_GameEventRectangles.get(currentElement).getId(),ConstraintSet.RIGHT,ConstraintSet.PARENT_ID ,ConstraintSet.RIGHT,0);
float horizontalBias = 1.0f ;
constraintSet.setHorizontalBias(arrayList_GameEventRectangles.get(currentElement).getId(), horizontalBias);
float verticalBiasOfEventElementToBeInTheLine = 0.049f;
constraintSet.setVerticalBias(arrayList_GameEventRectangles.get(currentElement).getId(), verticalBiasOfEventElementToBeInTheLine);
constraintSet.applyTo(constraintLayout);
}
//Shift the view to the right border of the display. This is done before the view is being displayed to the user such that it can flow from right to left in the game
if (currentTimeSlot == arrayList_GameEventRectangles.get(currentElement).getStartingTimeSlot() - 10) {
arrayList_GameEventRectangles.get(currentElement).setTranslationX(arrayList_GameEventRectangles.get(currentElement).getWidth());
}
//Animate view element
if (currentTimeSlot == arrayList_GameEventRectangles.get(currentElement).getStartingTimeSlot()) {
arrayList_GameEventRectangles.get(currentElement).getBackground().setAlpha(255);
View rectangle = arrayList_GameEventRectangles.get(currentElement);
int rectangleWidth = rectangle.getWidth();
float distanceToCover_current = widthDisplay + rectangleWidth;
float distanceToCover_normalizedObject = widthDisplay + 20;
double ratioDistanceDifference = distanceToCover_current /distanceToCover_normalizedObject;
int numberOfMillisecondsUntilTheMiddleOfTheScreen_Level1 = 8000;
long durationForTheAnimation = (long)(numberOfMillisecondsUntilTheMiddleOfTheScreen_Level1 * speedMultiplicatorLevel [currentLevel - 1] * ratioDistanceDifference);
arrayList_GameEventRectangles.get(currentElement).animate().setDuration(durationForTheAnimation).translationX(widthDisplay*(-1)).setInterpolator(new LinearInterpolator()).start();
}
helpCounterUpdateScreen++;
//Check if the view is still running
if (arrayList_GameEventRectangles.get(currentElement).isActive() && currentElement==0) {
if (arrayList_GameEventRectangles.get(currentElement).getX() < arrayList_GameEventRectangles.get(currentElement).getWidth() * (-0.8)) {
arrayList_GameEventRectangles.get(currentElement).incrementNumberOfTimeSlotsAfterFinishing();
}
if (arrayList_GameEventRectangles.get(currentElement).getNumberOfTimeSlotsAfterFinishing()> 100) {
arrayList_GameEventRectangles.get(currentElement).setActive(false);
}
}
}
Reminder: Does anyone have another idea without SurfaceView?
1 Answer 1
Using SurfaceView with the same logic of rectangles and animating them:
GameSurfaceView class:
package com.example.surfaceanimationtest;
import android.content.Context;
import android.content.res.Resources;
import android.util.AttributeSet;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import java.util.List;
public class GameSurfaceView extends SurfaceView implements SurfaceHolder.Callback {
private List<MyGameRect> rectangles = new ArrayList<>();
private MyDrawThread mMyDrawThread;
public GameSurfaceView(Context context) {
super(context);
getHolder().addCallback(this);
}
private void init() {
getHolder().addCallback(this);
int screenWidth = Resources.getSystem().getDisplayMetrics().widthPixels;
rectangles = new ArrayList<>();
mMyDrawThread = new MyDrawThread(getHolder(), rectangles, screenWidth);
}
public GameSurfaceView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
@Override
public void surfaceChanged(@NonNull SurfaceHolder surfaceHolder, int i, int i1, int i2) {
}
@Override
public void surfaceCreated(@NonNull SurfaceHolder holder) {
mMyDrawThread = new MyDrawThread(getHolder(), rectangles, getWidth());
mMyDrawThread.start();
}
@Override
public void surfaceDestroyed(@NonNull SurfaceHolder holder) {
if (mMyDrawThread != null) {
mMyDrawThread.stopDrawing();
}
}
public void addRectangle() {
MyGameRect rect = new MyGameRect();
rect.x = getWidth(); // from right
rect.y = getHeight() * 0.05f;
rect.width = getWidth() * 0.1f;
rect.height = getHeight() * 0.07f;
rect.startTime = System.currentTimeMillis();
rect.duration = 6000; // ms
synchronized (rectangles) {
rectangles.add(rect);
}
}
}
MyGameRect class:
public class MyGameRect {
float x, y, width, height;
long startTime;
long duration;
boolean isFinished() {
return (System.currentTimeMillis() - startTime) > duration;
}
void updatePosition(float screenWidth) {
float elapsed = System.currentTimeMillis() - startTime;
float progress = Math.min(1f, elapsed / (float) duration);
x = screenWidth - progress * (screenWidth + width);
}
}
MyDrawThread class:
public class MyDrawThread extends Thread {
private final SurfaceHolder holder;
private final List<MyGameRect> rectangles;
private final float screenWidth;
private boolean running = true;
public MyDrawThread(SurfaceHolder holder, List<MyGameRect> rectangles, float screenWidth) {
this.holder = holder;
this.rectangles = rectangles;
this.screenWidth = screenWidth;
}
public void stopDrawing() {
running = false;
interrupt();
}
@Override
public void run() {
Paint paint = new Paint();
paint.setColor(Color.YELLOW);
while (running) {
Canvas canvas = holder.lockCanvas();
if (canvas == null) continue;
canvas.drawColor(Color.BLACK);
synchronized (rectangles) {
Iterator<MyGameRect> iterator = rectangles.iterator();
while (iterator.hasNext()) {
MyGameRect rect = iterator.next();
rect.updatePosition(screenWidth);
if (rect.isFinished()) {
iterator.remove();
continue;
}
canvas.drawRect(rect.x, rect.y, rect.x + rect.width, rect.y + rect.height, paint);
}
}
holder.unlockCanvasAndPost(canvas);
try {
sleep(16); // about 60 FPS
} catch (InterruptedException ignored) {}
}
}
}
Setting view in main activity XML or your fragment:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.example.surfaceanimationtest.GameSurfaceView
android:id="@+id/gameSurfaceView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
MainActivity:
package com.example.surfaceanimationtest;
import android.os.Bundle;
import android.os.Handler;
import androidx.activity.EdgeToEdge;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
public class MainActivity extends AppCompatActivity {
GameSurfaceView gameSurfaceView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
EdgeToEdge.enable(this);
setContentView(R.layout.activity_main);
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
return insets;
});
gameSurfaceView = findViewById(R.id.gameSurfaceView);
// this will add rectangles every 1.5 seconds
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
gameSurfaceView.addRectangle();
gameSurfaceView.postDelayed(this, 1500);
}
}, 1000);
}
}
The result on Google Pixel Emulator 2 is similar to the Samsung Galaxy S7:
Add android:hardwareAccelerated="true" in AndroidManifest.xml. This enables hardware acceleration which can significantly improve animation performance on supported devices.
As an alternative to a raw thread consider RxJava for thread management and scheduling. It provides a cleaner and more reactive approach to handle frame updates or timed animations, especially UI-related timing operations. It should have better performance on weak devices.
3 Comments
Explore related questions
See similar questions with these tags.