前言
日常逛Material UP,发现了Leo Leung设计的multiple-card-flow,觉得效果不错于是写了对应的效果库ECardFlow,随后尾随到他的dribbble,看到了ToFind-Concept-Controller,感觉也不错,索性又做了ECardFlowLayout一起放到了这个库里
ECardFlow是一个用于复数卡片滑动与展开效果的自定义ViewPager控件
ECardFlowLayout是一个为ViewPager提供多种联动背景效果的布局,本体是FrameLayout
这篇文章主要是记录下开发过程中踩过的坑,所以下面就按要点来说了
- 在
ViewPager
中同时显示多个子page,需要给ViewPager
的父布局加上属性android:clipChildren="false"
,确保不会将超出ViewPager
范围的子视图裁剪
- 使用
ViewPager
的setOffscreenPageLimit(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
| 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
| 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); } }); 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下面再详谈
create
是mLruCache.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); } public void setIsDisplayed(boolean isDisplayed) { synchronized (this) { if (isDisplayed) { mDisplayRefCount++; mHasBeenDisplayed = true; } else { mDisplayRefCount--; } } checkState(); } public void setIsCached(boolean isCached) { synchronized (this) { if (isCached) { mCacheRefCount++; } else { mCacheRefCount--; } } checkState(); } 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(); } }
|
上面注释写的已经很详细了,因此在LruCache
的entryRemoved
方法中应该执行一次recyclingBitmapDrawable(false);
,而不是直接回收bitmap。在代码其他合适的地方用setIsCached
和setIsDisplayed
方法记录引用数就能让bitmap在正确的时机回收。
Blur
关于高斯模糊效果,这里参考了glide-transformations的做法,引入了两个模糊工具类
FastBlur
Java编写的模糊算法
RSBlur
使用Android自带的RenderScript
来做模糊处理,使用时需要在Gradle
文件中做相关配置
renderscriptTargetApi 23
renderscriptSupportModeEnabled true
性能上来说RSBlur
要更好一些,所以代码中模糊处理时会优先使用RSBlur
,无法使用时则会去调用FastBlur
最后
OK收工~ 回顾一下这个项目还是用到不少知识点的,上面的介绍如果有疏漏或者错误也欢迎指出,更多详情戳repo地址ECardFlow
声明:本站所有文章均为原创或翻译,遵循署名-非商业性使用-禁止演绎 4.0 国际许可协议,如需转载请确保您对该协议有足够了解,并附上作者名(Est)及原贴地址