Android自定义控件总结
自定义控件分类:
1、使用系统控件,实现自定义的效果
2、自己定义一个类继承View ,如textView、ImageView等,通过重写相关的方法来实现新的效果
3、自己定义一个类继承ViewGroup,实现相应的效果
继承view类或viewgroup类,来创建所需要的控件。一般来讲,通过继承已有的控件来自定义控件要简单一点。
介绍下实现一个自定义view的基本流程
1.明确需求,确定你想实现的效果。
2.确定是使用组合控件的形式还是全新自定义的形式,组合控件即使用多个系统控件来合成一个新控件,你比如titilebar,这种形式相对简单。
3.如果是完全自定义一个view的话,你首先需要考虑继承哪个类,是View呢,还是ImageView等子类。
4.根据需要去复写View#onDraw、View#onMeasure、View#onLayout方法。
5.根据需要去复写dispatchTouchEvent、onTouchEvent方法。
6.根据需要为你的自定义view提供自定义属性,即编写attr.xml,然后在代码中通过TypedArray等类获取到自定义属性值。
7.需要处理滑动冲突、像素转换等问题。
绘制流程
onMeasure测量view的大小,设置自己显示在屏幕上的宽高。
onLayout确定view的位置,父view 会根据子view的需求,和自身的情况,来综合确定子view的位置(确定他的大小)。
onDraw(Canvas)绘制 view 的内容。
在主线程中 拿到view调用Invalide()方法,刷新当前视图,导致执行onDraw执行,如果是在子线程用postinvalidate,或者不需要一直刷新用postinvalidateDelayed(300),每隔300毫秒刷新一次。
如果希望视图的绘制流程(三步)可以完完整整地重新走一遍,就不能使用invalidate()方法,而应该调用requestLayout()了。
事件冲突
当点击事件发生时,事件最先传递给Activity,Activity会首先将事件将被所属的Window进行处理,即调用superDispatchTouchEvent()方法。
通过观察superDispatchTouchEvent()方法的调用链,我们可以发现事件的传递顺序:
- PhoneWinodw.superDispatchTouchEvent()
- DecorView.dispatchTouchEvent(event)
- ViewGroup.dispatchTouchEvent(event)
事件一层层传递到了ViewGroup里。
当事件出现时,先从顶级开始往下传递,每到一个子view,看他的onInterceptTouchEvent 方法是否拦截,ontouch是否消费方法,如果没有继续向下dispatchTouchEvent分发事件,都不处理向上传,当回到顶级,若顶层(activity)也不对此事件进行处理,此事件相当于消失了(无效果)。
View没有onInterceptTouchEvent()方法,一但有点击事件传递给它,它的ouTouchEvent()方法就会被调用。
当事件发现冲突的时候,处理的原则就是事件分发机制,有俩种方法:
- 外部处理,重写父view的onInterceptTouchEvent ,MotionEvent的事件全部返回false,不拦截;
- 内部处理。重写子view的dispatchTouchEvent,通过requestDisallowInterceptTouchEvent方法(这个方法可以在子元素中干预父元素的事件分发过程),请求父控件不拦截自己的事件,true是不拦截,false是拦截。
Activity/Window/View三者的差别,Activity 如何显示到屏幕上
ActivityManager :用于维护与管理 Activity 的启动与销毁
WindowManagerService:用来创建、管理和销毁Window。
Activity像一个工匠(控制单元),Window像窗户(承载模型),View像窗花(显示视图) LayoutInflater像剪刀,Xml配置像窗花图纸。
- 在Activity中执行setContentView方法后会执行PhoneWindow的setContentView,在该方法中会生成DecorView 组件作为应用窗口的顶层视图。
- DecorView 是PhoneWindow的内部类,继承至FrameLayout,DecorView 会添加一个id为content的FrameLayout作为根布局,Activity的xml文件会通过LayoutInflater的inflate方法解析成View树添加到id为content的FrameLayout中。
- ViewRoot不是View,它的实现类是ViewRootImpl,ViewRoot是DecorView的“管理者”。它是DecorView和WindowManager之间的纽带。
-
毕竟“管理者”,所以View的绘制流程是从ViewRoot的performTraversals方法开始的。所以performTraversals方法依次调用performMeasure,performLayout和performDraw三个方法。然后各自经历measure、layout、draw三个流程最终显示在用户面前,用户在点击屏幕时,点击事件随着Activity传入Window,最终由ViewGroup/View进行分发处理。
ActivityThread,Ams,Wms的工作原理
ActivityThread: 运行在应用进程的主线程上,响应 ActivityMananger、Service 启动、暂停Activity,广播接收等消息。
Ams:统一调度各应用程序的Activity、内存管理、进程管理。
自定义控件有几个重要方法:
1、实现构造方法 。(三个构造方法)
第二个是创建布局文件调用的构造函数
2、onMeasure测量view的大小。 设置自己显示在屏幕上的宽高。
MeasureSpec有SpecMode和SpecSize俩个属性。对于普通view,其MeasureSpec是由父容器的MeasureSpec和自身的layoutparams共同决定的,那么针对不同的父容器和view不同layoutparams,view可以有多种不同的MeasureSpec。
SpecMode有三类。
unspecified:父View不对子View做任何限制,需要多大给多大,一般不关心这个模式
exactly:view的大小就是SpecSize指定的大小。相当于mach_parents和具体数值
at_most:父容器指定了一个specsize,view不能大于这个值。具体的值看view,相当于wrap_content
日常开发中我们接触最多的不是MeasureSpec而是LayoutParams,在View测量的时候,LayoutParams会和父View的MeasureSpec相结合被换算成View的MeasureSpec,进而决定View的大小。
- 对于顶级View(DecorView)其MeasureSpec由窗口的尺寸和自身的LayoutParams共同确定的。
- 对于普通View其MeasureSpec由父容器的Measure和自身的LayoutParams共同确定的。
重写onMeasure为了测量view的大小, 设置自己显示在屏幕上的宽高。
如果写的自定义View是直接继承View的,而且写了super.measure(),则会默认给这个View设置了一个测量宽和高,这个宽高是多少?
//如果View没有设置背景,那么返回android:minWidth这个属性的值,这个值可以为0 //如果View设置了背景,那么返回android:minWidth和背景最小宽度两者中的最大值。
如果写的自定义View是继承现有控件的,而且写了super.measure(),则会默认使用那个现有控件的测量宽高,你可以在这个已经测量好的宽高上做修改,当然也可以全部重新测过再改掉。
如果我们的View直接继承ImageView,ImageView已经运行了一大堆已经写好的代码测出了相应的宽高。我们可以在它基础上更改即可。比如我们的Image2View是一个自定义的正方形的ImageView:
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { //这里已经帮我们测好了ImageView的规则下的宽高,并且通过了setMeasuredDimension方法赋值进去了。 super.onMeasure(widthMeasureSpec, heightMeasureSpec); //我们这里通过getMeasuredWidth/Height放来获取已经赋值过的测量的宽和高 //然后在ImageView帮我们测量好的宽高中,取小的值作为正方形的边。 //然后重新调用setMeasuredDimension赋值进去覆盖ImageView的赋值。 //我们从头到位都没有进行复杂测量的操作,全靠ImageView。哈哈 int width = getMeasuredWidth(); int height = getMeasuredHeight(); if (width < height) { setMeasuredDimension(width, width); } else { setMeasuredDimension(height, height); } }
- setMeasuredDimension后才能getmeasure宽高,super里做了这步,因为这方法是用来设置view测量的宽和高。
- 如果需要重新测量或者动态改变自定义控件大小那就需要根据自己需求重写规则makeMeasureSpec,简单说就是规则改变了就需要重写规则。
- 重写onMeasure方法的目的是为了能够给view一个warp_content属性下的默认大小,因为不重写onMeasure,那么系统就不知道该使用默认多大的尺寸。如果不处理,那wrap_content就相当于match_parent。所以自定义控件需要支持warp_content属性就重写onMeasure。那如何重写呢?
- 可以自己尝试一下自定义一个View,然后不重写onMeasure()方法,你会发现只有设置match_parent和wrap_content效果是一样的,事实上TextView、ImageView 等系统组件都在wrap_content上有自己的处理。
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); Log.d(TAG, "widthMeasureSpec = " + widthMeasureSpec + " heightMeasureSpec = " + heightMeasureSpec); //指定一组默认宽高,至于具体的值是多少,这就要看你希望在wrap_cotent模式下 //控件的大小应该设置多大了 int mWidth = 200; int mHeight = 200; int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec); int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec); if (widthSpecMode == MeasureSpec.AT_MOST && heightMeasureSpec == MeasureSpec.AT_MOST) { setMeasuredDimension(mWidth, mHeight); } else if (widthSpecMode == MeasureSpec.AT_MOST) { setMeasuredDimension(mWidth, heightSpecSize); } else if (heightSpecMode == MeasureSpec.AT_MOST) { setMeasuredDimension(widthSpecSize, mHeight); } }
3、onLayout设置自己显示在屏幕上的位置(只有在自定义ViewGroup中才用到),这个坐标是相对于当前视图的父视图而言的。view自身有一些建议权,决定权在 父view手中。
调用场景:在view需要给其孩子设置尺寸和位置时被调用。子view,包括孩子在内,必须重写onLayout(boolean, int, int, int, int)方法,并且调用各自的layout(int, int, int, int)方法。
protected void onLayout(boolean changed, int l, int t, int r, int b) { for (int i = 0; i < getChildCount(); i++) { View view = getChildAt(i); // 取得下标为I的子view /** * 父view 会根据子view的需求,和自身的情况,来综合确定子view的位置,(确定他的大小) */ //指定子view的位置 , 左,上,右,下,是指在viewGround坐标系中的位置 view.layout(0+i*getWidth(), 0, getWidth()+i*getWidth(), getHeight()); } }
4、onDraw(Canvas)绘制 view 的内容。控制显示在屏幕上的样子(自定义viewgroup时不需要这个)
/*
* backgroundBitmap 要绘制的图片
* left 图片的左边界
* top 图片的上边界
* paint 绘制图片要使用的画笔
*/
canvas.drawBitmap(backgroundBitmap, 0, 0, paint);
View和ViewGroup的区别
- ViewGroup需要控制子view如何摆放的时候需要实现onLayout。
- View没有子view,所以不需要onLayout方法,需要的话实现onDraw
- 继承系统已有控件或容器,比如FrameLayou,它会帮我们去实现onMeasure方法中,不需要去实现onMeasure, 如果继承View或者ViewGroup的话需要warp_content属性的话需要实现onMeasure方法
- 自定义ViewGroup大多时候是控制子view如何摆放,并且做相应的变化(滑动页面、切换页面等)。自定义view主要是通过onDraw画出一些形状,然后通过触摸事件去决定如何变化
scrollTo()和scrollBy()
scrollTo:将当前视图的基准点移动到某个点(坐标点);
ScrollBy移动当前view内容 移动一段距离。
getHeight()和getMeasuredHeight()的区别:
有俩种方法可以获得控件的宽高
- getMeasuredHeight(): 控件实际的大小
获取测量完的高度,只要在onMeasure方法执行完,就可以用它获取到宽高,在自定义view内使用view.measure(0,0)方法可以主动通知系统去测量,然后就可以直接使用它获取宽高。measure里调用的onmeasure
- getHeight():控件显示的大小,必须在onLayout方法执行完后,才能获得宽高,这种方法不好,得等所以的都测量完才能获得。获取到的是屏幕上显示的高度,getMeasuredHeight是实际高度。
view.getViewTreeObserver().addOnGlobalLayoutListener(new OnGlobalLayoutListener() { @Override public void onGlobalLayout() { headerView.getViewTreeObserver().removeGlobalOnLayoutListener(this); int headerViewHeight = headerView.getHeight(); //直接可以获取宽高 } });
这俩个一般情况是一样的,但是在viewgroup里getWidth是父类给子view分配的空间:右边-左边。系统可能需要多次measure才能确定最终的测量宽高,很可能是不准确的,好习惯是在onlayout里获得测量宽高或最终宽高。
还有一种获得控件宽高的方法:
onSizeChanged:当该组件的大小被改变时回调此方法
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); // 当尺寸有变化的时候调用 mHeight = getMeasuredHeight(); mWidth = getMeasuredWidth(); // 移动的范围 mRange = (int) (mWidth * 0.6f); }
onFinishInflate
当xml被填充完毕时调用,在自定义viewgroup中,可以通过这个方法获得子view对象
protected void onFinishInflate() { super.onFinishInflate(); // 容错性检查 (至少有俩子View, 子View必须是ViewGroup的子类) if(getChildCount() < 2){ throw new IllegalStateException("布局至少有俩孩子. Your ViewGroup must have 2 children at least."); } if(!(getChildAt(0) instanceof ViewGroup && getChildAt(1) instanceof ViewGroup)){ throw new IllegalArgumentException("子View必须是ViewGroup的子类. Your children must be an instance of ViewGroup"); } mLeftContent = (ViewGroup) getChildAt(0); mMainContent = (ViewGroup) getChildAt(1); }
其他概念
-
view的位置参数有left、right、top、bottom(可以getXX获得),3.0后又增加了几个参数:x、y、translationX和translationY,其中x和y是view左上角的坐标,而translationX和translationY是view左上角相对于父容器的偏移量。这些参数都是相对于父容器的坐标,并且translationX和translationY的默认值是0,他们的换算关系是:x=left+translationX y=top+ translationY。需要注意的是,view在平移的过程中,top和left表示的是原始左上角的位置信息,其值并不会发生改变,此时发生改变的是x、y、translationX和translationY这四个参数
-
touchslop是系统所能识别出的被认为是滑动的最小距离,比如当俩次滑动事件的滑动距离小于这个值,我们就可以认为未达到滑动距离的临界值
事件分发
View中 setOnTouchListener的onTouch,onTouchEvent,onClick的执行顺序
追溯到View的dispatchTouchEvent源码查看,有这么一段代码
public boolean dispatchTouchEvent(MotionEvent event) { if (!onFilterTouchEventForSecurity(event)) { return false; } if (mOnTouchListener != null && (mViewFlags & ENABLED_MASK) == ENABLED && mOnTouchListener.onTouch(this, event)) { return true; } return onTouchEvent(event); } }
当以下三个条件任意一个不成立时,
- mOnTouchListener不为null
- view是enable的状态
- mOnTouchListener.onTouch(this, event)返回true,
函数会执行到onTouchEvent。在这里我们可以看到,首先执行的是mOnTouchListener.onTouch的方法,然后是onTouchEvent方法
继续追溯源码,到onTouchEvent()观察,发现在处理ACTION_UP事件里有这么一段代码
if (!post(mPerformClick)) { performClick(); }
此时可知,onClick方法也在最后得到了执行
所以三者的顺序是:
- setOnTouchListener() 的onTouch
- onTouchEvent()
- onClick()
view的事件分发:View为啥会有dispatchTouchEvent方法?
View可以注册很多事件监听器,事件的调度顺序是onTouchListener> onTouchEvent>onLongClickListener> onClickListener
View的事件分发
当事件出现时,先从顶级父类开始往下传递,每到一个孩子,看他的onInterceptTouchEvent 方法是否拦截,ontouch是否消费方法,如果没有继续向下dispatchTouchEvent分发事件,都不处理回到顶级的父空间,若顶层(activity)也不对此事件进行处理,此事件相当于消失了(无效果)。
Touchevent 中,返回值是 true ,则说明消耗掉了这个事件,返回值是 false ,则没有消耗掉,会继续传递下去
1)dispatchTouchEvent:这个方法用来分发事件,如果拦截了交给ontouchevent处理,对应上面的和ontounch理解,否则传给子view
2)onInterceptTouchEvent: 这个方法用来拦截事件,返回true表示拦截(不允许事件继续向子view传递),false不拦截,如果自定义viewgroup里某个子view需要自己处理事件,就需要重写改方法,让他返回false。
3)onTouchEvent: 这个方法用来处理事件。Android事件分发是先传递到ViewGroup,再由ViewGroup传递到View的。,子View中如果将传递的事件消费掉,ViewGroup中将无法接收到任何事件。
onTouchEvent
一般自定义控件都需要去重写onTouchEvent方法。
1.在down的时候去记录坐标点
getX/getY获取相对于当前View左上角的坐标,getRawX/getRawY获取相对于屏幕左上角的坐标。
比如接触到按钮时,x,y是相对于该按钮左上点的相对位置。而rawx,rawy始终是相对于屏幕的位置。
2.move的时候计算偏移量,并用scrollTo()或scrollBy()方法移动view。这俩个方法都是快速滑动,是瞬间移动的。
注意:滚动的并不是viewgroup内容本身,而是它的矩形边框。
三种滑动的方法
- 使用scrollTo()或scrollBy()
- 动画
- 实时改变layoutparams,重新布局
如果让view在一段时间内移动到某个位置(不是快速滑动,弹性)方法:
a.使用自定义动画(让view在一段时间内做某件事),extends Animation,
总要修改的是translationx.y这俩个值
(相对于父容器移动的距离)
b.使用Scoller
c.offsetTopAndBottom(offset)和offsetLeftAndRight(offset);,这个好理解,左右移动多少
模板(固定代码):
* @param startX 开始时的X坐标 * @param startY 开始时的Y坐标 * @param disX X方向 要移动的距离 * @param disY Y方向 要移动的距离 myScroller.startScroll(getScrollX(),0,distance,0,Math.abs(distance));//持续的时间 /** * Scroller不主动去调用这个方法 * 而invalidate()可以掉这个方法 * invalidate->draw->computeScroll */ @Override public void computeScroll() { super.computeScroll(); if(scroller.computeScrollOffset()){//返回true,表示动画没结束 scrollTo(scroller.getCurrX(), 0); invalidate(); } }
scroller的工作原理:scroller本身并不能实现view的滑动,它需要配合view的的comouteScroll方法才能完成弹性滑动的效果,它不断的让view重绘,而每一次重绘距滑动起始时间会有有一个时间间隔,通过这个时间间隔srcoller就可以得出view当前的滑动位置,知道了滑动位置就可以通过scrollTo方法来完成view的滑动,就这样,view的每一次重绘就会导致view进行小幅度的滑动,而多次的小幅度滑动就组成了弹性动画。
3.在up的时候,判断应显示的页面位置,并计算距离、滑动页面。见下:
ontouch触摸事件也可以交给其他工具类去实现
1.GestureDetector(手势识别器)去处理,可以在onFling里处理快速滑动事件,同时在MotionEvent.ACTION_UP里处理没有快速滑动的时候。有时候比ontounch更方便,比如处理onfling,onscroll(按下屏幕后拖动),长安,双击等事件。
mDectector.onTouchEvent(event);// 委托手势识别器处理触摸事件 ... case MotionEvent.ACTION_UP: if(!isFling){// 在没有发生快速滑动的时候,才执行按位置判断currid int nextId = 0; if(event.getX()-firstX>getWidth()/2){ // 手指向右滑动,超过屏幕的1/2 当前的currid - 1 nextId = currId-1; }else if(firstX - event.getX()>getWidth()/2){ // 手指向左滑动,超过屏幕的1/2 当前的currid + 1 nextId = currId+1; }else{ nextId = currId; } moveToDest(nextId); // scrollTo(0, 0); } isFling = false; break;
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { //移动屏幕 /** * 移动当前view内容 移动一段距离 * disX X方向移的距离 为正是,图片向左移动,为负时,图片向右移动 * disY Y方向移动的距离 */ scrollBy((int) distanceX, 0); return false; } @Override /** * 发生快速滑动时的回调 */ public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { isFling = true; if(velocityX>0 && currId>0){ // 快速向右滑动。当前子view的下标 currId--; }else if(velocityX<0 && currId<getChildCount()-1){ // 快速向左滑动 currId++; } moveToDest(currId); return false; } @Override public boolean onDown(MotionEvent e) { return false; } });
2. 交给ViewDragHelper去处理
用法:
// a.初始化 (通过静态方法) mDragHelper = ViewDragHelper.create(this , mCallback); // b.传递触摸事件 @Override public boolean onInterceptTouchEvent(MotionEvent ev) { // 传递给mDragHelper return mDragHelper.shouldInterceptTouchEvent(ev); } @Override public boolean onTouchEvent(MotionEvent event) { try { mDragHelper.processTouchEvent(event); } catch (Exception e) { e.printStackTrace(); } // 返回true, 持续接受事件 return true; } ViewDragHelper.Callback mCallback = new ViewDragHelper.Callback() { // c. 重写事件 // 1. 根据返回结果决定当前child是否可以拖拽 // child 当前被拖拽的View // pointerId 区分多点触摸的id @Override public boolean tryCaptureView(View child, int pointerId) { Log.d(TAG, "tryCaptureView: " + child); return true; }; @Override public void onViewCaptured(View capturedChild, int activePointerId) { Log.d(TAG, "onViewCaptured: " + capturedChild); // 当capturedChild被捕获时,调用. super.onViewCaptured(capturedChild, activePointerId); } @Override public int getViewHorizontalDragRange(View child) { // 返回拖拽的范围, 不对拖拽进行真正的限制. 仅仅决定了动画执行速度 return mRange; } // 2. 根据建议值 修正将要移动到的(横向)位置 (重要) // 此时没有发生真正的移动 public int clampViewPositionHorizontal(View child, int left, int dx) { // child: 当前拖拽的View // left 新的位置的建议值, dx 位置变化量 // left = oldLeft + dx; Log.d(TAG, "clampViewPositionHorizontal: " + "oldLeft: " + child.getLeft() + " dx: " + dx + " left: " +left); if(child == mMainContent){ left = fixLeft(left); } return left; } // 3. 当View位置改变的时候, 处理要做的事情 (更新状态, 伴随动画, 重绘界面) // 此时,View已经发生了位置的改变 @Override public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) { // changedView 改变位置的View // left 新的左边值 // dx 水平方向变化量 super.onViewPositionChanged(changedView, left, top, dx, dy); Log.d(TAG, "onViewPositionChanged: " + "left: " + left + " dx: " + dx); int newLeft = left; if(changedView == mLeftContent){ // 把当前变化量传递给mMainContent newLeft = mMainContent.getLeft() + dx; } // 进行修正 newLeft = fixLeft(newLeft); if(changedView == mLeftContent) { // 当左面板移动之后, 再强制放回去. mLeftContent.layout(0, 0, 0 + mWidth, 0 + mHeight); mMainContent.layout(newLeft, 0, newLeft + mWidth, 0 + mHeight); } // 更新状态,执行动画 dispatchDragEvent(newLeft); // 为了兼容低版本, 每次修改值之后, 进行重绘 invalidate(); } // 4. 当View被释放的时候, 处理的事情(执行动画) @Override public void onViewReleased(View releasedChild, float xvel, float yvel) { // View releasedChild 被释放的子View // float xvel 水平方向的速度, 向右为+ // float yvel 竖直方向的速度, 向下为+ Log.d(TAG, "onViewReleased: " + "xvel: " + xvel + " yvel: " + yvel); super.onViewReleased(releasedChild, xvel, yvel); // 判断执行 关闭/开启 // 先考虑所有开启的情况,剩下的就都是关闭的情况 if(xvel == 0 && mMainContent.getLeft() > mRange / 2.0f){ open(); }else if (xvel > 0) { open(); }else { close(); } } @Override public void onViewDragStateChanged(int state) { // TODO Auto-generated method stub super.onViewDragStateChanged(state); } /** * 根据范围修正左边值 * @param left * @return */ private int fixLeft(int left) { if(left < 0){ return 0; }else if (left > mRange) { return mRange; } return left; }
在view移动的时候也可以用伴随动画:
private void animViews(float percent) { // > 1. 左面板: 缩放动画, 平移动画, 透明度动画 // 缩放动画 0.0 -> 1.0 >>> 0.5f -> 1.0f >>> 0.5f * percent + 0.5f // mLeftContent.setScaleX(0.5f + 0.5f * percent); // mLeftContent.setScaleY(0.5f + 0.5f * percent); ViewHelper.setScaleX(mLeftContent, evaluate(percent, 0.5f, 1.0f)); ViewHelper.setScaleY(mLeftContent, 0.5f + 0.5f * percent); // 平移动画: -mWidth / 2.0f -> 0.0f ViewHelper.setTranslationX(mLeftContent, evaluate(percent, -mWidth / 2.0f, 0)); // 透明度: 0.5 -> 1.0f ViewHelper.setAlpha(mLeftContent, evaluate(percent, 0.5f, 1.0f)); // > 2. 主面板: 缩放动画 // 1.0f -> 0.8f ViewHelper.setScaleX(mMainContent, evaluate(percent, 1.0f, 0.8f)); ViewHelper.setScaleY(mMainContent, evaluate(percent, 1.0f, 0.8f)); // > 3. 背景动画: 亮度变化 (颜色变化) getBackground().setColorFilter((Integer)evaluateColor(percent, Color.BLACK, Color.TRANSPARENT), Mode.SRC_OVER);