Android自定义控件:如何使view动起来?
本文发表于CSDN《程序员》杂志2016年8月期,未经允许不得转载!
摘要
Android中的很多控件都有滑动功能,但是很多时候原生控件满足不了需求时,就需要自定义控件,那么如何能让控件滑动起来呢?本文主要总结几种可以使控件滑动起来的方法
实现
其实能让view动起来的方法,要么就是view本身具备滑动功能,像listview那样可以上下滑动;要么就是布局实现滑动功能,像ScrollView那样使内测的子view滑动;要么就直接借助动画或者工具类实现view滑动,下面从这几方面给出view滑动的方法
view本身实现移动:
- offsetLeftAndRight(offsetX) or offsetTopAndBottom(offsetY)
- layout方法
offsetLeftAndRight(offsetX) or offsetTopAndBottom(offsetY)
看到这两个方法的名字基本就知道它是做什么的,下面先看一下源码,了解一下实现原理
public void offsetLeftAndRight(int offset) {
if (offset != 0) {
final boolean matrixIsIdentity = hasIdentityMatrix();
if (matrixIsIdentity) {
if (isHardwareAccelerated()) {
invalidateViewProperty(false, false);
} else {
final ViewParent p = mParent;
if (p != null && mAttachInfo != null) {
final Rect r = mAttachInfo.mTmpInvalRect;
int minLeft;
int maxRight;
if (offset < 0) {
minLeft = mLeft + offset;
maxRight = mRight;
} else {
minLeft = mLeft;
maxRight = mRight + offset;
}
r.set(0, 0, maxRight - minLeft, mBottom - mTop);
p.invalidateChild(this, r);
}
}
} else {
invalidateViewProperty(false, false);
}
mLeft += offset;
mRight += offset;
mRenderNode.offsetLeftAndRight(offset);
if (isHardwareAccelerated()) {
invalidateViewProperty(false, false);
invalidateParentIfNeededAndWasQuickRejected();
} else {
if (!matrixIsIdentity) {
invalidateViewProperty(false, true);
}
invalidateParentIfNeeded();
}
notifySubtreeAccessibilityStateChangedIfNeeded();
}
}
判断offset是否为0,也就是说是否存在滑动距离,不为0的情况下,根据是否在矩阵中做过标记来操作。如果做过标记,没有开启硬件加速则开始计算坐标。先获取到父view,如果父view不为空,在offset<0时,计算出左侧的最小边距,在offset>0时,计算出右侧的最大值,其实分析了这么多主要的实现代码就那一句 mRenderNode.offsetLeftAndRight(offset),由native实现的左右滑动,以上分析的部分主要计算view显示的区域。
最后总结一下,offsetLeftAndRight(int offset)就是通过offset值改变了View的getLeft()和getRight()实现了View的水平移动。
offsetTopAndBottom(int offset)方法实现原理与offsetLeftAndRight(int offset)相同,offsetTopAndBottom(int offset)通过offset值改变View的getTop()、getBottom()值,同样给出核心代码mRenderNode.offsetTopAndBottom(offset),这个方法也是有native实现
在实现自定义view的时候,可以直接使用这两个方法,简单,方便
layout方法
layout方法是如何实现view移动呢?talk is cheap show me the code
public void layout(int l, int t, int r, int b) {
if ((mPrivateFlags3 & PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT) != 0) {
onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec);
mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
}
int oldL = mLeft;
int oldT = mTop;
int oldB = mBottom;
int oldR = mRight;
boolean changed = isLayoutModeOptical(mParent) ?
setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);
if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
onLayout(changed, l, t, r, b);
mPrivateFlags &= ~PFLAG_LAYOUT_REQUIRED; ListenerInfo li = mListenerInfo;
if (li != null && li.mOnLayoutChangeListeners != null) {
ArrayList<OnLayoutChangeListener> listenersCopy =
(ArrayList<OnLayoutChangeListener>)li.mOnLayoutChangeListeners.clone();
int numListeners = listenersCopy.size();
for (int i = 0; i < numListeners; ++i) {
listenersCopy.get(i).onLayoutChange(this, l, t, r, b, oldL, oldT, oldR, oldB);
}
}
}
mPrivateFlags &= ~PFLAG_FORCE_LAYOUT;
mPrivateFlags3 |= PFLAG3_IS_LAID_OUT;
}
先计算mPrivateFlags3和PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT的与运算,先来看一下mPrivateFlags3赋值的过程:
if (cacheIndex < 0 || sIgnoreMeasureCache) {
// measure ourselves, this should set the measured dimension flag back
onMeasure(widthMeasureSpec, heightMeasureSpec);
mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
} else {
long value = mMeasureCache.valueAt(cacheIndex);
// Casting a long to int drops the high 32 bits, no mask needed
setMeasuredDimensionRaw((int) (value >> 32), (int) value);
mPrivateFlags3 |= PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
}
以上代码摘自measure方法中,如果当前的if条件成立,就走onMeasure方法,给mPrivateFlags3赋值,跟PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT与运算为0,也就是说layout方法的第一个if不成立,不执行onMeasure方法,如果measure方法中的if条件不成立,那个mPrivateFlags3和PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT作与运算时就不为0,在layout方法中的第一个if成立,执行onMeasure方法。
如果左上右下的任何一个值发生改变,都会触发onLayout(changed, l, t, r, b)方法,到这里应该明白View是如何移动的,通过Layout方法给的l,t,r,b改变View的位置。
layout(int l, int t, int r, int b)
- 第一个参数 view左侧到父布局的距离
- 第二个参数 view顶部到父布局之间的距离
- 第三个参数 view右侧到父布局之间的距离
- 第四个参数 view底端到父布局之间的距离
通过改变父布局实现view移动
- scrollTo or scrollBy
- LayoutParams
### scrollTo or scrollBy
先看一下scrollTo 的源码
public void scrollTo(int x, int y) {
if (mScrollX != x || mScrollY != y) {
int oldX = mScrollX;
int oldY = mScrollY;
mScrollX = x;
mScrollY = y;
invalidateParentCaches();
onScrollChanged(mScrollX, mScrollY, oldX, oldY);
if (!awakenScrollBars()) {
postInvalidateOnAnimation();
}
}
}
判断当前的坐标是否是同一个坐标,不是的话,把当前坐标点赋值给旧的坐标点,把即将移动到的坐标点赋值给当前坐标点,通过onScrollChanged(mScrollX, mScrollY, oldX, oldY)方法移动到坐标点(x,y)处。
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}
scrollBy方法简单粗暴,调用scrollTo 方法,在当前的位置继续偏移(x , y)
这里把它归类到通过改变父布局实现view移动是有原因,如果在view中使用这个方法改变的是内容,不是改变view本身,如果在ViewGroup使用这个方法,改变的是子view的位置,相对来说这个实用的概率比较大.
注:以上例子继承自LinearLayout,如果在view中使用,想改变view自身的话,就要先获得外层布局了,想改变view的内容的话,直接写就OK了
LayoutParams
LayoutParams保存布局参数,通过改变局部参数里面的值改变view的位置,如果布局中有多个view,那么多个view的位置整体移动
@Override public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
lastX = x;
lastY = y;
break;
case MotionEvent.ACTION_MOVE:
int offsetX = x - lastX;
int offsetY = y - lastY;
LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) getLayoutParams();
params.leftMargin = getLeft() + offsetX;
params.topMargin = getTop() + offsetY;
setLayoutParams(params);
break;
}
return true;
}
借助 Android 提供的工具实现移动
- 动画
- Scroller
- ViewDragHelper
动画
说到借助工具实现view的移动,相信第一个出现在脑海中的就是动画,动画有好几种,属性动画,帧动画,补间动画等,这里只给出属性动画的实例,属性动画就能实现以上几种动画的所有效果
直接在代码中写属性动画或者写入xml文件,这里给出一个xml文件的属性动画
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<objectAnimator
android:duration="5000"
android:propertyName="translationX"
android:valueFrom="100dp"
android:valueTo="200dp"/>
<objectAnimator
android:duration="5000"
android:propertyName="translationY"
android:valueFrom="100dp"
android:valueTo="200dp"/>
</set>
然后在代码中读取xml文件
animator = AnimatorInflater.loadAnimator(MainActivity.this,R.animator.translation);
animator.setTarget(image);
animator.start();
Scroller
Android 中的 Scroller 类封装了滚动操作,记录滚动的位置,下面看一下scroller的源码
public Scroller(Context context) {
this(context, null);
}
public Scroller(Context context, Interpolator interpolator) {
this(context, interpolator,
context.getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.HONEYCOMB);
}
public Scroller(Context context, Interpolator interpolator, boolean flywheel) {
mFinished = true;
if (interpolator == null) {
mInterpolator = new ViscousFluidInterpolator();
} else {
mInterpolator = interpolator;
}
mPpi = context.getResources().getDisplayMetrics().density * 160.0f;
mDeceleration = computeDeceleration(ViewConfiguration.getScrollFriction());
mFlywheel = flywheel;
mPhysicalCoeff = computeDeceleration(0.84f);
// look and feel tuning
}
一般直接使用第一个构造函数,interpolator默认创建一个ViscousFluidInterpolator,主要就是初始化参数
public void startScroll(int startX, int startY, int dx, int dy) {
startScroll(startX, startY, dx, dy, DEFAULT_DURATION);
}
public void startScroll(int startX, int startY, int dx, int dy, int duration) {
mMode = SCROLL_MODE;
mFinished = false;
mDuration = duration;
mStartTime = AnimationUtils.currentAnimationTimeMillis();
mStartX = startX;
mStartY = startY;
mFinalX = startX + dx;
mFinalY = startY + dy;
mDeltaX = dx;
mDeltaY = dy;
mDurationReciprocal = 1.0f / (float) mDuration;
}
使用过Scroller的都知道要调用这个方法,它主要起到记录参数的作用,记录下当前滑动模式,是否滑动结束,滑动时间,开始时间,开始滑动的坐标点,滑动结束的坐标点,滑动时的偏移量,插值器的值,看方法名字会造成一个错觉,view要开始滑动了,其实这是不正确的,这个方法仅仅是记录而已,其他事什么也没做
Scroller还有一个重要的方法就是computeScrollOffset(),它的职责就是计算当前的坐标点
public boolean computeScrollOffset() {
if (mFinished) {
return false;
}
int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
if (timePassed < mDuration) {
switch (mMode) {
case SCROLL_MODE:
final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
mCurrX = mStartX + Math.round(x * mDeltaX);
mCurrY = mStartY + Math.round(x * mDeltaY);
break;
case FLING_MODE:
final float t = (float) timePassed / mDuration;
final int index = (int) (NB_SAMPLES * t);
float distanceCoef = 1.f;
float velocityCoef = 0.f;
if (index < NB_SAMPLES) {
final float t_inf = (float) index / NB_SAMPLES;
final float t_sup = (float) (index + 1) / NB_SAMPLES;
final float d_inf = SPLINE_POSITION[index];
final float d_sup = SPLINE_POSITION[index + 1];
velocityCoef = (d_sup - d_inf) / (t_sup - t_inf);
distanceCoef = d_inf + (t - t_inf) * velocityCoef;
}
mCurrVelocity = velocityCoef * mDistance / mDuration * 1000.0f;
mCurrX = mStartX + Math.round(distanceCoef * (mFinalX - mStartX));
// Pin to mMinX <= mCurrX <= mMaxX
mCurrX = Math.min(mCurrX, mMaxX);
mCurrX = Math.max(mCurrX, mMinX);
mCurrY = mStartY + Math.round(distanceCoef * (mFinalY - mStartY));
// Pin to mMinY <= mCurrY <= mMaxY
mCurrY = Math.min(mCurrY, mMaxY);
mCurrY = Math.max(mCurrY, mMinY);
if (mCurrX == mFinalX && mCurrY == mFinalY) {
mFinished = true;
}
break;
}
}
else {
mCurrX = mFinalX;
mCurrY = mFinalY;
mFinished = true;
}
return true;
}
当前时间减去开始的时间小于滑动时间,也就是当前还没有滑动结束,利用插值器的值计算当前坐标点的值。
其实Scroller并不会使View动起来,它起到的作用就是记录和计算的作用,通过invalidate()刷新界面调用onDraw方法,进而调用computeScroll()方法完成实际的滑动。
ViewDragHelper
ViewDragHelper封装了滚动操作,内部使用了Scroller滑动,所以使用ViewDragHelper也要实现computeScroll()方法,这里不再给出实例,最好的实例就是Android的源码,最近有看DrawerLayout源码,DrawerLayout滑动部分就是使用的ViewDragHelper实现的,先了解更多关于ViewDragHelper的内容请看DrawerLayout源码分析
注:ViewDragHelper比较重要的两点,一是ViewDragHelper.callback方法,这里面的方法比较多,可以按照需要重写,另一个就是要把事件拦截和事件处理留给ViewDragHelper,否则写的这一推代码,都没啥价值了。
总结
熟练掌握以上这几种方法,完美的使view动起来,然后在onMeasure方法中准确的去计算view的宽高,完美的自定义view就出自你手了!再熟悉一下onLayout方法,自定义ViewGroup也就熟练掌握了,当然自定义view或者自定义ViewGroup写的越多越熟练。本文如果有不正确的地方,欢迎指正!
本文与已发布的文章有些许出入,详情见《程序员》杂志2016年8月期

浙公网安备 33010602011771号