天天看點

Android上實作仿IOS彈性ListView前言實作

前言

衆所周知,與android相比,IOS的界面更炫,使用者體驗更好。現在Android雖然已經到了4.4,在UI上已經得到了很大的提升,但是依然和IOS有一定的差距。例如,IOS上的控件在拖拽時會有彈性效果,在拖拽控件時,控件會随手勢的移動而移動,并且停止拖拽放開手指時。本人沒有開發過IOS程式,對IOS的了解不是很深,但是從搞IOS開發的同僚那裡得知,控件會回彈這種效果是IOS預設的。

Android上的控件沒有這種效果,這就導緻使用者在使用Android應用時,會感覺比較生硬,互動性不是很好。本文會提供一種解決方案,實作類似IOS的彈性效果。Android中的控件有很多,在這些控件中最常用的一個元件就是ListView,原生的ListView使用者體驗并不好,沒有自帶下拉重新整理等功能,對Item排序, 左右滑動Item顯示選項等功能也沒有加入。(github上有很多關于ListView的開源項目,實作了這些功能)本文實作了ListView在滑到第一個條目時,繼續下拉時的彈性效果。

首先介紹一下實作的基本原理。主要的實作機制是為ListView添加一個headerView, 該headerView的原始高度為0,監聽觸摸事件,根據下拉的距離動态改變headerView的高度,并且讓headerView及時重繪,在放開手指時,重新設定headerView的高度為0,這樣的話listView就會回彈到原始狀态。如下圖所示:

Android上實作仿IOS彈性ListView前言實作

實作

下面以代碼的形式介紹實作機制:

1 首先建立一個PullListView, 繼承自SDK中的ListView類,在構造方法中建立一個HeaderView,設定HeaderView的高度為0,并且調用addHeaderView方法添加HeaderView。代碼如下所示:

/**
	 * 構造函數
	 * @param context
	 */
	public PullListView(Context context) {
		super(context);
		setOnScrollListener(this);
		
		//建立PullListView的headerview
		headView = new View(this.getContext());
		headView.setBackgroundColor(Color.WHITE);
		
		headView.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.FILL_PARENT, 0));
		
		this.addHeaderView(headView);
		
	}
           

2  覆寫父類ListView的onTouchEvent方法, 監聽下拉手勢。

在ACTION_DOWN事件中判斷是否已經滑動到頂部。如果滑動到頂部,則記錄下來手勢的起點狀态,如果在按下時,沒有滑動到頂部,也就是第一個Item不可見,那麼就不記錄這個狀态,還是讓listview執行它預設的行為。代碼如下所示:

switch (event.getAction()) {
		case MotionEvent.ACTION_DOWN:
			
			if (firstItemIndex == 0 ) {
				
				isRecored = true;
				startY = (int) event.getY();
				
			}
			break;
           

這裡的firstItemIndex是成員變量,表示第一個可見的Item的索引是不是為0, 如果為0就表示已經滑動到頂部,再繼續下拉時就可以顯示彈性效果。 PullListView實作了OnScrollListener接口, 在構造方法中設定本身的OnScrollListener,監聽滾動事件,并且根據滾動事件改變firstItemIndex的值。代碼如下:

public PullListView(Context context) {
		super(context);
		setOnScrollListener(this);
           
public void onScroll(AbsListView view, int firstVisiableItem,
			int visibleItemCount, int totalItemCount) {
		firstItemIndex = firstVisiableItem;

	}
	
	public void onScrollStateChanged(AbsListView view, int scrollState) {
		currentScrollState = scrollState;
	}
           

在ACTION_MOVE事件中,監聽下拉手勢。并且隻有記錄下了起點狀态才能執行相關邏輯,如果沒有記錄下起點狀态,那麼再次監聽随着移動,是否滑動到頂點,如果在MOVE事件的過程中,ListView滑動到了第一個條目,那麼同樣記錄下起點狀态,如果再繼續下滑,就可以執行彈性效果的相關邏輯。 執行彈性效果的相關邏輯之前,還要判斷是不是向下滑動,如果是向上滑動的,則不執行任何操作。在下滑的過程中計算滑動的距離,随着下滑距離的增加,改變headView的高度,并且請求重繪。相關代碼如下:

case MotionEvent.ACTION_MOVE:
			
			if (!isRecored && firstItemIndex == 0 ) {
				isRecored = true;
				startY = (int) event.getY();
			}
			
			if(!isRecored){
				break;
			}
			
			int tempY = (int) event.getY();
			int moveY = tempY - startY;
			
			if(moveY < 0){
				break;
			}
			
			headView.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.FILL_PARENT,  (int)(moveY * PULL_FACTOR)));
			headView.invalidate();
			
			break;
		}
           

PULL_FACTOR是一個因子,它被定義成一個float類型的常量,值為0.6。這個因子的作用是實作下拉時ListView跟随手指移動的延遲效果,例如向下滑動了100像素, 那麼 ListView并不會向下移動100像素, 而是移動60像素。這樣的話就有了一個延遲,在下拉時就感覺不那麼生硬。

在ACTION_UP事件中監聽手指離開螢幕的操作,在離開螢幕時, 設定headView的高度為0,并且請求重繪, 那麼ListView就回彈到初始狀态。優化之前的代碼如下所示:

case MotionEvent.ACTION_CANCEL:
		case MotionEvent.ACTION_UP:


			if(!isRecored){
				break;
			}

			headView.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.FILL_PARENT, 0));
			
			headView.invalidate();
			
			isRecored = false;

			break;
           

這樣的話能實作回彈效果, 但是headView的高度從一個較大的值瞬間變成0,同樣讓使用者感覺生硬,容易閃瞎使用者的眼 :) 。那麼怎麼解決這個問題呢?我們知道ACTION_MOVE事件時按一定的頻率觸發的,是以在 ACTION_MOVE中能夠多次改變headview的高度,并且它的高度是逐漸增加的,這樣就有平滑的效果,能夠讓ListView跟随使用者手指的移動而移動。但是ACTION_UP事件在整個手勢期間隻會觸發一次,是以無法達到漸變的效果。那麼我們隻能模拟這種漸變效果。在這裡, 我使用的是Java 5線程并發庫中的可排程線程池(ScheduledExecutorService)。該類能夠按一定的頻率重複多次執行一個任務。在按一定的頻率執行任務時, 每次都會使用一個預定義的Handler對象發送消息,并且在處理消息時,遞減headview的高度并重繪,等到headview的高度遞減到0時,就停掉這個周期性的任務。相關代碼如下:

private Handler handler = new Handler(){
		@Override
		public void handleMessage(Message msg) {
			super.handleMessage(msg);
			
			AbsListView.LayoutParams params = (LayoutParams) headView.getLayoutParams();
			
			params.height -= PULL_BACK_REDUCE_STEP;
			
			headView.setLayoutParams(params);
			
			headView.invalidate();
			
			if(params.height <= 0){
				schedulor.shutdownNow();
			}
		}
	};
           
case MotionEvent.ACTION_CANCEL:
		case MotionEvent.ACTION_UP:


			if(!isRecored){
				break;
			}

//			headView.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.FILL_PARENT, 0));
//			headView.invalidate();
			
			schedulor = Executors.newScheduledThreadPool(1);
			schedulor.scheduleAtFixedRate(new Runnable() {
				
				@Override
				public void run() {
					handler.obtainMessage().sendToTarget();
					
					Log.i("testFixedRate", "xxxxxxxxxx");
				}
			}, 0, PULL_BACK_TASK_PERIOD, TimeUnit.NANOSECONDS);
			
			isRecored = false;

			break;
           

這裡定義了兩個常量:PULL_BACK_REDUCE_STEP和PULL_BACK_TASK_PERIOD。  PULL_BACK_REDUCE_STEP表示headview高度每次遞減的像素數,這裡定義為1; PULL_BACK_TASK_PERIOD表示間隔多長時間遞減一次headview的高度,這裡定義為700,注意機關是納秒。也就是說, 在回彈時,每間隔700納秒遞減一次headview的高度,每次遞減1個像素。這兩個值是經過測試而設定的,如果設定不恰當,會使回彈過快或過慢, 并且在回彈的過程中出現一跳一跳的卡頓現象。

這裡為什麼要用handler呢?因為任務是在子線程中排程的,而在子線程中不能操作view,也就是不能設定view的寬度,是以要用一個handler在主線程中處理。

上面就是該PullListView的所有實作。因為代碼并不是很多,是以在下面給出所有代碼。

全部代碼

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import android.content.Context;
import android.graphics.Color;
import android.os.Handler;
import android.os.Message;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.widget.AbsListView;
import android.widget.AbsListView.OnScrollListener;
import android.widget.ListView;

/**
 * 下拉時具有彈性的ListView
 * @author zhangjg
 * @date Dec 21, 2013 4:54:29 PM
 */
public class PullListView extends ListView implements OnScrollListener{
	
	private static final String TAG = "PullListView";

	//下拉因子,實作下拉時的延遲效果
	private static final float PULL_FACTOR = 0.6F;
	
	//回彈時每次減少的高度
	private static final int PULL_BACK_REDUCE_STEP = 1;
	
	//回彈時遞減headview高度的頻率, 注意以納秒為機關
	private static final int PULL_BACK_TASK_PERIOD = 700;
	

	//記錄下拉的起始點
	private boolean isRecored;

	//記錄剛開始下拉時的觸摸位置的Y坐标
	private int startY; 
		
	//第一個可見條目的索引
	private int firstItemIndex;

	//用于實作下拉彈性效果的headView
	private View headView;
		
	private int currentScrollState;
	
	//實作回彈效果的排程器
	private ScheduledExecutorService  schedulor;
	
	//實作回彈效果的handler,用于遞減headview的高度并請求重繪
	private Handler handler = new Handler(){
		@Override
		public void handleMessage(Message msg) {
			super.handleMessage(msg);
			
			AbsListView.LayoutParams params = (LayoutParams) headView.getLayoutParams();
			
			//遞減高度
			params.height -= PULL_BACK_REDUCE_STEP;
			
			headView.setLayoutParams(params);
			
			//重繪
			headView.invalidate();
			
			//停止回彈時遞減headView高度的任務
			if(params.height <= 0){
				schedulor.shutdownNow();
			}
		}
	};

	/**
	 * 構造函數
	 * @param context
	 */
	public PullListView(Context context) {
		super(context);
		
		init();
		
	}

	/**
	 * 構造函數
	 * @param context
	 * @param attr
	 */
	public PullListView(Context context, AttributeSet attr) {
		super(context, attr);
		
		init();
	}
	
	/**
	 * 初始化
	 */
	private void init() {
		//監聽滾動狀态
		setOnScrollListener(this);
		
		//建立PullListView的headview
		headView = new View(this.getContext());
		
		//預設白色背景,可以改變顔色, 也可以設定背景圖檔
		headView.setBackgroundColor(Color.WHITE);  
		
		//預設高度為0
		headView.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.FILL_PARENT, 0));
		
		this.addHeaderView(headView);
	}
	

	
	/**
	 * 覆寫onTouchEvent方法,實作下拉回彈效果
	 */
	@Override
	public boolean onTouchEvent(MotionEvent event) {
		
		switch (event.getAction()) {
		case MotionEvent.ACTION_DOWN:
			
			//記錄下拉起點狀态
			if (firstItemIndex == 0 ) {
				
				isRecored = true;
				startY = (int) event.getY();
				
			}
			break;

		case MotionEvent.ACTION_CANCEL:
		case MotionEvent.ACTION_UP:


			if(!isRecored){
				break;
			}
			
			//以一定的頻率遞減headview的高度,實作平滑回彈
			schedulor = Executors.newScheduledThreadPool(1);
			schedulor.scheduleAtFixedRate(new Runnable() {
				
				@Override
				public void run() {
					handler.obtainMessage().sendToTarget();
					
				}
			}, 0, PULL_BACK_TASK_PERIOD, TimeUnit.NANOSECONDS);
			
			isRecored = false;

			break;


		case MotionEvent.ACTION_MOVE:
			
			if (!isRecored && firstItemIndex == 0 ) {
				isRecored = true;
				startY = (int) event.getY();
			}
			
			if(!isRecored){
				break;
			}
			
			int tempY = (int) event.getY();
			int moveY = tempY - startY;
			
			if(moveY < 0){
				isRecored = false;
				break;
			}
			
			headView.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.FILL_PARENT,  (int)(moveY * PULL_FACTOR)));
			headView.invalidate();
			
			break;
		}
		return super.onTouchEvent(event);
	}
	
	
	
	public void onScroll(AbsListView view, int firstVisiableItem,
			int visibleItemCount, int totalItemCount) {
		firstItemIndex = firstVisiableItem;

	}
	
	public void onScrollStateChanged(AbsListView view, int scrollState) {
		currentScrollState = scrollState;
	}

}