kurukuru-papaのブログ

主に、ソフトウェア開発に関連したメモを書き溜めたいと思います。

ListViewの項目をドラッグして並び替え可能にしてみた

先日の記事(こんなに簡単だとは思わなかった!Viewのドラッグ方法)で、Viewをドラッグ・アンド・ドロップしてみました。今回は、発展させて、ListViewの項目をドラッグ・アンド・ドロップして並び替えられるようにしてみました。少し長いですが、作成したプログラムを紹介します。

画面イメージ

作成したプログラムの画面イメージは、次のようになります。画面中央の背景色が白っぽくなっている矩形部分がドラッグ中の項目です。

プログラム構成

画面とJavaクラスの構成を図にしました。DragListViewは、AndroidのListViewを継承し、ドラッグ・アンド・ドロップの主な制御を行なっています。DragListAdapterは、ListViewに表示する項目とその並び順を管理しています。PopupViewは、ドラッグ時に選択項目をポップアップ表示するビューです。

プログラム詳細(レイアウトXML

レイアウトXML(drag_list_activity.xml)は、次のようになります。今回作成したDragListViewを配置しています。ドラッグ時に表示する選択項目のポップアップは、実行時に作成するため、レイアウトXMLには出て来ません。レイアウトやビューに余白や背景色を付けているのは、画面上でビューの配置をわかりやすくするためです。

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/FrameLayout1"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:background="#555555"
    android:padding="20dip" >
    <com.kurukurupapa.tryandroidui.draglist.DragListView
        android:id="@+id/list"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:background="#000000" >
    </com.kurukurupapa.tryandroidui.draglist.DragListView>
</FrameLayout>

プログラム詳細(アクティビティ - DragListActivity)

アクティビティのクラス(DragListActivity.java)です。上記のレイアウトXMLを呼び出し、DragListViewとDragListAdapterを紐付けています。

public class DragListActivity extends Activity {
	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);

		setContentView(R.layout.drag_list_activity);

		DragListAdapter adapter = new DragListAdapter(this);
		DragListView listView = (DragListView) findViewById(R.id.list);
		listView.setAdapter(adapter);
	}
}

プログラム詳細(リストビュー - DragListView)

ドラッグ・アンド・ドロップの制御を行うクラス(DragListView.java)です。AndroidのListViewクラスを継承しています。タッチイベントが発生すると、DragListAdapterとPopupViewの、ドラッグ開始、ドラッグ中、ドラッグ終了メソッドを各々呼び出し、画面再描画しています。リスト項目の長押しでドラッグ開始するようにしています。

public class DragListView extends ListView implements
		AdapterView.OnItemLongClickListener {

	private static final int SCROLL_SPEED_FAST = 25;
	private static final int SCROLL_SPEED_SLOW = 8;

	private DragListAdapter adapter;
	private PopupView popupView;
	private MotionEvent downEvent;
	private boolean dragging = false;

	public DragListView(Context context, AttributeSet attrs) {
		super(context, attrs);
		popupView = new PopupView(context);
		setOnItemLongClickListener(this);
	}

	@Override
	public void setAdapter(ListAdapter adapter) {
		if (adapter instanceof DragListAdapter == false) {
			throw new RuntimeException("引数adapterがDragListAdapterクラスではありません。");
		}
		super.setAdapter(adapter);
		this.adapter = (DragListAdapter) adapter;
	}

	/**
	 * 長押しイベント<br>
	 * ドラッグを開始する。当イベントの前に、タッチイベント(ACTION_DOWN)が呼ばれている前提。
	 */
	@Override
	public boolean onItemLongClick(AdapterView<?> parent, View view,
			int position, long id) {
		return startDrag(downEvent);
	}

	/**
	 * タッチイベント<br>
	 * ドラッグしている項目の移動や、ドラッグ終了の制御を行う。
	 */
	@Override
	public boolean onTouchEvent(MotionEvent event) {
		boolean result = false;
		switch (event.getAction()) {
		case MotionEvent.ACTION_DOWN:
			storeMotionEvent(event);
			break;
		case MotionEvent.ACTION_MOVE:
			result = doDrag(event);
			break;
		case MotionEvent.ACTION_UP:
		case MotionEvent.ACTION_CANCEL:
		case MotionEvent.ACTION_OUTSIDE:
			result = stopDrag(event);
			break;
		}

		// イベントを処理していなければ、親のイベント処理を呼ぶ。
		// 長押しイベントを発生させるため、ACTION_DOWNイベント時は、親のイベント処理を呼ぶ。
		if (result == false) {
			result = super.onTouchEvent(event);
		}
		return result;
	}

	/**
	 * 長押しイベント時に、タッチ位置を取得するため、ACTION_DOWN時のMotionEventを保持する。
	 */
	private void storeMotionEvent(MotionEvent event) {
		downEvent = event;
	}

	/**
	 * ドラッグ開始
	 */
	private boolean startDrag(MotionEvent event) {
		dragging = false;
		int x = (int) event.getX();
		int y = (int) event.getY();

		// イベントから position を取得
		// 取得した position が 0未満=範囲外の場合はドラッグを開始しない
		int position = eventToPosition(event);
		if (position < 0) {
			return false;
		}

		// アダプターにドラッグ対象項目位置を渡す
		adapter.startDrag(position);

		// ドラッグ中のリスト項目の描画を開始する
		popupView.startDrag(x, y, getChildByIndex(position));

		// リストビューを再描画する
		invalidateViews();
		dragging = true;
		return true;
	}

	/**
	 * ドラッグ処理
	 */
	private boolean doDrag(MotionEvent event) {
		if (!dragging) {
			return false;
		}

		int x = (int) event.getX();
		int y = (int) event.getY();
		int position = pointToPosition(x, y);

		// ドラッグの移動先リスト項目が存在する場合
		if (position != AdapterView.INVALID_POSITION) {
			// アダプターのデータを並び替える
			adapter.doDrag(position);
		}

		// ドラッグ中のリスト項目の描画を更新する
		popupView.doDrag(x, y);

		// リストビューを再描画する
		invalidateViews();

		// 必要あればスクロールさせる
		// 注意:invalidateViews()後に処理しないとスクロールしなかった
		setScroll(event);
		return true;
	}

	/**
	 * ドラッグ終了
	 */
	private boolean stopDrag(MotionEvent event) {
		if (!dragging) {
			return false;
		}

		// アダプターにドラッグ対象なしを渡す
		adapter.stopDrag();

		// ドラッグ中のリスト項目の描画を終了する
		popupView.stopDrag();

		// リストビューを再描画する
		invalidateViews();
		dragging = false;
		return true;
	}

	/**
	 * 必要あればスクロールさせる。<br>
	 * 座標の計算が煩雑になるので当Viewのマージンとパディングはゼロの前提とする。
	 */
	private void setScroll(MotionEvent event) {
		int y = (int) event.getY();
		int height = getHeight();
		int harfHeight = height / 2;
		int harfWidth = getWidth() / 2;

		// スクロール速度の決定
		int speed;
		int fastBound = height / 9;
		int slowBound = height / 4;
		if (event.getEventTime() - event.getDownTime() < 500) {
			// ドラッグの開始から500ミリ秒の間はスクロールしない
			speed = 0;
		} else if (y < slowBound) {
			speed = y < fastBound ? -SCROLL_SPEED_FAST : -SCROLL_SPEED_SLOW;
		} else if (y > height - slowBound) {
			speed = y > height - fastBound ? SCROLL_SPEED_FAST
					: SCROLL_SPEED_SLOW;
		} else {
			// スクロールなしのため処理終了
			return;
		}

		// 画面の中央にあるリスト項目位置を求める
		// 横方向はとりあえず考えない
		// 中央がちょうどリスト項目間の境界の場合は、位置が取得できないので、
		// 境界からずらして再取得する。
		int middlePosition = pointToPosition(harfWidth, harfHeight);
		if (middlePosition == AdapterView.INVALID_POSITION) {
			middlePosition = pointToPosition(harfWidth, harfHeight
					+ getDividerHeight());
		}

		// スクロール実施
		final View middleView = getChildByIndex(middlePosition);
		if (middleView != null) {
			setSelectionFromTop(middlePosition, middleView.getTop() - speed);
		}
	}

	/**
	 * MotionEvent から position を取得する
	 */
	private int eventToPosition(MotionEvent event) {
		return pointToPosition((int) event.getX(), (int) event.getY());
	}

	/**
	 * 指定インデックスのView要素を取得する
	 */
	private View getChildByIndex(int index) {
		return getChildAt(index - getFirstVisiblePosition());
	}
}

プログラム詳細(リストデータ管理 - DragListAdapter)

リスト項目の並び替え行うクラス(DragListAdapter.java)です。AndroidのBaseAdapterクラスを継承しています。データの並び替えを行う処理を実装しています。ドラッグ中の選択項目は、ポップアップ表示する方針ですので、当クラスでは非表示にしています。

public class DragListAdapter extends BaseAdapter {
	private static final String[] items = { "Android 1.0(APIレベル1)",
			"Android 1.1(APIレベル2)", "Android 1.5(APIレベル3)",
			"Android 1.6(APIレベル4)", "Android 2.0(APIレベル5)",
			"Android 2.0.1(APIレベル6)", "Android 2.1(APIレベル7)",
			"Android 2.2(APIレベル8)", "Android 2.3(APIレベル9)",
			"Android 2.3.3(APIレベル10)", "Android 3.0(APIレベル11)",
			"Android 3.1(APIレベル12)", "Android 3.2(APIレベル13)",
			"Android 4.0(APIレベル14)", };

	private Context context;
	private int currentPosition = -1;

	public DragListAdapter(Context context) {
		this.context = context;
	}

	@Override
	public int getCount() {
		return items.length;
	}

	@Override
	public Object getItem(int position) {
		return items[position];
	}

	@Override
	public long getItemId(int position) {
		return position;
	}

	/**
	 * リスト項目のViewを取得する
	 */
	@Override
	public View getView(int position, View convertView, ViewGroup parent) {
		// View作成
		if (convertView == null) {
			convertView = new TextView(context);
		}
		TextView textView = (TextView) convertView;
		textView.setTextSize(30);

		// データ設定
		textView.setText((String) getItem(position));

		// ドラッグ対象項目は、ListView側で別途描画するため、非表示にする。
		if (position == currentPosition) {
			textView.setVisibility(View.INVISIBLE);
		} else {
			textView.setVisibility(View.VISIBLE);
		}
		return textView;
	}

	/**
	 * ドラッグ開始
	 *
	 * @param position
	 */
	public void startDrag(int position) {
		this.currentPosition = position;
	}

	/**
	 * ドラッグに従ってデータを並び替える
	 *
	 * @param newPosition
	 */
	public void doDrag(int newPosition) {
		String item = items[currentPosition];
		if (currentPosition < newPosition) {
			// リスト項目を下に移動している場合
			for (int i = currentPosition; i < newPosition; i++) {
				items[i] = items[i + 1];
			}
		} else if (currentPosition > newPosition) {
			// リスト項目を上に移動している場合
			for (int i = currentPosition; i > newPosition; i--) {
				items[i] = items[i - 1];
			}
		}
		items[newPosition] = item;

		currentPosition = newPosition;
	}

	/**
	 * ドラッグ終了
	 */
	public void stopDrag() {
		this.currentPosition = -1;
	}
}

プログラム詳細(ポップアップ表示ビュー - PopupView)

ドラッグ中の選択項目をポップアップ表示するクラス(PopupView.java)です。AndroidのImageViewクラスを継承しています。このビューはWindowManager上に表示しています。WindowManager上では座標系が変わることに注意しなければならないため、getLocationInWindow()メソッドを使用しています。

public class PopupView extends ImageView {
	private static final Bitmap.Config DRAG_BITMAP_CONFIG = Bitmap.Config.ARGB_8888;
	private static final int BACKGROUND_COLOR = Color.argb(128, 0xFF, 0xFF,
			0xFF);
	private static final int Y_GAP = 20;

	private WindowManager windowManager;
	private WindowManager.LayoutParams layoutParams;
	private boolean dragging = false;
	private int baseX;
	private int baseY;
	private int[] itemLocation = new int[2];

	public PopupView(Context context) {
		super(context);
		windowManager = (WindowManager) context
				.getSystemService(Context.WINDOW_SERVICE);

		// レイアウトを初期化する
		initLayoutParams();
	}

	/**
	 * ドラッグ開始
	 *
	 * @param itemView
	 */
	public void startDrag(int x, int y, View itemView) {
		// ドラッグ終了処理が未完了の場合、前回のドラッグ処理が不正に終了しているかもしれない。
		// 念のため、ドラッグ終了処理を行う。
		if (dragging) {
			stopDrag();
		}

		// ドラッグ開始座標を保持する
		baseX = x;
		baseY = y;

		// ドラッグする項目の初期位置を保持する
		itemView.getLocationInWindow(itemLocation);

		// ドラッグ中の画像イメージを設定する
		setBitmap(itemView);

		// WindowManagerに登録する
		updateLayoutParams(x, y);
		windowManager.addView(this, layoutParams);
		dragging = true;
	}

	/**
	 * ドラッグ中処理
	 *
	 * @param x
	 * @param y
	 */
	public void doDrag(int x, int y) {
		// ドラッグ開始していなければ中止
		if (dragging == false) {
			return;
		}

		// ImageViewの位置を更新
		updateLayoutParams(x, y);
		windowManager.updateViewLayout(this, layoutParams);
	}

	/**
	 * ドラッグ項目の描画を終了する
	 */
	public void stopDrag() {
		// ドラッグ開始していなければ中止
		if (dragging == false) {
			return;
		}

		// WindowManagerから除去する
		windowManager.removeView(this);
		dragging = false;
	}

	/**
	 * ドラッグ中の項目を表す画像を作成する
	 */
	private void setBitmap(View itemView) {
		Bitmap bitmap = Bitmap.createBitmap(itemView.getWidth(),
				itemView.getHeight(), DRAG_BITMAP_CONFIG);
		Canvas canvas = new Canvas();
		canvas.setBitmap(bitmap);
		itemView.draw(canvas);
		setImageBitmap(bitmap);
		setBackgroundColor(BACKGROUND_COLOR);
	}

	/**
	 * ImageView 用 LayoutParams の初期化
	 */
	private void initLayoutParams() {
		// getLocationInWindow()と座標系を合わせるためFLAG_LAYOUT_IN_SCREENを設定する。
		// FLAG_LAYOUT_IN_SCREENを設定すると、端末ディスプレイ全体の左上を原点とする座標系となる。
		// 設定しない場合、ステータスバーを含まない左上を原点とする。
		layoutParams = new WindowManager.LayoutParams();
		layoutParams.gravity = Gravity.TOP | Gravity.LEFT;
		layoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT;
		layoutParams.width = WindowManager.LayoutParams.WRAP_CONTENT;
		layoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
				| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
				| WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
				| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
				| WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN;
		layoutParams.format = PixelFormat.TRANSLUCENT;
		layoutParams.windowAnimations = 0;
	}

	/**
	 * ImageView 用 LayoutParams の座標情報を更新
	 */
	private void updateLayoutParams(int x, int y) {
		// ドラッグ中であることが分かるように少し上にずらす
		layoutParams.x = itemLocation[0] + x - baseX;
		layoutParams.y = itemLocation[1] + y - baseY - Y_GAP;
	}
}

環境

  • 動作可能な最小OSバージョン:Android 2.1
  • 動作確認端末:Xperia SO-01B

参考サイト

次のサイトを参考にして作成しました。感謝です。