基于Rebound打造绚丽的动画效果
最近闲来无事学习了一下wensefu的高仿探探首页滑动效果StackCardsView,真的很不错。其中涉及到一些不常用的自定义View的属性,让我获益匪浅。所以我就将自己对其项目的学习记录下来,参照的逻辑流程自己实现一遍
个人仿写的地址在github上
在功能实现方面,项目使用了Rebound这个库。Rebound是Facebook出产的通过胡克定律实现的一个类似“弹簧”动画效果的工具包。
参数说明
在回弹参数上,Rebound只有两个参数:tension(拉力)、friction(摩擦力)。拉力越大,弹簧效果越明显;摩擦力阻力越大,回弹效果越不明显。
描述比较空泛,如果想体验一下差别,建议还是跑一下官方demo即可,这里就不多说了
基本使用
先来看看Rebound的弹簧效果
回弹效果
再来看看代码
val springSystem = SpringSystem.create()
val scaleSpring = springSystem.createSpring()
scaleSpring.currentValue = 0.0
scaleSpring.springConfig = SpringConfig.fromOrigamiTensionAndFriction(40.0, 5.0)
scaleSpring.addListener(object : SpringListener {
override fun onSpringUpdate(spring: Spring?) {
val mappedValue = SpringUtil.mapValueFromRangeToRange(spring?.currentValue!!, 0.0, 1.0, 0.5, 1.0).toFloat()
println(mappedValue)
iv_main.scaleX = mappedValue
iv_main.scaleY = mappedValue
}
override fun onSpringEndStateChange(spring: Spring?) {
}
override fun onSpringAtRest(spring: Spring?) {
scaleSpring.setAtRest()
}
override fun onSpringActivate(spring: Spring?) {
}
})
几个主要方法
setCurrentValue:设置弹簧起始值的参数
setEndValue:设置弹簧结束值的参数。调用结束之后将出现值的变化过程,这个过程的监听由SpringListener
完成
setAtRest:立即停止变化过程
SpringListener:
public interface SpringListener {
// 在首次开始运动时候调用。
void onSpringActivate(Spring spring);
// 在advance后调用,表示状态更新。
void onSpringUpdate(Spring spring);
// 在进入rest状态后调用。
void onSpringAtRest(Spring spring);
// 在setEndValue中被调用。只有该Spring在运动中且新的endValue不等于原endValue才会触发
void onSpringEndStateChange(Spring spring);
}
getCurrentValue:获取当前流程进度
SpringUtil.mapValueFromRangeToRange:映射工具类,本代码中将0->1转映射到0.5->1
预备知识
在看别人源码过程中,难免会有一些自己不熟悉或者易混淆的地方需要单独抽出来说明。
- 视图基本属性的使用:
当前层叠效果我们需要通过视图的属性来实现,主要涉及到缩放以及移动,将原本大小一致的三层视图缩放并沿底部排列
层叠效果
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_height="match_parent">
<View
android:id="@+id/view_1"
android:layout_width="300dip"
android:layout_height="300dip"
android:background="@android:color/black" />
<View
android:id="@+id/view_2"
android:layout_width="300dip"
android:layout_height="300dip"
android:background="@android:color/holo_red_light" />
<View
android:id="@+id/view_3"
android:layout_width="300dip"
android:layout_height="300dip"
android:background="@android:color/holo_green_light" />
</RelativeLayout>
view_2.scaleX = Math.pow(0.8, 1.0).toFloat()
view_2.scaleY = Math.pow(0.8, 1.0).toFloat()
view_2.translationY = (1-0.8f)*SizeUtils.dp2px(300f)/2
view_3.scaleX = Math.pow(0.8, 2.0).toFloat()
view_3.scaleY = Math.pow(0.8, 2.0).toFloat()
view_3.translationY = (1-0.64f)*SizeUtils.dp2px(300f)/2
由于属性动画默认以中心点为参考点,故缩放之后向下平移即可
- 同级节点的触摸事件传递顺序
探探的效果跟上一个效果比起来正好反过来。我们实现的效果是前小后大,探探的效果是前大后小。也就是说,整个视图添加过程貌似是倒着来的,每次add的View都在最底层而不是在最上层,这个怎么处理呢?
在之前的文章中我们说过,在事件分发过程中查找View的过程是倒序的。这里我们来详细说明一下childIndex
我们来到这部分源码
final ArrayList<View> preorderedList = buildTouchDispatchChildList();
final boolean customOrder = preorderedList == null && isChildrenDrawingOrderEnabled();
final View[] children = mChildren;
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = getAndVerifyPreorderedIndex(childrenCount, i, customOrder);
...................................省略很多代码.......................................
}
正常情况下childIndex
就是按照倒序顺序而来的视图索引,也就是xml中视图添加的那个顺序。不过请注意这里还有一个参数customOrder
,这个值会改变childIndex
的值
private int getAndVerifyPreorderedIndex(int childrenCount, int i, boolean customOrder) {
final int childIndex;
if (customOrder) {
final int childIndex1 = getChildDrawingOrder(childrenCount, i);
if (childIndex1 >= childrenCount) {
throw new IndexOutOfBoundsException("getChildDrawingOrder() "
+ "returned invalid index " + childIndex1
+ " (child count is " + childrenCount + ")");
}
childIndex = childIndex1;
} else {
childIndex = i;
}
return childIndex;
}
那么什么时候customOrder
为true呢?在preorderedList == null && isChildrenDrawingOrderEnabled()
两个条件满足的时候
一般情况下我们不会设置elevation或者translationZ,所以preorderedList为null;另外一方面,如果我们设置了setChildrenDrawingOrderEnabled(true)
,那么isChildrenDrawingOrderEnabled()
就会返回true了,然后getChildDrawingOrder
就会决定childIndex
的顺序。
class AnimationView: RelativeLayout {
constructor(context: Context, attributeSet: AttributeSet, defStyleAttr: Int) : super(context, attributeSet, defStyleAttr) {
isChildrenDrawingOrderEnabled = true
}
constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet) {
isChildrenDrawingOrderEnabled = true
}
constructor(context: Context) : super(context) {
isChildrenDrawingOrderEnabled = true
}
override fun getChildDrawingOrder(childCount: Int, i: Int): Int {
return childCount - 1 - i
}
}
像这里,如果有3个View,表面上最后一个View进来了,但是我却取的是3-1-2=0,变成最上层视图了
setChildrenDrawingOrderEnabled探探 页面布局UI分析
简单看下探探App的效果
这里不放效果图了,以免有打广告嫌疑
先看看页面布局方面
探探在页面布局方面,主要就是我用红色粗框绘制出来的部分。用最通俗的语言来描述这部分效果就是:这里有三张卡片,呈从大到小、从完全不透明到半透明、距离顶部高度依次增加的顺序排列。由此我们联想出这个效果涉及到如下几个属性:
- 可见卡片个数
- 层叠时相对前一张卡片的边距
- 层叠时相对前一张卡片的缩放比例
- 层叠时相对前一张卡片的渐变比例
- 卡片宽度
- 卡片高度
- 滑动时判定可以消失的最小滑动距离比例
咱们就先通过这几个参数完成页面的布局,随后再实现拖拽时的特效。
实现层叠效果
其实刚才咱们就已经基本了解了这种层叠布局的实现方法了,但是这次我们要添加一下自定义的参数,方便我们在其他场景下的使用
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="StackCardsView">
<!-- 可见卡片个数 -->
<attr name="maxVisibleCnt" format="integer" />
<!-- 层叠时相对前一张卡片的边距 -->
<attr name="edgeHeight" format="integer" />
<!-- 层叠时相对前一张卡片缩放的比例 -->
<attr name="scaleFactor" format="float" />
<!-- 层叠时相对前一张卡片的渐变比例 -->
<attr name="alphaFactor" format="float" />
<!-- 判定可以消失的最小滑动距离比例 -->
<attr name="dismissFactor" format="float" />
<!-- 卡片宽度 -->
<attr name="itemWidth" format="dimension" />
<!-- 卡片高度 -->
<attr name="itemHeight" format="dimension" />
</declare-styleable>
</resources>
这部分没什么好说的,都是通用的变量读取模板
class StackCardsView: FrameLayout {
private val INVALID_SIZE = Int.MIN_VALUE
// 可见卡片个数
var maxVisibleCnt = 0
// 层叠时相对前一张卡片的边距
var edgeHeight = 0
// 层叠时相对前一张卡片的缩放比例
var scaleFactor = 1f
// 层叠时相对前一张卡片的渐变比例
var alphaFactor = 1f
// 滑动时判定可以消失的最小滑动距离比例
var dismissFactor = 0.4f
// 卡片宽度
private var itemWidth = 0
// 卡片高度
private var itemHeight = 0
constructor(context: Context, attributeSet: AttributeSet, defStyleAttr: Int) : super(context, attributeSet, defStyleAttr) {
init(context, attributeSet, defStyleAttr)
}
constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet) {
init(context, attributeSet, 0)
}
constructor(context: Context) : super(context) {
init(context, null, 0)
}
private fun init(context: Context, attributeSet: AttributeSet?, defStyleAttr: Int) {
isChildrenDrawingOrderEnabled = true
val typeArray = context.obtainStyledAttributes(attributeSet, R.styleable.StackCardsView, defStyleAttr, 0)
maxVisibleCnt = typeArray.getInt(R.styleable.StackCardsView_maxVisibleCnt, 3)
edgeHeight = typeArray.getInt(R.styleable.StackCardsView_edgeHeight, SizeUtils.dp2px(8f))
scaleFactor = typeArray.getFloat(R.styleable.StackCardsView_scaleFactor, 0.8f)
alphaFactor = typeArray.getFloat(R.styleable.StackCardsView_alphaFactor, 0.8f)
dismissFactor = typeArray.getFloat(R.styleable.StackCardsView_dismissFactor, 0.4f)
itemWidth = typeArray.getDimensionPixelSize(R.styleable.StackCardsView_itemWidth, INVALID_SIZE)
if (itemWidth == INVALID_SIZE) {
throw IllegalArgumentException("必须设置itemWidth")
}
itemHeight = typeArray.getDimensionPixelSize(R.styleable.StackCardsView_itemHeight, INVALID_SIZE)
if (itemHeight == INVALID_SIZE) {
throw IllegalArgumentException("必须设置itemHeight")
}
typeArray.recycle()
}
override fun getChildDrawingOrder(childCount: Int, i: Int): Int {
return childCount - 1 - i
}
}
原生的addView
与removeView
这次就不选择开放了,这是因为我将额外提供视图增、删的方法,这将会牵扯到比较多的逻辑
override fun addView(child: View?) {
throw UnsupportedOperationException("不支持addView(child: View?)")
}
override fun addView(child: View?, index: Int) {
throw UnsupportedOperationException("不支持addView(child: View?, index: Int)")
}
override fun addView(child: View?, width: Int, height: Int) {
throw UnsupportedOperationException("不支持addView(child: View?, width: Int, height: Int)")
}
override fun addView(child: View?, params: ViewGroup.LayoutParams?) {
throw UnsupportedOperationException("不支持addView(child: View?, params: ViewGroup.LayoutParams?)")
}
override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
throw UnsupportedOperationException("不支持addView(child: View?, index: Int, params: ViewGroup.LayoutParams?)")
}
override fun removeView(view: View?) {
throw UnsupportedOperationException("不支持removeView(view: View?)")
}
override fun removeViewAt(index: Int) {
throw UnsupportedOperationException("不支持removeViewAt(index: Int)")
}
override fun removeViews(start: Int, count: Int) {
throw UnsupportedOperationException("不支持removeViews(start: Int, count: Int)")
}
override fun removeAllViews() {
throw UnsupportedOperationException("不支持removeAllViews()")
}
开始进入正式环节了。首先是对卡片进行配置,就像之前我们在预备知识里面做的那样:对它们的缩放、位移、渐变程度完成配置后布局。在这边,我为后续View的添加功能先做一个铺垫:将所有可见卡片的基础位置从1到N记录下来,后续View添加的位置就是最后一张卡片,即N的位置
// 默认缩放渐变位移区间参数
var mScaleArray: Array<Float?>? = null
var mAlphaArray: Array<Float?>? = null
var mTranslationYArray: Array<Int?>? = null
这里就是具体配置逻辑部分,主要是数据的保存以及视图的调整。可见卡片个数范围内的卡片按照一定的比例进行尺寸、渐变、位移的调整;可见卡片个数以外的视图尺寸、渐变均与最后一张卡片一致,并且透明度为0
/**
* 对所有卡片进行初始化配置,并对缩放、渐变、位移属性区间参数配置完成
*/
private fun adjustChildren() {
val count = childCount
if (count == 0) {
return
}
var scale = 0.0
var alpha = 0.0
var translationY = 0
// 当前最大可见卡片个数
var maxVisibleIndex = Math.min(count, maxVisibleCnt) - 1
mScaleArray = arrayOfNulls(count)
mAlphaArray = arrayOfNulls(count)
mTranslationYArray = arrayOfNulls(count)
// 对所有卡片进行初始化配置,并对缩放、渐变、位移属性区间参数配置完成
// 先对可见卡片进行配置
for (i in 0..maxVisibleIndex) {
val child = getChildAt(i)
scale = Math.pow(scaleFactor.toDouble(), i.toDouble())
mScaleArray!![i] = scale.toFloat()
alpha = Math.pow(alphaFactor.toDouble(), i.toDouble())
mAlphaArray!![i] = alpha.toFloat()
translationY = ((1 - scale) * child.measuredHeight/2 + edgeHeight*i).toInt()
mTranslationYArray!![i] = translationY
child.scaleX = scale.toFloat()
child.scaleY = scale.toFloat()
child.alpha = alpha.toFloat()
child.translationY = translationY.toFloat()
}
// 再对不可见卡片进行配置
for (i in (maxVisibleIndex+1)..count) {
val child = getChildAt(i)
// 不可见卡片直接沿用最后一张可见卡片
mScaleArray!![i] = scale.toFloat()
mAlphaArray!![i] = alpha.toFloat()
mTranslationYArray!![i] = translationY
child.scaleX = scale.toFloat()
child.scaleY = scale.toFloat()
// 透明度设置为0
child.alpha = 0f
child.translationY = translationY.toFloat()
}
}
后续View都是从最底层添加的,所以需要用最后一个View的rect作为参数进行添加。这里我直接用一组rect变量来记录他的位置。同时,并不是每次调用onLayout
都需要调整子视图,所以我们这里加了一个mNeedAdjustChildren
标记
// 提供给新添加View使用的Rect
var mLastLeft = 0
var mLastTop = 0
var mLastRight = 0
var mLastBottom = 0
// 是否需要重新配置卡片的位置
var mNeedAdjustChildren = false
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
if (mNeedAdjustChildren) {
mNeedAdjustChildren = false
adjustChildren()
}
val count = childCount
if (count>0) {
mLastLeft = getChildAt(count - 1).left
mLastTop = getChildAt(count - 1).top
mLastRight = getChildAt(count - 1).right
mLastBottom = getChildAt(count - 1).bottom
}
}
来看一下View整体添加的操作。这里采用addViewInLayout
来添加视图,这是因为addView
往往会触发requestLayout
,这个不是我们所希望看到的,因为我想自己管理onLayout
的调用
/**
* 重新添加全部子视图
*/
private fun initChildren() {
val count = adapter?.getCount()
removeAllViewsInLayout()
if (count != 0 && count != null) {
var maxVisibleIndex = Math.min(count!!, maxVisibleCnt) - 1
for (i in 0..maxVisibleIndex) {
addViewInLayout(adapter?.getView(i, null, this), -1, LayoutParams(itemWidth, itemHeight, Gravity.CENTER), true)
}
}
mNeedAdjustChildren = true
requestLayout()
}
在数据处理这方面模仿了Adapter的使用方式。在StackCardsView
类中添加了Adapter
,希望使用者可以直接通过对Adapter
的操作完成StackCardsView
类中相关数据的处理
abstract inner class Adapter {
abstract fun getCount(): Int
abstract fun getView(position: Int, convertView: View?, parent: ViewGroup): View
// 数据变化的时候调用
fun notifyDataSetChanged() {
initChildren()
}
// 数据增加的时候调用
fun notifyItemInserted(position: Int) {
}
// 数据被删除的时候调用
fun notifyItemRemoved(position: Int) {
}
}
但是在代码编写过程中出现一个问题,由于Adapter
是StackCardsView
的内部类,而CardAdapter
跨包引用了内部类,所以这样的写法在继承时会找不到相应的内部类
这时你只能放弃内部类的写法,转而使用系统提供的观察者基类Observable
。只有这样才能同时满足外部类调用内部类并且这个内部类也可以调用其所在的外部类的方法
InnerDataObserver
就是一个观察者,通过CardDataObservable
去管理全部的观察者们。
inner class InnerDataObserver {
fun notifyDataSetChanged() {
initChildren()
}
fun notifyItemInserted(position: Int) {
}
fun notifyItemRemoved(position: Int) {
}
}
private class CardDataObservable : Observable<InnerDataObserver>() {
fun notifyDataSetChanged() {
for (i in 0 until mObservers.size) {
mObservers[i].notifyDataSetChanged()
}
}
fun notifyItemInserted(position: Int) {
for (i in 0 until mObservers.size) {
mObservers[i].notifyItemInserted(position)
}
}
fun notifyItemRemoved(position: Int) {
for (i in 0 until mObservers.size) {
mObservers[i].notifyItemRemoved(position)
}
}
}
这样CardAdapter
只需要对CardDataObservable
进行操作,即可间接的操作InnerDataObserver
了
abstract class Adapter {
private val ob = CardDataObservable()
// 必须要通过此种方式才能跨包引用到内部类,并且内部类可以使用到外部类的方法
fun registerDataObserver(innerDataObserver: InnerDataObserver) {
ob.registerObserver(innerDataObserver)
}
fun unRegisterDataObserver(innerDataObserver: InnerDataObserver) {
ob.unregisterObserver(innerDataObserver)
}
abstract fun getCount(): Int
abstract fun getView(position: Int, convertView: View?, parent: ViewGroup): View
// 数据变化的时候调用
fun notifyDataSetChanged() {
ob.notifyDataSetChanged()
}
// 数据增加的时候调用
fun notifyItemInserted(position: Int) {
ob.notifyItemInserted(position)
}
// 数据被删除的时候调用
fun notifyItemRemoved(position: Int) {
ob.notifyItemRemoved(position)
}
}
class CardAdapter(private val context: Context, private val list: ArrayList<String>) : StackCardsView.Adapter() {
override fun getCount(): Int = list.size
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
return LayoutInflater.from(context).inflate(R.layout.activity_main, parent, false)
}
}
首次添加的时候,需要将Adapter
中的InnerDataObserver
与StackCardsView
关联起来并绘制视图
fun setCardAdapter(adapter: Adapter) {
this.adapter = adapter
safeRegisterObserver()
initChildren()
}
最后就是调用,完成布局绘制
class StackCardsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_stackcards)
val arrayList = ArrayList<String>()
for (i in 0..10) {
arrayList.add(""+i)
}
val adapter = CardAdapter(this, arrayList)
view_stackcard.setCardAdapter(adapter)
}
}
布局完成效果图
初始化手势操作的处理
布局绘制完成之后,接下来就是手势操作的处理了
说到手势操作,这几个参数恐怕是最常见的了
// 基本滑动参数
private var mTouchSlop: Int? = null
private var mMaxVelocity: Int? = null
private var mMinVelocity: Int? = null
因为涉及到横纵向滑动认可的判断,所以滑动距离与滑动速度都是需要的
另外因为我们用的Rebound,所以基本参数也要初始化
// Debound参数配置
private var springSystem: SpringSystem? = null
private var spring: Spring? = null
完成初始化操作
init {
val configuration = ViewConfiguration.get(stackCardsView.context)
mTouchSlop = configuration.scaledTouchSlop
mMaxVelocity = configuration.scaledMaximumFlingVelocity
mMinVelocity = configuration.scaledMinimumFlingVelocity
springSystem = SpringSystem.create()
}
事件分发操作处理
在完成初始化工作之后,我们就要开始实现滑动流程了。整体来说这个流程还是很简单的。首先,我们要将最上层的那个View找到,就是当前选中的View的下面那个View
/**
* 重新寻找当前可拖动的View
* 特别是在ViewGroup整体刷新的时候原先的mTouchChild已经不在该ViewGroup上,所以要重新获取
*/
private fun findTouchChild() {
val index = stackCardsView.indexOfChild(mTouchChild)
// 将下一个View作为mTouchChild
val next = index +1
mTouchChild = if (next < stackCardsView.childCount) stackCardsView.getChildAt(next) else null
// 当前选中的View的起始位置不会发生变化,所以只需要获取一次就行
if (mTouchChild != null && !mInitPropSetted) {
mInitPropSetted = true
mChildInitX = mTouchChild!!.x
mChildInitY = mTouchChild!!.y
}
}
将当前滑动中的那个View记录下来,并且将其初始化坐标作为一个定值
// 当前选中的View
private var mTouchChild: View? = null
// 当前选中的View的初始位置
var mChildInitX = 0f
var mChildInitY = 0f
这个值只需要配置一次就可以了,所以还需要一个变量mInitPropSetted
记录一下
在onLayout
的adjustChildren()
之后调用一下findTouchChild
,这样就可以重新查找到选中的View了
找到mTouchChild
之后,就开始拖动了。
先看拦截部分,这个部分很简单,仅仅是判断什么时候需要触发事件拦截
fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
val x = ev!!.x
val y = ev.y
if (mTouchChild == null) {
return false
}
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
if (!ViewDragHelperUtils.isTouchOnView(mTouchChild, x, y)) {
return false
}
mInitDownX = x
mInitDownY = y
mLastX = x
mLastY = y
}
MotionEvent.ACTION_MOVE -> {
if (!mIsIntercept) {
// 判断滑动距离是否允许拦截
if (Math.abs(x- mInitDownX) < mTouchSlop!! && Math.abs(y-mInitDownY)< mTouchSlop!!) {
mLastX = x
mLastY = y
return mIsIntercept
}
mIsIntercept = true
}
mLastX = x
mLastY = y
}
}
return mIsIntercept
}
无论事件是否被onInterceptTouchEvent
拦截,业务都需要在onTouchEvent
进行处理。在Move的时候,主要涉及到选中View的移动以及剩余View的渐变、缩放、位移2个流程,而在Up的时候,则需要区分是复原还是移除。
fun onTouchEvent(event: MotionEvent?): Boolean {
val x = event!!.x
val y = event.y
if (mTouchChild == null) {
return false
}
when (event.action) {
MotionEvent.ACTION_DOWN -> {
if (!ViewDragHelperUtils.isTouchOnView(mTouchChild, x, y)) {
return false
}
}
MotionEvent.ACTION_MOVE -> {
if (!mIsIntercept) {
// 判断当前滑动距离是否允许拦截
if (Math.abs(x- mInitDownX) < mTouchSlop!! && Math.abs(y-mInitDownY)< mTouchSlop!!) {
mLastX = x
mLastY = y
return true
}
mIsIntercept = true
}
// 根据位移绘制当前选中的View以及剩余的View
updateTouchChild(x - mLastX, y - mLastY)
// 将剩下未滑动的视图进行调整
onCoverScrolled(mTouchChild!!)
mLastX = x
mLastY = y
}
MotionEvent.ACTION_UP -> {
// 手指松开后,判断下一步是还原还是移除
doAnim()
}
}
return true
}
View的移动
这下来越来越清楚我们应该做什么了吧,先进入updateTouchChild()
方法。我们还是在那几个属性上做文章:将手指的移动距离作为选中View的移动距离、根据滑动距离的比例计算出合适的旋转角度
private fun updateTouchChild(dx: Float, dy: Float) {
mTouchChild!!.x = mTouchChild!!.x + dx
mTouchChild!!.y = mTouchChild!!.y + dy
// 用X轴数值作为进度计算体验度比较好
val progress = (mTouchChild!!.x - mChildInitX) / stackCardsView!!.getDismissDistance()
// 控制进度在一定范围内
var rotation = 8 * progress
if (rotation > 8) {
rotation = 8f
} else if (rotation < -8) {
rotation = -8f
}
mTouchChild!!.rotation = rotation
}
随后进入onCoverScrolled()
方法,要根据当前视图位置来获取移动的比例
/**
* 根据可移动视图移动比例,修改其他视图状态
*/
private fun onCoverScrolled(movingView: View) {
val selfInfo = calcScrollInfo(movingView)
// 根据当前选中视图移动比例后更新剩余的视图
stackCardsView.updateChildren(selfInfo.progress, movingView)
}
比例的获取在calcScrollInfo
方法中,也是根据勾股定理来计算得出的
/**
* 获取当前选中视图移动比例信息
*/
private fun calcScrollInfo(movingView: View) : ScrollInfo {
val x = movingView.x - mChildInitX
val y = movingView.y - mChildInitY
val distance = Math.sqrt((x*x+y*y).toDouble())
return if (distance> stackCardsView.getDismissDistance()) {
ScrollInfo(1f)
}
else {
ScrollInfo(distance.toFloat() / stackCardsView.getDismissDistance())
}
}
最后来到updateChildren
,这里就是绘制剩余视图的地方。找到当前选中的View之后的所有View,对他们的位移、尺寸、渐变进行调节
/**
* 调整未拖动的子View的动画属性
*/
fun updateChildren(progress: Float, scrollingView: View) {
val count = childCount
// 一般情况下都是当前可见的第一个视图是scrollingView,所以未发送滚动的View都是第二个视图开始
val startIndex = indexOfChild(scrollingView) + 1
if (startIndex > count) {
return
}
// 动画执行初始值
var originScale: Double
var originAlpha: Double
var originTranslationY: Int
// 动画执行上限
var maxScale: Double
var maxAlpha: Double
var maxTranslationY: Int
for (i in startIndex until count) {
val view = getChildAt(i)
val originIndex = i - startIndex + 1
val maxIndex = i - startIndex
originScale = mScaleArray!![originIndex]!!.toDouble()
maxScale = mScaleArray!![maxIndex]!!.toDouble()
view.scaleX = ((maxScale - originScale) * progress + originScale).toFloat()
view.scaleY = ((maxScale - originScale) * progress + originScale).toFloat()
originAlpha = mAlphaArray!![originIndex]!!.toDouble()
maxAlpha = mAlphaArray!![maxIndex]!!.toDouble()
view.alpha = ((maxAlpha - originAlpha) * progress + originAlpha).toFloat()
originTranslationY = mTranslationYArray!![originIndex]!!
maxTranslationY = mTranslationYArray!![maxIndex]!!
view.translationY = ((maxTranslationY - originTranslationY) * progress + originTranslationY)
}
}
到此为止滑动结束
滑动收手
最后一个环节就是Up的时候判断是还原还是移除。进入doAnim()
,如果移动距离超过上限,则移除,反之则还原
/**
* 手指松开后,判断下一步是还原还是移除
*/
private fun doAnim() {
if (isDistanceAllowDismiss()) {
doSlowDisappear()
}
else {
animateToInitPos()
}
resetTouch()
}
先看animateToInitPos()
复原的操作。起始点就是当前mTouchChild
的位置,结束点就是mChildInit
。复原过程跟拖动过程其实差别不是很大,只不过放在回调方法里面进行了
private fun animateToInitPos() {
if (spring != null) {
spring!!.removeAllListeners()
}
mAnimStartX = mTouchChild!!.x
mAnimStartY = mTouchChild!!.y
mAnimStartRotation = mTouchChild!!.rotation
spring = springSystem!!.createSpring()
spring!!.springConfig = SpringConfig.fromOrigamiTensionAndFriction(40.0, 5.0)
spring!!.addListener(object : SimpleSpringListener() {
override fun onSpringUpdate(spring: Spring?) {
super.onSpringUpdate(spring)
val process = spring!!.currentValue
mTouchChild!!.x = (mAnimStartX + (mChildInitX - mAnimStartX) * process).toFloat()
mTouchChild!!.y = (mAnimStartY + (mChildInitY - mAnimStartY) * process).toFloat()
mTouchChild!!.rotation = (mAnimStartRotation + (0 - mAnimStartRotation) * process).toFloat()
// 将剩下未滑动的视图进行动画调整
onCoverScrolled(mTouchChild!!)
}
})
spring!!.endValue = 1.0
}
复原
在进入doSlowDisappear()
方法中,这个就是移除操作。我们需要在先增加一个新View,就是调用tryAppendChild
方法,然后通过findTouchChild
找到新的mTouchChild
。随后通过手势判断到底是x方向的滑动还是y方向的滑动,这决定最
终属性动画的property。随后属性动画执行完成之后,将老的mTouchChild移除
private fun doSlowDisappear() {
val disappearView = mTouchChild
val initX = mChildInitX
val initY = mChildInitY
// 添加新子View,重新获取新的可触摸视图
stackCardsView.tryAppendChild()
findTouchChild()
val currentX = disappearView!!.x
val currentY = disappearView.y
val dx = currentX - initX
val dy = currentY - initY
val rect = Rect()
disappearView.getHitRect(rect)
// 动画所需属性
var property: String
var target: Float
var duration: Long
var delta: Float
if (Math.abs(dx) * SLOPE > Math.abs(dy)) {
val width = stackCardsView!!.width
property = "x"
delta = if (dx>0) {
(width - rect.left).toFloat()
} else {
- rect.right.toFloat()
}
target = currentX + delta
duration = ViewDragHelperUtils.computeSettleDuration(stackCardsView, delta.toInt(), 0, 0, 0, mMinVelocity!!, mMaxVelocity!!).toLong()
}
else {
val height = stackCardsView!!.height
property = "y"
delta = if (dy > 0) {
(height - rect.top).toFloat()
} else {
- rect.bottom.toFloat()
}
target = currentY + delta
duration = ViewDragHelperUtils.computeSettleDuration(stackCardsView, 0, delta.toInt(), 0, 0, mMinVelocity!!, mMaxVelocity!!).toLong()
}
val animation: ObjectAnimator = ObjectAnimator.ofFloat(disappearView, property, target).setDuration(duration)
animation.interpolator = ViewDragHelperUtils.sInterpolator
animation.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
super.onAnimationEnd(animation)
// 删除可移动视图
stackCardsView.removeCover()
}
override fun onAnimationStart(animation: Animator?) {
super.onAnimationStart(animation)
}
})
animation.start()
}
这是新增View的代码,不需要调用onLayout
/**
* 添加下一个View
*/
fun tryAppendChild() {
val count = childCount
if (adapter?.getCount()!! > childCount) {
// adapter中数据集与当前ViewGroup中的视图顺序是一对一的,假设当前视图中有三个子View,则取下一个视图的索引正好是3
val view = adapter?.getView(count, null, this)
addViewInLayout(view, -1, LayoutParams(itemWidth, itemHeight, Gravity.CENTER), true)
view?.layout(mLastLeft, mLastTop, mLastRight, mLastBottom)
}
}
最后重置是否拦截这个参数
mIsIntercept = false
移除
参考文章
Android布局中同级节点的触摸事件传递顺序
What is the difference between addView and addViewInLayout?