RecyclerView's Items Position On The Screen Changes On The Run

Viewed 631


I have this RecyclerView where any item can be clicked to open, and when it's opened, it grows (picture 1 is a closed item, picture 2 is an opened item)


The layout file of an item holds two states - the closed card, and the opened card. To switch between them, I change only the visibility of any state. Here are the methods that control an item's opening (expanding) or closing (shrinking) :

    /**
    * handles the expanding functionality.
    */
    public void expand() {
        shrink = true;
        if (expandedItem != -1) {
            notifyItemChanged(expandedItem);
        }
        expandedItem = getLayoutPosition();

        toggleExpandShrinkIcon(true);
        if (!openedFromParent[1]) {
            openedFromParent[1] = true;
        } else {
            openedFromParent[0] = false;
        }
        expandedContainer.setVisibility(View.VISIBLE);
        shrunkProgressBar.setVisibility(View.INVISIBLE);
    }

    /**
     * handles the shrinking functionality.
     */
    public void shrink() {
        toggleExpandShrinkIcon(false);
        expandedContainer.setVisibility(View.GONE);
        shrunkProgressBar.setVisibility(View.VISIBLE);
        shrink = false;
    }

These methods are located in the RecyclerView's adapter, inside of the ViewHolder's class, and they are public so I could use them also out of the RecyclerView's adapter class (not only by clicking), as I did when one item hovers another.


Recently I added drag-to-hover functionality (using this library) so that I can drag any item on top of any other item, and when one item hovers another item, the lower item gets opened.
When an item gets opened, it pushes all the other items below it to be able to expand without hiding the items under it (like in the first video).
When moving from hovering one item to another, say from the second item to the third, when hovering the second item it gets opened and the third item is pushed down, and when moving to the third item the second item gets closed, but the third item won't go back up. Then when hovering the third item, it gets opened on the fourth item (see the second video to understand better).

Here's the code in the class that handles the hovering action:

public class HoveringCallback extends ItemTouchHelper.SimpleCallback {
    //re-used list for selecting a swap target
    private List<RecyclerView.ViewHolder> swapTargets = new ArrayList<>();
    //re used for for sorting swap targets
    private List<Integer> distances = new ArrayList<>();
    private float selectedStartX;
    private float selectedStartY;

    public interface OnDroppedListener {
        void onDroppedOn(ActiveGoalsAdapter.ActiveGoalsViewHolder viewHolder, ActiveGoalsAdapter.ActiveGoalsViewHolder target);
    }

    private List<OnDroppedListener> onDroppedListeners = new ArrayList<>();
    @Nullable
    private RecyclerView recyclerView;
    @Nullable
    ActiveGoalsAdapter.ActiveGoalsViewHolder selected;
    @Nullable
    private ActiveGoalsAdapter.ActiveGoalsViewHolder hovered;

    ItemBackgroundCallback backgroundCallback;

    public HoveringCallback() {
        super(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0);
    }

    public void attachToRecyclerView(@Nullable RecyclerView recyclerView) {
        this.recyclerView = recyclerView;
    }

    public void addOnDropListener(OnDroppedListener listener) {
        onDroppedListeners.add(listener);
    }

    public void removeOnDropListener(OnDroppedListener listener) {
        onDroppedListeners.remove(listener);
    }

    @Override
    public void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState) {
        super.onSelectedChanged(viewHolder, actionState);
        if (viewHolder == null) {
            if (hovered != null) {
                notifyDroppedOnListeners(hovered);
            }
        } else {
            selectedStartX = viewHolder.itemView.getLeft();
            selectedStartY = viewHolder.itemView.getTop();
        }
        this.selected = (ActiveGoalsAdapter.ActiveGoalsViewHolder) viewHolder;
        if (actionState != ItemTouchHelper.ACTION_STATE_IDLE && viewHolder != null) {
            viewHolder.itemView.setBackgroundColor(backgroundCallback.getDraggingBackgroundColor(viewHolder));
        }
    }

    private void notifyDroppedOnListeners(ActiveGoalsAdapter.ActiveGoalsViewHolder holder) {
        for (OnDroppedListener listener : onDroppedListeners) {
            listener.onDroppedOn(selected, (ActiveGoalsAdapter.ActiveGoalsViewHolder) holder);
        }
    }

    @Override
    public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
        super.clearView(recyclerView, viewHolder);
        viewHolder.itemView.setBackgroundColor(backgroundCallback.getDefaultBackgroundColor(viewHolder));
    }

    @Override
    public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
        return false;
    }

    @Override
    public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
    }

    @Override
    public void onChildDraw(Canvas canvas, RecyclerView parent, RecyclerView.ViewHolder viewHolder,
                            float dX, float dY, int actionState, boolean isCurrentlyActive) {
        super.onChildDraw(canvas, parent, viewHolder, dX, dY, actionState, isCurrentlyActive);

        if (actionState != ItemTouchHelper.ACTION_STATE_DRAG) {
            return;
        }

        if (recyclerView == null || selected == null) {
            return;
        }

        final RecyclerView.LayoutManager lm = recyclerView.getLayoutManager();
        final int childCount = lm.getChildCount();
        List<RecyclerView.ViewHolder> swapTargets = findSwapTargets((ActiveGoalsAdapter.ActiveGoalsViewHolder) viewHolder, dX, dY);
        final int x = (int) (selectedStartX + dX);
        final int y = (int) (selectedStartY + dY);

        hovered = (ActiveGoalsAdapter.ActiveGoalsViewHolder) chooseDropTarget((ActiveGoalsAdapter.ActiveGoalsViewHolder) viewHolder, swapTargets, x, y);
        if (hovered == null) {
            this.swapTargets.clear();
            this.distances.clear();
        }
        for (int i = 0; i < childCount; i++) {
            final View child = lm.getChildAt(i);

            if (viewHolder.itemView == child) {
                continue;
            }

            ActiveGoalsAdapter.ActiveGoalsViewHolder childViewHolder = (ActiveGoalsAdapter.ActiveGoalsViewHolder) parent.findContainingViewHolder(child);

            if (childViewHolder == null || childViewHolder.getAdapterPosition() == RecyclerView.NO_POSITION) {
                continue;
            }

            final int count = canvas.save();
            if (childViewHolder == hovered) {
                new Handler().postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        childViewHolder.expand();
                    }
                }, 500);
            } else {
                if(!childViewHolder.isShrunk()) {
                    childViewHolder.shrink();
                    if(canvas.getSaveCount() != count) {
                        canvas.restoreToCount(count);
                    }
                }
            }
        }
    }

    private List<RecyclerView.ViewHolder> findSwapTargets(ActiveGoalsAdapter.ActiveGoalsViewHolder viewHolder, float dX, float dY) {
        swapTargets.clear();
        distances.clear();
        final int margin = getBoundingBoxMargin();
        final int left = Math.round(selectedStartX + dX) - margin;
        final int top = Math.round(selectedStartY + dY) - margin;
        final int right = left + viewHolder.itemView.getWidth() + 2 * margin;
        final int bottom = top + viewHolder.itemView.getHeight() + 2 * margin;
        final int centerX = (left + right) / 2;
        final int centerY = (top + bottom) / 2;
        final RecyclerView.LayoutManager lm = recyclerView.getLayoutManager();
        final int childCount = lm.getChildCount();
        for (int i = 0; i < childCount; i++) {
            View other = lm.getChildAt(i);
            if (other == viewHolder.itemView) {
                continue; //myself!
            }
            if (other.getBottom() < top || other.getTop() > bottom
                    || other.getRight() < left || other.getLeft() > right) {
                continue;
            }
            final ActiveGoalsAdapter.ActiveGoalsViewHolder otherVh = (ActiveGoalsAdapter.ActiveGoalsViewHolder) recyclerView.getChildViewHolder(other);
            if (canDropOver(recyclerView, selected, otherVh)) {
                // find the index to add
                final int dx = Math.abs(centerX - (other.getLeft() + other.getRight()) / 2);
                final int dy = Math.abs(centerY - (other.getTop() + other.getBottom()) / 2);
                final int dist = dx * dx + dy * dy;

                int pos = 0;
                final int cnt = swapTargets.size();
                for (int j = 0; j < cnt; j++) {
                    if (dist > distances.get(j)) {
                        pos++;
                    } else {
                        break;
                    }
                }
                swapTargets.add(pos, otherVh);
                distances.add(pos, dist);
            }
        }
        return swapTargets;
    }

    @Override
    public float getMoveThreshold(RecyclerView.ViewHolder viewHolder) {
        return 0.05f;
    }

    @Override
    public RecyclerView.ViewHolder chooseDropTarget(RecyclerView.ViewHolder selected,
                                                    List<RecyclerView.ViewHolder> dropTargets,
                                                    int curX, int curY) {
        int right = curX + selected.itemView.getWidth();
        int bottom = curY + selected.itemView.getHeight();
        ActiveGoalsAdapter.ActiveGoalsViewHolder winner = null;
        int winnerScore = -1;
        final int dx = curX - selected.itemView.getLeft();
        final int dy = curY - selected.itemView.getTop();
        final int targetsSize = dropTargets.size();
        for (int i = 0; i < targetsSize; i++) {
            final ActiveGoalsAdapter.ActiveGoalsViewHolder target = (ActiveGoalsAdapter.ActiveGoalsViewHolder) dropTargets.get(i);
            if (dx > 0) {
                int diff = target.itemView.getRight() - right;
                if (diff < 0 && target.itemView.getRight() > selected.itemView.getRight()) {
                    final int score = Math.abs(diff);
                    if (score > winnerScore) {
                        winnerScore = score;
                        winner = target;
                    }
                }
            }
            if (dx < 0) {
                int diff = target.itemView.getLeft() - curX;
                if (diff > 0 && target.itemView.getLeft() < selected.itemView.getLeft()) {
                    final int score = Math.abs(diff);
                    if (score > winnerScore) {
                        winnerScore = score;
                        winner = target;
                    }
                }
            }
            if (dy < 0) {
                int diff = target.itemView.getTop() - curY;
                if (target.itemView.getTop() < selected.itemView.getTop()) {
                    final int score = Math.abs(diff);
                    if (score > winnerScore) {
                        winnerScore = score;
                        winner = target;
                    }
                }
            }

            if (dy > 0) {
                int diff = target.itemView.getBottom() - bottom;
                if (target.itemView.getBottom() > selected.itemView.getBottom()) {
                    final int score = Math.abs(diff);
                    if (score > winnerScore) {
                        winnerScore = score;
                        winner = target;
                    }
                }
            }
        }
        return winner;
    }
}


How can I solve this? (make the third item go back up, like when the drag is released, at the end of the second video)
Help would be highly appreciated! (:


First picture (closed item):
closed item


Second picture (opened item):
opened item


First Video (item gets opened & closed in the list):
item gets opened & closed in the list


Second video (item dragged):
item dragged with the bug

2 Answers

Without studying the code of Library I assume positions are being changed so it is highly likely that .notifyItemChanged(position) is invoked, and in that, expand function invoke because there is no check to isolate the expand function from unhandled events

A simple answer can be to save the state and disable the expansion of items

Set isDragActive to true when where the item is long pressed

private static isDragActive = false
  @Overrides
   public onCreateViewHolder(){
     itemView.setOnItemLongClickListener(new OnItemLongClickListener() {
        @Override
        public boolean onItemLongClick(AdapterView<?> arg0, View arg1,
                int pos, long id) {
            isDragActive = true;
            return true;
        }
    }); 
 }

    public void expand() {
    if(!isDragActive){
        shrink = true;
        if (expandedItem != -1) {
            notifyItemChanged(expandedItem);
        }
        expandedItem = getLayoutPosition();

        toggleExpandShrinkIcon(true);
        if (!openedFromParent[1]) {
            openedFromParent[1] = true;
        } else {
            openedFromParent[0] = false;
        }
        expandedContainer.setVisibility(View.VISIBLE);
        shrunkProgressBar.setVisibility(View.INVISIBLE);
    }
 }

You can also use the library given below instead of above solution

https://github.com/mikepenz/FastAdapter

I think the best bet is to set your code that opens each recyclerview using ACTION_UP so that the code is triggered on the lifting of the finger, not on the sensing of it.

button.setOnTouchListener(new View.OnTouchListener() { @Override public boolean onTouch(View view, MotionEvent motionEvent) { sw If(MotionEvent.ACTION_UP) {Toast.makeText(MainActivity.this, "Up", Toast.LENGTH_SHORT).show(); }

Related