diff --git a/ParseUI-Widget-Sample/src/main/java/com/parse/ui/widget/sample/ListActivity.java b/ParseUI-Widget-Sample/src/main/java/com/parse/ui/widget/sample/ListActivity.java index cf004bc..08a0595 100644 --- a/ParseUI-Widget-Sample/src/main/java/com/parse/ui/widget/sample/ListActivity.java +++ b/ParseUI-Widget-Sample/src/main/java/com/parse/ui/widget/sample/ListActivity.java @@ -3,6 +3,7 @@ import android.os.Bundle; import android.support.annotation.Nullable; import android.support.v7.app.AppCompatActivity; +import android.util.Log; import android.widget.ListView; import android.widget.Toast; @@ -16,6 +17,8 @@ public class ListActivity extends AppCompatActivity { + private static final String TAG = "ListActivity"; + @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -36,11 +39,12 @@ public ParseQuery create() { adapter.addOnQueryLoadListener(new ParseQueryAdapter.OnQueryLoadListener() { @Override public void onLoading() { - + Log.d(TAG, "loading"); } @Override public void onLoaded(List objects, Exception e) { + Log.d(TAG, "loaded"); if (e != null && e instanceof ParseException && ((ParseException) e).getCode() != ParseException.CACHE_MISS) { diff --git a/ParseUI-Widget/src/main/java/com/parse/ParseQueryAdapter.java b/ParseUI-Widget/src/main/java/com/parse/ParseQueryAdapter.java index 225e3e3..e241a6f 100644 --- a/ParseUI-Widget/src/main/java/com/parse/ParseQueryAdapter.java +++ b/ParseUI-Widget/src/main/java/com/parse/ParseQueryAdapter.java @@ -32,17 +32,14 @@ import android.widget.LinearLayout; import android.widget.TextView; -import com.parse.ParseQuery.CachePolicy; +import com.parse.widget.util.ParseQueryPager; import java.util.ArrayList; -import java.util.Collections; import java.util.Iterator; import java.util.List; -import java.util.Set; import java.util.WeakHashMap; -import java.util.concurrent.ConcurrentHashMap; -import bolts.Capture; +import bolts.CancellationTokenSource; /** * A {@code ParseQueryAdapter} handles the fetching of objects by page, and displaying objects as @@ -112,15 +109,23 @@ public interface OnQueryLoadListener { void onLoaded(List objects, Exception e); } + private final Object lock = new Object(); + private ParseQueryPager pager; + private CancellationTokenSource cts; + + //region Backwards compatibility + private ParseQuery query; + private int objectsPerPage = 25; + //endregion + + private Integer itemResourceId; + // The key to use to display on the cell text label. private String textKey; // The key to use to fetch an image for display in the cell's image view. private String imageKey; - // The number of objects to show per page (default: 25) - private int objectsPerPage = 25; - // Whether the table should use the built-in pagination feature (default: // true) private boolean paginationEnabled = true; @@ -142,24 +147,6 @@ public interface OnQueryLoadListener { private Context context; - private List objects = new ArrayList<>(); - - private Set runningQueries = - Collections.newSetFromMap(new ConcurrentHashMap()); - - - // Used to keep track of the pages of objects when using CACHE_THEN_NETWORK. When using this, - // the data will be flattened and put into the objects list. - private List> objectPages = new ArrayList<>(); - - private int currentPage = 0; - - private Integer itemResourceId; - - private boolean hasNextPage = true; - - private QueryFactory queryFactory; - private List> onQueryLoadListeners = new ArrayList<>(); @@ -277,7 +264,7 @@ public ParseQueryAdapter(Context context, QueryFactory queryFactory, int item private ParseQueryAdapter(Context context, QueryFactory queryFactory, Integer itemViewResource) { super(); this.context = context; - this.queryFactory = queryFactory; + query = queryFactory.create(); itemResourceId = itemViewResource; } @@ -290,13 +277,38 @@ public Context getContext() { return context; } + private ParseQueryPager getPager() { + synchronized (lock) { + if (pager == null) { + pager = new ParseQueryPager(query, objectsPerPage) { + @Override + protected ParseQuery createQuery(int page) { + // Workaround for backwards compatibility + ParseQuery query = new ParseQuery<>(getQuery()); + if (paginationEnabled) { + setPageOnQuery(page, query); + } + return query; + } + }; + cts = new CancellationTokenSource(); + } + + return pager; + } + } + + private List getObjects() { + return getPager().getObjects(); + } + /** {@inheritDoc} **/ @Override public T getItem(int index) { if (index == getPaginationCellRow()) { return null; } - return objects.get(index); + return getObjects().get(index); } /** {@inheritDoc} **/ @@ -337,18 +349,15 @@ public void unregisterDataSetObserver(DataSetObserver observer) { * Remove all elements from the list. */ public void clear() { - objectPages.clear(); - cancelAllQueries(); - syncObjectsWithPages(); - notifyDataSetChanged(); - currentPage = 0; - } - - private void cancelAllQueries() { - for (ParseQuery q : runningQueries) { - q.cancel(); + synchronized (lock) { + if (cts != null) { + cts.cancel(); + } + pager = null; + cts = null; } - runningQueries.clear(); + + notifyDataSetChanged(); } /** @@ -359,105 +368,39 @@ private void cancelAllQueries() { * {@code false}. */ public void loadObjects() { - loadObjects(0, true); + loadNextPage(true); } - private void loadObjects(final int page, final boolean shouldClear) { - final ParseQuery query = queryFactory.create(); - - if (objectsPerPage > 0 && paginationEnabled) { - setPageOnQuery(page, query); + private void loadNextPage(final boolean shouldClear) { + synchronized (lock) { + if (shouldClear && pager != null) { + cts.cancel(); + pager = null; + } } notifyOnLoadingListeners(); - // Create a new page - if (page >= objectPages.size()) { - objectPages.add(page, new ArrayList()); - } - - // In the case of CACHE_THEN_NETWORK, two callbacks will be called. Using this flag to keep - // track of the callbacks. - final Capture firstCallBack = new Capture<>(true); - - runningQueries.add(query); - - // TODO convert to Tasks and CancellationTokens - // (depends on https://github.com/ParsePlatform/Parse-SDK-Android/issues/6) - query.findInBackground(new FindCallback() { + getPager().loadNextPage(new FindCallback() { @Override - public void done(List foundObjects, ParseException e) { - if (!runningQueries.contains(query)) { + public void done(List results, ParseException e) { + if (results == null && e == null) { // cancelled return; } - // In the case of CACHE_THEN_NETWORK, two callbacks will be called. We can only remove the - // query after the second callback. - if (Parse.isLocalDatastoreEnabled() || - (query.getCachePolicy() != CachePolicy.CACHE_THEN_NETWORK) || - (query.getCachePolicy() == CachePolicy.CACHE_THEN_NETWORK && !firstCallBack.get())) { - runningQueries.remove(query); - } + // Backwards compatibility if ((!Parse.isLocalDatastoreEnabled() && - query.getCachePolicy() == CachePolicy.CACHE_ONLY) && + query.getCachePolicy() == ParseQuery.CachePolicy.CACHE_ONLY) && (e != null) && e.getCode() == ParseException.CACHE_MISS) { // no-op on cache miss return; } - if ((e != null) && - ((e.getCode() == ParseException.CONNECTION_FAILED) || - (e.getCode() != ParseException.CACHE_MISS))) { - hasNextPage = true; - } else if (foundObjects != null) { - if (shouldClear && firstCallBack.get()) { - runningQueries.remove(query); - cancelAllQueries(); - runningQueries.add(query); // allow 2nd callback - objectPages.clear(); - objectPages.add(new ArrayList()); - currentPage = page; - firstCallBack.set(false); - } - - // Only advance the page, this prevents second call back from CACHE_THEN_NETWORK to - // reset the page. - if (page >= currentPage) { - currentPage = page; + notifyDataSetChanged(); - // since we set limit == objectsPerPage + 1 - hasNextPage = (foundObjects.size() > objectsPerPage); - } - - if (paginationEnabled && foundObjects.size() > objectsPerPage) { - // Remove the last object, fetched in order to tell us whether there was a "next page" - foundObjects.remove(objectsPerPage); - } - - List currentPage = objectPages.get(page); - currentPage.clear(); - currentPage.addAll(foundObjects); - - syncObjectsWithPages(); - - // executes on the UI thread - notifyDataSetChanged(); - } - - notifyOnLoadedListeners(foundObjects, e); + notifyOnLoadedListeners(results, e); } - }); - } - - /** - * This is a helper function to sync the objects with objectPages. This is only used with the - * CACHE_THEN_NETWORK option. - */ - private void syncObjectsWithPages() { - objects.clear(); - for (List pageOfObjects : objectPages) { - objects.addAll(pageOfObjects); - } + }, cts.getToken()); } /** @@ -465,12 +408,7 @@ private void syncObjectsWithPages() { * changed. */ public void loadNextPage() { - if (objects.size() == 0 && runningQueries.size() == 0) { - loadObjects(0, false); - } - else { - loadObjects(currentPage + 1, false); - } + loadNextPage(false); } /** @@ -482,7 +420,7 @@ public void loadNextPage() { */ @Override public int getCount() { - int count = objects.size(); + int count = getObjects().size(); if (shouldShowPaginationCell()) { count++; @@ -689,7 +627,7 @@ public void setAutoload(boolean autoload) { return; } this.autoload = autoload; - if (this.autoload && !dataSetObservers.isEmpty() && objects.isEmpty()) { + if (this.autoload && !dataSetObservers.isEmpty() && getObjects().isEmpty()) { loadObjects(); } } @@ -725,11 +663,12 @@ private View getDefaultView(Context context) { } private int getPaginationCellRow() { - return objects.size(); + return getObjects().size(); } private boolean shouldShowPaginationCell() { - return paginationEnabled && objects.size() > 0 && hasNextPage; + ParseQueryPager pager = getPager(); + return paginationEnabled && pager.getObjects().size() > 0 && pager.hasNextPage(); } private void notifyOnLoadingListeners() { diff --git a/ParseUI-Widget/src/main/java/com/parse/widget/util/ParseQueryPager.java b/ParseUI-Widget/src/main/java/com/parse/widget/util/ParseQueryPager.java new file mode 100644 index 0000000..e6e08e3 --- /dev/null +++ b/ParseUI-Widget/src/main/java/com/parse/widget/util/ParseQueryPager.java @@ -0,0 +1,367 @@ +package com.parse.widget.util; + +import com.parse.FindCallback; +import com.parse.ParseException; +import com.parse.ParseObject; +import com.parse.ParseQuery; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import bolts.CancellationToken; +import bolts.Continuation; +import bolts.Task; +import bolts.TaskCompletionSource; + +/** + * A utility class to page through {@link ParseQuery} results. + * + * @param + */ +public class ParseQueryPager { + + private static final int DEFAULT_PAGE_SIZE = 25; + + private static Task> findAsync( + ParseQuery query, final CancellationToken ct) { + return query.findInBackground().continueWithTask(new Continuation, Task>>() { + @Override + public Task> then(Task> task) throws Exception { + if (ct != null && ct.isCancellationRequested()) { + return Task.cancelled(); + } + return task; + } + }); + } + + /** + * The callback that is called by {@link ParseQueryPager} when the results have changed. + * + * @param + */ + public interface OnObjectsChangedCallback { + /** + * Called whenever a change of unknown type has occurred, such as the entire list being set to + * new values. + * @param sender The changing pager. + */ + void onChanged(T sender); + + /** + * Called whenever one or more items have changed. + * @param sender The changing pager. + * @param positionStart The starting index that has changed. + * @param itemCount The number of items that have been changed. + */ + void onItemRangeChanged(T sender, int positionStart, int itemCount); + + /** + * Called whenever one or more items have been inserted into the result set. + * @param sender The changing pager. + * @param positionStart The starting index that has been inserted. + * @param itemCount The number of items that have been inserted. + */ + void onItemRangeInserted(T sender, int positionStart, int itemCount); + + /** + * Called whenever one or more items have been moved from the result set. + * @param sender The changing pager. + * @param fromPosition The position from which the items were moved. + * @param toPosition The destination position of the items. + * @param itemCount The number of items that have been inserted. + */ + void onItemRangeMoved(T sender, int fromPosition, int toPosition, int itemCount); + + /** + * Called whenever one or more items have been removed from the result set. + * @param sender The changing pager. + * @param positionStart The starting index that has been inserted. + * @param itemCount The number of items that have been inserted. + */ + void onItemRangeRemoved(T sender, int positionStart, int itemCount); + } + + private final ParseQuery query; + private final int pageSize; + private final List objects = new ArrayList<>(); + private final List unmodifiableObjects = Collections.unmodifiableList(objects); + private final List callbacks = new ArrayList<>(); + private final Object lock = new Object(); + + private int currentPage = -1; + private boolean hasNextPage = true; + private Task> loadNextPageTask; + + /** + * Constructs a new instance of {@code ParseQueryPager} with the specified query. + * + * @param query The query for this {@code ParseQueryPager}. + */ + public ParseQueryPager(ParseQuery query) { + this(query, DEFAULT_PAGE_SIZE); + } + + /** + * Constructs a new instance of {@code ParseQueryPager} with the specified query. + * + * @param query The query for this {@code ParseQueryPager}. + * @param pageSize The size of each page. + */ + public ParseQueryPager(ParseQuery query, int pageSize) { + this.query = new ParseQuery<>(query); + this.pageSize = pageSize; + } + + /** + * @return the query for this {@code ParseQueryPager}. + */ + public ParseQuery getQuery() { + return query; + } + + /** + * @return the size of each page for this {@code ParseQueryPager}. + */ + public int getPageSize() { + return pageSize; + } + + /** + * Returns the current page of the pager in the result set. + * + * The value is zero-based. When the row set is first returned the pager will be at position -1, + * which is before the first page. + * + * @return the current page. + */ + public int getCurrentPage() { + synchronized (lock) { + return currentPage; + } + } + + /** + * @return whether the pager has more pages. + */ + public boolean hasNextPage() { + synchronized (lock) { + return hasNextPage; + } + } + + /** + * @return whether the pager is currently loading the next page. + */ + public boolean isLoadingNextPage() { + synchronized (lock) { + return loadNextPageTask != null && !loadNextPageTask.isCompleted(); + } + } + + /** + * @return the loaded objects. + */ + public List getObjects() { + return unmodifiableObjects; + } + + public void addOnObjectsChangedCallback(OnObjectsChangedCallback callback) { + synchronized (lock) { + callbacks.add(callback); + } + } + + public void removeOnObjectsChangedCallback(OnObjectsChangedCallback callback) { + synchronized (lock) { + callbacks.remove(callback); + } + } + + @SuppressWarnings("unchecked") + private void notifyRangeChanged(int positionStart, int positionEnd) { + synchronized (lock) { + for (OnObjectsChangedCallback callback : callbacks) { + callback.onItemRangeChanged(this, positionStart, positionEnd); + } + } + } + + @SuppressWarnings("unchecked") + private void notifyRangeInserted(int positionStart, int positionEnd) { + synchronized (lock) { + for (OnObjectsChangedCallback callback : callbacks) { + callback.onItemRangeInserted(this, positionStart, positionEnd); + } + } + } + + private void setLoadNextPageTask(Task> task) { + synchronized (lock) { + loadNextPageTask = task.continueWithTask(new Continuation, Task>>() { + @Override + public Task> then(Task> task) throws Exception { + synchronized (lock) { + loadNextPageTask = null; + } + return task; + } + }); + } + } + + /** + * Returns a new instance of {@link ParseQuery} to be used to load the next page of results. + * + * Its limit should be one more than {@code pageSize} so that {@code hasNextPage} can be + * determined. + * + * @param page The page the query should load. + * @return a new instance of {@link ParseQuery}. + */ + protected ParseQuery createQuery(int page) { + ParseQuery query = new ParseQuery<>(getQuery()); + query.setSkip(getPageSize() * page); + // Limit is pageSize + 1 so we can detect if there are more pages + query.setLimit(getPageSize() + 1); + return query; + } + + // Note: This should not be called multiple times. + public Task> loadNextPage() { + return loadNextPage((CancellationToken) null); + } + + /** + * Loads the next page. + * + * The next page is defined by {@code currentPage + 1}. + * + * @param ct Token used to cancel the task. + * @return A {@link Task} that resolves to the result of the next page. + */ + public Task> loadNextPage(CancellationToken ct) { + if (!hasNextPage()) { + throw new IllegalStateException("Unable to load next page when there are no more pages available"); + } + + final int page = getCurrentPage() + 1; + + // TODO(grantland): Utilize query.findInBackground(CancellationToken) + final ParseQuery query = createQuery(page); + Task> task = findAsync(query, ct).continueWithTask(new Continuation, Task>>() { + @Override + public Task> then(Task> task) throws Exception { + if (task.isCancelled() || task.isFaulted()) { + return task; + } + + List results = task.getResult(); + onPage(query, page, results); + + return task; + } + }, Task.UI_THREAD_EXECUTOR); + + setLoadNextPageTask(task); + + return task; + } + + /** + * Loads the next page. + * + * The next page is defined by {@code currentPage + 1}. + * + * @param callback A {@code callback} that will be called with the result of the next page. + */ + public void loadNextPage(final FindCallback callback) { + loadNextPage(callback, null); + } + + /** + * Loads the next page. + * + * The next page is defined by {@code currentPage + 1}. + * + * @param callback A {@code callback} that will be called with the result of the next page. + * @param ct Token used to cancel the task. + */ + public void loadNextPage(final FindCallback callback, final CancellationToken ct) { + if (!hasNextPage()) { + throw new IllegalStateException("Unable to load next page when there are no more pages available"); + } + + final int page = getCurrentPage() + 1; + + final TaskCompletionSource> tcs = new TaskCompletionSource<>(); + final ParseQuery query = createQuery(page); + query.findInBackground(new FindCallback() { + + AtomicInteger callbacks = new AtomicInteger(); + + @Override + public void done(List results, ParseException e) { + boolean isCancelled = ct != null && ct.isCancellationRequested(); + if (!isCancelled && e == null) { + onPage(query, page, results); + } + + boolean isCacheThenNetwork = false; + try { + ParseQuery.CachePolicy policy = getQuery().getCachePolicy(); + isCacheThenNetwork = policy == ParseQuery.CachePolicy.CACHE_THEN_NETWORK; + } catch (IllegalStateException ex) { + // do nothing, LDS is enabled and we can't use CACHE_THEN_NETWORK + } + if (!isCacheThenNetwork || callbacks.incrementAndGet() >= 2) { + if (isCancelled) { + tcs.trySetCancelled(); + } else { + tcs.trySetResult(results); + } + } + + callback.done(results, e); + } + }); + + setLoadNextPageTask(tcs.getTask()); + } + + private void onPage(ParseQuery query, int page, List results) { + synchronized (lock) { + int itemCount = results.size(); + + currentPage = page; + int limit = query.getLimit(); + if (limit == -1 || limit == pageSize) { + // Backwards compatibility hack to support ParseQueryAdapter#setPaginationEnabled(false) + hasNextPage = false; + } else { + // We detect if there are more pages by setting the limit pageSize + 1 and we remove the extra + // if there are more pages. + hasNextPage = itemCount >= pageSize + 1; + if (itemCount > pageSize) { + results.remove(pageSize); + } + } + int objectsSize = objects.size(); + boolean inserted = true; + if (objectsSize > pageSize * page) { + inserted = false; + objects.subList(pageSize * page, Math.min(objectsSize, pageSize * (page + 1))).clear(); + } + objects.addAll(pageSize * page, results); + + int positionStart = pageSize * page; + if (inserted) { + notifyRangeInserted(positionStart, itemCount); + } else { + notifyRangeChanged(positionStart, itemCount); + } + } + } +}