天天看點

ListView的拖動和側滑實作

前言

ListView在Android App中占有重要的地位,很多界面的展示都要借助于這個控件,雖然RecyclerView已經逐漸取代它的地位,掌握它的一些基本使用技巧還是很有必要的,現在就來探究一下如何實作拖動ListView的條目和側滑删除ListView中的資料。

Android DND實作拖動

Android從3.0引入的Drag&Drop架構,實作在界面中的拖拽效果,使用者為需要拖拽的子視圖設定傳遞的資料、拖拽産生的陰影對象和本地資料等參數,調用View.startDrag方法就可以拖動子視圖。同時還要對需要接收拖動事件的子視圖設定拖動回調函數,拖動回調方法裡處理多種拖動事件,根據拖動事件做出反應。

拖動事件 意義
DragEvent.ACTION_DRAG_STARTED 目标對象接收到拖動子視圖開始,這時如果傳回true代表對拖動的子視圖感興趣,系統會将後續的事件返送過來,否則就是不感興趣,系統就忽略目前拖拽監視者,隻會回調一次
DragEvent.ACTION_DRAG_ENTERED DRAG_STARTED傳回true之後,拖動子視圖進入目前監控子視圖範圍,隻會回調一次
DragEvent.ACTION_DRAG_LOCATION 拖動子視圖進入後開始準備向監控子視圖drop,目前還在不斷移動定位中,可以回調多次
DragEvent.ACTION_DRAG_EXITED 拖動子視圖離開了目前監控子視圖的範圍,隻會回調一次
DragEvent.ACTION_DROP 使用者将拖動的子視圖drop到目前監控子視圖,隻會回調一次
DragEvent.ACTION_DRAG_ENDED 使用者松手後所有的其他事件都已發送處理完成,最後發送DRAG_ENDED事件

在ListView的拖動事件中,被拖動的子視圖就是使用者點選的子視圖,監聽的子視圖是目前螢幕中所有的ListView展示的子視圖,當然被拖動的子視圖自己除外。通常的拖動操作都是經過LongClick觸發,隻需要在ListView的adapter中getView生成的View設定LongClick時間監聽。

final View dragView = convertView;
convertView.setOnLongClickListener(new View.OnLongClickListener() {
    @Override
    public boolean onLongClick(View v) {
        // DND架構要求傳遞的資料
        ClipData.Item item = new ClipData.Item(String.valueOf(position));
        ClipData clipData = new ClipData("", new String[] {
                ClipDescription.MIMETYPE_TEXT_PLAIN
        }, item);

        // 開始目前View的拖動操作,将目前拖動對象的position當作localState傳遞到拖動事件中
        dragView.startDrag(clipData, new View.DragShadowBuilder(dragView), position, );
        return true;
    }
});
           

View.DragShadowBuilder對象會在目前的界面上繪制子視圖的外形稱作DragShadow,也就是拖動陰影對象,這個對象會被透明化,這個應該是底層操作的,無法在應用層修改。設定了拖動監聽之後開始設定其他的可能和它交換的Drag監聽事件。

convertView.setOnDragListener(new View.OnDragListener() {
    @Override
    public boolean onDrag(View v, DragEvent event) {
        switch (event.getAction()) {
            case DragEvent.ACTION_DRAG_STARTED:
                Log.d(TAG, "target: action = ACTION_DRAG_STARTED, clipData = " + event.getClipData());
                // 如果傳遞的資料類型正确,而且監聽對象不是目前拖動的對象
                // event.getLocalState()可以擷取前面拖動對方放進去的localState的position
                if (event.getClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) &&
                        Integer.parseInt(String.valueOf(event.getLocalState())) != position) {
                    return true;
                }
                break;
            case DragEvent.ACTION_DRAG_ENTERED:
                Log.d(TAG, "target: action = ACTION_DRAG_ENTERED, clipData = " + event.getClipData());
               // 當進入目前監聽對象,設定目前監聽對象背景變色 dragView.setBackgroundColor(context.getResources().getColor(R.color.colorAccent));
                return true;
            case DragEvent.ACTION_DRAG_LOCATION:
                // 正在目前監聽對象内拖動,不必關心
                Log.d(TAG, "target: action = ACTION_DRAG_LOCATION, clipData = " + event.getClipData());
                return true;
            case DragEvent.ACTION_DRAG_EXITED:
                // 如果離開了目前監聽對象那麼恢複目前監聽對象的背景色
                Log.d(TAG, "target: action = ACTION_DRAG_EXITED, clipData = " + event.getClipData());
     dragView.setBackgroundColor(Color.WHITE);
                return true;
            case DragEvent.ACTION_DROP:
                Log.d(TAG, "target: action = ACTION_DROP, clipData = " + event.getClipData());
                final int srcPosition = Integer.parseInt((String) event.getClipData().getItemAt().getText());
                // 如果使用者将拖動對象drop到了目前監聽對象,
               // 交換拖動對象和目前監聽對象資料位置并且重新整理
                if (srcPosition == position) {
                    return true;
                }
                Collections.swap(users, srcPosition, position);
                notifyDataSetChanged();
                return true;
            case DragEvent.ACTION_DRAG_ENDED:
                Log.d(TAG, "target: action = ACTION_DRAG_ENDED, clipData = " + event.getClipData());
                // 拖動完成,恢複目前監聽對象的背景色 dragView.setBackgroundColor(Color.WHITE);
                return true;
        }
        return false;
    }
});
           

拖動實作效果如下圖所示:

ListView的拖動和側滑實作

DND對于普通的拖動事件能夠較好的支援,在ListView中使用這種方式有很多問題,比如無法控制拖動陰影的拖動位置,使用者能夠把它拖到螢幕的任何位置,這顯然是不應該的,還有就是拖動陰影的透明化問題,有時候不希望有這個透明處理,除此之外使用者在拖動陰影出現的時候後明顯感覺到抖動了一下,這時因為陰影預設手指正好放在拖動陰影的正中間位置,使用者要實作手指按下真實子視圖位置和放在拖動陰影所在位置一緻實作較難。

WindowManager實作拖動

前面的DND實作拖動有多種問題,這裡就采用WindowManager來實作拖動陰影,這種拖動陰影完全在開發者的控制之下,這樣就可以限定拖動位置、定制陰影效果,防止抖動問題的産生了。實作的原理很簡單,就是直接為使用者長按位置的子視圖生成一副Bitmap圖像,然後用ImageView展示這幅圖像,WindowManager将這幅圖像添加到螢幕當中去,并且為ListView拖動事件添加自定義邏輯實作拖動陰影随着使用者手指移動。

// ListView初始化
private void init() {
    windowManager = (WindowManager) getContext().getSystemService(Context.WINDOW_SERVICE);
    // 使用GestureDetector監控使用者的長按動作
    detector = new GestureDetector(getContext(), new GestureAdapter() {
        @Override
        public void onLongPress(MotionEvent e) {
            performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
            isLongPressed = true;
            View child = getChildAt(dragPos);

            // 如果拖動陰影還沒有定義
            if (shadow == null) {
                // 建立拖動陰影圖檔
                shadow = Bitmap.createBitmap(child.getWidth(), child.getHeight(), Bitmap.Config.ARGB_8888);
                // 将圖檔綁定到canvas對象
                canvas = new Canvas(shadow);
                // 建立加入到螢幕的ImageView對象
                shadowView = new ImageView(getContext());
            }

            // 将長按處的view畫到陰影圖檔上
            child.draw(canvas);
            shadowView.setImageBitmap(shadow);
            int[] locations = new int[];
            child.getLocationInWindow(locations);

            // 得到拖動view在螢幕上的位置,并且将拖動陰影添加到螢幕中
            addShadowView(shadow, locations[], locations[]);
        }
    });
    params = new WindowManager.LayoutParams();

    // 監控ListView是否在滾動狀态,當拖動陰影到達ListView最底部或者最高處需要滾動ListView
    setOnScrollListener(new OnScrollListener() {
        @Override
        public void onScrollStateChanged(AbsListView view, int state) {
            Log.d(TAG, "state " + state);
            scrollState = state;
        }

        @Override
        public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {

        }
    });
}

// 将使用者的觸摸操作發送到detector對象,用于判斷是否在長按
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    detector.onTouchEvent(ev);
    return super.dispatchTouchEvent(ev);
}
           

初始化完成之後檢視移動陰影操作的方法實作。

// 向螢幕中添加拖動陰影
private void addShadowView(Bitmap shadow, int x, int y) {
    params.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT;
    params.width = shadow.getWidth();
    params.height = shadow.getHeight();
    params.format = PixelFormat.RGBA_8888;
    params.gravity = Gravity.TOP | Gravity.LEFT;
    params.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL |
            WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN;
    params.x = x;
    params.y = y + CommonUtils.dp2px();
    // WindowManager添加ImageView到螢幕上
    windowManager.addView(shadowView, params);
}

private void updateShadowView(int dx, int dy) {
    // 更新拖動陰影在螢幕上的位置,改變的位置來源于使用者的拖動操作
    params.x += dx;
    params.y = Math.max(bound.top, params.y + dy);
    windowManager.updateViewLayout(shadowView, params);
}

private void removeShadowView() {
    // 當使用者手指離開螢幕需要移除拖動陰影
    try {
        windowManager.removeView(shadowView);
    } catch (Exception e) {
        e.printStackTrace();
    }
}
           

拖動陰影的操作已經完成,現在需要覆寫ListView的onTouchEvent處理方法,在ACTION_DOWN記錄下使用者的按下的位置,使用pointToPosition擷取使用者按下的position位置。如果監控到使用者做了長按操作,那麼這時ACTION_DOWN記錄下的資料就是使用者要拖動的資料位置。

case MotionEvent.ACTION_DOWN:
    lastX = downX = x;
    lastY = downY = y;
    dataPos = pointToPosition(x, y);
    dragPos = pointToPosition(x, y) - getFirstVisiblePosition();
    break;
           

接下來在ACTION_MOVE中響應使用者的拖動操作,不停的更新拖動陰影在螢幕上的位置。

case MotionEvent.ACTION_MOVE:
    // 如果監控到使用者長按動作
    if (isLongPressed) {
        // 更新拖動陰影的位置
        updateShadowView(x - lastX, y - lastY);

        // 判斷目前情況下是否需要滾動ListView
        if (scrollState != OnScrollListener.SCROLL_STATE_IDLE && !shouldScrollDown() && !shouldScrollUp()) {
            stop();
        }
        lastX = x;
        lastY = y;

        // 更新界面中的元素的選中背景顔色
        if (pointToPosition(x, y) == INVALID_POSITION) {
            for (int i = , count = getChildCount(); i < count; i++) {
                getChildAt(i).setBackgroundColor(Color.WHITE);
            }
            return true;
        }
        int pos = pointToPosition(x, y) - getFirstVisiblePosition();
        if (testScroll() == NONE) {
            for (int i = , count = getChildCount(); i < count; i++) {
                if (pos != dragPos && i == pos) {
                    getChildAt(i).setBackgroundColor(getResources().getColor(R.color.colorAccent));
                } else {
                    getChildAt(i).setBackgroundColor(Color.WHITE);
                }
            }
        } else {
            for (int i = , count = getChildCount(); i < count; i++) {
                getChildAt(i).setBackgroundColor(Color.WHITE);
            }
        }

        // 如果需要滾動那麼就滾動
        scrollList();
        return true;
    }
    break;
           

當使用者拖動陰影到最上方或這最下方,這時候ListView就需要将未展示的資料展示出來,判斷最上方和最下方的代碼如下:

// 擷取ListView在螢幕中的展示位置
// bound變量裡放的就是ListView的位置資訊
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    super.onLayout(changed, l, t, r, b);
    if (bound == null) {
        int[] locations = new int[];
        getLocationInWindow(locations);
        bound = new Rect(locations[], locations[],
                locations[] + getWidth(), locations[] + getHeight());
    }
}

// 如果拖動陰影到達了ListView螢幕最上方而且第一個展示的View不是position==0或
// 者第一個展示的Viewposition==0但是View還有一部分被卷在上方
 private boolean shouldScrollUp() {
    return params.y <= bound.top && (getFirstVisiblePosition() !=  ||
            getFirstVisiblePosition() ==  && getChildAt().getTop() != getPaddingTop());
}

// 如果拖動陰影到達了ListView在螢幕的最下方,而且最後一個可見對象不是最後的資料,
// 或者最後一個可見對象是最後一個資料但還有一部分被卷在下方
private boolean shouldScrollDown() {
    return params.y >= bound.bottom - params.height && (getLastVisiblePosition() != getAdapter().getCount() -  ||
            getLastVisiblePosition() == getAdapter().getCount() -  &&
                    getChildAt(getChildCount() - ).getBottom() != getHeight() - getPaddingBottom());
}

// 滾動使用smoothScroll實作
private void scrollList() {
    if (scrollState == OnScrollListener.SCROLL_STATE_IDLE) {
        if (shouldScrollDown()) {
            smoothScrollBy(bound.height(), bound.height() /  * );
        } else if (shouldScrollUp()) {
            smoothScrollBy(-bound.height(), bound.height() /  * );
        }
    }
}
           

上面提到的兩種情況就是需要滾動的情況,加入在滾動過程中使用者突然将拖動陰影向中間方向移動這時就應該直接停止移動。

// 立即停止ListView的滾動
public void stop() {
    manualCancel = true;
    dispatchTouchEvent(MotionEvent.obtain(SystemClock.uptimeMillis(), SystemClock.uptimeMillis(),
            MotionEvent.ACTION_CANCEL, , , ));
}

// 檢查到是人工停止了ListView滾動,需要把這個事件傳遞給ListView,
// 其他的ACTION_CANCE事件和ACTION_UP處理一緻。
case MotionEvent.ACTION_CANCEL:
    if (manualCancel) {
        manualCancel = false;
        super.onTouchEvent(ev);
        return true;
    }
           

當使用者手指從螢幕上離開會觸發ACTION_UP事件,這時如果使用者的位置在某一個具體的View上,就需要交換這個View和拖動View之間的位置。

case MotionEvent.ACTION_UP:
    // 移除拖動陰影
    removeShadowView();
    if (isLongPressed) {
        isLongPressed = false;
        int targetPos = pointToPosition(x, y);
        if (targetPos == INVALID_POSITION) {
            for (int i = , count = getChildCount(); i < count; i++) {
                getChildAt(i).setBackgroundColor(Color.WHITE);
            }
            return true;
        }
        int pos = pointToPosition(x, y) - getFirstVisiblePosition();
        getChildAt(pos).setBackgroundColor(Color.WHITE);
        // 交換拖動和drop的View資料
        if (targetPos != dataPos) {
            Collections.swap(UserListAdapter.users, targetPos, dataPos);
            ((BaseAdapter) getAdapter()).notifyDataSetChanged();
        }
        return true;
    }
    break;
           

最終的實作效果如下圖所示:

ListView的拖動和側滑實作

ViewDragHelper實作側滑

前面已經完成了拖動效果,其實側滑删除的效果在很多應用裡都很常用,這個效果其實可以使用ViewDragHelper對象來實作。考慮到側滑其實是把上方的View拖動并且露出下方的View,那麼可以使用擴充的FrameLayout的來實作側滑布局。在布局中包含上放布局、下方布局,上方布局就是使用者在界面中看到的展示資料的布局,下方布局就是展示删除操作的布局。

<com.example.scroll.widget.SwipeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    // 底部展示删除按鈕的布局
    <LinearLayout
        android:id="@id/below_view"
        android:orientation="horizontal"
        android:layout_gravity="end"
        android:gravity="center_vertical"
        android:layout_width="wrap_content"
        android:layout_height="match_parent">

        <TextView
            android:id="@+id/delete"
            android:text="@string/delete"
            android:gravity="center"
            android:background="@color/colorAccent"
            android:padding="20dp"
            android:layout_width="wrap_content"
            android:layout_height="match_parent" />

    </LinearLayout>

    // 展示使用者資料的布局
    <include layout="@layout/user_item"
        android:layout_height="wrap_content"
        android:layout_width="match_parent" />

</com.example.scroll.widget.SwipeLayout>
           

然後使用者在橫向(x軸)拖動上方的布局,上方布局最多隻能向左移動下方布局寬度的位置,無法向右移動,需要限制移動過程中的left數值,由于無法在豎向(y軸)拖動是以top始終都不應該改變。

public class SwipeLayout extends FrameLayout {
    private ViewDragHelper helper;
    private ViewGroup aboveView;
    private ViewGroup belowView;

    public SwipeLayout(@NonNull Context context) {
        this(context, null);
    }

    public SwipeLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, );
    }

    public SwipeLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    // 初始畫上方和下方布局
    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        aboveView = (ViewGroup) findViewById(R.id.above_view);
        belowView = (ViewGroup) findViewById(R.id.below_view);
    }

    private void init() {
        helper = ViewDragHelper.create(this, new ViewDragHelper.Callback() {
            @Override
            public boolean tryCaptureView(View child, int pointerId) {
                // 隻允許上方布局拖動
                return child == aboveView;
            }

            @Override
            public void onViewReleased(View releasedChild, float xvel, float yvel) {
                // 如果上方布局拖動超出一半就全部露出下方布局,
                // 否則還原到初始狀态
                if (Math.abs(aboveView.getLeft()) < belowView.getWidth() / ) {
                    helper.smoothSlideViewTo(aboveView, , aboveView.getTop());
                } else {
                    helper.smoothSlideViewTo(aboveView, -belowView.getWidth(), aboveView.getTop());
                }
                invalidate();
            }

            @Override
            public int clampViewPositionHorizontal(View child, int left, int dx) {
                // 限制橫向拖動的位置坐标
                if (left < -belowView.getWidth()) {
                    return -belowView.getWidth();
                }

                if (left > ) {
                    return ;
                }

                return left;
            }

            @Override
            public int clampViewPositionVertical(View child, int top, int dy) {
                // 限制豎向拖動的位置坐标
                return aboveView.getTop();
            }
        });
    }

    @Override
    public void computeScroll() {
        super.computeScroll();
        if (helper != null && helper.continueSettling(true)) {
            invalidate();
        }
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return helper.shouldInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        helper.processTouchEvent(event);
        return true;
    }
}
           

最終實作效果如下圖所示:

ListView的拖動和側滑實作

檢視所有實作的代碼請點選檢視源碼。

繼續閱讀