用文字札记描绘自己 android学习之路
转载请保留出处 by Qiao
http://blog.csdn.net/qiaoidea/article/details/46608385
【导航】
作为一款优秀的社交聊天软件,QQ始终保持着优秀的交互与设计,同时引领不少新时尚与标准规范,而它同时也有一些人性化的设计颇值得为人称道。今天要提到的是QQ消息未读拖拽清除(一键退朝”,“一键清除未读”,“一键下班”)的功能。具体细节参考 知乎:一键消除红点功能是怎么想出来的?当然,得益于诸位大大的各种尝试,小弟也稍加模仿修改了一个类似的Demo.这里展示下我们最后实现的各种样式及效果图:
(注:部分设计思想借鉴 chenupt的博文,不过其部分细节和实用性不够友善,拖动区域小,移除效果不随手指移动等原因,特作修缮并加一个人理解稍作补充。)
示例源码 已更新上传,包涵了滑动切换fragment和底部tab变色效果。具体实现方案鸿神已经提过,有兴趣的点个赞我后边再另开一篇仔细啰嗦下。
具体原理分析请看前边贴的知乎原文,实现的话稍作讲解下吧。
先贴老图:
1.以未读消息原图中心为原点,计算p1~p4四点坐标,根据未读红点和手势拖动点间的距离来判断红点的消除与回弹。
2.初始化消除动画view,关联到指定未读view并绑定拖动事件,根据手势位置更新图片坐标。
3.判断是否超出最大距离,并根据连接/断开状态来处理手势释放事件,看是否需要消除view。
4.在上述各状态时绑定相应事件。
貌似很多同学更关注如何使用,因此从本篇开始优先讲引入与使用。
1.直接导入tipsview至项目作为库/(或直接引入到自己项目)
(1)Android Studio 在项目 build.gradle 中配置
compile project(“:tipsview”)
(2)eclipse 直接 add library(或在 project.properties 配置)
android.library.reference.1=../tipsview
1.将TipsView添加至layout.xml 布局最顶层
<code.qiao.com.tipsview.TipsView
android:id="@+id/tip"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
或者
rootView.addView(tipview, LayoutParams.MATCH_PARENT,LayoutParams.MATCH_PARENT);
2.关联至指定可拖动view,并实现拖动响应事件
tipsView.attach(targetView, new TipsView.Listener(){
@Override
public void onStart() {
targetView.setVisibility(View.INVISIBLE);
}
@Override
public void onComplete() {
}
@Override
public void onCancel() {
targetView.setVisibility(View.VISIBLE);
}
});
如果是添加在listView等有触摸事件处理作为子view的地方,记得在onStart()方法中调用
//当requestDisallowInterceptTouchEvent 参数为true的时候 它不会拦截其子控件的 触摸事件
listView.requestDisallowInterceptTouchEvent(true);
//缺省方法
attach(final View attachView, Listener listener)
attach(final View attachView, final Func copyViewCreator, final Listener listener)
其中,
+ View attachView 为点击拖动目标view,比如显示消息未读的view
+ Func copyViewCreator 点击拖动时候显示的View,缺省方法默认显示被拖动view本身,当然可以返回其他view,比如选中弹出另外一个view样式。
重写invoke()方法返回拖动显示的view
new TipsView.Func() {
@Override
public View invoke() {
return null;//返回要显示view
}
}
new TipsView.Listener(){
@Override
public void onStart() {
//开始拖动
}
@Override
public void onComplete() {
//拖动并移除后
}
@Override
public void onCancel() {
//拖动取消
}
});
实现上述接口便可以达到类似QQ拖动清除效果。
整个View大概用了不到300行代码,原理除了计算和利用贝塞尔曲线绘制拖拽效果外,并无什么特别复杂的逻辑。这里从源码层面简要走一遍。
定义记录各个位置的坐标点 ,初始化画笔,半径和view ,并设置状态值
public class TipsView extends FrameLayout {
//默认半径
public static final float DEFAULT_RADIUS = 20;
//画笔与路径
private Paint paint;
//当前拖动位置
float x = 0;
float y = 0;
//贝塞尔曲线的操作点
float anchorX = 0;
//相对于view起点
float startX = 500;
float startY = 100;
//相对于屏幕的起点位置
float thisX = 0;
float thisY = 0;
float radius = DEFAULT_RADIUS;//当前半径
boolean isTrigger, isTouch;//是否触发消失动画,是否手势触摸状态
ImageView exploredImageView;//带有消失动画效果的imageview
View tipImageView; //可拖动的View
//。。。
因为是显示在最顶层,用于展示拖动View,所以初始化时候设置背景透明,并设置画笔和添加带有消失动画效果的imageview(默认不可见)。
public TipsView(Context context) {
super(context);
init();
}
public TipsView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public TipsView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
//透明背景
setBackgroundColor(Color.TRANSPARENT);
path = new Path();//轨迹
//初始化画笔
paint = new Paint();
paint.setAntiAlias(true);
paint.setStyle(Paint.Style.FILL_AND_STROKE);
paint.setStrokeWidth(2);
paint.setColor(0xffed5050);
//添加消失效果的View
LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
exploredImageView = new ImageView(getContext());
exploredImageView.setLayoutParams(params);
exploredImageView.setImageResource(R.drawable.tips_bubble);
exploredImageView.setVisibility(View.INVISIBLE);
addView(exploredImageView);
}
private void calculate() {
//计算两点(拖动位置和起点)距离,得到当前圆点半径
float distance = (float) Math.sqrt(Math.pow(y - startY, 2) + Math.pow(x - startX, 2));
radius = -distance / 15 + DEFAULT_RADIUS;
if (radius < 7) { //当半径小于指定值时候触发消除动画
isTrigger = true;
} else {
isTrigger = false;
}
/**
*近似地将两个圆起始半径设置相等,简单地求与圆心线垂直的线和圆的相交的四个点
*/
float offsetX = (float) (radius * Math.sin(Math.atan((y - startY) / (x - startX))));
float offsetY = (float) (radius * Math.cos(Math.atan((y - startY) / (x - startX))));
float x1 = startX - offsetX;
float y1 = startY + offsetY;
float x2 = x - offsetX;
float y2 = y + offsetY;
float x3 = x + offsetX;
float y3 = y - offsetY;
float x4 = startX + offsetX;
float y4 = startY - offsetY;
//计算轨迹
path.reset();
path.moveTo(x1, y1);
path.quadTo(anchorX, anchorY, x2, y2); //贝塞尔曲线
path.lineTo(x3, y3); //直线
path.quadTo(anchorX, anchorY, x4, y4);
path.lineTo(x1, y1);
}
@Override
protected void onDraw(Canvas canvas) {
calculate();//计算轨迹
if (isTrigger || !isTouch || tipImageView == null) { //断开或手势释放或展示view为空均只绘制透明图层
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.OVERLAY);
} else { //绘制透明图层后,接着绘制轨迹,并在起点和当前手势位置绘制两个圆
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.OVERLAY);
canvas.drawPath(path, paint);
canvas.drawCircle(startX, startY, radius, paint);
canvas.drawCircle(x, y, radius, paint);
}
super.onDraw(canvas);
}
//缺省方法,默认使用目标view作为可拖动view
public void attach(final View attachView, Listener listener) {
attach(attachView,
new Func() { //返回拖动时候显示的view
@Override
public View invoke() {
Bitmap bm = view2Bitmap(attachView);
ImageView iv = new ImageView(getContext());
iv.setImageBitmap(bm);
return iv;
}
}, listener);
}
//目标view,拖动时候展示的view(可定制)和监听事件
public void attach(final View attachView, final Func copyViewCreator, final Listener listener) {
attachView.setOnTouchListener(new OnTouchListener() {
protected void init() { //初始化
/**
*获取目标view在屏幕中位置 和 可展示区域(TipView)在屏幕位置
*从而得到目标在展示区域的位置startX 和startY
*/
int[] attachLocation = new int[2];
attachView.getLocationOnScreen(attachLocation);
int[] thisLocation = new int[2];
TipsView.this.getLocationOnScreen(thisLocation);
//得到目标view在展示区域中的位置
startX = attachLocation[0] - thisLocation[0] + attachView.getWidth() / 2;
startY = attachLocation[1] - thisLocation[1] + attachView.getHeight() / 2;
//当前手势位置即起点
x = startX;
y = startY;
tipImageView = copyViewCreator.invoke(); //得到拖动时候要展示的View
//添加到展示区域中
tipImageView.setLayoutParams(new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
TipsView.this.addView(tipImageView);
tipImageView.measure(0,0);//由于刚添加并没有测量和绘制,会导致娶不到高宽,影响展示view(中心与起点对齐),所以先进行一次测量
//绘制展示的view位置中心与起点重合
tipImageView.setX(startX - tipImageView.getMeasuredWidth() / 2);
tipImageView.setY(startY - tipImageView.getMeasuredHeight() / 2);
if (listener != null) {
listener.onStart(); //触发开始拖动事件
}
}
protected void destory() {
TipsView.this.removeView(tipImageView); //移除拖动展示的view
}
@Override
public boolean onTouch(View v, MotionEvent event) { //触摸事件监听
if (event.getAction() == MotionEvent.ACTION_DOWN) { //按下
init(); //初始化
isTouch = true;
int[] location = new int[2];
TipsView.this.getLocationOnScreen(location);
//相对于屏幕的起点位置
thisX = location[0];
thisY = location[1];
invalidate(); //重绘
return true;
}
//如果isTouch为false,不处理触摸事件
if (!isTouch)
return false;
if (event.getAction() == MotionEvent.ACTION_UP || event.getAction() == MotionEvent.ACTION_CANCEL) { //手势移开或取消
isTouch = false;
destory(); //移除展示view
if (isTrigger) { //触发了消失动画
postDelayed(new Runnable() {
@Override
public void run() {
isTrigger = false;
if (listener != null) {
listener.onComplete();//调用移除完成事件
}
}
}, 1000);
//在手势释放处显示移除效果的View
exploredImageView.setX(x - exploredImageView.getWidth() / 2);
exploredImageView.setY(y - exploredImageView.getHeight() / 2);
exploredImageView.setVisibility(View.VISIBLE);
//取得移除效果view的帧动画背景,播放消失动画
exploredImageView.setImageResource(R.drawable.tips_bubble);
((AnimationDrawable) exploredImageView.getDrawable()).stop();
((AnimationDrawable) exploredImageView.getDrawable()).start();
} else {
if (listener != null) {
listener.onCancel();
}
}
}
/**
* 获取当前位置和起点的中间点为贝塞尔曲线的操作点
*/
anchorX = (event.getRawX() - thisX + startX) / 2;
anchorY = (event.getRawY() - thisY + startY) / 2;
x = event.getRawX() - thisX;
y = event.getRawY() - thisY;
//在当前位置绘制展示view
tipImageView.setX(x - tipImageView.getWidth() / 2);
tipImageView.setY(y - tipImageView.getHeight() / 2);
invalidate();
return true;
}
});
}
public interface Func<Tresult> {
Tresult invoke(); //返回类型为Tresult的对象实例
}
public static interface Listener {
void onStart();
void onComplete();
void onCancel();
}
通过位置记录和绘制view,根据手势时间来计算和改变view的状态。其中有三个view,分别对应:
exploredImageView 移除时候负责显示带动画效果的view
以上就是我们的TipsView简易包装。
如果单单只是在目标view的父view区域拖动该未读图标,大可不必在根布局里添加一个顶层view。但是如果我们想要实现目标view全屏任意位置拖动,就需要额外模拟一个填充View,隐藏掉原来的view,达到全屏幕拖动的效果。
需要说明的一点,在有些时候,比如listview或者scrollView等布局下,我们会发现无法拖动目标view,这是因为这些父容器有拦截触摸滑动事件。所以我们必须在onStart()监听事件接口中设置
//当requestDisallowInterceptTouchEvent 参数为true的时候 它不会拦截其子控件的 触摸事件
requestDisallowInterceptTouchEvent(true);
实现目标view的触摸事件有效。
注: 请在布局根视图添加TipView,并保持在最顶层。他将决定你的拖动范围。
注: 请在布局根视图添加TipView,并保持在最顶层。他将决定你的拖动范围。
最后,附上代码下载地址:
示例demo源码下载地址 (资源上传较慢,如果不可用,试试这里)