Implement stretch overscroll effect
This commit is contained in:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user