AdapterView - OriginalのListViewを作成 - Part1

さて、AdapterViewを拡張し、OriginalのListViewを作ってみようという回です。一応、3回に分けてやろうかなと考えてます。SonyEricsson Developer Worldというblogサイトに掲載されているTutorialに沿って、自分でも試しに実装してみようというのが目的です。3回に分けようと考えてるのはOriginalのtutorialがそうなっているからです。(途中で挫折するかもしれませんが...)
今回はListを表示し、Scroll, Click, Long Clickが出来るようになるというレベルの実装ですが、基礎となる回になります。最終的にはOriginalのように3D Listにするかどうかはわかりませんが、より実用的なflickとかbounceの動きはつけたいなと考えてます。
勉強の為ですのでOriginalとは違うように自分の分かりやすいように実装しているところや加えて実装している面もあります

AdapterViewとは

AdapterViewは子Viewとして表示されるViewをAdapterから受け取り、それを配置/表示させます。よって子Viewとして表示させるViewのContents/DataはAdapterによって決定されます。ListViewやGridView、Galleryなどのおおもととなるclassです

実装の要点をまとめます

Activity
  1. AdapterViewを拡張したOriginalのListView(以後、MyListView)にAdapterをset
  2. MyListViewに子ViewがClick/Long clickされた際のListenerを実装/登録
Adapter
  1. Adapter#getView()でMyListViewの子Viewとして表示するViewを作成し、戻り値として返す様に実装
    • 第2引数で渡されるViewがnullでなければ、そのViewを再利用し、xmlからのinflateを避ける
    • ViewのTag機能を使用し、View#findViewById()でViewのreferenceをすることを避ける
AdapterView(MyListView)
  1. AdapterView#setAdapter()を実装し、引数として渡されたAdapterを保持
  2. View#onLayout()を実装
    • 保持したAdapterのgetView()を呼び、子Viewを取得し、ViewGroup#addViewInLayout()でViewをattach
      • 表示領域に入らないものに関してはMyListViewにattachしないよう/MyListViewから取り除くよう実装
    • ViewのLayoutParamsの情報やMyListViewがどう子Viewを表示したいかという事を考慮しMeasureSpecを作成し、それをもとにView#measure()で子ViewのMeasureing
    • Measuringして得たMeasured width/heightをもとにView#layout()で子ViewをLayout
    • Layoutが出来たのでView#invalidate()で描画処理を呼ぶ
  3. View#onTouchEvent()を実装
    • scroll判定を実装
      • scroll判定した場合はView#requestLayout()を呼び、子ViewのLayoutをしなおす
    • click判定を実装
      • CallbackのonItemClickListener#onItemClick()を呼ぶためAdapterView#performItemClick()を記述
    • Long Click判定を実装
      • Long Click判定の為にRunnableを使用
      • CallbackのためOnItemLongClickListener#onItemLongClick()を記述

それでは実装です

Activity & Adapter(SonyEricsson Developer Worldのほぼコピーになります)

package com.android.practice1;

import java.util.ArrayList;


import android.app.Activity;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.AdapterView.OnItemLongClickListener;

public class MyActivity extends Activity {

    /** The list view */
    private MyListView mListView;

    /**
     * Small class that represents a contact
     */
    private static class Contact {

        /** Name of the contact */
        String mName;

        /** Phone number of the contact */
        String mNumber;

        /**
         * Constructor
         * 
         * @param name The name
         * @param number The number
         */
        public Contact(final String name, final String number) {
            mName = name;
            mNumber = number;
        }

    }

    @Override
    public void onCreate(final Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        final ArrayList<Contact> contacts = createContactList(20);
        final MyAdapter adapter = new MyAdapter(this, contacts);
        
        //MyListViewをxmlより取得
        mListView = (MyListView)findViewById(R.id.my_list);
        //AdapterをMyListViewにset
        mListView.setAdapter(adapter);
        
        //子Viewがclickされた際の処理を実装し、Listener登録
        mListView.setOnItemClickListener(new OnItemClickListener() {
        	//Click時、MyListViewからCallbackされる
            public void onItemClick(final AdapterView<?> parent, final View view,
                    final int position, final long id) {
                final String message = "OnClick: " + contacts.get(position).mName;
                Toast.makeText(MyActivity.this, message, Toast.LENGTH_SHORT).show();
            }
        });
        
        //子ViewがLong clickされた際の処理を実装し、Listener登録
        mListView.setOnItemLongClickListener(new OnItemLongClickListener() {
        	//LongClick時、MyListViewからCallbackされる
            public boolean onItemLongClick(final AdapterView<?> parent, final View view,
                    final int position, final long id) {
                final String message = "OnLongClick: " + contacts.get(position).mName;
                Toast.makeText(MyActivity.this, message, Toast.LENGTH_SHORT).show();
                return true;
            }
        });
    }

    /**
     * Creates a list of fake contacts
     * 
     * @param size How many contacts to create
     * @return A list of fake contacts
     */
    private ArrayList<Contact> createContactList(final int size) {
        final ArrayList<Contact> contacts = new ArrayList<Contact>();
        for (int i = 0; i < size; i++) {
            contacts.add(new Contact("Contact Number " + i, "+46(0)"
                    + (int)(1000000 + 9000000 * Math.random())));
        }
        return contacts;
    }

    /**
     * Adapter class to use for the list
     */
    private static class MyAdapter extends ArrayAdapter<Contact> {

        /** Re-usable contact image drawable */
        private final Drawable contactImage;

        /**
         * Constructor
         * 
         * @param context The context
         * @param contacts The list of contacts
         */
        public MyAdapter(final Context context, final ArrayList<Contact> contacts) {
            super(context, 0, contacts);
            contactImage = context.getResources().getDrawable(R.drawable.contact_image);
        }
        
        //MyListViewのonLayout()内で呼ばれる
        //第一引数で指定されたposition番目の表示Content/Dataを一つの子Viewとbindingし、
        //その子ViewをMyListViewに戻り値として与える
        //converViewにはMyListViewのmCachedViewにCacheされたViewが渡されるので、
        //convertViewがnullでなければxmlからinflateする必要がない
        @Override
        public View getView(final int position, final View convertView, final ViewGroup parent) {
            View view = convertView;
            //ViewのTagにsetするholder
            ViewHolder holder = null;
            if (view == null) {
                view = LayoutInflater.from(getContext()).inflate(R.layout.list_item, null);
                //ViewのTagにsetするholderを作成
                holder = new ViewHolder();
                //次回からView#findViewById()せずにaccessできるように、holderのfieldに
                //TextView、ImageViewのreferenceを格納
                holder.mName = (TextView)view.findViewById(R.id.contact_name);
                holder.mNumber = (TextView)view.findViewById(R.id.contact_number);
                holder.mPhoto = (ImageView)view.findViewById(R.id.contact_photo);
                view.setTag(holder);
            }else{
            	//すでにViewHolderがViewにsetされていて、そこからTextView、ImageViewにaccessできるので
            	//View#findViewById()をする必要がない
            	holder = (ViewHolder)view.getTag();
            }

            if (position == 14) {
                holder.mName.setText("This is a long text that will make this box big. "
                        + "Really big. Bigger than all the other boxes. Biggest of them all.");
            } else {
                holder.mName.setText(getItem(position).mName);
            }
            
            holder.mNumber.setText(getItem(position).mNumber);

            holder.mPhoto.setImageDrawable(contactImage);

            return view;
        }
    }
    
    public static class ViewHolder{
    	public TextView mName;
    	public TextView mNumber;
    	public ImageView mPhoto;
    }
}

OriginalのAdapterView(MyListView)

package com.android.practice1;

import java.util.LinkedList;

import android.content.Context;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup.LayoutParams;
import android.widget.Adapter;
import android.widget.AdapterView;
import android.widget.Toast;
import android.widget.AdapterView.OnItemLongClickListener;

public class MyListView extends AdapterView {
	
	private static final String TAG = "MyListView";
	
	private static final int INVALID_INDEX = -1;
	private static final int LAYOUT_MODE_BELOW = -1;
	private static final int LAYOUT_MODE_ABOVE = 0;
	
	private static final int SCROLL_THRESHOLD = 10;
	private static final int TOUCH_STATE_RESTING = 0;
	private static final int TOUCH_STATE_CLICK = 1;
	private static final int TOUCH_STATE_SCROLL = 2;
	
	private Context mContext;
	private Adapter mAdapter;
	private LinkedList<View> mCachedViews;
	
	private int mListTop;
	private int mListTopOffset;
	
	private int mFirstVisibleItem;
	private int mLastVisibleItem = -1;
	
	private int mListTopWhenTouch;
	private int mTouchState = TOUCH_STATE_RESTING;
	private int mTouchStartX;
	private int mTouchStartY;
	
	private Rect mRect;
	private Runnable mLongPressRunnable;
	
	public MyListView(Context context, AttributeSet attrs) {
		super(context, attrs);
		mContext = context;
		//Paddingの分を考慮し、どこからlistのfirst itemが始まるかを決める
		mListTop = getPaddingTop();
		//Adapter#getView()に渡すためのViewをCacheするList
		mCachedViews = new LinkedList<View>();
	}

	@Override
	public Adapter getAdapter() {
		return mAdapter;
	}

	@Override
	public View getSelectedView() {
		throw new UnsupportedOperationException("Not supported");
	}

	@Override
	public void setAdapter(Adapter adapter) {
		mAdapter = adapter;
		//新しいListがsetされたので過去にattachした子Viewを削除する
		removeAllViewsInLayout();
		//再度、Layoutのprocessを実行する
		requestLayout();
	}

	@Override
	public void setSelection(int position) {
		throw new UnsupportedOperationException("Not supported");		
	}

	@Override
	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
		//直接の基底classはViewGroupだが、そこでは何も定義されていないので
		//ViewのonMeasureが呼ばれる
		super.onMeasure(widthMeasureSpec, heightMeasureSpec);
	}
	
	
	//ここからがキモ
	@Override
	protected void onLayout(boolean changed, int left, int top, int right,int bottom) {
		//一応、superを呼んでおく。たいした事はやってない
		super.onLayout(changed, left, top, right, bottom);
		//Adapterがセットされていなかったら何もしない
		if(mAdapter == null){
			return;
		}
		//子Viewがまだattachされていない時
		if(getChildCount() == 0){
			//MyListViewの表示領域の上限からpadding topの値を引いたところから子Viewを配置していく
			//そのためのMeasuring process
			fillListDown(getPaddingTop());
		}else{
			//まずは全く見えなくなってしまった子Viewをdetach
			removeNonVisibleViews();
			//上記とは逆に表示領域内に現れてきた子Viewが無いか確認
			fillList();
		}
		//上記、Measuring processで得た子ViewのMeasured heightをもとに配置していく
		positionItems();
		//View#invalidate()を呼び、描画処理
		invalidate();
	}
	
	private void removeNonVisibleViews() {
		//Listの先頭のitemのtop位置
		int firstItemTop = mListTop + mListTopOffset;
        int childCount = getChildCount();
        
        if (mLastVisibleItem != mAdapter.getCount() - 1 && childCount > 1) {
            
            View firstChild = getChildAt(0);
            //paddingよりもfirst itemのbottomの値が小さい間
            while (firstChild != null && firstItemTop + firstChild.getHeight() < getPaddingTop()) {
            	Log.d(TAG, "first child invisible");
            	//first visible itemをListから取り除くので、firstItemTopをその分プラスする
            	firstItemTop += firstChild.getHeight();
                //first visible itemを取り除く
                removeViewInLayout(firstChild);
                //Cacheに削除したviewを追加し、後で使えるようにする
                mCachedViews.addLast(firstChild);
                //firstChildをremoveしたのでchildCountを減算
                childCount--;
                //画面に見えている最初のItemのpositionを合わせてupdate
                mFirstVisibleItem++;
                //mListTopOffsetをupdate
                mListTopOffset += firstChild.getMeasuredHeight();
                //次のViewも念のため画面から出ていないか調べるための準備
                if (childCount > 1) {
                    firstChild = getChildAt(0);
                } else {
                    firstChild = null;
                }
            }
        }

        //first visible itemが0ではなく、Listのitemが2個以上の時
        if (mFirstVisibleItem != 0 && childCount > 1) {
            View lastChild = getChildAt(childCount - 1);
            //paddingよりもListのlast itemのtopの値が大きい間
            while (lastChild != null && firstItemTop + lastChild.getTop() > getHeight() - getPaddingBottom()) {
            	Log.d(TAG, "last child invisible");
                //最後のitemを取り除く
                removeViewInLayout(lastChild);
                //lastChildをremoveしたのでchildCountを減算
                childCount--;
                //Cacheに削除したviewを追加し、後で使えるようにする
                mCachedViews.addLast(lastChild);
                //画面に見えている最後のItemのpositionを合わせてupdate                
                mLastVisibleItem--;
                //次のViewも念のため画面から出ていないか調べるための準備
                if (childCount > 1) {
                    lastChild = getChildAt(childCount - 1);
                } else {
                    lastChild = null;
                }
            }
        }
        Log.d(TAG, "child count: " + getChildCount());
    }
	
	private void fillList(){
		//現在見えているItemのtopの位置
		int firstItemTop = mListTop + mListTopOffset;
		fillListUp(firstItemTop);
		//現在見えているItemのbottomの位置
		int lastItemBottom = getChildAt(getChildCount() - 1).getBottom();
		fillListDown(lastItemBottom);
	}
	
	private void fillListUp(int firstItemTop){
		//mFistVisibleItemがposition 0ではなく、現在のfirst visible itemのtopがMyListViewの表示領域内にある時、
		//position 0に新しいviewを追加
		while(mFirstVisibleItem > 0 && firstItemTop > getPaddingTop()){
			//position 0に(現在のmFirstVisibleItem -1)のviewを加えるのでmFirstVisibleItemはマイナスされる
			mFirstVisibleItem--;
			//AdapterからmFirstVisibleItem positionのViewを取得する
			View child = (View)mAdapter.getView(mFirstVisibleItem, getCachedView(), this);
			//取得したViewをlayoutに加え、measuringする
			//Listの始めにViewを追加するのでLAYOUT_MODE_ABOVE(= postion 0)
			addAndMeasureChild(child, LAYOUT_MODE_ABOVE);
			//Viewが追加されたのでfirstItemTopも変更される
			firstItemTop -= child.getMeasuredHeight();
			//mListTopOffsetもViewがListの頭に追加されたので、その分、マイナス
			mListTopOffset -= child.getMeasuredHeight();
			Log.d(TAG, "firstItemTop: " + firstItemTop);
		}
	}
	
	private void fillListDown(int lastItemBottom){
		//View#layout()が呼ばれるとView#setFrame()が呼ばれ、getHeigth()やgetWidth()が意味のある値を返すようになる
		//View#onLayout()はView#layout()によりView#setFrame()の後に呼ばれるのでgetHeight()で値を取得する事が可能
		//last itemがAdapterで保持しているitemの数よりも少なく、last item bottomがMyListViewの表示領域内にある時、
		//Listの最後に新しいviewを追加
		while(lastItemBottom < getHeight() - getPaddingBottom() && mLastVisibleItem < mAdapter.getCount() - 1){
			//現在のmLastVisibleItem + 1の位置にviewを加えるのでmLastVisibleItemはプラスされる
			//初期値は0の事を考慮し-1としておく
			mLastVisibleItem++;
			//AdapterからmFirstVisibleItem positionのViewを取得する	        
			View child = (View)mAdapter.getView(mLastVisibleItem, getCachedView(), this);
			//取得したViewをlayoutに加え、measuringする
			//Listの最後にViewを追加するのでLAYOUT_MODE_BELOW(= -1)(-1を指定すると最後にViewが追加される)
			addAndMeasureChild(child, LAYOUT_MODE_BELOW);
			//Viewが追加されたのでlastItemBottomが合わせて変更される			
	        lastItemBottom = lastItemBottom + child.getMeasuredHeight();
	        Log.d(TAG, "lastItemBottom: " + lastItemBottom);
		}
	}
	
	private void addAndMeasureChild(View child, int layoutMode){
		int childWidthMeasureSpec = 0;
		int childHeightMeasureSpec = 0;
		
        LayoutParams params = child.getLayoutParams();
        if (params == null) {
            params = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
        }
        final int index = layoutMode == LAYOUT_MODE_ABOVE ? 0 : -1;
        //マイナスの値を第二引数に渡せば、listの最後にviewが追加される
        addViewInLayout(child, index, params, true);
        
        //ViewGroup#getChildMeasureSpce()を使ってchildのwidthMeasureSpecを取得
        //第1引数:childに対するMeasureSpec。幅は最大でMyListViewの大きさに合わせるようにする
        //第2引数:Paddingの値
        //第3匹数:childのLayoutParam.width
        childWidthMeasureSpec = getChildMeasureSpec(MeasureSpec.AT_MOST | getWidth(), getPaddingLeft() + getPaddingRight(), params.width);
        
        //ViewGroup#getChildMeasureSpec()を使ってchildのheightMeasureSpecを取得
        //第1引数:childに対するMeasureSpec。ListViewなのでchildの高さは特に指定しない
        //第2引数:Paddingの値
        //第3匹数:childのLayoutParam.height
        childHeightMeasureSpec = getChildMeasureSpec(MeasureSpec.UNSPECIFIED, getPaddingTop() + getPaddingBottom(), params.height);
        
        //上記で得たmeasureSpecを使ってchildのmeasuringを行う
    	child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
	}
	
	private void positionItems(){
		//itemTopを初期化 itemTop = first visible itemのtop
		int itemTop = mListTop + mListTopOffset;
		for(int i = 0; i < getChildCount(); i++){
			View child = (View) getChildAt(i);
			//childのView#measure() -> View#onMeasure()で得た
                        //MeasuredWidth/MeasuredHeightを使用しchildをlayout
			int itemWidth = child.getMeasuredWidth();
			int itemHeight = child.getMeasuredHeight();
			int itemLeft = (getWidth() - itemWidth) / 2;
			
			child.layout(itemLeft, itemTop, (itemWidth + itemLeft), (itemTop + itemHeight));
			itemTop += itemHeight;
		}
	}

	@Override
	public boolean onTouchEvent(MotionEvent event) {
		//childのviewがなければ何もしない
		if(getChildCount() == 0){
			return true;
		}
		switch(event.getAction()){
		case MotionEvent.ACTION_DOWN:
			startTouchDown(event);
			break;
		case MotionEvent.ACTION_MOVE:
			if(mTouchState == TOUCH_STATE_CLICK){
				startScrollIfNeeded(event);
			}else if(mTouchState == TOUCH_STATE_SCROLL){
				scroll(event);
			}
			break;
		case MotionEvent.ACTION_UP:
			endTouch(event);
			break;
		}
		return true;
	}
	
	private void startTouchDown(MotionEvent event){
		mTouchStartX = (int)event.getX();
		mTouchStartY = (int)event.getY();
		//現在表示されている子Viewの先頭からmListTopOffset値を引いた値が
		//touch down actionが発生した時のAdapterが保持している先頭Itemのtopがあるであろう位置
		//実質mListTopと同じか
		mListTopWhenTouch = getChildAt(0).getTop() - mListTopOffset;
		//Long clickの判定をする
		startLongPressCheck(event);
		mTouchState = TOUCH_STATE_CLICK;
	}
	
	private void startScrollIfNeeded(MotionEvent event){
		//しきい値を超えてACTION_MOVEが行われた場合、Scrollと判定
		if(Math.abs(event.getY() - mTouchStartY) > SCROLL_THRESHOLD 
                     && Math.abs(event.getY() - mTouchStartY) > Math.abs(event.getX() - mTouchStartX)){
			//Long click判定の為にpostDelayしていたRunnableをキャンセル
			removeCallbacks(mLongPressRunnable);
			mTouchState = TOUCH_STATE_SCROLL;
		}
	}
	
	private void scroll(MotionEvent event){
		mListTop = (int)(mListTopWhenTouch + (event.getY() - mTouchStartY));
		//変更されたmListTopを基準点に子ViewをLayoutしなおす
		requestLayout();
	}
	
	private void endTouch(MotionEvent event){
		//Long click判定の為にpostDelayedしていたRunnableをキャンセル
		removeCallbacks(mLongPressRunnable);
		mTouchStartX = 0;
		mTouchStartY = 0;
		if(mTouchState == TOUCH_STATE_CLICK){
			clickChild(event);
		}
		mTouchState = TOUCH_STATE_RESTING;
	}
	
        private void clickChild(MotionEvent event){
	        int index = getClickedChild(event);
		if(index != INVALID_INDEX){
			View itemView = (View) getChildAt(index);
			int position = mFirstVisibleItem + index;
			long id = mAdapter.getItemId(position);
			//onItemClickListener#onItemClick()を呼ぶため
			performItemClick(itemView, position, id);
		}
	}
	
    private void startLongPressCheck(final MotionEvent event) {
        if (mLongPressRunnable == null) {
            mLongPressRunnable = new Runnable() {
                public void run() {
                    if (mTouchState == TOUCH_STATE_CLICK) {
                        final int index = getClickedChild(event);
                        if (index != INVALID_INDEX) {
                            longClickChild(index);
                        }
                    }
                }
            };
        }
        //Long click判定の為、RunnableとしてpostDelayedする
        postDelayed(mLongPressRunnable, ViewConfiguration.getLongPressTimeout());
    }
	
    private void longClickChild(final int index) {
        final View itemView = getChildAt(index);
        final int position = mFirstVisibleItem + index;
        final long id = mAdapter.getItemId(position);
        final OnItemLongClickListener listener = getOnItemLongClickListener();
        if (listener != null) {
        	//OnItemLongClickListnerへのCallback
            listener.onItemLongClick(this, itemView, position, id);
        }
    }
	
    private int getClickedChild(MotionEvent event){
        int touchX = (int)event.getX();
	int touchY = (int)event.getY();
	if(mRect == null){
		mRect = new Rect();
	}
	for(int i = 0; i < getChildCount(); i++){
		getChildAt(i).getHitRect(mRect);
		//Touch downの場所をもとにClickされた子Viewを判定
		if(mRect.contains(touchX, touchY)){
			return i;
		}
	}
	return INVALID_INDEX;
    }
	
    //MyListViewからdetach(remove)されたViewのChacheからViewを取得
    private View getCachedView() {
        if (mCachedViews.size() != 0) {
            return mCachedViews.removeFirst();
        }
        return null;
    }
}

Android標準のListView使用した場合、flick時のscrollのspeedが早いせいか描画処理が思いとトビトビに見えたりといただけないところがあると思います。
次回/次次回で、flick等も実装したいと思いますのでカスタマイズ出来るという点でOriginalのリスト作成にも利点はありますし、
結構、AdapterViewを拡張したLayoutを使うことがあると思うので、今回、得た知識は無駄にはならないと思います

HashTat #android #java