下拉刷新、上拉加載實戰:帶你理解自定義 View 整個過程

RobEZB 7年前發布 | 12K 次閱讀 安卓開發 Android開發 移動開發

寫在前面的話

這篇文章主要是對以前學習的自定義View的一個小總結,拿這個例子來做再合適不過了。簡單介紹一下,主要內容是參照 自個兒寫Android的下拉刷新/上拉加載控件 這篇文章里面的內容(不是自定義ListView,而是ViewGroup,更有難度),但是我還是略有改動,感謝作者無私分享。前面也看了一些關于自定義View,事件分發,滑動沖突等內容,特別是郭神的書,讓我受益匪淺。我的目的就是想帶大家從實際的例子,來認識自定義View中幾個關鍵的步驟,以及怎樣與動畫相結合,希望對一些童鞋能有所幫助。

效果圖

Github地址

建議直接下載整個例子代碼,然后跟著下面的步驟來理解

https://github.com/yixiaoming/PullRefreshLayout

正式開始

如果自定義View還不熟悉的,可以看看這篇基礎知識,能對你有幫助 自定義View應該明白的基礎知識 。

首先明確任務,我們要做的是自定義一個ViewGroup,然后你可以在這個ViewGroup中放入 ListView,RecyclerView,ScrollView只能的可滑動的view,然后給它們添加下拉刷新和上拉加載更多的功能。這和直接自定義ListView還是有一定的區別,后者可以直接使用 addHeader() ,addFooter() 添加頭和尾,而我們需要自己測量,布局,處理滑動沖突等。來看一個圖:

下面的代碼不建議邊看邊貼,主要是理清思路,然后看完整項目再寫

第一步:添加Header和Footer,并隱藏

我們定義一個PullRefreshLayout類,繼承ViewGroup,需要重寫構造方法(如果有自定義屬性),onFinishInflate(),onMeasure(),onLayout(),如果你在這4個函數里面分別加上Log的話,你會發現它們的調用順序就是前面的出現順序,但是 onMeasure 和 onLayout 都會被多次調用。

下面展示的是主要過程,便于理解,具體代碼可以看Github上完整源碼。

onFinishInflate

Called after a view and all of its children has been inflated from XML.

public class PullRefreshLayout extends ViewGroup {
  //...

//保持原樣
public PullRefreshLayout(Context context, AttributeSet attrs) { super(context, attrs); }

// 當view的所有child從xml中被初始化后調用 @Override protected void onFinishInflate() { super.onFinishInflate();

lastChildIndex = getChildCount() - 1;

addHeader();
addFooter();

} }</pre>

這個函數會在View的所有child從xml中被初始化后調用,緊接著構造函數。 lastChildIndex 記錄xml中配置的最后一個child的索引,下面這樣寫,就可以獲得 listview的索引,后面我們將用這個 索引獲取到View,來判斷footer是否顯示。

<org.yxm.pullrefreshlayout.PullRefreshLayout
    android:id="@+id/main_pullrefresh_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

<ListView
  android:id="@+id/main_listview"
  android:layout_width="match_parent"
  android:layout_height="match_parent">
</ListView>

</org.yxm.pullrefreshlayout.PullRefreshLayout></pre>

然后還有 addHeader 和 addFooter,就是為 整個layout添加 Header和 Footer,以及初始化 header和footer中的 textview等。

private void addHeader() {
    mHeader = LayoutInflater.from(getContext()).inflate(R.layout.pull_header, null, false);
    RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(
        LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
    addView(mHeader, params);

mHeaderText = (TextView) findViewById(R.id.header_text);
mHeaderProgressBar = (ProgressBar) findViewById(R.id.header_progressbar);

}

private void addFooter() { mFooter = LayoutInflater.from(getContext()).inflate(R.layout.pull_footer, null, false); RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); addView(mFooter, params); mFooterText = (TextView) findViewById(R.id.footer_text); mFooterProgressBar = (ProgressBar) findViewById(R.id.footer_progressbar); }</pre>

onMeasure

Called to determine the size requirements for this view and all of its children.

我們都知道 onMeasure 的作用是計算自己和 所有孩子 所需要的尺寸,上面我們提到 onMeasure 和 onLayout 都會被多次調用,就是因為我們定義的View中還有child,所以會被調用多次。所以我們還需要在里面計算所有child的尺寸。

@Override
  protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);

for (int i = 0; i < getChildCount(); i++) {
  View child = getChildAt(i);
  measureChild(child, widthMeasureSpec, heightMeasureSpec);
}

}</pre>

onLayout

Called when this view should assign a size and position to all of its children.

onLayout在自己或child,的大小和位置發生變化時會被調用。它個主要的作用還是決定這個View應該放在那兒,怎么放。

@Override
  protected void onLayout(boolean changed, int l, int t, int r, int b) {
    mLayoutContentHeight = 0;

for (int i = 0; i < getChildCount(); i++) {
  View child = getChildAt(i);
  if (child == mHeader) {
    child.layout(0, 0 - child.getMeasuredHeight(), child.getMeasuredWidth(), 0);
    mEffectiveHeaderHeight = child.getHeight();
  } else if (child == mFooter) {
    child.layout(0, mLayoutContentHeight, child.getMeasuredWidth(), mLayoutContentHeight + child.getMeasuredHeight());
    mEffictiveFooterHeight = child.getHeight();
  } else {
    child.layout(0, mLayoutContentHeight, child.getMeasuredWidth(), mLayoutContentHeight + child.getMeasuredHeight());
    if (i < getChildCount()) {
      if (child instanceof ScrollView) {
        mLayoutContentHeight += getMeasuredHeight();
        continue;
      }
      mLayoutContentHeight += child.getMeasuredHeight();
    }
  }
}

}</pre>

里面有幾個重要的地方: layout 函數 的參數是 :(left,top,right,bottom)

如果是header,應該擺放在:

(0,- header height,header width,0)

footer應該擺放在:

(0,content height, footer width,content height + footer height)

如果是 ViewGroup 里面的內容,應該擺放在:

(0,content height,content width,content height + 當前加進來的child height)

需要注意的是, mLayoutContentHeight 是指所有content的高度,就是所有child加起來的高度,是一個不斷累加的值,添加一個child就添加一些,但是不包括header和footer。

將內容擺放好,那么我們的第一步就完成了,并且header隱藏在上面,footer隱藏在下面。

第二步:處理滑動事件

處理滑動事件,我們需要注意兩個函數: onTouchEventonInterceptTouchEvent ,onTouchEvent處理touch事件,如按下,滑動,松開等。onInterceptTouchEvent 會在 onTouchEvent 前面執行,在這里需要判斷是否應該攔截這個事件,然后交由我的 onTouchEvent 處理。一旦 onInterceptTouchEvent 返回 true 表示攔截,后續事件都會交給 onTouchEvent 處理,onInterceptTouchEvent 都不會再執行,下一次按下事件。不知道這樣描述有沒有問題,如果不清楚,你可以在兩個函數里面添加 Log ,然后試一試。

onInterceptTouchEvent

我們需要在這個函數中判斷是否應該攔截滑動事件,例如child是一個ListView,那么 它沒有滑到頭或者沒有滑到尾 的時候,我們都不應該攔截,ACTION_DOWN和ACTION_UP和不需要攔截,當事件為 ACTION_MOVE 時,如果是向下滑動,判斷第一個child是否滑倒最上面,如果是,則更新狀態為 TRY_REFRESH;如果是向上滑動,則判斷最后一個child是否滑動最底部,如果是,則更新狀態為TRY_LOADMORE。然后返回 intercept = true。這樣接下來的滑動事件就會傳給本類的 onTouchEvent 處理。

@Override
  public boolean onInterceptTouchEvent(MotionEvent event) {
    boolean intercept = false;
    int y = (int) event.getY();

if (mStatus == Status.REFRESHING || mStatus == Status.LOADING) {
  return false;
}

switch (event.getAction()) {
  case MotionEvent.ACTION_DOWN: {
    // 攔截時需要記錄點擊位置,不然下一次滑動會出錯
    mlastMoveY = y;
    intercept = false;
    break;
  }
  case MotionEvent.ACTION_MOVE: {

    //向下滑動
    if (y > mLastYIntercept) {
      View child = getChildAt(0);
      intercept = getRefreshIntercept(child);

      if (intercept) {
        updateStatus(mStatus.TRY_REFRESH);
      }
    } 
    //向上滑動
    else if (y < mLastYIntercept) {
      View child = getChildAt(lastChildIndex);
      intercept = getLoadMoreIntercept(child);

      if (intercept) {
        updateStatus(mStatus.TRY_LOADMORE);
      }
    } else {
      intercept = false;
    }
    break;
  }
  case MotionEvent.ACTION_UP: {
    intercept = false;
    break;
  }
}

mLastYIntercept = y;
return intercept;

}</pre>

至于怎么判斷是否應該攔截,這里不同的ViewGroup判斷方法不一樣,主要分為 ScrollView,ListView,RecyclerView,這里的內容要繁瑣一點,可以直接跳過。

/匯總判斷 刷新和加載是否攔截/
  private boolean getRefreshIntercept(View child) {
    boolean intercept = false;

if (child instanceof AdapterView) {
  intercept = adapterViewRefreshIntercept(child);
} else if (child instanceof ScrollView) {
  intercept = scrollViewRefreshIntercept(child);
} else if (child instanceof RecyclerView) {
  intercept = recyclerViewRefreshIntercept(child);
}
return intercept;

}

private boolean getLoadMoreIntercept(View child) { boolean intercept = false;

if (child instanceof AdapterView) {
  intercept = adapterViewLoadMoreIntercept(child);
} else if (child instanceof ScrollView) {
  intercept = scrollViewLoadMoreIntercept(child);
} else if (child instanceof RecyclerView) {
  intercept = recyclerViewLoadMoreIntercept(child);
}
return intercept;

} /匯總判斷 刷新和加載是否攔截/

/具體判斷各種View是否應該攔截/ // 判斷AdapterView下拉刷新是否攔截 private boolean adapterViewRefreshIntercept(View child) { boolean intercept = true; AdapterView adapterChild = (AdapterView) child; if (adapterChild.getFirstVisiblePosition() != 0 || adapterChild.getChildAt(0).getTop() != 0) { intercept = false; } return intercept; }

// 判斷AdapterView加載更多是否攔截 private boolean adapterViewLoadMoreIntercept(View child) { boolean intercept = false; AdapterView adapterChild = (AdapterView) child; if (adapterChild.getLastVisiblePosition() == adapterChild.getCount() - 1 && (adapterChild.getChildAt(adapterChild.getChildCount() - 1).getBottom() >= getMeasuredHeight())) { intercept = true; } return intercept; }

// 判斷ScrollView刷新是否攔截 private boolean scrollViewRefreshIntercept(View child) { boolean intercept = false; if (child.getScrollY() <= 0) { intercept = true; } return intercept; }

// 判斷ScrollView加載更多是否攔截 private boolean scrollViewLoadMoreIntercept(View child) { boolean intercept = false; ScrollView scrollView = (ScrollView) child; View scrollChild = scrollView.getChildAt(0);

if (scrollView.getScrollY() >= (scrollChild.getHeight() - scrollView.getHeight())) {
  intercept = true;
}
return intercept;

}

// 判斷RecyclerView刷新是否攔截 private boolean recyclerViewRefreshIntercept(View child) { boolean intercept = false;

RecyclerView recyclerView = (RecyclerView) child;
if (recyclerView.computeVerticalScrollOffset() <= 0) {
  intercept = true;
}
return intercept;

}

// 判斷RecyclerView加載更多是否攔截 private boolean recyclerViewLoadMoreIntercept(View child) { boolean intercept = false;

RecyclerView recyclerView = (RecyclerView) child;
if (recyclerView.computeVerticalScrollExtent() + recyclerView.computeVerticalScrollOffset()
    >= recyclerView.computeVerticalScrollRange()) {
  intercept = true;
}

return intercept;

} /具體判斷各種View是否應該攔截/</pre>

onTouchEvent

這里面就是處理攔截后的touch事件,我們主要根據滑動的位置來做狀態的修改,和屬性動畫的控制。

下面的代碼我們先沒有加動畫,先理清楚思路。

@Override
  public boolean onTouchEvent(MotionEvent event) {
    int y = (int) event.getY();

// 正在刷新或加載更多,避免重復
if (mStatus == Status.REFRESHING || mStatus == Status.LOADING) {
  return true;
}

switch (event.getAction()) {
  case MotionEvent.ACTION_DOWN:
    mlastMoveY = y;
    break;
  case MotionEvent.ACTION_MOVE:
    int dy = mlastMoveY - y;

    // 一直在下拉
    if (getScrollY() <= 0 && dy <= 0) {
      if (mStatus == Status.TRY_LOADMORE) {
        scrollBy(0, dy / 100);
      } else {
        scrollBy(0, dy / 3);
      }
    }
    // 一直在上拉
    else if (getScrollY() >= 0 && dy >= 0) {
      if (mStatus == Status.TRY_REFRESH) {
        scrollBy(0, dy / 100);
      } else {
        scrollBy(0, dy / 3);
      }
    } else {
      scrollBy(0, dy / 3);
    }

    beforeRefreshing();
    beforeLoadMore();

    break;
  case MotionEvent.ACTION_UP:
    // 下拉刷新,并且到達有效長度
    if (getScrollY() <= -mEffectiveHeaderHeight) {
      releaseWithStatusRefresh();
      if (mRefreshListener != null) {
        mRefreshListener.refreshFinished();
      }
    }
    // 上拉加載更多,達到有效長度
    else if (getScrollY() >= mEffictiveFooterHeight) {
      releaseWithStatusLoadMore();
      if (mRefreshListener != null) {
        mRefreshListener.loadMoreFinished();
      }
    } else {
      releaseWithStatusTryRefresh();
      releaseWithStatusTryLoadMore();
    }
    break;
}

mlastMoveY = y;
return super.onTouchEvent(event);

}</pre>

第一個: mlastMoveY ,這里采取的是 scrollBy 相對滑動的方式,每向下移動一點,就會觸發 onTouchEvent,用當前event的y 減去 上一次記錄的y,就是我剛剛滑動的一點點距離,然后使用 scrollBy 將整個view 向下滑動一點點,如果動作連貫就形成了滑動的效果。

第二個:ACTION_MOVE 時的狀態變化,注意這里的兩個距離: getScrollY() 獲得的是整體,在我松開之前,整體的View在Y軸上滑動的距離,為負值表示整體往下滑動。 dy = mLastY - y ,表示剛剛 scrollBy 滑動的一小段距離是向上還是向下,如果為負,表示向下滑動一點點。

這里情況稍微復雜一點,這里舉下拉的例子,記住我們實在 onIntercetpTouchEvent 中做得事件攔截,并且如果是下拉就將 mStatus = Status.TRY_REFRESH。攔截之后知道你松開手指,所有事件都直接傳遞個 onTouchEvent ,而不會再經過地方。

滑動的距離分為下面幾種情況,假設有效距離20:

  1. 如果我們一直下拉,拉到20松開就可以更新,這是最好的情況。
  2. 如果一直下拉,拉了20。然后又慢慢向上移動滑上去到10松開,不應該更新。但是整體效果也是向下拉的,不會有問題。
  3. 如果一直下拉,拉了10,這時反向向上滑動,返回到原來位置,甚至負數,那么這個時候layout整體向上移動,導致下面的加載更多出現,這種情況是不對的。應該是在返回到原來位置時,將攔截設置為false,交給child去處理,但是我們剛剛說了,直到松開手指,onInterceptTouchEvent 都不會被調用。所以這里做了這種判斷,如果前面記錄了是想下拉,但是又反向超過了原來位置,則使反向拉特別費力 dy / 100 ,讓下半部無法出現,迫使用戶松開手指。這種處理不是太好,但是我也沒有想到更好的方法。

其他的情況都好處理,直接滑動就好, scrollBy 的距離是 實際距離/3 是想造成簡單的阻尼運動的效果。

if (getScrollY() >= 0 && dy >= 0) {
  if (mStatus == Status.TRY_REFRESH) {
    scrollBy(0, dy / 100);
  } else {
    scrollBy(0, dy / 3);
  }
}
else {
    scrollBy(0, dy / 3);
}

然后 beforeRefreshingbeforeLoadMore 就是和用戶交互所需要做的事情。比如滑動達到有效距離,更新文字,出現圖標。然后又滑回去,又修改文字,消失圖標,這里先做簡單的處理,后面需要和動畫相結合。

public void beforeRefreshing() {
    if (getScrollY() <= -mEffectiveHeaderHeight) {
      mHeaderText.setText("松開刷新");
    } else {
      mHeaderText.setText("下拉刷新");
    }
  }

public void beforeLoadMore() { if (getScrollY() >= mEffectiveHeaderHeight) { mFooterText.setText("松開加載更多"); } else { mFooterText.setText("上拉加載更多"); } }</pre>

第三個:當手指抬起的時候,會相應 ACTION_UP 事件,這時我們我們需要根據是否達到有效距離,做后續的工作,這里直接看代碼就可以理解。

// 下拉刷新,并且到達有效長度
        if (getScrollY() <= -mEffectiveHeaderHeight) {
          releaseWithStatusRefresh();
          if (mRefreshListener != null) {
            mRefreshListener.refreshFinished();
          }
        }
        // 上拉加載更多,達到有效長度
        else if (getScrollY() >= mEffictiveFooterHeight) {
          releaseWithStatusLoadMore();
          if (mRefreshListener != null) {
            mRefreshListener.loadMoreFinished();
          }
        } else {
          releaseWithStatusTryRefresh();
          releaseWithStatusTryLoadMore();
        }

具體實現

private void releaseWithStatusTryRefresh() {
    scrollBy(0, -getScrollY());
    mHeaderText.setText("下拉刷新");
    updateStatus(Status.NORMAL);
  }

private void releaseWithStatusTryLoadMore() { scrollBy(0, -getScrollY()); mFooterText.setText("上拉加載更多"); updateStatus(Status.NORMAL); }

private void releaseWithStatusRefresh() { scrollTo(0, -mEffectiveHeaderHeight); mHeaderProgressBar.setVisibility(VISIBLE); mHeaderText.setText("正在刷新"); updateStatus(Status.REFRESHING); }

private void releaseWithStatusLoadMore() { scrollTo(0, mEffictiveFooterHeight); mFooterText.setText("正在加載"); mFooterProgressBar.setVisibility(VISIBLE); updateStatus(Status.LOADING); }

public void refreshFinished() { scrollTo(0, 0); mHeaderText.setText("下拉刷新"); mHeaderProgressBar.setVisibility(GONE); updateStatus(Status.NORMAL); }

public void loadMoreFinished() { mFooterText.setText("上拉加載"); mFooterProgressBar.setVisibility(GONE); scrollTo(0, 0); updateStatus(Status.NORMAL); }</pre>

到這里主要的邏輯已經走完了,下面我們來看看和用戶的交互動畫怎么添加。

第三部:交互動畫

如果在這個過程中只使用文字,用戶體驗是很差的,所以我們需要用一些動畫效果來提示用戶應該怎么做,增強用戶體驗。一般下拉刷新都會有一個小圖標,指示下拉的程度,然后提示用戶松開, 我們這里用一個小箭頭來做指示,根據用戶拉下的距離計算小箭頭應該旋轉的角度 ,做一個小交互。

首先看一下 header的xml文件: pull_header.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="

<TextView android:textSize="16sp" android:id="@+id/header_text" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" android:text="下拉刷新"/>

<ProgressBar android:id="@+id/header_progressbar" android:layout_width="30dp" android:layout_height="30dp" android:layout_toLeftOf="@+id/header_text" android:visibility="gone"/>

<ImageView android:id="@+id/header_arrow" android:layout_width="30dp" android:layout_height="30dp" android:layout_centerVertical="true" android:layout_toLeftOf="@+id/header_text" android:layout_toStartOf="@+id/header_text" android:src="@mipmap/ic_action_arrow_bottom"/>

</RelativeLayout></pre>

計算旋轉角度

邏輯理清楚,3個控件:1. 文字提示,2.運行進度條在刷新時顯示,3.箭頭圖標根據滑動距離旋轉角度,刷新時隱藏。

首先解決旋轉問題, 根據滑動距離計算旋轉角度 ,首先我們應該想到在 onTouchEvent 中的ACTION_MOVE 中解決,還記得我們前面下了一個 beforeRefreshing 函數,專門用來處理文字的改變和動畫的處理,這里我們就直接在這個函數中添加交互動畫:

public void beforeRefreshing(float dy) {
    //計算旋轉角度
    int scrollY = Math.abs(getScrollY());
    scrollY = scrollY > mEffectiveHeaderHeight ? mEffectiveHeaderHeight : scrollY;
    float angle = (float) (scrollY  1.0 / mEffectiveHeaderHeight  180);
    //旋轉角度
    mHeaderArrow.setRotation(angle);

if (getScrollY() <= -mEffectiveHeaderHeight) {
  mHeaderText.setText("松開刷新");
} else {
  mHeaderText.setText("下拉刷新");
}

}</pre>

首先根據滑動的距離,最大是header的高度,然后計算旋轉角度比例*180,就得到了旋轉的角度,然后直接將ImageView的rotation設置旋轉角度,就完成了,就是這么簡單。在做之前我還想用屬性動畫來做,嘗試了一下,各種問題,呵呵,只怪自己沒有經驗,像這種瞬時的動畫,還是直接設置屬性來的簡單。

然后就是在松開手時隱藏箭頭,顯示進度條。

private void releaseWithStatusRefresh() {
    scrollTo(0, -mEffectiveHeaderHeight);
    mHeaderProgressBar.setVisibility(VISIBLE);
    mHeaderText.setText("正在刷新");
    // 新加
    mHeaderArrow.setVisibility(GONE);

updateStatus(Status.REFRESHING);

}</pre>

加載完成隱藏進度條,顯示箭頭。

private void refreshFinished() {
    scrollTo(0, 0);
    mHeaderText.setText("下拉刷新");
    mHeaderProgressBar.setVisibility(GONE);
    // 新加
    mHeaderArrow.setVisibility(VISIBLE);

updateStatus(Status.NORMAL);

}</pre>

這樣整個簡單的交互動畫也完成了。

寫在最后的話

到這里,3個步驟已經分析得很詳細,自定義View到底應該怎么做,并且將交互動畫也添加了進來,結合Github上的整個代碼,希望你能理解。自定義View也是有很多的套路的,自己可以琢磨琢磨。再次感謝參考文章的作者,從他的文章中我理解很多細節上的內容。

 

來自:http://blog.csdn.net/u013647382/article/details/58092102

 

 本文由用戶 RobEZB 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!