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值改變了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月期