1. 程式人生 > >Android自定義控制元件:如何使view動起來?

Android自定義控制元件:如何使view動起來?

本文發表於CSDN《程式設計師》

摘要

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月期