在 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) }
缺点很明显,如果只是单方向滑动,没什么问题,但是如果按下后来回滑动反人类操作,适配难度较大,这里是直接归零处理,只有往一个方向时才开始计算阻尼
方案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) }
同时自定义 ItemDecoration,必要时还能对滑动方向进行控制
package com.test.recent_task.view.list import android.graphics.Rect import android.view.View import androidx.recyclerview.widget.RecyclerView import com.blankj.utilcode.util.Utils import com.test.recent_task.R import com.test.recent_task.ext.ViewExt.getDimension /** * @author liuzhen * 横向间距 */ class SpacingItemDecoration : RecyclerView.ItemDecoration() { private val leftMargin = Utils.getApp().getDimension(R.dimen.item_margin_left) private val rightMargin = Utils.getApp().getDimension(R.dimen.item_margin_right) private val centerMargin = Utils.getApp().getDimension(R.dimen.common_dp_28) 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 itemCount = parent.adapter?.itemCount ?: 0 val position = parent.getChildAdapterPosition(view) if (position == RecyclerView.NO_POSITION) return val totalWidth = parent.width val itemWidth = view.layoutParams.width val otherMargin = (totalWidth - itemCount * itemWidth) / 2 when { itemCount < 3 && position <= 0 -> outRect.set(otherMargin, centerMargin) itemCount < 3 && position == itemCount - 1 -> outRect.set(centerMargin, otherMargin) position <= 0 -> outRect.set(leftMargin, centerMargin) position == itemCount - 1 -> outRect.set(centerMargin, rightMargin) else -> outRect.set(centerMargin, centerMargin) } touchPosition?.let { if (it > position) { outRect.left += scaleNum outRect.right -= scaleNum } else if (it < position) { outRect.left -= scaleNum outRect.right += scaleNum } } } private fun Rect.set(left: Int, right: Int) = set(left, 0, right, 0) }
结合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) }
完整代码如下
package com.test.recent_task.view.list import android.animation.ValueAnimator import android.annotation.SuppressLint import android.content.Context 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.RecyclerView import com.test.recent_task.R import kotlin.math.abs /** * @author liuzhen * 任务管理器列表 */ class RecentTaskListView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : RecyclerView(context, attrs, defStyleAttr) { companion object { private const val TAG = "RecentTaskListView" } private val itemDecoration = SpacingItemDecoration() 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 autoCancelAnimThreshold = resources.getDimension(R.dimen.common_dp_50) private val directionThreshold = resources.getDimension(R.dimen.common_dp_100) private val pullMaxThreshold = resources.getDimension(R.dimen.common_dp_140) private var dDx = 0F private var dDy = 0F private var itemDY = 0F private var pScrollY = 0F private var isUpScale = false private var isRemove = false var removeTaskListener: ((Int) -> Unit)? = null var taskClickListener: ((Int) -> Unit)? = null var rootClickListener: (() -> Unit)? = null init { addItemDecoration(itemDecoration) layoutManager = manager edgeEffectFactory = DampingEdgeEffect() itemAnimator = TransformerAnimator() } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(e: MotionEvent?): Boolean { touch(e) return super.onTouchEvent(e) } private fun touch(e: MotionEvent?) { e?.apply { when (action) { MotionEvent.ACTION_DOWN -> { downClick(e.x, e.y) downScaleAnim(e.x, e.y) } MotionEvent.ACTION_MOVE -> { downClick(e.x, e.y) val diffY = e.y - dDy val diffX = e.x - dDx val canScroll = abs(diffX) > abs(diffY) && abs(diffX) < directionThreshold manager.setScrollHorizontally(canScroll) val isHorizontalScroll = abs(diffX) > autoCancelAnimThreshold if (isHorizontalScroll && manager.canScrollHorizontally()) { upScaleAnim() } if (!manager.canScrollHorizontally()) { val moveY = e.y - itemDY pScrollY += moveY * 0.5f touchView?.translationY = if (pScrollY > 100) 100f else pScrollY itemDY = e.y } } MotionEvent.ACTION_OUTSIDE, MotionEvent.ACTION_CANCEL -> { removeAnim(0f) manager.reset() pScrollY = 0f itemDY = 0f (edgeEffectFactory as DampingEdgeEffect).up() upScaleAnim() } MotionEvent.ACTION_UP -> { val isScroll = abs(dDx - e.x) < 10 && abs(dDy - e.y) < 10 touchView?.apply { if (-translationY >= pullMaxThreshold) { removeAnim(-rootView.height.toFloat()) } else if (isRemove) { isUpScale = false removeTaskListener?.invoke(getChildAdapterPosition(this)) } else if (isScroll) { taskClickListener?.invoke(getChildAdapterPosition(this)) } else if (abs(translationY) != 0f) { removeAnim(0f) } } manager.reset() pScrollY = 0f itemDY = 0f (edgeEffectFactory as DampingEdgeEffect).up() log("click $touchView\ndown x=$dDx y=$dDy, up x=${e.x} y=${e.y},translationY = $translationY, isScroll $isScroll") if (isScroll && touchView == null) { rootClickListener?.invoke() } upScaleAnim() } } } } private fun isClickRemove(ivDel: View?, x: Float, y: Float) { ivDel?.let { val location = IntArray(2) it.getLocationOnScreen(location) val rangeX = it.width / 4 val rangeY = it.height / 4 if (x > location[0] - rangeX && x < location[0] + it.width && y > location[1] - rangeY && y < location[1] + it.height) { log("isClickRemove = true") isRemove = true } } } private fun removeAnim(endY: Float) { touchView?.apply { animate().translationY(endY).setDuration(150).withEndAction { if (endY != 0f) { removeTaskListener?.invoke(getChildAdapterPosition(this)) } }.start() } } private fun downClick(x: Float, y: Float) { if (itemDY == 0f) { dDx = x dDy = y itemDY = dDy touchView = findChildViewUnder(x, y) isUpScale = false } } private fun downScaleAnim(x: Float, y: Float) { isRemove = false scaleAnim.cancel() touchView?.let { val ivDel = it.findViewById<View>(R.id.delete_icon) isClickRemove(ivDel, x, y) scaleAnim.setFloatValues(1f, 0.9f) scaleAnim.start() } } private fun upScaleAnim() { isRemove = false if (!isUpScale) { isUpScale = true scaleAnim.cancel() touchView?.let { scaleAnim.setFloatValues(it.scaleX, 1f) scaleAnim.start() } } } private fun updateDecoration(scaleNum: Float) { touchView?.apply { scaleX = scaleNum scaleY = scaleNum val touchPosition = getChildAdapterPosition(this) val num = (300 * (1 - scaleNum)).toInt() itemDecoration.updateDecoration(num, touchPosition) invalidateItemDecorations() } } override fun onDetachedFromWindow() { super.onDetachedFromWindow() log("onDetachedFromWindow") scaleAnim.cancel() (edgeEffectFactory as DampingEdgeEffect).up() removeTaskListener = null taskClickListener = null rootClickListener = null touchView = null } private fun log(str: String) = Log.i(TAG, str) }
package com.test.recent_task.view.list import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.RecyclerView import java.util.concurrent.CopyOnWriteArrayList class TransformerAnimator : DefaultItemAnimator() { companion object { private const val TRANSFORMER_TIME = 300L } init { removeDuration = 0 changeDuration = TRANSFORMER_TIME moveDuration = 0 } private val moveList = CopyOnWriteArrayList<MoveBean>() override fun animateRemove(holder: RecyclerView.ViewHolder): Boolean { endAnimation(holder) dispatchRemoveFinished(holder) return false } override fun runPendingAnimations() { if (moveList.isNotEmpty()) { moveList.forEach { bean -> val view = bean.holder.itemView dispatchMoveStarting(bean.holder) view.animate().translationX(0f).setDuration(TRANSFORMER_TIME).withEndAction { view.translationX = 0f dispatchMoveFinished(bean.holder) dispatchFinishedWhenDone() moveList.remove(bean) }.start() } } super.runPendingAnimations() } override fun animateMove( holder: RecyclerView.ViewHolder, fromX: Int, fromY: Int, toX: Int, toY: Int ): Boolean { val view = holder.itemView val foX = fromX + holder.itemView.translationX.toInt() endAnimation(holder) val deltaX = toX - fromX if (deltaX == 0) { dispatchMoveFinished(holder) return false } view.translationX = -deltaX.toFloat() moveList.add(MoveBean(holder, foX, toX)) return true } override fun endAnimation(item: RecyclerView.ViewHolder) { item.itemView.translationX = 0f item.itemView.translationY = 0f moveList.forEach { bean -> if (bean.holder === item) { dispatchMoveFinished(item) moveList.remove(bean) } } super.endAnimation(item) } private fun dispatchFinishedWhenDone() { if (!isRunning) { dispatchAnimationsFinished() } } override fun isRunning(): Boolean { return !moveList.isEmpty() || super.isRunning() } private data class MoveBean( val holder: RecyclerView.ViewHolder, val fromX: Int, val toX: Int, ) }
package com.test.recent_task.view.list import android.content.Context import android.util.Log import androidx.recyclerview.widget.LinearLayoutManager class ScrollLayoutManager(context: Context) : LinearLayoutManager(context) { companion object { private const val TAG = "ScrollLayoutManager" } init { orientation = HORIZONTAL } private var canScrollHorizontally = true private var isLock = false override fun canScrollHorizontally() = canScrollHorizontally fun setScrollHorizontally(canScroll: Boolean) { if (!isLock) { Log.i(TAG, "setScrollHorizontally $canScroll") canScrollHorizontally = canScroll } isLock = true } fun reset() { isLock = false canScrollHorizontally = true } }
package com.test.recent_task.adapter import android.annotation.SuppressLint import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.test.recent_task.bean.TaskBean import com.test.recent_task.databinding.RecentTaskLayoutItemBinding import com.test.recent_task.holder.TaskViewHolder class RecentTaskAdapter : RecyclerView.Adapter<TaskViewHolder>() { private val mData = mutableListOf<TaskBean>() var taskClickListener: ((Int) -> Unit)? = null override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TaskViewHolder { val inflater = LayoutInflater.from(parent.context) return TaskViewHolder( RecentTaskLayoutItemBinding.inflate(inflater, parent, false), taskClickListener ) } override fun onBindViewHolder(holder: TaskViewHolder, position: Int) { holder.onBind(mData[position]) } fun getData() = mData fun getItem(position: Int): TaskBean? { return if (position >= 0 && position < mData.size) { mData[position] } else null } @SuppressLint("NotifyDataSetChanged") fun setData(data: Collection<TaskBean>) { mData.clear() mData.addAll(data) notifyDataSetChanged() } override fun getItemCount() = mData.size fun removeTask(position: Int): TaskBean? { var result: TaskBean? = null if (position >= 0 && position < mData.size) { result = mData.removeAt(position) notifyItemRemoved(position) } return result } fun clear() = mData.clear() }
package com.test.recent_task.holder import androidx.recyclerview.widget.RecyclerView import com.test.recent_task.Constant import com.test.recent_task.R import com.test.recent_task.bean.TaskBean import com.test.recent_task.databinding.RecentTaskLayoutItemBinding import com.test.recent_task.ext.ViewExt.setExtBackground import com.test.recent_task.ext.ViewExt.setExtBackgroundColor import com.test.recent_task.ext.ViewExt.setExtSrc import com.test.recent_task.ext.ViewExt.setExtTextColor import com.test.recent_task.ext.ViewExt.setPXTextSize class TaskViewHolder( val binding: RecentTaskLayoutItemBinding, private val clickListener: ((Int) -> Unit)? ) : RecyclerView.ViewHolder(binding.root) { init { binding.clRoot.post { binding.clRoot.pivotX = binding.clRoot.width / 2f binding.clRoot.pivotY = binding.clRoot.height / 2f } binding.groupTitle.setOnClick { clickListener?.invoke(adapterPosition) } } fun onBind(bean: TaskBean) { binding.apply { if (bean.packageName == Constant.PACKAGE_APPLIB || bean.packageName == Constant.PACKAGE_APPLIB_ABROAD) { ivImg.setExtSrc(R.drawable.icon_task_applab) } else { if (bean.thumbnailBmp != null) { ivImg.setImageBitmap(bean.thumbnailBmp) } else { ivImg.setExtSrc(R.drawable.icon_task_app_default) } } ivImg.setExtBackgroundColor(R.color.task_item_bg) appIcon.setImageBitmap(bean.appLogo) appName.text = bean.title appName.setExtTextColor(R.color.app_name_color) appName.setPXTextSize(R.dimen.app_name_text_size) deleteIcon.setExtBackground(R.drawable.icon_delete_task) } } }
浙公网安备 33010602011771号