package com.airbnb.epoxy; import android.support.annotation.LayoutRes; import android.view.View; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; import android.view.ViewStub; import com.airbnb.epoxy.EpoxyModelGroup.Holder; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.List; /** * An {@link EpoxyModel} that contains other models, and allows you to combine those models in * whatever view configuration you want. * <p> * The constructors take a list of models and a layout resource. The layout must have a viewgroup as * its top level view; it determines how the view of each model is laid out. There are two ways to * specify this * <p> * 1. Leave the viewgroup empty. The view for each model will be inflated and added in order. This * works fine if you don't need to include any other views, your model views don't need their layout * params changed, and your views don't need ids (eg for saving state). * <p> * 2. Include a {@link ViewStub} for each of the models in the list. There should be at least as * many view stubs as models. Extra stubs will be ignored. Each model will be inflated into a view * stub in order of the view stub's position in the view group. That is, the view group's children * will be iterated through in order. The first view stub found will be used for the first model in * the models list, the second view stub will be used for the second model, and so on. A depth first * recursive search through nested viewgroups is done to find these viewstubs. * <p> * The layout can be of any ViewGroup subclass, and can have arbitrary other child views besides the * view stubs. It can arrange the views and view stubs however is needed. * <p> * Any layout param options set on the view stubs will be transferred to the corresponding model * view by default. If you want a model to keep the layout params from it's own layout resource you * can override {@link #useViewStubLayoutParams(EpoxyModel, int)} * <p> * If an {@link EpoxyModelWithView} is used then the view created by that model will simply replace * its ViewStub instead of inflating the view stub with a resource. If layout params are set on the * view created by {@link EpoxyModelWithView#buildView(ViewGroup)} then those will be kept, * otherwise any layout params specified on the view stub will be transferred over as with normal * models. * <p> * If you want to override the id used for a model's view you can set {@link * ViewStub#setInflatedId(int)} via xml. That id will be transferred over to the view taking that * stub's place. This is necessary if you want your model to save view state, since without this the * model's view won't have an id to associate the saved state with. * <p> * By default this model inherits the same id as the first model in the list. Call {@link #id(long)} * to override that if needed. * <p> * The same number of models must always be used for a specific layout resource. This is because the * view stubs are only inflated once and then the view is recycled between groups with the same * layout. If you want to dynamically change what models are shown you can use {@link * EpoxyModel#hide()} to have the associated view be set to GONE. */ @SuppressWarnings("rawtypes") public class EpoxyModelGroup extends EpoxyModelWithHolder<Holder> { protected final List<? extends EpoxyModel<?>> models; /** By default we save view state if any of the models need to save state. */ private final boolean shouldSaveViewState; /** * @param layoutRes The layout to use with these models. * @param models The models that will be used to bind the views in the given layout. */ public EpoxyModelGroup(@LayoutRes int layoutRes, Collection<? extends EpoxyModel<?>> models) { this(layoutRes, new ArrayList<>(models)); } /** * @param layoutRes The layout to use with these models. * @param models The models that will be used to bind the views in the given layout. */ public EpoxyModelGroup(@LayoutRes int layoutRes, EpoxyModel<?>... models) { this(layoutRes, new ArrayList<>(Arrays.asList(models))); } /** * @param layoutRes The layout to use with these models. * @param models The models that will be used to bind the views in the given layout. */ private EpoxyModelGroup(@LayoutRes int layoutRes, List<? extends EpoxyModel<?>> models) { if (models.isEmpty()) { throw new IllegalArgumentException("Models cannot be empty"); } this.models = models; layout(layoutRes); id(models.get(0).id()); boolean saveState = false; for (EpoxyModel<?> model : models) { if (model.shouldSaveViewState()) { saveState = true; break; } } shouldSaveViewState = saveState; } @Override public final void bind(Holder holder) { iterateModels(holder, new IterateModelsCallback() { @Override public void onModel(EpoxyModel model, Object boundObject, View view) { setViewVisibility(model, view); //noinspection unchecked model.bind(boundObject); } }); } @Override public final void bind(Holder holder, final List<Object> payloads) { iterateModels(holder, new IterateModelsCallback() { @Override public void onModel(EpoxyModel model, Object boundObject, View view) { setViewVisibility(model, view); //noinspection unchecked model.bind(boundObject, payloads); } }); } private static void setViewVisibility(EpoxyModel model, View view) { if (model.isShown()) { view.setVisibility(View.VISIBLE); } else { view.setVisibility(View.GONE); } } @Override public final void unbind(Holder holder) { iterateModels(holder, new IterateModelsCallback() { @Override public void onModel(EpoxyModel model, Object boundObject, View view) { //noinspection unchecked model.unbind(boundObject); } }); } @Override public void onViewAttachedToWindow(Holder holder) { iterateModels(holder, new IterateModelsCallback() { @Override public void onModel(EpoxyModel model, Object boundObject, View view) { //noinspection unchecked model.onViewAttachedToWindow(boundObject); } }); } @Override public void onViewDetachedFromWindow(Holder holder) { iterateModels(holder, new IterateModelsCallback() { @Override public void onModel(EpoxyModel model, Object boundObject, View view) { //noinspection unchecked model.onViewDetachedFromWindow(boundObject); } }); } private void iterateModels(Holder holder, IterateModelsCallback callback) { int modelCount = models.size(); if (modelCount != holder.views.size()) { throw new IllegalStateException( "The number of models used in this group has changed. The model count must remain " + "constant if the same layout resource is used. If you need to change which models" + " are shown you can call EpoxyMode#hide() to have a model's view hidden, or use a" + " different layout resource for the group."); } for (int i = 0; i < modelCount; i++) { EpoxyModel model = models.get(i); View view = holder.views.get(i); EpoxyHolder epoxyHolder = holder.holders.get(i); Object objectToBind = (model instanceof EpoxyModelWithHolder) ? epoxyHolder : view; callback.onModel(model, objectToBind, view); } } private interface IterateModelsCallback { void onModel(EpoxyModel model, Object boundObject, View view); } @Override public int getSpanSize(int totalSpanCount, int position, int itemCount) { // Defaults to using the span size of the first model. Override this if you need to customize it return models.get(0).getSpanSize(totalSpanCount, position, itemCount); } @Override protected final int getDefaultLayout() { throw new UnsupportedOperationException( "You should set a layout with layout(...) instead of using this."); } @Override public boolean shouldSaveViewState() { // By default state is saved if any of the models have saved state enabled. // Override this if you need custom behavior. return shouldSaveViewState; } /** * Whether the layout params set on the view stub for the given model should be carried over to * the model's view. Default is true * <p> * Set this to false if you want the layout params on the model's layout resource to be kept. * * @param model The model who's view is being created * @param modelPosition The position of the model in the models list */ protected boolean useViewStubLayoutParams(EpoxyModel<?> model, int modelPosition) { return true; } @Override protected final Holder createNewHolder() { return new Holder(); } protected class Holder extends EpoxyHolder { private List<View> views; private List<EpoxyHolder> holders; @Override protected void bindView(View itemView) { if (!(itemView instanceof ViewGroup)) { throw new IllegalStateException( "The layout provided to EpoxyModelGroup must be a ViewGroup"); } ViewGroup groupView = (ViewGroup) itemView; int modelCount = models.size(); views = new ArrayList<>(modelCount); holders = new ArrayList<>(modelCount); boolean useViewStubs = groupView.getChildCount() != 0; for (int i = 0; i < models.size(); i++) { EpoxyModel model = models.get(i); View view; if (useViewStubs) { view = replaceNextViewStub(groupView, model, useViewStubLayoutParams(model, i)); } else { view = createAndAddView(groupView, model); } if (model instanceof EpoxyModelWithHolder) { EpoxyHolder holder = ((EpoxyModelWithHolder) model).createNewHolder(); holder.bindView(view); holders.add(holder); } else { holders.add(null); } views.add(view); } } private View createAndAddView(ViewGroup groupView, EpoxyModel<?> model) { View modelView = model.buildView(groupView); LayoutParams modelLayoutParams = modelView.getLayoutParams(); if (modelLayoutParams != null) { groupView.addView(modelView, modelLayoutParams); } else { groupView.addView(modelView); } return modelView; } private View replaceNextViewStub(ViewGroup groupView, EpoxyModel<?> model, boolean useStubLayoutParams) { ViewStubData stubData = getNextViewStubPosition(groupView); if (stubData == null) { throw new IllegalStateException( "Your layout should provide a ViewStub for each model to be inflated into."); } stubData.viewGroup.removeView(stubData.viewStub); View modelView = model.buildView(stubData.viewGroup); // Carry over the stub id manually since we aren't inflating via the stub int inflatedId = stubData.viewStub.getInflatedId(); if (inflatedId != View.NO_ID) { modelView.setId(inflatedId); } LayoutParams modelLayoutParams = modelView.getLayoutParams(); if (useStubLayoutParams) { stubData.viewGroup .addView(modelView, stubData.position, stubData.viewStub.getLayoutParams()); } else if (modelLayoutParams != null) { stubData.viewGroup.addView(modelView, stubData.position, modelLayoutParams); } else { stubData.viewGroup.addView(modelView, stubData.position); } return modelView; } private ViewStubData getNextViewStubPosition(ViewGroup viewGroup) { int childCount = viewGroup.getChildCount(); for (int i = 0; i < childCount; i++) { View child = viewGroup.getChildAt(i); if (child instanceof ViewGroup) { ViewStubData nestedResult = getNextViewStubPosition((ViewGroup) child); if (nestedResult != null) { return nestedResult; } } else if (child instanceof ViewStub) { return new ViewStubData(viewGroup, (ViewStub) child, i); } } return null; } } private static class ViewStubData { private final ViewGroup viewGroup; private final ViewStub viewStub; private final int position; private ViewStubData(ViewGroup viewGroup, ViewStub viewStub, int position) { this.viewGroup = viewGroup; this.viewStub = viewStub; this.position = position; } } @Override public boolean equals(Object o) { if (this == o) { return true; } if (!(o instanceof EpoxyModelGroup)) { return false; } if (!super.equals(o)) { return false; } EpoxyModelGroup that = (EpoxyModelGroup) o; return models.equals(that.models); } @Override public int hashCode() { int result = super.hashCode(); result = 31 * result + models.hashCode(); return result; } }