仿每日优鲜个人中心滑动效果

效果展示

Hello朋友们,大家好,今天我们来记录分享一下,每日优鲜个人中心的页面交互效果

大家有app的可以点开看一下效果,又是比较复杂的一个实现,之前我们也实现了一个比较复杂的功能就是仿抖音直播间内的侧滑功能,右滑清屏,左滑弹出列表,有兴趣的朋友可以点击链接查看仿抖音右滑清屏,左滑列表功能

好,我们就开始讲下今天实现的功能,先看下效果图
效果图

由于图片上传大小的限制,不能超过5M,所以时间压缩了很多,还有很多细节的地方没有体现到,不过没关系,后边给大家提供下Demo的链接,大家可以自己运行后体验一下

这里我是用了两个图片简单做了下演示,实际使用换成具体的布局实现就可以了

功能分解

可以看的这是一个滑动特效,滑动主要分为三个区域

顶部TitleBar的显示隐藏
屏幕内的主要两部分,第一部分上滑
下边第二部分上滑下滑

  • 难点一

其中顶部的Bar显示隐藏问题不大,都知道是用动画TranslateY上下移动就可以了,难点是下边的两个View

上边View,后边我们称View1的速率跟下边View,下边简称View2的速率是不同的。View1的速度要比View2快

在View2上上下滑动,View1总是更早的比View2先达到顶部和底部,但同时可以观察他们又是同步的,所以肯定是View1移动的

距离跟View2是倍数的关系

View2的下滑松开回弹也比较简单,设置动画的差值器为CycleInterpolator的即可

拦截器方面跟大家分享一个在线调试的链接http://inloop.github.io/interpolator/ 上边可以方便的调试拦截器的效果

因为是动画TranslateY实现,所以就有很多边界需要处理,不能让View1向下滑超过顶部,不能让View2向上滑超过底部等等

其实刚开始有想过用Scrollview实现,继承改变里边滑动逻辑,分别处理View1,View2,然后再对外提供scrollby的距离,让titlebar联动。这样就不用担心View1、View2有滑出边界的风险了。但是实现了一段时间,写了个demo发现实操的难点比想象的要大很多

一个是以View2为单独的ScrollView处理的话是View2的滑动效果就比较流畅简单,但是要对外关联View1和TitleBar就比较麻烦了,并且,从原生App上效果来看,在View1上滑动View2也是有滑动效果的,这就更难处理了

第二种方式是整体一个ScrollView处理,然后单独处理View1的滑动逻辑,实现上也没走通,因为View1单独处理的同时还要考虑ScrollView的滑动,它是在此基础上滑动的,View1的TranslateY肯定是不可控,做不到很流畅的效果,所以最后就放弃了Scrollview的方案,改为全动画实现了

  • 难点二

动画实现的第二个难点是要模拟ScrollView的Fling效果,就是上滑一定速率松开后,view可以自己滑动一段距离。因为不是ScrollView实现,所以这部分肯定也是动画实现了。难点就是不同速度,Fling的效果是不一样的,这部分就不展开了,因为需要根据Demo效果体验,加上代码理解才能明白,大家就自己去看好了

  • 难点三

Fling效果完了以后,判断当前Translate的距离,是不是超过一个分界线,超过了弹到顶部,没超过弹到底部。

这里要注意一下,Fling执行完以后如果刚好在底部或顶部位置,就不要再执行回弹的动画了,多个判断,少执行些无用代码,也是平常编程是要注意的地方,虽然效果上看不出差别,但你心里要清楚它在那跑空代码,耗cpu呢

  • 难点四

先向下滑动,再向上滑动,可以看到View1是立马跟着移动的,而不是等View2回到初始位置后开始移动,这就有点复杂了,如果View1直接根据View2的TranslateY移动是很明显达不到这一点的,因为View2的Translate是先大于0,逐渐增大,然后再小于0,逐渐变小,这里实现不好很不容易达到原生App实现的效果的,体验也差很多,非等View2回到原位后再开始移动View1。

刚开始也是实现到这就卡住了,因为要重构代码,之前的实现方式肯定是达到不了这种效果了。

重构的滑意味之前写好的滑动逻辑都要调整,就好像好不容易从一片荆棘里穿过来,一不小心发现走差道了,目的地在反方向,又要从头走一边的感觉。试了好几次也是差点放弃,好几次想revert的冲动,但是最后还是坚持下来了,还好没放弃

最后看的满意的效果还是很欣慰的,而且看了每日优鲜IOS端的就没实现下滑后上滑立即跟随的效果,而是等View2回到原点后再跟随的滑动,这就让人更自豪了,开心😄!哈哈

实现思路

因为在View1和View2上滑动都有效果,所以肯定是在父ViewGroup里处理的事件分发逻辑,自定义ViewGroup重写onMeasure和onLayout方法,onInterceptTouchEvent方法里处理拦截事件,上下滑动时拦截事件。事件处理在onTouchEvent方法里,MotionEvent.ACTION_MOVE 事件的时候移动View

这里因为要实现下滑后上滑及时滑动View1的效果,所以上下滑动的判断不能按传统比较Down事件里的差值,而要每次在MOVE事件里更新Down坐标点,我们换个名字叫mLastOffsetY,上次位移坐标

核心事件处理逻辑如下

首先是各种动画初始化用属性动画,View1、View2获取,边界限定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
fun initView() {
//手势指示器初始化
mTouchLisener = ScrollTouchListener()
mDetector = GestureDetector(context, mTouchLisener)

mVelocityTracker = VelocityTracker.obtain()

mSecondAnimator = ValueAnimator.ofFloat(0f, 1f).setDuration(1000)
mSecondAnimator.addUpdateListener(ValueAnimator.AnimatorUpdateListener { valueAnimator ->
val value = valueAnimator.animatedValue as Float
Log.e(TAG,"second执行 $value transY:${mSecondChild.translationY}")
if (isDownFirstThenUp)
translateFirst(firstStartY + (value * (firstEndY - firstStartY)))
translateSecond(secondStartY + (value * (secondEndY - secondStartY)) - mSecondChild.translationY)
})
mSecondAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationStart(animation: Animator) {
}

override fun onAnimationEnd(animation: Animator) {

}
})

mSecondFlingAnimator = ValueAnimator.ofFloat(0f, 1f).setDuration(200)
mSecondFlingAnimator.addUpdateListener(ValueAnimator.AnimatorUpdateListener { valueAnimator ->
val value = valueAnimator.animatedValue as Float
Log.e(TAG,"fling执行$value transY:${mSecondChild.translationY}")
translateSecond(secondStartY + (value * (secondEndY - secondStartY)) - mSecondChild.translationY)
})
mSecondFlingAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationStart(animation: Animator) {
}

override fun onAnimationEnd(animation: Animator) {
Log.e(TAG,"fling 完成 $firstTranslateY")
if (isAllAnimCancel) {
Log.e(TAG,"********")
return
}
if ( secondTranslateY > -boundaryLine) {
// 向上滑,未超过分界线抬起,到底部
setPivotStart()
mSecondAnimator.interpolator = DecelerateInterpolator(3f)
mSecondAnimator.start()
} else if (secondTranslateY < -boundaryLine && abs(secondTranslateY) < abs(secondCanMaxUp)) {
// 向上滑超过分界线抬起,到顶部
setPivotEnd()
mSecondAnimator.interpolator = DecelerateInterpolator(2f)
mSecondAnimator.start()
}
}
})
}

/**
* 获得 First 和 Second 子View
* 获得可移动的最大距离,根据xml中值设定
*/
override fun onAttachedToWindow() {
super.onAttachedToWindow()
Log.e(TAG, "onAttach")
mFirstChild = getChildAt(0)
mSecondChild = getChildAt(1)
mSecondChild.post {
secondCanMaxDown = dp2px(context, 60)
secondCanMaxUp =
mSecondChild.height - measuredHeight - mSecondChild.top.toFloat() - secondCanMaxDown
firstCanMaxUp = -mFirstChild.height.toFloat() - 20
boundaryLine = dp2px(context, 130)
barViewCanMaxDown = dp2px(context,90)
Log.e(TAG, "最大移动:$secondCanMaxUp")
}

mShakeAnimator = ObjectAnimator.ofFloat(mSecondChild,"translationY",0f,-5f)
mShakeAnimator.duration = 500
mShakeAnimator.interpolator = CycleInterpolator(2.5f)

mAlphaAnimator = ObjectAnimator.ofFloat(mFirstChild,"Alpha",0.2f,1f)
mAlphaAnimator.duration = 800
}

其次是拦截事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
val x = event.rawX.toInt()
val y = event.rawY.toInt()


when (event.action) {
MotionEvent.ACTION_DOWN -> {
mDownX = x
mDownY = y
mLastOffsetY = y
Log.e(TAG, "按下 $mDownY")
isBombing = false
if (mFirstChild.translationY == 0f)
isDownFirstThenUp = false
isAllAnimCancel = true
if (animatorSet.isRunning){
animatorSet.cancel()
}
if (mSecondAnimator.isRunning) {
mSecondAnimator.cancel()
}
if (mSecondFlingAnimator.isRunning) {
mSecondFlingAnimator.cancel()
}
isAllAnimCancel = false
Log.e(TAG,"-------")
}
MotionEvent.ACTION_MOVE -> {
if ( 0 < abs(y - mDownY)) {
return true
}
}
MotionEvent.ACTION_UP -> {
}
}
return super.onInterceptTouchEvent(event)
}

拦截事件里按下时要处理停止所有动画,模拟ScrollView滑动过程中按下停止

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
override fun onTouchEvent(event: MotionEvent?): Boolean {
mDetector!!.onTouchEvent(event)
mVelocityTracker.addMovement(event)
mVelocityTracker.computeCurrentVelocity(10)
val y = event!!.rawY.toInt()
val offsetY = y - mLastOffsetY

when (event.action) {
MotionEvent.ACTION_MOVE -> {
mLastOffsetY = y
if (offsetY < 0) {
isScrollDownDirectly = false
// 向上滑动
if (mSecondChild.translationY > 0){
isDownFirstThenUp = true
}
translateSecond(offsetY.toFloat() )
} else if (offsetY > 0) {
// 向下滑动
translateSecond(offsetY.toFloat() )
}
if (offsetY > 0 && overCrossBoundary() &&
!mAlphaAnimator.isRunning &&
!isScrollDownDirectly &&
mTitleBarView.translationY > abs(barViewCanMaxDown) - 30){
// alpha正在执行时不执行 && 重复下滑不执行(下滑再上滑后再执行)&& 超过一定高度再执行
mAlphaAnimator.start()
isScrollDownDirectly = true
}
}
MotionEvent.ACTION_UP -> {
Log.e(TAG, "抬起:$secondTranslateY")
// Fling 执行时先执行Fling
if (mSecondFlingAnimator.isRunning)
return true

if (secondTranslateY > 0) {
// 向下滑抬起
Log.e(TAG,"下滑动画:$isDownFirstThenUp")
isBombing = true
setPivotStart()
mSecondAnimator.interpolator = AccelerateDecelerateInterpolator()
animatorSet.play(mShakeAnimator).after(mSecondAnimator)
animatorSet.start()
} else if (abs(secondTranslateY) < abs(boundaryLine)) {
// 向上滑,未超过分界线抬起
Log.e(TAG,"回弹动画:")
setPivotStart()
mSecondAnimator.interpolator = AccelerateDecelerateInterpolator()
mSecondAnimator.start()
} else if (abs(secondTranslateY) > abs(boundaryLine)) {
// 向上滑超过分界线抬起
Log.e(TAG,"顶部动画:")
setPivotEnd()
mSecondAnimator.duration = 200
mSecondAnimator.interpolator = AccelerateDecelerateInterpolator()
mSecondAnimator.start()
}
}
}
return true
}

这里还要监听Fling事件所以要实现自定义手势监听类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
* 自定义手势监听类
*/
private inner class ScrollTouchListener : GestureDetector.OnGestureListener {
//按下事件
override fun onDown(e: MotionEvent): Boolean {
return false
}

//单击事件
override fun onShowPress(e: MotionEvent) {
}

//手指抬起事件
override fun onSingleTapUp(e: MotionEvent): Boolean {
return false
}

//滚动事件
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent?,
distanceX: Float,
distanceY: Float
): Boolean {
Log.e(TAG, "Y: $distanceY")
return false
}

//长按事件
override fun onLongPress(e: MotionEvent) {
}

//滑动事件
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
Log.e(TAG, "fling: $velocityY")
if (abs(mVelocityTracker.yVelocity) > 5 && mFirstChild.translationY != 0f)
simulateFling(velocityY)
return false
}
}

初始化的时候监听手势:

1
2
3
//手势指示器初始化
mTouchLisener = ScrollTouchListener()
mDetector = GestureDetector(context, mTouchLisener)

动画模拟Fling的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 模拟Fling
*/
private fun simulateFling(fling: Float) {
Log.e(TAG, "速度:${mVelocityTracker.yVelocity}")
secondStartY = mSecondChild.translationY
if (abs(mVelocityTracker.yVelocity) < 30){
secondEndY = secondTranslateY + fling / 10
mSecondFlingAnimator.interpolator = DecelerateInterpolator(2f)
} else {
mSecondFlingAnimator.interpolator = DecelerateInterpolator(1f)
secondEndY = secondTranslateY + fling / 5
}
if (secondEndY > 0) {
secondEndY = 0f
} else if (secondEndY < secondCanMaxUp) {
secondEndY = secondCanMaxUp
}
mSecondFlingAnimator.duration = getRangeOfDuration(mVelocityTracker.yVelocity)
mSecondFlingAnimator.start()
Log.e(TAG,"fling 开始执行")
}

里边要根据速度值决定Fling位置的长短还有Fling时间长短,速率决定时间的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 不同速率返回时间段
*/
private fun getRangeOfDuration(velocityY: Float): Long {
var duration = 0
val speed = abs(velocityY)
if (speed > 100) {
duration = 40
} else if (speed > 80 && speed < 100) {
duration = 100
} else if (speed > 50 && speed < 80) {
duration = 250
} else if (speed < 50) {
duration = 500
}
return duration.toLong()
}

每个值都是一遍一遍调试感觉修改整好的,你如果觉得体验不好也可以自己继续修改值直到自己满意为止

最主要的,移动View1、View2的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/**
* 移动First
*/
private fun translateFirst(distanceY: Float){
firstTranslateY = distanceY
Log.e(TAG,"第一个View: $distanceY")
if (Math.abs(firstTranslateY) <= Math.abs(firstCanMaxUp)) {
// 未超过最大距离
if (distanceY < 0 || mFirstChild.translationY < 0) {
//上滑 || 已经上滑一段距离
Log.e(TAG,"执行了")
mFirstChild.translationY = firstTranslateY
} else {
//超过时置0
mFirstChild.translationY = 0f
}
}
}

/**
* 移动Second
*/
private fun translateSecond(distanceY: Float) {
// second 移动距离 计算 = 滑动距离 + 当前移动距离
secondTranslateY = distanceY + mSecondChild.translationY

// first 移动距离
firstTranslateY = distanceY * 1.3f + mFirstChild.translationY
Log.e(TAG,"distanceY: $distanceY")

// 没超过最大距离 && 不是回弹动画 && (不是先下滑后上滑的情况)
if (Math.abs(firstTranslateY) <= Math.abs(firstCanMaxUp)
&& !isBombing
&& (!mSecondAnimator.isRunning || !isDownFirstThenUp)) {
if (distanceY < 0 || firstTranslateY < 0) {
// 移动first
mFirstChild.translationY = firstTranslateY
} else {
mFirstChild.translationY = 0f
}
}
barViewTranslateY = secondTranslateY * 0.5f
if (abs(barViewTranslateY) > abs(barViewCanMaxDown)) {
barViewTranslateY = -barViewCanMaxDown.toFloat()
} else if (mSecondChild.translationY > 0){
barViewTranslateY = 0f
}
if (secondTranslateY > 0 && secondTranslateY > secondCanMaxDown) {
// second超过底部
secondTranslateY = secondCanMaxDown.toFloat()
}
if (distanceY < 0 && Math.abs(secondTranslateY) >= Math.abs(secondCanMaxUp)) {
// second超过顶部
secondTranslateY = secondCanMaxUp
}
val percent = abs(barViewTranslateY) / mTitleBarView.height
mTitleBarView.alpha = percent
// 移动barView
mTitleBarView.translationY = -barViewTranslateY
// 移动second
mSecondChild.translationY = secondTranslateY
}

里边一堆判断,都是各种限制,边界判断,分情况等等的。都是一边体验效果,一边加条件判断,一边修改优化,细活

最后给大家附上链接

GitHub地址

您的点赞是我持续输出写作的动力,写文章用了我三个小时的时间,如果对您有帮助,请花一秒钟的时间给我点点关注,点点赞,谢谢同学们了!!