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值改变了ViewgetLeft()getRight()实现了View的水平移动。

offsetTopAndBottom(int offset)方法实现原理与offsetLeftAndRight(int offset)相同,offsetTopAndBottom(int offset)通过offset值改变ViewgetTop()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;
}

先计算mPrivateFlags3PFLAG3_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条件不成立,那个mPrivateFlags3PFLAG3_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月期

你可能感兴趣的:(android,控件)