RoboSwag-components/src/main/java/ru/touchin/roboswag/components/adapters/ObservableCollectionAdapter...

438 lines
16 KiB
Java

/*
* Copyright (c) 2015 RoboSwag (Gavriil Sitnikov, Vsevolod Ivanov)
*
* This file is part of RoboSwag library.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package ru.touchin.roboswag.components.adapters;
import android.os.Looper;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.StringRes;
import android.support.v7.widget.RecyclerView;
import android.view.View;
import android.view.ViewGroup;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import ru.touchin.roboswag.components.utils.LifecycleBindable;
import ru.touchin.roboswag.components.utils.UiUtils;
import ru.touchin.roboswag.core.log.Lc;
import ru.touchin.roboswag.core.observables.collections.Change;
import ru.touchin.roboswag.core.observables.collections.ObservableCollection;
import ru.touchin.roboswag.core.observables.collections.ObservableList;
import rx.Observable;
import rx.Subscription;
import rx.android.schedulers.AndroidSchedulers;
import rx.functions.Actions;
import rx.subjects.BehaviorSubject;
/**
* Created by Gavriil Sitnikov on 20/11/2015.
* Adapter based on {@link ObservableCollection} and providing some useful features like:
* - item-based binding by {@link #onBindItemToViewHolder(ViewHolder, int, Object)}} method;
* - item click listener setup by {@link #setOnItemClickListener(OnItemClickListener)};
* - allows to inform about footers/headers by overriding base create/bind methods and {@link #getHeadersCount()} plus {@link #getFootersCount()};
* - by default it is pre-loading items for collections like {@link ru.touchin.roboswag.core.observables.collections.loadable.LoadingMoreList}.
*
* @param <TItem> Type of items to bind to ViewHolders;
* @param <TItemViewHolder> Type of ViewHolders to show items.
*/
public abstract class ObservableCollectionAdapter<TItem, TItemViewHolder extends ObservableCollectionAdapter.ViewHolder>
extends RecyclerView.Adapter<BindableViewHolder> {
private static final int PRE_LOADING_COUNT = 10;
@NonNull
private final BehaviorSubject<ObservableCollection<TItem>> observableCollectionSubject
= BehaviorSubject.create((ObservableCollection<TItem>) null);
@NonNull
private final LifecycleBindable lifecycleBindable;
@Nullable
private OnItemClickListener<TItem> onItemClickListener;
private int lastUpdatedChangeNumber = -1;
@NonNull
private final Observable historyPreLoadingObservable;
@NonNull
private final ObservableList<TItem> innerCollection = new ObservableList<>();
private boolean anyChangeApplied;
@NonNull
private final List<RecyclerView> attachedRecyclerViews = new LinkedList<>();
public ObservableCollectionAdapter(@NonNull final LifecycleBindable lifecycleBindable) {
super();
this.lifecycleBindable = lifecycleBindable;
innerCollection.observeChanges().subscribe(this::onItemsChanged);
lifecycleBindable.untilDestroy(observableCollectionSubject
.doOnNext(collection -> innerCollection.set(collection != null ? collection.getItems() : new ArrayList<>()))
.<ObservableCollection.CollectionChange<TItem>>switchMap(observableCollection -> observableCollection != null
? observableCollection.observeChanges().observeOn(AndroidSchedulers.mainThread())
: Observable.empty()),
changes -> {
anyChangeApplied = true;
for (final Change<TItem> change : changes.getChanges()) {
switch (change.getType()) {
case INSERTED:
innerCollection.addAll(change.getStart(), change.getChangedItems());
break;
case CHANGED:
innerCollection.update(change.getStart(), change.getChangedItems());
break;
case REMOVED:
innerCollection.remove(change.getStart(), change.getCount());
break;
default:
Lc.assertion("Not supported " + change.getType());
break;
}
}
});
historyPreLoadingObservable = observableCollectionSubject
.switchMap(observableCollection -> {
final int size = observableCollection.size();
return observableCollection.loadRange(size, size + PRE_LOADING_COUNT);
});
}
/**
* Returns if any change of source collection applied to adapter.
* It's important to not show some footers or header before first change have applied.
*
* @return True id any change applied.
*/
public boolean isAnyChangeApplied() {
return anyChangeApplied;
}
@Override
public void onAttachedToRecyclerView(@NonNull final RecyclerView recyclerView) {
super.onAttachedToRecyclerView(recyclerView);
attachedRecyclerViews.add(recyclerView);
}
private boolean anyRecyclerViewShown() {
for (final RecyclerView recyclerView : attachedRecyclerViews) {
if (recyclerView.isShown()) {
return true;
}
}
return false;
}
@Override
public void onDetachedFromRecyclerView(@NonNull final RecyclerView recyclerView) {
super.onDetachedFromRecyclerView(recyclerView);
attachedRecyclerViews.remove(recyclerView);
}
/**
* Returns parent {@link LifecycleBindable} (Activity/ViewController etc.).
*
* @return Parent {@link LifecycleBindable}.
*/
@NonNull
public LifecycleBindable getLifecycleBindable() {
return lifecycleBindable;
}
/**
* Returns {@link ObservableCollection} which provides items and it's changes.
*
* @return Inner {@link ObservableCollection}.
*/
@Nullable
public ObservableCollection<TItem> getObservableCollection() {
return observableCollectionSubject.getValue();
}
/**
* Method to observe {@link ObservableCollection} which provides items and it's changes.
*
* @return Observable of inner {@link ObservableCollection}.
*/
@NonNull
public Observable<ObservableCollection<TItem>> observeObservableCollection() {
return observableCollectionSubject.distinctUntilChanged();
}
/**
* Sets {@link ObservableCollection} which will provide items and it's changes.
*
* @param observableCollection Inner {@link ObservableCollection}.
*/
public void setObservableCollection(@Nullable final ObservableCollection<TItem> observableCollection) {
this.observableCollectionSubject.onNext(observableCollection);
refreshUpdate();
}
/**
* Simply sets items.
*
* @param items Items to set.
*/
public void setItems(@NonNull final Collection<TItem> items) {
setObservableCollection(new ObservableList<>(items));
}
/**
* Calls when collection changes.
*
* @param collectionChange Changes of collection.
*/
protected void onItemsChanged(@NonNull final ObservableCollection.CollectionChange<TItem> collectionChange) {
if (Looper.myLooper() != Looper.getMainLooper()) {
Lc.assertion("Items changes called on not main thread");
return;
}
if (!anyChangeApplied || !anyRecyclerViewShown()) {
anyChangeApplied = true;
refreshUpdate();
return;
}
if (collectionChange.getNumber() != innerCollection.getChangesCount()
|| collectionChange.getNumber() != lastUpdatedChangeNumber + 1) {
if (lastUpdatedChangeNumber < collectionChange.getNumber()) {
refreshUpdate();
}
return;
}
notifyAboutChanges(collectionChange.getChanges());
lastUpdatedChangeNumber = innerCollection.getChangesCount();
}
private void refreshUpdate() {
notifyDataSetChanged();
lastUpdatedChangeNumber = innerCollection.getChangesCount();
}
private void notifyAboutChanges(@NonNull final Collection<Change<TItem>> changes) {
for (final Change change : changes) {
switch (change.getType()) {
case INSERTED:
notifyItemRangeInserted(change.getStart() + getHeadersCount(), change.getCount());
break;
case CHANGED:
notifyItemRangeChanged(change.getStart() + getHeadersCount(), change.getCount());
break;
case REMOVED:
if (getItemCount() - getHeadersCount() == 0) {
//TODO: bug of recyclerview?
notifyDataSetChanged();
} else {
notifyItemRangeRemoved(change.getStart() + getHeadersCount(), change.getCount());
}
break;
default:
Lc.assertion("Not supported " + change.getType());
break;
}
}
}
/**
* Returns headers count goes before items.
*
* @return Headers count.
*/
protected int getHeadersCount() {
return 0;
}
/**
* Returns footers count goes after items and headers.
*
* @return Footers count.
*/
protected int getFootersCount() {
return 0;
}
@Override
public int getItemCount() {
return getHeadersCount() + innerCollection.size() + getFootersCount();
}
@NonNull
@Override
public abstract BindableViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType);
@SuppressWarnings({"unchecked", "deprecation"})
@Override
public void onBindViewHolder(@NonNull final BindableViewHolder holder, final int position) {
lastUpdatedChangeNumber = innerCollection.getChangesCount();
final int itemPosition = position - getHeadersCount();
if (itemPosition < 0 || itemPosition >= innerCollection.size()) {
return;
}
final TItemViewHolder itemViewHolder;
try {
itemViewHolder = (TItemViewHolder) holder;
} catch (final ClassCastException exception) {
Lc.assertion(exception);
return;
}
final TItem item = innerCollection.get(itemPosition);
itemViewHolder.setAdapter(this);
onBindItemToViewHolder(itemViewHolder, position, item);
itemViewHolder.bindPosition(position);
if (onItemClickListener != null && !isOnClickListenerDisabled(item)) {
UiUtils.setOnRippleClickListener(holder.itemView, () -> onItemClickListener.onItemClicked(item, position), getItemClickDelay());
}
}
/**
* Method to bind item (from {@link #getObservableCollection()}) to item-specific ViewHolder.
* It is not calling for headers and footer which counts are returned by {@link #getHeadersCount()} and @link #getFootersCount()}.
* You don't need to override this method if you have delegates for every view type.
*
* @param holder ViewHolder to bind item to;
* @param position Position of ViewHolder (NOT item!);
* @param item Item returned by position (WITH HEADER OFFSET!).
*/
protected void onBindItemToViewHolder(@NonNull final TItemViewHolder holder, final int position, @NonNull final TItem item) {
//do nothing by default
}
@Nullable
public TItem getItem(final int position) {
final int positionInList = position - getHeadersCount();
return positionInList < 0 || positionInList >= innerCollection.size() ? null : innerCollection.get(positionInList);
}
/**
* Sets item click listener.
*
* @param onItemClickListener Item click listener.
*/
public void setOnItemClickListener(@Nullable final OnItemClickListener<TItem> onItemClickListener) {
this.onItemClickListener = onItemClickListener;
refreshUpdate();
}
/**
* Returns delay of item click. By default returns delay of ripple effect duration.
*
* @return Milliseconds delay of click.
*/
protected long getItemClickDelay() {
return UiUtils.RIPPLE_EFFECT_DELAY;
}
/**
* Returns if click listening disabled or not for specific item.
*
* @param item Item to check click availability;
* @return True if click listener enabled for such item.
*/
public boolean isOnClickListenerDisabled(@NonNull final TItem item) {
return false;
}
/**
* Interface to simply add item click listener.
*
* @param <TItem> Type of item
*/
public interface OnItemClickListener<TItem> {
/**
* Calls when item have clicked.
*
* @param item Clicked item;
* @param position Position of clicked item.
*/
void onItemClicked(@NonNull TItem item, int position);
}
/**
* Base item ViewHolder that have included pre-loading logic.
*/
public static class ViewHolder extends BindableViewHolder {
//it is needed to avoid massive requests on initial view holders attaching (like if we will add 10 items they all will try to load history)
private static final long DELAY_BEFORE_LOADING_HISTORY = TimeUnit.SECONDS.toMillis(1);
@Nullable
private Subscription historyPreLoadingSubscription;
@Nullable
private ObservableCollectionAdapter adapter;
public ViewHolder(@NonNull final LifecycleBindable baseBindable, @NonNull final View itemView) {
super(baseBindable, itemView);
}
/**
* Bind position to enable pre-loading for connected {@link ObservableCollection}.
*
* @param position Position of ViewHolder.
*/
@SuppressWarnings("unchecked")
//unchecked: it's ok, we just need to load something more
public void bindPosition(final int position) {
if (historyPreLoadingSubscription != null) {
historyPreLoadingSubscription.unsubscribe();
historyPreLoadingSubscription = null;
}
if (adapter != null && position - adapter.getHeadersCount() > adapter.innerCollection.size() - PRE_LOADING_COUNT) {
historyPreLoadingSubscription = untilDestroy(adapter.historyPreLoadingObservable
.delaySubscription(DELAY_BEFORE_LOADING_HISTORY, TimeUnit.MILLISECONDS)
.onErrorResumeNext(Observable.empty()), Actions.empty());
}
}
@SuppressWarnings("PMD.DefaultPackage")
@Deprecated
//it is for internal use only
void setAdapter(@Nullable final ObservableCollectionAdapter adapter) {
this.adapter = adapter;
}
/**
* Simply get String from resources.
*
* @param stringRes string resource id;
* @return Requested String that matches with provided string resource id.
*/
@NonNull
public String getString(@StringRes final int stringRes) {
return itemView.getContext().getString(stringRes);
}
/**
* Simply get String from resources with arguments.
*
* @param stringRes String resource id;
* @param formatArgs The format arguments that will be used for substitution;
* @return Requested String that matches with provided string resource id.
*/
@NonNull
public String getString(@StringRes final int stringRes, @Nullable final Object... formatArgs) {
return itemView.getContext().getString(stringRes, formatArgs);
}
}
}