diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/BodyBubbleLayoutTransition.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/BodyBubbleLayoutTransition.kt index c148fa9269..d5a2645506 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/BodyBubbleLayoutTransition.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/BodyBubbleLayoutTransition.kt @@ -23,7 +23,7 @@ class BodyBubbleLayoutTransition(bodyBubble: ConversationItemBodyBubble) : Layou val parentRecycler: RecyclerView? = bodyBubble.parent.parent as? RecyclerView try { - parentRecycler?.invalidateItemDecorations() + parentRecycler?.invalidate() } catch (e: IllegalStateException) { // In scroll or layout. Skip this frame. } 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 218738621e..2c83c05510 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -266,18 +266,9 @@ public class ConversationFragment extends LoggingFragment implements Multiselect } else { return Util.hasItems(adapter.getSelectedItems()); } - }, () -> listSubmissionCount < 2, multiselectPart -> { - ConversationAdapter adapter = getListAdapter(); - if (adapter == null) { - return false; - } else { - return adapter.getSelectedItems().contains(multiselectPart); - } - }, () -> list.canScrollVertically(1) || list.canScrollVertically(-1)); + }, () -> listSubmissionCount < 2, () -> list.canScrollVertically(1) || list.canScrollVertically(-1)); multiselectItemDecoration = new MultiselectItemDecoration(requireContext(), - () -> conversationViewModel.getWallpaper().getValue(), - multiselectItemAnimator::getSelectedProgressForPart, - multiselectItemAnimator::isInitialMultiSelectAnimation); + () -> conversationViewModel.getWallpaper().getValue()); list.setHasFixedSize(false); list.setLayoutManager(layoutManager); 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 08ae922b02..64a77d3de7 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 @@ -14,15 +14,9 @@ import androidx.recyclerview.widget.RecyclerView class MultiselectItemAnimator( private val isInMultiSelectMode: () -> Boolean, private val isLoadingInitialContent: () -> Boolean, - private val isPartSelected: (MultiselectPart) -> Boolean, private val isParentFilled: () -> Boolean ) : RecyclerView.ItemAnimator() { - private data class Selection( - val multiselectPart: MultiselectPart, - val viewHolder: RecyclerView.ViewHolder - ) - private data class SlideInfo( val viewHolder: RecyclerView.ViewHolder, val operation: Operation @@ -33,25 +27,10 @@ class MultiselectItemAnimator( CHANGE } - var isInitialMultiSelectAnimation: Boolean = true - private set - - private val selected: MutableSet = mutableSetOf() - - private val pendingSelectedAnimations: MutableSet = mutableSetOf() private val pendingSlideAnimations: MutableSet = mutableSetOf() - private val selectedAnimations: MutableMap = mutableMapOf() private val slideAnimations: MutableMap = mutableMapOf() - fun getSelectedProgressForPart(multiselectPart: MultiselectPart): Float { - return if (pendingSelectedAnimations.any { it.multiselectPart == multiselectPart }) { - 0f - } else { - selectedAnimations.filter { it.key.multiselectPart == multiselectPart }.values.firstOrNull()?.animatedFraction ?: 1f - } - } - override fun animateDisappearance(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo?): Boolean { dispatchAnimationFinished(viewHolder) return false @@ -102,52 +81,20 @@ class MultiselectItemAnimator( } val isInMultiSelectMode = isInMultiSelectMode() - if (!isInMultiSelectMode) { - selected.clear() - isInitialMultiSelectAnimation = true - return if (preLayoutInfo.top == postLayoutInfo.top) { + return if (!isInMultiSelectMode) { + if (preLayoutInfo.top == postLayoutInfo.top) { dispatchAnimationFinished(newHolder) false } else { animateSlide(newHolder, preLayoutInfo, postLayoutInfo, Operation.CHANGE) } - } - - var isAnimationStarted = false - val parts: MultiselectCollection? = (newHolder.itemView as? Multiselectable)?.conversationMessage?.multiselectCollection - - if (parts == null || parts.isExpired()) { - dispatchAnimationFinished(newHolder) - return false - } - - parts.toSet().forEach { part -> - val partIsSelected = isPartSelected(part) - if (selected.contains(part) && !partIsSelected) { - pendingSelectedAnimations.add(Selection(part, newHolder)) - selected.remove(part) - isAnimationStarted = true - } else if (!selected.contains(part) && partIsSelected) { - pendingSelectedAnimations.add(Selection(part, newHolder)) - selected.add(part) - isAnimationStarted = true - } else if (isInitialMultiSelectAnimation) { - pendingSelectedAnimations.add(Selection(part, newHolder)) - isAnimationStarted = true - } - } - - if (isAnimationStarted) { - dispatchAnimationStarted(newHolder) } else { dispatchAnimationFinished(newHolder) + false } - - return isAnimationStarted } override fun runPendingAnimations() { - runPendingSelectedAnimations() runPendingSlideAnimations() } @@ -157,7 +104,7 @@ class MultiselectItemAnimator( slideAnimations[slideInfo] = animator animator.duration = 150L animator.addUpdateListener { - (slideInfo.viewHolder.itemView.parent as RecyclerView?)?.invalidateItemDecorations() + (slideInfo.viewHolder.itemView.parent as RecyclerView?)?.invalidate() } animator.doOnEnd { dispatchAnimationFinished(slideInfo.viewHolder) @@ -169,51 +116,22 @@ class MultiselectItemAnimator( pendingSlideAnimations.clear() } - private fun runPendingSelectedAnimations() { - for (selection in pendingSelectedAnimations) { - val animator = ValueAnimator.ofFloat(0f, 1f) - selectedAnimations[selection] = animator - animator.duration = 150L - animator.addUpdateListener { - (selection.viewHolder.itemView.parent as RecyclerView?)?.invalidateItemDecorations() - } - animator.doOnEnd { - dispatchAnimationFinished(selection.viewHolder) - selectedAnimations.remove(selection) - isInitialMultiSelectAnimation = false - } - animator.start() - } - - pendingSelectedAnimations.clear() - } - override fun endAnimation(item: RecyclerView.ViewHolder) { - endSelectedAnimation(item) endSlideAnimation(item) } override fun endAnimations() { - endSelectedAnimations() endSlideAnimations() dispatchAnimationsFinished() } override fun isRunning(): Boolean { - return (selectedAnimations.values + slideAnimations.values).any { it.isRunning } + return slideAnimations.values.any { it.isRunning } } override fun onAnimationFinished(viewHolder: RecyclerView.ViewHolder) { val parent = (viewHolder.itemView.parent as? RecyclerView) - parent?.post { parent.invalidateItemDecorations() } - } - - private fun endSelectedAnimation(item: RecyclerView.ViewHolder) { - val selections = selectedAnimations.filter { (k, _) -> k.viewHolder == item } - selections.forEach { (k, v) -> - v.end() - selectedAnimations.remove(k) - } + parent?.post { parent.invalidate() } } private fun endSlideAnimation(item: RecyclerView.ViewHolder) { @@ -224,11 +142,6 @@ class MultiselectItemAnimator( } } - fun endSelectedAnimations() { - selectedAnimations.values.forEach { it.end() } - selectedAnimations.clear() - } - fun endSlideAnimations() { slideAnimations.values.forEach { it.end() } slideAnimations.clear() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt index a30a1323db..e915337bb9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.conversation.mutiselect +import android.animation.ValueAnimator import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas @@ -32,9 +33,7 @@ import java.lang.Integer.max */ class MultiselectItemDecoration( context: Context, - private val chatWallpaperProvider: () -> ChatWallpaper?, - private val selectedAnimationProgressProvider: (MultiselectPart) -> Float, - private val isInitialAnimation: () -> Boolean + private val chatWallpaperProvider: () -> ChatWallpaper? ) : RecyclerView.ItemDecoration(), DefaultLifecycleObserver { private val path = Path() @@ -54,6 +53,10 @@ class MultiselectItemDecoration( private val ultramarine30 = ContextCompat.getColor(context, R.color.core_ultramarine_33) private val ultramarine = ContextCompat.getColor(context, R.color.signal_accent_primary) + private val selectedParts: MutableSet = mutableSetOf() + private var enterExitAnimation: ValueAnimator? = null + private val multiselectPartAnimatorMap: MutableMap = mutableMapOf() + private var checkedBitmap: Bitmap? = null private var focusedItem: MultiselectPart? = null @@ -99,7 +102,34 @@ class MultiselectItemDecoration( style = Paint.Style.FILL } + private fun getCurrentSelection(parent: RecyclerView): Set { + return (parent.adapter as ConversationAdapter).selectedItems + } + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val currentSelection = getCurrentSelection(parent) + if (selectedParts.isEmpty() && currentSelection.isNotEmpty()) { + enterExitAnimation?.end() + enterExitAnimation = ValueAnimator.ofFloat(enterExitAnimation?.animatedFraction ?: 0f, 1f).apply { + duration = 150L + start() + } + } else if (selectedParts.isNotEmpty() && currentSelection.isEmpty()) { + enterExitAnimation?.end() + enterExitAnimation = ValueAnimator.ofFloat(enterExitAnimation?.animatedFraction ?: 1f, 0f).apply { + duration = 150L + start() + } + } + + if (view is Multiselectable) { + val parts = view.conversationMessage.multiselectCollection.toSet() + parts.forEach { updateMultiselectPartAnimator(currentSelection, it) } + } + + selectedParts.clear() + selectedParts.addAll(currentSelection) + outRect.setEmpty() updateChildOffsets(parent, view) } @@ -151,6 +181,8 @@ class MultiselectItemDecoration( canvas.restore() } + + drawChecks(parent, canvas, adapter) } /** @@ -160,9 +192,12 @@ class MultiselectItemDecoration( val adapter = parent.adapter as ConversationAdapter if (adapter.selectedItems.isEmpty()) { drawFocusShadeOverIfNecessary(canvas, parent) - return } + invalidateIfAnimatorsAreRunning(parent) + } + + private fun drawChecks(parent: RecyclerView, canvas: Canvas, adapter: ConversationAdapter) { val drawCircleBehindSelector = chatWallpaperProvider()?.isPhoto == true val multiselectChildren: Sequence = parent.children.filterIsInstance(Multiselectable::class.java) @@ -190,7 +225,7 @@ class MultiselectItemDecoration( drawPhotoCircle(canvas, parent, topBoundary, bottomBoundary) } - val alphaProgress = selectedAnimationProgressProvider(it) + val alphaProgress = selectedAnimationProgress(it) if (adapter.selectedItems.contains(it)) { drawUnselectedCircle(canvas, parent, topBoundary, bottomBoundary, 1f - alphaProgress) drawSelectedCircle(canvas, parent, topBoundary, bottomBoundary, alphaProgress) @@ -271,7 +306,6 @@ class MultiselectItemDecoration( val isLtr = ViewUtil.isLtr(child) if (adapter.selectedItems.isNotEmpty() && child is Multiselectable) { - val firstPart = child.conversationMessage.multiselectCollection.toSet().first() val target = child.getHorizontalTranslationTarget() if (target != null) { @@ -282,7 +316,7 @@ class MultiselectItemDecoration( } val translation: Float = if (isInitialAnimation()) { - max(0, gutter - start) * selectedAnimationProgressProvider(firstPart) + max(0, gutter - start) * (enterExitAnimation?.animatedFraction ?: 1f) } else { max(0, gutter - start).toFloat() } @@ -341,4 +375,62 @@ class MultiselectItemDecoration( canvas.restore() } } + + private fun isInitialAnimation(): Boolean { + return (enterExitAnimation?.animatedFraction ?: 0f) < 1f + } + + // This is reentrant + private fun updateMultiselectPartAnimator(currentSelection: Set, multiselectPart: MultiselectPart) { + val difference: Difference = getDifferenceForPart(currentSelection, multiselectPart) + val animator: ValueAnimator? = multiselectPartAnimatorMap[multiselectPart] + + when (difference) { + Difference.SAME -> Unit + Difference.ADDED -> { + val newAnimator = ValueAnimator.ofFloat(animator?.animatedFraction ?: 0f, 1f).apply { + duration = 150L + start() + } + animator?.end() + multiselectPartAnimatorMap[multiselectPart] = newAnimator + } + Difference.REMOVED -> { + val newAnimator = ValueAnimator.ofFloat(animator?.animatedFraction ?: 1f, 0f).apply { + duration = 150L + start() + } + animator?.end() + multiselectPartAnimatorMap[multiselectPart] = newAnimator + } + } + } + + private fun selectedAnimationProgress(multiselectPart: MultiselectPart): Float { + val animator = multiselectPartAnimatorMap[multiselectPart] + return animator?.animatedFraction ?: 1f + } + + private fun getDifferenceForPart(currentSelection: Set, multiselectPart: MultiselectPart): Difference { + val isSelected = currentSelection.contains(multiselectPart) + val wasSelected = selectedParts.contains(multiselectPart) + + return when { + isSelected && !wasSelected -> Difference.ADDED + !isSelected && wasSelected -> Difference.REMOVED + else -> Difference.SAME + } + } + + private fun invalidateIfAnimatorsAreRunning(parent: RecyclerView) { + if (enterExitAnimation?.isRunning == true || multiselectPartAnimatorMap.values.any { it.isRunning }) { + parent.invalidate() + } + } + + private enum class Difference { + REMOVED, + ADDED, + SAME + } }