仿写一个QQ空间图片预览Dialog
前言
弹幕除了能用来做直播,还能用来做什么?如果你看过QQ空间,你肯定知道,QQ空间的图片预览使用了弹幕。今天,我们本着学习的目的,来实现一个QQ空间图片预览Dialog。如果你偶然看过我上周的Blog,肯定知道,我在上周已经写了如何实现弹幕
所以我们可以直接在图片预览中拿来用就可以了。
最终效果
效果如果你注意到细节,发现这个库还是很有趣的:
- 弹幕
- 众多的手势(很大一部分来自
PhotoView
) - 随着滑动高度变化的背景透明度
- 多种动画
由于之前我已经讲过如何实现弹幕,所以在本文中,不会涉及到如何实现弹幕,只会直接引用Muti-Barrage
目录
目录一、整体把握
想要实现QQ空间的图片预览,我们可以使用什么?首先,我们的基础肯定是一个Dialog
;其次,图片的切换可以使用ViewPager
,同样你也可以使用ViewPager2
,可以支持纵向图片切换和更好的切换动画过渡,不过,ViewPager2
是属于androidx
的,如果使用ViewPager2
,那么整个库就需要迁移到androidx
了;接着,手势的处理及图片我们可以采用PhotoView,至于弹幕我们可以采用之前写好的Muti-Barrage
;最后,你可能会问,使用了这么多第三方库,我们还能大展身手吗?剩下的工作就比较轻松了,主要负责触摸事件和动画的处理。好了,现在整个结构清晰了,ViewPager + PhotoView + Muti-BarrageView
和手势处理+动画
就可以构成一个简单的仿QQ空间的图片预览了。
1. 类图
上面我们已经知道需要使用什么技术去实现了,现在我们再看一下主要的UML类图,从而方便我们下面的代码实战的讲解:
聪明的你可能已经发现了,这不是
代理模式
吗?没错,如果你想对代理模式
了解更多一点,移步:
对于一些琐碎的类,UML类图中并没有给出。
二、代码实战
由于我们已经上了UML类图,那我们就按照UML类图的顺序讲起吧。
1. IPhotoPager
public interface IPhotoPager {
void show();
void dismiss();
void setConfig(Config config);
/*
config
*/
class Config {
List<String> paths;// 图片路径
List<Bitmap> bitmaps; // Bitmap
boolean canDelete = true; // 普通主题使用
boolean isShowAnimation = false; // 是否展示动画
boolean isShowBarrage = true; // 是否显示弹幕
int animationType; // 动画类型
int startPosition = 0; // 图片开始位置
DeleteListener deleteListener; // 删除监听器
List<BarrageData> barrages; // 弹幕数据
}
}
IPhotoPager
定义一些基本的约束,以及我们需要使用的一些数据类型。
2. BasePager
public abstract class BasePager extends Dialog
implements ViewPager.OnPageChangeListener,IPhotoPager {
protected Context mContext;
// all base info
private IPhotoPager.Config mConfig;
// basic info
protected int curPosition;
protected boolean isCanDelete;
protected boolean isShowAnimation;
protected int animationType;
protected DeleteListener deleteListener;
protected boolean isShowBarrages;
protected List<Bitmap> bitmaps;
protected List<BarrageData> barrages;
public BasePager(@NonNull Context context) {
this(context, R.style.Dialog);
}
public BasePager(@NonNull Context context, int themeResId) {
super(context, themeResId);
mContext = context;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Window window = getWindow();
if (window != null) {
window.setDimAmount(1f);
}
}
//... 省略一些ViewPager的接口
@Override
public void setConfig(Config config) {
this.mConfig = config;
initParams();
}
/*
init parameter
*/
private void initParams() {
this.isCanDelete = mConfig.canDelete;
this.isShowAnimation = mConfig.isShowAnimation;
this.animationType = mConfig.animationType;
this.curPosition = mConfig.startPosition;
// init bitmaps
this.bitmaps = new ArrayList<>();
this.bitmaps.addAll(mConfig.bitmaps);
this.deleteListener = mConfig.deleteListener;
this.barrages = mConfig.barrages;
this.isShowBarrages = mConfig.isShowBarrage;
}
@Override
public void show() {
if(bitmaps == null || bitmaps.size() == 0){
throw new RuntimeException("bitmaps can't be null");
}
super.show();
// seting rect must be after dialog.showing(),otherwise dialog will show in initial size.
Rect rect = new Rect();
((Activity) mContext).getWindow().getDecorView().getWindowVisibleDisplayFrame(rect);
// set position and size
Window window = getWindow();
WindowManager.LayoutParams lp = 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);
} else if (animationType == ANIMATION_TRANSLATION) {
window.setWindowAnimations(R.style.PhotoPagerTranslation);
} else {
// default animaiont is translation
window.setWindowAnimations(R.style.PhotoPagerAlpha);
}
}
}
}
BasePager
内容也挺简单,实现ViewPager
的监听器,虽然并不做什么内容,其次就是将获取到的Config
对基础的数据进行初始化。
3. QQPager
QQPager
的代码将近400行左右,还是拆分按照过程讲解。
3.1 数据初始化
数据初始化主要分为初始化ViewPager
和Muti-BarrageView
,简单的初始化过程,这里就只是介绍我们的数据就好了:
public class QQPager extends BasePager {
private static final String TAG = "QQPager";
private static final int SCROLL_THRESHOlD = 100; // 滑动的阈值
private static final int MSG_UP = 0;
private ImageView mBarrage; // 弹幕的开关
private MyViewPager mPhotoPager; // 简单处理过的ViewPager
private TextView mPosition; // 位置信息
private PhotoPagerAdapter mAdapter; // ViewPager的item就是PhotoView
private BarrageView mBarrageView;
private BarrageAdapter<BarrageData> mBarrageAdapter;
private boolean isInitBarrage;
private int touchSloop; // 滑动的阈值
private float lastX; // 上次事件的坐标
private float lastY;
private float deltaY;
private boolean isHorizontalMove = false;
private boolean isVerticalMove = false;
private boolean isMove = false;
private int clickCount = 0; // 判断单击还是双击,因为如果是双击需要交给PhotoView处理
private Handler mHandler = new QQPagerHandler(this);
private static class QQPagerHandler extends Handler {
private WeakReference<QQPager> mQQPagerReference;
QQPagerHandler(QQPager qqPager) {
this.mQQPagerReference = new WeakReference<QQPager>(qqPager);
}
@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);
switch (msg.what) {
case MSG_UP:
if (mQQPagerReference.get().clickCount == 1)
mQQPagerReference.get().dismiss();
else
mQQPagerReference.get().clickCount = 0;
break;
}
}
}
class TextViewHolder extends BarrageAdapter.BarrageViewHolder<BarrageData> {
// ...代码省略
}
class ViewHolder extends BarrageAdapter.BarrageViewHolder<BarrageData> {
// ...代码省略
}
}
一些基础的数据以及两个类型的弹幕Holder,弹幕Holder的代码被省略了,需要的可以看源码。QQPagerHandler
作用是判断双击,具体的过程我们在下面讲解。
3.2 事件分发
用过PhotoView
的同学应该都知道,双击是放大图片,那么我们采用的既然是PhotoView
,自然也是这样的,以下是我们要在事件分发中考虑的地方:
- 单击关闭图片预览,我们需要阻止触摸事件下发,
Dialog
自身处理。 - 双击需要交给
ViewPager
,再由ViewPager
交给PhotoView
处理。 - 水平方向移动就是
ViewPager
中图片切换,事件交给ViewPager
处理。 - 竖直方向移动就是移动我们的
ViewPager
,Dialog
自身处理,并且ViewPager
纵向滑动距离会影响背景的透明度。
说到这里,我想你应该就明白了,只要处理单双击和纵横向的判断就好了,事实就是这么简单,看代码:
public boolean dispatchTouchEvent(@NonNull MotionEvent ev) {
if (isHorizontalMove)
return super.dispatchTouchEvent(ev);
float curX = ev.getX();// 获取当前坐标
float curY = ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
mPosition.setAlpha(1f); // Action_Down会触发位置文本的显示
mPosition.setVisibility(View.VISIBLE);
isMove = false;
clickCount++; // 点击次数增加
break;
case MotionEvent.ACTION_MOVE:
float deltaX = 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;
case MotionEvent.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;
return super.dispatchTouchEvent(ev);
}
public boolean onTouchEvent(@NonNull MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE:
mPhotoPager.scrollBy(0, (int) -deltaY);// ViewPager竖直移动
// set dialog's background alpha
float offsetPercent = Math.abs(mPhotoPager.getScrollY() - 0f) / mPhotoPager.getMeasuredHeight();
Log.e(TAG,"offset:"+offsetPercent);
if (getWindow() != null)
getWindow().setDimAmount(1f - offsetPercent);
break;
case MotionEvent.ACTION_UP:
if (isVerticalMove) {
if (Math.abs(mPhotoPager.getScrollY() - 0f) > SCROLL_THRESHOlD) {
scrollCloseAnimation();
} else {
rollbackAnimation();
}
}
break;
}
return super.onTouchEvent(event);
}
很多东西代码的注释很详细了,这边我要补充一下:
- 单双击是通过
QQPagerHandler
延迟发送400ms
来判断的,400ms
内单击一次执行关闭动画,如果再点击一次就重置单击计数。 -
QQPager
在onTouchEvent
处理的时候,会通过getWindow().setDimAmount(1f - offsetPercent)
改变背景的透明度。 - 竖直方向移动会阻断
ViewPager
事件的下发,所以,事件到最后还会交给自身处理,在手指释放的时候,如果竖直方向移动距离大于我们设置的最小滑动阈值,就执行滑动关闭动画,否则,ViewPager
会回滚,移动到初始位置。
再来看一下手势处理,双击、水平移动、纵向移动:
演示
3.3 动画处理
图片预览需要用到两种动画,View动画
和属性动画
,View动画在QQPager
打开和关闭的时候使用,详见上面的BasePager
的show()
方法,设置的style,这里不再介绍。属性动画
使用的场景就是位置文本定时显示、ViewPager
的回滚和滑动退出,代码类似,这里就挑滑动退出讲一下:
private void scrollCloseAnimation() {
Window window = getWindow();
if (window != null)
window.setDimAmount(0f);
if (deltaY > 0) {
mPhotoPager.animate()
.y(mPhotoPager.getMeasuredHeight())
.setDuration(600)
.setListener(new SimpleAnimationListener() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
//getWindow().setWindowAnimations(R.style.PhotoPagerAlpha);
dismiss();
}
})
.start();
} else {
mPhotoPager.animate()
.y(-mPhotoPager.getMeasuredHeight())
.setDuration(600)
.setListener(new SimpleAnimationListener() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
//getWindow().setWindowAnimations(R.style.PhotoPagerAlpha);
dismiss();
}
})
.start();
}
}
不得不说,使用View
本身的animate()
来使用属性动画还挺方便的,一次使用一次爽,次次使用次次爽~
4. PhotoPagerViewProxy
最后的最后,我们再来介绍以下代理类,主要用来构建数据:
public class PhotoPagerViewProxy implements IPhotoPager {
public static final int TYPE_NORMAL = 1;
public static final int TYPE_QQ = 2;
public static final int TYPE_WE_CHAT = 3;
public static final int ANIMATION_SCALE_ALPHA = 1;
public static final int ANIMATION_TRANSLATION = 2;
public static final int ANIMATION_ALPHA = 3;
private BasePager photoPageView;
private PhotoPagerViewProxy(Context context, int type, Config config) {
switch (type) {
case TYPE_QQ:
photoPageView = new QQPager(context,R.style.Dialog);
break;
case TYPE_WE_CHAT:
break;
default:
photoPageView = new NormalPager(context, R.style.Dialog);
break;
}
setConfig(config);
}
@Override
public void show() {
photoPageView.show();
}
@Override
public void dismiss() {
photoPageView.dismiss();
}
@Override
public void setConfig(Config config) {
photoPageView.setConfig(config);
}
public static class Builder {
private Activity context;
private IPhotoPager.Config config;
private int type;
public Builder(Activity context, int type) {
this.context = context;
this.config = new IPhotoPager.Config();
this.type = type;
}
public Builder(Activity context) {
// default type is TYPE_NORMAL
this(context, TYPE_NORMAL);
}
// ...同样省略大段代码,你只需要知道这里是初始化数据使用的Builder模式
public PhotoPagerViewProxy create() {
return new PhotoPagerViewProxy(context, type, config);
}
}
}
三、总结
总的来说,代码量不大也不难,不过,这份代码还有很多需要提高的地方,比如说,背景透明度随着ViewPager
的纵向滑动距离的变化不是那么快等。当然了,本人水平有限,难免有误,如果你发现哪里有问题,欢迎指正~
Demo地址:PhotoPagerView