package com.airbnb.epoxy;
import android.os.Bundle;
import android.os.Handler;
import android.support.annotation.Nullable;
import android.support.v7.widget.GridLayoutManager.SpanSizeLookup;
import android.support.v7.widget.RecyclerView;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Set;
import static com.airbnb.epoxy.ControllerHelperLookup.getHelperForController;
/**
* A controller for easily combining {@link EpoxyModel} instances in a {@link RecyclerView.Adapter}.
* Simply implement {@link #buildModels()} to declare which models should be used, and in which
* order. Call {@link #requestModelBuild()} whenever your data changes, and the controller will call
* {@link #buildModels()}, update the adapter with the new models, and notify any changes between
* the new and old models.
* <p>
* The controller maintains a {@link android.support.v7.widget.RecyclerView.Adapter} with the latest
* models, which you can get via {@link #getAdapter()} to set on your RecyclerView.
* <p>
* All data change notifications are applied automatically via Epoxy's diffing algorithm. All of
* your models must have a unique id set on them for diffing to work. You may choose to use {@link
* AutoModel} annotations to have the controller create models with unique ids for you
* automatically.
* <p>
* Once a model is created and added to the controller in {@link #buildModels()} it should be
* treated as immutable and never modified again. This is necessary for adapter updates to be
* accurate.
*/
public abstract class EpoxyController {
private static final Timer NO_OP_TIMER = new NoOpTimer();
private final EpoxyControllerAdapter adapter = new EpoxyControllerAdapter(this);
private final ControllerHelper helper = getHelperForController(this);
private final Handler handler = new Handler();
private final List<Interceptor> interceptors = new ArrayList<>();
private ControllerModelList modelsBeingBuilt;
private boolean filterDuplicates;
/** Used to time operations and log their duration when in debug mode. */
private Timer timer = NO_OP_TIMER;
private EpoxyDiffLogger debugObserver;
private boolean hasBuiltModelsEver;
private List<ModelInterceptorCallback> modelInterceptorCallbacks;
private int recyclerViewAttachCount = 0;
private EpoxyModel<?> stagedModel;
/**
* Call this to request a model update. The controller will schedule a call to {@link
* #buildModels()} so that models can be rebuilt for the current data. All calls after the first
* are posted and debounced so that the calling code need not worry about calling this multiple
* times in a row.
*/
public void requestModelBuild() {
if (isBuildingModels()) {
throw new IllegalEpoxyUsage("Cannot call `requestModelBuild` from inside `buildModels`");
}
// If it is the first time building models then we do it right away, otherwise we post the call.
// We want to do it right away the first time so that scroll position can be restored correctly,
// shared element transitions aren't delayed, and content is shown asap. We post later calls
// so that they are debounced, and so any updates to data can be completely finished before
// the models are built.
if (hasBuiltModelsEver) {
requestDelayedModelBuild(0);
} else {
cancelPendingModelBuild();
dispatchModelBuild();
}
}
/**
* Call this to request a delayed model update. The controller will schedule a call to {@link
* #buildModels()} so that models can be rebuilt for the current data.
* <p>
* Using this to delay a model update may be helpful in cases where user input is causing many
* rapid changes in the models, such as typing. In that case, the view is already updated on
* screen and constantly rebuilding models is potentially slow and unnecessary. The downside to
* delaying the model build too long is that models will not be in sync with the data or view, and
* scrolling the view offscreen and back onscreen will cause the model to bind old data.
* <p>
* This will cancel any currently queued request to build models.
* <p>
* In most cases you should use {@link #requestModelBuild()} instead of this.
*
* @param delayMs The time in milliseconds to delay the model build by. Should be greater than or
* equal to 0. Even if a delay of 0 is given the model build will be posted to the
* next frame.
*/
public void requestDelayedModelBuild(int delayMs) {
if (isBuildingModels()) {
throw new IllegalEpoxyUsage(
"Cannot call `requestDelayedModelBuild` from inside `buildModels`");
}
cancelPendingModelBuild();
handler.postDelayed(buildModelsRunnable, delayMs);
}
/**
* Cancels a pending call to {@link #buildModels()} if one has been queued by {@link
* #requestModelBuild()}.
*/
public void cancelPendingModelBuild() {
handler.removeCallbacks(buildModelsRunnable);
}
private final Runnable buildModelsRunnable = new Runnable() {
@Override
public void run() {
dispatchModelBuild();
}
};
private void dispatchModelBuild() {
helper.resetAutoModels();
modelsBeingBuilt = new ControllerModelList(getExpectedModelCount());
timer.start();
buildModels();
addCurrentlyStagedModelIfExists();
timer.stop("Models built");
runInterceptors();
filterDuplicatesIfNeeded(modelsBeingBuilt);
modelsBeingBuilt.freeze();
timer.start();
adapter.setModels(modelsBeingBuilt);
timer.stop("Models diffed");
modelsBeingBuilt = null;
hasBuiltModelsEver = true;
}
/** An estimate for how many models will be built in the next {@link #buildModels()} phase. */
private int getExpectedModelCount() {
int currentModelCount = adapter.getItemCount();
return currentModelCount != 0 ? currentModelCount : 25;
}
/**
* Subclasses should implement this to describe what models should be shown for the current state.
* Implementations should call either {@link #add(EpoxyModel)}, {@link
* EpoxyModel#addTo(EpoxyController)}, or {@link EpoxyModel#addIf(boolean, EpoxyController)} with
* the models that should be shown, in the order that is desired.
* <p>
* Once a model is added to the controller it should be treated as immutable and never modified
* again. This is necessary for adapter updates to be accurate. If "validateEpoxyModelUsage" is
* enabled then runtime validations will be done to make sure models are not changed.
* <p>
* You CANNOT call this method directly. Instead, call {@link #requestModelBuild()} to have the
* controller schedule an update.
*/
protected abstract void buildModels();
int getFirstIndexOfModelInBuildingList(EpoxyModel<?> model) {
int size = modelsBeingBuilt.size();
for (int i = 0; i < size; i++) {
if (modelsBeingBuilt.get(i) == model) {
return i;
}
}
return -1;
}
boolean isModelAddedMultipleTimes(EpoxyModel<?> model) {
int modelCount = 0;
int size = modelsBeingBuilt.size();
for (int i = 0; i < size; i++) {
if (modelsBeingBuilt.get(i) == model) {
modelCount++;
}
}
return modelCount > 1;
}
void addAfterInterceptorCallback(ModelInterceptorCallback callback) {
if (!isBuildingModels()) {
throw new IllegalEpoxyUsage("Can only call when building models");
}
if (modelInterceptorCallbacks == null) {
modelInterceptorCallbacks = new ArrayList<>();
}
modelInterceptorCallbacks.add(callback);
}
/**
* Callbacks to each model for when interceptors are started and stopped, so the models know when
* to allow changes.
*/
interface ModelInterceptorCallback {
void onInterceptorsStarted(EpoxyController controller);
void onInterceptorsFinished(EpoxyController controller);
}
private void runInterceptors() {
if (!interceptors.isEmpty()) {
if (modelInterceptorCallbacks != null) {
for (ModelInterceptorCallback callback : modelInterceptorCallbacks) {
callback.onInterceptorsStarted(this);
}
}
timer.start();
for (Interceptor interceptor : interceptors) {
interceptor.intercept(modelsBeingBuilt);
}
timer.stop("Interceptors executed");
if (modelInterceptorCallbacks != null) {
for (ModelInterceptorCallback callback : modelInterceptorCallbacks) {
callback.onInterceptorsFinished(this);
}
// Interceptors are cleared so that future model builds don't notify past models
modelInterceptorCallbacks = null;
}
}
}
/** A callback that is run after {@link #buildModels()} completes and before diffing is run. */
public interface Interceptor {
/**
* This is called immediately after {@link #buildModels()} and before diffing is run and the
* models are set on the adapter. This is a final chance to make any changes to the the models
* added in {@link #buildModels()}. This may be useful for actions that act on all models in
* aggregate, such as toggling divider settings, or for cases such as rearranging models for an
* experiment.
* <p>
* The models list must not be changed after this method returns. Doing so will throw an
* exception.
*/
void intercept(List<EpoxyModel<?>> models);
}
/**
* Add an interceptor callback to be run after models are built, to make any last changes before
* they are set on the adapter. Interceptors are run in the order they are added.
*
* @see Interceptor#intercept(List)
*/
public void addInterceptor(Interceptor interceptor) {
interceptors.add(interceptor);
}
/** Remove an interceptor that was added with {@link #addInterceptor(Interceptor)}. */
public void removeInterceptor(Interceptor interceptor) {
interceptors.remove(interceptor);
}
/**
* Get the number of models added so far during the {@link #buildModels()} phase. It is only valid
* to call this from within that method.
* <p>
* This is different from the number of models currently on the adapter, since models on the
* adapter are not updated until after models are finished being built. To access current adapter
* count call {@link #getAdapter()} and {@link EpoxyControllerAdapter#getItemCount()}
*/
protected int getModelCountBuiltSoFar() {
if (!isBuildingModels()) {
throw new IllegalEpoxyUsage("Can only all this when inside the `buildModels` method");
}
return modelsBeingBuilt.size();
}
/**
* Add the model to this controller. Can only be called from inside {@link
* EpoxyController#buildModels()}.
*/
protected void add(EpoxyModel<?> model) {
model.addTo(this);
}
/**
* Add the models to this controller. Can only be called from inside {@link
* EpoxyController#buildModels()}.
*/
protected void add(EpoxyModel<?>... modelsToAdd) {
modelsBeingBuilt.ensureCapacity(modelsBeingBuilt.size() + modelsToAdd.length);
for (EpoxyModel<?> model : modelsToAdd) {
model.addTo(this);
}
}
/**
* Add the models to this controller. Can only be called from inside {@link
* EpoxyController#buildModels()}.
*/
protected void add(List<? extends EpoxyModel<?>> modelsToAdd) {
modelsBeingBuilt.ensureCapacity(modelsBeingBuilt.size() + modelsToAdd.size());
for (EpoxyModel<?> model : modelsToAdd) {
model.addTo(this);
}
}
/**
* Method to actually add the model to the list being built. Should be called after all
* validations are done.
*/
void addInternal(EpoxyModel<?> modelToAdd) {
if (!isBuildingModels()) {
throw new IllegalEpoxyUsage(
"You can only add models inside the `buildModels` methods, and you cannot call "
+ "`buildModels` directly. Call `requestModelBuild` instead");
}
if (modelToAdd.hasDefaultId()) {
throw new IllegalEpoxyUsage(
"You must set an id on a model before adding it. Use the @AutoModel annotation if you "
+ "want an id to be automatically generated for you.");
}
if (!modelToAdd.isShown()) {
throw new IllegalEpoxyUsage(
"You cannot hide a model in an EpoxyController. Use `addIf` to conditionally add a "
+ "model instead.");
}
// The model being added may not have been staged if it wasn't mutated before it was added.
// In that case we may have a previously staged model that still needs to be added.
clearModelFromStaging(modelToAdd);
modelToAdd.controllerToStageTo = null;
modelsBeingBuilt.add(modelToAdd);
}
/**
* Staging models allows them to be implicitly added after the user finishes modifying them. This
* means that if a user has modified a model, and then moves on to modifying a different model,
* the first model is automatically added as soon as the second model is modified.
* <p>
* There are some edge cases for handling models that are added without modification, or models
* that are modified but then fail an `addIf` check.
* <p>
* This only works for AutoModels, and only if implicity adding is enabled in configuration.
*/
void setStagedModel(EpoxyModel<?> model) {
if (model != stagedModel) {
addCurrentlyStagedModelIfExists();
}
stagedModel = model;
}
void addCurrentlyStagedModelIfExists() {
if (stagedModel != null) {
stagedModel.addTo(this);
}
stagedModel = null;
}
void clearModelFromStaging(EpoxyModel<?> model) {
if (stagedModel != model) {
addCurrentlyStagedModelIfExists();
}
stagedModel = null;
}
boolean isBuildingModels() {
return modelsBeingBuilt != null;
}
private void filterDuplicatesIfNeeded(List<EpoxyModel<?>> models) {
if (!filterDuplicates) {
return;
}
timer.start();
Set<Long> modelIds = new HashSet<>(models.size());
ListIterator<EpoxyModel<?>> modelIterator = models.listIterator();
while (modelIterator.hasNext()) {
EpoxyModel<?> model = modelIterator.next();
if (!modelIds.add(model.id())) {
int indexOfDuplicate = modelIterator.previousIndex();
modelIterator.remove();
int indexOfOriginal = findPositionOfDuplicate(models, model);
EpoxyModel<?> originalModel = models.get(indexOfOriginal);
if (indexOfDuplicate <= indexOfOriginal) {
// Adjust for the original positions of the models before the duplicate was removed
indexOfOriginal++;
}
onExceptionSwallowed(
new IllegalEpoxyUsage("Two models have the same ID. ID's must be unique!"
+ "\nOriginal has position " + indexOfOriginal + ":\n" + originalModel
+ "\nDuplicate has position " + indexOfDuplicate + ":\n" + model)
);
}
}
timer.stop("Duplicates filtered");
}
private int findPositionOfDuplicate(List<EpoxyModel<?>> models, EpoxyModel<?> duplicateModel) {
int size = models.size();
for (int i = 0; i < size; i++) {
EpoxyModel<?> model = models.get(i);
if (model.id() == duplicateModel.id()) {
return i;
}
}
throw new IllegalArgumentException("No duplicates in list");
}
/**
* If set to true, Epoxy will search for models with duplicate ids added during {@link
* #buildModels()} and remove any duplicates found. If models with the same id are found, the
* first one is left in the adapter and any subsequent models are removed. {@link
* #onExceptionSwallowed(RuntimeException)} will be called for each duplicate removed.
* <p>
* This may be useful if your models are created via server supplied data, in which case the
* server may erroneously send duplicate items. Duplicate items break Epoxy's diffing and would
* normally cause a crash, so filtering them out can make a production application more robust to
* server inconsistencies.
*/
public void setFilterDuplicates(boolean filterDuplicates) {
this.filterDuplicates = filterDuplicates;
}
/**
* If enabled, DEBUG logcat messages will be printed to show when models are rebuilt, the time
* taken to build them, the time taken to diff them, and the item change outcomes from the
* differ. The tag of the logcat message is your adapter name.
* <p>
* This is useful to verify that models are being diffed as expected, as well as to watch for
* slowdowns in model building or diffing to indicate when you should optimize model building or
* model hashCode/equals implementations (which can often slow down diffing).
* <p>
* This should only be used in debug builds to avoid a performance hit in prod.
*/
public void setDebugLoggingEnabled(boolean enabled) {
if (isBuildingModels()) {
throw new IllegalEpoxyUsage("Debug logging should be enabled before models are built");
}
if (enabled) {
timer = new DebugTimer(getClass().getSimpleName());
debugObserver = new EpoxyDiffLogger(getClass().getSimpleName());
adapter.registerAdapterDataObserver(debugObserver);
} else {
timer = NO_OP_TIMER;
if (debugObserver != null) {
adapter.unregisterAdapterDataObserver(debugObserver);
}
}
}
/**
* Get the underlying adapter built by this controller. Use this to get the adapter to set on a
* RecyclerView, or to get information about models currently in use.
*/
public EpoxyControllerAdapter getAdapter() {
return adapter;
}
public void onSaveInstanceState(Bundle outState) {
adapter.onSaveInstanceState(outState);
}
public void onRestoreInstanceState(@Nullable Bundle inState) {
adapter.onRestoreInstanceState(inState);
}
/**
* For use with a grid layout manager - use this to get the {@link SpanSizeLookup} for models in
* this controller. This will delegate span look up calls to each model's {@link
* EpoxyModel#getSpanSize(int, int, int)}. Make sure to also call {@link #setSpanCount(int)} so
* the span count is correct.
*/
public SpanSizeLookup getSpanSizeLookup() {
return adapter.getSpanSizeLookup();
}
/**
* If you are using a grid layout manager you must call this to set the span count of the grid.
* This span count will be passed on to the models so models can choose which span count to be.
*
* @see #getSpanSizeLookup()
* @see EpoxyModel#getSpanSize(int, int, int)
*/
public void setSpanCount(int spanCount) {
adapter.setSpanCount(spanCount);
}
public int getSpanCount() {
return adapter.getSpanCount();
}
public boolean isMultiSpan() {
return adapter.isMultiSpan();
}
/**
* This is called when recoverable exceptions happen at runtime. They can be ignored and Epoxy
* will recover, but you can override this to be aware of when they happen.
*/
protected void onExceptionSwallowed(RuntimeException exception) {
}
void onAttachedToRecyclerViewInternal(RecyclerView recyclerView) {
recyclerViewAttachCount++;
if (recyclerViewAttachCount > 1) {
onExceptionSwallowed(new IllegalStateException(
"Epoxy does not support attaching an adapter to more than one RecyclerView because "
+ "saved state will not work properly. If you did not intend to attach your adapter "
+ "to multiple RecyclerViews you may be leaking a "
+ "reference to a previous RecyclerView. Make sure to remove the adapter from any "
+ "previous RecyclerViews (eg if the adapter is reused in a Fragment across "
+ "multiple onCreateView/onDestroyView cycles). See https://github"
+ ".com/airbnb/epoxy/wiki/Avoiding-Memory-Leaks for more information."));
}
onAttachedToRecyclerView(recyclerView);
}
void onDetachedFromRecyclerViewInternal(RecyclerView recyclerView) {
recyclerViewAttachCount--;
onDetachedFromRecyclerView(recyclerView);
}
/** Called when the controller's adapter is attach to a recyclerview. */
protected void onAttachedToRecyclerView(RecyclerView recyclerView) {
}
/** Called when the controller's adapter is detached from a recyclerview. */
protected void onDetachedFromRecyclerView(RecyclerView recyclerView) {
}
/**
* Called immediately after a model is bound to a view holder. Subclasses can override this if
* they want alerts on when a model is bound. Alternatively you may attach a listener directly to
* a generated model with model.onBind(...)
*
* @param previouslyBoundModel If non null, this is a model with the same id as the newly bound
* model, and was previously bound to a view. This means that {@link
* #buildModels()} returned a model that is different from the
* previouslyBoundModel and the view is being rebound to incorporate
* the change. You can compare this previous model with the new one to
* see exactly what changed.
* <p>
* The newly bound model and the previously bound model are guaranteed
* to have the same id, but will not necessarily be of the same type
* depending on your implementation of {@link #buildModels()}. With
* common usage patterns of Epoxy they should be the same type, and
* will only differ if you are using different model classes with the
* same id.
* <p>
* Comparing the newly bound model with the previous model allows you
* to be more intelligent when updating your view. This may help you
* optimize, or make it easier to work with animations.
* <p>
* If the new model and the previous model have the same view type
* (given by {@link EpoxyModel#getViewType()}), and if you are using
* the default ReyclerView item animator, the same view will be kept.
* If you are using a custom item animator then the view will be the
* same if the animator returns true in canReuseUpdatedViewHolder.
* <p>
* This previously bound model is taken as a payload from the diffing
* process, and follows the same general conditions for all
* recyclerview change payloads.
*/
protected void onModelBound(EpoxyViewHolder holder, EpoxyModel<?> boundModel, int position,
@Nullable EpoxyModel<?> previouslyBoundModel) {
}
/**
* Called immediately after a model is unbound from a view holder. Subclasses can override this if
* they want alerts on when a model is unbound. Alternatively you may attach a listener directly
* to a generated model with model.onUnbind(...)
*/
protected void onModelUnbound(EpoxyViewHolder holder, EpoxyModel<?> model) {
}
/**
* Called when the given viewholder is attached to the window, along with the model it is bound
* to.
*
* @see BaseEpoxyAdapter#onViewAttachedToWindow(EpoxyViewHolder)
*/
protected void onViewAttachedToWindow(EpoxyViewHolder holder, EpoxyModel<?> model) {
}
/**
* Called when the given viewholder is detechaed from the window, along with the model it is bound
* to.
*
* @see BaseEpoxyAdapter#onViewDetachedFromWindow(EpoxyViewHolder)
*/
protected void onViewDetachedFromWindow(EpoxyViewHolder holder, EpoxyModel<?> model) {
}
}