Fade animation when temporarily hiding the floating header
Change-Id: I29cae2ef7b8d537205f413b68bebe9cd0ecebd8b
This commit is contained in:
parent
8c414b48d9
commit
9879659df7
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue