-
-
Notifications
You must be signed in to change notification settings - Fork 57
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
173 additions
and
22 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
159 changes: 159 additions & 0 deletions
159
app/src/main/java/dev/chungjungsoo/gptmobile/util/PinnedExitUntilCollapsedScrollBehavior.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,159 @@ | ||
package dev.chungjungsoo.gptmobile.util | ||
|
||
import androidx.compose.animation.core.AnimationSpec | ||
import androidx.compose.animation.core.AnimationState | ||
import androidx.compose.animation.core.DecayAnimationSpec | ||
import androidx.compose.animation.core.Spring | ||
import androidx.compose.animation.core.animateDecay | ||
import androidx.compose.animation.core.animateTo | ||
import androidx.compose.animation.core.spring | ||
import androidx.compose.animation.rememberSplineBasedDecay | ||
import androidx.compose.material3.ExperimentalMaterial3Api | ||
import androidx.compose.material3.TopAppBarScrollBehavior | ||
import androidx.compose.material3.TopAppBarState | ||
import androidx.compose.material3.rememberTopAppBarState | ||
import androidx.compose.runtime.Composable | ||
import androidx.compose.ui.geometry.Offset | ||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection | ||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource | ||
import androidx.compose.ui.unit.Velocity | ||
import kotlin.math.abs | ||
|
||
/** | ||
* Special thanks to @BenjyTec: https://stackoverflow.com/a/78538564/8606428 | ||
*/ | ||
@ExperimentalMaterial3Api | ||
@Composable | ||
fun pinnedExitUntilCollapsedScrollBehavior( | ||
state: TopAppBarState = rememberTopAppBarState(), | ||
canScroll: () -> Boolean = { true }, | ||
snapAnimationSpec: AnimationSpec<Float>? = spring(stiffness = Spring.StiffnessMediumLow), | ||
flingAnimationSpec: DecayAnimationSpec<Float>? = rememberSplineBasedDecay() | ||
): TopAppBarScrollBehavior = | ||
PinnedExitUntilCollapsedScrollBehavior( | ||
state = state, | ||
snapAnimationSpec = snapAnimationSpec, | ||
flingAnimationSpec = flingAnimationSpec, | ||
canScroll = canScroll | ||
) | ||
|
||
@OptIn(ExperimentalMaterial3Api::class) | ||
private class PinnedExitUntilCollapsedScrollBehavior( | ||
override val state: TopAppBarState, | ||
override val snapAnimationSpec: AnimationSpec<Float>?, | ||
override val flingAnimationSpec: DecayAnimationSpec<Float>?, | ||
val canScroll: () -> Boolean = { true } | ||
) : TopAppBarScrollBehavior { | ||
override val isPinned: Boolean = true | ||
override var nestedScrollConnection = | ||
object : NestedScrollConnection { | ||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { | ||
// Don't intercept if scrolling down. | ||
if (!canScroll() || available.y > 0f) return Offset.Zero | ||
|
||
val prevHeightOffset = state.heightOffset | ||
state.heightOffset = state.heightOffset + available.y | ||
return if (prevHeightOffset != state.heightOffset) { | ||
// We're in the middle of top app bar collapse or expand. | ||
// Consume only the scroll on the Y axis. | ||
available.copy(x = 0f) | ||
} else { | ||
Offset.Zero | ||
} | ||
} | ||
|
||
override fun onPostScroll( | ||
consumed: Offset, | ||
available: Offset, | ||
source: NestedScrollSource | ||
): Offset { | ||
if (!canScroll()) return Offset.Zero | ||
state.contentOffset += consumed.y | ||
|
||
if (available.y < 0f || consumed.y < 0f) { | ||
// When scrolling up, just update the state's height offset. | ||
val oldHeightOffset = state.heightOffset | ||
state.heightOffset = state.heightOffset + consumed.y | ||
return Offset(0f, state.heightOffset - oldHeightOffset) | ||
} | ||
|
||
if (consumed.y == 0f && available.y > 0) { | ||
// Reset the total content offset to zero when scrolling all the way down. This | ||
// will eliminate some float precision inaccuracies. | ||
state.contentOffset = 0f | ||
} | ||
|
||
if (available.y > 0f) { | ||
// Adjust the height offset in case the consumed delta Y is less than what was | ||
// recorded as available delta Y in the pre-scroll. | ||
val oldHeightOffset = state.heightOffset | ||
state.heightOffset = state.heightOffset + available.y | ||
return Offset(0f, state.heightOffset - oldHeightOffset) | ||
} | ||
return Offset.Zero | ||
} | ||
|
||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { | ||
val superConsumed = super.onPostFling(consumed, available) | ||
return superConsumed + settleAppBar( | ||
state, | ||
available.y, | ||
flingAnimationSpec, | ||
snapAnimationSpec | ||
) | ||
} | ||
} | ||
} | ||
|
||
@OptIn(ExperimentalMaterial3Api::class) | ||
private suspend fun settleAppBar( | ||
state: TopAppBarState, | ||
velocity: Float, | ||
flingAnimationSpec: DecayAnimationSpec<Float>?, | ||
snapAnimationSpec: AnimationSpec<Float>? | ||
): Velocity { | ||
// Check if the app bar is completely collapsed/expanded. If so, no need to settle the app bar, | ||
// and just return Zero Velocity. | ||
// Note that we don't check for 0f due to float precision with the collapsedFraction | ||
// calculation. | ||
if (state.collapsedFraction < 0.01f || state.collapsedFraction == 1f) { | ||
return Velocity.Zero | ||
} | ||
var remainingVelocity = velocity | ||
// In case there is an initial velocity that was left after a previous user fling, animate to | ||
// continue the motion to expand or collapse the app bar. | ||
if (flingAnimationSpec != null && abs(velocity) > 1f) { | ||
var lastValue = 0f | ||
AnimationState( | ||
initialValue = 0f, | ||
initialVelocity = velocity | ||
) | ||
.animateDecay(flingAnimationSpec) { | ||
val delta = value - lastValue | ||
val initialHeightOffset = state.heightOffset | ||
state.heightOffset = initialHeightOffset + delta | ||
val consumed = abs(initialHeightOffset - state.heightOffset) | ||
lastValue = value | ||
remainingVelocity = this.velocity | ||
// avoid rounding errors and stop if anything is unconsumed | ||
if (abs(delta - consumed) > 0.5f) this.cancelAnimation() | ||
} | ||
} | ||
// Snap if animation specs were provided. | ||
if (snapAnimationSpec != null) { | ||
if (state.heightOffset < 0 && | ||
state.heightOffset > state.heightOffsetLimit | ||
) { | ||
AnimationState(initialValue = state.heightOffset).animateTo( | ||
if (state.collapsedFraction < 0.5f) { | ||
0f | ||
} else { | ||
state.heightOffsetLimit | ||
}, | ||
animationSpec = snapAnimationSpec | ||
) { state.heightOffset = value } | ||
} | ||
} | ||
|
||
return Velocity(0f, remainingVelocity) | ||
} |