// Copyright 2016 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package org.chromium.chrome.browser.widget; import android.os.AsyncTask; import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView.Adapter; import android.support.v7.widget.RecyclerView.ViewHolder; import android.text.format.DateUtils; import android.util.Pair; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; import org.chromium.chrome.R; import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.List; import java.util.SortedSet; import java.util.TreeSet; import java.util.concurrent.ExecutionException; /** * An {@link Adapter} that works with a {@link RecyclerView}. It sorts the given {@link List} of * {@link TimedItem}s according to their date, and divides them into sub lists and displays them in * different sections. * <p> * Subclasses should not care about the how date headers are placed in the list. Instead, they * should call {@link #loadItems(List)} with a list of {@link TimedItem}, and this adapter will * insert the headers automatically. */ public abstract class DateDividedAdapter extends Adapter<RecyclerView.ViewHolder> { /** * Interface that the {@link Adapter} uses to interact with the items it manages. */ public interface TimedItem { /** @return The timestamp for this item. */ long getTimestamp(); /** * Returns an ID that uniquely identifies this TimedItem and doesn't change. * To avoid colliding with IDs generated for Date headers, at least one of the upper 32 * bits of the long should be set. * @return ID that can uniquely identify the TimedItem. */ long getStableId(); } private static class DateViewHolder extends RecyclerView.ViewHolder { private TextView mTextView; public DateViewHolder(View view) { super(view); if (view instanceof TextView) mTextView = (TextView) view; } public void setDate(Date date) { // Calender.getInstance() may take long time to run, so Calendar object should be reused // as much as possible. Pair<Calendar, Calendar> pair = getCachedCalendars(); Calendar cal1 = pair.first, cal2 = pair.second; cal1.setTimeInMillis(System.currentTimeMillis()); cal2.setTime(date); StringBuilder builder = new StringBuilder(); if (compareCalendar(cal1, cal2) == 0) { builder.append(mTextView.getContext().getString(R.string.today)); builder.append(" - "); } else { // Set cal1 to yesterday. cal1.add(Calendar.DATE, -1); if (compareCalendar(cal1, cal2) == 0) { builder.append(mTextView.getContext().getString(R.string.yesterday)); builder.append(" - "); } } builder.append(DateUtils.formatDateTime(mTextView.getContext(), date.getTime(), DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR)); mTextView.setText(builder); } } /** * A bucket of items with the same date. */ private static class ItemGroup { private final Date mDate; private final List<TimedItem> mItems = new ArrayList<>(); private boolean mIsSorted; public ItemGroup(TimedItem item) { mDate = new Date(item.getTimestamp()); mItems.add(item); mIsSorted = true; } public void addItem(TimedItem item) { mItems.add(item); mIsSorted = false; } /** * @return Whether the given date happens in the same day as the items in this group. */ public boolean isSameDay(Date otherDate) { return compareDate(mDate, otherDate) == 0; } /** * @return The size of this group. */ public int size() { // Plus 1 to account for the date header. return mItems.size() + 1; } public TimedItem getItemAt(int index) { // 0 is allocated to the date header. if (index == 0) return null; sortIfNeeded(); return mItems.get(index - 1); } /** * Rather than sorting the list each time a new item is added, the list is sorted when * something requires a correct ordering of the items. */ private void sortIfNeeded() { if (mIsSorted) return; mIsSorted = true; Collections.sort(mItems, new Comparator<TimedItem>() { @Override public int compare(TimedItem lhs, TimedItem rhs) { // More recent items are listed first. Ideally we'd use Long.compare, but that // is an API level 19 call for some inexplicable reason. long timeDelta = lhs.getTimestamp() - rhs.getTimestamp(); if (timeDelta > 0) { return -1; } else if (timeDelta == 0) { return 0; } else { return 1; } } }); } } // Cached async tasks to get the two Calendar objects, which are used when comparing dates. private static final AsyncTask<Void, Void, Calendar> sCal1 = createCalendar(); private static final AsyncTask<Void, Void, Calendar> sCal2 = createCalendar(); public static final int TYPE_DATE = 0; public static final int TYPE_NORMAL = 1; private int mSize; private SortedSet<ItemGroup> mItems = new TreeSet<>(new Comparator<ItemGroup>() { @Override public int compare(ItemGroup lhs, ItemGroup rhs) { return compareDate(lhs.mDate, rhs.mDate); } }); /** * Creates a {@link ViewHolder} in the given view parent. * @see #onCreateViewHolder(ViewGroup, int) */ protected abstract ViewHolder createViewHolder(ViewGroup parent); /** * Binds the {@link ViewHolder} with the given {@link TimedItem}. * @see #onBindViewHolder(ViewHolder, int) */ protected abstract void bindViewHolderForTimedItem(ViewHolder viewHolder, TimedItem item); /** * Gets the resource id of the view showing the date header. * Contract for subclasses: this view should be a {@link TextView}. */ protected abstract int getTimedItemViewResId(); /** * Loads a list of {@link TimedItem}s to this adapter. Any previous data will be removed. */ public void loadItems(List<? extends TimedItem> timedItems) { mSize = 0; mItems.clear(); for (TimedItem timedItem : timedItems) { Date date = new Date(timedItem.getTimestamp()); boolean found = false; for (ItemGroup item : mItems) { if (item.isSameDay(date)) { found = true; item.addItem(timedItem); mSize++; break; } } if (!found) { // Create a new ItemGroup with the date for the new item. This increases the // size by two because we add new views for the date and the item itself. mItems.add(new ItemGroup(timedItem)); mSize += 2; } } notifyDataSetChanged(); } /** * Removes all items from this adapter. */ public void clear() { mSize = 0; mItems.clear(); notifyDataSetChanged(); } @Override public long getItemId(int position) { if (!hasStableIds()) return RecyclerView.NO_ID; Pair<Date, TimedItem> pair = getItemAt(position); return pair.second == null ? getStableIdFromDate(pair.first) : pair.second.getStableId(); } /** * Gets the item at the given position. For date headers, {@link TimedItem} will be null; for * normal items, {@link Date} will be null. */ public Pair<Date, TimedItem> getItemAt(int position) { Pair<ItemGroup, Integer> pair = getGroupAt(position); ItemGroup group = pair.first; return new Pair<>(group.mDate, group.getItemAt(pair.second)); } @Override public final int getItemViewType(int position) { Pair<ItemGroup, Integer> pair = getGroupAt(position); return pair.second == 0 ? TYPE_DATE : TYPE_NORMAL; } @Override public final RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { if (viewType == TYPE_DATE) { return new DateViewHolder(LayoutInflater.from(parent.getContext()).inflate( getTimedItemViewResId(), parent, false)); } else if (viewType == TYPE_NORMAL) { return createViewHolder(parent); } return null; } @Override public final void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { Pair<Date, TimedItem> pair = getItemAt(position); if (holder instanceof DateViewHolder) { ((DateViewHolder) holder).setDate(pair.first); } else { bindViewHolderForTimedItem(holder, pair.second); } } @Override public final int getItemCount() { return mSize; } /** * Utility method to traverse all groups and find the {@link ItemGroup} for the given position. */ private Pair<ItemGroup, Integer> getGroupAt(int position) { // TODO(ianwen): Optimize the performance if the number of groups becomes too large. int i = position; for (ItemGroup group : mItems) { if (i >= group.size()) { i -= group.size(); } else { return new Pair<>(group, i); } } assert false; return null; } /** * Creates a long ID that identifies a particular day in history. * @param date Date to process. * @return Long that has the day of the year (1-365) in the lowest 16 bits and the year in the * next 16 bits over. */ private static long getStableIdFromDate(Date date) { Pair<Calendar, Calendar> pair = getCachedCalendars(); Calendar calendar = pair.first; calendar.setTime(date); long dayOfYear = calendar.get(Calendar.DAY_OF_YEAR); long year = calendar.get(Calendar.YEAR); return (year << 16) + dayOfYear; } /** * Compares two {@link Date}s. Note if you already have two {@link Calendar} objects, use * {@link #compareCalendar(Calendar, Calendar)} instead. * @return 0 if date1 and date2 are in the same day; 1 if date1 is before date2; -1 otherwise. */ private static int compareDate(Date date1, Date date2) { Pair<Calendar, Calendar> pair = getCachedCalendars(); Calendar cal1 = pair.first, cal2 = pair.second; cal1.setTime(date1); cal2.setTime(date2); return compareCalendar(cal1, cal2); } /** * @return 0 if cal1 and cal2 are in the same day; 1 if cal1 happens before cal2; -1 otherwise. */ private static int compareCalendar(Calendar cal1, Calendar cal2) { boolean sameDay = cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) && cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR); if (sameDay) { return 0; } else if (cal1.before(cal2)) { return 1; } else { return -1; } } /** * Convenient getter for {@link #sCal1} and {@link #sCal2}. */ private static Pair<Calendar, Calendar> getCachedCalendars() { Calendar cal1, cal2; try { cal1 = sCal1.get(); cal2 = sCal2.get(); } catch (InterruptedException | ExecutionException e) { // We've tried our best. If AsyncTask really does not work, we give up. :( cal1 = Calendar.getInstance(); cal2 = Calendar.getInstance(); } return new Pair<>(cal1, cal2); } /** * Wraps {@link Calendar#getInstance()} in an {@link AsyncTask} to avoid Strict mode violation. */ private static AsyncTask<Void, Void, Calendar> createCalendar() { return new AsyncTask<Void, Void, Calendar>() { @Override protected Calendar doInBackground(Void... unused) { return Calendar.getInstance(); } }.execute(); } }