From 9879659df75a5d13f10fe84d42ce1c0b8b3a5ce4 Mon Sep 17 00:00:00 2001 From: SpiritCroc Date: Sat, 14 May 2022 16:14:10 +0200 Subject: [PATCH] Fade animation when temporarily hiding the floating header Change-Id: I29cae2ef7b8d537205f413b68bebe9cd0ecebd8b --- .../StickyHeaderItemDecoration.kt | 120 ++++++++++++++++-- .../home/room/detail/TimelineFragment.kt | 8 +- 2 files changed, 115 insertions(+), 13 deletions(-) diff --git a/vector/src/main/java/de/spiritcroc/recyclerview/StickyHeaderItemDecoration.kt b/vector/src/main/java/de/spiritcroc/recyclerview/StickyHeaderItemDecoration.kt index 79370ba0ec..6423cee8fd 100644 --- a/vector/src/main/java/de/spiritcroc/recyclerview/StickyHeaderItemDecoration.kt +++ b/vector/src/main/java/de/spiritcroc/recyclerview/StickyHeaderItemDecoration.kt @@ -7,16 +7,23 @@ package de.spiritcroc.recyclerview * - not require EpoxyRecyclerView * - work with reverse layouts * - hide the currently overlaid header for a smoother animation without duplicate headers + * - ... */ import android.graphics.Canvas import android.view.View import android.view.ViewGroup +import android.view.ViewPropertyAnimator import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import com.airbnb.epoxy.EpoxyController import com.airbnb.epoxy.EpoxyModel +import com.airbnb.epoxy.EpoxyViewHolder import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.extensions.orTrue +import kotlin.math.abs + +const val FADE_DURATION = 200 abstract class StickyHeaderItemDecoration( private val epoxyController: EpoxyController, @@ -25,6 +32,17 @@ abstract class StickyHeaderItemDecoration( private var mStickyHeaderHeight: Int = 0 private var lastHeaderPos: Int? = null + private var lastFadeState: FadeState? = null + + data class FadeState( + val headerPos: Int, + val headerView: View, + val animatedView: View, + var shouldBeVisible: Boolean = true, + var animation: ViewPropertyAnimator? = null + ) + // An extra header view we still draw while it's fading out + private var oldFadingView: View? = null override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { super.onDrawOver(c, parent, state) @@ -45,7 +63,15 @@ abstract class StickyHeaderItemDecoration( val headerPos = getHeaderPositionForItem(topChildPosition) if (headerPos != RecyclerView.NO_POSITION) { - val currentHeader = getHeaderViewForItem(headerPos, parent) + val oldFadeState = lastFadeState + val newFadeState: FadeState = if (headerPos == oldFadeState?.headerPos) { + oldFadeState.copy() + } else { + val currentHeaderHolder = getHeaderViewHolderForItem(headerPos, parent) + oldFadeState?.let { onDeleteFadeState(it) } + FadeState(headerPos, currentHeaderHolder.itemView, getViewForFadeAnimation(currentHeaderHolder)) + } + val currentHeader = newFadeState.headerView fixLayoutSize(parent, currentHeader) val contactPoint = currentHeader.bottom @@ -54,23 +80,27 @@ abstract class StickyHeaderItemDecoration( val childInContactModel = childInContact?.let { epoxyController.adapter.getModelAtPosition(parent.getChildAdapterPosition(childInContact)) } val childBelowModel = childBelow?.let { epoxyController.adapter.getModelAtPosition(parent.getChildAdapterPosition(childBelow)) } - if (childInContact != null) { + val shouldBeVisible = if (childInContact != null) { if (isHeader(childInContactModel)) { updateOverlaidHeaders(parent, headerPos) + updateFadeAnimation(newFadeState) moveHeader(c, currentHeader, childInContact) return } - if (preventOverlay(childInContactModel) || preventOverlay(childBelowModel)) { - // Hide header temporarily - return - } + !(preventOverlay(childInContactModel) || preventOverlay(childBelowModel)) + } else { + // Header unhide + true } - // Un-hide views early, so we don't get flashing headers while scrolling - val overlaidHeaderPos: Int? = if (childBelow != childInContact && + newFadeState.shouldBeVisible = shouldBeVisible + + val overlaidHeaderPos: Int? = if (!shouldBeVisible || + // Un-hide views early, so we don't get flashing headers while scrolling + (childBelow != childInContact && childBelow != null && isHeader(childBelowModel) && - contactPoint - childBelow.bottom < (childBelow.bottom - childBelow.top)/8 + contactPoint - childBelow.bottom < (childBelow.bottom - childBelow.top)/8) ) { null } else { @@ -78,11 +108,75 @@ abstract class StickyHeaderItemDecoration( } updateOverlaidHeaders(parent, overlaidHeaderPos) + updateFadeAnimation(newFadeState) drawHeader(c, currentHeader) } else { // Show hidden header again updateOverlaidHeaders(parent, null) } + + // Fade out an old header view + oldFadingView?.let { + if (it.alpha == 0.0f) { + oldFadingView = null + } else { + drawHeader(c, it) + } + } + + // Keep invalidating while we are animating, so we can draw animation updates + if (oldFadingView != null || !lastFadeState?.hasTargetAlpha().orTrue()) { + parent.invalidate() + } + } + + private fun onDeleteFadeState(oldState: FadeState) { + oldState.animation?.cancel() + oldState.animatedView.alpha = 1.0f + } + + private fun updateFadeAnimation(newState: FadeState) { + val oldState = lastFadeState + if (oldState == null) { + newState.applyAlphaImmediate() + } else if (oldState.headerPos == newState.headerPos) { + if (oldState.shouldBeVisible != newState.shouldBeVisible) { + newState.startAlphaAnimation() + } + } else { + if (!oldState.shouldBeVisible && oldState.animatedView.alpha != 1.0f) { + // Keep drawing for soft fade out + oldFadingView = oldState.animatedView + } + newState.applyAlphaImmediate() + } + lastFadeState = newState + } + + private fun FadeState.targetAlpha(): Float { + return if (shouldBeVisible) 1.0f else 0.0f + } + + private fun FadeState.hasTargetAlpha(): Boolean { + return animatedView.alpha == targetAlpha() + } + + private fun FadeState.applyAlphaImmediate() { + animation?.cancel() + animation = null + animatedView.alpha = targetAlpha() + } + + private fun FadeState.startAlphaAnimation() { + animation?.cancel() + val targetAlpha = targetAlpha() + val currentAlpha = animatedView.alpha + val remainingAlpha = abs(targetAlpha - currentAlpha) + // Shorter duration if we just aborted a different fade animation, thus leaving us with less necessary alpha changes + val duration = (remainingAlpha * FADE_DURATION).toLong() + animation = animatedView.animate().alpha(targetAlpha()).setDuration(duration).apply { + start() + } } private fun updateOverlaidHeaders(parent: RecyclerView, headerPos: Int?) { @@ -113,13 +207,17 @@ abstract class StickyHeaderItemDecoration( return false } - open fun getHeaderViewForItem(headerPosition: Int, parent: RecyclerView): View { + open fun getHeaderViewHolderForItem(headerPosition: Int, parent: RecyclerView): EpoxyViewHolder { val viewHolder = epoxyController.adapter.onCreateViewHolder( parent, epoxyController.adapter.getItemViewType(headerPosition) ) epoxyController.adapter.onBindViewHolder(viewHolder, headerPosition) - return viewHolder.itemView + return viewHolder + } + + open fun getViewForFadeAnimation(holder: EpoxyViewHolder): View { + return holder.itemView } private fun drawHeader(c: Canvas, header: View) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index b9e66d988e..9238573cbd 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -1507,7 +1507,7 @@ class TimelineFragment @Inject constructor( return model is TimelineReadMarkerItem } - override fun getHeaderViewForItem(headerPosition: Int, parent: RecyclerView): View { + override fun getHeaderViewHolderForItem(headerPosition: Int, parent: RecyclerView): EpoxyViewHolder { // Same as super val viewHolder = timelineEventController.adapter.onCreateViewHolder( parent, @@ -1519,7 +1519,7 @@ class TimelineFragment @Inject constructor( // We want to hide the separator line for floating dates (viewHolder.holder as? DaySeparatorItem.Holder)?.let { DaySeparatorItem.asFloatingDate(it) } - return viewHolder.itemView + return viewHolder } // While the header has a sticky overlay, only hide its text, not the separator lines @@ -1532,6 +1532,10 @@ class TimelineFragment @Inject constructor( } return false } + + override fun getViewForFadeAnimation(holder: EpoxyViewHolder): View { + return (holder.holder as? DaySeparatorItem.Holder)?.dayTextView ?: super.getViewForFadeAnimation(holder) + } } ) }