├── .gitignore ├── EdgeEffect ├── .gitignore ├── build.gradle.kts ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── me │ │ └── kpa │ │ └── edgeeffect │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ └── kotlin │ │ └── me │ │ └── kpa │ │ └── edgeeffect │ │ ├── EdgeEffectScrollView.kt │ │ └── recycler │ │ ├── BaseEdgeEffectFactory.kt │ │ ├── BaseSpringAnimationViewHolder.kt │ │ ├── RecyclerViewHorizontalEdgeEffect.kt │ │ └── RecyclerViewVerticaEdgeEffect.kt │ └── test │ └── java │ └── me │ └── kpa │ └── edgeeffect │ └── ExampleUnitTest.kt ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── me │ │ └── kpa │ │ └── edgeeffect │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── me │ │ │ └── kpa │ │ │ └── edgeeffect │ │ │ ├── MainActivity.kt │ │ │ ├── SimpleRecyclerActivity.kt │ │ │ ├── SimpleScrollView1Activity.kt │ │ │ ├── SimpleScrollViewMainActivity.java │ │ │ └── SimpleViewPager2MainActivity.java │ └── res │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ └── ic_launcher_foreground.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── activity_simple_recycler.xml │ │ ├── activity_simple_scroll_view1.xml │ │ ├── activity_simple_scroll_view_main.xml │ │ ├── activity_simple_view_pager2_main.xml │ │ ├── horizontal_simple_item.xml │ │ ├── simple_item.xml │ │ └── vp_simple_item.xml │ │ ├── mipmap-anydpi │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values-night │ │ └── themes.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── me │ └── kpa │ └── edgeeffect │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | # Gradle files 2 | .gradle/ 3 | build/ 4 | 5 | # Local configuration file (sdk path, etc) 6 | local.properties 7 | 8 | # Log/OS Files 9 | *.log 10 | 11 | # Android Studio generated files and folders 12 | captures/ 13 | .externalNativeBuild/ 14 | .cxx/ 15 | *.apk 16 | output.json 17 | 18 | # IntelliJ 19 | *.iml 20 | .idea/ 21 | misc.xml 22 | deploymentTargetDropDown.xml 23 | render.experimental.xml 24 | 25 | # Keystore files 26 | *.jks 27 | *.keystore 28 | 29 | # Google Services (e.g. APIs or Firebase) 30 | google-services.json 31 | 32 | # Android Profiling 33 | *.hprof -------------------------------------------------------------------------------- /EdgeEffect/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /EdgeEffect/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.library) 3 | alias(libs.plugins.jetbrains.kotlin.android) 4 | } 5 | 6 | android { 7 | namespace = "me.kpa.edgeeffect" 8 | compileSdk = 34 9 | 10 | defaultConfig { 11 | minSdk = 26 12 | 13 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 14 | consumerProguardFiles("consumer-rules.pro") 15 | } 16 | 17 | buildTypes { 18 | release { 19 | isMinifyEnabled = false 20 | proguardFiles( 21 | getDefaultProguardFile("proguard-android-optimize.txt"), 22 | "proguard-rules.pro" 23 | ) 24 | } 25 | } 26 | compileOptions { 27 | sourceCompatibility = JavaVersion.VERSION_1_8 28 | targetCompatibility = JavaVersion.VERSION_1_8 29 | } 30 | kotlinOptions { 31 | jvmTarget = "1.8" 32 | } 33 | } 34 | 35 | dependencies { 36 | 37 | implementation(libs.androidx.core.ktx) 38 | implementation(libs.androidx.appcompat) 39 | implementation(libs.material) 40 | testImplementation(libs.junit) 41 | androidTestImplementation(libs.androidx.junit) 42 | androidTestImplementation(libs.androidx.espresso.core) 43 | } -------------------------------------------------------------------------------- /EdgeEffect/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kongxiaoan/EdgeEffectScrollingContainers/8a69d84cb2d57257762cae4143d3bdcef4a621ad/EdgeEffect/consumer-rules.pro -------------------------------------------------------------------------------- /EdgeEffect/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /EdgeEffect/src/androidTest/java/me/kpa/edgeeffect/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package me.kpa.edgeeffect 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("me.kpa.edgeeffect.test", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /EdgeEffect/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /EdgeEffect/src/main/kotlin/me/kpa/edgeeffect/EdgeEffectScrollView.kt: -------------------------------------------------------------------------------- 1 | package me.kpa.edgeeffect 2 | 3 | import android.content.Context 4 | import android.graphics.Canvas 5 | import android.util.AttributeSet 6 | import android.view.InputDevice 7 | import android.view.MotionEvent 8 | import android.view.View 9 | import android.widget.EdgeEffect 10 | import androidx.annotation.IntDef 11 | import androidx.core.view.MotionEventCompat 12 | import androidx.core.view.ViewCompat 13 | import androidx.core.widget.EdgeEffectCompat 14 | import androidx.core.widget.NestedScrollView 15 | import androidx.dynamicanimation.animation.SpringAnimation 16 | import androidx.dynamicanimation.animation.SpringForce 17 | import kotlin.math.max 18 | 19 | /** 20 | * Description: 21 | * Created by kpa on 2024/6/19. 22 | */ 23 | class EdgeEffectScrollView @JvmOverloads constructor( 24 | context: Context, attrs: AttributeSet? = null 25 | ) : NestedScrollView(context, attrs, 0) { 26 | private var mEdgeEffectFactory: EdgeEffectFactory = 27 | EdgeEffectFactory() 28 | 29 | private var mLastTouchY = 0.0f 30 | private var mTopGlow: EdgeEffect? = null 31 | private var mBottomGlow: EdgeEffect? = null 32 | 33 | init { 34 | overScrollMode = View.OVER_SCROLL_NEVER 35 | isFillViewport = true 36 | setEdgeEffectFactory(buildDefaultEdgeEffect()) 37 | } 38 | 39 | override fun onTouchEvent(motionEvent: MotionEvent): Boolean { 40 | when (motionEvent.action) { 41 | MotionEvent.ACTION_MOVE -> { 42 | val deltaY = (motionEvent.y - mLastTouchY).toInt() 43 | scrollByInternal(0, -deltaY, motionEvent) 44 | mLastTouchY = motionEvent.y 45 | } 46 | 47 | MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { 48 | resetScroll(); 49 | } 50 | 51 | MotionEvent.ACTION_DOWN -> { 52 | mLastTouchY = motionEvent.y 53 | } 54 | } 55 | return super.onTouchEvent(motionEvent) 56 | } 57 | 58 | private fun resetScroll() { 59 | stopNestedScroll(ViewCompat.TYPE_TOUCH) 60 | releaseGlows() 61 | } 62 | 63 | private fun releaseGlows() { 64 | var needsInvalidate = false 65 | if (mTopGlow != null) { 66 | mTopGlow!!.onRelease() 67 | needsInvalidate = needsInvalidate or mTopGlow!!.isFinished 68 | } 69 | if (mBottomGlow != null) { 70 | mBottomGlow!!.onRelease() 71 | needsInvalidate = needsInvalidate or mBottomGlow!!.isFinished 72 | } 73 | if (needsInvalidate) { 74 | ViewCompat.postInvalidateOnAnimation(this) 75 | } 76 | } 77 | 78 | 79 | fun innerComputeVerticalScrollRange(): Int { 80 | val count = childCount 81 | val parentSpace = height - paddingBottom - paddingTop 82 | if (count == 0) { 83 | return parentSpace 84 | } 85 | 86 | val child = getChildAt(0) 87 | val lp = child.layoutParams as LayoutParams 88 | var scrollRange = child.bottom + lp.bottomMargin 89 | val scrollY = scrollY 90 | val overscrollBottom = 91 | max(0.0, (scrollRange - parentSpace).toDouble()).toInt() 92 | if (scrollY < 0) { 93 | scrollRange -= scrollY 94 | } else if (scrollY > overscrollBottom) { 95 | scrollRange += scrollY - overscrollBottom 96 | } 97 | 98 | return scrollRange 99 | } 100 | 101 | private fun scrollByInternal(x: Int, y: Int, motionEvent: MotionEvent) { 102 | var unconsumedY = 0 103 | var consumedY = 0 104 | if (y != 0) { 105 | val scrollRange = innerComputeVerticalScrollRange() - height 106 | val currentScrollY = scrollY 107 | val newScrollY = currentScrollY + y 108 | 109 | 110 | // 限制滚动范围 111 | if (newScrollY < 0) { 112 | consumedY = -currentScrollY 113 | unconsumedY = y - consumedY 114 | } else if (newScrollY > scrollRange) { 115 | consumedY = scrollRange - currentScrollY 116 | unconsumedY = y - consumedY 117 | } else { 118 | consumedY = y 119 | } 120 | } 121 | if (motionEvent != null && !MotionEventCompat.isFromSource( 122 | motionEvent, 123 | InputDevice.SOURCE_MOUSE 124 | ) 125 | ) { 126 | pullGlows(motionEvent.getX(), motionEvent.getY(), unconsumedY.toFloat()) 127 | } 128 | considerReleasingGlowsOnScroll(x, y) 129 | } 130 | 131 | private fun considerReleasingGlowsOnScroll(dx: Int, dy: Int) { 132 | var needsInvalidate = false 133 | if (mTopGlow != null && !mTopGlow!!.isFinished() && dy > 0) { 134 | mTopGlow!!.onRelease() 135 | needsInvalidate = needsInvalidate or mTopGlow!!.isFinished() 136 | } 137 | if (mBottomGlow != null && !mBottomGlow!!.isFinished() && dy < 0) { 138 | mBottomGlow!!.onRelease() 139 | needsInvalidate = needsInvalidate or mBottomGlow!!.isFinished() 140 | } 141 | if (needsInvalidate) { 142 | ViewCompat.postInvalidateOnAnimation(this) 143 | } 144 | } 145 | 146 | 147 | fun ensureTopGlow() { 148 | if (mTopGlow != null) { 149 | return 150 | } 151 | mTopGlow = mEdgeEffectFactory.createEdgeEffect(this, EdgeEffectFactory.DIRECTION_TOP) 152 | mTopGlow?.setSize(measuredWidth, measuredHeight) 153 | } 154 | 155 | fun ensureBottomGlow() { 156 | if (mBottomGlow != null) { 157 | return 158 | } 159 | mBottomGlow = mEdgeEffectFactory.createEdgeEffect(this, EdgeEffectFactory.DIRECTION_BOTTOM) 160 | mBottomGlow?.setSize(measuredWidth, measuredHeight) 161 | } 162 | 163 | private fun pullGlows(x: Float, y: Float, overscrollY: Float) { 164 | var invalidate = false 165 | if (overscrollY < 0) { 166 | ensureTopGlow() 167 | EdgeEffectCompat.onPull(mTopGlow!!, -overscrollY / height, x / width) 168 | invalidate = true 169 | } else if (overscrollY > 0) { 170 | ensureBottomGlow() 171 | EdgeEffectCompat.onPull(mBottomGlow!!, overscrollY / height, 1f - x / width) 172 | invalidate = true 173 | } 174 | if (invalidate) { 175 | ViewCompat.postInvalidateOnAnimation(this) 176 | } 177 | } 178 | 179 | override fun fling(velocityY: Int) { 180 | super.fling(velocityY) 181 | absorbGlows(velocityY) 182 | } 183 | 184 | fun absorbGlows(velocityY: Int) { 185 | if (velocityY < 0) { 186 | ensureTopGlow() 187 | if (mTopGlow!!.isFinished) { 188 | mTopGlow!!.onAbsorb(-velocityY) 189 | } 190 | } else if (velocityY > 0) { 191 | ensureBottomGlow() 192 | if (mBottomGlow!!.isFinished) { 193 | mBottomGlow!!.onAbsorb(velocityY) 194 | } 195 | } 196 | 197 | if (velocityY != 0) { 198 | ViewCompat.postInvalidateOnAnimation(this) 199 | } 200 | } 201 | 202 | 203 | fun setEdgeEffectFactory(edgeEffectFactory: EdgeEffectFactory) { 204 | mEdgeEffectFactory = edgeEffectFactory 205 | invalidateGlows() 206 | } 207 | 208 | private fun invalidateGlows() { 209 | mBottomGlow = null 210 | mTopGlow = null 211 | } 212 | 213 | open class EdgeEffectFactory { 214 | companion object { 215 | @Retention(AnnotationRetention.SOURCE) 216 | @IntDef(DIRECTION_TOP, DIRECTION_BOTTOM) 217 | annotation class EdgeDirection 218 | 219 | /** 220 | * Direction constant for the top edge 221 | */ 222 | const val DIRECTION_TOP: Int = 1 223 | 224 | /** 225 | * Direction constant for the bottom edge 226 | */ 227 | const val DIRECTION_BOTTOM: Int = 3 228 | 229 | } 230 | 231 | /** 232 | * Create a new EdgeEffect for the provided direction. 233 | */ 234 | open fun createEdgeEffect( 235 | view: NestedScrollView, 236 | @EdgeDirection direction: Int 237 | ): EdgeEffect { 238 | return EdgeEffect(view.context) 239 | } 240 | } 241 | 242 | private fun buildDefaultEdgeEffect(): EdgeEffectFactory { 243 | return object : EdgeEffectFactory() { 244 | private val ratio = 0.3f 245 | override fun createEdgeEffect(view: NestedScrollView, direction: Int): EdgeEffect { 246 | return object : EdgeEffect(view.context) { 247 | private val springAnimation: SpringAnimation by lazy { 248 | SpringAnimation(view, SpringAnimation.TRANSLATION_Y) 249 | .setSpring( 250 | SpringForce().setFinalPosition(0f).setDampingRatio(0.8f) 251 | .setStiffness(SpringForce.STIFFNESS_LOW) 252 | ) 253 | } 254 | 255 | override fun onPull(deltaDistance: Float) { 256 | super.onPull(deltaDistance) 257 | handlePull(deltaDistance) 258 | } 259 | 260 | override fun onPull(deltaDistance: Float, displacement: Float) { 261 | super.onPull(deltaDistance, displacement) 262 | handlePull(deltaDistance) 263 | } 264 | 265 | private fun handlePull(delta: Float) { 266 | val direct = 267 | if (direction == DIRECTION_TOP) 1 else -1 268 | val transY = view.height * delta * ratio * direct 269 | springAnimation.cancel() 270 | var y = view.translationY 271 | y += transY 272 | view.translationY = y 273 | } 274 | 275 | override fun onRelease() { 276 | super.onRelease() 277 | finish() 278 | if (isFinished) { 279 | springAnimation.start() 280 | } 281 | } 282 | 283 | override fun onAbsorb(velocity: Int) { 284 | super.onAbsorb(velocity) 285 | val direct = 286 | if (direction == DIRECTION_TOP) 1 else -1 287 | val velocityX = velocity * ratio * direct 288 | springAnimation 289 | .setStartVelocity(velocityX) 290 | .start() 291 | } 292 | 293 | override fun draw(canvas: Canvas): Boolean { 294 | setSize(0, 0) 295 | return super.draw(canvas) 296 | } 297 | } 298 | } 299 | } 300 | } 301 | } -------------------------------------------------------------------------------- /EdgeEffect/src/main/kotlin/me/kpa/edgeeffect/recycler/BaseEdgeEffectFactory.kt: -------------------------------------------------------------------------------- 1 | package me.kpa.edgeeffect.recycler 2 | 3 | import android.graphics.Canvas 4 | import android.widget.EdgeEffect 5 | import androidx.recyclerview.widget.RecyclerView 6 | 7 | /** 8 | * @author kpa 9 | */ 10 | abstract class BaseEdgeEffectFactory : 11 | RecyclerView.EdgeEffectFactory() { 12 | @JvmField 13 | protected val ratio: Float = 0.3f 14 | 15 | 16 | override fun createEdgeEffect(recyclerView: RecyclerView, direction: Int): EdgeEffect { 17 | return object : EdgeEffect(recyclerView.context) { 18 | private fun handlePull(delta: Float) { 19 | val direct = if (direction == currentDirection()) 1 else -1 20 | applyPullEffect(recyclerView, direct, delta) 21 | } 22 | 23 | override fun onPull(deltaDistance: Float, displacement: Float) { 24 | super.onPull(deltaDistance, displacement) 25 | handlePull(deltaDistance) 26 | } 27 | 28 | override fun onPull(deltaDistance: Float) { 29 | super.onPull(deltaDistance) 30 | handlePull(deltaDistance) 31 | } 32 | 33 | override fun onRelease() { 34 | super.onRelease() 35 | finish() 36 | if (isFinished) { 37 | for (i in 0 until recyclerView.childCount) { 38 | val vh = recyclerView.getChildViewHolder(recyclerView.getChildAt(i)) as T 39 | vh!!.springAnimation.start() 40 | } 41 | } 42 | } 43 | 44 | override fun onAbsorb(velocity: Int) { 45 | super.onAbsorb(velocity) 46 | val direct = if (direction == currentDirection()) 1 else -1 47 | val velocityX = velocity * ratio * direct 48 | for (i in 0 until recyclerView.childCount) { 49 | val vh = recyclerView.getChildViewHolder(recyclerView.getChildAt(i)) as T 50 | vh!!.springAnimation 51 | .setStartVelocity(velocityX) 52 | .start() 53 | } 54 | } 55 | 56 | override fun draw(canvas: Canvas): Boolean { 57 | setSize(0, 0) 58 | return super.draw(canvas) 59 | } 60 | } 61 | } 62 | 63 | protected abstract fun applyPullEffect(recyclerView: RecyclerView, direct: Int, delta: Float) 64 | 65 | protected abstract fun currentDirection(): Int 66 | } -------------------------------------------------------------------------------- /EdgeEffect/src/main/kotlin/me/kpa/edgeeffect/recycler/BaseSpringAnimationViewHolder.kt: -------------------------------------------------------------------------------- 1 | package me.kpa.edgeeffect.recycler 2 | 3 | import android.view.View 4 | import androidx.dynamicanimation.animation.SpringAnimation 5 | import androidx.recyclerview.widget.RecyclerView 6 | 7 | /** 8 | * Description: 9 | * Created by kpa on 2024/6/18. 10 | */ 11 | abstract class BaseSpringAnimationViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { 12 | @JvmField 13 | var springAnimation: SpringAnimation 14 | 15 | init { 16 | springAnimation = createSpringAnimation() 17 | } 18 | 19 | protected abstract fun createSpringAnimation(): SpringAnimation 20 | } 21 | -------------------------------------------------------------------------------- /EdgeEffect/src/main/kotlin/me/kpa/edgeeffect/recycler/RecyclerViewHorizontalEdgeEffect.kt: -------------------------------------------------------------------------------- 1 | package me.kpa.edgeeffect.recycler 2 | 3 | import android.view.View 4 | import androidx.dynamicanimation.animation.SpringAnimation 5 | import androidx.dynamicanimation.animation.SpringForce 6 | import androidx.recyclerview.widget.RecyclerView 7 | 8 | /** 9 | * Description: 10 | * 11 | * @author kpa 12 | * @date 2024/6/18 13 | */ 14 | class RecyclerViewHorizontalEdgeEffect : 15 | BaseEdgeEffectFactory() { 16 | override fun applyPullEffect(recyclerView: RecyclerView, direct: Int, delta: Float) { 17 | val transX: Float = recyclerView.width * delta * ratio * direct 18 | for (i in 0 until recyclerView.childCount) { 19 | val vh = 20 | recyclerView.getChildViewHolder(recyclerView.getChildAt(i)) as SpringHorizontalAnimationViewHolder 21 | if (vh.itemView.isShown) { 22 | vh.springAnimation.cancel() 23 | vh.itemView.translationX += transX 24 | } 25 | } 26 | } 27 | 28 | override fun currentDirection(): Int { 29 | return DIRECTION_LEFT 30 | } 31 | 32 | class SpringHorizontalAnimationViewHolder(itemView: View) : 33 | BaseSpringAnimationViewHolder(itemView) { 34 | override fun createSpringAnimation(): SpringAnimation { 35 | return SpringAnimation(itemView, SpringAnimation.TRANSLATION_X) 36 | .setSpring( 37 | SpringForce().setFinalPosition(0f).setDampingRatio(0.8f) 38 | .setStiffness(SpringForce.STIFFNESS_LOW) 39 | ) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /EdgeEffect/src/main/kotlin/me/kpa/edgeeffect/recycler/RecyclerViewVerticaEdgeEffect.kt: -------------------------------------------------------------------------------- 1 | package me.kpa.edgeeffect.recycler 2 | 3 | import android.view.View 4 | import androidx.dynamicanimation.animation.SpringAnimation 5 | import androidx.dynamicanimation.animation.SpringForce 6 | import androidx.recyclerview.widget.RecyclerView 7 | 8 | /** 9 | * Description: 10 | * 11 | * @author kpa 12 | * @date 2024/6/18 13 | */ 14 | class RecyclerViewVerticaEdgeEffect : BaseEdgeEffectFactory() { 15 | override fun applyPullEffect(recyclerView: RecyclerView, direct: Int, delta: Float) { 16 | val transY: Float = recyclerView.height * delta * ratio * direct 17 | for (i in 0 until recyclerView.childCount) { 18 | val vh = 19 | recyclerView.getChildViewHolder(recyclerView.getChildAt(i)) as SpringVerticaAnimationViewHolder 20 | if (vh.itemView.isShown) { 21 | vh.springAnimation.cancel() 22 | var y = vh.itemView.translationY 23 | y += transY 24 | vh.itemView.translationY = y 25 | } 26 | } 27 | } 28 | 29 | override fun currentDirection(): Int { 30 | return DIRECTION_TOP 31 | } 32 | 33 | class SpringVerticaAnimationViewHolder(itemView: View) : 34 | BaseSpringAnimationViewHolder(itemView) { 35 | override fun createSpringAnimation(): SpringAnimation { 36 | return SpringAnimation(itemView, SpringAnimation.TRANSLATION_Y) 37 | .setSpring( 38 | SpringForce().setFinalPosition(0f).setDampingRatio(0.8f) 39 | .setStiffness(SpringForce.STIFFNESS_LOW) 40 | ) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /EdgeEffect/src/test/java/me/kpa/edgeeffect/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package me.kpa.edgeeffect 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EdgeEffectScrollingContainers 2 | Android. 滚动容器 边界动画定义,仿照IOS页面弹性动画(包含收拾触发和惯性触发) 3 | 4 | --- 5 | theme: hydrogen 6 | --- 7 | 怎么开头呢? 8 | 9 | 在Ui过程中,每一个Android开发可能都接收到如下需求: 10 | 11 | > 这个XXX的时候,防一下IOS的某某动画 12 | 13 | 是不是,肯定是的. 14 | 15 | IOS中页面滑动动画确实不错, 我说的是**页面边界的弹簧动画**, 其实这是一个简单的动画,出现的场景可以是任意位置 16 | 17 | 1. 如果出现在布局的某个位置,我们直接可以使用动画处理 18 | 2. 需求中基本出现的位置是滚动性容器,或者某一个页面 19 | 20 | ## 先看下效果 21 | 22 | 效果1 页面边界弹性动画 23 | 24 | output_1.gif 25 | 26 | 效果2 列表边界弹性动画 27 | 28 | 29 | out.gif 30 | 31 | ## 特性描述 32 | 33 | 1. 边界状态时手指拖动跟随手指,松开动画 34 | 2. 快速滑动,惯性弹性动画 35 | 36 | ## 相关技术调研 37 | 38 | 其实就是Google找轮子,找的过程中发现,有人使用EdgeEffect 实现了RecyclerView 列表的阻尼滑动效果(就是弹簧动画) https://juejin.cn/post/7235463575300046903 , 39 | 40 | 41 | 42 | 43 | 可以参考这个大佬的文章:https://juejin.cn/post/7235463575300046903 44 | 45 | ## 满足需求的核心技术 (读源码 很好理解) 46 | 1. 判断容器是否已经在边界 47 | 2. 处理惯性手势 48 | 49 | 50 | RV看完这个https://juejin.cn/post/7235463575300046903 文章就搞定了,ScrollView 爬了RV的代码 修改完成,我不想写原理 有需要找我要代码 51 | 52 | 实现效果: 53 | 54 | out.gif 55 | 56 | 仓库地址: 57 | 58 | https://github.com/kongxiaoan/EdgeEffectScrollingContainers 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.jetbrains.kotlin.android) 4 | } 5 | 6 | android { 7 | namespace = "me.kpa.edgeeffect" 8 | compileSdk = 34 9 | 10 | defaultConfig { 11 | applicationId = "me.kpa.edgeeffect" 12 | minSdk = 26 13 | targetSdk = 34 14 | versionCode = 1 15 | versionName = "1.0" 16 | 17 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 18 | } 19 | 20 | buildTypes { 21 | release { 22 | isMinifyEnabled = false 23 | proguardFiles( 24 | getDefaultProguardFile("proguard-android-optimize.txt"), 25 | "proguard-rules.pro" 26 | ) 27 | } 28 | } 29 | compileOptions { 30 | sourceCompatibility = JavaVersion.VERSION_1_8 31 | targetCompatibility = JavaVersion.VERSION_1_8 32 | } 33 | kotlinOptions { 34 | jvmTarget = "1.8" 35 | } 36 | } 37 | 38 | dependencies { 39 | 40 | implementation(libs.androidx.core.ktx) 41 | implementation(libs.androidx.appcompat) 42 | implementation(libs.material) 43 | implementation(libs.androidx.activity) 44 | implementation(libs.androidx.constraintlayout) 45 | implementation(project(":EdgeEffect")) 46 | testImplementation(libs.junit) 47 | androidTestImplementation(libs.androidx.junit) 48 | androidTestImplementation(libs.androidx.espresso.core) 49 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/me/kpa/edgeeffect/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package me.kpa.edgeeffect 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("me.kpa.edgeeffect", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 15 | 18 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /app/src/main/java/me/kpa/edgeeffect/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package me.kpa.edgeeffect 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.view.View 6 | import androidx.activity.enableEdgeToEdge 7 | import androidx.appcompat.app.AppCompatActivity 8 | import androidx.core.view.ViewCompat 9 | import androidx.core.view.WindowInsetsCompat 10 | 11 | class MainActivity : AppCompatActivity() { 12 | override fun onCreate(savedInstanceState: Bundle?) { 13 | super.onCreate(savedInstanceState) 14 | enableEdgeToEdge() 15 | setContentView(R.layout.activity_main) 16 | ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> 17 | val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) 18 | v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) 19 | insets 20 | } 21 | findViewById(R.id.recyclerSimple).setOnClickListener { v: View? -> 22 | startActivity( 23 | Intent( 24 | this@MainActivity, 25 | SimpleRecyclerActivity::class.java 26 | ) 27 | ) 28 | } 29 | 30 | findViewById(R.id.viewPagerSimple).setOnClickListener { v: View? -> 31 | startActivity( 32 | Intent( 33 | this@MainActivity, 34 | SimpleViewPager2MainActivity::class.java 35 | ) 36 | ) 37 | } 38 | 39 | findViewById(R.id.scrollViewSimple).setOnClickListener { v: View? -> 40 | startActivity( 41 | Intent( 42 | this@MainActivity, 43 | SimpleScrollViewMainActivity::class.java 44 | ) 45 | ) 46 | } 47 | findViewById(R.id.scrollViewSimple1).setOnClickListener { v: View? -> 48 | startActivity( 49 | Intent( 50 | this@MainActivity, 51 | SimpleScrollView1Activity::class.java 52 | ) 53 | ) 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /app/src/main/java/me/kpa/edgeeffect/SimpleRecyclerActivity.kt: -------------------------------------------------------------------------------- 1 | package me.kpa.edgeeffect 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import android.widget.TextView 8 | import androidx.activity.enableEdgeToEdge 9 | import androidx.appcompat.app.AppCompatActivity 10 | import androidx.core.graphics.Insets 11 | import androidx.core.view.OnApplyWindowInsetsListener 12 | import androidx.core.view.ViewCompat 13 | import androidx.core.view.WindowInsetsCompat 14 | import androidx.recyclerview.widget.LinearLayoutManager 15 | import androidx.recyclerview.widget.RecyclerView 16 | import me.kpa.edgeeffect.recycler.RecyclerViewHorizontalEdgeEffect 17 | import me.kpa.edgeeffect.recycler.RecyclerViewVerticaEdgeEffect 18 | 19 | class SimpleRecyclerActivity : AppCompatActivity() { 20 | private var recyclerView: RecyclerView? = null 21 | private var recyclerView1: RecyclerView? = null 22 | private val mList = ArrayList() 23 | 24 | protected override fun onCreate(savedInstanceState: Bundle?) { 25 | super.onCreate(savedInstanceState) 26 | this.enableEdgeToEdge() 27 | setContentView(R.layout.activity_simple_recycler) 28 | ViewCompat.setOnApplyWindowInsetsListener( 29 | findViewById(R.id.main), 30 | OnApplyWindowInsetsListener { v: View, insets: WindowInsetsCompat -> 31 | val systemBars: Insets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) 32 | v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) 33 | insets 34 | }) 35 | recyclerView = findViewById(R.id.recycler) as RecyclerView? 36 | recyclerView1 = findViewById(R.id.rv1) as RecyclerView? 37 | 38 | val adapter = SimpleAdapter(mList) 39 | recyclerView?.setLayoutManager( 40 | LinearLayoutManager( 41 | this, 42 | LinearLayoutManager.VERTICAL, 43 | false 44 | ) 45 | ) 46 | //GridLayoutManager 47 | // recyclerView.setLayoutManager(new GridLayoutManager(this, 3, LinearLayoutManager.VERTICAL, false)); 48 | recyclerView?.setAdapter(adapter) 49 | recyclerView?.setEdgeEffectFactory(RecyclerViewVerticaEdgeEffect()) 50 | 51 | recyclerView1?.setLayoutManager( 52 | LinearLayoutManager( 53 | this, 54 | LinearLayoutManager.HORIZONTAL, 55 | false 56 | ) 57 | ) 58 | val adapter1 = SimpleAdapter1(mList) 59 | recyclerView1?.setAdapter(adapter1) 60 | recyclerView1?.setEdgeEffectFactory(RecyclerViewHorizontalEdgeEffect()) 61 | 62 | for (i in 0..29) { 63 | mList.add("$i TEST - $i") 64 | } 65 | } 66 | 67 | internal class SimpleAdapter(mList: ArrayList) : 68 | RecyclerView.Adapter() { 69 | private var mList = ArrayList() 70 | 71 | init { 72 | this.mList = mList 73 | } 74 | 75 | override fun onCreateViewHolder( 76 | parent: ViewGroup, 77 | viewType: Int 78 | ): RecyclerViewVerticaEdgeEffect.SpringVerticaAnimationViewHolder { 79 | return RecyclerViewVerticaEdgeEffect.SpringVerticaAnimationViewHolder( 80 | LayoutInflater.from(parent.context).inflate(R.layout.simple_item, parent, false) 81 | ) 82 | } 83 | 84 | override fun getItemCount(): Int { 85 | return mList.size 86 | } 87 | 88 | override fun onBindViewHolder(holder: RecyclerViewVerticaEdgeEffect.SpringVerticaAnimationViewHolder, position: Int) { 89 | (holder.itemView.findViewById(R.id.textView) as TextView).text = 90 | "12 " + mList[position] 91 | } 92 | 93 | } 94 | 95 | internal class SimpleAdapter1(mList: ArrayList) : 96 | RecyclerView.Adapter() { 97 | private var mList = ArrayList() 98 | 99 | init { 100 | this.mList = mList 101 | } 102 | 103 | override fun onCreateViewHolder( 104 | parent: ViewGroup, 105 | viewType: Int 106 | ): RecyclerViewHorizontalEdgeEffect.SpringHorizontalAnimationViewHolder { 107 | return RecyclerViewHorizontalEdgeEffect.SpringHorizontalAnimationViewHolder( 108 | LayoutInflater.from(parent.context) 109 | .inflate(R.layout.horizontal_simple_item, parent, false) 110 | ) 111 | } 112 | 113 | override fun getItemCount(): Int { 114 | return mList.size 115 | } 116 | 117 | override fun onBindViewHolder(holder: RecyclerViewHorizontalEdgeEffect.SpringHorizontalAnimationViewHolder, position: Int) { 118 | (holder.itemView.findViewById(R.id.textView) as TextView).text = "12 " + mList[position] 119 | } 120 | 121 | } 122 | } -------------------------------------------------------------------------------- /app/src/main/java/me/kpa/edgeeffect/SimpleScrollView1Activity.kt: -------------------------------------------------------------------------------- 1 | package me.kpa.edgeeffect 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.activity.enableEdgeToEdge 6 | import androidx.appcompat.app.AppCompatActivity 7 | import androidx.core.view.ViewCompat 8 | import androidx.core.view.WindowInsetsCompat 9 | 10 | class SimpleScrollView1Activity : AppCompatActivity() { 11 | override fun onCreate(savedInstanceState: Bundle?) { 12 | super.onCreate(savedInstanceState) 13 | this.enableEdgeToEdge() 14 | setContentView(R.layout.activity_simple_scroll_view1) 15 | ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v: View, insets: WindowInsetsCompat -> 16 | val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) 17 | v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) 18 | insets 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/java/me/kpa/edgeeffect/SimpleScrollViewMainActivity.java: -------------------------------------------------------------------------------- 1 | package me.kpa.edgeeffect; 2 | 3 | import android.os.Bundle; 4 | 5 | import androidx.activity.EdgeToEdge; 6 | import androidx.appcompat.app.AppCompatActivity; 7 | import androidx.core.graphics.Insets; 8 | import androidx.core.view.ViewCompat; 9 | import androidx.core.view.WindowInsetsCompat; 10 | import androidx.core.widget.NestedScrollView; 11 | 12 | public class SimpleScrollViewMainActivity extends AppCompatActivity { 13 | 14 | 15 | private NestedScrollView scrollView; 16 | 17 | @Override 18 | protected void onCreate(Bundle savedInstanceState) { 19 | super.onCreate(savedInstanceState); 20 | EdgeToEdge.enable(this); 21 | setContentView(R.layout.activity_simple_scroll_view_main); 22 | ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> { 23 | Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); 24 | v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom); 25 | return insets; 26 | }); 27 | 28 | } 29 | 30 | } -------------------------------------------------------------------------------- /app/src/main/java/me/kpa/edgeeffect/SimpleViewPager2MainActivity.java: -------------------------------------------------------------------------------- 1 | package me.kpa.edgeeffect; 2 | 3 | import android.os.Bundle; 4 | import android.view.LayoutInflater; 5 | import android.view.ViewGroup; 6 | import android.widget.TextView; 7 | 8 | import androidx.activity.EdgeToEdge; 9 | import androidx.annotation.NonNull; 10 | import androidx.appcompat.app.AppCompatActivity; 11 | import androidx.core.graphics.Insets; 12 | import androidx.core.view.ViewCompat; 13 | import androidx.core.view.WindowInsetsCompat; 14 | import androidx.recyclerview.widget.RecyclerView; 15 | import androidx.viewpager2.widget.ViewPager2; 16 | 17 | import java.util.ArrayList; 18 | 19 | import me.kpa.edgeeffect.recycler.RecyclerViewVerticaEdgeEffect; 20 | 21 | 22 | public class SimpleViewPager2MainActivity extends AppCompatActivity { 23 | 24 | private ViewPager2 viewPager2; 25 | 26 | private ArrayList mList = new ArrayList<>(); 27 | @Override 28 | protected void onCreate(Bundle savedInstanceState) { 29 | super.onCreate(savedInstanceState); 30 | EdgeToEdge.enable(this); 31 | setContentView(R.layout.activity_simple_view_pager2_main); 32 | ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> { 33 | Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); 34 | v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom); 35 | return insets; 36 | }); 37 | 38 | viewPager2 = ((ViewPager2) findViewById(R.id.viewPager)); 39 | viewPager2.setOrientation(ViewPager2.ORIENTATION_VERTICAL); 40 | for (int i = 0; i < 30; i++) { 41 | mList.add(i + " TEST - " + i); 42 | } 43 | 44 | viewPager2.setAdapter(new SimpleAdapter(mList)); 45 | 46 | RecyclerView recyclerView = (RecyclerView) viewPager2.getChildAt(0); 47 | recyclerView.setEdgeEffectFactory(new RecyclerViewVerticaEdgeEffect()); 48 | 49 | } 50 | 51 | 52 | static class SimpleAdapter extends RecyclerView.Adapter { 53 | 54 | private ArrayList mList = new ArrayList<>(); 55 | 56 | public SimpleAdapter(ArrayList mList) { 57 | this.mList = mList; 58 | } 59 | 60 | @NonNull 61 | @Override 62 | public RecyclerViewVerticaEdgeEffect.SpringVerticaAnimationViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { 63 | return new RecyclerViewVerticaEdgeEffect.SpringVerticaAnimationViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.vp_simple_item, parent, false)); 64 | } 65 | 66 | @Override 67 | public void onBindViewHolder(@NonNull RecyclerViewVerticaEdgeEffect.SpringVerticaAnimationViewHolder holder, int position) { 68 | ((TextView) holder.itemView.findViewById(R.id.textView)).setText("12 " + mList.get(position)); 69 | } 70 | 71 | @Override 72 | public int getItemCount() { 73 | return mList.size(); 74 | } 75 | } 76 | 77 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 |