Support for sticky date headers

Closes #4696
// FREEBIE
This commit is contained in:
Moxie Marlinspike 2017-01-23 16:44:38 -08:00
parent b677370597
commit 4c815db076
12 changed files with 320 additions and 193 deletions

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#bb999999" />
<corners android:radius="3dp" />
</shape>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#bb444444" />
<corners android:radius="3dp" />
</shape>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="3dp"
android:paddingBottom="3dp">
<TextView android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:paddingLeft="6dp"
android:paddingRight="6dp"
android:paddingTop="3dp"
android:paddingBottom="3dp"
android:textColor="@color/white"
android:background="?conversation_item_header_background"
android:textSize="14sp"
tools:text="March 1, 2015" />
</FrameLayout>

View File

@ -71,6 +71,7 @@
<attr name="conversation_item_received_text_secondary_color" format="reference|color"/> <attr name="conversation_item_received_text_secondary_color" format="reference|color"/>
<attr name="conversation_item_sent_text_indicator_tab_color" format="reference|color"/> <attr name="conversation_item_sent_text_indicator_tab_color" format="reference|color"/>
<attr name="conversation_item_sent_indicator_text_background" format="reference" /> <attr name="conversation_item_sent_indicator_text_background" format="reference" />
<attr name="conversation_item_header_background" format="reference"/>
<attr name="dialog_info_icon" format="reference" /> <attr name="dialog_info_icon" format="reference" />
<attr name="dialog_alert_icon" format="reference" /> <attr name="dialog_alert_icon" format="reference" />

View File

@ -227,6 +227,8 @@
<!-- DateUtils --> <!-- DateUtils -->
<string name="DateUtils_just_now">Just now</string> <string name="DateUtils_just_now">Just now</string>
<string name="DateUtils_minutes_ago">%d min</string> <string name="DateUtils_minutes_ago">%d min</string>
<string name="DateUtils_today">Today</string>
<string name="DateUtils_yesterday">Yesterday</string>
<!-- DeviceListActivity --> <!-- DeviceListActivity -->
<string name="DeviceListActivity_unlink_s">Unlink \'%s\'?</string> <string name="DeviceListActivity_unlink_s">Unlink \'%s\'?</string>

View File

@ -156,6 +156,7 @@
<item name="conversation_item_sent_text_indicator_tab_color">#99000000</item> <item name="conversation_item_sent_text_indicator_tab_color">#99000000</item>
<item name="conversation_item_received_text_primary_color">@color/white</item> <item name="conversation_item_received_text_primary_color">@color/white</item>
<item name="conversation_item_received_text_secondary_color">#BFffffff</item> <item name="conversation_item_received_text_secondary_color">#BFffffff</item>
<item name="conversation_item_header_background">@drawable/conversation_item_header_background</item>
<item name="quick_camera_icon">@drawable/quick_camera_light</item> <item name="quick_camera_icon">@drawable/quick_camera_light</item>
<item name="quick_mic_icon">@drawable/ic_mic_grey600_24dp</item> <item name="quick_mic_icon">@drawable/ic_mic_grey600_24dp</item>
@ -242,6 +243,7 @@
<item name="conversation_item_received_text_primary_color">@color/white</item> <item name="conversation_item_received_text_primary_color">@color/white</item>
<item name="conversation_item_received_text_secondary_color">#BFffffff</item> <item name="conversation_item_received_text_secondary_color">#BFffffff</item>
<item name="conversation_item_sent_indicator_text_background">@drawable/conversation_item_sent_indicator_text_shape_dark</item> <item name="conversation_item_sent_indicator_text_background">@drawable/conversation_item_sent_indicator_text_shape_dark</item>
<item name="conversation_item_header_background">@drawable/conversation_item_header_background_dark</item>
<item name="verification_background">#ff333333</item> <item name="verification_background">#ff333333</item>

View File

@ -18,16 +18,12 @@ package org.thoughtcrime.securesms;
import android.database.Cursor; import android.database.Cursor;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.os.Build; import android.os.Build;
import android.os.Build.VERSION;
import android.os.Bundle; import android.os.Bundle;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.v4.app.Fragment; import android.support.v4.app.Fragment;
import android.support.v4.app.LoaderManager; import android.support.v4.app.LoaderManager;
import android.support.v4.content.Loader; import android.support.v4.content.Loader;
import android.support.v4.view.ViewCompat;
import android.support.v4.widget.SwipeRefreshLayout; import android.support.v4.widget.SwipeRefreshLayout;
import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView; 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.ContactSelectionListItem;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader; import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.HashMap;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -205,180 +201,4 @@ public class ContactSelectionListFragment extends Fragment
void onContactDeselected(String number); void onContactDeselected(String number);
} }
/**
* A sticky header decoration for android's RecyclerView.
*/
public static class StickyHeaderDecoration extends RecyclerView.ItemDecoration {
private Map<Long, RecyclerView.ViewHolder> 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 <T> the header view holder
*/
public interface StickyHeaderAdapter<T extends RecyclerView.ViewHolder> {
/**
* 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);
}
} }

View File

@ -29,6 +29,7 @@ import android.view.View;
import android.view.View.OnClickListener; import android.view.View.OnClickListener;
import android.view.View.OnLongClickListener; import android.view.View.OnLongClickListener;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.TextView;
import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.crypto.MasterSecret;
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; 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.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.recipients.Recipients; import org.thoughtcrime.securesms.recipients.Recipients;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.LRUCache; 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.util.ViewUtil;
import org.thoughtcrime.securesms.ConversationAdapter.HeaderViewHolder;
import java.lang.ref.SoftReference; import java.lang.ref.SoftReference;
import java.util.Calendar;
import java.util.Collections; import java.util.Collections;
import java.util.Date;
import java.util.HashSet; import java.util.HashSet;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
@ -59,6 +66,7 @@ import java.util.Set;
*/ */
public class ConversationAdapter <V extends View & BindableConversationItem> public class ConversationAdapter <V extends View & BindableConversationItem>
extends CursorRecyclerViewAdapter<ConversationAdapter.ViewHolder> extends CursorRecyclerViewAdapter<ConversationAdapter.ViewHolder>
implements StickyHeaderDecoration.StickyHeaderAdapter<HeaderViewHolder>
{ {
private static final int MAX_CACHE_SIZE = 40; private static final int MAX_CACHE_SIZE = 40;
@ -82,6 +90,7 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
private final @NonNull Recipients recipients; private final @NonNull Recipients recipients;
private final @NonNull MmsSmsDatabase db; private final @NonNull MmsSmsDatabase db;
private final @NonNull LayoutInflater inflater; private final @NonNull LayoutInflater inflater;
private final @NonNull Calendar calendar;
protected static class ViewHolder extends RecyclerView.ViewHolder { protected static class ViewHolder extends RecyclerView.ViewHolder {
public <V extends View & BindableConversationItem> ViewHolder(final @NonNull V itemView) { public <V extends View & BindableConversationItem> ViewHolder(final @NonNull V itemView) {
@ -94,6 +103,21 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
} }
} }
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 { public interface ItemClickListener {
void onItemClick(MessageRecord item); void onItemClick(MessageRecord item);
void onItemLongClick(MessageRecord item); void onItemLongClick(MessageRecord item);
@ -109,6 +133,7 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
this.recipients = null; this.recipients = null;
this.inflater = null; this.inflater = null;
this.db = null; this.db = null;
this.calendar = null;
} }
public ConversationAdapter(@NonNull Context context, public ConversationAdapter(@NonNull Context context,
@ -125,6 +150,7 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
this.recipients = recipients; this.recipients = recipients;
this.inflater = LayoutInflater.from(context); this.inflater = LayoutInflater.from(context);
this.db = DatabaseFactory.getMmsSmsDatabase(context); this.db = DatabaseFactory.getMmsSmsDatabase(context);
this.calendar = Calendar.getInstance();
setHasStableIds(true); setHasStableIds(true);
} }
@ -137,10 +163,8 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
@Override @Override
public void onBindItemViewHolder(ViewHolder viewHolder, @NonNull Cursor cursor) { public void onBindItemViewHolder(ViewHolder viewHolder, @NonNull Cursor cursor) {
long start = System.currentTimeMillis(); long start = System.currentTimeMillis();
long id = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.ID)); MessageRecord messageRecord = getMessageRecord(cursor);
String type = cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsDatabase.TRANSPORT));
MessageRecord messageRecord = getMessageRecord(id, cursor, type);
viewHolder.getView().bind(masterSecret, messageRecord, locale, batchSelected, recipients); viewHolder.getView().bind(masterSecret, messageRecord, locale, batchSelected, recipients);
Log.w(TAG, "Bind time: " + (System.currentTimeMillis() - start)); Log.w(TAG, "Bind time: " + (System.currentTimeMillis() - start));
@ -191,11 +215,9 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
@Override @Override
public int getItemViewType(@NonNull Cursor cursor) { public int getItemViewType(@NonNull Cursor cursor) {
long id = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.ID)); MessageRecord messageRecord = getMessageRecord(cursor);
String type = cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsDatabase.TRANSPORT));
MessageRecord messageRecord = getMessageRecord(id, cursor, type);
if (messageRecord.isGroupAction() || messageRecord.isCallLog() || messageRecord.isJoined() || if (messageRecord.isGroupAction() || messageRecord.isCallLog() || messageRecord.isJoined() ||
messageRecord.isExpirationTimerUpdate() || messageRecord.isEndSession() || messageRecord.isIdentityUpdate()) { messageRecord.isExpirationTimerUpdate() || messageRecord.isEndSession() || messageRecord.isIdentityUpdate()) {
return MESSAGE_TYPE_UPDATE; return MESSAGE_TYPE_UPDATE;
} else if (hasAudio(messageRecord)) { } else if (hasAudio(messageRecord)) {
@ -216,7 +238,10 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.UNIQUE_ROW_ID)); 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<MessageRecord> reference = messageRecordCache.get(type + messageId); final SoftReference<MessageRecord> reference = messageRecordCache.get(type + messageId);
if (reference != null) { if (reference != null) {
final MessageRecord record = reference.get(); final MessageRecord record = reference.get();
@ -254,4 +279,26 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
private boolean hasThumbnail(MessageRecord messageRecord) { private boolean hasThumbnail(MessageRecord messageRecord) {
return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getThumbnailSlide() != null; 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()));
}
} }

View File

@ -32,7 +32,6 @@ import android.support.v7.app.AppCompatActivity;
import android.support.v7.view.ActionMode; import android.support.v7.view.ActionMode;
import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.RecyclerView.ItemAnimator.ItemAnimatorFinishedListener;
import android.support.v7.widget.RecyclerView.OnScrollListener; import android.support.v7.widget.RecyclerView.OnScrollListener;
import android.text.ClipboardManager; import android.text.ClipboardManager;
import android.text.TextUtils; import android.text.TextUtils;
@ -60,6 +59,7 @@ import org.thoughtcrime.securesms.recipients.Recipients;
import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.SaveAttachmentTask; import org.thoughtcrime.securesms.util.SaveAttachmentTask;
import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment; import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
@ -170,7 +170,10 @@ public class ConversationFragment extends Fragment
private void initializeListAdapter() { private void initializeListAdapter() {
if (this.recipients != null && this.threadId != -1) { 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); getLoaderManager().restartLoader(0, Bundle.EMPTY, this);
} }
} }

View File

@ -35,7 +35,7 @@ import android.widget.TextView;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller.FastScrollAdapter; 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.HeaderViewHolder;
import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter.ViewHolder; import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter.ViewHolder;
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;

View File

@ -18,6 +18,7 @@ package org.thoughtcrime.securesms.util;
import android.content.Context; import android.content.Context;
import android.os.Build; import android.os.Build;
import android.support.annotation.NonNull;
import android.text.format.DateFormat; import android.text.format.DateFormat;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
@ -37,6 +38,10 @@ public class DateUtils extends android.text.format.DateUtils {
return System.currentTimeMillis() - millis <= unit.toMillis(span); 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) { private static int convertDelta(final long millis, TimeUnit to) {
return (int) to.convert(System.currentTimeMillis() - millis, TimeUnit.MILLISECONDS); 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); 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) { private static String getLocalizedPattern(String template, Locale locale) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
return DateFormat.getBestDateTimePattern(locale, template); return DateFormat.getBestDateTimePattern(locale, template);

View File

@ -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<Long, ViewHolder> 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 <T> the header view holder
*/
public interface StickyHeaderAdapter<T extends ViewHolder> {
/**
* 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);
}
}