Implement stretch overscroll effect

This commit is contained in:
Suphon Thanakornpakapong
2021-08-04 16:48:51 +07:00
parent 6703c379ee
commit 133602fb9d
4 changed files with 162 additions and 124 deletions
@@ -0,0 +1,86 @@
package app.lawnchair.ui
import androidx.core.util.Consumer
import androidx.dynamicanimation.animation.DynamicAnimation
import androidx.dynamicanimation.animation.FloatPropertyCompat
import androidx.dynamicanimation.animation.SpringAnimation
import androidx.dynamicanimation.animation.SpringForce
import com.android.launcher3.anim.Interpolators
import kotlin.math.abs
class StretchEdgeEffect(
private val setShift: Consumer<Float>
) {
private val springAnim = SpringAnimation(this, DAMPED_SCROLL, 0f).apply {
spring = SpringForce(0f).apply {
stiffness = STIFFNESS
dampingRatio = DAMPING_RATIO
}
}
private var dampedScrollShift = 0f
set(value) {
if (field != value) {
field = value
setShift.accept(value)
}
}
private fun finishScrollWithVelocity(velocity: Float) {
springAnim.setStartVelocity(velocity)
springAnim.setStartValue(dampedScrollShift)
springAnim.start()
}
fun onAbsorb(velocity: Float) {
finishScrollWithVelocity(velocity * VELOCITY_MULTIPLIER)
}
fun onPull(deltaDistance: Float) {
dampedScrollShift += deltaDistance * (VELOCITY_MULTIPLIER / 6f)
springAnim.cancel()
}
fun onRelease() {
if (dampedScrollShift != 0f && !springAnim.isRunning) {
springAnim.setStartValue(dampedScrollShift)
springAnim.start()
}
}
fun addEndListener(listener: DynamicAnimation.OnAnimationEndListener) {
springAnim.addEndListener(listener)
}
companion object {
private const val STIFFNESS = SpringForce.STIFFNESS_LOW * 2
private const val DAMPING_RATIO = SpringForce.DAMPING_RATIO_NO_BOUNCY
private const val VELOCITY_MULTIPLIER = 0.1f
private val SCALE_INTERPOLATOR = Interpolators.DEACCEL_2
private const val MAX_DISTANCE = 0.05f
private val DAMPED_SCROLL = object : FloatPropertyCompat<StretchEdgeEffect>("value") {
override fun getValue(obj: StretchEdgeEffect): Float {
return obj.dampedScrollShift
}
override fun setValue(obj: StretchEdgeEffect, value: Float) {
obj.dampedScrollShift = value
}
}
fun getScale(shift: Float, size: Float): Float {
if (shift == 0f) {
return 1f
}
val distance = abs(shift / size).coerceAtMost(MAX_DISTANCE)
val progress = distance / MAX_DISTANCE
val interpolatedProgress = SCALE_INTERPOLATOR.getInterpolation(progress)
return 1f + interpolatedProgress * MAX_DISTANCE
}
fun getPivot(shift: Float, size: Float): Float {
return if (shift < 0f) size else 0f
}
}
}
@@ -1,122 +0,0 @@
/*
* Copyright 2021, Lawnchair
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package app.lawnchair.ui.preferences.components
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.Velocity
import androidx.dynamicanimation.animation.FloatPropertyCompat
import androidx.dynamicanimation.animation.SpringAnimation
import androidx.dynamicanimation.animation.SpringForce
import kotlin.math.abs
@Composable
fun NestedScrollSpring(content: @Composable () -> Unit) {
val dampedScrollShift = remember { mutableStateOf(0f) }
val nestedScrollConnection = remember { NestedScrollSpringConnection(dampedScrollShift) }
Box(
modifier = Modifier
.nestedScroll(nestedScrollConnection)
.graphicsLayer {
translationY = dampedScrollShift.value
},
) {
content()
}
}
private const val STIFFNESS = (SpringForce.STIFFNESS_MEDIUM + SpringForce.STIFFNESS_LOW) / 2
private const val DAMPING_RATIO = SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY
private const val VELOCITY_MULTIPLIER = 0.3f
class NestedScrollSpringConnection(
dampedScrollShiftState: MutableState<Float>
) : NestedScrollConnection {
private val springAnim = SpringAnimation(this, DAMPED_SCROLL, 0f).apply {
spring = SpringForce(0f).apply {
stiffness = STIFFNESS
dampingRatio = DAMPING_RATIO
}
}
private var dampedScrollShift by dampedScrollShiftState
private var isFlinging = false
private fun finishScrollWithVelocity(velocity: Float) {
springAnim.setStartVelocity(velocity)
springAnim.setStartValue(dampedScrollShift)
springAnim.start()
}
private fun onAbsorb(velocity: Float) {
finishScrollWithVelocity(velocity * VELOCITY_MULTIPLIER)
}
private fun onPull(deltaDistance: Float) {
dampedScrollShift += deltaDistance
springAnim.cancel()
}
private fun onRelease() {
finishScrollWithVelocity(0f)
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
if (isFlinging) return Offset.Zero
if (available.y != 0f) {
onPull(available.y * (VELOCITY_MULTIPLIER / 3f))
} else if (dampedScrollShift != 0f && !springAnim.isRunning) {
springAnim.setStartValue(dampedScrollShift)
springAnim.start()
}
return available
}
override suspend fun onPreFling(available: Velocity): Velocity {
onRelease()
isFlinging = true
return Velocity.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
isFlinging = false
onAbsorb(available.y)
return Velocity(0f, available.y)
}
companion object {
private val DAMPED_SCROLL = object : FloatPropertyCompat<NestedScrollSpringConnection>("value") {
override fun getValue(obj: NestedScrollSpringConnection): Float {
return obj.dampedScrollShift
}
override fun setValue(obj: NestedScrollSpringConnection, value: Float) {
obj.dampedScrollShift = value
}
}
}
}
@@ -0,0 +1,74 @@
package app.lawnchair.ui.preferences.components
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.Velocity
import app.lawnchair.ui.StretchEdgeEffect
@Composable
fun NestedScrollStretch(content: @Composable () -> Unit) {
val shift = remember { mutableStateOf(0f) }
val connection = remember { NestedScrollStretchConnection(shift) }
Box(
modifier = Modifier
.nestedScroll(connection)
.drawWithContent {
val value = shift.value
val height = size.height
val scaleY = StretchEdgeEffect.getScale(value, height)
if (scaleY != 1f) {
val pivotY = StretchEdgeEffect.getPivot(value, height)
scale(1f, scaleY, pivot = Offset(0f, pivotY)) {
this@drawWithContent.drawContent()
}
} else {
drawContent()
}
}
) {
content()
}
}
private class NestedScrollStretchConnection(
shiftState: MutableState<Float>
) : NestedScrollConnection {
private val effect = StretchEdgeEffect { shift -> shiftState.value = shift }
private var isFlinging = false
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
if (isFlinging) return Offset.Zero
if (available.y != 0f) {
effect.onPull(available.y)
} else {
effect.onRelease()
}
return available
}
override suspend fun onPreFling(available: Velocity): Velocity {
effect.onRelease()
isFlinging = true
return Velocity.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
isFlinging = false
effect.onAbsorb(available.y)
return Velocity(0f, available.y)
}
}
@@ -44,7 +44,7 @@ fun PreferenceLayout(
val scrollState = rememberScrollState()
ProvideTopBarFloatingState(scrolled = scrollState.value > 0)
NestedScrollSpring {
NestedScrollStretch {
Column(
verticalArrangement = verticalArrangement,
horizontalAlignment = horizontalAlignment,
@@ -63,7 +63,7 @@ fun PreferenceLayoutLazyColumn(modifier: Modifier = Modifier, content: LazyListS
val scrollState = rememberLazyListState()
ProvideTopBarFloatingState(scrolled = scrollState.firstVisibleItemIndex > 0 || scrollState.firstVisibleItemScrollOffset > 0)
NestedScrollSpring {
NestedScrollStretch {
LazyColumn(
modifier = modifier.fillMaxHeight(),
contentPadding = preferenceLayoutPadding(),