一起撸个朋友圈吧 - 图片浏览(下)【ViewPager优化】
项目地址:https://github.com/razerdp/FriendCircle (能弱弱的求个star或者fork么QAQ)
上篇链接:http://www.jianshu.com/p/4c5b5d7dc856
下篇链接:http://www.jianshu.com/p/42119b89c26f
【ps:评论功能羽翼君我补全了后台交互了哟,如果您想体验一下不同的用户而不是一直都是羽翼君,可以在FriendCircleApp下,在onCreate中,将LocalHostInfo.INSTANCE.setHostId(1001);
的id改为1001~1115之间任意一个】
在上一篇,我们实现了朋友圈的图片浏览,在文章的最后,留下了几个问题,那么这一片我们解决这些。
本篇需要解决的几个问题(本篇主要为控件的自定义,但相信我,不会很难):
- viewpager如何复用
- 图片浏览viewpager的指示器
本篇图片预览如下:
Q1:指示器
我们知道,在微信图片浏览的时候,多张图下方是有个指示器的,比如这样
当然,我们可以找库,但这个如此简单的控件为此花时间去找库,倒不如我们自己来定制一番对吧。
我们来分析一下,可以如何实现这个指示器功能。
首先可以确认的是,指示器要跟ViewPager联调,就必须要跟ViewPager的滑动状态进行关联。
而对于ViewPager的滑动状态,使用的最多的就是ViewPager.OnPageChangeListener
这个接口。
从图中我们可以看到,微信下方的指示器滑动的时候,白点并没有什么移动动画,而是直接就跳到另一个点上面了,这样一来,这个控件的实现就更加的容易了。
因此我们可以初步得到思路如下:
首先可以肯定的是,指示器不应该隶属于ViewPager,否则每次instantiateItem的时候又inflate出来是很不合理的,所以我们的indicator必须跟ViewPager同级,但可以通过ViewPager的滑动状态来改变。
第二,小点点的数量永远都是0~9,因为微信的图片数量最多9张。
第三,小点点都是水平居中,因此我们的indicator可以继承LinearLayout来实现。
第四,小点点有两个状态,一个选中,一个非选中。所以小点点的定制必须要提供改变选中状态的接口。
Q1 - 代码的编写:
小点点的自定义
既然思路有了,那么剩下来的也仅仅是用代码将我们的思路实现而已。
首先我们来弄小点点。
由于我懒得打开AE,所以我选择直接采用Drawable的方式来写。
来到drawable文件下,新建一个drawable
首先来定制一个未选中状态的drawable
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval"> <size android:width="25dp" android:height="25dp"/> <stroke android:color="@color/white" android:width="1dp"/> </shape>
代码非常简单,效果也仅仅是一个圆环。
而选中的实心圆只是把上述代码的stroke换成solid而已,这里就略过了。
然后我们新建一个类继承View,叫做**“DotView”**
或许看到继承View你就会觉得,难道又要重写onMeasure,onLayout什么的?烦死了。。。。
其实不用,毕竟咱们用的是drawable。。。
我们的代码整体结构如下:
public class DotView extends View { private static final String TAG = "DotView"; //正常状态下的dot Drawable mDotNormal; //选中状态下的dot Drawable mDotSelected; private boolean isSelected; public DotView(Context context) { this(context, null); } public DotView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public DotView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context, attrs); } private void init(Context context, AttributeSet attrs) { mDotNormal = context.getResources().getDrawable(R.drawable.ic_viewpager_dot_indicator_normal); mDotSelected = context.getResources().getDrawable(R.drawable.ic_viewpager_dot_indicator_selected); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); } public void setSelected(boolean selected) { this.isSelected = selected; invalidate(); } public boolean getSelected() { return isSelected; } }
可以看到,我们只需要实现onDraw方法和提供是否选中的方法而已。其他的都不需要。
在onDraw里面,我们编写以下代码:
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int width=getWidth(); int height=getHeight(); if (isSelected) { mDotSelected.setBounds(0,0,width,height); mDotSelected.draw(canvas); } else { mDotNormal.setBounds(0,0,width,height); mDotNormal.draw(canvas); } }
这里仅仅为了确定drawable的大小并根据不同的状态进行不同的drawable绘制。非常简单。
indicator的自定义
在上面的思路里,我们可以通过继承LinearLayout来实现指示器。
因此我们新建一个类继承LinearLayout,取名**“DotIndicator”**
在这个指示器中,我们需要确定他拥有的功能:
- 包含0~9个DotView
- 通过公有方法来设置当前选中的DotView
- 通过公有方法来设置当前显示的DotView的数量
因此我们可以初步设计以下代码结构:
package razerdp.friendcircle.widget; import android.content.Context; import android.util.AttributeSet; import android.util.Log; import android.view.Gravity; import android.widget.LinearLayout; import java.util.ArrayList; import java.util.List; import razerdp.friendcircle.utils.UIHelper; /** * Created by 大灯泡 on 2016/4/21. * viewpager图片浏览器底部的小点点指示器 */ public class DotIndicator extends LinearLayout { private static final String TAG = "DotIndicator"; List<DotView> mDotViews; private int currentSelection = 0; private int mDotsNum = 9; public DotIndicator(Context context) { this(context,null); } public DotIndicator(Context context, AttributeSet attrs) { this(context, attrs,0); } public DotIndicator(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context, attrs); } private void init(Context context, AttributeSet attrs) { setOrientation(HORIZONTAL); setGravity(Gravity.CENTER); buildDotView(context); } /** * 初始化dotview * @param context */ private void buildDotView(Context context) { } /** * 当前选中的dotview * @param selection */ public void setCurrentSelection(int selection) { } public int getCurrentSelection() { return currentSelection; } /** * 当前需要展示的dotview数量 * @param num */ public void setDotViewNum(int num) { } public int getDotViewNum() { return mDotsNum; } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); mDotViews.clear(); mDotViews=null; Log.d(TAG, "清除dotview引用"); } }
在这里说明一下,由于我们操作不同位置的dotview,所以我们需要有一个列表来存下这些dotview。
另外,我们设置指示器必须是水平的同时Gravity=CENTER
另外注意记得在onDetachedFromWindow清除所有引用哦。否则无法回收就内存泄漏了。
接下来我们补全代码。
首先是buildDotView
在这里我们将会进行indicator的初始化,也就是将9个dotView添加进来
/** * 初始化dotview * @param context */ private void buildDotView(Context context) { mDotViews = new ArrayList<>(); for (int i = 0; i < 9; i++) { DotView dotView = new DotView(context); dotView.setSelected(false); LinearLayout.LayoutParams params = new LayoutParams(UIHelper.dipToPx(context, 10f), UIHelper.dipToPx(context, 10f)); if (i == 0) { params.leftMargin = 0; } else { params.leftMargin = UIHelper.dipToPx(context, 6f); } addView(dotView,params); mDotViews.add(dotView); } }
这里有一个需要注意的是第0个dotview是不需要marginleft的。
接下来补全setCurrentSelection
这个方法我们的思路也很简单,首先将所有的DotView设置为未选中状态,然后再设置对应num的DotView为选中状态。虽然是遍历了两次数组,但因为很少东西,而且CPU的处理速度完全可以在肉眼无法观察的速度下完成,所以这里无需过度考虑。
/** * 当前选中的dotview * @param selection */ public void setCurrentSelection(int selection) { this.currentSelection = selection; for (DotView dotView : mDotViews) { dotView.setSelected(false); } if (selection >= 0 && selection < mDotViews.size()) { mDotViews.get(selection).setSelected(true); } else { Log.e(TAG, "the selection can not over dotViews size"); } }
值得注意的是,我们需要留意边界问题
最后我们补全setDotViewNum
这里的思路跟上面的差不多,首先我们将所有的dotview设置为可见,然后将指定数量之后的dotview设置为GONE,这时候由于LinearLayout的Gravity是CENTER,所以剩余的dotView会水平居中。
/** * 当前需要展示的dotview数量 * @param num */ public void setDotViewNum(int num) { if (num > 9 || num <= 0) { Log.e(TAG, "num必须在1~9之间哦"); return; } for (DotView dotView : mDotViews) { dotView.setVisibility(VISIBLE); } this.mDotsNum = num; for (int i = num; i < mDotViews.size(); i++) { DotView dotView = mDotViews.get(i); if (dotView != null) { dotView.setSelected(false); dotView.setVisibility(GONE); } } }
同样需要注意边界问题。
完成之后,我们回到图片浏览的布局,将我们的自定义dotindicator添加到布局,并对其父布局底部。
最后在我们封装好的PhotoPagerManager引入DotIndicator
在调用showPhoto的时候,先设置dotindicator展示的dotview数量,然后再设置选中的dotview
最后在viewpager的pagechangerlistener监听中设置dotindicator的对应方法就好了
【DotIndicator完】
Q2:viewpager复用
在上一篇文章,我们看到当某个动态的图片数量超过3张,我们点击第四张图片的时候,会发现放大动画并不明显。
这是因为ViewPager的机制,ViewPager默认会缓存当前item左右共三个view,当划到第四个,则会重新执行initItem,对应我们的adapter,就是重新new了一个PhotoView,由于这个PhotoView并没有图片,所以放大动画无法展示。
而我们选择解决方案就是,在adapter初始化的时候,就直接把9个photoview给new出来放到一个对象池里面,每次执行到instantiateItem就从池里面拿出来,这样就可以防止每次都new,保证放大动画。
因此我们的改动如下:
/** * Created by 大灯泡 on 2016/4/12. * 图片浏览窗口的adapter */ public class PhotoBoswerPagerAdapter extends PagerAdapter { private static final String TAG = "PhotoBoswerPagerAdapter"; private static ArrayList<MPhotoView> sMPhotoViewPool; private static final int sMPhotoViewPoolSize = 10; ...跟上次一样 public PhotoBoswerPagerAdapter(Context context) { ...不变 sMPhotoViewPool = new ArrayList<>(); //buildProgressTV(context); buildMPhotoViewPool(context); } private void buildMPhotoViewPool(Context context) { for (int i = 0; i < sMPhotoViewPoolSize; i++) { MPhotoView sPhotoView = new MPhotoView(context); sPhotoView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); sMPhotoViewPool.add(sPhotoView); } } ...resetDatas()方法不变 @Override public Object instantiateItem(ViewGroup container, int position) { MPhotoView mPhotoView = sMPhotoViewPool.get(position); if (mPhotoView == null) { mPhotoView = new MPhotoView(mContext); mPhotoView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); } Glide.with(mContext).load(photoAddress.get(position)).into(mPhotoView); container.addView(mPhotoView); return mPhotoView; } ...setPrimaryItem()方法不变 @Override public void destroyItem(ViewGroup container, int position, Object object) { container.removeView((View) object); } ...其余方法不变 //=============================================================destroy public void destroy(){ for (MPhotoView photoView : sMPhotoViewPool) { photoView.destroy(); } sMPhotoViewPool.clear(); sMPhotoViewPool=null; } }
在adapter初始化的时候,我们将对象池new出来,并new出10个photoview添加到池里面。
在instantiateItem我们直接从池里面拿出来,如果没有,才创建。然后跟以前一样,glide载入。
在destroyItem我们把view给remove掉,这样可以防止在instantiateItem的时候在池里拿出的view拥有parent导致了异常的抛出。
最后记得提供destroy方法来清掉池的引用哦。
Q2 - 关于PhotoView在ViewPager里面爆出的"ImageView no longer exists. You should not use this PhotoViewAttacher any more."错误
如果您细心,会发现我的代码里写的是MPhotoView而不是PhotoView
原因就是如小标题。
在viewpager中,如果采用对象池的方式结合PhotoView来实现复用,就会因为这个错误而导致PhotoView的点击事件无法相应。
要解决这个问题,就必须得查看PhotoView的源码。
首先我们找到这个错误的提示位置
首先PhotoView的实现跟我们PhotoPagerMananger的实现思路差不多,都是将事件的处理委托给另一个对象,这样的好处是可以降低耦合度,其他的控件想实现类似功能会更简单。
在getImageView中,如果imageview==null,就会log出这个错误。
我们看看imageview的引用,在PhotoViewAttacher中,imageview是属于弱引用,这样可以更快的被回收。
而imageview的清理则是在cleanup中
/** * Clean-up the resources attached to this object. This needs to be called when the ImageView is * no longer used. A good example is from {@link android.view.View#onDetachedFromWindow()} or * from {@link android.app.Activity#onDestroy()}. This is automatically called if you are using * {@link uk.co.senab.photoview.PhotoView}. */ @SuppressWarnings("deprecation") public void cleanup() { if (null == mImageView) { return; // cleanup already done } final ImageView imageView = mImageView.get(); if (null != imageView) { // Remove this as a global layout listener ViewTreeObserver observer = imageView.getViewTreeObserver(); if (null != observer && observer.isAlive()) { observer.removeGlobalOnLayoutListener(this); } // Remove the ImageView's reference to this imageView.setOnTouchListener(null); // make sure a pending fling runnable won't be run cancelFling(); } if (null != mGestureDetector) { mGestureDetector.setOnDoubleTapListener(null); } // Clear listeners too mMatrixChangeListener = null; mPhotoTapListener = null; mViewTapListener = null; // Finally, clear ImageView mImageView = null; }
那么现在问题的出现就很明显了,爆出这个错误是因为imageview==null,也就是说两个可能:
- 要么被执行了cleanup
- 要么就是引用的对象被销毁了
第二点我们可以排除,因为我们有个list来引用着photoview,所以只可能是第一个问题。
最终,我们在PhotoView的onDetachedFromWindow找到了cleanup方法的调用
还记得在ViewPager中我们的destroyItem吗,那里我们执行的是container.remove(View),一个View在被remove的时候会回调onDetachedFromWindow。
而在PhotoView中,回调的时候就会执行attacher.cleanup,也就是说attacher已经没有了imageview的引用,然而我们的photoview却是在我们的池里面。
这样导致的结果就是在下一次instantiateItem时,从池里拿出的photoview里面的attacher根本就没有imageview的引用,所以就会log出那个错误。
所以我们的解决方法就很明了了:
把photoview的代码copy,注释掉onDetachedFromWindow中的mattacher.cleanup,然后提供cleanup方法来手动进行attacher.cleanup,这样就可以避免这个错误了。
大概代码如下:
/** * Created by 大灯泡 on 2016/4/14. * * 针对onDetachedFromWindow * * 因为PhotoView在这里会导致attacher.cleanup,从而导致attacher的imageview=null * 最终无法在viewpager响应onPhotoViewClick * * 这里将cleanup注释掉,把cleanup移到手动调用方法中 */ public class MPhotoView extends ImageView implements IPhotoView { private PhotoViewAttacher mAttacher; private ScaleType mPendingScaleType; public MPhotoView(Context context) { this(context, null); } public MPhotoView(Context context, AttributeSet attr) { this(context, attr, 0); } public MPhotoView(Context context, AttributeSet attr, int defStyle) { super(context, attr, defStyle); super.setScaleType(ScaleType.MATRIX); init(); } protected void init() { if (null == mAttacher || null == mAttacher.getImageView()) { mAttacher = new PhotoViewAttacher(this); } if (null != mPendingScaleType) { setScaleType(mPendingScaleType); mPendingScaleType = null; } } ...copy from photoview @Override protected void onDetachedFromWindow() { //mAttacher.cleanup(); super.onDetachedFromWindow(); } @Override protected void onAttachedToWindow() { init(); super.onAttachedToWindow(); } public void destroy(){ setImageBitmap(null); mAttacher.cleanup(); onDetachedFromWindow(); } }
至此,我们上一篇留下来的问题全部解决。
下一篇。。。暂时没想到做什么好,大家有没有什么提议的