Android仿蘋果版QQ下拉刷新實現(一) ——打造簡單平滑的通用下拉刷新控件
前言:
因為公司人員變動原因,導致了博主四個月沒有動安卓,一直在做IOS開發,如今接近年前,終於可以花一定的時間放在安卓上了.好了,廢話不多說,今天我們要帶來的效果是蘋果版本的QQ下拉刷新.首先看一下目標效果以及demo效果:
因為此效果實現的步驟較多,所以今天博主要實現以上效果的第一步——打造一個通用的下拉刷新控件,具體效果如下:
GIF圖片比較大,還希望讀者能耐心等待一下下從效果圖中可以看出,我們的下拉刷新的滑動還是很流暢的,可能大多數開發者用的是XListview或者PullToRefresh控件,在此博主本著能造輪子就造輪子的原則,打算自己打造一個自己喜歡的通用下拉刷新控件;下面就由博主來說明一下此控件是如何完成的:
一、自定義LinearLayout,手動加入下拉刷新布局
首先我們得準備好我們的刷新頭部的布局,布局稍微復雜一點,分兩層.一個是正在刷新時候的布局,一個是刷新完成的布局,在這裏我用的RelativeLayout來布局,當然啦,除了RelativeLayout之外,FrameLayout和Linearlayout都可以直接或者間接的實現布局,下面上布局文件代碼:
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="60dp"> <LinearLayout android:id="@+id/ll_ok" android:layout_width="match_parent" android:layout_height="60dp" android:gravity="center" android:orientation="horizontal" android:visibility="gone"> <ImageView android:id="@+id/iv_ok" android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@mipmap/pull_ok" /> <TextView android:id="@+id/tv_ok" android:layout_width="wrap_content" android:layout_height="match_parent" android:layout_marginLeft="10dp" android:gravity="center" android:text="刷新成功" android:textSize="14sp" android:textAppearance="?android:attr/textAppearance" android:textColor="#999999" android:textStyle="bold" /> </LinearLayout> <LinearLayout android:id="@+id/ll_refresh" android:layout_width="match_parent" android:layout_height="60dp" android:gravity="center"> <ProgressBar android:id="@+id/pb_refresh" style="?android:attr/progressBarStyleSmall" android:layout_width="15dp" android:layout_height="15dp" android:layout_centerVertical="true" android:layout_gravity="center" android:indeterminate="true" android:indeterminateDrawable="@drawable/pulling" android:visibility="gone" /> <ImageView android:id="@+id/iv_refresh" android:layout_width="15dp" android:layout_height="15dp" android:layout_centerVertical="true" android:src="@mipmap/pull_down" android:layout_toRightOf="@+id/pb_refresh" android:visibility="visible"/> <LinearLayout android:layout_width="wrap_content" android:layout_height="match_parent" android:layout_toRightOf="@+id/iv_refresh" android:gravity="center" android:layout_marginLeft="10dp" android:orientation="vertical"> <TextView android:id="@+id/tv_tip" android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="center" android:text="下拉刷新" android:singleLine="true" android:textColor="#9D9D9B" android:textSize="14sp" android:textStyle="bold" /> <TextView android:id="@+id/tv_time" android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="center" android:layout_marginTop="5dp" android:text="上次刷新:" android:singleLine="true" android:textColor="#AEAEAC" android:textSize="12sp" /> </LinearLayout> </LinearLayout> </RelativeLayout>
高度我們限制死60dp的高度,這個很重要,因為我們的刷新控件都是基於這個高度來計算的;
接下來,我們開始編寫自定義View,首先我們繼承LinearLayout然後在初始化中加入下拉刷新布局,並定義好一些重要參數:
/** * 下拉刷新狀態 */ public static final int REFRESH_BY_PULLDOWN=0; /** * 松開刷新狀態 */ public static final int REFRESH_BY_RELEASE=1; /** * 正在刷新狀態 */ public static final int REFRESHING=2; /** * 刷新成功狀態 */ public static final int REFRESHING_SUCCESS=3; /** * 刷新失敗狀態 */ public static final int REFRESHING_FAILD=4; private View refreshView; private int refreshTargetTop; ObjectAnimator anim; //下拉刷新相關布局 LinearLayout ll_ok; RelativeLayout ll_refresh; ImageView iv_refresh, iv_ok; TextView tv_tip, tv_time, tv_ok; ProgressBar pb_refresh; private RefreshListener refreshListener; private int lastY; // 是否可刷新標記 private boolean isRefreshEnabled = true; /** * 刷新時間 */ Calendar LastRefreshTime; int refreshState=REFRESH_BY_PULLDOWN; private Context mContext; public YPXRefreshView(Context context) { this(context,null); } public YPXRefreshView(Context context, AttributeSet attrs) { super(context, attrs); mContext = context; init(); } private void init() { LastRefreshTime = Calendar.getInstance(); //刷新視圖頂端的的view refreshView = LayoutInflater.from(mContext).inflate(R.layout.layout_refresh_header, null); initRefreshView(); refreshTargetTop =-ScreenUtils.dpToPx(getResources(),60); LayoutParams lp = new LayoutParams(android.view.ViewGroup.LayoutParams.MATCH_PARENT, -refreshTargetTop); lp.topMargin = refreshTargetTop; lp.gravity = Gravity.CENTER; addView(refreshView, lp); anim = ObjectAnimator.ofFloat(refreshView, "ypx", 0.0f, 1.0f); } private void initRefreshView() { ll_ok = (LinearLayout) refreshView.findViewById(R.id.ll_ok); ll_refresh = (RelativeLayout) refreshView.findViewById(R.id.ll_refresh); iv_refresh = (ImageView) refreshView.findViewById(R.id.iv_refresh); iv_ok = (ImageView) refreshView.findViewById(R.id.iv_ok); tv_tip = (TextView) refreshView.findViewById(R.id.tv_tip); tv_time = (TextView) refreshView.findViewById(R.id.tv_time); tv_ok = (TextView) refreshView.findViewById(R.id.tv_ok); pb_refresh = (ProgressBar) refreshView.findViewById(R.id.pb_refresh); }
變量註釋很詳細,首先我們定義好下拉刷新的五種狀態,分別代表了:下拉刷新、松開刷新、正在刷新、刷新成功、刷新失敗五種樣式.定義好我們的屬性動畫用作滑動動畫,最後就是手動塞入我們的布局,代碼很簡單,下面上一下五中刷新狀態對應的顯示代碼:
/** * 下拉刷新狀態 */ public void pullDownToRefresh() { setRefreshState(REFRESH_BY_PULLDOWN); ll_refresh.setVisibility(View.VISIBLE); ll_ok.setVisibility(View.GONE); tv_tip.setText("下拉刷新"); getRefreshTime(); RotateAnimation anim1 = new RotateAnimation(0, 180, RotateAnimation.RELATIVE_TO_SELF, 0.5f, RotateAnimation.RELATIVE_TO_SELF, 0.5f); anim1.setDuration(300); anim1.setFillAfter(true); iv_refresh.clearAnimation(); iv_refresh.startAnimation(anim1); pb_refresh.setVisibility(View.GONE); iv_refresh.setVisibility(View.VISIBLE); Log.i("下拉刷新","下拉刷新"); } /** * 松開刷新狀態 */ public void pullUpToRefresh() { setRefreshState(REFRESH_BY_RELEASE); ll_refresh.setVisibility(View.VISIBLE); ll_ok.setVisibility(View.GONE); tv_tip.setText("松開刷新"); getRefreshTime(); iv_refresh.setImageDrawable(mContext.getResources().getDrawable(R.mipmap.pull_up)); RotateAnimation anim1 = new RotateAnimation(180, 0, RotateAnimation.RELATIVE_TO_SELF, 0.5f, RotateAnimation.RELATIVE_TO_SELF, 0.5f); anim1.setDuration(300); anim1.setFillAfter(true); iv_refresh.clearAnimation(); iv_refresh.startAnimation(anim1); pb_refresh.setVisibility(View.GONE); iv_refresh.setVisibility(View.VISIBLE); Log.i("松開刷新", "松開刷新"); } /** * 正在刷新狀態 */ public void refreshing() { setRefreshState(REFRESHING); ll_refresh.setVisibility(View.VISIBLE); ll_ok.setVisibility(View.GONE); tv_tip.setText("正在刷新......"); getRefreshTime(); SPUtil.getInstance(mContext).setRefreshTime("MyMobile", "" + DateUtils.getDate(DateUtils.MM_DD_HH_MM, System.currentTimeMillis())); iv_refresh.clearAnimation(); iv_refresh.setVisibility(View.GONE); pb_refresh.setVisibility(View.VISIBLE); } /** * 刷新成功狀態 */ public void refreshOK() { setRefreshState(REFRESHING_SUCCESS); ll_refresh.setVisibility(View.GONE); ll_ok.setVisibility(View.VISIBLE); tv_ok.setText("刷新成功"); iv_ok.setImageDrawable(getResources().getDrawable(R.mipmap.pull_ok)); } /** * 刷新失敗狀態 */ public void refreshFailed() { setRefreshState(REFRESHING_FAILD); ll_refresh.setVisibility(View.GONE); ll_ok.setVisibility(View.VISIBLE); tv_ok.setText("刷新失敗"); iv_ok.setImageDrawable(getResources().getDrawable(R.mipmap.pull_failure)); } public void getRefreshTime(){ String time = SPUtil.getInstance(mContext).getRefreshTime("MyMobile"); if (time == null || "".equals(time)) { tv_time.setVisibility(View.GONE); tv_tip.setGravity(Gravity.CENTER | Gravity.LEFT); ll_refresh.setGravity(Gravity.CENTER); } else { tv_time.setVisibility(View.VISIBLE); ll_refresh.setGravity(Gravity.CENTER | Gravity.LEFT); tv_time.setText("上次刷新:" + time); tv_tip.setGravity(Gravity.BOTTOM|Gravity.LEFT); } }
五種刷新狀態對應五種不同的布局,簡單明了!
二、下拉刷新的原理以及邏輯實現
首先我們介紹一下我們的刷新控件的原理:
- 監聽滑動手勢,使用LayoutParams的topMargin屬性,動態改變topMargin的值達到滑動效果
- 通過滑動的高度判斷當前的狀態為哪種刷新狀態
- 滑動結束,通過屬性動畫收回,回到初始樣式
原理很簡單,難點在於滑動手勢的判斷,廢話不多說,先上一下滑動手勢的代碼:
@Override public boolean onTouchEvent(MotionEvent event) { int y = (int) event.getRawY(); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: //記錄下y坐標 lastY = y; break; case MotionEvent.ACTION_MOVE: //y移動坐標 int m = y - lastY; doMovement(m); //記錄下此刻y坐標 this.lastY = y; break; case MotionEvent.ACTION_UP: fling(); break; } return true; } /** * 下拉move事件處理 * * @param moveY */ private void doMovement(int moveY) { LayoutParams lp = (LayoutParams) refreshView.getLayoutParams(); float f1 = lp.topMargin; int i = (int) (f1 + moveY * 0.4F); if (i >= refreshTargetTop) {//如果下拉大於-60dp的高度,動態刷新子視圖 lp.topMargin = i; refreshView.setLayoutParams(lp); refreshView.invalidate(); invalidate(); } if (lp.topMargin > 0) {//松開刷新狀態 if(refreshState!=REFRESH_BY_RELEASE) { pullUpToRefresh(); setRefreshState(REFRESH_BY_RELEASE); } } else {//下拉刷新狀態 if(refreshState!=REFRESH_BY_PULLDOWN) { setRefreshState(REFRESH_BY_PULLDOWN); pullDownToRefresh(); } } }
在這裏我們設置了一個0.4的滑動阻力值,在手勢滑動的時候,我們通過累加topMargin的值從而達到下拉的目的,如果下拉大於-60dp(即我們一開始設置的下拉刷新頭部高度),則進入刷新狀態,動態改變頁面.同樣原理我們可以判斷滑動的高度是向下還是向上,從而進行下拉刷新和松開刷新的判斷,在這裏,博主在改變狀態之前先加入判斷,這樣可以過濾掉很多不必要的點,從而使我們的刷新頭部箭頭動畫流暢,不會導致狀態混亂問題.
到目前為止,我們的刷新控件基本上已經可以下拉和上拉了,怎麽樣,原理是不是很簡單,最後,我們來看一下核心代碼,就是手指離開後的處理:
/** * up事件處理 */ private void fling() { LayoutParams lp = (LayoutParams) refreshView.getLayoutParams(); if (lp.topMargin > 0) {//拉到了觸發可刷新事件 refresh(); } else {//收回 animRefreshView(lp.topMargin,refreshTargetTop,300); } } private void refresh() { LayoutParams lp = (LayoutParams) this.refreshView.getLayoutParams(); int i = lp.topMargin; animRefreshView(i,0,200); refreshing(); if (refreshListener != null) { refreshListener.onRefresh(); setRefreshState(REFRESHING); } } /** * 從開始位置滑動到結束位置 * * @param startHeight * @param endHeight */ public void animRefreshView(final int startHeight,final int endHeight,int duration){ anim.start(); anim.setDuration(duration); anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation){ float cVal = (Float) animation.getAnimatedValue(); LayoutParams lp = (LayoutParams)refreshView.getLayoutParams(); int k =startHeight+(int)(cVal*(endHeight-startHeight)); lp.topMargin = k; refreshView.setLayoutParams(lp); refreshView.invalidate(); invalidate(); } }); }
首先我們判斷是否拉到了可觸發刷新的高度,如果觸發到了,即顯示刷新狀態,開啟收回動畫,因為當前用戶很可能滑動到了超過刷新頭的高度,這時候我們需要先收回到刷新的高度,即屏幕中顯示正在刷新時候的樣式.重點在於屬性動畫,其實這裏博主之前沒有使用屬性動畫,而是使用了Scroller滑動器來實現收回,雖然效果大差不差,但是滑動和收回的感覺總感覺不是那麽的平滑,所以我首先想到的是用屬性動畫來收回,如果有不熟悉屬性動畫的朋友們,可以自行百度一下,使用很簡單,有許多的動畫監聽和回調,它可以返回當前每一幀的offset,然後通過改變某個狀態值來刷新整個頁面,上面的addUpdateListener就是它的一個動畫監聽方法,如何得到我們的offset呢,很簡單,只需要:
(Float) animation.getAnimatedValue()
即可,我們得到了偏移量之後就可以通過當前的高度和要回到的高度來動態設置topMargin,從而達到平滑的收回
到此,我們的刷新控件完成了四分之三,是不是覺得很簡單呢!
三、刷新結束回調以及使用
當然了,因為我們的控件是下拉刷新,當然少不了刷新時候的回調,當刷新完成的時候,我們還要收回我們的刷新控件,代碼很簡單, 在這裏,博主就直接貼代碼了:
/** * 刷新監聽接口 * * @author Nono */ public interface RefreshListener { void onRefresh(); } /** * 設置刷新回調 * @param listener */ public void setRefreshListener(RefreshListener listener) { this.refreshListener = listener; } /** * 結束刷新事件 */ public void finishRefresh(boolean isOK) { LayoutParams lp = (LayoutParams) this.refreshView.getLayoutParams(); final int i = lp.topMargin; if (isOK) { refreshOK(); } else { refreshFailed(); } if(!anim.isRunning()&&refreshState!=REFRESHING){ new Handler().postDelayed(new Runnable(){ public void run() { animRefreshView(i,refreshTargetTop,500); } }, 300); } }
其中結束刷新事件中我們添加了延時,因為刷新成功或者失敗要給用戶一個反饋,所以我們需要延時0.5秒給用戶.當我們的刷新完成後,只需要調用一下finishRefresh的方法,告訴控件滑動完成了,可以收回了.
使用很簡單,因為我們是繼承LinearLayout的,所以我們可以直接在布局中套在ScrollView上,使用的時候直接findViewByID綁定實現刷新方法即可下面貼上布局代碼:
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <com.ypx.jiehunle.ypx_bezierqqrefreshdemo.YPXRefreshView android:id="@+id/refreshableView1" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:visibility="visible"> <ScrollView android:id="@+id/scrollView1" android:layout_width="match_parent" android:layout_height="match_parent"> <LinearLayout android:id="@+id/ll_layout" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" > </LinearLayout> </ScrollView> </com.ypx.jiehunle.ypx_bezierqqrefreshdemo.YPXRefreshView> </RelativeLayout>
使用時的Activity代碼:
public class MainActivity extends Activity { YPXRefreshView refreshableView; LinearLayout layout; final int SUCCESS = 1; final int FAILED = 0; @SuppressLint("HandlerLeak") Handler handler = new Handler() { public void handleMessage(android.os.Message msg) { switch (msg.what) { case SUCCESS: refreshableView.finishRefresh(true); TextView textView = new TextView(MainActivity.this); textView.setTextColor(Color.BLACK); textView.setTextSize(20); textView.setText("這是刷新的文本"); layout.addView(textView,0); break; case FAILED: refreshableView.finishRefresh(false); break; default: break; } }; }; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); initView(); initData(); } private void initData() { layout.removeAllViews(); for (int i = 0; i < 50; i++) { final TextView textView = new TextView(MainActivity.this); textView.setTextColor(Color.BLACK); textView.setTextSize(20); textView.setText("這是第" + i + "個文本"); textView.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { Toast.makeText(MainActivity.this,textView.getText(),0).show(); } }); layout.addView(textView); } refreshableView.setRefreshListener(new YPXRefreshView.RefreshListener() { @Override public void onRefresh() { handler.postDelayed(new Runnable() { @Override public void run() { handler.sendEmptyMessage(SUCCESS); } }, 500); } }); } private void initView() { refreshableView = (YPXRefreshView) findViewById(R.id.refreshableView1); layout = (LinearLayout) findViewById(R.id.ll_layout); refreshableView.setRefreshEnabled(true); } }
到這裏,我們的刷新控件差不多完成了四分之三點五了,什麽?還沒有結束嗎?當然,因為題目是打造通用的刷新控件,所以我們還有最後的環節!
四、事件攔截處理,達到通用效果
什麽是通用,因為我們的控件是下拉刷新,所以應該支持所有的可滑動布局才對,這就涉及到了事件分發機制,還不了解的小夥伴們,可以自行去補習一下,這裏博主就不贅述了.言歸正傳,既然我們要實現通用的刷新,必然要進行事件攔截,首先想到的就是重寫ViewGroup的onInterceptTouchEvent方法了,那麽我們研究一下什麽時候需要攔截,什麽時候不需要攔截呢?
其實很簡單,當我們內部的滑動控件(ListView或ScrollView等)滑動到最頂部的時候,這時候我們需要觸發下拉刷新,反之則不攔截,給子View自己處理,當然,在進行這一切的時候,我們要先判斷是否存在子View以及判斷子View是繼承哪一種滑動布局,在這裏博主只是簡單的給個例子.所以如果要實現更多的滑動布局刷新,要添加判斷,比如WebView、GridView、RecyclerView等,判斷它們是否滑動到頂部即可,下面上博主的代碼:
@Override public boolean onInterceptTouchEvent(MotionEvent e) { if(!isRefreshEnabled){ return false; } int action = e.getAction(); int y = (int) e.getRawY(); switch (action) { case MotionEvent.ACTION_DOWN: lastY = y; break; case MotionEvent.ACTION_MOVE: if (y > lastY && canScroll()) { return true; } //記錄下此刻y坐標 this.lastY = y; break; } return false; } private boolean canScroll() { View childView; if (getChildCount() > 1) { childView = this.getChildAt(1); if (childView instanceof ListView) { int top = ((ListView) childView).getChildAt(0).getTop(); int pad = ((ListView) childView).getListPaddingTop(); if ((Math.abs(top - pad)) < 3 && ((ListView) childView).getFirstVisiblePosition() == 0) { return true; } else { return false; } } else if (childView instanceof ScrollView) { if (((ScrollView) childView).getScrollY() == 0) { return true; } else { return false; } }else if (childView instanceof WebView) { if (((WebView) childView).getScrollY() == 0) { return true; } else { return false; } }else if (childView instanceof GridView) { int top = ((GridView) childView).getChildAt(0).getTop(); int pad = ((GridView) childView).getListPaddingTop(); if ((Math.abs(top - pad)) < 3 && ((GridView) childView).getFirstVisiblePosition() == 0) { return true; } else { return false; } }else if (childView instanceof RecyclerView) { RecyclerView.LayoutManager manager=((RecyclerView)childView).getLayoutManager(); int top=0; if(manager instanceof LinearLayoutManager){ top = ((LinearLayoutManager)manager).findFirstVisibleItemPosition(); }else if(manager instanceof StaggeredGridLayoutManager){ top = ((StaggeredGridLayoutManager)manager).findFirstVisibleItemPositions(null)[0]; } if(((RecyclerView)childView).getChildAt(0).getY()==0 &&top==0){ return true; } else { return false; } } } return false; }
可以看到在canScroll函數中博主添加了很多判斷, 這裏只要判斷了字View類型是否是滑動布局類型,其中包括,ScrollView、ListView、WebView、GridView、RecyclerView等,其中判斷很簡單,就是當前用戶如果滑動到頂部,則交給外部下拉刷新處理,其余則放給字View處理.如果用戶有自己自定義的滑動布局的話,可以在此基礎上手動添加即可.到這裏,總算完成了我們的刷新控件.
五、總結
總的來說,博主實現的下拉刷新還是非常簡單易懂的,滑動流暢,使用簡單,當然,這不是博主的目的,正如前言所說,博主的目的是為了實現仿IOS的QQ下拉刷新,本篇下拉刷新只是實現的第一步,下一步將會在下一篇博客(安卓仿IOS版QQ下拉刷新(二) ——二維貝塞爾遠沒有你想的那麽復雜)中給大家帶來一點關於貝塞爾曲線的實現,期待的朋友們歡迎支持一下博主哦~
感謝大家的支持,謝謝!
QQ:313930500
下載地址:http://download.csdn.net/detail/qq_16674697/9741375
轉載請註明出處~謝謝~
Android仿蘋果版QQ下拉刷新實現(一) ——打造簡單平滑的通用下拉刷新控件