Handle RecyclerView’s scroll events in custom TopAppBarScrollBehavior

A TopAppBarScrollBehavior defines how an app bar should behave when the content under it is scrolled.

class MyEnterAlwaysScrollBehavior(
override val state: TopAppBarState,
) : TopAppBarScrollBehavior {
override val isPinned: Boolean = false
override val snapAnimationSpec: AnimationSpec<Float>? = null
override val flingAnimationSpec: DecayAnimationSpec<Float>? = null
override val nestedScrollConnection: NestedScrollConnection =
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset {
if (available.y == 0f) return Offset.Zero
val previousOffset = state.heightOffset
val newOffset = if (available.y > 0) 0f else state.heightOffsetLimit
state.heightOffset = newOffset
return Offset(0f, newOffset – previousOffset)
}
}
}
class MyEnterAlwaysScrollBehavior(
override val state: TopAppBarState,
private val coroutineScope: CoroutineScope,
) : TopAppBarScrollBehavior {
override val isPinned: Boolean = false
override val snapAnimationSpec: AnimationSpec<Float>? = null
override val flingAnimationSpec: DecayAnimationSpec<Float>? = null
override val nestedScrollConnection: NestedScrollConnection =
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset {
if (available.y == 0f) return Offset.Zero
if (available.y > 0 && state.collapsedFraction == 0f) return Offset.Zero
if (available.y < 0 && state.collapsedFraction == 1f) return Offset.Zero
scrollAccumulation += abs(available.y)
if (scrollAccumulation < 100f) return Offset.Zero
scrollAccumulation = 0f
val previousOffset = state.heightOffset
val newOffset = if (available.y > 0) 0f else state.heightOffsetLimit
coroutineScope.launch {
animate(
initialValue = previousOffset,
targetValue = newOffset,
animationSpec = tween(durationMillis = 150),
) { value, _ ->
state.heightOffset = value
}
}
return Offset(0f, newOffset – previousOffset)
}
}
private var scrollAccumulation: Float = 0f
}
class MyEnterAlwaysScrollBehavior(
override val state: TopAppBarState,
private val coroutineScope: CoroutineScope,
) : TopAppBarScrollBehavior {
override val isPinned: Boolean = false
override val snapAnimationSpec: AnimationSpec<Float>? = null
override val flingAnimationSpec: DecayAnimationSpec<Float>? = null
override val nestedScrollConnection: NestedScrollConnection =
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset {
if (animationInProgress) return Offset.Zero
if (available.y == 0f) return Offset.Zero
if (available.y > 0 && state.collapsedFraction == 0f) return Offset.Zero
if (available.y < 0 && state.collapsedFraction == 1f) return Offset.Zero
scrollAccumulation += abs(available.y)
if (scrollAccumulation < 100f) return Offset.Zero
scrollAccumulation = 0f
val previousOffset = state.heightOffset
val newOffset = if (available.y > 0) 0f else state.heightOffsetLimit
coroutineScope.launch {
animationInProgress = true
try {
animate(
initialValue = previousOffset,
targetValue = newOffset,
animationSpec = tween(durationMillis = 150),
) { value, _ ->
state.heightOffset = value
}
} finally {
animationInProgress = false
}
}
return Offset(0f, newOffset – previousOffset)
}
}
private var scrollAccumulation: Float = 0f
private var animationInProgress: Boolean = false
}

Bonus: MyExitUntilCollapsedScrollBehavior

override fun onScrolled(
recyclerView: RecyclerView,
dx: Int,
dy: Int,
) {
// …
if (!recyclerView.canScrollVertically(-1)) {
dispatcher.dispatchPostScroll(
consumed = Offset(0f, -distanceY),
available = Offset.Zero,
source = UserInput,
)
}
}
class MyExitUntilCollapsedScrollBehavior(
override val state: TopAppBarState,
private val coroutineScope: CoroutineScope,
) : TopAppBarScrollBehavior {
override val isPinned: Boolean = false
override val snapAnimationSpec: AnimationSpec<Float>? = null
override val flingAnimationSpec: DecayAnimationSpec<Float>? = null
override val nestedScrollConnection: NestedScrollConnection =
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset {
if (animationInProgress) return Offset.Zero
if (available.y >= 0f) return Offset.Zero
if (available.y < 0f && state.collapsedFraction == 1f) return Offset.Zero
scrollAccumulation += abs(available.y)
if (scrollAccumulation < 100f) return Offset.Zero
scrollAccumulation = 0f
val previousOffset = state.heightOffset
val newOffset = state.heightOffsetLimit
coroutineScope.launch {
animationInProgress = true
try {
animate(
initialValue = previousOffset,
targetValue = newOffset,
animationSpec = tween(durationMillis = 150),
) { value, _ ->
state.heightOffset = value
}
} finally {
animationInProgress = false
}
}
return Offset(0f, newOffset – previousOffset)
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
if (available != Offset.Zero) return consumed
val previousOffset = state.heightOffset
val newOffset = 0f
coroutineScope.launch {
animationInProgress = true
try {
animate(
initialValue = previousOffset,
targetValue = newOffset,
animationSpec = tween(durationMillis = 150),
) { value, _ ->
state.heightOffset = value
}
} finally {
animationInProgress = false
}
}
return consumed
}
}
private var scrollAccumulation: Float = 0f
private var animationInProgress: Boolean = false
}

Leave a comment