LoginSignup
7
6

More than 5 years have passed since last update.

ドラッグ&ドロップでアイテム移動できるListView

Last updated at Posted at 2017-02-25

概要

タイトルのようなListViewほしいなあ、と思っていろいろ調べ、
先人たちの遺産をカスタマイズしました。

以下の資料を参考にしました。

変更点

変更・追加した機能は以下。

  1. ドラッグ中のアイテムを非表示(INVISIBLE)に。

  2. スムースなスクロール。
    先人のはタッチイベントをトリガーにしてスクロールさせていたため、指を静止させるとスクロールしないなど、なんとなくがさがさした動きでした。
    Runnableなinnerクラスにスクロール動作を書き、これを定期的に呼び出すことでスムースなスクロールを実現しました。

  3. スクロール速度をなめらかに変化
    先人のはlistviewの上下領域を2段階に分け、移動速度をslowとfastに切り替えていました。この方式だと境界線付近で急にスクロール速度が変化するのでユーザーフレンドリーではないと感じました。
    listviewの上下にスクロール領域を設定したところは一緒です。領域の中央寄りは遅く、辺縁付近は早くスクロールするように、タッチ位置に比例してスクロール速度を動的に変えることにしました。

ソース

SortableListView.java
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.PixelFormat;
import android.os.Handler;
import android.util.AttributeSet;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.WindowManager;
import android.widget.AbsListView;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.ListView;

/**
 * Created by itona on 2016/09/12.
 */
public class SortableListView extends ListView implements AdapterView.OnItemLongClickListener, AbsListView.RecyclerListener {
    // 配列順序を入れ替えられるListView
    // アイテム長押しでドラッグ開始。掴んだアイテムは非表示化され、アイテムのイメージコピーが生成される。イメージコピーは移動可能。
    // アイテム掴んだまま移動させることで、前後のアイテムと順序入れ替える。
    // アイテム離すとその場にドロップ。
    // アイテム掴んだ状態で、ビューの上部/下部に持っていくと、リストがスクロールする。スクロールスピードは可変。

    private static final Bitmap.Config BITMAP_CONFIG = Bitmap.Config.ARGB_8888;
    private static final int BACKGROUND_COLOR = Color.argb(128, 0xFF, 0xFF, 0xFF);

    private static final int SCROLL_SPEED_MAX = 750;  //at dp/sec   …最大スクロールスピード
    private static final int SCROLL_SPEED_MIN = 0;  //at dp/sec     …最小スクロールスピード
    private static final int SCROLL_AREA = 120;   //at dp           …スクロール範囲
    private static float density;

    private final Scroller scroller = new Scroller();
    private final Handler scrollHandler = new Handler();

    private WindowManager.LayoutParams layoutParams = null;
    private ImageView imageView = null;
    private Bitmap bitmap = null;

    private int topInWindow = 0;

    private MotionEvent actionDownEvent;
    private boolean isDragging = false;
    private int draggingPosition = -1;

    private ArrayAdapter adapter = null;
    private int previousDataCount = 0;

    public interface Immobilizable {
        boolean isImmobile(int position);
    }

    public SortableListView(Context context) {
        super(context);
        initialize();
    }

    public SortableListView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initialize();
    }

    public SortableListView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initialize();
    }

    private void initialize() {
        setOnItemLongClickListener(this);
        setRecyclerListener(this);
        density = getResources().getDisplayMetrics().density;
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);

        // ListViewのWindowに対する位置を記録する。
        int[] posArray = new int[2];
        getLocationInWindow(posArray);
        topInWindow = posArray[1];
    }

    @Override
    protected void layoutChildren() {
        // 移動中のビューをinvisibleにする。
        super.layoutChildren();
        if(isDragging){
            getChildAtPosition(draggingPosition).setVisibility(INVISIBLE);
        }
    }

    @Override
    public void onMovedToScrapHeap(View view) {
        // 子ビューがフレームアウトし、ScrapHeapに回収されたときに呼ばれる。
        // 子ビューは移動中で、invisibleになっているかもしれない。visibleに戻してあげる。
        view.setVisibility(VISIBLE);
    }

    @Override
    protected void handleDataChanged() {
        // データが1つ追加されたとき、表示位置をリスト終端に移動する。
        super.handleDataChanged();
        if (adapter == null) {
            adapter = (ArrayAdapter) getAdapter();
            previousDataCount = adapter.getCount();
            return;
        }
        if (adapter.getCount() == previousDataCount + 1) {
            smoothScrollToPosition(adapter.getCount() - 1);
        }
        previousDataCount = adapter.getCount();
    }

    @Override
    public boolean onItemLongClick(AdapterView<?> adapterView, View view, int i, long l) {
        // ロングクリック時、ドラッグを開始する。
        if (adapter == null) {
            adapter = (ArrayAdapter) getAdapter();
            previousDataCount = adapter.getCount();
        }
        return startDrag(i, actionDownEvent);
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                storeMotionEvent(ev);
                break;
            case MotionEvent.ACTION_MOVE:
                if (duringDrag(ev)) {
                    return true;
                }
                break;
            case MotionEvent.ACTION_UP:
                if (stopDrag(ev, true)) {
                    return true;
                }
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_OUTSIDE:
                if (stopDrag(ev, false)) {
                    return true;
                }
                break;
        }
        return super.onTouchEvent(ev);
    }

    private boolean startDrag(int position, MotionEvent ev) {
        draggingPosition = position;

        View view = getChildAtPosition(position);
        Canvas canvas = new Canvas();
        WindowManager wm = getWindowManager();

        // 移動元のviewからbitmapを生成
        bitmap = Bitmap.createBitmap(view.getWidth(), view.getHeight(), BITMAP_CONFIG);
        canvas.setBitmap(bitmap);
        view.draw(canvas);

        // 移動中のアイテムを示すimageViewを生成
        imageView = new ImageView(getContext());
        imageView.setBackgroundColor(BACKGROUND_COLOR);
        imageView.setImageBitmap(bitmap);
        initLayoutParams(view, ev);
        wm.addView(imageView, layoutParams);

        // 移動元のviewを非表示にする
        view.setVisibility(INVISIBLE);

        // スクロール開始
        scrollHandler.postDelayed(scroller, 50);

        // ドラッグ開始
        isDragging = true;
        return true;
    }

    private boolean duringDrag(MotionEvent ev) {
        if (!isDragging || imageView == null) {
            return false;
        }

        final float y = ev.getY();
        final int height = getHeight();

        // スクロール速度の決定
        // ドラッグ開始から500 ms未満の時はスクロールしない。
        // 両端のスクロールエリア内のときは、エリア内の位置に応じてスピードを決める。
        float speed;
        if (ev.getEventTime() - ev.getDownTime() < 500) {
            speed = 0;
        } else if (y < SCROLL_AREA * density) {
            speed = (y / SCROLL_AREA / density - 1) * (SCROLL_SPEED_MAX - SCROLL_SPEED_MIN) - SCROLL_SPEED_MIN;
        } else if (y > height - SCROLL_AREA * density) {
            speed = SCROLL_SPEED_MIN + (1 - (height - y) / SCROLL_AREA / density) * (SCROLL_SPEED_MAX - SCROLL_SPEED_MIN);
        } else {
            speed = 0;
        }
        scroller.setSpeed(speed * density);

        // ImageViewの位置を更新
        updateLayoutParams(ev);
        getWindowManager().updateViewLayout(imageView, layoutParams);

        // アイテムの入れ替え
        int currentPosition = pointToPosition((int) ev.getX(), (int) ev.getY());
        if (currentPosition != draggingPosition && currentPosition != INVALID_POSITION) {
            Object item = adapter.getItem(draggingPosition);
            adapter.setNotifyOnChange(false);
            adapter.remove(item);
            adapter.setNotifyOnChange(true);
            adapter.insert(item, currentPosition);

            draggingPosition = currentPosition;
        }

        return true;
    }

    private boolean stopDrag(MotionEvent ev, boolean isDrop) {
        if (!isDragging) {
            return false;
        }
        if (isDrop) {
            duringDrag(ev);
        }
        isDragging = false;

        // ImageViewの消去
        getWindowManager().removeView(imageView);
        imageView = null;
        bitmap = null;

        actionDownEvent.recycle();
        actionDownEvent = null;

        // 非表示にしたitemを再表示
        View view = getChildAtPosition(draggingPosition);
        view.setVisibility(VISIBLE);

        // スクロール停止
        scrollHandler.removeCallbacksAndMessages(null);

        return true;
    }

    private WindowManager getWindowManager() {
        return (WindowManager) getContext().getSystemService(Context.WINDOW_SERVICE);
    }

    public void initLayoutParams(View view, MotionEvent ev) {
        layoutParams = new WindowManager.LayoutParams();
        layoutParams.gravity = Gravity.TOP | Gravity.START;
        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;
        layoutParams.format = PixelFormat.TRANSLUCENT;
        layoutParams.windowAnimations = 0;

        int[] posArray = new int[2];
        view.getLocationInWindow(posArray);
        layoutParams.x = posArray[0];

        layoutParams.y = topInWindow + (int) ev.getY() - view.getHeight() / 2;
    }

    private void updateLayoutParams(MotionEvent ev) {
        layoutParams.y = topInWindow + (int) ev.getY() - imageView.getHeight() / 2;
    }

    private View getChildAtPosition(int position) {
        return getChildAt(position - getFirstVisiblePosition());
    }

    private void storeMotionEvent(MotionEvent ev) {
        actionDownEvent = MotionEvent.obtain(ev);
    }

    private class Scroller implements Runnable {
        final int MINIMUM_DURATION = 50;
        final int MAXIMUM_DURATION = 200;
        int distance = 0;
        int duration = 50;

        void setSpeed(float speed) {
            // speedの単位はpx/sec
            // speed / 1000 * dur = 1 <-> dur = 1000/speed
            if (speed == 0 || (1000 / Math.abs(speed)) > MAXIMUM_DURATION) {
                distance = 0;
                duration = MAXIMUM_DURATION;
            } else {
                duration = (int) Math.abs(1000 / speed);
                if (duration < MINIMUM_DURATION) {
                    duration = MINIMUM_DURATION;
                    distance = (int) (speed / 1000 * duration);
                } else {
                    distance = 1;
                }
            }
        }

        @Override
        public void run() {
            SortableListView.this.smoothScrollBy(distance, duration);
            scrollHandler.postDelayed(this, duration);
        }
    }
}

注釈

  1. スクロール速度・範囲の変更

スクロール範囲の広さはSCROLL_AREAで調整できます。単位はdpのつもり。
 最低のスクロール速度はSCROLL_SPEED_MINで調整できます。単位はdp/secと書いてありますが、違う気がします。
 最低速度とは、スクロール範囲の最も中央寄りの位置での速度です。
 最高のスクロール速度はSCROLL_SPEED_MAXで調整できます。やっぱり単位はよくわからん。
 最高速度は、ListViewの上下端での速度です。

  1. Immobilizableインターフェース

 特定のアイテムを動かしたくないときに使おうかと思っていましたが、まだ実装してません。

 ListViewに結び付けるAdapterにImmobilizalbeインターフェースを実装します。isImmobile(position)がtrueを返したときは、アイテムを動かさない、みたいな。

その他

Androidアプリ開発を独学で学び始めてまだ1年なので、いろいろおかしいところあるかも。変なところはご指摘いただければ嬉しいです。

7
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
6