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.