Android RecyclerView itemAnimator problem with LiveQuery


#1

In all the working examples of utilizing CBL Android and RecyclerView I see the LiveQuery employed as such:

public class MyTaskListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {

OnItemClickListener mItemClickListener;
OnItemLongClickListener mItemLongClickListener;

public LiveQuery query;
public QueryEnumerator enumerator;
Context context;

public MyTaskListAdapter(Context context, LiveQuery query) {
    this.context = context;
    this.query = query;

    query.addChangeListener(new LiveQuery.ChangeListener() {
        @Override
        public void changed(final LiveQuery.ChangeEvent changeEvent) {
            ((Activity) MyTaskListAdapter.this.context).runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    enumerator = changeEvent.getRows();
                    notifyDataSetChanged();

                }
            });
        }
    });
    query.start();

}

The method works well in keeping the view updated when I do any item add/delete/update operations. However, it appears to have one usage flaw in that calling notifyDataSetChanged() results in the default item animations from firing (i.e. notifyItemInserted(), notifyItemRemoved()) calls have no effect). So the problem is that when any item is deleted or added it shows up instantly and it is a bit jarring to the user instead of folding in/out of view gracefully which would occur if the default itemAnimator was firing.

So this appears to me as a design pattern problem. My knowledge of working with Query, LiveQuery is limited and I struggle with it as I try to figure out what would work here. Maybe taking out the LiveQuery and instead manually refresh the view with a standard query?? Here are some additional pieces of my code to help you get a better picture of what I currently have and if you can offer some advice or code examples it would be greatly appreciated. This is one of the last few pieces I need to nail before releasing the app.

Recycler Initialization:

   public static Query allTasksQuery(Database database) {

    com.couchbase.lite.View view = database.getView(VIEW_NAME);
    if (view.getMap() == null) {
        Mapper map = new Mapper() {
            @Override
            public void map(Map<String, Object> document, Emitter emitter) {

                if (DOC_TYPE_EMAIL.equals(document.get("type"))) {
                    emitter.emit(document.get("created_at"), document);
                }
                if (DOC_TYPE_SMS.equals(document.get("type"))) {
                    emitter.emit(document.get("created_at"), document);
                }
            }
        };
        view.setMap(map, "2");
    }

    Query query = view.createQuery();
    query.setDescending(true);


    return query;
}


public static MyTaskListFragment newInstance(String param1, String param2) {
    MyTaskListFragment fragment = new MyTaskListFragment();
    Bundle args = new Bundle();
    args.putString(ARG_PARAM1, param1);
    args.putString(ARG_PARAM2, param2);
    fragment.setArguments(args);
    return fragment;
}

public MyTaskListFragment() {
    // Required empty public constructor
}

@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    if (getArguments() != null) {
        mParam1 = getArguments().getString(ARG_PARAM1);
        mParam2 = getArguments().getString(ARG_PARAM2);

    }
}

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
                         Bundle savedInstanceState) {
    View v = inflater.inflate(R.layout.fragment_list_main, container, false);

    mRecyclerView = (RecyclerView) v.findViewById(R.id.my_recycler_view);
    LiveQuery query = allTasksQuery(((MyApplication) getActivity().getApplication()).getDatabase()).toLiveQuery();
    mTaskListAdapter = new MyTaskListAdapter(getActivity(), query);

    return v;
}

@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
    super.onViewCreated(view, savedInstanceState);

    mRecyclerView.setAdapter(mTaskListAdapter);
    mRecyclerView.setHasFixedSize(true);
    mRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
    mRecyclerView.setItemAnimator(new DefaultItemAnimator());

    mTaskListAdapter.SetOnItemClickListener(new MyTaskListAdapter.OnItemClickListener() {
        @Override
        public void onItemClick(View view, Document task) {
            Log.d("TAG", "Inside MyTaskListFragment SetOnItemClickListener");
            String docID = (String) task.getProperty("_id");
            String taskType = (String) task.getProperty("type");
            mListener.onTaskInteraction(view, docID, taskType, false);
        }
    });
    mTaskListAdapter.SetOnItemLongClickListener(new MyTaskListAdapter.OnItemLongClickListener() {
        @Override
        public void onLongItemClick(View view, Document task) {
            Log.d("TAG", "Inside MyTaskListFragment SetOnItemLONGClickListener");
            String docID = (String) task.getProperty("_id");
            String taskType = (String) task.getProperty("type");
            mListener.onTaskInteraction(view, docID, taskType, true);
        }
    });

}

#2

Hi @dariuus, to make use of the recyclerview itemAnimator, you need to invoke notifyItemInserted/notifyItemRemoved manually. The live query returns the full data set, not only the changes, so you have to manually match the items in your list and implement add/remove methods of your adapter. Does this make any sense :smile: ? I can write a small example later, if it will help

Regards,
Vlad


#3

Vlad,

I understand that notifyItemInserted/notifyItemRemoved need to be implemented manually to notify the Adapter and trigger the animation, but what I have understood by reading a few Stack Overflow questions in regards to itemAnimator is that if you execute a notifyDataSetChanged() and then go execute either notifyItemInserted/notifyItemRemoved, the animation will never occur.

Since LiveQuery has notifyDataSetChanged() wired in to fire after any changes, the animation will never occur. Hence, my stating this as more of a design pattern problem than a technical one…at least …that is what I am hoping.

If you have any code that demonstrates how to approach using the itemAnimator in conjunction with LiveQuery I would be very much grateful. Maybe there is a RxJava Android way to approach this problem to keep things reactive, but with a little bit more intelligence behind it.

I don’t believe it is necessarily to use LiveQuery to refresh views, maybe in this particular case it would be just as good to refresh the view after any add/delete/updates manually that way I could gain more granular control over the behavior of the UI.

There is a very interesting article that talks about the internals of RecyclerView and itemAnimator here:

http://www.birbit.com/recyclerview-animations-part-2-behind-the-scenes/

But to quote where my frustrations lay here is a short quote:

What happens if notifyDataSetChanged is called? How do predictive animations run?

They don’t, which is why notifyDataSetChanged should be your last resort. When notifyDataSetChanged is called on the adapter, RecyclerView does not know where items moved so it cannot properly fake getViewForPosition calls. It simply runs animations as a LayoutTransition would do.

So I guess… is there a way maybe to use LiveQuery to update the UI based upon whether an item was removed added or changed instead of calling notifyDataSetChanged()??

Thoughts?

Regards,
dariuus


#4

That’s a very interesting discussion so thanks for bringing this up.

To @vladoatanasov’s point, it should be possible to handle local CRUD operations and call notifyItemInserted/notifyItemRemoved accordingly, then persist the operation to CBL.

But to your point, if a live query is used at the same time. The call to save the doc will in turn trigger the live query change listener and notifyDataSetDidChange and maybe interfere with the other notify calls.

A live query is listening on the database change listener. A ChangeEvent object on the database listener has very interesting properties like external to indicate if the change was the result of a replication or of a local CRUD operation. It should be possible to register on the database change listener and only reload the query for external change events and in turn call notifyDataSetChanged. In summary, using a database change listener instead of a live query.

Sort of like this:

James


#5

James,

Thanks for contributing :slight_smile: It sounds like ditching LiveQuery and going with a Database changeListener might be the secret sauce I need. I see in the API documentation that thereare some constructor parameters for the changeListener. a getChanges() method, as well as a getSource(), and an isExternal().

Could you point me to some example code that gives a little more detail on implementation specifics. If I can figure out what type of database change occurred (i.e. CRUD). I would then be able to specify the proper android api call (i.e. notifyinserted, notifyremoved, etc) to call.

Regards,
dariuus


#6

Hi @dariuus,
I have implemented a small example for insert/remove elements from the adapter, I hope you will find it useful. It’s a simple implementation, enough to illustrate the idea.

@jamiltz, love the user :smiley:

Regards,
Vlad


#7

@dariuus Following the setup on the diagram, you wouldn’t get animations for documents that are replicated. Only for those modified locally. I’ll try it and let you know.

Looks like @vladoatanasov came up with a simpler solution by doing a manual diff when a live query change listener is triggered. I’m wondering how this would work for document updates. Perhaps, comparing the rev of a model in the current and new list would tell if it’s an update.

@vladoatanasov, check out draw.io to get that user shape in your diagrams too :slight_smile:

James


#8

Vlad - thanks for the example. I will try to see if I can utilize your solution. It’s at least the first ever example I have seen that utilizes CB Lite, and item animations.

Thanks James,

I look forward to see what your solution looks like. My app currently does not perform any replication today, but that may change in the future. After studying your diagram, it looks as if I should probably be letting the adapter handle the CRUD operations. Today that is not the case for me.

I utilize a Couchbase ‘Helper’ class that does all the heavy lifting CRUD stuff. When you select an operation the OnClickListener is passed back from the Adapter to a Fragment which contains all the adapter/recycler view initialization which in turn passes the click back to my MainActivity:

ADAPTER -> RECYCLER FRAGMENT -> ACTIVITY -> OnTaskInteraction() Method to handle the request.

My CB instance is also instantiated via overriding the Application class similar to what you did in one of you example projects.

Back to your diagram… Do you feel it would be easier and/or recommended to let the Adapter handle the CRUD work, I believe you infer that via your diagram but maybe I am just over thinking it…

Regards,
Dariuus


#9

Here’s a draft tutorial for animations on user input and no animations for replication changes (scroll to the bottom to see the result). Now you have a sample with airports and one with restaurants :). Huum.

@vladoatanasov I see you’re using LazyJsonObject in the model classes. I didn’t know about it. Neat trick for putting model classes together quickly!


#10

Wow. Thanks James. What you did was very impressive, and I am sure it will be of assistance to not only me but future CBLite developers. I really have no excuses now. I have concluded however I am going to have to make some significant changes to my adapter code to accommodate the animation capability. But it will be worth it. :slight_smile:

Thanks Vlad, James for the two working examples and hopefully I can push my first app out to the Play Store in a couple weeks. Can’t wait…

Regards,
Dariuus


#11

You are welcome @dariuus, send us a link to check it out, once you publish it :smile:

@jamiltz, utilizing the LazyJson like that turned out to be the most performant technique for us

Regards,
Vlad


#12

Good morning gentlemen,

I have had success in getting the animations working (Thanks again for that). But I have encountered an interesting anomaly that I thought I would share, and maybe you could comment on.

Since moving from a LiveQuery model to one utilizing standard query and relying on a List array to manage the view. I have encountered some behavior that I guess I took no notice of before. When the user creates a new Document they have to fill in a lot of details (scheduling info, message text, recipient info). I have an option when someone clicks on one of these “Task” documents they can choose to “Clone” it and make minor edits without having to type all that info in every time.

I know the position of the source Document and when they choose the option to “Clone” it I insert the new copy above the original. The animation works perfectly and I can see the new document show up in the proper location.

The “Problem” is that CBLite did not create the Document above the original. It inserted a new Document at the end where it always does, So what I see on the screen (which is what I ultimately want) is not reality. Executing a notifyDataSetChanged(), or exiting the app and coming back, refresh the view to what it is . New Documents should show up at the top (notifyItemInserted(0)) but that suffers the same fate as Clone/Copied Documents.

So at this point I am trying to figure out how to fix this. I suck at Views and Queries, so I am not sure if is something in the View or the Query that is hosed, or if I have to to do something “special” to make sure that CBLite inserts the Document at the location I want it (if thats is even possible). Could someone take a quick look or offer some advice?

I put an excerpt of my MainActivity here:

and Adapter here:


#13

For the immediate time being just to keep things “sane”. Instead of fighting the behavior of CBLite, I inserted a static counter variable into my redrawTaskView() method, and used that as the value passed as notifyItemInserted(mTotalRows) inside my switch statement after the cloneTask().

Now… to the casual observer, the behavior is deliberate. However, I would still like to understand how to better control my query/view relationship to achieve my desired result of being able to copy a Document, and have it appear adjacent to its parent (I hope I am making sense to anyone reading this, if not please let me know).

A possibility I have considered is to use a custom Document ID (_id) or sub-identiier key/value field and use query.setAscending(true). I am reluctant to attempt that solution as it would require me to update every Document in the database after every single add/delete. That kind of overhead even with a mobile app is not elegant or desirable in my opinion, so I am still searching for a better solution. Please respond if able.

Thank you all and have a happy 4th of July.


#14

To keep it as simple as possible, the ordering of items in a list should match the ordering of the QueryEnumerator.

What about emitting the created_at field as the key and sort the setAscending to true? It looks like that’s what you did in the query.
This would match the position where you insert the doc manually with it’s position in the QueryEnumerator returned by the view query.

James


#15

James,

My created_at is a date/time concat from curentTimeString variable as below:

SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyy-MM-  dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault());
Calendar calendar = GregorianCalendar.getInstance();        
String currentTimeString = dateFormatter.format(calendar.getTime());

My current map:

emitter.emit(document.get("created_at"), document);

My current query:

queryTasks = database.getView(BHM_VIEW).createQuery();
queryTasks.setDescending(true);

Now currently the behavior in the app is that any new Document created shows up at the top. I am unable to see your point how to “emit” (wrong choice of word maybe) a Document to a particular position in the view without setting some kind of numerical key/value and have it sort on it.

As I mentioned previously. I can insert Documents in any position, because I am just manipulating my ‘tasks’ array view via notify** commands. In this instance the view (‘view’ meaning what the user sees) does not match the underlying document since Couchbase put it at the top of the list since it is sorting on the created_at key but what you see on the screen is not correct and if you try to click on that newly created document (as per the view) you will be interacting with the wrong Document. So I am forced to put notifyItemInserted(0) to keep the view accurate to what has been emitted.

If there is a design pattern to address this requirement using Couchbase. It eludes me. I have researched a few java sorting/inserting dialogs on SO but nothing to help in this instance. Let me illustrate what is going on in addition to the above verbal.

emitter/query run:
pos(0), doc1
pos(1), doc2
pos(2), doc3

User selects “doc2” and creates a copy. The screen shows:
pos(0), doc1
pos(1), doc2 COPY
pos(2), doc2
pos(3), doc3

The database change listener re-runs the query and emits this:
pos(0), doc2 COPY
pos(1), doc1
pos(2), doc2
pos(3), doc3

As you see from above the user’s view does not match the output of the query, and I see no way to solve this.

My current Query view code is:

private void redrawTaskView(Query queryTasks) {

    mTotalRows = 0;                  //  Just a counter, not used currently.
    QueryEnumerator rows = null;
    try {
        rows = queryTasks.run();                     //Just emit created_at for each document in the database
    } catch (CouchbaseLiteException e) {
        e.printStackTrace();
    }
    List<Task> task = new ArrayList<>();
    for (Iterator<QueryRow> it = rows; it.hasNext(); ) {
        mTotalRows++;
        QueryRow row = it.next();
        Map<String, Object> properties = database.getDocument(row.getDocumentId()).getProperties();
        task.add(new Task((LazyJsonObject) row.getValue()));       
    }

    mTaskListAdapter.dataSet = task;

}

Thanks.