From 4c815db076bdad365c785857e690982bfdb63aa3 Mon Sep 17 00:00:00 2001 From: Moxie Marlinspike Date: Mon, 23 Jan 2017 16:44:38 -0800 Subject: [PATCH] Support for sticky date headers Closes #4696 // FREEBIE --- .../conversation_item_header_background.xml | 6 + ...nversation_item_header_background_dark.xml | 6 + res/layout/conversation_item_header.xml | 21 ++ res/values/attrs.xml | 1 + res/values/strings.xml | 2 + res/values/themes.xml | 2 + .../ContactSelectionListFragment.java | 182 +--------------- .../securesms/ConversationAdapter.java | 65 +++++- .../securesms/ConversationFragment.java | 7 +- .../contacts/ContactSelectionListAdapter.java | 2 +- .../securesms/util/DateUtils.java | 18 ++ .../util/StickyHeaderDecoration.java | 201 ++++++++++++++++++ 12 files changed, 320 insertions(+), 193 deletions(-) create mode 100644 res/drawable/conversation_item_header_background.xml create mode 100644 res/drawable/conversation_item_header_background_dark.xml create mode 100644 res/layout/conversation_item_header.xml create mode 100644 src/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java diff --git a/res/drawable/conversation_item_header_background.xml b/res/drawable/conversation_item_header_background.xml new file mode 100644 index 0000000000..6cc80221f1 --- /dev/null +++ b/res/drawable/conversation_item_header_background.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/res/drawable/conversation_item_header_background_dark.xml b/res/drawable/conversation_item_header_background_dark.xml new file mode 100644 index 0000000000..2cc83dbe26 --- /dev/null +++ b/res/drawable/conversation_item_header_background_dark.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/res/layout/conversation_item_header.xml b/res/layout/conversation_item_header.xml new file mode 100644 index 0000000000..ef6fb13203 --- /dev/null +++ b/res/layout/conversation_item_header.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/res/values/attrs.xml b/res/values/attrs.xml index be6834af3d..131369a817 100644 --- a/res/values/attrs.xml +++ b/res/values/attrs.xml @@ -71,6 +71,7 @@ + diff --git a/res/values/strings.xml b/res/values/strings.xml index 31496313cc..befdf1b6c0 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -227,6 +227,8 @@ Just now %d min + Today + Yesterday Unlink \'%s\'? diff --git a/res/values/themes.xml b/res/values/themes.xml index fe5a50402e..556afe5abc 100644 --- a/res/values/themes.xml +++ b/res/values/themes.xml @@ -156,6 +156,7 @@ #99000000 @color/white #BFffffff + @drawable/conversation_item_header_background @drawable/quick_camera_light @drawable/ic_mic_grey600_24dp @@ -242,6 +243,7 @@ @color/white #BFffffff @drawable/conversation_item_sent_indicator_text_shape_dark + @drawable/conversation_item_header_background_dark #ff333333 diff --git a/src/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/src/org/thoughtcrime/securesms/ContactSelectionListFragment.java index be3b3083d9..b48f187786 100644 --- a/src/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/src/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -18,16 +18,12 @@ package org.thoughtcrime.securesms; import android.database.Cursor; -import android.graphics.Canvas; -import android.graphics.Rect; import android.os.Build; -import android.os.Build.VERSION; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.v4.app.Fragment; import android.support.v4.app.LoaderManager; import android.support.v4.content.Loader; -import android.support.v4.view.ViewCompat; import android.support.v4.widget.SwipeRefreshLayout; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; @@ -41,9 +37,9 @@ import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter; import org.thoughtcrime.securesms.contacts.ContactSelectionListItem; import org.thoughtcrime.securesms.contacts.ContactsCursorLoader; import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; +import org.thoughtcrime.securesms.util.StickyHeaderDecoration; import org.thoughtcrime.securesms.util.ViewUtil; -import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -205,180 +201,4 @@ public class ContactSelectionListFragment extends Fragment void onContactDeselected(String number); } - /** - * A sticky header decoration for android's RecyclerView. - */ - public static class StickyHeaderDecoration extends RecyclerView.ItemDecoration { - - private Map mHeaderCache; - - private StickyHeaderAdapter mAdapter; - - private boolean mRenderInline; - - /** - * @param adapter the sticky header adapter to use - */ - public StickyHeaderDecoration(StickyHeaderAdapter adapter, boolean renderInline) { - mAdapter = adapter; - mHeaderCache = new HashMap<>(); - mRenderInline = renderInline; - } - - /** - * {@inheritDoc} - */ - @Override - public void getItemOffsets(Rect outRect, View view, RecyclerView parent, - RecyclerView.State state) - { - int position = parent.getChildAdapterPosition(view); - - int headerHeight = 0; - if (position != RecyclerView.NO_POSITION && hasHeader(position)) { - View header = getHeader(parent, position).itemView; - headerHeight = getHeaderHeightForLayout(header); - } - - outRect.set(0, headerHeight, 0, 0); - } - - private boolean hasHeader(int position) { - if (position == 0) { - return true; - } - - int previous = position - 1; - return mAdapter.getHeaderId(position) != mAdapter.getHeaderId(previous); - } - - private RecyclerView.ViewHolder getHeader(RecyclerView parent, int position) { - final long key = mAdapter.getHeaderId(position); - - if (mHeaderCache.containsKey(key)) { - return mHeaderCache.get(key); - } else { - final RecyclerView.ViewHolder holder = mAdapter.onCreateHeaderViewHolder(parent); - final View header = holder.itemView; - - //noinspection unchecked - mAdapter.onBindHeaderViewHolder(holder, position); - - int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY); - int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED); - - int childWidth = ViewGroup.getChildMeasureSpec(widthSpec, - parent.getPaddingLeft() + parent.getPaddingRight(), header.getLayoutParams().width); - int childHeight = ViewGroup.getChildMeasureSpec(heightSpec, - parent.getPaddingTop() + parent.getPaddingBottom(), header.getLayoutParams().height); - - header.measure(childWidth, childHeight); - header.layout(0, 0, header.getMeasuredWidth(), header.getMeasuredHeight()); - - mHeaderCache.put(key, holder); - - return holder; - } - } - - /** - * {@inheritDoc} - */ - @Override - public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { - final int count = parent.getChildCount(); - - for (int layoutPos = 0; layoutPos < count; layoutPos++) { - final View child = parent.getChildAt(layoutPos); - - final int adapterPos = parent.getChildAdapterPosition(child); - - if (adapterPos != RecyclerView.NO_POSITION && (layoutPos == 0 || hasHeader(adapterPos))) { - View header = getHeader(parent, adapterPos).itemView; - c.save(); - final int left = child.getLeft(); - final int top = getHeaderTop(parent, child, header, adapterPos, layoutPos); - c.translate(left, top); - header.draw(c); - c.restore(); - } - } - } - - private int getHeaderTop(RecyclerView parent, View child, View header, int adapterPos, - int layoutPos) - { - int headerHeight = getHeaderHeightForLayout(header); - int top = getChildY(parent, child) - headerHeight; - if (layoutPos == 0) { - final int count = parent.getChildCount(); - final long currentId = mAdapter.getHeaderId(adapterPos); - // find next view with header and compute the offscreen push if needed - for (int i = 1; i < count; i++) { - int adapterPosHere = parent.getChildAdapterPosition(parent.getChildAt(i)); - if (adapterPosHere != RecyclerView.NO_POSITION) { - long nextId = mAdapter.getHeaderId(adapterPosHere); - if (nextId != currentId) { - final View next = parent.getChildAt(i); - final int offset = getChildY(parent, next) - (headerHeight + getHeader(parent, adapterPosHere).itemView.getHeight()); - if (offset < 0) { - return offset; - } else { - break; - } - } - } - } - - top = Math.max(0, top); - } - - return top; - } - - private int getChildY(RecyclerView parent, View child) { - if (VERSION.SDK_INT < 11) { - Rect rect = new Rect(); - parent.getChildVisibleRect(child, rect, null); - return rect.top; - } else { - return (int)ViewCompat.getY(child); - } - } - - private int getHeaderHeightForLayout(View header) { - return mRenderInline ? 0 : header.getHeight(); - } - } - - /** - * The adapter to assist the {@link StickyHeaderDecoration} in creating and binding the header views. - * - * @param the header view holder - */ - public interface StickyHeaderAdapter { - - /** - * Returns the header id for the item at the given position. - * - * @param position the item position - * @return the header id - */ - long getHeaderId(int position); - - /** - * Creates a new header ViewHolder. - * - * @param parent the header's view parent - * @return a view holder for the created view - */ - T onCreateHeaderViewHolder(ViewGroup parent); - - /** - * Updates the header view to reflect the header data for the given position - * @param viewHolder the header view holder - * @param position the header's item position - */ - void onBindHeaderViewHolder(T viewHolder, int position); - } } diff --git a/src/org/thoughtcrime/securesms/ConversationAdapter.java b/src/org/thoughtcrime/securesms/ConversationAdapter.java index de7bcac90f..c08f4fcd58 100644 --- a/src/org/thoughtcrime/securesms/ConversationAdapter.java +++ b/src/org/thoughtcrime/securesms/ConversationAdapter.java @@ -29,6 +29,7 @@ import android.view.View; import android.view.View.OnClickListener; import android.view.View.OnLongClickListener; import android.view.ViewGroup; +import android.widget.TextView; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; @@ -39,11 +40,17 @@ import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.recipients.Recipients; +import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.LRUCache; +import org.thoughtcrime.securesms.util.StickyHeaderDecoration; +import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.ConversationAdapter.HeaderViewHolder; import java.lang.ref.SoftReference; +import java.util.Calendar; import java.util.Collections; +import java.util.Date; import java.util.HashSet; import java.util.Locale; import java.util.Map; @@ -59,6 +66,7 @@ import java.util.Set; */ public class ConversationAdapter extends CursorRecyclerViewAdapter + implements StickyHeaderDecoration.StickyHeaderAdapter { private static final int MAX_CACHE_SIZE = 40; @@ -82,6 +90,7 @@ public class ConversationAdapter private final @NonNull Recipients recipients; private final @NonNull MmsSmsDatabase db; private final @NonNull LayoutInflater inflater; + private final @NonNull Calendar calendar; protected static class ViewHolder extends RecyclerView.ViewHolder { public ViewHolder(final @NonNull V itemView) { @@ -94,6 +103,21 @@ public class ConversationAdapter } } + + protected static class HeaderViewHolder extends RecyclerView.ViewHolder { + private TextView textView; + + public HeaderViewHolder(View itemView) { + super(itemView); + textView = ViewUtil.findById(itemView, R.id.text); + } + + public void setText(CharSequence text) { + textView.setText(text); + } + } + + public interface ItemClickListener { void onItemClick(MessageRecord item); void onItemLongClick(MessageRecord item); @@ -109,6 +133,7 @@ public class ConversationAdapter this.recipients = null; this.inflater = null; this.db = null; + this.calendar = null; } public ConversationAdapter(@NonNull Context context, @@ -125,6 +150,7 @@ public class ConversationAdapter this.recipients = recipients; this.inflater = LayoutInflater.from(context); this.db = DatabaseFactory.getMmsSmsDatabase(context); + this.calendar = Calendar.getInstance(); setHasStableIds(true); } @@ -137,10 +163,8 @@ public class ConversationAdapter @Override public void onBindItemViewHolder(ViewHolder viewHolder, @NonNull Cursor cursor) { - long start = System.currentTimeMillis(); - long id = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.ID)); - String type = cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsDatabase.TRANSPORT)); - MessageRecord messageRecord = getMessageRecord(id, cursor, type); + long start = System.currentTimeMillis(); + MessageRecord messageRecord = getMessageRecord(cursor); viewHolder.getView().bind(masterSecret, messageRecord, locale, batchSelected, recipients); Log.w(TAG, "Bind time: " + (System.currentTimeMillis() - start)); @@ -191,11 +215,9 @@ public class ConversationAdapter @Override public int getItemViewType(@NonNull Cursor cursor) { - long id = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.ID)); - String type = cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsDatabase.TRANSPORT)); - MessageRecord messageRecord = getMessageRecord(id, cursor, type); + MessageRecord messageRecord = getMessageRecord(cursor); - if (messageRecord.isGroupAction() || messageRecord.isCallLog() || messageRecord.isJoined() || + if (messageRecord.isGroupAction() || messageRecord.isCallLog() || messageRecord.isJoined() || messageRecord.isExpirationTimerUpdate() || messageRecord.isEndSession() || messageRecord.isIdentityUpdate()) { return MESSAGE_TYPE_UPDATE; } else if (hasAudio(messageRecord)) { @@ -216,7 +238,10 @@ public class ConversationAdapter return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.UNIQUE_ROW_ID)); } - private MessageRecord getMessageRecord(long messageId, Cursor cursor, String type) { + private MessageRecord getMessageRecord(Cursor cursor) { + long messageId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.ID)); + String type = cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsDatabase.TRANSPORT)); + final SoftReference reference = messageRecordCache.get(type + messageId); if (reference != null) { final MessageRecord record = reference.get(); @@ -254,4 +279,26 @@ public class ConversationAdapter private boolean hasThumbnail(MessageRecord messageRecord) { return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getThumbnailSlide() != null; } + + + @Override + public long getHeaderId(int position) { + Cursor cursor = getCursorAtPositionOrThrow(position); + MessageRecord record = getMessageRecord(cursor); + + calendar.setTime(new Date(record.getDateSent())); + return Util.hashCode(calendar.get(Calendar.YEAR), calendar.get(Calendar.DAY_OF_YEAR)); + } + + @Override + public HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent) { + return new HeaderViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.conversation_item_header, parent, false)); + } + + @Override + public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int position) { + Cursor cursor = getCursorAtPositionOrThrow(position); + viewHolder.setText(DateUtils.getRelativeDate(getContext(), locale, getMessageRecord(cursor).getDateReceived())); + } } + diff --git a/src/org/thoughtcrime/securesms/ConversationFragment.java b/src/org/thoughtcrime/securesms/ConversationFragment.java index 90b155a0d7..5db12fd418 100644 --- a/src/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/org/thoughtcrime/securesms/ConversationFragment.java @@ -32,7 +32,6 @@ import android.support.v7.app.AppCompatActivity; import android.support.v7.view.ActionMode; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; -import android.support.v7.widget.RecyclerView.ItemAnimator.ItemAnimatorFinishedListener; import android.support.v7.widget.RecyclerView.OnScrollListener; import android.text.ClipboardManager; import android.text.TextUtils; @@ -60,6 +59,7 @@ import org.thoughtcrime.securesms.recipients.Recipients; import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.util.SaveAttachmentTask; import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment; +import org.thoughtcrime.securesms.util.StickyHeaderDecoration; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; @@ -170,7 +170,10 @@ public class ConversationFragment extends Fragment private void initializeListAdapter() { if (this.recipients != null && this.threadId != -1) { - list.setAdapter(new ConversationAdapter(getActivity(), masterSecret, locale, selectionClickListener, null, this.recipients)); + ConversationAdapter adapter = new ConversationAdapter(getActivity(), masterSecret, locale, selectionClickListener, null, this.recipients); + list.setAdapter(adapter); + list.addItemDecoration(new StickyHeaderDecoration(adapter, false)); + getLoaderManager().restartLoader(0, Bundle.EMPTY, this); } } diff --git a/src/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java b/src/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java index c889fde7b3..c723f1f9f2 100644 --- a/src/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java +++ b/src/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java @@ -35,7 +35,7 @@ import android.widget.TextView; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.RecyclerViewFastScroller.FastScrollAdapter; -import org.thoughtcrime.securesms.ContactSelectionListFragment.StickyHeaderAdapter; +import org.thoughtcrime.securesms.util.StickyHeaderDecoration.StickyHeaderAdapter; import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter.HeaderViewHolder; import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter.ViewHolder; import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; diff --git a/src/org/thoughtcrime/securesms/util/DateUtils.java b/src/org/thoughtcrime/securesms/util/DateUtils.java index 6d12ad4eea..c53039c132 100644 --- a/src/org/thoughtcrime/securesms/util/DateUtils.java +++ b/src/org/thoughtcrime/securesms/util/DateUtils.java @@ -18,6 +18,7 @@ package org.thoughtcrime.securesms.util; import android.content.Context; import android.os.Build; +import android.support.annotation.NonNull; import android.text.format.DateFormat; import java.text.SimpleDateFormat; @@ -37,6 +38,10 @@ public class DateUtils extends android.text.format.DateUtils { return System.currentTimeMillis() - millis <= unit.toMillis(span); } + private static boolean isYesterday(final long when) { + return DateUtils.isToday(when + TimeUnit.DAYS.toMillis(1)); + } + private static int convertDelta(final long millis, TimeUnit to) { return (int) to.convert(System.currentTimeMillis() - millis, TimeUnit.MILLISECONDS); } @@ -111,6 +116,19 @@ public class DateUtils extends android.text.format.DateUtils { return new SimpleDateFormat(dateFormatPattern, locale); } + public static String getRelativeDate(@NonNull Context context, + @NonNull Locale locale, + long timestamp) + { + if (isToday(timestamp)) { + return context.getString(R.string.DateUtils_today); + } else if (isYesterday(timestamp)) { + return context.getString(R.string.DateUtils_yesterday); + } else { + return getFormattedDateTime(timestamp, "EEE, MMM d, yyyy", locale); + } + } + private static String getLocalizedPattern(String template, Locale locale) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { return DateFormat.getBestDateTimePattern(locale, template); diff --git a/src/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java b/src/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java new file mode 100644 index 0000000000..c4bc881d7a --- /dev/null +++ b/src/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java @@ -0,0 +1,201 @@ +package org.thoughtcrime.securesms.util; + +import android.graphics.Canvas; +import android.graphics.Rect; +import android.os.Build.VERSION; +import android.support.v4.view.ViewCompat; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.RecyclerView.ViewHolder; +import android.view.View; +import android.view.ViewGroup; + +import java.util.HashMap; +import java.util.Map; + +/** + * A sticky header decoration for android's RecyclerView. + * Currently only supports LinearLayoutManager in VERTICAL orientation. + */ +public class StickyHeaderDecoration extends RecyclerView.ItemDecoration { + + private final Map headerCache; + private final StickyHeaderAdapter adapter; + private final boolean renderInline; + + /** + * @param adapter the sticky header adapter to use + */ + public StickyHeaderDecoration(StickyHeaderAdapter adapter, boolean renderInline) { + this.adapter = adapter; + this.headerCache = new HashMap<>(); + this.renderInline = renderInline; + } + + /** + * {@inheritDoc} + */ + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, + RecyclerView.State state) + { + int position = parent.getChildAdapterPosition(view); + + int headerHeight = 0; + if (position != RecyclerView.NO_POSITION && hasHeader(parent, position)) { + View header = getHeader(parent, position).itemView; + headerHeight = getHeaderHeightForLayout(header); + } + + outRect.set(0, headerHeight, 0, 0); + } + + private boolean hasHeader(RecyclerView parent, int adapterPos) { + boolean isReverse = isReverseLayout(parent); + if (isReverse && adapterPos == parent.getAdapter().getItemCount() - 1 || + !isReverse && adapterPos == 0) { + return true; + } + + int previous = adapterPos + (isReverse ? 1 : -1); + return adapter.getHeaderId(adapterPos) != adapter.getHeaderId(previous); + } + + private ViewHolder getHeader(RecyclerView parent, int position) { + final long key = adapter.getHeaderId(position); + + if (headerCache.containsKey(key)) { + return headerCache.get(key); + } else { + final ViewHolder holder = adapter.onCreateHeaderViewHolder(parent); + final View header = holder.itemView; + + //noinspection unchecked + adapter.onBindHeaderViewHolder(holder, position); + + int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY); + int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED); + + int childWidth = ViewGroup.getChildMeasureSpec(widthSpec, + parent.getPaddingLeft() + parent.getPaddingRight(), header.getLayoutParams().width); + int childHeight = ViewGroup.getChildMeasureSpec(heightSpec, + parent.getPaddingTop() + parent.getPaddingBottom(), header.getLayoutParams().height); + + header.measure(childWidth, childHeight); + header.layout(0, 0, header.getMeasuredWidth(), header.getMeasuredHeight()); + + headerCache.put(key, holder); + + return holder; + } + } + + /** + * {@inheritDoc} + */ + @Override + public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { + final int count = parent.getChildCount(); + + for (int layoutPos = 0; layoutPos < count; layoutPos++) { + final View child = parent.getChildAt(translatedChildPosition(parent, layoutPos)); + + final int adapterPos = parent.getChildAdapterPosition(child); + + if (adapterPos != RecyclerView.NO_POSITION && (layoutPos == 0 || hasHeader(parent, adapterPos))) { + View header = getHeader(parent, adapterPos).itemView; + c.save(); + final int left = child.getLeft(); + final int top = getHeaderTop(parent, child, header, adapterPos, layoutPos); + c.translate(left, top); + header.draw(c); + c.restore(); + } + } + } + + private int getHeaderTop(RecyclerView parent, View child, View header, int adapterPos, + int layoutPos) + { + int headerHeight = getHeaderHeightForLayout(header); + int top = getChildY(parent, child) - headerHeight; + if (layoutPos == 0) { + final int count = parent.getChildCount(); + final long currentId = adapter.getHeaderId(adapterPos); + // find next view with header and compute the offscreen push if needed + for (int i = 1; i < count; i++) { + int adapterPosHere = parent.getChildAdapterPosition(parent.getChildAt(translatedChildPosition(parent, i))); + if (adapterPosHere != RecyclerView.NO_POSITION) { + long nextId = adapter.getHeaderId(adapterPosHere); + if (nextId != currentId) { + final View next = parent.getChildAt(translatedChildPosition(parent, i)); + final int offset = getChildY(parent, next) - (headerHeight + getHeader(parent, adapterPosHere).itemView.getHeight()); + if (offset < 0) { + return offset; + } else { + break; + } + } + } + } + + top = Math.max(0, top); + } + + return top; + } + + private int translatedChildPosition(RecyclerView parent, int position) { + return isReverseLayout(parent) ? parent.getChildCount() - 1 - position : position; + } + + private int getChildY(RecyclerView parent, View child) { + if (VERSION.SDK_INT < 11) { + Rect rect = new Rect(); + parent.getChildVisibleRect(child, rect, null); + return rect.top; + } else { + return (int)ViewCompat.getY(child); + } + } + + private int getHeaderHeightForLayout(View header) { + return renderInline ? 0 : header.getHeight(); + } + + private boolean isReverseLayout(final RecyclerView parent) { + return (parent.getLayoutManager() instanceof LinearLayoutManager) && + ((LinearLayoutManager)parent.getLayoutManager()).getReverseLayout(); + } + + /** + * The adapter to assist the {@link StickyHeaderDecoration} in creating and binding the header views. + * + * @param the header view holder + */ + public interface StickyHeaderAdapter { + + /** + * Returns the header id for the item at the given position. + * + * @param position the item position + * @return the header id + */ + long getHeaderId(int position); + + /** + * Creates a new header ViewHolder. + * + * @param parent the header's view parent + * @return a view holder for the created view + */ + T onCreateHeaderViewHolder(ViewGroup parent); + + /** + * Updates the header view to reflect the header data for the given position + * @param viewHolder the header view holder + * @param position the header's item position + */ + void onBindHeaderViewHolder(T viewHolder, int position); + } +} \ No newline at end of file