From 9815851bb96268a06d87cb29f48cab9f7ca1b08c Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 22 Oct 2021 23:35:51 -0300 Subject: [PATCH] Fix various issues with conversation animation. --- .../conversation/ConversationAdapter.java | 58 +++++---- .../conversation/ConversationFragment.java | 103 ++++----------- .../mutiselect/MultiselectItemAnimator.kt | 119 ++++++++++++------ .../main/res/layout/conversation_fragment.xml | 6 +- 4 files changed, 141 insertions(+), 145 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java index 25b5c3d42..dfcaf8aaa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java @@ -97,7 +97,7 @@ public class ConversationAdapter private static final int MESSAGE_TYPE_INCOMING_TEXT = 3; private static final int MESSAGE_TYPE_UPDATE = 4; private static final int MESSAGE_TYPE_HEADER = 5; - private static final int MESSAGE_TYPE_FOOTER = 6; + public static final int MESSAGE_TYPE_FOOTER = 6; private static final int MESSAGE_TYPE_PLACEHOLDER = 7; private static final int PAYLOAD_TIMESTAMP = 0; @@ -118,13 +118,14 @@ public class ConversationAdapter private String searchQuery; private ConversationMessage recordToPulse; - private View headerView; + private View typingView; private View footerView; private PagingController pagingController; private boolean hasWallpaper; private boolean isMessageRequestAccepted; private ConversationMessage inlineContent; private Colorizer colorizer; + private boolean isTypingViewEnabled; ConversationAdapter(@NonNull Context context, @NonNull LifecycleOwner lifecycleOwner, @@ -221,6 +222,7 @@ public class ConversationAdapter v.setLayoutParams(new FrameLayout.LayoutParams(1, ViewUtil.dpToPx(100))); return new PlaceholderViewHolder(v); case MESSAGE_TYPE_HEADER: + return new HeaderViewHolder(CachedInflater.from(parent.getContext()).inflate(R.layout.cursor_adapter_header_footer_view, parent, false)); case MESSAGE_TYPE_FOOTER: return new HeaderFooterViewHolder(CachedInflater.from(parent.getContext()).inflate(R.layout.cursor_adapter_header_footer_view, parent, false)); default: @@ -293,7 +295,7 @@ public class ConversationAdapter } break; case MESSAGE_TYPE_HEADER: - ((HeaderFooterViewHolder) holder).bind(headerView); + ((HeaderViewHolder) holder).bind(typingView); break; case MESSAGE_TYPE_FOOTER: ((HeaderFooterViewHolder) holder).bind(footerView); @@ -303,17 +305,14 @@ public class ConversationAdapter @Override public int getItemCount() { - boolean hasHeader = headerView != null; boolean hasFooter = footerView != null; - return super.getItemCount() + fastRecords.size() + (hasHeader ? 1 : 0) + (hasFooter ? 1 : 0); + return super.getItemCount() + fastRecords.size() + (isTypingViewEnabled ? 1 : 0) + (hasFooter ? 1 : 0); } @Override public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) { if (holder instanceof ConversationViewHolder) { ((ConversationViewHolder) holder).getBindable().unbind(); - } else if (holder instanceof HeaderFooterViewHolder) { - ((HeaderFooterViewHolder) holder).unbind(); } } @@ -465,25 +464,24 @@ public class ConversationAdapter /** * Sets the view that appears at the bottom of the list (because the list is reversed). */ - void setHeaderView(@Nullable View view) { - boolean hadHeader = hasHeader(); - - this.headerView = view; - - if (view == null && hadHeader) { - notifyItemRemoved(0); - } else if (view != null && hadHeader) { - notifyItemChanged(0); - } else if (view != null) { - notifyItemInserted(0); - } + void setTypingView(@NonNull View view) { + this.typingView = view; } - /** - * Returns the header view, if one was set. - */ - @Nullable View getHeaderView() { - return headerView; + void setTypingViewEnabled(boolean isTypingViewEnabled) { + if (typingView == null && isTypingViewEnabled) { + throw new IllegalStateException("Must set header before enabling."); + } + + if (this.isTypingViewEnabled && !isTypingViewEnabled) { + this.isTypingViewEnabled = false; + notifyItemRemoved(0); + } else if (this.isTypingViewEnabled) { + notifyItemChanged(0); + } else if (isTypingViewEnabled) { + this.isTypingViewEnabled = true; + notifyItemInserted(0); + } } /** @@ -604,8 +602,8 @@ public class ConversationAdapter } } - private boolean hasHeader() { - return headerView != null; + public boolean hasHeader() { + return isTypingViewEnabled; } public boolean hasFooter() { @@ -749,7 +747,7 @@ public class ConversationAdapter } } - private static class HeaderFooterViewHolder extends RecyclerView.ViewHolder { + public static class HeaderFooterViewHolder extends RecyclerView.ViewHolder { private ViewGroup container; @@ -778,6 +776,12 @@ public class ConversationAdapter } } + public static class HeaderViewHolder extends HeaderFooterViewHolder { + HeaderViewHolder(@NonNull View itemView) { + super(itemView); + } + } + private static class PlaceholderViewHolder extends RecyclerView.ViewHolder { PlaceholderViewHolder(@NonNull View itemView) { super(itemView); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 2c83c0551..211cef058 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -25,7 +25,6 @@ import android.content.res.Configuration; import android.graphics.Rect; import android.net.Uri; import android.os.AsyncTask; -import android.os.Build; import android.os.Bundle; import android.text.SpannableStringBuilder; import android.text.TextUtils; @@ -36,7 +35,6 @@ import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; -import android.view.Window; import android.view.animation.Animation; import android.view.animation.AnimationUtils; import android.widget.FrameLayout; @@ -55,7 +53,9 @@ import androidx.core.app.ActivityOptionsCompat; import androidx.core.text.HtmlCompat; import androidx.core.view.ViewCompat; import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.Observer; +import androidx.lifecycle.Transformations; import androidx.lifecycle.ViewModelProviders; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -154,11 +154,11 @@ import org.thoughtcrime.securesms.util.StickyHeaderDecoration; import org.thoughtcrime.securesms.util.Stopwatch; import org.thoughtcrime.securesms.util.StorageUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.WindowUtil; import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; import org.thoughtcrime.securesms.util.views.AdaptiveActionsToolbar; import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; @@ -223,7 +223,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect private ConversationUpdateTick conversationUpdateTick; private MultiselectItemDecoration multiselectItemDecoration; - private int listSubmissionCount = 0; + private boolean initialDataLoaded = false; public static void prepare(@NonNull Context context) { FrameLayout parent = new FrameLayout(context); @@ -266,7 +266,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect } else { return Util.hasItems(adapter.getSelectedItems()); } - }, () -> listSubmissionCount < 2, () -> list.canScrollVertically(1) || list.canScrollVertically(-1)); + }, () -> !initialDataLoaded, () -> list.canScrollVertically(1) || list.canScrollVertically(-1)); multiselectItemDecoration = new MultiselectItemDecoration(requireContext(), () -> conversationViewModel.getWallpaper().getValue()); @@ -301,19 +301,31 @@ public class ConversationFragment extends LoggingFragment implements Multiselect this::onViewHolderPositionTranslated ).attachToRecyclerView(list); - setupListLayoutListeners(); giphyMp4ProjectionRecycler = initializeGiphyMp4(); this.groupViewModel = ViewModelProviders.of(requireActivity(), new ConversationGroupViewModel.Factory()).get(ConversationGroupViewModel.class); this.messageCountsViewModel = ViewModelProviders.of(requireActivity()).get(MessageCountsViewModel.class); this.conversationViewModel = ViewModelProviders.of(requireActivity(), new ConversationViewModel.Factory()).get(ConversationViewModel.class); + MutableLiveData hasSubmittedNonEmptyList = new MutableLiveData<>(false); + LiveData hasMessagesInThread = Transformations.map(conversationViewModel.getConversationMetadata(), c -> c.getThreadSize() > 0); + + LiveData playAnimations = LiveDataUtil.combineLatest(hasSubmittedNonEmptyList, hasMessagesInThread, (a, b) -> { + if (a && b) { + return true; + } else { + return !b; + } + }); + + playAnimations.observe(getViewLifecycleOwner(), p -> initialDataLoaded = p); + conversationViewModel.getChatColors().observe(getViewLifecycleOwner(), recyclerViewColorizer::setChatColors); conversationViewModel.getMessages().observe(getViewLifecycleOwner(), messages -> { ConversationAdapter adapter = getListAdapter(); if (adapter != null) { getListAdapter().submitList(messages, () -> { - listSubmissionCount++; + hasSubmittedNonEmptyList.postValue(!messages.isEmpty()); }); } }); @@ -382,35 +394,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect reactionsShade.setVisibility(View.GONE); } - private void setupListLayoutListeners() { - list.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> setListVerticalTranslation()); - - list.addOnChildAttachStateChangeListener(new RecyclerView.OnChildAttachStateChangeListener() { - @Override - public void onChildViewAttachedToWindow(@NonNull View view) { - setListVerticalTranslation(); - } - - @Override - public void onChildViewDetachedFromWindow(@NonNull View view) { - setListVerticalTranslation(); - } - }); - } - - private void setListVerticalTranslation() { - if (list.canScrollVertically(1) || list.canScrollVertically(-1) || list.getChildCount() == 0) { - list.setTranslationY(0); - reactionsShade.setTranslationY(0); - list.setOverScrollMode(RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS); - } else { - int chTop = list.getChildAt(list.getChildCount() - 1).getTop(); - list.setTranslationY(Math.min(0, -chTop)); - reactionsShade.setTranslationY(Math.min(0, -chTop)); - list.setOverScrollMode(RecyclerView.OVER_SCROLL_NEVER); - } - } - private void updateConversationItemTimestamps() { ConversationAdapter conversationAdapter = getListAdapter(); if (conversationAdapter != null) { @@ -736,44 +719,22 @@ public class ConversationFragment extends LoggingFragment implements Multiselect typingView.setTypists(GlideApp.with(ConversationFragment.this), recipients, resolved.isGroup(), resolved.hasWallpaper()); ConversationAdapter adapter = getListAdapter(); - - if (adapter.getHeaderView() != null && adapter.getHeaderView() != typingView) { - Log.i(TAG, "Skipping typing indicator -- the header slot is occupied."); - return; - } + adapter.setTypingView(typingView); if (recipients.size() > 0) { if (!isTypingIndicatorShowing() && isAtBottom()) { - Context context = requireContext(); - list.setVerticalScrollBarEnabled(false); - list.post(() -> { - if (!isReacting) { - getListLayoutManager().smoothScrollToPosition(context, 0, 250); - } - }); - list.postDelayed(() -> list.setVerticalScrollBarEnabled(true), 300); - adapter.setHeaderView(typingView); + adapter.setTypingViewEnabled(true); + list.scrollToPosition(0); } else { - if (isTypingIndicatorShowing()) { - adapter.setHeaderView(typingView); - } else { - adapter.setHeaderView(typingView); - } + adapter.setTypingViewEnabled(true); } } else { if (isTypingIndicatorShowing() && getListLayoutManager().findFirstCompletelyVisibleItemPosition() == 0 && getListLayoutManager().getItemCount() > 1 && !replacedByIncomingMessage) { - if (!isReacting) { - getListLayoutManager().smoothScrollToPosition(requireContext(), 1, 250); - } - list.setVerticalScrollBarEnabled(false); - list.postDelayed(() -> { - adapter.setHeaderView(null); - list.post(() -> list.setVerticalScrollBarEnabled(true)); - }, 200); + adapter.setTypingViewEnabled(false); } else if (!replacedByIncomingMessage) { - adapter.setHeaderView(null); + adapter.setTypingViewEnabled(false); } else { - adapter.setHeaderView(null); + adapter.setTypingViewEnabled(false); } } }); @@ -1023,17 +984,10 @@ public class ConversationFragment extends LoggingFragment implements Multiselect Toast.LENGTH_LONG).show(); } - private void clearHeaderIfNotTyping(ConversationAdapter adapter) { - if (adapter.getHeaderView() != typingView) { - adapter.setHeaderView(null); - } - } - public long stageOutgoingMessage(OutgoingMediaMessage message) { MessageRecord messageRecord = MmsDatabase.readerFor(message, threadId).getCurrent(); if (getListAdapter() != null) { - clearHeaderIfNotTyping(getListAdapter()); setLastSeen(0); list.post(() -> list.scrollToPosition(0)); } @@ -1045,7 +999,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect MessageRecord messageRecord = SmsDatabase.readerFor(message, threadId, messageId).getCurrent(); if (getListAdapter() != null) { - clearHeaderIfNotTyping(getListAdapter()); setLastSeen(0); list.post(() -> list.scrollToPosition(0)); } @@ -1068,8 +1021,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect setLastSeen(conversation.getLastSeen()); - clearHeaderIfNotTyping(adapter); - listener.onCursorChanged(); conversationScrollListener.onScrolled(list, 0, 0); @@ -1113,7 +1064,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect } private boolean isTypingIndicatorShowing() { - return getListAdapter().getHeaderView() == typingView; + return getListAdapter().hasHeader(); } public void onSearchQueryUpdated(@Nullable String query) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemAnimator.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemAnimator.kt index 64a77d3de..e1190f413 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemAnimator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemAnimator.kt @@ -4,6 +4,7 @@ import android.animation.ObjectAnimator import android.animation.ValueAnimator import androidx.core.animation.doOnEnd import androidx.recyclerview.widget.RecyclerView +import org.thoughtcrime.securesms.conversation.ConversationAdapter /** * Class for managing the triggering of item animations (here in the form of decoration redraws) whenever @@ -17,26 +18,33 @@ class MultiselectItemAnimator( private val isParentFilled: () -> Boolean ) : RecyclerView.ItemAnimator() { - private data class SlideInfo( - val viewHolder: RecyclerView.ViewHolder, - val operation: Operation - ) - private enum class Operation { ADD, CHANGE } - private val pendingSlideAnimations: MutableSet = mutableSetOf() + private val pendingSlideAnimations: MutableSet = mutableSetOf() + private var pendingTypingViewSlideOut: RecyclerView.ViewHolder? = null - private val slideAnimations: MutableMap = mutableMapOf() + private val slideAnimations: MutableMap = mutableMapOf() override fun animateDisappearance(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo?): Boolean { + if (viewHolder is ConversationAdapter.HeaderViewHolder && pendingTypingViewSlideOut == null) { + pendingTypingViewSlideOut = viewHolder + dispatchAnimationStarted(viewHolder) + return true + } + dispatchAnimationFinished(viewHolder) return false } override fun animateAppearance(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo?, postLayoutInfo: ItemHolderInfo): Boolean { + if (viewHolder.absoluteAdapterPosition > 1) { + dispatchAnimationFinished(viewHolder) + return false + } + return animateSlide(viewHolder, preLayoutInfo, postLayoutInfo, Operation.ADD) } @@ -46,7 +54,12 @@ class MultiselectItemAnimator( return false } - if (operation == Operation.CHANGE && !isParentFilled()) { + if (operation == Operation.CHANGE && !isParentFilled() || slideAnimations.containsKey(viewHolder)) { + dispatchAnimationFinished(viewHolder) + return false + } + + if (slideAnimations.containsKey(viewHolder)) { dispatchAnimationFinished(viewHolder) return false } @@ -57,22 +70,31 @@ class MultiselectItemAnimator( preLayoutInfo.top - postLayoutInfo.top }.toFloat() - viewHolder.itemView.translationY = translationY - val slideInfo = SlideInfo(viewHolder, operation) - - if (slideAnimations.filterKeys { slideInfo.viewHolder == viewHolder }.isNotEmpty()) { + if (translationY == 0f) { dispatchAnimationFinished(viewHolder) return false } - pendingSlideAnimations.add(slideInfo) + viewHolder.itemView.translationY = translationY + + pendingSlideAnimations.add(viewHolder) dispatchAnimationStarted(viewHolder) return true } override fun animatePersistence(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo): Boolean { - dispatchAnimationFinished(viewHolder) - return false + val isInMultiSelectMode = isInMultiSelectMode() + return if (!isInMultiSelectMode) { + if (pendingSlideAnimations.contains(viewHolder) || slideAnimations.containsKey(viewHolder)) { + dispatchAnimationFinished(viewHolder) + false + } else { + animateSlide(viewHolder, preLayoutInfo, postLayoutInfo, Operation.CHANGE) + } + } else { + dispatchAnimationFinished(viewHolder) + false + } } override fun animateChange(oldHolder: RecyclerView.ViewHolder, newHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo): Boolean { @@ -80,35 +102,28 @@ class MultiselectItemAnimator( dispatchAnimationFinished(oldHolder) } - val isInMultiSelectMode = isInMultiSelectMode() - return if (!isInMultiSelectMode) { - if (preLayoutInfo.top == postLayoutInfo.top) { - dispatchAnimationFinished(newHolder) - false - } else { - animateSlide(newHolder, preLayoutInfo, postLayoutInfo, Operation.CHANGE) - } - } else { - dispatchAnimationFinished(newHolder) - false - } + return animatePersistence(newHolder, preLayoutInfo, postLayoutInfo) } override fun runPendingAnimations() { runPendingSlideAnimations() + runPendingSlideOutAnimation() } private fun runPendingSlideAnimations() { - for (slideInfo in pendingSlideAnimations) { - val animator = ObjectAnimator.ofFloat(slideInfo.viewHolder.itemView, "translationY", 0f) - slideAnimations[slideInfo] = animator + for (viewHolder in pendingSlideAnimations) { + val animator = ObjectAnimator.ofFloat(viewHolder.itemView, "translationY", 0f) + slideAnimations[viewHolder]?.cancel() + slideAnimations[viewHolder] = animator animator.duration = 150L animator.addUpdateListener { - (slideInfo.viewHolder.itemView.parent as RecyclerView?)?.invalidate() + (viewHolder.itemView.parent as RecyclerView?)?.invalidate() } animator.doOnEnd { - dispatchAnimationFinished(slideInfo.viewHolder) - slideAnimations.remove(slideInfo) + viewHolder.itemView.translationY = 0f + slideAnimations.remove(viewHolder) + dispatchAnimationFinished(viewHolder) + dispatchFinishedWhenDone() } animator.start() } @@ -116,6 +131,29 @@ class MultiselectItemAnimator( pendingSlideAnimations.clear() } + private fun runPendingSlideOutAnimation() { + val viewHolder = pendingTypingViewSlideOut + if (viewHolder != null) { + pendingTypingViewSlideOut = null + slideAnimations[viewHolder]?.cancel() + + val animator = ObjectAnimator.ofFloat(viewHolder.itemView, "translationY", viewHolder.itemView.height.toFloat()) + + slideAnimations[viewHolder] = animator + animator.duration = 150L + animator.addUpdateListener { + (viewHolder.itemView.parent as RecyclerView?)?.invalidate() + } + animator.doOnEnd { + viewHolder.itemView.translationY = 0f + slideAnimations.remove(viewHolder) + dispatchAnimationFinished(viewHolder) + dispatchFinishedWhenDone() + } + animator.start() + } + } + override fun endAnimation(item: RecyclerView.ViewHolder) { endSlideAnimation(item) } @@ -135,15 +173,16 @@ class MultiselectItemAnimator( } private fun endSlideAnimation(item: RecyclerView.ViewHolder) { - val selections = slideAnimations.filter { (k, _) -> k.viewHolder == item } - selections.forEach { (k, v) -> - v.end() - slideAnimations.remove(k) - } + slideAnimations[item]?.cancel() } fun endSlideAnimations() { - slideAnimations.values.forEach { it.end() } - slideAnimations.clear() + slideAnimations.values.forEach { it.cancel() } + } + + private fun dispatchFinishedWhenDone() { + if (!isRunning) { + dispatchAnimationsFinished() + } } } diff --git a/app/src/main/res/layout/conversation_fragment.xml b/app/src/main/res/layout/conversation_fragment.xml index 53b58c938..ceea46422 100644 --- a/app/src/main/res/layout/conversation_fragment.xml +++ b/app/src/main/res/layout/conversation_fragment.xml @@ -26,12 +26,14 @@ + android:scrollbars="vertical" + android:overScrollMode="ifContentScrolls" + app:layout_constraintTop_toTopOf="parent" />