From d46d3b72c885c998606e0244f224276223161a73 Mon Sep 17 00:00:00 2001 From: Moxie Marlinspike Date: Wed, 25 Jan 2017 16:38:36 -0800 Subject: [PATCH] Make the sticky date header only visible during scroll // FREEBIE --- res/anim/slide_from_top.xml | 4 + res/layout/conversation_fragment.xml | 19 ++++- .../ContactSelectionListFragment.java | 2 +- .../securesms/ConversationAdapter.java | 14 ++-- .../securesms/ConversationFragment.java | 75 ++++++++++++++++++- .../contacts/ContactSelectionListAdapter.java | 7 +- .../util/StickyHeaderDecoration.java | 27 ++++--- 7 files changed, 118 insertions(+), 30 deletions(-) diff --git a/res/anim/slide_from_top.xml b/res/anim/slide_from_top.xml index ef75904da2..761b9151dc 100644 --- a/res/anim/slide_from_top.xml +++ b/res/anim/slide_from_top.xml @@ -1,5 +1,9 @@ + + + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> + + protected static class HeaderViewHolder extends RecyclerView.ViewHolder { - private TextView textView; + protected TextView textView; public HeaderViewHolder(View itemView) { super(itemView); textView = ViewUtil.findById(itemView, R.id.text); } + public HeaderViewHolder(TextView textView) { + super(textView); + this.textView = textView; + } + public void setText(CharSequence text) { textView.setText(text); } @@ -283,6 +288,8 @@ public class ConversationAdapter @Override public long getHeaderId(int position) { + if (!isActiveCursor()) return -1; + Cursor cursor = getCursorAtPositionOrThrow(position); MessageRecord record = getMessageRecord(cursor); @@ -300,10 +307,5 @@ public class ConversationAdapter Cursor cursor = getCursorAtPositionOrThrow(position); viewHolder.setText(DateUtils.getRelativeDate(getContext(), locale, getMessageRecord(cursor).getDateReceived())); } - - @Override - public boolean isActive() { - return isActiveCursor(); - } } diff --git a/src/org/thoughtcrime/securesms/ConversationFragment.java b/src/org/thoughtcrime/securesms/ConversationFragment.java index 9193587d67..8cf9df1f9e 100644 --- a/src/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/org/thoughtcrime/securesms/ConversationFragment.java @@ -47,8 +47,10 @@ import android.view.ViewGroup; import android.view.Window; import android.view.animation.Animation; import android.view.animation.AnimationUtils; +import android.widget.TextView; import android.widget.Toast; +import org.thoughtcrime.securesms.ConversationAdapter.HeaderViewHolder; import org.thoughtcrime.securesms.ConversationAdapter.ItemClickListener; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.database.DatabaseFactory; @@ -94,6 +96,7 @@ public class ConversationFragment extends Fragment private View loadMoreView; private View composeDivider; private View scrollToBottomButton; + private TextView scrollDateHeader; @Override public void onCreate(Bundle icicle) { @@ -108,6 +111,7 @@ public class ConversationFragment extends Fragment list = ViewUtil.findById(view, android.R.id.list); composeDivider = ViewUtil.findById(view, R.id.compose_divider); scrollToBottomButton = ViewUtil.findById(view, R.id.scroll_to_bottom_button); + scrollDateHeader = ViewUtil.findById(view, R.id.scroll_date_header); scrollToBottomButton.setOnClickListener(new OnClickListener() { @Override @@ -185,7 +189,7 @@ public class ConversationFragment extends Fragment if (this.recipients != null && this.threadId != -1) { ConversationAdapter adapter = new ConversationAdapter(getActivity(), masterSecret, locale, selectionClickListener, null, this.recipients); list.setAdapter(adapter); - list.addItemDecoration(new StickyHeaderDecoration(adapter, false)); + list.addItemDecoration(new StickyHeaderDecoration(adapter, false, false)); getLoaderManager().restartLoader(0, Bundle.EMPTY, this); } @@ -413,15 +417,18 @@ public class ConversationFragment extends Fragment private class ConversationScrollListener extends OnScrollListener { - private final Animation scrollButtonInAnimation; - private final Animation scrollButtonOutAnimation; + private final Animation scrollButtonInAnimation; + private final Animation scrollButtonOutAnimation; + private final ConversationDateHeader conversationDateHeader; private boolean wasAtBottom = true; private boolean wasAtZoomScrollHeight = false; + private long lastPositionId = -1; ConversationScrollListener(@NonNull Context context) { this.scrollButtonInAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_in); this.scrollButtonOutAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_out); + this.conversationDateHeader = new ConversationDateHeader(context, scrollDateHeader); this.scrollButtonInAnimation.setDuration(100); this.scrollButtonOutAnimation.setDuration(50); @@ -431,6 +438,7 @@ public class ConversationFragment extends Fragment public void onScrolled(final RecyclerView rv, final int dx, final int dy) { boolean currentlyAtBottom = isAtBottom(); boolean currentlyAtZoomScrollHeight = isAtZoomScrollHeight(); + int positionId = getHeaderPositionId(); if (currentlyAtBottom && !wasAtBottom) { ViewUtil.fadeOut(composeDivider, 50, View.INVISIBLE); @@ -443,8 +451,22 @@ public class ConversationFragment extends Fragment ViewUtil.animateIn(scrollToBottomButton, scrollButtonInAnimation); } + if (positionId != lastPositionId) { + bindScrollHeader(conversationDateHeader, positionId); + } + wasAtBottom = currentlyAtBottom; wasAtZoomScrollHeight = currentlyAtZoomScrollHeight; + lastPositionId = positionId; + } + + @Override + public void onScrollStateChanged(RecyclerView recyclerView, int newState) { + if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { + conversationDateHeader.show(); + } else if (newState == RecyclerView.SCROLL_STATE_IDLE) { + conversationDateHeader.hide(); + } } private boolean isAtBottom() { @@ -460,6 +482,14 @@ public class ConversationFragment extends Fragment private boolean isAtZoomScrollHeight() { return ((LinearLayoutManager) list.getLayoutManager()).findFirstCompletelyVisibleItemPosition() > 4; } + + private int getHeaderPositionId() { + return ((LinearLayoutManager)list.getLayoutManager()).findLastVisibleItemPosition(); + } + + private void bindScrollHeader(HeaderViewHolder headerViewHolder, int positionId) { + ((ConversationAdapter)list.getAdapter()).onBindHeaderViewHolder(headerViewHolder, positionId); + } } private class ConversationFragmentItemClickListener implements ItemClickListener { @@ -553,4 +583,43 @@ public class ConversationFragment extends Fragment return false; } } + + private static class ConversationDateHeader extends HeaderViewHolder { + + private final Animation animateIn; + private final Animation animateOut; + + private boolean pendingHide = false; + + private ConversationDateHeader(Context context, TextView textView) { + super(textView); + this.animateIn = AnimationUtils.loadAnimation(context, R.anim.slide_from_top); + this.animateOut = AnimationUtils.loadAnimation(context, R.anim.slide_to_top); + + this.animateIn.setDuration(100); + this.animateOut.setDuration(100); + } + + public void show() { + if (pendingHide) { + pendingHide = false; + } else { + ViewUtil.animateIn(textView, animateIn); + } + } + + public void hide() { + pendingHide = true; + + textView.postDelayed(new Runnable() { + @Override + public void run() { + if (pendingHide) { + pendingHide = false; + ViewUtil.animateOut(textView, animateOut, View.GONE); + } + } + }, 400); + } + } } diff --git a/src/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java b/src/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java index 6820fb7cdf..92aa9eae99 100644 --- a/src/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java +++ b/src/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java @@ -103,6 +103,8 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter getSelectedContacts() { return selectedContacts; } diff --git a/src/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java b/src/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java index 4291a90c1c..e0f1b23a09 100644 --- a/src/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java +++ b/src/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java @@ -7,7 +7,6 @@ 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.util.Log; import android.view.View; import android.view.ViewGroup; @@ -22,17 +21,21 @@ public class StickyHeaderDecoration extends RecyclerView.ItemDecoration { private static final String TAG = StickyHeaderDecoration.class.getName(); + private static final long NO_HEADER_ID = -1L; + private final Map headerCache; private final StickyHeaderAdapter adapter; private final boolean renderInline; + private boolean sticky; /** * @param adapter the sticky header adapter to use */ - public StickyHeaderDecoration(StickyHeaderAdapter adapter, boolean renderInline) { + public StickyHeaderDecoration(StickyHeaderAdapter adapter, boolean renderInline, boolean sticky) { this.adapter = adapter; this.headerCache = new HashMap<>(); this.renderInline = renderInline; + this.sticky = sticky; } /** @@ -42,9 +45,9 @@ public class StickyHeaderDecoration extends RecyclerView.ItemDecoration { public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { - int position = parent.getChildAdapterPosition(view); - + int position = parent.getChildAdapterPosition(view); int headerHeight = 0; + if (position != RecyclerView.NO_POSITION && hasHeader(parent, adapter, position)) { View header = getHeader(parent, adapter, position).itemView; headerHeight = getHeaderHeightForLayout(header); @@ -56,16 +59,14 @@ public class StickyHeaderDecoration extends RecyclerView.ItemDecoration { private boolean hasHeader(RecyclerView parent, StickyHeaderAdapter adapter, int adapterPos) { boolean isReverse = isReverseLayout(parent); - if (!adapter.isActive()) { - return false; - } - if (isReverse && adapterPos == ((RecyclerView.Adapter)adapter).getItemCount() - 1 || !isReverse && adapterPos == 0) { return true; } - int previous = adapterPos + (isReverse ? 1 : -1); - return adapter.getHeaderId(adapterPos) != adapter.getHeaderId(previous); + int previous = adapterPos + (isReverse ? 1 : -1); + long headerId = adapter.getHeaderId(adapterPos); + + return headerId != NO_HEADER_ID && (headerId != adapter.getHeaderId(previous)); } private ViewHolder getHeader(RecyclerView parent, StickyHeaderAdapter adapter, int position) { @@ -109,7 +110,7 @@ public class StickyHeaderDecoration extends RecyclerView.ItemDecoration { final int adapterPos = parent.getChildAdapterPosition(child); - if (adapterPos != RecyclerView.NO_POSITION && ((layoutPos == 0 && adapter.isActive()) || hasHeader(parent, adapter, adapterPos))) { + if (adapterPos != RecyclerView.NO_POSITION && ((layoutPos == 0 && sticky) || hasHeader(parent, adapter, adapterPos))) { View header = getHeader(parent, adapter, adapterPos).itemView; c.save(); final int left = child.getLeft(); @@ -146,7 +147,7 @@ public class StickyHeaderDecoration extends RecyclerView.ItemDecoration { } } - top = Math.max(0, top); + if (sticky) top = Math.max(0, top); } return top; @@ -204,7 +205,5 @@ public class StickyHeaderDecoration extends RecyclerView.ItemDecoration { * @param position the header's item position */ void onBindHeaderViewHolder(T viewHolder, int position); - - boolean isActive(); } } \ No newline at end of file