1 /*
2 * Copyright (C) 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package com.android.compose.gesture.effect
18
19 import androidx.compose.animation.core.AnimationSpec
20 import androidx.compose.foundation.OverscrollEffect
21 import androidx.compose.foundation.OverscrollFactory
22 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
23 import androidx.compose.material3.MaterialTheme
24 import androidx.compose.runtime.Composable
25 import androidx.compose.runtime.remember
26 import androidx.compose.runtime.rememberCoroutineScope
27 import androidx.compose.ui.Modifier
28 import androidx.compose.ui.layout.Measurable
29 import androidx.compose.ui.layout.MeasureResult
30 import androidx.compose.ui.layout.MeasureScope
31 import androidx.compose.ui.node.DelegatableNode
32 import androidx.compose.ui.node.LayoutModifierNode
33 import androidx.compose.ui.unit.Constraints
34 import androidx.compose.ui.unit.Density
35 import androidx.compose.ui.unit.dp
36 import kotlin.math.roundToInt
37 import kotlinx.coroutines.CoroutineScope
38
39 /** Returns a [remember]ed [OffsetOverscrollEffect]. */
40 @Composable
41 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
rememberOffsetOverscrollEffectnull42 fun rememberOffsetOverscrollEffect(
43 animationSpec: AnimationSpec<Float> = MaterialTheme.motionScheme.slowSpatialSpec()
44 ): OffsetOverscrollEffect {
45 val animationScope = rememberCoroutineScope()
46 return remember(animationScope, animationSpec) {
47 OffsetOverscrollEffect(animationScope, animationSpec)
48 }
49 }
50
51 @Composable
52 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
rememberOffsetOverscrollEffectFactorynull53 fun rememberOffsetOverscrollEffectFactory(
54 animationSpec: AnimationSpec<Float> = MaterialTheme.motionScheme.slowSpatialSpec()
55 ): OverscrollFactory {
56 val animationScope = rememberCoroutineScope()
57 return remember(animationScope, animationSpec) {
58 OffsetOverscrollEffectFactory(animationScope, animationSpec)
59 }
60 }
61
62 data class OffsetOverscrollEffectFactory(
63 private val animationScope: CoroutineScope,
64 private val animationSpec: AnimationSpec<Float>,
65 ) : OverscrollFactory {
createOverscrollEffectnull66 override fun createOverscrollEffect(): OverscrollEffect {
67 return OffsetOverscrollEffect(
68 animationScope = animationScope,
69 animationSpec = animationSpec,
70 )
71 }
72 }
73
74 /** An [OverscrollEffect] that offsets the content by the overscroll value. */
75 class OffsetOverscrollEffect(animationScope: CoroutineScope, animationSpec: AnimationSpec<Float>) :
76 BaseContentOverscrollEffect(animationScope, animationSpec) {
77 override val node: DelegatableNode =
78 object : Modifier.Node(), LayoutModifierNode {
measurenull79 override fun MeasureScope.measure(
80 measurable: Measurable,
81 constraints: Constraints,
82 ): MeasureResult {
83 val placeable = measurable.measure(constraints)
84 return layout(placeable.width, placeable.height) {
85 val offsetPx = computeOffset(density = this@measure, overscrollDistance)
86 if (offsetPx != 0) {
87 placeable.placeWithLayer(
88 with(requireConverter()) { offsetPx.toIntOffset() }
89 )
90 } else {
91 placeable.place(0, 0)
92 }
93 }
94 }
95 }
96
97 companion object {
98 private val MaxDistance = 400.dp
99
computeOffsetnull100 fun computeOffset(density: Density, overscrollDistance: Float): Int {
101 val maxDistancePx = with(density) { MaxDistance.toPx() }
102 val progress = ProgressConverter.Default.convert(overscrollDistance / maxDistancePx)
103 return (progress * maxDistancePx).roundToInt()
104 }
105 }
106 }
107
108 /** This converter lets you change a linear progress into a function of your choice. */
interfacenull109 fun interface ProgressConverter {
110 fun convert(progress: Float): Float
111
112 companion object {
113 /** Starts linearly with some resistance and slowly approaches to 0.2f */
114 val Default = tanh(maxProgress = 0.2f, tilt = 3f)
115
116 /**
117 * The scroll stays linear, with [factor] you can control how much resistance there is.
118 *
119 * @param factor If you choose a value between 0f and 1f, the progress will grow more
120 * slowly, like there's resistance. A value of 1f means there's no resistance.
121 */
122 fun linear(factor: Float = 1f) = ProgressConverter { it * factor }
123
124 /**
125 * This function starts linear and slowly approaches [maxProgress].
126 *
127 * See a [visual representation](https://www.desmos.com/calculator/usgvvf0z1u) of this
128 * function.
129 *
130 * @param maxProgress is the maximum progress value.
131 * @param tilt behaves similarly to the factor in the [linear] function, and allows you to
132 * control how quickly you get to the [maxProgress].
133 */
134 fun tanh(maxProgress: Float, tilt: Float = 1f) = ProgressConverter {
135 maxProgress * kotlin.math.tanh(x = it / (maxProgress * tilt))
136 }
137 }
138 }
139