I am working on an application that displays a work schedule on a time line.
This is an example of how the application is currently created:

Data is stored in SQLite DB. When a Timeline object (singleton object) requests data from an auxiliary database class, it receives an ArrayList Event (for example, an Event can be a duty starting May 1, 2016 at 03:00 and ending May 3, 2016 at 16:00). Timeline then converts these Event to TimelineItem s, a class representing (part) a Event for a specific day.
Downloading Event and converting Event to TimelineItem done in AsyncTasks. So far so good.
Now comes the part I'm struggling with: updating the user interface after a new fetch.
My first approach was to pass the updated ArrayList TimelineItems to the RecyclerView adapter and tell the adapter that the data was changed using notifyDatasetChanged() . The problem with this approach is that 1) a lot of unnecessary work is done (because we recount all the events / Timeline elements, and not just those that have been changed) and 2) the scroll position on the RecyclerView is reset after each DB extraction
In my second approach, I implemented some methods to check which TimelineItems events / elements have changed since the last show with the idea of changing only those TimelineItems elements using notifyItemChanged() . Less work is being done and there is no need to worry about scroll positions at all. The hard bit is that checking which items have been changed takes some time, so you must also make an asynchronous call:
I tried to manipulate the code in doInBackground() and update the user interface by posting otto bus events in onProgressUpdate() .
private class InsertEventsTask extends AsyncTask<Void, Integer, Void> { @Override protected Void doInBackground(Void... params) { ArrayList<Event> events = mCachedEvents;
The problem is that somehow the scrolling while this progress is being made completely violates the user interface.
My main problem: when I update a specific element in the adapter's TimelineItems, notifyItemChanged () changes the element, but does not put the element in the correct position.
Here is my adapter:
/** * A custom RecyclerView Adapter to display a Timeline in a TimelineFragment. */ public class TimelineAdapter extends RecyclerView.Adapter<TimelineAdapter.TimelineItemViewHolder> { /************* * VARIABLES * *************/ private ArrayList<TimelineItem> mTimelineItems; /**************** * CONSTRUCTORS * ****************/ /** * Constructor with <code>ArrayList<TimelineItem></code> as data set argument. * * @param timelineItems ArrayList with TimelineItems to display */ public TimelineAdapter(ArrayList<TimelineItem> timelineItems) { this.mTimelineItems = timelineItems; } // Create new views (invoked by the layout manager) @Override public TimelineItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { // create a new view View v = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_timeline, parent, false); // set the view size, margins, paddings and layout parameters // ... return new TimelineItemViewHolder(v); } // Replace the contents of a view (invoked by the layout manager) @Override public void onBindViewHolder(TimelineItemViewHolder holder, int position) { // - get element from your data set at this position // - replace the contents of the view with that element // if the item is a ShowPreviousMonthsItem, set the showPreviousMonthsText accordingly if (mTimelineItems.get(position).isShowPreviousMonthsItem) { holder.showPreviousMonthsText.setText(mTimelineItems.get(position).showPreviousMonthsText); } else { // otherwise set the showPreviousMonthsText blank holder.showPreviousMonthsText.setText(""); } // day of month & day of week of the TimelineItem if (mTimelineItems.get(position).isFirstItemOfDay) { holder.dayOfWeek.setText(mTimelineItems.get(position).dayOfWeek); holder.dayOfMonth.setText(mTimelineItems.get(position).dayOfMonth); } else { holder.dayOfWeek.setText(""); holder.dayOfMonth.setText(""); } // Event name for the TimelineItem holder.name.setText(mTimelineItems.get(position).name); // place and goingTo of the TimelineItem // if combinedPlace == "" if(mTimelineItems.get(position).combinedPlace.equals("")) { if (mTimelineItems.get(position).isFirstDayOfEvent) { holder.place.setText(mTimelineItems.get(position).place); } else { holder.place.setText(""); } if (mTimelineItems.get(position).isLastDayOfEvent) { holder.goingTo.setText(mTimelineItems.get(position).goingTo); } else { holder.goingTo.setText(""); } holder.combinedPlace.setText(""); } else { holder.place.setText(""); holder.goingTo.setText(""); holder.combinedPlace.setText(mTimelineItems.get(position).combinedPlace); } if(mTimelineItems.get(position).startDateTime != null) { holder.startTime.setText(mTimelineItems.get(position).startDateTime.toString("HH:mm")); } else { holder.startTime.setText(""); } if(mTimelineItems.get(position).endDateTime != null) { holder.endTime.setText(mTimelineItems.get(position).endDateTime.toString("HH:mm")); } else { holder.endTime.setText(""); } if (!mTimelineItems.get(position).isShowPreviousMonthsItem) { if (mTimelineItems.get(position).date.getDayOfWeek() == DateTimeConstants.SUNDAY) { holder.dayOfWeek.setTextColor(Color.RED); holder.dayOfMonth.setTextColor(Color.RED); } else { holder.dayOfWeek.setTypeface(null, Typeface.NORMAL); holder.dayOfMonth.setTypeface(null, Typeface.NORMAL); holder.dayOfWeek.setTextColor(Color.GRAY); holder.dayOfMonth.setTextColor(Color.GRAY); } } else { ((RelativeLayout) holder.dayOfWeek.getParent()).setBackgroundColor(Color.WHITE); } holder.bindTimelineItem(mTimelineItems.get(position)); } // Return the size of the data set (invoked by the layout manager) @Override public int getItemCount() { return mTimelineItems.size(); } // replace the data set public void setTimelineItems(ArrayList<TimelineItem> timelineItems) { this.mTimelineItems = timelineItems; } // replace an item in the data set public void swapTimelineItemAtPosition(TimelineItem item, int position) { mTimelineItems.remove(position); mTimelineItems.add(position, item); notifyItemChanged(position); } // the ViewHolder class containing the relevant views, // also binds the Timeline item itself to handle onClick events public class TimelineItemViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { protected TextView dayOfWeek; protected TextView dayOfMonth; protected TextView showPreviousMonthsText; protected TextView name; protected TextView place; protected TextView combinedPlace; protected TextView goingTo; protected TextView startTime; protected TextView endTime; protected TimelineItem timelineItem; public TimelineItemViewHolder(View view) { super(view); view.setOnClickListener(this); this.dayOfWeek = (TextView) view.findViewById(R.id.day_of_week); this.dayOfMonth = (TextView) view.findViewById(R.id.day_of_month); this.showPreviousMonthsText = (TextView) view.findViewById(R.id.load_previous_data); this.name = (TextView) view.findViewById(R.id.name); this.place = (TextView) view.findViewById(R.id.place); this.combinedPlace = (TextView) view.findViewById(R.id.combined_place); this.goingTo = (TextView) view.findViewById(R.id.going_to); this.startTime = (TextView) view.findViewById(R.id.start_time); this.endTime = (TextView) view.findViewById(R.id.end_time); } public void bindTimelineItem(TimelineItem item) { timelineItem = item; } // handles the onClick of a TimelineItem @Override public void onClick(View v) { // if the TimelineItem is a ShowPreviousMonthsItem if (timelineItem.isShowPreviousMonthsItem) { BusProvider.getInstance().post(new ShowPreviousMonthsRequest()); } // if the TimelineItem is a PlaceholderItem else if (timelineItem.isPlaceholderItem) { Toast.makeText(v.getContext(), "(no details)", Toast.LENGTH_SHORT).show(); } // else the TimelineItem is an actual event else { Toast.makeText(v.getContext(), "eventId = " + timelineItem.eventId, Toast.LENGTH_SHORT).show(); } } }
And this is the method that runs in TimelineFragment when a change occurs on the event bus:
@Subscribe public void onTimelineItemChanged(TimelineItemChangedNotification notification) { int position = notification.position; Log.d(TAG, "TimelineItemChanged detected for position " + position); mAdapter.swapTimelineItemAtPosition(mTimeline.mTimelineItems.get(position), position); mAdapter.notifyItemChanged(position); Log.d(TAG, "Item for position " + position + " swapped"); }
It should be noted that the adapter dataset seems to be correctly displayed after I scroll far away from the changed data far enough and return to the position after that. Initially, the user interface is completely confused.
EDIT:
I found that adding
mAdapter.notifyItemRangeChanged(position, mAdapter.getItemCount());
fixes the problem, but unfortunately sets the scroll position in what changes: (
Here is my TimelineFragment:
/** * Fragment displaying a Timeline using a RecyclerView */ public class TimelineFragment extends BackHandledFragment { // DEBUG flag and TAG private static final boolean DEBUG = false; private static final String TAG = TimelineFragment.class.getSimpleName(); // variables protected RecyclerView mRecyclerView; protected TimelineAdapter mAdapter; protected LinearLayoutManager mLinearLayoutManager; protected Timeline mTimeline; protected MenuItem mMenuItemScroll2Today; protected MenuItem mMenuItemReload; protected String mToolbarTitle; // TODO: get the value of this boolean from the shared preferences private boolean mUseTimelineItemDividers = true; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // get a handle to the app Timeline singleton mTimeline = Timeline.getInstance(); setHasOptionsMenu(true); } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View rootView = inflater.inflate(R.layout.fragment_timeline, container, false); rootView.setTag(TAG); mRecyclerView = (RecyclerView) rootView.findViewById(R.id.timeline_list); mRecyclerView.hasFixedSize(); // LinearLayoutManager constructor mLinearLayoutManager = new LinearLayoutManager(getActivity()); // set the layout manager setRecyclerViewLayoutManager(); // adapter constructor mAdapter = new TimelineAdapter(mTimeline.mTimelineItems); // set the adapter for the RecyclerView. mRecyclerView.setAdapter(mAdapter); // add lines between the different items if using them if (mUseTimelineItemDividers) { RecyclerView.ItemDecoration itemDecoration = new TimelineItemDivider(this.getContext()); mRecyclerView.addItemDecoration(itemDecoration); } // add the onScrollListener mRecyclerView.addOnScrollListener(new TimelineOnScrollListener(mLinearLayoutManager) { // when the first visible item on the Timeline changes, // adjust the Toolbar title accordingly @Override public void onFirstVisibleItemChanged(int position) { mTimeline.mCurrentScrollPosition = position; try { String title = mTimeline.mTimelineItems .get(position).date .toString(TimelineConfig.TOOLBAR_DATE_FORMAT); // if mToolbarTitle is null, set it to the new title and post on bus if (mToolbarTitle == null) { if (DEBUG) Log.d(TAG, "mToolbarTitle is null - posting new title request on bus: " + title); mToolbarTitle = title; BusProvider.getInstance().post(new ChangeToolbarTitleRequest(mToolbarTitle)); } else { // if mToolbarTitle is not null // only post on the bus if the new title is different from the previous one if (!title.equals(mToolbarTitle)) { if (DEBUG) Log.d(TAG, "mToolbarTitle is NOT null, but new title detected - posting new title request on bus: " + title); mToolbarTitle = title; BusProvider.getInstance().post(new ChangeToolbarTitleRequest(mToolbarTitle)); } } } catch (NullPointerException e) { // if the onFirstVisibleItemChanged is called on a "ShowPreviousMonthsItem", // leave the title as it is } } }); return rootView; } /** * Set RecyclerView LayoutManager to the one given. */ public void setRecyclerViewLayoutManager() { int scrollPosition; // If a layout manager has already been set, get current scroll position. if (mRecyclerView.getLayoutManager() != null) { scrollPosition = ((LinearLayoutManager) mRecyclerView.getLayoutManager()) .findFirstCompletelyVisibleItemPosition(); } else { scrollPosition = mTimeline.mFirstPositionForToday; } mRecyclerView.setLayoutManager(mLinearLayoutManager); mLinearLayoutManager.scrollToPositionWithOffset(scrollPosition, 0); } // set additional menu items for the Timeline fragment @Override public void onPrepareOptionsMenu(Menu menu) { // scroll to today mMenuItemScroll2Today = menu.findItem(R.id.action_scroll2today); mMenuItemScroll2Today.setVisible(true); mMenuItemScroll2Today.setIcon(Timeline.getIconForDateTime(new DateTime())); mMenuItemScroll2Today.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() { @Override public boolean onMenuItemClick(MenuItem item) { // stop scrolling mRecyclerView.stopScroll(); // get today position int todaysPosition = mTimeline.mFirstPositionForToday; // scroll to today position mLinearLayoutManager.scrollToPositionWithOffset(todaysPosition, 0); return false; } }); // reload data from Hacklberry mMenuItemReload = menu.findItem(R.id.action_reload_from_hacklberry); mMenuItemReload.setVisible(true); mMenuItemReload.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() { @Override public boolean onMenuItemClick(MenuItem item) { // stop scrolling mRecyclerView.stopScroll(); // mTimeline.reloadDBForCurrentMonth(); mTimeline.loadEventsFromUninfinityDBAsync(mTimeline.mTimelineStart, mTimeline.mTimelineEnd); return false; } }); super.onPrepareOptionsMenu(menu); } @Override public void onResume() { super.onResume(); // if the Timeline has been invalidated, let AllInOneActivity know it needs to replace // this Fragment with a new one if (mTimeline.isInvalidated()) { Log.d(TAG, "posting TimelineInvalidatedNotification on the bus ..."); BusProvider.getInstance().post( new TimelineInvalidatedNotification()); } // fetch today menu icon if (mMenuItemScroll2Today != null) { if (DEBUG) Log.d(TAG, "fetching scroll2today menu icon"); mMenuItemScroll2Today.setIcon(Timeline.getIconForDateTime(new DateTime())); } } // from BackHandledFragment @Override public String getTagText() { return TAG; } // from BackHandledFragment @Override public boolean onBackPressed() { return false; } @Subscribe public void onHacklberryReloaded(HacklberryLoadedNotification notification) { resetReloading(); } // handles ShowPreviousMonthsRequests posted on the bus by the TimelineAdapter ShowPreviousMonthsItem onClick() @Subscribe public void onShowPreviousMonthsRequest(ShowPreviousMonthsRequest request) { // create an empty OnItemTouchListener to prevent the user from manipulating // the RecyclerView while it loads more data (would mess up the scroll position) EmptyOnItemTouchListener listener = new EmptyOnItemTouchListener(); // add it to the RecyclerView mRecyclerView.addOnItemTouchListener(listener); // load the previous months (= add the required TimelineItems) int newScrollToPosition = mTimeline.showPreviousMonths(); // pass the new data set to the TimelineAdapter mAdapter.setTimelineItems(mTimeline.mTimelineItems); // notify the adapter the data set has changed mAdapter.notifyDataSetChanged(); // scroll to the last scroll (updated) position mLinearLayoutManager.scrollToPositionWithOffset(newScrollToPosition, 0); } @Subscribe public void onTimelineItemChanged(TimelineItemChangeNotification notification) { int position = notification.position; Log.d(TAG, "TimelineItemChanged detected for position " + position); mAdapter.swapTimelineItemAtPosition(mTimeline.mTimelineItems.get(position), position); //mAdapter.notifyItemRangeChanged(position, position); Log.d(TAG, "Item for position " + position + " swapped"); }
I took a screenshot of the application after its first download. I will explain what happens during initialization:
- A timeline is created by filling in all days using PlaceholderItems (TimelineItem with date).
- Events are loaded from the database and converted to TimelineItems
- Whenever a new TimelineItem has been modified and ready, Timeline unloads the TimelineFragment via the otto bus to update the adapter dataset for that particular position with the new TimelineItem.
Here is a screenshot of what happens after the initial boot:
The timeline loads, but some items are inserted in the wrong position.

When scrolling and returning to a range of days that was not previously shown, all is well:

