惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

月光博客
月光博客
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
阮一峰的网络日志
阮一峰的网络日志
罗磊的独立博客
T
Tailwind CSS Blog
博客园_首页
博客园 - 司徒正美
Google DeepMind News
Google DeepMind News
Hugging Face - Blog
Hugging Face - Blog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
V
V2EX
J
Java Code Geeks
量子位
D
DataBreaches.Net
MongoDB | Blog
MongoDB | Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Microsoft Azure Blog
Microsoft Azure Blog
P
Proofpoint News Feed
C
Check Point Blog
V
Visual Studio Blog
H
Help Net Security
Recent Announcements
Recent Announcements
Engineering at Meta
Engineering at Meta

博客园 - 翻滚的咸鱼

Ops权限行为记录源码解析 TaskView方案框架原理 视频解码器问题 常用命令 扫光动效 引导记录 性能优化 View共享动效 常用脚本文件 Launcher 卡片框架多模块集成 Launcher 桌面源码笔记二 Launcher 桌面源码笔记一 氛围灯动态屏保取色方案二 氛围灯动态屏保取色方案一 自定义最近任务管理器 anr问题 TV RecyclerView 焦点处理笔记 多类型适配器 多屏下字体自动取色 图片区域点击处理 RecyclerView设置默认焦点跟多页面焦点抢占 TV RecyclerView 滑动后操作保持落焦在左侧第一个View Fragment翻盖动效 当个View下,使用Drawable入场退场动画
阻尼拉伸方案
翻滚的咸鱼 · 2025-01-15 · via 博客园 - 翻滚的咸鱼

在 recyclerView 列表中,滑动到边界后,继续滑动,会发现自带一个阻尼效果,但是往往不能满足产品需求,需要自定义

比如拉伸的最大距离,或者拉伸的位置

模仿安卓最近任务列表,列表中item可以上下左右滑动,并且左右下方向滑动到边界后会产生阻尼效果,随着拉伸的距离增大而增大

方案1:这里可以自定义阻尼算法,比如粗糙的方式计算

package com.test.recent_task.utils

import android.util.Log
import kotlin.math.max

/**
 * @author liuzhen
 * 拖拽阻尼
 */
class DragDampingHelper {

    companion object {
        private const val TAG = "DragDampingHelper"
    }

    // 最大拉动值
    private var maxNum = 100f

    // 前一次记录位置
    private var previousNum = 0f

    // 起始位置
    private val startNum = 0f

    // 两次移动间移动的相对距离
    private var deltaNum = 0f

    // 结果
    private var result = 0f

    private fun getDampingNum(y: Float, isUp: Boolean): Float {
        // 抬手或者复位时需要清除缓存数据重新计算
        if (isUp || y == 0f) {
            result = 0f
            previousNum = 0f
            return 0f
        }
        deltaNum = y - previousNum
        previousNum = y

        //计算阻尼
        var distance = y - startNum
        if (distance < 0) {
            distance *= -1f
        }

        var damping = (maxNum - distance) / maxNum
        // 1 ~ 0.1
        damping = max(damping, 0.1f)
        if (y - startNum < 0) {
            damping = 1 - damping
        }

        result += deltaNum * damping
        log("getDampingNum previousNum $previousNum deltaNum $deltaNum result $result damping $damping")
        return result
    }

    /** 阻尼拉动 */
    fun pull(y: Float, isUp: Boolean, maxNum: Float): Float {
        this.maxNum = maxNum * 2
        val result = if (y >= 0) getDampingNum(y, isUp) else {
            getDampingNum(0f, false)
            y
        }
        log("pull y $y isUp $isUp result $result")
        return if (result >= maxNum) maxNum else result
    }

    private fun log(str: String) = Log.w(TAG, str)

}

DragDampingHelper

缺点很明显,如果只是单方向滑动,没什么问题,但是如果按下后来回滑动反人类操作,适配难度较大,这里是直接归零处理,只有往一个方向时才开始计算阻尼

方案2:使用 recyclerView 自带的 EdgeEffect

package com.test.recent_task.utils

import android.animation.ValueAnimator
import android.util.Log
import android.view.animation.DecelerateInterpolator
import android.widget.EdgeEffect
import androidx.recyclerview.widget.RecyclerView
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min

/**
 * @author liuzhen
 * 滑动阻尼
 */
class DampingEdgeEffect : RecyclerView.EdgeEffectFactory() {

    private var effect: CustomEdgeEffect? = null

    override fun createEdgeEffect(view: RecyclerView, direction: Int) =
        CustomEdgeEffect(view, direction).apply {
            effect = this
        }

    fun up() = effect?.up()

}

class CustomEdgeEffect(private val recyclerView: RecyclerView, private val direction: Int) :
    EdgeEffect(recyclerView.context) {

    companion object {
        const val TAG = "CustomEdgeEffect"
    }

    private val maxEdgeNum = recyclerView.width / 4.5f

    private val upAnim = ValueAnimator().apply {
        duration = 300
        interpolator = DecelerateInterpolator(2.0f)
        addUpdateListener {
            recyclerView.translationX = it.animatedValue as Float
        }
    }

    override fun onPull(deltaDistance: Float, displacement: Float) {
        super.onPull(deltaDistance, displacement)
        handlePull(deltaDistance)
    }

    private fun handlePull(deltaDistance: Float) {
        val transX = recyclerView.translationX
        val sign = if (direction == RecyclerView.EdgeEffectFactory.DIRECTION_RIGHT) -1 else 1
        val moveX = sign * recyclerView.width * deltaDistance
        // 滑动阻尼
        var offset = 1 - abs(transX) / maxEdgeNum
        if (sign < 0 && transX > 0) {
            offset = 1f
        }
        if (sign > 0 && transX < 0) {
            offset = 1f
        }
        // 1 ~ 0.1
        val damping = max(offset, 0.1f)
        val result = transX + moveX * damping
        log("result $result damping $damping moveX $moveX transX $transX sign $sign")
        recyclerView.translationX = min(result, maxEdgeNum)
    }

    fun up() {
        val transX = recyclerView.translationX
        log("up transX $transX")
        if (abs(transX) > 0) {
            upAnim.cancel()
            upAnim.setFloatValues(transX, 0f)
            upAnim.start()
        }
    }

    private fun log(string: String) = Log.d(TAG, string)

}

DampingEdgeEffect

同时自定义 ItemDecoration,必要时还能对滑动方向进行控制

package com.test.recent_task

import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView

/**
 * @author liuzhen
 * 横向间距
 */
class SpacingItemDecoration(
    private val firstLeftMargin: Int,
    private val lastRightMargin: Int,
    private val margin: Int
) : RecyclerView.ItemDecoration() {

    private var scaleNum: Int = 0
    private var touchPosition: Int? = null

    fun updateDecoration(scaleNum: Int, touchPosition: Int) {
        this.scaleNum = scaleNum
        this.touchPosition = touchPosition
    }

    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        parent: RecyclerView,
        state: RecyclerView.State
    ) {
        val position = parent.getChildAdapterPosition(view)
        when (position) {
            0 -> outRect[firstLeftMargin, 0, margin] = 0
            state.itemCount - 1 -> outRect[margin, 0, lastRightMargin] = 0
            else -> outRect[margin, 0, margin] = 0
        }
        if (touchPosition != null) {
            if ((touchPosition ?: 0) > position) {
                outRect.left += scaleNum
                outRect.right -= scaleNum
            } else if ((touchPosition ?: 0) < position) {
                outRect.left -= scaleNum
                outRect.right += scaleNum
            }
        }
    }

    fun getLeftMargin() = firstLeftMargin

}

SpacingItemDecoration

结合effect通过自定义的方式修改Decoration,达到拉伸的阻尼效果

package com.test.recent_task.view

import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.util.AttributeSet
import android.util.Log
import android.view.MotionEvent
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.test.recent_task.R
import com.test.recent_task.ScrollLayoutManager
import com.test.recent_task.SpacingItemDecoration
import com.test.recent_task.utils.DampingEdgeEffect
import com.test.recent_task.utils.DragDampingHelper
import kotlin.math.abs

/**
 * @author liuzhen
 * 任务管理器列表
 */
class RecentTaskListView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {

    companion object {
        const val TAG = "RecentTaskListView"
    }

    private val itemDecoration = SpacingItemDecoration(
        resources.getDimension(R.dimen.item_margin_left).toInt(),
        resources.getDimension(R.dimen.item_margin_right).toInt(),
        resources.getDimension(R.dimen.common_dp_28).toInt()
    )

    private var touchView: View? = null
    private val scaleAnim = ValueAnimator().apply {
        repeatCount = 0
        repeatMode = ValueAnimator.RESTART
        duration = 150
        interpolator = AccelerateDecelerateInterpolator()
        addUpdateListener { animator ->
            val num = animator.animatedValue as Float
            updateDecoration(num)
        }
    }

    private val manager = ScrollLayoutManager(context)
    private var maxNum = resources.getDimension(R.dimen.common_dp_150)
    private var threshold = resources.getDimension(R.dimen.common_dp_50)
    private var dDx = 0F
    private var dDy = 0F

    // 用于列表滑动跟抬起时多次触发动效
    private var isUpScale = false

    var removeTaskListener: ((Int) -> Unit)? = null
    var taskClickListener: ((Int) -> Unit)? = null
    var rootClickListener: (() -> Unit)? = null

    private val helper = ItemTouchHelper(object : ItemTouchHelper.Callback() {
        private var swipeBack = false
        private val dragDampingHelper = DragDampingHelper()
        override fun getMovementFlags(
            recyclerView: RecyclerView,
            viewHolder: ViewHolder
        ): Int {
            val swipeFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
            return makeMovementFlags(0, swipeFlags)
        }

        override fun onMove(
            recyclerView: RecyclerView,
            viewHolder: ViewHolder,
            target: ViewHolder
        ): Boolean {
            Log.d(TAG, "onMove position=${viewHolder.adapterPosition}")
            return false
        }

        override fun onSwiped(viewHolder: ViewHolder, direction: Int) {
            val position = viewHolder.adapterPosition
            Log.d(TAG, "onSwiped position=$position data.size=${adapter?.itemCount}")
            if (position >= 0) {
                swipeBack = false
                removeTaskListener?.invoke(position)
            }
        }

        override fun onChildDraw(
            c: Canvas,
            rv: RecyclerView,
            viewHolder: ViewHolder,
            dX: Float,
            dY: Float,
            actionState: Int,
            isCurrentlyActive: Boolean
        ) {
            val isUp = !isCurrentlyActive
            val offDy = dragDampingHelper.pull(dY, isUp, 100f)
            swipeBack = dY > 100
            setCanScroll(abs(offDy) == 0f || !isCurrentlyActive)
            Log.d(TAG, "onChildDraw swipeBack=$swipeBack")
            super.onChildDraw(c, rv, viewHolder, dX, offDy, actionState, isCurrentlyActive)
        }

        override fun convertToAbsoluteDirection(flags: Int, layoutDirection: Int): Int {
            return if (swipeBack) 0 else super.convertToAbsoluteDirection(flags, layoutDirection)
        }

        override fun getSwipeThreshold(viewHolder: ViewHolder) = 0.151f

        override fun getSwipeEscapeVelocity(defaultValue: Float) = 500F
    })

    init {
        addItemDecoration(itemDecoration)
        helper.attachToRecyclerView(this)
        layoutManager = manager
        edgeEffectFactory = DampingEdgeEffect()
        post {
            maxNum = width / 4f
        }
    }

    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(e: MotionEvent?): Boolean {
        touch(e)
        return super.onTouchEvent(e)
    }

    private fun touch(e: MotionEvent?) {
        Log.v(TAG, "touch action ${e?.action}")
        e?.apply {
            when (action) {
                MotionEvent.ACTION_DOWN -> {
                    dDx = e.x
                    dDy = e.y
                    touchView = findChildViewUnder(x, y)
                    downScale()
                }

                MotionEvent.ACTION_MOVE -> {
                    // 正常滑动距离
                    val scrollX = dDx - e.x
                    // 滑动超阈值,需要取消动效
                    val isHorizontalScroll = abs(scrollX) > threshold
                    if (isHorizontalScroll && manager.canScrollHorizontally()) {
                        upScale()
                    }
                }

                MotionEvent.ACTION_UP -> {
                    val isScroll = abs(dDx - e.x) < 10 && abs(dDy - e.y) < 10
                    log("click $touchView\ndown x=$dDx y=$dDy, up x=${e.x} y=${e.y}, isScroll $isScroll")
                    (edgeEffectFactory as DampingEdgeEffect).up()
                    setCanScroll(true)
                    upScale()
                    // 滑动时不触发点击
                    if (isScroll) {
                        if (touchView == null) {
                            rootClickListener?.invoke()
                        } else {
                            taskClickListener?.invoke(getChildAdapterPosition(touchView!!))
                        }
                    }
                }
            }
        }
    }

    private fun downScale() {
        log("downScale")
        isUpScale = false
        scaleAnim.cancel()
        touchView?.let {
            scaleAnim.setFloatValues(1f, 0.9f)
            scaleAnim.start()
        }
    }

    private fun upScale() {
        log("upScale isUpScale $isUpScale")
        if (!isUpScale) {
            isUpScale = true
            scaleAnim.cancel()
            touchView?.let {
                scaleAnim.setFloatValues(it.scaleX, 1f)
                scaleAnim.start()
            }
        }
    }

    fun setCanScroll(canScroll: Boolean) {
        log("setCanScroll $canScroll")
        manager.setCanScroll(canScroll)
    }

    /**
     * desc:触摸时更新间距
     * @param scaleNum 缩放度
     */
    private fun updateDecoration(scaleNum: Float) {
        touchView?.apply {
            scaleX = scaleNum
            scaleY = scaleNum
            val touchPosition = getChildAdapterPosition(this)
            log("updateDecoration touchPosition $touchPosition scaleNum $scaleNum")
            val num = (300 * (1 - scaleNum)).toInt()
            itemDecoration.updateDecoration(num, touchPosition)
            invalidateItemDecorations()
        }
    }

    private fun log(str: String) = Log.d(TAG, str)

}

RecentTaskListView