前言

日常逛Material UP,发现了Leo Leung设计的multiple-card-flow,觉得效果不错于是写了对应的效果库ECardFlow,随后尾随到他的dribbble,看到了ToFind-Concept-Controller,感觉也不错,索性又做了ECardFlowLayout一起放到了这个库里

ECardFlow是一个用于复数卡片滑动与展开效果的自定义ViewPager控件
ECardFlowLayout是一个为ViewPager提供多种联动背景效果的布局,本体是FrameLayout

这篇文章主要是记录下开发过程中踩过的坑,所以下面就按要点来说了

ViewPager

  • ViewPager中同时显示多个子page,需要给ViewPager的父布局加上属性android:clipChildren="false",确保不会将超出ViewPager范围的子视图裁剪
  • 使用ViewPagersetOffscreenPageLimit(mPreloadPageNum);来预先加载屏幕外的子page视图,否则当滑动到屏幕内才开始加载会有一个闪烁的过程,造成不好的体验,这个预加载的页面值也不宜设置的太高,会增加内存开销
  • ECardFlow是不支持滚动的,只支持滑动切换,所以不会跟随手指移动悬停,那么就要重写touchEvent系列方法来屏蔽ViewPager自带的滑动事件处理,这点下面再谈。页面切换使用的是setCurrentItem方法,ViewPager的源码中能看到这个方法默认的切换时间是200ms,速度太快了不利于动画效果的展示,所以我们需要通过反射在运行时动态的修改滚动时间
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private void initSwitchSpeed(float scrollFactor) {
try {
Class<?> viewpager = ViewPager.class;
Field scroller = viewpager.getDeclaredField("mScroller");
scroller.setAccessible(true);
Field interpolator = viewpager.getDeclaredField("sInterpolator");
interpolator.setAccessible(true);
mScroller = new ScrollerCustomDuration(getContext(),
(Interpolator) interpolator.get(null));
mScroller.setScrollFactor(scrollFactor);
scroller.set(this, mScroller);
} catch (Exception e) {
}
}

PageTransformer

可以看到ECardFlow在左右切换页面时会有一个绕Y轴立体旋转的效果,只需要自定义一个ViewPager.PageTransformer的接口实现类即可,实现接口中的transformPage方法,根据参数中当前页面的位置计算rotateY值赋给page,page.setRotationY(mFraction * mMaxRotateY),最后给ViewPager设置这个TransformersetPageTransformer(true, mTransformer);。那么旋转角度的比率mFraction是怎么控制的呢?仔细观察效果可以看到,对一个页面而言在滑动起始时旋转角度为零,滑到下一个页面位置的过程中,角度先逐渐变大,到达中间位置时达到最大,然后逐渐变小,到达下一个页面位置时角度归零,至此完成一个滑动周期,后面继续滑动时依然保持这个规律。举个栗子,假如当前page的position是1,当position从1滑到1.5时角度逐渐变大,1.5到达峰值,随后下降直到position为2。用一个取绝对值的正弦函数来拟合这个过程不是正好吗!且一个滑动周期对应正弦函数上的半个周期,能计算出正弦函数的周期值是2,由此写出公式:

float mFraction = mDirection * (float) Math.abs(Math.sin(Math.PI * position));

其中mDirection是当前滑动的方向,向右滑动时向正方向旋转,rotateY值为正,反之为负,完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public void transformPage(View page, float position) {
final float height = page.getHeight();
final float width = page.getWidth();
page.setPivotY(0.5f * height);
page.setPivotX(0.5f * width);
if (position >= -2 && position <= 2) {
float mFraction = mDirection * (float) Math.abs(Math.sin(Math.PI * position));
page.setRotationY(mFraction * mMaxRotateY);
page.setScaleX(PAGE_SCALE);
page.setScaleY(PAGE_SCALE);
}
}

TouchEvent

处理手势最重要的是理清思路,明确什么时候该拦截事件、消耗事件或分发原事件处理
重写onInterceptTouchEvent,如果当前ECardFlow是未展开状态的话则拦截滑动事件,交由onTouchEvent处理,如果是展开状态的话则不拦截,由优先级更高的子控件来处理事件,在demo中是由RecyclerView来处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
if (isSwitching) {
return false;
}
mInterLastX = (int) event.getRawX();
mInterLastY = (int) event.getRawY();
break;
case MotionEvent.ACTION_MOVE:
return !isExpanding;
case MotionEvent.ACTION_UP:
mLastX = 0;
mLastY = 0;
mInterLastX = 0;
mInterLastY = 0;
hasReset = true;
}
return super.onInterceptTouchEvent(event);
}

重写onTouchEvent,ECardFlow展开状态下自身不处理事件,否则滑动时判断左右方向来做切换,判断Y轴方向触发上滑来展开,若展开模式设置为点击展开,则是在手指离开屏幕时处理展开。中间加了一些标志位,主要判断当前切换或展开过程是否完成,防止重复执行。

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
@Override
public boolean onTouchEvent(MotionEvent event) {
if (isExpanding) {
return false;
}
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_MOVE:
if(mLastX == 0) {
mLastX = (int) event.getRawX();
}
if(mLastY == 0) {
mLastY = (int) event.getRawY();
}
int mCurX = (int) event.getRawX();
int mCurY = (int) event.getRawY();
if (Math.abs(mCurX - mLastX) > mSlop && hasReset) {
hasReset = false;
if (mCurX > mLastX) {
gotoLast();
} else {
gotoNext();
}
} else if(mExpandMode == SLIDE_UP_TO_EXPAND && mLastY - mCurY > mSlop && hasReset && !isSwitching) {
hasReset = false;
expand();
}
break;
case MotionEvent.ACTION_UP:
int mUpX = (int) event.getRawX();
int mUpY = (int) event.getRawY();
if (mExpandMode == CLICK_TO_EXPAND && Math.abs(mUpX - mInterLastX) <= mSlop && Math.abs(mUpY - mInterLastY) <= mSlop && !isSwitching) {
expand();
}
mLastX = 0;
mLastY = 0;
hasReset = true;
break;
}
return true;
}

PaddingBottom

兴高采烈的撸完了代码,发现ECardFlow展开后将RecyclerView滑动到最底部会显示不全,WTF?!仔细分析发现,由于展开效果是基于Scale动画的,父布局的大小不会变化,放大后的RecyclerView有一部分已经在屏幕底部之外了,所以滑动到最底部时会暴露出显示不全的感觉,我们需要把这部分屏幕外的内容给“顶”回屏幕内,即计算这部分内容的高度,增加对应大小的paddingBottom,代码如下:

1
2
3
4
5
6
7
8
//计算额外的paddingBottom的大小
private int getExtraPaddingBottom() {
if (getHeight() * mRate > DimenUtils.getScreenHeight(getContext().getApplicationContext())) {
return (int) ((getHeight() * mRate - DimenUtils.getScreenHeight(getContext().getApplicationContext())) / mRate);
} else {
return 0;
}
}
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
//展开动画结束后增加这段额外的paddingBottom
anim.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
ECardFlow.this.setPadding(
ECardFlow.this.getPaddingLeft(),
ECardFlow.this.getPaddingTop(),
ECardFlow.this.getPaddingRight(),
ECardFlow.this.getPaddingBottom() + getExtraPaddingBottom());
super.onAnimationEnd(animation);
}
});
//收缩动画开始时减去这段额外的paddingBottom
anim.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animation) {
ECardFlow.this.setPadding(
ECardFlow.this.getPaddingLeft(),
ECardFlow.this.getPaddingTop(),
ECardFlow.this.getPaddingRight(),
ECardFlow.this.getPaddingBottom() - getExtraPaddingBottom());
super.onAnimationEnd(animation);
}
});

Bitmap

ECardFlowLayout使用LruCache来管理Bitmap,LruCache本身并不具有缓存Bitmap的能力,它只是一个给定大小后可以依照Lru算法,在达到容量上限时优先移除最近最久未使用的对象的容器。
使用LruCache时可以重写3个重要方法:

  • sizeOf用于返回当前对象占据的容器容量,如果LruCache创建时传入的缓存空间大小是以内存大小为单位的话,这里也要返回对象占据的内存大小,比如Bitmap的大小value.getBitmap().getByteCount()。如果创建时是以数量为单位的话,这里直接返回1即可
  • entryRemoved是对象从LruCache中移除时产生的回调,需要在这里做回收处理,但是需要注意的是在这里调用bitmap.recycler()要根据实际情况考虑,假如该bitmap从LruCache移除时仍在界面上显示,直接调用recycler()可能会导致问题,具体该在什么时候回收bitmap下面再详谈
  • createmLruCache.get(key)从当前缓存中未取到所需对象时才会调用的方法,此时get方法会把新创建的对象返回,如果不重写该方法就可能会返回null,需要做额外的判空处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
mLruCache = new LruCache<String, RecyclingBitmapDrawable>(cacheSize) {
@Override
protected int sizeOf(String key, RecyclingBitmapDrawable value) {
return value.getBitmap().getByteCount() / 1024;
}
@Override
protected void entryRemoved(boolean evicted, String key, RecyclingBitmapDrawable oldValue, RecyclingBitmapDrawable newValue) {
super.entryRemoved(evicted, key, oldValue, newValue);
if (evicted && oldValue != null) {
oldValue.setIsCached(false);
}
}
@Override
protected RecyclingBitmapDrawable create(String key) {
RecyclingBitmapDrawable bitmap = new RecyclingBitmapDrawable(getResources(), mProvider.onProvider(Integer.valueOf(key)));
if (bitmap.getBitmap() == null)
return null;
bitmap.setIsCached(true);
return bitmap;
}
};

现在承接上面,继续说一说bitmap应该在什么时候回收,这里定义了一个RecyclingBitmapDrawable来负责判断回收时机

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
public class RecyclingBitmapDrawable extends BitmapDrawable {
//记录缓存相关的引用数
private int mCacheRefCount = 0;
//记录显示相关的引用数
private int mDisplayRefCount = 0;
private boolean mHasBeenDisplayed;
public RecyclingBitmapDrawable(Resources res, Bitmap bitmap) {
super(res, bitmap);
}
//每当有ImageView等使用该Bitmap时就传入true让引用数加一,
//反之当不再使用时传入false让引用数减一,每次引用数发生变化
//都要判断是否应该回收
public void setIsDisplayed(boolean isDisplayed) {
synchronized (this) {
if (isDisplayed) {
mDisplayRefCount++;
mHasBeenDisplayed = true;
} else {
mDisplayRefCount--;
}
}
checkState();
}
//每当有LruCache等容器存入该Bitmap时就传入true让引用数加
//一,反之从容器中移除时传入false让引用数减一,每次引用数发
//生变化都要判断是否应该回收
public void setIsCached(boolean isCached) {
synchronized (this) {
if (isCached) {
mCacheRefCount++;
} else {
mCacheRefCount--;
}
}
checkState();
}
//判断当前是否可以回收,当两个引用参数都为0时表示既不在界面上
//显示,也不在缓存容器中,此时就可以回收了
private synchronized void checkState() {
if (mCacheRefCount <= 0 && mDisplayRefCount <= 0 && mHasBeenDisplayed
&& hasValidBitmap()) {
getBitmap().recycle();
}
}
private synchronized boolean hasValidBitmap() {
Bitmap bitmap = getBitmap();
return bitmap != null && !bitmap.isRecycled();
}
}

上面注释写的已经很详细了,因此在LruCacheentryRemoved方法中应该执行一次recyclingBitmapDrawable(false);,而不是直接回收bitmap。在代码其他合适的地方用setIsCachedsetIsDisplayed方法记录引用数就能让bitmap在正确的时机回收。

Blur

关于高斯模糊效果,这里参考了glide-transformations的做法,引入了两个模糊工具类

  • FastBlur Java编写的模糊算法
  • RSBlur 使用Android自带的RenderScript来做模糊处理,使用时需要在Gradle文件中做相关配置

renderscriptTargetApi 23
renderscriptSupportModeEnabled true

性能上来说RSBlur要更好一些,所以代码中模糊处理时会优先使用RSBlur,无法使用时则会去调用FastBlur

最后

OK收工~ 回顾一下这个项目还是用到不少知识点的,上面的介绍如果有疏漏或者错误也欢迎指出,更多详情戳repo地址ECardFlow

声明:本站所有文章均为原创或翻译,遵循署名-非商业性使用-禁止演绎 4.0 国际许可协议,如需转载请确保您对该协议有足够了解,并附上作者名(Est)及原贴地址