Android仿QQ未读消息拖拽删除粘性效果
2016-02-01 14:53
429 查看
这种效果已经有很多人实现了,网上相关的博文也不少。今天,我就站在巨人的肩膀上再稍微做一些优化和扩展。有写的不对的地方还望大家指正。
先上一张高清无码图:
闲话不多说,直接上代码
先看一下布局文件activity_main:
布局中的StickyFlagView是一个封装好的未读消息拖拽控件,在这里让我们先来了解一下它的xml属性。至于具体的实现,我们后面再重点讲解。
然后让我们看一下MainActivity.java文件
上面这部分代码也很简单,设置了未读消息标记的文本和删除后的回调监听。
接下来让我们一起来看一下StickyFlagView的具体实现:
首先看一下StickyFlagView的成员变量、构造方法以及初始化方法
上面的代码主要是初始化一些成员变量,拿到自定属性值,设置一些默认值。需要注意的是init()方法中setWillNotDraw(false),这段代码不能缺少,在高版本SDK中,invalid方法可能不能促使view的onDraw方法执行。大家可能注意到成员变量中有这么两个属性:parent和originalLp,这两个属性的作用是什么呢?大家可以想象一下QQ的未读消息拖拽效果,可以从自己所在的item中拖出来,并不受父控件大小的束缚。这种效果要借助WindowManager把StickyFlagView加入到window中,并把StickyFlagView的宽高设置为match。但是一个孩子不能有两个父亲,所以我们要先把view从父控件中移除,再把view添加到window中,等到拖拽效果完成之后,再把view从window中移除,然后还给原来的父亲。parent属性就用来保存view本来的父控件,originalLp用来保存view的布局参数。那么在什么时机把view加入window,又在什么时机把view加入原来的父控件呢,让我们接着看代码。
当move事件触发时,我们记录了拖拽点dragFlagPoint,这个坐标是用于不断重绘标记的位置。获得拖拽点坐标时,使用了event.getRawY(),然后减去状态栏的高度。一开始我使用的是event.getY(),不过拿到的坐标点出现了极大的误差,按理说view的大小是充满window的,使用getY和getRawY应该只是一个状态栏高度的差别。我也不知道这是为什么,有知道的朋友可以告诉我一下。好了,让我们继续,得到dragFlagPoint后,我们计算了它与黏着点(stickPoint)的距离distance,stickPoint的初始值是view还在原父控件中时的自身的中心点,当view加入到window时stickPoint的坐标要修正一下,不然view的大小发生了变化,再按原来的坐标绘制,黏着点肯定要跑偏了,这部分的逻辑我们稍后会讲。isReachLimit是个标识位,当distance大于最大拖拽距离时把它设为true,否则设为false,并根据distance改变黏着点半径的大小。
当up事件触发时,我们会判断是否达到了极限距离,如果达到了调用launchDisappearAnimation方法,这个方法是标记的一个删除时的动画,否则执行标记的回弹动画。两个动画执行完毕后都会做同一件事情,那就是调用restoreView方法,把view从window中移除,然后把view还给原来的父亲。
上面提到了addViewInWindow和restoreView方法,下面贴出它们的代码
parent.addView()和windowManager.addView()都会触发以下三个方法执行
我们的onDraw方法就不在这里贴出了,里面主要就是画圆、画曲线,至于绘制的算法和原理大家可以参考:http://www.bubuko.com/infodetail-1092644.html
最后附上源码地址:https://github.com/weijia1991/StickyFlagView
先上一张高清无码图:
闲话不多说,直接上代码
先看一下布局文件activity_main:
<span style="font-size:14px;"><?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@android:color/white" android:orientation="vertical"> <RelativeLayout android:id="@+id/rl" android:layout_width="match_parent" android:layout_height="60dp"> <com.wj.sticky.StickyFlagView android:id="@+id/sticky_view" android:layout_width="40dp" android:layout_height="40dp" android:layout_centerVertical="true" app:flagRadius="10dp" app:flagTextSize="15sp" /> <TextView android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" android:text="This is a RelativeLayout" android:textSize="20sp" /> </RelativeLayout> <View android:layout_width="match_parent" android:layout_height="1dp" android:background="@android:color/darker_gray"/> <FrameLayout android:id="@+id/fl" android:layout_width="match_parent" android:layout_height="60dp"> <TextView android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" android:text="This is a FrameLayout" android:textSize="20sp" /> <com.wj.sticky.StickyFlagView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginLeft="10dp" android:layout_gravity="center_vertical" app:flagColor="#f74c31" app:flagDrawable="@drawable/bubble" /> </FrameLayout> <View android:layout_width="match_parent" android:layout_height="1dp" android:background="@android:color/darker_gray"/> </LinearLayout></span>
布局中的StickyFlagView是一个封装好的未读消息拖拽控件,在这里让我们先来了解一下它的xml属性。至于具体的实现,我们后面再重点讲解。
flagColor | 标记的颜色(包括黏着线和黏着点) |
flagTextColor | 标记的文本颜色 |
flagTextSize | 标记的文本大小 |
flagRadius | 标记的半径,设置此属性后,将按照给定的半径绘制一个标记圆点。 如果同时设置了flagDrawable属性,则此属性失效。 |
flagDrawable | 为标记指定图片,设置此属性后,将绘制一个图片标记。 |
maxStickRadius | 黏着点的最大黏着半径 |
minStickRadius | 黏着点的最小黏着半径 |
maxDistance | 标记最大的拖拽距离 |
<span style="font-size:14px;">public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); final StickyFlagView sfv = (StickyFlagView) findViewById(R.id.sticky_view); sfv.setOnFlagDisappearListener(new StickyFlagView.OnFlagDisappearListener() { @Override public void onFlagDisappear(StickyFlagView view) { Toast.makeText(MainActivity.this, "Flag have disappeared.", Toast.LENGTH_SHORT).show(); } }); sfv.setFlagText("6"); } }</span>
上面这部分代码也很简单,设置了未读消息标记的文本和删除后的回调监听。
接下来让我们一起来看一下StickyFlagView的具体实现:
首先看一下StickyFlagView的成员变量、构造方法以及初始化方法
<span style="font-size:14px;">private Context context; private ViewGroup parent; private ViewGroup.LayoutParams originalLp; // view原始layout private int[] originalLocation; // view原始的location private int originalWidth; // view原始的宽度 private int originalHeight; // view原始的高度 private float stickRadius; // 黏贴半径 private int flagColor; // 标记颜色 private int flagTextColor; // 标记文本颜色 private float maxDragDistance; // 最大拖拽距离 private String flagText; // 标记文本 private float flagTextSize; // 标记文本大小 private float flagRadius; // 标记半径 private Bitmap flagBitmap; // 标记图片 private float maxStickRadius; // 最大黏贴半径 private float minStickRadius; // 最小黏贴半径 private float rate = 0.8f; private boolean isFirstSizeChange = true; private boolean isTouched; private boolean isReachLimit; // 是否达到最大拖拽距离 private boolean isRollBackAnimating; // 回滚动画是否在执行 private boolean isDisappearAnimating; // 消失动画是否在执行 private boolean isFlagDisappear; // 标记是否消失 private boolean isViewLoadFinish; // view是否加载完毕 private boolean isViewInWindow; // view是否在window中 private int which; private List<Integer> disappearRes; private PointF stickPoint; // 黏贴点 private PointF dragFlagPoint; // 拖拽标记点 private Paint flagPaint; // 标记画笔 private Paint flagTextPaint; // 标记文本画笔 private Path flagPath; private OnFlagDisappearListener listener; private WindowManager windowManager; public StickyFlagView(Context context) { super(context); this.context = context; init(); } public StickyFlagView(Context context, AttributeSet attrs) { super(context, attrs); this.context = context; initViewProperty(context, attrs); init(); } public StickyFlagView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); this.context = context; initViewProperty(context, attrs); init(); } /** * 初始化view属性 */ private void initViewProperty(Context context, AttributeSet attrs) { TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.StickyFlagView); flagColor = typedArray.getColor(R.styleable.StickyFlagView_flagColor, Color.RED); flagTextColor = typedArray.getColor(R.styleable.StickyFlagView_flagTextColor, Color.WHITE); flagTextSize = typedArray.getDimension(R.styleable.StickyFlagView_flagTextSize, ScreenUtils.spTopx(context, 12)); maxDragDistance = typedArray.getDimension(R.styleable.StickyFlagView_maxDistance, ScreenUtils.getScreenHeight(context) / 6); minStickRadius = typedArray.getDimension(R.styleable.StickyFlagView_minStickRadius, ScreenUtils.dpToPx(context, 2)); flagRadius = typedArray.getDimension(R.styleable.StickyFlagView_flagRadius, ScreenUtils.dpToPx(context, 10)); maxStickRadius = typedArray.getDimension(R.styleable.StickyFlagView_maxStickRadius, flagRadius * rate); Drawable flagDrawable = typedArray.getDrawable(R.styleable.StickyFlagView_flagDrawable); if (flagDrawable != null) { flagBitmap = ((BitmapDrawable) flagDrawable).getBitmap(); } typedArray.recycle(); } private void init() { // 处理onDraw方法不执行的问题 setWillNotDraw(false); // 这些默认值是为第一个构造函数准备的 if (flagColor == 0) { flagColor = Color.RED; } if (flagTextColor == 0) { flagTextColor = Color.WHITE; } if (flagTextSize == 0) { flagTextSize = ScreenUtils.spTopx(context, 12); } if (flagRadius == 0) { flagRadius = ScreenUtils.dpToPx(context, 10); } if (maxDragDistance == 0) { maxDragDistance = ScreenUtils.getScreenHeight(context) / 6; } if (minStickRadius == 0) { minStickRadius = ScreenUtils.dpToPx(context, 2); } if (maxStickRadius == 0) { maxStickRadius = flagRadius * rate; } originalLocation = new int[2]; stickPoint = new PointF(); dragFlagPoint = new PointF(); flagPath = new Path(); flagPaint = new Paint(); flagPaint.setAntiAlias(true); flagPaint.setColor(flagColor); flagTextPaint = new Paint(); flagTextPaint.setAntiAlias(true); flagTextPaint.setColor(flagTextColor); flagTextPaint.setTextSize(flagTextSize); windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); }</span>
上面的代码主要是初始化一些成员变量,拿到自定属性值,设置一些默认值。需要注意的是init()方法中setWillNotDraw(false),这段代码不能缺少,在高版本SDK中,invalid方法可能不能促使view的onDraw方法执行。大家可能注意到成员变量中有这么两个属性:parent和originalLp,这两个属性的作用是什么呢?大家可以想象一下QQ的未读消息拖拽效果,可以从自己所在的item中拖出来,并不受父控件大小的束缚。这种效果要借助WindowManager把StickyFlagView加入到window中,并把StickyFlagView的宽高设置为match。但是一个孩子不能有两个父亲,所以我们要先把view从父控件中移除,再把view添加到window中,等到拖拽效果完成之后,再把view从window中移除,然后还给原来的父亲。parent属性就用来保存view本来的父控件,originalLp用来保存view的布局参数。那么在什么时机把view加入window,又在什么时机把view加入原来的父控件呢,让我们接着看代码。
<span style="font-size:14px;">@Override public boolean onTouchEvent(MotionEvent event) { if (!isViewLoadFinish || isRollBackAnimating || isDisappearAnimating || isFlagDisappear) { return true; } switch (event.getAction()) { case MotionEvent.ACTION_DOWN: isTouched = true; addViewInWindow(); break; case MotionEvent.ACTION_MOVE: dragFlagPoint.x = event.getRawX(); dragFlagPoint.y = event.getRawY() - ScreenUtils.getStatusHeight(context); double distance = Math.sqrt(Math.pow(dragFlagPoint.y - stickPoint.y, 2) + Math.pow(dragFlagPoint.x - stickPoint.x, 2)); if (distance > maxDragDistance) { isReachLimit = true; } else { isReachLimit = false; stickRadius = (float) (maxStickRadius * (1 - distance / maxDragDistance)); stickRadius = stickRadius < minStickRadius ? minStickRadius : stickRadius; } postInvalidate(); break; case MotionEvent.ACTION_UP: isTouched = false; if (isReachLimit) { launchDisappearAnimation(1000); if (listener != null) { listener.onFlagDisappear(this); } } else { launchRollBackAnimation(300); } break; } return true; }</span>这里重写了view的onTouchEvent方法,在down事件触发时调用addViewInWindow方法,这个方法的作用就是把view从当前的父控件中移除,然后加入到window中,view的大小充满整个window,这样我们就拥有了一个面积足够大的悬浮画板,以便我们随心所欲的在上面进行绘制。
当move事件触发时,我们记录了拖拽点dragFlagPoint,这个坐标是用于不断重绘标记的位置。获得拖拽点坐标时,使用了event.getRawY(),然后减去状态栏的高度。一开始我使用的是event.getY(),不过拿到的坐标点出现了极大的误差,按理说view的大小是充满window的,使用getY和getRawY应该只是一个状态栏高度的差别。我也不知道这是为什么,有知道的朋友可以告诉我一下。好了,让我们继续,得到dragFlagPoint后,我们计算了它与黏着点(stickPoint)的距离distance,stickPoint的初始值是view还在原父控件中时的自身的中心点,当view加入到window时stickPoint的坐标要修正一下,不然view的大小发生了变化,再按原来的坐标绘制,黏着点肯定要跑偏了,这部分的逻辑我们稍后会讲。isReachLimit是个标识位,当distance大于最大拖拽距离时把它设为true,否则设为false,并根据distance改变黏着点半径的大小。
当up事件触发时,我们会判断是否达到了极限距离,如果达到了调用launchDisappearAnimation方法,这个方法是标记的一个删除时的动画,否则执行标记的回弹动画。两个动画执行完毕后都会做同一件事情,那就是调用restoreView方法,把view从window中移除,然后把view还给原来的父亲。
上面提到了addViewInWindow和restoreView方法,下面贴出它们的代码
<span style="font-size:14px;">private void addViewInWindow() { if (isViewLoadFinish && !isViewInWindow) { if (parent != null) { // 将view从它的父控件中移除 parent.removeView(this); } WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams(); layoutParams.type = WindowManager.LayoutParams.TYPE_PHONE; layoutParams.format = PixelFormat.TRANSPARENT; layoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; layoutParams.gravity = Gravity.START | Gravity.TOP; layoutParams.width = WindowManager.LayoutParams.MATCH_PARENT; layoutParams.height = WindowManager.LayoutParams.MATCH_PARENT; layoutParams.x = 0; layoutParams.y = 0; if (windowManager != null) { // 将view加入window windowManager.addView(this, layoutParams); post(new Runnable() { @Override public void run() { isViewInWindow = true; } }); } } } private void restoreView() { if (isViewLoadFinish) { // 还原黏贴半径 stickRadius = flagRadius > maxStickRadius ? maxStickRadius : flagRadius * rate; isReachLimit = false; if (windowManager != null && isViewInWindow) { // 把view从window中移除 windowManager.removeView(this); isViewInWindow = false; if (parent != null) { parent.addView(this, originalLp); // 在高版本的SDK上,没有这段代码,view可能不会刷新 post(new Runnable() { @Override public void run() { parent.invalidate(); } }); } } } else { post(new Runnable() { @Override public void run() { restoreView(); } }); } }</span>上面代码中的isInWindow是为了防止不断触发down事件,造成view重复加入window发生异常。isViewLoadFinish是判断view是否还在加载中,我们知道一次点击事件就是一个down和一个up,down的时候我们把view加入window,up时我们把view加入parent,view的加载需要时间,windowManager.addView()调用后,view开始加载,在加载过程中,restoreView方法调用,如果没有isViewLoadFinish这个判断,那么执行windowManner.removeView()就会出现问题。
parent.addView()和windowManager.addView()都会触发以下三个方法执行
<span style="font-size:14px;">@Override public void setLayoutParams(ViewGroup.LayoutParams params) { super.setLayoutParams(params); isViewLoadFinish = false; this.post(new Runnable() { @Override public void run() { isViewLoadFinish = true; } }); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int width = 0; int widthMode = MeasureSpec.getMode(widthMeasureSpec); if (widthMode == MeasureSpec.UNSPECIFIED) { if (flagBitmap == null) { width = (int) ScreenUtils.dpToPx(context, 20); } else { width = flagBitmap.getWidth(); } } else if (widthMode == MeasureSpec.EXACTLY){ width = MeasureSpec.getSize(widthMeasureSpec); } else if (widthMode == MeasureSpec.AT_MOST) { if (flagBitmap == null) { width = (int) ScreenUtils.dpToPx(context, 20); } else { width = flagBitmap.getWidth(); } } int height = 0; int heightMode = MeasureSpec.getMode(heightMeasureSpec); if (heightMode == MeasureSpec.UNSPECIFIED) { if (flagBitmap == null) { height = (int) ScreenUtils.dpToPx(context, 20); } else { height = flagBitmap.getHeight(); } } else if (heightMode == MeasureSpec.EXACTLY){ height = MeasureSpec.getSize(heightMeasureSpec); } else if (heightMode == MeasureSpec.AT_MOST) { if (flagBitmap == null) { height = (int) ScreenUtils.dpToPx(context, 20); } else { height = flagBitmap.getHeight(); } } setMeasuredDimension(width, height); } @Override protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { super.onSizeChanged(width, height, oldWidth, oldHeight); if (isFirstSizeChange) { parent = (ViewGroup) getParent(); // StickyFlagView的父控件只能是RelativeLayout或FrameLayout if (!(parent instanceof RelativeLayout || parent instanceof FrameLayout)) { throw new RuntimeException("StickyFlagView can only be placed on the RelativeLayout or FrameLayout."); } // 记录view原始layout参数 originalLp = getLayoutParams(); originalWidth = width; originalHeight = height; getLocationOnScreen(originalLocation); originalLocation[1] = originalLocation[1] - ScreenUtils.getStatusHeight(context); if (flagBitmap == null) { float radius = Math.min(originalWidth, originalHeight) * 0.5f; flagRadius = flagRadius > radius ? radius : flagRadius; stickRadius = flagRadius > maxStickRadius ? maxStickRadius : flagRadius * rate; } else { // 黏贴半径不能超过图片宽和高的最小值的一半 flagRadius = Math.min(flagBitmap.getWidth(), flagBitmap.getHeight()) * 0.5f; stickRadius = maxStickRadius > flagRadius ? flagRadius * rate : maxStickRadius; } // 黏贴点在原始view的中心点 stickPoint.set((float) (originalWidth * 0.5), (float) (originalHeight * 0.5)); isFirstSizeChange = false; } else { // view的size改变之后,修正黏贴点坐标 if (originalWidth == width && originalHeight == height) { stickPoint.set((float) (originalWidth * 0.5), (float) (originalHeight * 0.5)); } else { stickPoint.x += originalLocation[0]; stickPoint.y += originalLocation[1]; } } dragFlagPoint.x = stickPoint.x; dragFlagPoint.y = stickPoint.y; }</span>上面三个方法的调用顺序是setLayoutParams,onMeasure,onSizeChanged 大家可以看到isViewLoadFinish的值是在setLayoutParams中设置的,post方法将一个Runnable加入队列,当view加载完毕后,队列中的runnable会依次执行。onMeasure方法里面的代码应该很好理解,当指定了StickyFlagView的宽高后,我们就用指定的宽高,如果没有我们就给一个默认的。在onSizeChanged方法中,大家可以看到parent和originalLp是在size第一次被改变是初始化的,与此同时还记录了view在屏幕中的位置,记录这个位置是为了在view加入window后,修正黏着点的位置,黏着点的初始位置是view的中心点,下面上一张图解释一下
我们的onDraw方法就不在这里贴出了,里面主要就是画圆、画曲线,至于绘制的算法和原理大家可以参考:http://www.bubuko.com/infodetail-1092644.html
最后附上源码地址:https://github.com/weijia1991/StickyFlagView
相关文章推荐
- Android 关于“NetworkOnMainThreadException”出错提示的原因及解决办法
- Android 应用开发(一):搭建 Android 开发环境
- android 四大组件 BroadcastReceiver使用
- Android读取Asset读取指定的Text文档
- android 图标和图片位置,drawable or mipmap?
- android 弹性ScrollView
- Android-86需 在如下几个方向做提升
- android studio打jar包
- Android 四大核心组件之Activity
- 源码解析Android中View的measure量算过程
- Android-彻底理解文件存储
- assets文件夹资源的访问
- 阅读《Android 从入门到精通》(20)——图片视图
- assets文件夹资源的访问
- Android的消息循环机制 Looper Handler类分析
- Android ToolBar 基本使用
- android键盘监听方案
- Android 自定义View之柱状图实践
- android notification 使用
- android 处理图片之--bitmap处理