blob: 9aac3e1cf8b98ca3b5427523c99314bad6536e29 [file] [log] [blame]
// 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.support.v7.widget.RecyclerView;
import android.support.v7.widget.RecyclerView.Adapter;
import android.support.v7.widget.RecyclerView.ViewHolder;
import android.util.Pair;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import org.chromium.base.Log;
import org.chromium.base.task.AsyncTask;
import org.chromium.base.task.BackgroundOnlyAsyncTask;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.download.DownloadUtils;
import org.chromium.chrome.browser.download.home.list.UiUtils;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
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 abstract static class TimedItem {
/** Value indicating that a TimedItem is not currently being displayed. */
public static final int INVALID_POSITION = -1;
/** Position of the TimedItem in the list, or {@link #INVALID_POSITION} if not shown. */
private int mPosition = INVALID_POSITION;
private boolean mIsFirstInGroup;
private boolean mIsLastInGroup;
private boolean mIsDateHeader;
/** See {@link #mPosition}. */
private final void setPosition(int position) {
mPosition = position;
}
/** See {@link #mPosition}. */
public final int getPosition() {
return mPosition;
}
/**
* @param isFirst Whether this item is the first in its group.
*/
public final void setIsFirstInGroup(boolean isFirst) {
mIsFirstInGroup = isFirst;
}
/**
* @param isLast Whether this item is the last in its group.
*/
public final void setIsLastInGroup(boolean isLast) {
mIsLastInGroup = isLast;
}
/**
* @return Whether this item is the first in its group.
*/
public boolean isFirstInGroup() {
return mIsFirstInGroup;
}
/**
* @return Whether this item is the last in its group.
*/
public boolean isLastInGroup() {
return mIsLastInGroup;
}
/** @return The timestamp for this item. */
public abstract 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.
*/
public abstract long getStableId();
}
/**
* Contains information of a single header that this adapter uses to manage headers.
*/
public static class HeaderItem extends TimedItem {
private final long mStableId;
private final View mView;
/**
* Initialize stable id and view associated with this HeaderItem.
* @param position Position of this HeaderItem in the header group.
* @param view View associated with this HeaderItem.
*/
public HeaderItem(int position, View view) {
mStableId = getTimestamp() - position;
mView = view;
}
@Override
public long getTimestamp() {
return Long.MAX_VALUE;
}
@Override
public long getStableId() {
return mStableId;
}
/**
* @return The View associated with this HeaderItem.
*/
public View getView() {
return mView;
}
}
/**
* Contains information of a single footer that this adapter uses to manage footers.
* Share most of the same funcionality as a Header class.
*/
public static class FooterItem extends HeaderItem {
public FooterItem(int position, View view) {
super(position, view);
}
@Override
public long getTimestamp() {
return Long.MIN_VALUE;
}
}
/** An item representing a date header. */
class DateHeaderTimedItem extends TimedItem {
private long mTimestamp;
public DateHeaderTimedItem(long timestamp) {
mTimestamp = DownloadUtils.getDateAtMidnight(timestamp).getTime();
}
@Override
public long getTimestamp() {
return mTimestamp;
}
@Override
public long getStableId() {
return getStableIdFromDate(new Date(getTimestamp()));
}
}
/**
* A {@link RecyclerView.ViewHolder} that displays a date header.
*/
public static class DateViewHolder extends RecyclerView.ViewHolder {
private TextView mTextView;
public DateViewHolder(View view) {
super(view);
if (view instanceof TextView) mTextView = (TextView) view;
}
/**
* @param date The date that this DateViewHolder should display.
*/
public void setDate(Date date) {
mTextView.setText(UiUtils.dateToHeaderString(date));
}
}
protected static class BasicViewHolder extends RecyclerView.ViewHolder {
public BasicViewHolder(View itemView) {
super(itemView);
}
}
protected static class SubsectionHeaderViewHolder extends RecyclerView.ViewHolder {
private View mView;
public SubsectionHeaderViewHolder(View itemView) {
super(itemView);
mView = itemView;
}
public View getView() {
return mView;
}
}
/**
* A bucket of items with the same date. The date header should also be an item of the group.
* Special groups are subclassed for list header(s) and list footers.
*/
public static class ItemGroup {
private final Date mDate;
private final List<TimedItem> mItems = new ArrayList<>();
/** Index of the header, relative to the full list. Must be set only once.*/
private int mIndex;
private boolean mIsSorted;
/** Constructors for groups that contain same date items. */
public ItemGroup(long timestamp) {
mDate = new Date(timestamp);
mIsSorted = true;
}
/**
* Default constructor for groups that don't contain same date items e.g. header, footer,
* elevated priority groups etc.
*/
public ItemGroup() {
mDate = new Date(0L);
}
public void addItem(TimedItem item) {
mItems.add(item);
mIsSorted = mItems.size() == 1;
}
public void removeItem(TimedItem item) {
mItems.remove(item);
}
public void removeAllItems() {
mItems.clear();
}
/** Records the position of all the TimedItems in this group, relative to the full list. */
public void setPosition(int index) {
assert mIndex == 0 || mIndex == TimedItem.INVALID_POSITION;
mIndex = index;
sortIfNeeded();
for (int i = 0; i < mItems.size(); i++) {
TimedItem item = mItems.get(i);
item.setPosition(index);
item.setIsFirstInGroup(i == 0);
item.setIsLastInGroup(i == mItems.size() - 1);
index += 1;
}
}
/** Unsets the position of all TimedItems in this group. */
public void resetPosition() {
mIndex = TimedItem.INVALID_POSITION;
for (TimedItem item : mItems) item.setPosition(TimedItem.INVALID_POSITION);
}
/**
* @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() {
return mItems.size();
}
/**
* Used for sorting list groups.
* @return The priority used to determine the position of this {@link ItemGroup} relative to
* the top of the list.
*/
@GroupPriority
public int priority() {
return GroupPriority.NORMAL_CONTENT;
}
/**
* Returns the item to be displayed at the given index of this group.
* @param index The index of the item.
* @return The corresponding item.
*/
public TimedItem getItemAt(int index) {
assert index < size();
sortIfNeeded();
return mItems.get(index);
}
/** @return The view type associated for the given index */
public @ItemViewType int getItemViewType(int index) {
return mItems.get(index).mIsDateHeader ? ItemViewType.DATE : ItemViewType.NORMAL;
}
/**
* 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.
*/
protected void sortIfNeeded() {
if (mIsSorted) return;
mIsSorted = true;
Collections.sort(mItems, new Comparator<TimedItem>() {
@Override
public int compare(TimedItem lhs, TimedItem rhs) {
return compareItem(lhs, rhs);
}
});
}
/** Sorting function that determines the ordering of the items in this group. */
protected int compareItem(TimedItem lhs, TimedItem rhs) {
if (lhs.mIsDateHeader) return -1;
if (rhs.mIsDateHeader) return 1;
// 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;
}
}
}
/** An item group representing the list header(s). */
public static class HeaderItemGroup extends ItemGroup {
@Override
public @GroupPriority int priority() {
return GroupPriority.HEADER;
}
@Override
public @ItemViewType int getItemViewType(int index) {
return ItemViewType.HEADER;
}
}
/** An item group representing the list footer(s). */
public static class FooterItemGroup extends ItemGroup {
@Override
public @GroupPriority int priority() {
return GroupPriority.FOOTER;
}
@Override
public @ItemViewType int getItemViewType(int index) {
return ItemViewType.FOOTER;
}
}
// Cached async tasks to get the two Calendar objects, which are used when comparing dates.
private static final AsyncTask<Calendar> sCal1 = createCalendar();
private static final AsyncTask<Calendar> sCal2 = createCalendar();
/**
* Specifies various view types of the list items for the purpose of recycling.
*/
@IntDef({ItemViewType.FOOTER, ItemViewType.HEADER, ItemViewType.DATE, ItemViewType.NORMAL,
ItemViewType.SUBSECTION_HEADER})
@Retention(RetentionPolicy.SOURCE)
public @interface ItemViewType {
int FOOTER = -2;
int HEADER = -1;
int DATE = 0;
int NORMAL = 1;
int SUBSECTION_HEADER = 2;
}
/**
* The priorities that determine the relative position of item groups starting at the top.
* Default priority is GroupPriority.NORMAL_CONTENT.
*/
@IntDef({GroupPriority.HEADER, GroupPriority.ELEVATED_CONTENT, GroupPriority.NORMAL_CONTENT,
GroupPriority.FOOTER})
@Retention(RetentionPolicy.SOURCE)
public @interface GroupPriority {
int HEADER = 1;
int ELEVATED_CONTENT = 2;
int NORMAL_CONTENT = 3;
int FOOTER = 4;
}
private static final String TAG = "DateDividedAdapter";
private int mSize;
private SortedSet<ItemGroup> mGroups = new TreeSet<>(new Comparator<ItemGroup>() {
@Override
public int compare(ItemGroup lhs, ItemGroup rhs) {
if (lhs == rhs) return 0;
if (lhs.priority() != rhs.priority()) {
return lhs.priority() < rhs.priority() ? -1 : 1;
}
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);
/**
* Creates a {@link BasicViewHolder} in the given view parent for the header. The default
* implementation will create an empty FrameLayout container as the view holder.
* @see #onCreateViewHolder(ViewGroup, int)
*/
protected BasicViewHolder createHeader(ViewGroup parent) {
// Create an empty layout as a container for the header view.
View v = LayoutInflater.from(parent.getContext())
.inflate(R.layout.date_divided_adapter_header_view_holder, parent, false);
return new BasicViewHolder(v);
}
/**
* Creates a {@link BasicViewHolder} in the given view parent for the footer.
* See {@link #onCreateViewHolder(ViewGroup, int)}.
*/
@Nullable
protected BasicViewHolder createFooter(ViewGroup parent) {
return null;
}
/**
* Creates a {@link DateViewHolder} in the given view parent.
* @see #onCreateViewHolder(ViewGroup, int)
*/
protected DateViewHolder createDateViewHolder(ViewGroup parent) {
return new DateViewHolder(LayoutInflater.from(parent.getContext()).inflate(
getTimedItemViewResId(), parent, false));
}
/**
* Creates a {@link ViewHolder} for a subsection in the given view parent.
* @see #onCreateViewHolder(ViewGroup, int)
*/
@Nullable
protected SubsectionHeaderViewHolder createSubsectionHeader(ViewGroup parent) {
return null;
}
/**
* Helper function to determine whether an item is a subsection header.
* @param timedItem The item.
* @return Whether the item is a subsection header.
*/
protected boolean isSubsectionHeader(TimedItem timedItem) {
return false;
}
/**
* Binds the {@link ViewHolder} with the given {@link TimedItem}.
* @see #onBindViewHolder(ViewHolder, int)
*/
protected abstract void bindViewHolderForTimedItem(ViewHolder viewHolder, TimedItem item);
/**
* Binds the {@link SubsectionHeaderViewHolder} with the given {@link TimedItem}.
* @see #onBindViewHolder(ViewHolder, int)
*/
protected void bindViewHolderForSubsectionHeader(
SubsectionHeaderViewHolder holder, TimedItem timedItem) {}
/**
* Binds the {@link BasicViewHolder} with the given {@link HeaderItem}.
* @see #onBindViewHolder(ViewHolder, int)
*/
protected void bindViewHolderForHeaderItem(ViewHolder viewHolder, HeaderItem headerItem) {
BasicViewHolder basicViewHolder = (BasicViewHolder) viewHolder;
View v = headerItem.getView();
((ViewGroup) basicViewHolder.itemView).removeAllViews();
if (v.getParent() != null) ((ViewGroup) v.getParent()).removeView(v);
((ViewGroup) basicViewHolder.itemView).addView(v);
}
/**
* Binds the {@link BasicViewHolder} with the given {@link FooterItem}.
* @see #onBindViewHolder(ViewHolder, int)
*/
protected void bindViewHolderForFooterItem(ViewHolder viewHolder, FooterItem footerItem) {
BasicViewHolder basicViewHolder = (BasicViewHolder) viewHolder;
View v = footerItem.getView();
((ViewGroup) basicViewHolder.itemView).removeAllViews();
if (v.getParent() != null) ((ViewGroup) v.getParent()).removeView(v);
((ViewGroup) basicViewHolder.itemView).addView(v);
}
/**
* 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. Previous data will not be removed. Call
* {@link #clear(boolean)} to remove previous items.
*/
public void loadItems(List<? extends TimedItem> timedItems) {
for (TimedItem timedItem : timedItems) {
Date date = new Date(timedItem.getTimestamp());
boolean found = false;
for (ItemGroup group : mGroups) {
if (group.isSameDay(date)) {
found = true;
group.addItem(timedItem);
break;
}
}
if (!found) {
// Create a new ItemGroup with the date for the new item. Insert the date header and
// the new item into the group.
TimedItem dateHeader = new DateHeaderTimedItem(timedItem.getTimestamp());
dateHeader.mIsDateHeader = true;
ItemGroup newGroup = new ItemGroup(timedItem.getTimestamp());
newGroup.addItem(dateHeader);
newGroup.addItem(timedItem);
mGroups.add(newGroup);
}
}
setSizeAndGroupPositions();
notifyDataSetChanged();
}
/**
* Tells each group where they start in the list. Also calculates the list size.
*/
private void setSizeAndGroupPositions() {
mSize = 0;
for (ItemGroup group : mGroups) {
group.resetPosition();
group.setPosition(mSize);
mSize += group.size();
}
}
/**
* The utility function to add an {@link ItemGroup}.
* @param group The group to be added.
*/
protected void addGroup(ItemGroup group) {
mGroups.add(group);
setSizeAndGroupPositions();
notifyDataSetChanged();
}
/**
* Add a list of headers as the first group in this adapter. If headerItems has no items,
* the header group will not be created. Otherwise, header items will be added as child items
* to the header group. Note that any previously added header items will be removed.
* {@link #bindViewHolderForHeaderItem(ViewHolder, HeaderItem)} will bind the HeaderItem views
* to the given ViewHolder. Sub-classes may override #bindViewHolderForHeaderItem and
* (@link #createHeader(ViewGroup)} if custom behavior is needed.
*
* @param headerItems Zero or more header items to be add to the header item group.
*/
public void setHeaders(HeaderItem... headerItems) {
if (headerItems == null || headerItems.length == 0) {
removeHeader();
return;
}
if (hasListHeader()) mGroups.remove(mGroups.first());
ItemGroup header = new HeaderItemGroup();
for (HeaderItem item : headerItems) {
header.addItem(item);
}
addGroup(header);
}
/**
* Removes the list header.
*/
public void removeHeader() {
if (!hasListHeader()) return;
mGroups.remove(mGroups.first());
setSizeAndGroupPositions();
notifyDataSetChanged();
}
/**
* Whether the adapter has a list header.
*/
public boolean hasListHeader() {
return !mGroups.isEmpty() && mGroups.first().priority() == GroupPriority.HEADER;
}
/**
* Whether the adapter has a list header.
*/
public boolean hasListFooter() {
return !mGroups.isEmpty() && mGroups.last().priority() == GroupPriority.FOOTER;
}
/**
* Adds a footer as the last group in this adapter.
*/
public void addFooter() {
if (hasListFooter()) return;
ItemGroup footer = new FooterItemGroup();
addGroup(footer);
}
/**
* Removes the footer group if present.
*/
public void removeFooter() {
if (!hasListFooter()) return;
mGroups.remove(mGroups.last());
setSizeAndGroupPositions();
notifyDataSetChanged();
}
/**
* Removes all items from this adapter.
* @param notifyDataSetChanged Whether to notify that the data set has been changed.
*/
public void clear(boolean notifyDataSetChanged) {
mSize = 0;
// Unset the positions of all items in the list.
for (ItemGroup group : mGroups) group.resetPosition();
mGroups.clear();
if (notifyDataSetChanged) 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.
*/
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
@ItemViewType
public final int getItemViewType(int position) {
Pair<ItemGroup, Integer> pair = getGroupAt(position);
ItemGroup group = pair.first;
return group.getItemViewType(pair.second);
}
@Override
public final RecyclerView.ViewHolder onCreateViewHolder(
ViewGroup parent, @ItemViewType int viewType) {
switch (viewType) {
case ItemViewType.DATE:
return createDateViewHolder(parent);
case ItemViewType.NORMAL:
return createViewHolder(parent);
case ItemViewType.HEADER:
return createHeader(parent);
case ItemViewType.FOOTER:
return createFooter(parent);
case ItemViewType.SUBSECTION_HEADER:
return createSubsectionHeader(parent);
default:
assert false;
return null;
}
}
@Override
public final void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
Pair<ItemGroup, Integer> groupAndPosition = getGroupAt(position);
ItemGroup group = groupAndPosition.first;
@ItemViewType
int viewType = group.getItemViewType(groupAndPosition.second);
Pair<Date, TimedItem> pair = getItemAt(position);
switch (viewType) {
case ItemViewType.DATE:
((DateViewHolder) holder).setDate(pair.first);
break;
case ItemViewType.NORMAL:
bindViewHolderForTimedItem(holder, pair.second);
break;
case ItemViewType.HEADER:
bindViewHolderForHeaderItem(holder, (HeaderItem) pair.second);
break;
case ItemViewType.FOOTER:
bindViewHolderForFooterItem(holder, (FooterItem) pair.second);
break;
case ItemViewType.SUBSECTION_HEADER:
bindViewHolderForSubsectionHeader((SubsectionHeaderViewHolder) holder, pair.second);
break;
}
}
@Override
public final int getItemCount() {
return mSize;
}
/**
* Utility method to traverse all groups and find the {@link ItemGroup} for the given position.
*/
protected 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 : mGroups) {
if (i >= group.size()) {
i -= group.size();
} else {
return new Pair<>(group, i);
}
}
assert false;
return null;
}
/**
* @param item The item to remove from the adapter.
*/
// #getGroupAt() asserts false before returning null, causing findbugs to complain about
// a redundant nullcheck even though getGroupAt can return null.
protected void removeItem(TimedItem item) {
Pair<ItemGroup, Integer> groupPair = getGroupAt(item.getPosition());
if (groupPair == null) {
Log.e(TAG,
"Failed to find group for item during remove. Item position: "
+ item.getPosition() + ", total size: " + mSize);
return;
}
ItemGroup group = groupPair.first;
group.removeItem(item);
// Remove the group if only the date header is left.
if (group.size() == 1) mGroups.remove(group);
// Remove header if only the header is left.
if (hasListHeader() && mGroups.size() == 1) removeHeader();
setSizeAndGroupPositions();
notifyDataSetChanged();
}
/**
* 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.
*/
protected static int compareDate(Date date1, Date date2) {
Pair<Calendar, Calendar> pair = getCachedCalendars();
Calendar cal1 = pair.first;
Calendar 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;
Calendar 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<Calendar> createCalendar() {
return new BackgroundOnlyAsyncTask<Calendar>() {
@Override
protected Calendar doInBackground() {
return Calendar.getInstance();
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
}