前记
android开发已经有很久了,但是感觉自己一天天的过的很懵,技术提升慢,虽然平时做了一些笔记,但是完整的博文很少(还是缺钱了呗)。一个程序猿的职业生涯就极其短暂,如果22岁毕业开始撸代码,还996,你还要去抽时间学习,不然就是安于现状,30岁就会被淘汰(我想剁了说这些话人的狗头)。我想说不论什么时候学习都不晚!诸天气荡荡,我道日兴隆。
效果
OLD版本
QQ版本
android中View的绘制流程
在实现控件效果之前,我们先回忆一下view的绘制,它绘制肯定是依赖于它的父View,一层层绘制而来,你不能脱离与父View独自绘制,所以它必定是从最根部的view也就是DecorView开始进行绘制的,这里有一个很有意思的问题,因为每个View都需要经历 measure -> layout -> draw的过程,measure依赖于父View的MeasureSpec,但是DecorView没有父View那么它的MeasureSpec从哪里来呢?
在源码中,View的绘制是从ViewRoot的perfromTraversals()方法开始,从根ViewGroup循环绘制子View。
查看perfromTraversals()方法:
if (!mStopped || mReportNextDraw) {
boolean focusChangedDueToTouchMode = ensureTouchModeLocally(
(relayoutResult&WindowManagerGlobal.RELAYOUT_RES_IN_TOUCH_MODE) != 0);
if (focusChangedDueToTouchMode || mWidth != host.getMeasuredWidth()
|| mHeight != host.getMeasuredHeight() || contentInsetsChanged ||
updatedConfiguration) {
int childWidthMeasureSpec = getRootMeasureSpec(mWidth, lp.width);//1
int childHeightMeasureSpec = getRootMeasureSpec(mHeight, lp.height);//2
if (DEBUG_LAYOUT) Log.v(mTag, "Ooops, something changed! mWidth="
+ mWidth + " measuredWidth=" + host.getMeasuredWidth()
+ " mHeight=" + mHeight
+ " measuredHeight=" + host.getMeasuredHeight()
+ " coveredInsetsChanged=" + contentInsetsChanged);
// Ask host how big it wants to be
performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);//3
从代码1,2可以看到,在调用performMeasure之前进行一次计算(getRootMeasureSpec),根据窗口尺寸和DecrorView的LayoutParams得到了Decorview的MeasureSpec。
private static int getRootMeasureSpec(int windowSize, int rootDimension) {
int measureSpec;
//传入的是窗口的尺寸,和当前DecorView的LayoutParams来决定的,DecorView的LayoutParams可以在很多地方进行改变。
switch (rootDimension) {
case ViewGroup.LayoutParams.MATCH_PARENT:
// Window can't resize. Force root view to be windowSize.
measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.EXACTLY);
break;
case ViewGroup.LayoutParams.WRAP_CONTENT:
// Window can resize. Set max size for root view.
measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.AT_MOST);
break;
default:
// Window wants to be an exact size. Force root view to be that size.
measureSpec = MeasureSpec.makeMeasureSpec(rootDimension, MeasureSpec.EXACTLY);
break;
}
return measureSpec;
}
自定义View的实现
简单的了解了一下View的绘制流程之后,开始手撸一个侧滑的View。
根据刚刚的侧滑效果OLD版本,我们需要自定义一个ViewGroup:
内容区域铺满了窗口,我们只需要通过scroll进行滚动显示出功能区域,不是很麻烦。
<com.example.ct.swipelayoutview.widget.SwipeLayout
android:id="@+id/swipe_layout"
android:layout_width="match_parent"
android:layout_height="89dp">
<TextView
android:id="@+id/tv_content"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/item_normal_bg"
android:text="这里是内容区域!"
android:textColor="@android:color/black" />
<!-- 功能区开始。。。。。。。。。。。。。。。。。。。。-->
<TextView
android:id="@+id/btnTop"
android:layout_width="60dp"
android:gravity="center"
android:layout_height="match_parent"
android:background="@drawable/top_bg_normal"
android:text="置顶"
android:textColor="@android:color/white"/>
<TextView
android:id="@+id/btnUnRead"
android:layout_width="120dp"
android:gravity="center"
android:layout_height="match_parent"
android:background="@drawable/unread_bg_normal"
android:clickable="true"
android:text="标记未读"
android:textColor="@android:color/white"/>
<TextView
android:id="@+id/btnDelete"
android:gravity="center"
android:layout_width="60dp"
android:layout_height="match_parent"
android:background="@drawable/delete_bg_normal"
android:text="删除"
android:textColor="@android:color/white"/>
</com.example.ct.swipelayoutview.widget.SwipeLayout>
测量布局,代码为了简单,都是用kotlin来实现
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
isClickable = true//设置可点击,不然无法接受到任何事件
mRightMenuWidth = 0
mHeight = 0
mDisplayWidth = 0 //内容区域的宽度
val childCount = childCount //获取childCount
//高度不确定不需要做测量
val measureMatchParentChildren = MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY
var isNeedMeasureChildHeight = false
for (i in 0..childCount){
val childView = getChildAt(i)
if (childView!=null&&childView.visibility != View.GONE){
//设置可以点击 获取触摸事件
childView.isClickable = true
//开始measureChildView
measureChild(childView,widthMeasureSpec,heightMeasureSpec)
val marginLayoutParams: MarginLayoutParams = childView.layoutParams as MarginLayoutParams
mHeight = max(mHeight, childView.measuredHeight)//设置高度为 子View中最高的
if(measureMatchParentChildren && marginLayoutParams.height == LayoutParams.MATCH_PARENT){
isNeedMeasureChildHeight = true
}
if(i>0){
//第一个为正常显示的item,从第二个开始进行计算功能区域的宽度
mRightMenuWidth += childView.measuredWidth
}else{
mContentView = childView
mDisplayWidth = childView.measuredWidth
}
}
}
//宽度设置为内容区域的宽度
setMeasuredDimension(paddingLeft + paddingRight + mDisplayWidth,mHeight + paddingTop + paddingBottom)
mLimit = mRightMenuWidth*3/10 //百分之30为滑动临界值,当大于这个宽度的时候,我们需要展开功能区
mScaleTouchSlop = mRightMenuWidth*1/10 //百分之10为视为滑动,手指大于这个就判定为侧滑
if(isNeedMeasureChildHeight){
//如果自身为warp_content,但是子View有match属性的时候,需要重新测量,让它和测量的父布局一样高。
forceUniformHeight(widthMeasureSpec)
}
}
测量之后我们要对齐进行布局,让其水平布局,一个挨一个的。
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
//开始布局,使用第一个View铺满页面
var left = 0+ paddingLeft
for (i:Int in 0..childCount){
val childView = getChildAt(i)
if (childView!=null&&childView.visibility != GONE) {
childView.layout(left, paddingTop, left + childView.measuredWidth, paddingTop + childView.measuredHeight)
left += childView.measuredWidth
}
}
}
到此,View的绘制已经完成了,至于onDraw方法就不用重写了,因为我们不需要添加额外的View。
自定义View的触摸事件
View已经绘制完毕,但是我们需要考虑几个问题。
- 1、怎么让功能区滑动出来?
- 2、什么时候才能点击?
- 3、回弹效果怎么实现?
展示功能区使用的是Scroll滑动。
点击情况需要简单分为三种。(以下的情况是参照QQ实现,如有其他情况,请自己分析一下子) -
功能区没有展开,点击应该内容区域。
如图2所示:
1)事件发生在在内容区域,如果当前手指滑动的距离很小,然后抬起。认为是普通点击事件。
2)事件发生在内容区域,如果当前手指向左滑动的距离很大,触发功能区,功能区开始跟随手指拖动,
手指放开,如果功能区滑动的距离大于临界值就进行展开动画,否则就进行回弹动画收起功能区。
如图3所示:
- 展开了,点击功能区(点击2),响应功能区
- 展开了,点击 非功能区(点击1),屏蔽一切点击事件,关闭功能区
简单分析之后我们开始进行滑动和拦截事件。
小知识
学习就是不断的遗忘和回忆,再重新学习的过程,我们再来回忆一下View的事件分发。
三个主要函数
dispatchTouchEvent ->onInterceptTouchEvevnt -> onTouchEvent
一个完整的事件是:
所有的事件都是从activity开始分发的,直接来看ViewGroup的dispatchTouchEvent:
public boolean dispatchTouchEvent(MotionEvent ev) {
..............................
// Handle an initial down.
if (actionMasked == MotionEvent.ACTION_DOWN) {
// Throw away all previous state when starting a new touch gesture.
// The framework may have dropped the up or cancel event for the previous gesture
// due to an app switch, ANR, or some other state change.
cancelAndClearTouchTargets(ev);
resetTouchState();
}
// Check for interception.
final boolean intercepted;//是否拦截的标志,如果不为true才会向子View分发事件,否则就自己处理
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;//这个是子view设置通过requestDisallowInterceptTouchEvent(boolean flag)设置
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
// There are no touch targets and this action is not an initial down
// so this view group continues to intercept touches.
intercepted = true;
}
}
....................
}
其中有几个重要的Flag
- intercepted 如果为false则进行循环调用能接受这个事件的子view的dispatchTouchEvent,否则就调用自身的onTocuhevent,如果onTouchEvent也返回false,事件就会回到Activity中去。
- mFirstTouchTarget !=null :代表的意思是当前的这个View没有拦截任何事件,如果有拦截down->up中任意一个事件,mFirstTouchTarget = null
if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null)
代表:只有down事件(代表新的点击事件)或者当前这个view没有拦截过任何事件的时候,才会去调用onInterceptTouchEvent,并不是每一次事件都会调用onInterceptTouchEvent!!!
- disallowIntercept :代表子view要求当前父View不能拦截除了down事件以外的事件。意思就是子view调用requestDisallowInterceptTouchEvent(true)方法之后,down->move ->up中除了down事件,其余的事件父View都不能拦截。而且每一次down事件会重置这个flag。
- ACTION_CANCEL:什么时候触发呢?1)父View拦截了除了down以外的事件,子view就会收到ACTION_CANCEL。2)手指滑动超过当前View的范围了,事件中断,子View会收到一个ACTION_CANCEL事件。这个事件应该和UP事件同样的处理。
拦截事件
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
//记录速度
acquireVelocityTracker(ev)
when(ev?.action){
ACTION_DOWN ->{
//防止多根手指进入滑动,只响应第一根手指,否则会出现乱滑动的情
if (isTouching){
//如果dispatchTouchEvent 返回true代表整个事件结束了 后续事件就不传递了
return true
}else{
isTouching = true
}
isSwiped = false//重置滑动状态
mLastP.set(ev.rawX,ev.rawY)//跟踪手指坐标
mFirstP.set(ev.rawX,ev.rawY)//手指落下的坐标
mPointerId = ev.getPointerId(0) //获取第一个触点的坐标,用于计算滑动速度
}
ACTION_MOVE->{
val gap:Float = mLastP.x - ev.rawX
if (abs(gap) > 15 || (abs(scrollX)>0&&!isExpand)){
//如果当前滑动距离触发功能区了,禁止父布局拦截事件,这样父布局就不能滑动
parent.requestDisallowInterceptTouchEvent(true)
}
if(gap>0){
//说明向左滑动,展开
scrollBy(gap.toInt(), 0)//跟随手指滑动
}else if(scrollX>0){
说明是向右滑动,我们只有在功能区展开的时候才去做滑动。
scrollBy(gap.toInt(), 0)//跟随手指滑动
}
//越界修正
if(scrollX < 0){
scrollTo(0,0)
}
if (scrollX > mRightMenuWidth){
scrollTo(mRightMenuWidth,0)
}
//跟踪坐标
mLastP.set(ev.rawX,ev.rawY)
}
ACTION_UP, ACTION_CANCEL->{
//测量瞬间速度
mVelocityTracker?.computeCurrentVelocity(1000, mMaxVelocity.toFloat())
val velocityTrackerX = mVelocityTracker!!.getXVelocity(mPointerId)
if (abs(velocityTrackerX) > 1000){//瞬间速度视为滑动了
if(velocityTrackerX < -1000){
//使用展开动画
smoothExpand()
}else{
//使用关闭动画
smoothClose()
}
}else{
if(abs(scrollX)>=mLimit && !isExpand){
smoothExpand()
}else if(abs(scrollX) > 0){
if(isExpand){
//关闭所有展开View
closeAllExpland()
}else{
smoothClose()
}
}
}
isTouching = false//没有手指触碰我了,不然会出现乱滑动的情况
relaseVelocityTracker() //释放资源
}
}
return super.dispatchTouchEvent(ev)
}
dispatchTouchEvent中的代码都很好理解展开动画和关闭动画都使用了属性动画,不采用重写computeScrol的方式来实现,使用属性动画。
/**
* 平滑展开菜单栏
*/
private fun smoothExpand(){
if(null!= mContentView){
//展开动画的时候,屏蔽内容区域的长按事件
mContentView?.isLongClickable = false
}
clearAnim()//停止所有的动画
mExpandAnim = ValueAnimator.ofInt(scrollX,mRightMenuWidth)//当前位置滑动到功能区的最大宽度。
mExpandAnim?.addUpdateListener {
scrollTo(animation.animatedValue as Int, 0)//滑动就完事了
}
. ...........................................
mExpandAnim!!.setDuration(300).start()//开始动画
}
如果我们当前View在一个列表中,多个View功能区被打开之后,我们点击任意非功能区的位置或者上下滑动都应该将所有的View进行关闭。所以我们需要一个集合来保存这些被打开的View。
val sExplands: SparseArray<SwipeLayout> = SparseArray() //记录展开的位置(多使用SpareseArray这种类似map的集合)
//当我们关闭的时候,需要将它从集合里面删除掉。
到此我们的滑动效果已经出现了,但是现在滑动的之后抬起手指就会触发点击事件。所以我们需要对这些事件进行过滤。
/**
* 并不是每次都会调用的,一个完整的事件是从down-move....-up or cancle
* 如当前的ViewGroup拦截除了down以外的任何一个事件,onInterceptTouchEvent都不会再调用
*/
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
when(ev?.action){
ACTION_DOWN->{
isOnIntercept = false //拦截标志
if (scrollX>0&&ev.rawX<(mDisplayWidth - mRightMenuWidth)){
//自身View展开 ,没有点击在功能区,进行关闭 拦截点击事件
closeAllExpland()
isOnIntercept = true
}else if (scrollX<=0&& sExplands.size()>0 ){
//自身view没有展开,但是点击在功能区了,进行关闭 拦截点击事件
closeAllExpland()
isOnIntercept = true
}
}
ACTION_MOVE->{
if (abs(ev.rawX - mFirstP.x)>mScaleTouchSlop){
return true //拦截事件 已经在滑动了
}
}
ACTION_UP->{
//如功能区被打开
if ( isOnIntercept ) {
return true
}
}
}
return super.onInterceptTouchEvent(ev)
}
我们只需要拦截,触发滑动事件和功能区展开的时候点击其他空白的地方。
效果已经实现了。但是。。。。。。。。。。。。好像和QQ的不太一样!
完全和QQ一样
效果是实现了,但是和QQ的不太一样,展开的时候,他是揭露式的,而不是滑动式!
原理其实并不复杂。如图:
内容区域覆盖了功能区域,我们只要改变内容区域的坐标位置,就可以将功能区域展示出来。
怎么改变坐标呢?使用translationX ,translationY。
@ViewDebug.ExportedProperty(category = "drawing")
public float getX() {
return mLeft + getTranslationX();
}
可以看到 x的坐标和translationX相关,只要和滑动一样改变内容区域translationX的位置,就可以完成揭露式效果。
到此我们已经完成了QQ的效果,但是差别还是有的。微信的效果又是另一个方式是多层覆盖,有兴趣的同学可以观察一下,仿照一个。
小知识
top left bottom right :view到父控件的距离
translationX 和 translationY 是 View 在相对于最初位置的偏移
scrollX scrollY 是view在滑动过程中的滚动距离
代码Git