Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to make list elements get DragEvents after scrolling

Short version:

  • Is there a way to make a newly created view receive DragEvents of an already running drag-and-drop operation?

There's How to register a DragEvent while already inside one and have it listen in the current DragEvent?, but I'd really like a cleaner solution.

The suggested GONE->VISIBLE workaround is quite complex to get "right", because you need to make sure to only use it when a list item becomes visible and not unconditionally on all current list view items. In this the hack is slightly leaky without even more workaround code to get it right.

Long version:

I have a ListView. The elements of the ListView are custom View`s that contain dragable symbols (small boxes), e.g. similar to this:

Example playout

It is possible to drag the small boxes between the items of the ListView, like sorting elements into boxes. The drag handler on the list items is more or less trivial:

@Override
public boolean onDragEvent(DragEvent event)
{
    if ((event.getLocalState() instanceof DragableSymbolView)) {
        final DragableSymbolView draggedView = (DragableSymbolView) event.getLocalState();
        if (draggedView.getTag() instanceof SymbolData) {
            final SymbolData symbol = (SymbolData) draggedView.getTag();
            switch (event.getAction()) {
            case DragEvent.ACTION_DRAG_STARTED:
                return true;

            case DragEvent.ACTION_DRAG_ENTERED:
                setSelected(true);
                return true;

            case DragEvent.ACTION_DRAG_ENDED:
            case DragEvent.ACTION_DRAG_EXITED:
                setSelected(false);
                return true;

            case DragEvent.ACTION_DROP:
                setSelected(false);
                // [...] remove symbol from soruce box and add to current box
                requestFocus();
                break;
            }
        }
    }

    return super.onDragEvent(event);
}

Dragging starts when holding the pointer over a symbol and starting to drag (i.e. moving it beyond a small threshold).

Now, however, the screen size may not be enough to contain all boxes and thus the ListView needs to scroll. I found out the hard way that I need to do implement the scrolling on my own since ListView does not automatically scroll while dragging.

In comes ListViewScrollingDragListener:

public class ListViewScrollingDragListener
    implements View.OnDragListener {

    private final ListView _listView;

    public static final int DEFAULT_SCROLL_BUFFER_DIP = 96;
    public static final int DEFAULT_SCROLL_DELTA_UP_DIP = 48;
    public static final int DEFAULT_SCROLL_DELTA_DOWN_DIP = 48;

    private int _scrollDeltaUp;
    private int _scrollDeltaDown;

    private boolean _doScroll = false;
    private boolean _scrollActive = false;

    private int _scrollDelta = 0;

    private int _scrollDelay = 250;
    private int _scrollInterval = 100;

    private int _scrollBuffer;

    private final Rect _visibleRect = new Rect();

    private final Runnable _scrollHandler = new Runnable() {

        @Override
        public void run()
        {
            if (_doScroll && (_scrollDelta != 0) && _listView.canScrollVertically(_scrollDelta)) {
                _scrollActive = true;
                _listView.smoothScrollBy(_scrollDelta, _scrollInterval);
                _listView.postDelayed(this, _scrollInterval);
            } else {
                _scrollActive = false;
            }
        }
    };

    public ListViewScrollingDragListener(final ListView listView, final boolean attach)
    {
        _scrollBuffer = UnitUtil.dipToPixels(listView, DEFAULT_SCROLL_BUFFER_DIP);
        _scrollDeltaUp = -UnitUtil.dipToPixels(listView, DEFAULT_SCROLL_DELTA_UP_DIP);
        _scrollDeltaDown = UnitUtil.dipToPixels(listView, DEFAULT_SCROLL_DELTA_DOWN_DIP);

        _listView = listView;
        if (attach) {
            _listView.setOnDragListener(this);
        }
    }

    public ListViewScrollingDragListener(final ListView listView)
    {
        this(listView, true);
    }

    protected void handleDragLocation(final float x, final float y)
    {
        _listView.getGlobalVisibleRect(_visibleRect);
        if (_visibleRect.contains((int) x, (int) y)) {
            if (y < _visibleRect.top + _scrollBuffer) {
                _scrollDelta = _scrollDeltaUp;
                _doScroll = true;
            } else if (y > _visibleRect.bottom - _scrollBuffer) {
                _scrollDelta = _scrollDeltaDown;
                _doScroll = true;
            } else {
                _doScroll = false;
                _scrollDelta = 0;
            }
            if ((_doScroll) && (!_scrollActive)) {
                _scrollActive = true;
                _listView.postDelayed(_scrollHandler, _scrollDelay);
            }
        }
    }

    public ListView getListView()
    {
        return _listView;
    }

    @Override
    public boolean onDrag(View v, DragEvent event)
    {
        /* hide sequence controls during drag */
        switch (event.getAction()) {
        case DragEvent.ACTION_DRAG_ENTERED:
            _doScroll = true;
            break;

        case DragEvent.ACTION_DRAG_EXITED:
        case DragEvent.ACTION_DRAG_ENDED:
        case DragEvent.ACTION_DROP:
            _doScroll = false;
            break;

        case DragEvent.ACTION_DRAG_LOCATION:
            handleDragLocation(event.getX(), event.getY());
            break;
        }
        return true;
    }
}

This basically scrolls the ListView when you you drag near the upper or lower borders of its visible area. It's not perfect, but it's good enough.

However, there's a catch:

When the list scrolls to a previously invisible element, that element does not receive DragEvents. It does not get selected (highlighted) when dragging a symbol over it nor does it accept drops.

Any ideas on how to make the "scrolled in" views receive DragEvents from the already active drag-and-drop operation?

like image 883
dhke Avatar asked Apr 23 '15 13:04

dhke


1 Answers

So the fundamental problem is that ViewGroup(that ListView extends) caches a List of children to notify of DragEvent. Moreover, it only populates this cache when receiving ACTION_DRAG_STARTED. For more details peruse the source code here.

On to the solution! Instead of listening for drop events on the individual rows of ListView, we're going to listen to them on ListView itself. Then, based on the coordinates of the events, we'll figure out which row the dragged view is being dragged from/to or hovered over. When the drop occurs, we'll perform the transaction of removing from the previous row and adding to the new row.

private void init(Context context) {
    setAdapter(new RandomIconAdapter()); // Adapter that contains our data set
    setOnDragListener(new ListDragListener());
    mListViewScrollingDragListener = new ListViewScrollingDragListener(this, false);
}

ListViewScrollingDragListener mListViewScrollingDragListener;

private class ListDragListener implements OnDragListener {
    // The view that our dragged view would be dropped on
    private View mCurrentDropZoneView = null;
    private int mDropStartRowIndex = -1;

    @Override
    public boolean onDrag(View v, DragEvent event) {
        switch (event.getAction()) {
            case DragEvent.ACTION_DRAG_LOCATION:
                // Update the active drop zone based on the position of the event
                updateCurrentDropZoneView(event);

                // Funnel drag events to separate listener to handle scrolling near edges
                mListViewScrollingDragListener.onDrag(v, event);

                if( mDropStartRowIndex == -1 )          // Only initialize once per drag->drop gesture
                {
                    mDropStartRowIndex = indexOfChild(mCurrentDropZoneView) + getFirstVisiblePosition();
                    log("mDropStartRowIndex %d", mDropStartRowIndex);
                }
                break;
            case DragEvent.ACTION_DRAG_ENDED:
            case DragEvent.ACTION_DRAG_EXITED:
                mCurrentDropZoneView = null;
                mDropStartRowIndex = -1;
                break;
            case DragEvent.ACTION_DROP:
                // Update our data set based on the row that the dragged view was dropped in
                int finalDropRow = indexOfChild(mCurrentDropZoneView) + getFirstVisiblePosition();
                updateDataSetWithDrop(mDropStartRowIndex, finalDropRow);

                // Let adapter update ui
                ((BaseAdapter)getAdapter()).notifyDataSetChanged();

                break;
        }

        // The ListView handles ALL drag events all the time. Fine for now since we don't need to
        // drag -> drop outside of the ListView.
        return true;
    }

    private void updateDataSetWithDrop(int fromRow, int toRow) {
        log("updateDataSetWithDrop fromRow %d and toRow %d", fromRow, toRow);
        sIconsForListItems[fromRow]--;
        sIconsForListItems[toRow]++;
    }

    // NOTE: The DragEvent in local to DragDropListView, as are children coordinates
    private void updateCurrentDropZoneView(DragEvent event) {
        View previousDropZoneView = mCurrentDropZoneView;
        mCurrentDropZoneView = findFrontmostDroppableChildAt(event.getX(), event.getY());
        log("mCurrentDropZoneView updated to %d for x/y : %f/%f with action %d",
                mCurrentDropZoneView == null ? -1 : indexOfChild(mCurrentDropZoneView) + getFirstVisiblePosition(),
                event.getX(), event.getY(), event.getAction());

        if (mCurrentDropZoneView != previousDropZoneView) {
            if (previousDropZoneView != null) previousDropZoneView.setSelected(false);
            if (mCurrentDropZoneView != null) mCurrentDropZoneView.setSelected(true);
        }
    }
}

/**
 * The next four methods are utility methods taken from Android Source Code. Most are package-private on View
 * or ViewGroup so I'm forced to replicate them here. Original source can be found:
 * http://grepcode.com/file/repository.grepcode.com/java/ext/com.google.android/android/5.1.0_r1/android/view/ViewGroup.java#ViewGroup.findFrontmostDroppableChildAt%28float%2Cfloat%2Candroid.graphics.PointF%29
 */
private View findFrontmostDroppableChildAt(float x, float y) {
    int childCount = this.getChildCount();
    for(int i=0; i<childCount; i++)
    {
        View child = getChildAt(i);
        if (isTransformedTouchPointInView(x, y, child)) {
            return child;
        }
    }

    return null;
}

static public boolean isTransformedTouchPointInView(float x, float y, View child) {
    PointF point = new PointF(x, y);
    transformPointToViewLocal(point, child);
    return pointInView(child, point.x, point.y);
}

static public void transformPointToViewLocal(PointF pointToModify, View child) {
    pointToModify.x -= child.getLeft();
    pointToModify.y -= child.getTop();
}

static public boolean pointInView(View v, float localX, float localY) {
    return localX >= 0 && localX < (v.getRight() - v.getLeft())
                && localY >= 0 && localY < (v.getBottom() - v.getTop());
}

static final int[] sIconsForListItems;
static final int NUM_LIST_ITEMS = 50;
static final int MAX_NUM_ICON_PER_ELEMENT = 8;
static {
    sIconsForListItems = new int[NUM_LIST_ITEMS];
    for (int i=0; i < NUM_LIST_ITEMS; i++)
    {
        sIconsForListItems[i] = (getRand(MAX_NUM_ICON_PER_ELEMENT));
    }
}

private static final String TAG = DragDropListView.class.getSimpleName();
private static void log(String format, Object... args) {
    Log.d(TAG, String.format(format, args));
}

Lots of comments so hopefully the code is self-documenting. A few notes:

  • RandomIconAdapter is just a basic adapter extending BaseAdapter and backed by sIconsForListItems.
  • ListViewScrollingDragListener is the same as the one in the prompt.
  • Tested on GS6 5.0.2
like image 185
Trevor Carothers Avatar answered Nov 05 '22 11:11

Trevor Carothers