1

I'm currently working on an app that in its core functionallity handles multiple count down timers in a recycler view.

Now, the way I've implemented this is to simply initiate a count down timer for every ViewHolder that is created. My problem is sometimes whenever a timer is finished, all the other timers are acting weird.

For instance, I want that everytime a timer is up, the ViewHolder of that timer would change the background of the CardView UI component that the above class holds reference to.

What actually happens is that if one timer is up, the other view holders are accessed and being changed.

Here is my adapter code where all of this is happen. I tried to document as much as I could:

package bikurim.silverfix.com.bikurim.adapters;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.content.res.ColorStateList;
import android.os.CountDownTimer;
import android.support.v7.widget.CardView;
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.Filter;
import android.widget.Filterable;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.TextView;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;

import bikurim.silverfix.com.bikurim.Constants;
import bikurim.silverfix.com.bikurim.models.Family;
import bikurim.silverfix.com.bikurim.R;
import bikurim.silverfix.com.bikurim.utils.CountDownManager;
import bikurim.silverfix.com.bikurim.utils.TimerEventListener;

public class FamiliesAdapter extends RecyclerView.Adapter<FamiliesAdapter.FamilyViewHolder> implements Filterable, TimerEventListener {

    // last position holds the last position of the element that was added, for animation purposes
    private static int lastPosition = -1;
    private Context context;
    private ArrayList<Family> families;
    private ArrayList<Family> dataSet;
    private FamilyFilter filter;

    private CountDownManager countDownManager;

    public FamiliesAdapter(Context context, ArrayList<Family> families) {
        this.context = context;
        this.dataSet = families;
        this.families = dataSet;

        countDownManager = new CountDownManager(Constants.Values.TIME_INTERVAL, this);
        countDownManager.start();
    }


    @Override
    public FamilyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        // Inflating the view from a given layout resource file
        View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.family_list_item, parent, false);
        final FamilyViewHolder pvh = new FamilyViewHolder(v);

        return pvh;
    }

    @Override
    public void onBindViewHolder(final FamilyViewHolder holder, int position) {
        // Binds the Family object to the holder view
        Family family = families.get(position);
        holder.bindData(family);

        countDownManager.addTimer(holder);
        // Sets animation on the given view, in case it wasn't displayed before
        setSlideAnimation(holder.cardView, position, false);
    }

    @Override
    public int getItemCount() {
        return families != null ? families.size() : 0;
    }

    /* Gets the current the filter object */
    @Override
    public Filter getFilter() {
        if (filter == null)
            filter = new FamilyFilter();
        return filter;
    }

    /* The following 2 methods are an implementations of the ViewHolderListener.
    * Inside every view holder lies an instance of ViewHolderListener, for communication between the two*/

    public void startAnimationOnItem(FamilyViewHolder holder, boolean isEndAnimation) {
        setSlideAnimation(holder.cardView, holder.getAdapterPosition(), isEndAnimation);
    }

    /* Changes the UI of a holder to a time's up view with a flickering ImageButton and a TextView*/

    @Override
    public void onFinish(FamilyViewHolder holder) {
        // Switches between the clock icon to the alarm icon
        holder.clock.setVisibility(View.GONE);
        holder.remove.setVisibility(View.VISIBLE);

        RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT);
        params.addRule(RelativeLayout.RIGHT_OF, holder.remove.getId());
        params.addRule(RelativeLayout.CENTER_VERTICAL);
        holder.timeLeft.setLayoutParams(params);
        holder.timeLeft.setText(context.getString(R.string.time_up_message));

        setFadeAnimation(holder.remove);
        holder.cardView.setBackgroundResource(R.color.cardview_light_background);
        startAnimationOnItem(holder, true);

        holder.family.timeLeft = 0;
    }

    @Override
    public void onLessThanMinute(FamilyViewHolder holder) {
        holder.cardView.setBackgroundResource(R.color.time_up_bg);
        holder.isBackgroundChanged = true;
    }

    /* Starts a slide in animation for a given Card View */
    private void setSlideAnimation(View viewToAnimate, int position, boolean isEndAnimation) {
        Animation animation = AnimationUtils.loadAnimation(context, android.R.anim.slide_in_left);
        if (!isEndAnimation) {
            if (position > lastPosition) {
                viewToAnimate.startAnimation(animation);
                lastPosition = position;
                return;
            }
        }
        viewToAnimate.startAnimation(animation);
    }

    private void setFadeAnimation(View viewToAnimate) {
        ObjectAnimator fadeOut = ObjectAnimator.ofFloat(viewToAnimate, "alpha", 1f, .1f);
        fadeOut.setDuration(750);
        ObjectAnimator fadeIn = ObjectAnimator.ofFloat(viewToAnimate, "alpha", .1f, 1f);
        fadeIn.setDuration(750);

        final AnimatorSet mAnimationSet = new AnimatorSet();

        mAnimationSet.play(fadeIn).after(fadeOut);

        mAnimationSet.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                mAnimationSet.start();
            }
        });
        mAnimationSet.start();
    }

    public void removeData(int pos, FamilyViewHolder holder) {
        // Sets the last position to the given deleted position for animation purposes
        lastPosition = pos;

        // Removes the family object from the data set
        families.remove(pos);
        notifyItemRemoved(pos);
        notifyItemRangeChanged(pos, getItemCount());

        // Cancels the the timer and removes it from the entry set
        countDownManager.cancelTimer(holder);
    }


    /* Cancels the timers and clears the entry set */
    public void cancelTimers() {
        countDownManager.reset();
        countDownManager.stop();
    }

    /* Clears the adapter's data and resets the last position to -1 */
    public void clearData() {
        cancelTimers();
        filter = null;
        lastPosition = -1;
    }

    /* The official view holder of the adapter. Holds references to the relevant views inside the layout, and */
    public static class FamilyViewHolder extends RecyclerView.ViewHolder {
        public boolean isBackgroundChanged;

        public CardView cardView;
        public TextView personLname;
        public TextView timeLeft;
        public TextView visitors;
        public ImageView clock;
        public ImageButton remove;

        public Family family;

        private ColorStateList colorStateList;

        public FamilyViewHolder(View itemView) {
            super(itemView);
            // isBackgroundChanged represents whether the holder is under 60 seconds or not
            isBackgroundChanged = false;

            // Getting the references for the UI components
            cardView = (CardView) itemView.findViewById(R.id.cv);
            personLname = (TextView) itemView.findViewById(R.id.person_Lname);
            visitors = (TextView) itemView.findViewById(R.id.visitors);
            timeLeft = (TextView) itemView.findViewById(R.id.person_timeLeft);
            clock = (ImageView) itemView.findViewById(R.id.clock);
            remove = (ImageButton) itemView.findViewById(R.id.time_up);

            // Sets a reference to the old colors of the text view
            colorStateList = timeLeft.getTextColors();
        }

        public void bindData(Family item) {
            family = item;
            personLname.setText(family.lastName);
            visitors.setText("מבקרים:  " + family.visitorsNum);
        }

        public ColorStateList getOriginalColors() {
            return colorStateList;
        }

    }

    /* Filter class that queries the constraint on the data set every whenInMillis the user types a key */
    private class FamilyFilter extends Filter {
        @Override
        protected FilterResults performFiltering(CharSequence constraint) {
            FilterResults results = new FilterResults();
            if (constraint.length() == 0 || constraint == null) {
                results.values = dataSet;
                results.count = dataSet.size();
            } else {
                ArrayList<Family> queryResults = new ArrayList<Family>();
                for (Family f : dataSet) {
                    if (constraint.charAt(0) == f.lastName.toUpperCase().indexOf(0)) {
                        queryResults.add(f);
                    } else if (f.lastName.toUpperCase().contains(constraint.toString().toUpperCase())) {
                        queryResults.add(f);
                    }
                }
                results.values = queryResults;
                results.count = queryResults.size();
            }
            return results;
        }

        @Override
        protected void publishResults(CharSequence constraint, FilterResults results) {
            // Now we have to inform the adapter about the new list filtered
            synchronized (families) {
                families = (ArrayList<Family>) results.values;
            }
            notifyDataSetChanged();
        }
    }
}

Why are the view holders get scrambled when a timer is finished?

UPDATE: I tried to implement this a little bit different. Instead of using multiple count downs, I use only one which holds an array of holders which need to be updated. But the problem remains the same. Here is my CountDownManager class:

package bikurim.silverfix.com.bikurim.utils;

import android.os.Handler;
import android.os.Message;
import android.os.SystemClock;

import java.util.ArrayList;

import bikurim.silverfix.com.bikurim.Constants;
import bikurim.silverfix.com.bikurim.adapters.FamiliesAdapter;

/**
 * Created by David on 07/07/2016.
 * @author David
 * Inspired by MiguelLavigne
 *
 * A custom CountDownTimer class, which takes care of all the running timers
 */
public class CountDownManager {

    private final long interval;
    private long base;

    // Holds references for all the visible text views
    private ArrayList<FamiliesAdapter.FamilyViewHolder> holders;

    private TimerEventListener listener;

    public CountDownManager(long interval, TimerEventListener listener) {
        this.listener = listener;
        this.interval = interval;
        holders = new ArrayList<>();
    }

    public void start() {
        base = System.currentTimeMillis();
        handler.sendMessage(handler.obtainMessage(MSG));
    }

    public void stop() {
        handler.removeMessages(MSG);
    }

    public void reset() {
        synchronized (this) {
            base = System.currentTimeMillis();
        }
    }

    public void addTimer(FamiliesAdapter.FamilyViewHolder holder) {
        synchronized (holders) {
            if(!holders.contains(holder))
                holders.add(holder);
        }
    }

    public void cancelTimer(FamiliesAdapter.FamilyViewHolder holder) {
        synchronized (holders) {
            holders.remove(holder);
        }
    }

    public void onTick(long elapsedTime) {
        long timeLeft, lengthOfVisit;
        FamiliesAdapter.FamilyViewHolder holderToDelete = null;
        for (FamiliesAdapter.FamilyViewHolder holder : holders) {
            lengthOfVisit = holder.family.whenInMillis - base;
            timeLeft =  lengthOfVisit - elapsedTime;
            if(timeLeft > 0) {
                if(timeLeft <= Constants.Values.ALERT_TIME && holder.isBackgroundChanged != false) {
                    listener.onLessThanMinute(holder);
                }
                holder.family.timeLeft = timeLeft;
                holder.timeLeft.setText(Utils.updateFormatTime(timeLeft));
            } else {
                listener.onFinish(holder);
                holderToDelete = holder;
            }
        }

        // Checks if there is a holder who's ran out of time. If so, removes it from the list
        if(holderToDelete != null)
            holders.remove(holderToDelete);
    }

    private static final int MSG = 1;

    private Handler handler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            synchronized (CountDownManager.this) {
                long elapsedTime = System.currentTimeMillis() - base;
                onTick(elapsedTime);
                sendMessageDelayed(obtainMessage(MSG), interval);
            }
        }
    };
}

I'm doing something wrong but I can't seem to pin-point the problem.

David Lasry
  • 1,407
  • 4
  • 26
  • 43

2 Answers2

1

It's not possible to cancel a countdown timer from within a countdown timer. This is apparently because the next event is already scheduled when you call cancel and when that event is triggered, it will trigger the next event. Your issues are probably because the timer is not being cancelled.

I've had to do this before and my approach was to use one countdown timer for all the views. You don't want to call notifyDataSetChanged as this would refresh everything. Instead, create a custom count up timer that holds an array of WeakReferences to all the visible Views that need the time ticked. You can update all these Views in one go at each tick. You can put the end time or start time in the tag for the View, so in your count up imer get the end time out of the tag and do your calculations.

You can start and stop the timer in the lifecycle methods. Hope this helps.

Ali
  • 12,354
  • 9
  • 54
  • 83
  • Thank you for you answer, Ali! But how can I implement what you are saying when all the timers are different from each other? Could you please clarify this for me? – David Lasry Jul 07 '16 at 10:11
  • They all either tick up or down don't they? The Count Up or Count Down timers only purpose is to tell you to cycle through all the Views and update the text. – Ali Jul 07 '16 at 10:49
  • Ok I tried to implement a CountDownManager just like you said. I updated the code for my Adapter and added the new code of CountDownManager to the post. Could you please look at it? I'm still getting the same problem - ViewHolders get scrambled with data from other ViewHolders – David Lasry Jul 07 '16 at 11:27
  • Do you have a video of the problem? The thing is that you create a relative layout and wrap your view in it. This is a permanent change, and all Views that use that View when it's recycled will have this change in them. You need to undo the change when new values are being written in to that View, or on Finish, call notifyDataSet changed and inflate a different View for that cell. I.e. Use [multiple views with the RecyclerView](http://stackoverflow.com/questions/26245139/how-to-create-recyclerview-with-multiple-view-type) instead of one. – Ali Jul 07 '16 at 12:34
  • @DavidLasry, Don't go with CountDownTimer for RecyclerView. It would become a mess. Instead try this. http://stackoverflow.com/a/35863919/6813468 – Rakesh May 12 '17 at 05:32
-1

For your issue I suggest you rather use Handle & Runnable. Using them you get event of end time and perform required action. You can check my answer here https://stackoverflow.com/a/53543180/6711554.