前言
弹幕除了能用来做直播,还能用来做什么?如果你看过QQ空间,你肯定知道,QQ空间的图片预览使用了弹幕。今天,我们本着学习的目的,来实现一个QQ空间图片预览Dialog。如果你偶然看过我上周的Blog,肯定知道,我在上周已经写了如何实现弹幕 https://www.jianshu.com/p/2b1f4da434f3

如果你注意到细节,发现这个库还是很有趣的:
- 弹幕
- 众多的手势(很大一部分来自PhotoView)
- 随着滑动高度变化的背景透明度
- 多种动画
- 由于之前我已经讲过如何实现弹幕,所以在本文中,不会涉及到如何实现弹幕,只会直接引用
弹幕库:https://github.com/mCyp/Muti-Barrage
目录

一、整体把握
想要实现QQ空间的图片预览,我们可以使用什么?首先,我们的基础肯定是一个Dialog;其次,图片的切换可以使用ViewPager,同样你也可以使用ViewPager2,可以支持纵向图片切换和更好的切换动画过渡,不过,ViewPager2是属于androidx的,如果使用ViewPager2,那么整个库就需要迁移到androidx了;接着,手势的处理及图片我们可以采用PhotoView,至于弹幕我们可以采用之前写好的Muti-Barrage;最后,你可能会问,使用了这么多第三方库,我们还能大展身手吗?剩下的工作就比较轻松了,主要负责触摸事件和动画的处理。好了,现在整个结构清晰了,ViewPager + PhotoView + Muti-BarrageView和手势处理+动画就可以构成一个简单的仿QQ空间的图片预览了。
「1. 类图」上面我们已经知道需要使用什么技术去实现了,现在我们再看一下主要的UML类图,从而方便我们下面的代码实战的讲解:

聪明的你可能已经发现了,这不是代理模式吗?没错
二、代码实战
由于我们已经上了UML类图,那我们就按照UML类图的顺序讲起吧。
「1. IPhotoPager」
publicinterfaceIPhotoPager{
voidshow();
voiddismiss();
voidsetConfig(Configconfig);
/*
config
*/
classConfig{
List<String>paths;//图片路径
List<Bitmap>bitmaps;//Bitmap
booleancanDelete=true;//普通主题使用
booleanisShowAnimation=false;//是否展示动画
booleanisShowBarrage=true;//是否显示弹幕
intanimationType;//动画类型
intstartPosition=0;//图片开始位置
DeleteListenerdeleteListener;//删除监听器
List<BarrageData>barrages;//弹幕数据
}
}
IPhotoPager定义一些基本的约束,以及我们需要使用的一些数据类型。
「2. BasePager」
publicabstractclassBasePagerextendsDialog
implementsViewPager.OnPageChangeListener,IPhotoPager{
protectedContextmContext;
//allbaseinfo
privateIPhotoPager.ConfigmConfig;
//basicinfo
protectedintcurPosition;
protectedbooleanisCanDelete;
protectedbooleanisShowAnimation;
protectedintanimationType;
protectedDeleteListenerdeleteListener;
protectedbooleanisShowBarrages;
protectedList<Bitmap>bitmaps;
protectedList<BarrageData>barrages;
publicBasePager(@NonNullContextcontext){
this(context,R.style.Dialog);
}
publicBasePager(@NonNullContextcontext,intthemeResId){
super(context,themeResId);
mContext=context;
}
@Override
protectedvoidonCreate(BundlesavedInstanceState){
super.onCreate(savedInstanceState);
Windowwindow=getWindow();
if(window!=null){
window.setDimAmount(1f);
}
}
//...省略一些ViewPager的接口
@Override
publicvoidsetConfig(Configconfig){
this.mConfig=config;
initParams();
}
/*
initparameter
*/
privatevoidinitParams(){
this.isCanDelete=mConfig.canDelete;
this.isShowAnimation=mConfig.isShowAnimation;
this.animationType=mConfig.animationType;
this.curPosition=mConfig.startPosition;
//initbitmaps
this.bitmaps=newArrayList<>();
this.bitmaps.addAll(mConfig.bitmaps);
this.deleteListener=mConfig.deleteListener;
this.barrages=mConfig.barrages;
this.isShowBarrages=mConfig.isShowBarrage;
}
@Override
publicvoidshow(){
if(bitmaps==null||bitmaps.size()==0){
thrownewRuntimeException("bitmapscan'tbenull");
}
super.show();
//setingrectmustbeafterdialog.showing(),otherwisedialogwillshowininitialsize.
Rectrect=newRect();
((Activity)mContext).getWindow().getDecorView().getWindowVisibleDisplayFrame(rect);
//setpositionandsize
Windowwindow=getWindow();
WindowManager.LayoutParamslp=window.getAttributes();
lp.gravity=Gravity.BOTTOM;
lp.width=WindowManager.LayoutParams.MATCH_PARENT;
lp.height=rect.height();
window.setAttributes(lp);
if(isShowAnimation){
if(animationType==ANIMATION_SCALE_ALPHA){
window.setWindowAnimations(R.style.PhotoPagerScale);
}elseif(animationType==ANIMATION_TRANSLATION){
window.setWindowAnimations(R.style.PhotoPagerTranslation);
}else{
//defaultanimaiontistranslation
window.setWindowAnimations(R.style.PhotoPagerAlpha);
}
}
}
}
BasePager内容也挺简单,实现ViewPager的监听器,虽然并不做什么内容,其次就是将获取到的Config对基础的数据进行初始化。
「3. QQPager」
QQPager的代码将近400行左右,还是拆分按照过程讲解。
3.1 数据初始化
数据初始化主要分为初始化ViewPager和Muti-BarrageView,简单的初始化过程,这里就只是介绍我们的数据就好了:
publicclassQQPagerextendsBasePager{
privatestaticfinalStringTAG="QQPager";
privatestaticfinalintSCROLL_THRESHOlD=100;//滑动的阈值
privatestaticfinalintMSG_UP=0;
privateImageViewmBarrage;//弹幕的开关
privateMyViewPagermPhotoPager;//简单处理过的ViewPager
privateTextViewmPosition;//位置信息
privatePhotoPagerAdaptermAdapter;//ViewPager的item就是PhotoView
privateBarrageViewmBarrageView;
privateBarrageAdapter<BarrageData>mBarrageAdapter;
privatebooleanisInitBarrage;
privateinttouchSloop;//滑动的阈值
privatefloatlastX;//上次事件的坐标
privatefloatlastY;
privatefloatdeltaY;
privatebooleanisHorizontalMove=false;
privatebooleanisVerticalMove=false;
privatebooleanisMove=false;
privateintclickCount=0;//判断单击还是双击,因为如果是双击需要交给PhotoView处理
privateHandlermHandler=newQQPagerHandler(this);
privatestaticclassQQPagerHandlerextendsHandler{
privateWeakReference<QQPager>mQQPagerReference;
QQPagerHandler(QQPagerqqPager){
this.mQQPagerReference=newWeakReference<QQPager>(qqPager);
}
@Override
publicvoidhandleMessage(Messagemsg){
super.handleMessage(msg);
switch(msg.what){
caseMSG_UP:
if(mQQPagerReference.get().clickCount==1)
mQQPagerReference.get().dismiss();
else
mQQPagerReference.get().clickCount=0;
break;
}
}
}
classTextViewHolderextendsBarrageAdapter.BarrageViewHolder<BarrageData>{
//...代码省略
}
classViewHolderextendsBarrageAdapter.BarrageViewHolder<BarrageData>{
//...代码省略
}
}
一些基础的数据以及两个类型的弹幕Holder,弹幕Holder的代码被省略了,需要的可以看源码。QQPagerHandler作用是判断双击,具体的过程我们在下面讲解。
3.2 事件分发
用过PhotoView的同学应该都知道,双击是放大图片,那么我们采用的既然是PhotoView,自然也是这样的,以下是我们要在事件分发中考虑的地方:
单击关闭图片预览,我们需要阻止触摸事件下发,Dialog自身处理。双击需要交给ViewPager,再由ViewPager交给PhotoView处理。水平方向移动就是ViewPager中图片切换,事件交给ViewPager处理。竖直方向移动就是移动我们的ViewPager,Dialog自身处理,并且ViewPager纵向滑动距离会影响背景的透明度。
说到这里,我想你应该就明白了,只要处理单双击和纵横向的判断就好了,事实就是这么简单,看代码:
publicbooleandispatchTouchEvent(@NonNullMotionEventev){
if(isHorizontalMove)
returnsuper.dispatchTouchEvent(ev);
floatcurX=ev.getX();//获取当前坐标
floatcurY=ev.getY();
switch(ev.getAction()){
caseMotionEvent.ACTION_DOWN:
mPosition.setAlpha(1f);//Action_Down会触发位置文本的显示
mPosition.setVisibility(View.VISIBLE);
isMove=false;
clickCount++;//点击次数增加
break;
caseMotionEvent.ACTION_MOVE:
floatdeltaX=curX-lastX;
deltaY=curY-lastY;
if(Math.abs(deltaX)>touchSloop||Math.abs(deltaY)>touchSloop){
isMove=true;//滑动距离大于阈值自动重置点击计数
clickCount=0;
}
if(Math.abs(deltaX)<Math.abs(deltaY)){
isVerticalMove=true;//如果纵向距离大于横向阻断ViewPager事件下发
mPhotoPager.setIntercept(true);
}
break;
caseMotionEvent.ACTION_UP:
if(clickCount==1&&!isMove&&
!isTouchPointInView(mBarrage,(int)ev.getRawX(),(int)ev.getRawY()))//如果单击的不是弹幕开关按钮就发送消息
mHandler.sendEmptyMessageDelayed(MSG_UP,400);
else
clickCount=0;
break;
}
lastX=curX;
lastY=curY;
returnsuper.dispatchTouchEvent(ev);
}
publicbooleanonTouchEvent(@NonNullMotionEventevent){
switch(event.getAction()){
caseMotionEvent.ACTION_MOVE:
mPhotoPager.scrollBy(0,(int)-deltaY);//ViewPager竖直移动
//setdialogbackgroundalpha
floatoffsetPercent=Math.abs(mPhotoPager.getScrollY()-0f)/mPhotoPager.getMeasuredHeight();
Log.e(TAG,"offset:"+offsetPercent);
if(getWindow()!=null)
getWindow().setDimAmount(1f-offsetPercent);
break;
caseMotionEvent.ACTION_UP:
if(isVerticalMove){
if(Math.abs(mPhotoPager.getScrollY()-0f)>SCROLL_THRESHOlD){
scrollCloseAnimation();
}else{
rollbackAnimation();
}
}
break;
}
returnsuper.onTouchEvent(event);
}
很多东西代码的注释很详细了,这边我要补充一下:
- 单双击是通过QQPagerHandler延迟发送400ms来判断的,400ms内单击一次执行关闭动画,如果再点击一次就重置单击计数。
- QQPager在onTouchEvent处理的时候,会通过getWindow().setDimAmount(1f - offsetPercent)改变背景的透明度。
- 竖直方向移动会阻断ViewPager事件的下发,所以,事件到最后还会交给自身处理,在手指释放的时候,如果竖直方向移动距离大于我们设置的最小滑动阈值,就执行滑动关闭动画,否则,ViewPager会回滚,移动到初始位置。
再来看一下手势处理,双击、水平移动、纵向移动:

3.3 动画处理
图片预览需要用到两种动画,View动画和属性动画,View动画在QQPager打开和关闭的时候使用,详见上面的BasePager的show()方法,设置的style,这里不再介绍。属性动画使用的场景就是位置文本定时显示、ViewPager的回滚和滑动退出,代码类似,这里就挑滑动退出讲一下:
privatevoidscrollCloseAnimation(){
Windowwindow=getWindow();
if(window!=null)
window.setDimAmount(0f);
if(deltaY>0){
mPhotoPager.animate()
.y(mPhotoPager.getMeasuredHeight())
.setDuration(600)
.setListener(newSimpleAnimationListener(){
@Override
publicvoidonAnimationEnd(Animatoranimation){
super.onAnimationEnd(animation);
//getWindow().setWindowAnimations(R.style.PhotoPagerAlpha);
dismiss();
}
})
.start();
}else{
mPhotoPager.animate()
.y(-mPhotoPager.getMeasuredHeight())
.setDuration(600)
.setListener(newSimpleAnimationListener(){
@Override
publicvoidonAnimationEnd(Animatoranimation){
super.onAnimationEnd(animation);
//getWindow().setWindowAnimations(R.style.PhotoPagerAlpha);
dismiss();
}
})
.start();
}
}
不得不说,使用View本身的animate()来使用属性动画还挺方便的,一次使用一次爽,次次使用次次爽~
「4. PhotoPagerViewProxy」
最后的最后,我们再来介绍以下代理类,主要用来构建数据:
publicclassPhotoPagerViewProxyimplementsIPhotoPager{
publicstaticfinalintTYPE_NORMAL=1;
publicstaticfinalintTYPE_QQ=2;
publicstaticfinalintTYPE_WE_CHAT=3;
publicstaticfinalintANIMATION_SCALE_ALPHA=1;
publicstaticfinalintANIMATION_TRANSLATION=2;
publicstaticfinalintANIMATION_ALPHA=3;
privateBasePagerphotoPageView;
privatePhotoPagerViewProxy(Contextcontext,inttype,Configconfig){
switch(type){
caseTYPE_QQ:
photoPageView=newQQPager(context,R.style.Dialog);
break;
caseTYPE_WE_CHAT:
break;
default:
photoPageView=newNormalPager(context,R.style.Dialog);
break;
}
setConfig(config);
}
@Override
publicvoidshow(){
photoPageView.show();
}
@Override
publicvoiddismiss(){
photoPageView.dismiss();
}
@Override
publicvoidsetConfig(Configconfig){
photoPageView.setConfig(config);
}
publicstaticclassBuilder{
privateActivitycontext;
privateIPhotoPager.Configconfig;
privateinttype;
publicBuilder(Activitycontext,inttype){
this.context=context;
this.config=newIPhotoPager.Config();
this.type=type;
}
publicBuilder(Activitycontext){
//defaulttypeisTYPE_NORMAL
this(context,TYPE_NORMAL);
}
//...同样省略大段代码,你只需要知道这里是初始化数据,使用的Builder模式
publicPhotoPagerViewProxycreate(){
returnnewPhotoPagerViewProxy(context,type,config);
}
}
}
三、总结
总的来说,代码量不大也不难,不过,这份代码还有很多需要提高的地方,比如说,背景透明度随着ViewPager的纵向滑动距离的变化不是那么快等。当然了,本人水平有限,难免有误,如果你发现哪里有问题,欢迎指正
Over~ Demo地址:https://github.com/mCyp/PhotoPagerView
Android核心知识点笔记github:https://github.com/AndroidCot/Android