Move animation for StaggeredGridLayoutManager is broken when using DefaultItemAnimator

This looks like a bug in StaggeredGridLayoutManager. I have taken your app and made a few modifications to it for the following demo. Consider the following that makes this point:

enter image description here

As you can see, initially everything works as expected: Items move and the animations are as they should be when the screen is not full. However, when we add item "M", everything goes haywire as you have presented. My conclusion is that this is a bug. It looks like a boundary problem related to gap management but that is just my guess.

I have further confirmed this as a layout issue and can demonstrate that while the order of the children of the RecyclerView is correct, StaggeredGridLayout fails to layout the children in the same order. When you scroll, you are forcing the layout manager to take another look at the layout and then it gets it right although there are no changes to the adapter.

I will add that this is not an issue with the item animator. If you set the animation off (recyclerView.setItemAnimator(null);) the problem persists.

One workaround is to customize ListUpdateCallback to capture the problem situation and do something different. In the code below, the onMoved() method of MyListUpdateCallback looks for a move to position 0 and call notifyItemChanged on the from the to positions; otherwise, processing proceeds as normal.

Here is the app with the fix applied:

enter image description here

While this solution addresses the issues with the MCVE that you offered, it may not address the issue in the real app exactly as written. If it doesn't, I believe that this approach can be adapted to work.

MainActivity.java

This is the updated code for the demo. Set the boolean mDoTheFix to true to apply the fix. If mDoTheFix is false, the app will exhibit the broken behavior.

public class MainActivity extends AppCompatActivity implements View.OnClickListener {

    private RecyclerView recyclerView;

    private List<Data> datas = new ArrayList<>();
    private Adapter adapter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        recyclerView = findViewById(R.id.recycler_view);

        StaggeredGridLayoutManager staggeredGridLayoutManager =
            new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL) {
                @Override
                public void onLayoutCompleted(RecyclerView.State state) {
//                    super.onLayoutCompleted(state);
                }
            };
        //        recyclerView.setItemAnimator(null);
//        staggeredGridLayoutManager.setGapStrategy(StaggeredGridLayoutManager.GAP_HANDLING_NONE);
        this.recyclerView.setLayoutManager(staggeredGridLayoutManager);
        datas.add(new Data(0, "A title", "A body"));
        datas.add(new Data(1, "B title", "B body"));
        datas.add(new Data(2, "C title", "C body"));
        datas.add(new Data(3, "D title", "D body"));
        datas.add(new Data(4, "E title", "E body"));
        datas.add(new Data(5, "F title", "F body"));
        datas.add(new Data(6, "G title", "G body"));
        datas.add(new Data(7, "H title", "H body"));
        datas.add(new Data(8, "I title", "I body"));
        datas.add(new Data(9, "J title", "J body"));
        datas.add(new Data(10, "K title", "K body"));
        datas.add(new Data(11, "L title", "L body"));
        datas.add(new Data(12, "M title", "M body"));
//        datas.add(new Data(13, "N title", "N body"));
//        datas.add(new Data(14, "O title", "O body"));
//        datas.add(new Data(11, "P title", "P body"));
//        datas.add(new Data(12, "Q title", "Q body"));
//        datas.add(new Data(13, "R title", "R body"));
//        datas.add(new Data(14, "S title", "S body"));
        adapter = new Adapter(datas, this);

        recyclerView.setAdapter(adapter);
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        MenuInflater inflater = getMenuInflater();
        inflater.inflate(R.menu.menu, menu);
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        // Handle item selection
        switch (item.getItemId()) {
            case R.id.debug: {
                String s = (String) ((TextView) (((LinearLayout) ((FrameLayout) recyclerView.getChildAt(0)).getChildAt(0)).getChildAt(0)))
                    .getText();
                Data data0 = datas.get(0);
                Data data1 = datas.get(1);

                datas.set(0, data1);
                datas.set(1, data0);

                new MyListUpdateCallback().onMoved(1, 0);

                return true;
            }

            case R.id.debug2:

                Data data2 = datas.get(2);
                Data data3 = datas.get(3);

                datas.set(2, data3);
                datas.set(3, data2);

                adapter.notifyItemMoved(2, 3);

                return true;

            case R.id.debug3: {
                List<Data> oldDatas = new ArrayList<>(datas);

                Data data0 = datas.get(0);
                Data data1 = datas.get(1);

                datas.set(0, data1);
                datas.set(1, data0);

                MyNoteDiffUtilCallback noteDiffUtilCallback = new MyNoteDiffUtilCallback(datas, oldDatas);
                DiffUtil.calculateDiff(noteDiffUtilCallback).dispatchUpdatesTo(new MyListUpdateCallback());

                return true;
            }

            case R.id.debug4:
                String ABC = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
                int i = datas.size();
                if (i < 26) {
                    String ch = ABC.substring(i, i + 1);
                    datas.add(new Data(12, ch + " title", ch + " body"));
                    adapter.notifyItemInserted(i - 1);
                }
                return true;

            case R.id.debug5:
                int toRemove = datas.size() - 1;
                datas.remove(toRemove);
                adapter.notifyItemRemoved(toRemove);
                return true;

            default:
                return super.onOptionsItemSelected(item);
        }
    }

    @Override
    public void onClick(View v) {
        int oldPos = recyclerView.getLayoutManager().getPosition(v);
        int newPos = oldPos - 1;
        if (newPos < 0) {
            return;
        }
        Data data0 = datas.get(oldPos);
        Data data1 = datas.get(newPos);

        datas.set(oldPos, data1);
        datas.set(newPos, data0);
        new MyListUpdateCallback().onMoved(oldPos, newPos);
    }

    public class MyNoteDiffUtilCallback extends DiffUtil.Callback {
        private List<Data> newsDatas;
        private List<Data> oldDatas;


        public MyNoteDiffUtilCallback(List<Data> newsDatas, List<Data> oldDatas) {
            this.newsDatas = newsDatas;
            this.oldDatas = oldDatas;
        }

        @Override
        public int getOldListSize() {
            return oldDatas.size();
        }

        @Override
        public int getNewListSize() {
            return newsDatas.size();
        }

        @Override
        public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
            return oldDatas.get(oldItemPosition).id == newsDatas.get(newItemPosition).id;
        }

        @Override
        public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
            return oldDatas.get(oldItemPosition).equals(newsDatas.get(newItemPosition));
        }
    }

    boolean mDoTheFix = true;

    private class MyListUpdateCallback implements ListUpdateCallback {

        @Override
        public void onMoved(int fromPosition, int toPosition) {
            if (mDoTheFix && toPosition == 0) {
                adapter.notifyItemChanged(fromPosition);
                adapter.notifyItemChanged(toPosition);
            } else {
                adapter.notifyItemMoved(fromPosition, toPosition);
            }
        }

        public void onInserted(int position, int count) {
            adapter.notifyItemRangeInserted(position, count);
        }

        @Override
        public void onRemoved(int position, int count) {
            adapter.notifyItemRangeRemoved(position, count);
        }

        @Override
        public void onChanged(int position, int count, Object payload) {
            adapter.notifyItemRangeChanged(position, count, payload);
        }
    }
}

menu.xml

<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:myApp="http://schemas.android.com/apk/res-auto">
    <item android:id="@+id/debug"
        android:title="0->1"
        myApp:showAsAction="always" />

    <item android:id="@+id/debug2"
        android:title="2->3"
        myApp:showAsAction="always" />

    <item android:id="@+id/debug3"
        android:title="DiffUtil"
        myApp:showAsAction="always" />

    <item android:id="@+id/debug4"
        android:title="Add"
        myApp:showAsAction="always" />

    <item android:id="@+id/debug5"
        android:title="Del"
        myApp:showAsAction="always" />
</menu>

I did some analysis for the StaggeredGridLayoutManager by scanning grep code. Looks like there is an issue with calculation when itemMoved is at index 0. You can refer this method which will be called when notifyItemMoved() is called implicitly by the RecyclerView's adapter.

It works for other layouts because, other layouts simply call invalidateSpanCache() to perform correct recalculation in next layout which will be cased by notifyItemMoved(), since its a structural change event.

So the solution/workaround which I found are as follows, which only focuses on dealing with position movement of index 0:

Option 1: Call notifyDataSetChanged

For your case, in order to enforce StaggeredGridLayoutManager to react correctly for item moved at index 0, you need to enforce the StaggeredGridLayoutManager fully rebind and relayout all visible views. This can be achieved by calling notifyDataSetChanged() as follows:

case R.id.debug: {

     Data data0 = datas.get(0);
     Data data1 = datas.get(1);

     datas.set(0, data1);
     datas.set(1, data0);
     adapter.notifyDataSetChanged();
     return true;
 }

This triggers your expected animation. But the drawback is this operation can be expensive when you are dealing with huge data

Option 2: Call onItemMoved directly on layout

This method somehow performs correct calculation and views are not messed up. You need to maintain reference for the layout object:

case R.id.debug:{

    Data data0=datas.get(0);
    Data data1=datas.get(1);

    datas.set(0,data1);
    datas.set(1,data0);
    staggeredGridLayoutManager.requestSimpleAnimationsInNextLayout();
    staggeredGridLayoutManager.onItemsMoved(recyclerView,0,1,1);


    return true;
}

Drawback here is, it applies different animation (fade in/fade out). This will also not call any data change observers since StaggeredGridLayoutManager doesnot propogate the call to RecyclerView. You might need to create your animation to have same effect.

Option 3: Notify notifyItemChanged

This is the option you don't like for obvious valid reasons:

case R.id.debug: {
    Collections.swap(datas, 0, 1);
    adapter.notifyItemChanged(0);
    adapter.notifyItemChanged(1);
    return true;
}

To conclude, based on the nature of code, unclear documentation and nature of usage StaggeredGridLayoutManager development looks to be in inchoate stage and would mature in near future. A good solution will only be possible, when the issues with the layout has been fixed. Till then we have to use some workarounds with different trade offs.


i tried that code and it works great
(instead of using notifyItemMoved I used notifyItemRangeChanged that is the correct notify function that you need)

you can see it also here https://jumpshare.com/v/fQklba8yFs4YTqkxoyMH

@Override
public boolean onOptionsItemSelected(MenuItem item) {
    // Handle item selection
    switch (item.getItemId()) {
        case R.id.debug:

            Data data0 = datas.get(0);
            Data data1 = datas.get(1);

            datas.set(0, data1);
            datas.set(1, data0);

            adapter.notifyItemRangeChanged(0,2);

            return true;

        case R.id.debug2:

            Data data2 = datas.get(2);
            Data data3 = datas.get(3);

            datas.set(2, data3);
            datas.set(3, data2);

            adapter.notifyItemRangeChanged(2,2);

            return true;

        default:
            return super.onOptionsItemSelected(item);
    }
}

Update

it is wrong to use notifyItemMoved for swap. You have to use 2 notifyItemChanged or a notifyItemRangeChanged or for all the dataset.

2nd working code

    @Override
public boolean onOptionsItemSelected(MenuItem item) {
    // Handle item selection
    switch (item.getItemId()) {
        case R.id.debug:

            Data data0 = datas.get(0);
            Data data1 = datas.get(1);

            datas.set(0, data1);
            datas.set(1, data0);
            adapter.notifyItemChanged(0);
            adapter.notifyItemChanged(1);

            return true;

        case R.id.debug2:

            Data data2 = datas.get(2);
            Data data3 = datas.get(3);

            datas.set(2, data3);
            datas.set(3, data2);
            adapter.notifyItemChanged(2);
            adapter.notifyItemChanged(3);

            return true;

        default:
            return super.onOptionsItemSelected(item);
    }
}

Tags:

Android