How to smoothly animate objects in Android

Viewed 145

I have a view object in Android and I would like to animate it such that it flows from right to left on the screen. I currently use ObjectAnimator with the following code:

ObjectAnimator animation = ObjectAnimator.ofFloat(view, "translationX", (-1)* 50* currentTimeSlot);
animation.setDuration(90);
animation.start();

The counting variable currentTimeSlot is just incremented after 100 Milliseconds by using handler.postDelayed.

Actually the code works and does what it should. However, the animation is not really smooth and I would like to know how I can make the animation smooth? Do you have any idea?

Update: Here is the whole code with the Fragment and the custom view that should be smoothly animated:

public class Test extends Fragment {

    private Handler handler = new Handler();
    public static final int DELAY_IN_MILLIS = 100;
    private int currentTimeLeft_MILLIS;
    int helpCounterRun =0;
    private boolean viewHasBeenCreated = false;
    private FragmentTestBinding binding;
    View view;
    Drawable dr;
    int currentTimeSlot;

    private ConstraintLayout constraintLayout;

    public Test() {

    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        currentTimeLeft_MILLIS = 90 * 1000;

    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        binding = FragmentTestBinding.inflate(inflater, container, false);
        viewHasBeenCreated = true;
        getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);

        container.getContext();
        constraintLayout= binding.constraintLayout;
        view = new View(getActivity());

        dr = ContextCompat.getDrawable(getActivity(),R.drawable.light_bulb_layer_list);
        currentTimeSlot =0;
        countDownTime();
        return binding.getRoot();
    }

    private void updateScreen() {
        binding.textViewTimeLeftValue.setText("" + currentTimeLeft_MILLIS /1000);

        //Set the parameter and background of the view
        constraintLayout.removeView(view);
        view.setLayoutParams(new ViewGroup.LayoutParams(0, 0));
        view.setBackground(dr);

        //Shift view to the right at the beginning of the level
        if (currentTimeSlot ==0) {
            view.setTranslationX(view.getWidth());
        }

        // Shift the view 70 pixels to the left in each iteration
        ObjectAnimator animation = ObjectAnimator.ofFloat(view, "translationX", (-1)* 70* currentTimeSlot);
        animation.setDuration((long) (DELAY_IN_MILLIS*0.9));
        animation.start();


        // Set the ConstraintLayout programatically for the view
        view.setId(View.generateViewId());
        ConstraintLayout constraintLayout = binding.constraintLayout;
        ConstraintSet constraintSet = new ConstraintSet();
        constraintLayout.addView(view);
        constraintSet.clone(constraintLayout);
        constraintSet.constrainPercentHeight(view.getId(), 0.071f);
        float widthConstrainPercentage_element1 = 0.25f;
        constraintSet.constrainPercentWidth(view.getId(), widthConstrainPercentage_element1);
        constraintSet.connect(view.getId(),ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID,ConstraintSet.BOTTOM,0);
        constraintSet.connect(view.getId(),ConstraintSet.TOP,ConstraintSet.PARENT_ID ,ConstraintSet.TOP,0);
        constraintSet.connect(view.getId(),ConstraintSet.LEFT,ConstraintSet.PARENT_ID ,ConstraintSet.LEFT,0);
        constraintSet.connect(view.getId(),ConstraintSet.RIGHT,ConstraintSet.PARENT_ID ,ConstraintSet.RIGHT,0);

        constraintSet.setHorizontalBias(view.getId(),  1.0f);
        constraintSet.setVerticalBias(view.getId(), 0.049f);
        constraintSet.applyTo(constraintLayout);

    }

    private void countDownTime(){
        currentTimeLeft_MILLIS = currentTimeLeft_MILLIS - DELAY_IN_MILLIS;
        currentTimeSlot++;

        handler.postDelayed(new Runnable() {
            @Override
            public void run() {
                helpCounterRun++;
                if(viewHasBeenCreated) {
                    countDownTime();
                }
            }

        }, DELAY_IN_MILLIS);
        updateScreen();
    }

    @Override
    public void onDestroyView() {
        super.onDestroyView();
        viewHasBeenCreated = false;
        handler.removeCallbacksAndMessages(null);
    }

}

Reminder: Does nobody have any idea how to animate objects in Android in a good way (such that the animation is smooth)? I'll appreciate every comment.

1 Answers

This is as much as I could help. Without any images, it is hard to figure out a reliable answer for your problem. I've included some comments in the code. I could not test it either.

public class Test extends Fragment implements Handler.Callback
{
    //Inner class. A background thread.
    private static final class CountdownThread implements Runnable
    {
        private final WeakReference<Handler> handler;
        private final Thread thread;
        private final long delay;
        private boolean isAlive;
        private boolean state;

        public final CountdownThread(final Handler handler, final long delayInMillis)
        {
            this.handler = new WeakReference<Handler>(handler);
            thread = new Thread(this);
            delay = delayInMillis;
            isAlive = true;
            thread.setPriority(Thread.NORM_PRIORITY);
            thread.start();
        }

        @Override
        public final void run()
        {
            //Unless suspend()/stop() is called, this will run forever.
            while(isAlive) {
                try {
                    synchronized(this) {
                        while(!state) this.wait();
                    }
                    Thread.sleep(delay); //Delay
                    final Handler hanRef = handler.get();
                    if(hanRef == null) {
                        isAlive = false;
                        handler.clear();
                        break;
                    }
                    final Message msg = Message.obtain();
                    //msg.what = 0;
                    hanRef.sendMessageAtTime(msg, SystemClock.uptimeMillis());
                } catch(final InterruptedException e) {}
            }
        }

        public final synchronized void resume() 
        {
            if(isAlive && !state) {
                state = true;
                this.notify();
            }
        }

        public final void suspend()
        {
            state = false;
            thread.interrupt();
        }

        //After calling this method, the object cannot be reused.
        public final void stop()
        {
            isAlive = false; // In case interrupt() does nothing (Thread was not in sleep nor wait mode).
            thread.interrupt();
            handler.clear();
        }
    }
    //End of inner class



    //Global variables
    public static final long DELAY_IN_MILLIS = 100L;

    private FragmentTestBinding binding;
    private ConstraintLayout constraintLayout;
    private Activity activity;
    private View view;
    private Drawable drawable; //Renamed from "dr".
    private Handler handler;
    private CountdownThread countDownThread;

    private long currentTimeLeft_MILLIS;
    private int helpCounterRun;
    private boolean viewHasBeenCreated = false;
    private long currentTimeSlot;

    @Override
    public final void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        currentTimeLeft_MILLIS = 90L * 1000L; //90000L aka 90 seconds.
        handler = new Handler(Looper.getMainLooper(), this);
        countDownThread = new CountdownThread(handler, DELAY_IN_MILLIS);
    }

    @Override
    public final View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)
    {
        binding = FragmentTestBinding.inflate(inflater, container, false);
        constraintLayout = binding.constraintLayout;

        activity = getActivity(); //This one can be cache locally. Avoid calling same method over and over again.
        activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);

        drawable = ContextCompat.getDrawable(activity, R.drawable.light_bulb_layer_list);

        viewHasBeenCreated = true;
        currentTimeSlot = 0L;

        updateScreen();
        countDownThread.resume(); //start/resume

        return binding.getRoot();
    }

    @Override
    public final boolean handleMessage(Message message)
    {
        if(viewHasBeenCreated) {
            currentTimeLeft_MILLIS = currentTimeLeft_MILLIS - DELAY_IN_MILLIS;
            currentTimeSlot++;
            helpCounterRun++;
            updateScreen();

            //If "something", then suspend the timer and return "true".
            //After 90 seconds has passed. Once again, a guess work. 
            if(currentTimeLeft_MILLIS <= 0L) countDownThread.suspend();

            return true;
        }
        return false;
    }

    @Override
    public final void onDestroy()
    {
        super.onDestroy();
        stopThread();
    }

    @Override
    public final void onDestroyView()
    {
        super.onDestroyView();
        stopThread();
    }

    private final void updateScreen()
    {
        //Now this is the hardest part. You did mentioned that the view will change
        //every e.g. 5 seconds. The existing view will be replaced by a new view.
        //But your code DOES NOT reflect that. You're only setting the "view" variable in "onCreateView()" once.
        //Futhermore, this method will be called every 100 ms and you're adding/removing the same view each time. 
        //I would NOT add/remove a view unnecessarily. This is somewhat heavy.
        //Instead, change the image/drawable/translate if I could. So the following code is just a guess work.
        //I am not sure if the "view" should animated first then a new view will be inflated
        //in the "constraintLayout" or vise versa?
        //Should this method be called infinitely? There should be a stopping point somewhere other than in onDestroy()/onDestroyView()!.
        //So make sure to call "countDownThread.suspend()" to pause the timer temporarily.
        //I would recommend you to rethink about the entire structure in this method.


        //Shift view to the right at the beginning of the level
        if(currentTimeSlot == 0L) {
            //constraintLayout.removeView(view); //Animating the view after removing it does not make any sense.

            view = new View(activity);
            final int viewID = View.generateViewId(); //cache
            view.setId(viewID); //Avoid calling "getId()" too often.
            view.setLayoutParams(new ViewGroup.LayoutParams(0, 0));
            view.setBackground(drawable);
            view.setTranslationX(view.getWidth());

            constraintLayout.addView(view);

            final ConstraintSet constraintSet = new ConstraintSet();
            constraintSet.clone(constraintLayout);
            constraintSet.constrainPercentHeight(viewID, 0.071f);
            constraintSet.constrainPercentWidth(viewID, 0.25F);
            constraintSet.connect(viewID, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM, 0);
            constraintSet.connect(viewID, ConstraintSet.TOP, ConstraintSet.PARENT_ID , ConstraintSet.TOP, 0);
            constraintSet.connect(viewID, ConstraintSet.LEFT, ConstraintSet.PARENT_ID , ConstraintSet.LEFT, 0);
            constraintSet.connect(viewID, ConstraintSet.RIGHT, ConstraintSet.PARENT_ID , ConstraintSet.RIGHT, 0);
            constraintSet.setHorizontalBias(viewID,  1.0f);
            constraintSet.setVerticalBias(viewID, 0.049f);
            constraintSet.applyTo(constraintLayout);

            binding.textViewTimeLeftValue.setText(String.valueOf((long) (currentTimeLeft_MILLIS / 1000D))); 

            return;
        }

        //Be careful with integers divison. Convert them or one of them to a floating point number then cast it back.
        binding.textViewTimeLeftValue.setText(String.valueOf((long) (currentTimeLeft_MILLIS / 1000D))); 

        // Shift the view 70 pixels to the left in each iteration
        //I do not recommend using numbers in pixels instead in dp so that every device will have a same animation.
        final ObjectAnimator animation = ObjectAnimator.ofFloat(view, "translationX", -70 * currentTimeSlot);
        animation.setDuration(DELAY_IN_MILLIS - 10L); //(long)(DELAY_IN_MILLIS * 0.9D)
        animation.start();
    }

    //Must be called or else the thread would not stop.
    private final void stopThread()
    {
        if(countDownThread != null) {
            viewHasBeenCreated = false;
            countDownThread.stop();
            countDownThread = null;
            handler = null;
        }
    }

}

Here is an example of CountDownTimer. You may want to play with the duration depending on your needs.

public class Test extends Fragment
{
    private CountDownTimer cdt;
    private static final long DURATION = 40000L; //40sec
    private static final long DELAY = 100L; //100ms

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,Bundle savedInstanceState)
    {
        //...
        countDownTime();
        return binding.getRoot();
    }

    private void countDownTime()
    {
        cdt = new CountDownTimer(DURATION, DELAY) {
            boolean delay = true;
            public void onTick(long millisUntilFinished) {
                if(delay) { delay = false;
                } else {
                    updateScreen();
                    delay = true;
                }
            }
            public void onFinish() {
                updateScreen();
            }
        }.start();
    }

    @Override
    public void onDestroyView() {
        super.onDestroyView();
        viewHasBeenCreated = false;
        cdt.cancel();
    }
}
Related