Fade animation when temporarily hiding the floating header

Change-Id: I29cae2ef7b8d537205f413b68bebe9cd0ecebd8b
This commit is contained in:
SpiritCroc 2022-05-14 16:14:10 +02:00
parent 8c414b48d9
commit 9879659df7
2 changed files with 115 additions and 13 deletions

View File

@ -7,16 +7,23 @@ package de.spiritcroc.recyclerview
* - not require EpoxyRecyclerView * - not require EpoxyRecyclerView
* - work with reverse layouts * - work with reverse layouts
* - hide the currently overlaid header for a smoother animation without duplicate headers * - hide the currently overlaid header for a smoother animation without duplicate headers
* - ...
*/ */
import android.graphics.Canvas import android.graphics.Canvas
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewPropertyAnimator
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyController import com.airbnb.epoxy.EpoxyController
import com.airbnb.epoxy.EpoxyModel 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.orFalse
import org.matrix.android.sdk.api.extensions.orTrue
import kotlin.math.abs
const val FADE_DURATION = 200
abstract class StickyHeaderItemDecoration( abstract class StickyHeaderItemDecoration(
private val epoxyController: EpoxyController, private val epoxyController: EpoxyController,
@ -25,6 +32,17 @@ abstract class StickyHeaderItemDecoration(
private var mStickyHeaderHeight: Int = 0 private var mStickyHeaderHeight: Int = 0
private var lastHeaderPos: Int? = null 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) { override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDrawOver(c, parent, state) super.onDrawOver(c, parent, state)
@ -45,7 +63,15 @@ abstract class StickyHeaderItemDecoration(
val headerPos = getHeaderPositionForItem(topChildPosition) val headerPos = getHeaderPositionForItem(topChildPosition)
if (headerPos != RecyclerView.NO_POSITION) { 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) fixLayoutSize(parent, currentHeader)
val contactPoint = currentHeader.bottom val contactPoint = currentHeader.bottom
@ -54,23 +80,27 @@ abstract class StickyHeaderItemDecoration(
val childInContactModel = childInContact?.let { epoxyController.adapter.getModelAtPosition(parent.getChildAdapterPosition(childInContact)) } val childInContactModel = childInContact?.let { epoxyController.adapter.getModelAtPosition(parent.getChildAdapterPosition(childInContact)) }
val childBelowModel = childBelow?.let { epoxyController.adapter.getModelAtPosition(parent.getChildAdapterPosition(childBelow)) } val childBelowModel = childBelow?.let { epoxyController.adapter.getModelAtPosition(parent.getChildAdapterPosition(childBelow)) }
if (childInContact != null) { val shouldBeVisible = if (childInContact != null) {
if (isHeader(childInContactModel)) { if (isHeader(childInContactModel)) {
updateOverlaidHeaders(parent, headerPos) updateOverlaidHeaders(parent, headerPos)
updateFadeAnimation(newFadeState)
moveHeader(c, currentHeader, childInContact) moveHeader(c, currentHeader, childInContact)
return return
} }
if (preventOverlay(childInContactModel) || preventOverlay(childBelowModel)) { !(preventOverlay(childInContactModel) || preventOverlay(childBelowModel))
// Hide header temporarily } else {
return // Header unhide
} true
} }
// Un-hide views early, so we don't get flashing headers while scrolling newFadeState.shouldBeVisible = shouldBeVisible
val overlaidHeaderPos: Int? = if (childBelow != childInContact &&
val overlaidHeaderPos: Int? = if (!shouldBeVisible ||
// Un-hide views early, so we don't get flashing headers while scrolling
(childBelow != childInContact &&
childBelow != null && childBelow != null &&
isHeader(childBelowModel) && isHeader(childBelowModel) &&
contactPoint - childBelow.bottom < (childBelow.bottom - childBelow.top)/8 contactPoint - childBelow.bottom < (childBelow.bottom - childBelow.top)/8)
) { ) {
null null
} else { } else {
@ -78,11 +108,75 @@ abstract class StickyHeaderItemDecoration(
} }
updateOverlaidHeaders(parent, overlaidHeaderPos) updateOverlaidHeaders(parent, overlaidHeaderPos)
updateFadeAnimation(newFadeState)
drawHeader(c, currentHeader) drawHeader(c, currentHeader)
} else { } else {
// Show hidden header again // Show hidden header again
updateOverlaidHeaders(parent, null) 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?) { private fun updateOverlaidHeaders(parent: RecyclerView, headerPos: Int?) {
@ -113,13 +207,17 @@ abstract class StickyHeaderItemDecoration(
return false return false
} }
open fun getHeaderViewForItem(headerPosition: Int, parent: RecyclerView): View { open fun getHeaderViewHolderForItem(headerPosition: Int, parent: RecyclerView): EpoxyViewHolder {
val viewHolder = epoxyController.adapter.onCreateViewHolder( val viewHolder = epoxyController.adapter.onCreateViewHolder(
parent, parent,
epoxyController.adapter.getItemViewType(headerPosition) epoxyController.adapter.getItemViewType(headerPosition)
) )
epoxyController.adapter.onBindViewHolder(viewHolder, 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) { private fun drawHeader(c: Canvas, header: View) {

View File

@ -1507,7 +1507,7 @@ class TimelineFragment @Inject constructor(
return model is TimelineReadMarkerItem return model is TimelineReadMarkerItem
} }
override fun getHeaderViewForItem(headerPosition: Int, parent: RecyclerView): View { override fun getHeaderViewHolderForItem(headerPosition: Int, parent: RecyclerView): EpoxyViewHolder {
// Same as super // Same as super
val viewHolder = timelineEventController.adapter.onCreateViewHolder( val viewHolder = timelineEventController.adapter.onCreateViewHolder(
parent, parent,
@ -1519,7 +1519,7 @@ class TimelineFragment @Inject constructor(
// We want to hide the separator line for floating dates // We want to hide the separator line for floating dates
(viewHolder.holder as? DaySeparatorItem.Holder)?.let { DaySeparatorItem.asFloatingDate(it) } (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 // 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 return false
} }
override fun getViewForFadeAnimation(holder: EpoxyViewHolder): View {
return (holder.holder as? DaySeparatorItem.Holder)?.dayTextView ?: super.getViewForFadeAnimation(holder)
}
} }
) )
} }