Skip to content

Generating Models from View Annotations

Eli Hart edited this page Jan 17, 2018 · 52 revisions

If you use custom views, an EpoxyModel can be completely generated for each view based on annotations in the view.

Basic Usage

To start, first annotate your custom view with @ModelView:

@ModelView(autoLayout = Size.MATCH_WIDTH_WRAP_HEIGHT)
public class MyView extends FrameLayout {
   ...
}

The Size enum passed to the autoLayout parameter determines which values to use for layout width and height when the view is created at runtime and added to the RecyclerView.

Alternatively, you can provide a custom layout like this:

@ModelView(defaultLayout = R.layout.my_view)

If a layout file is provided like this Epoxy will inflate it at runtime to create the view. This allows you to provide custom styling. The layout should contain only one child, whose type is your custom view.

In this case our layout might look like this:

<?xml version="1.0" encoding="utf-8"?>
<com.example.MyView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="#ff0000 />
The generated model

Once this annotation is added to the view and the project is built, a MyViewModel_ class will be generated in the same package. The name of this class is always the name of the view suffixed with Model_ to represent that it is generated.

This generated model can then be used in your EpoxyControllers.

Adding Properties

The generated model binds data to your view for you. To allow this, annotate each setter method in your view.

@ModelView(defaultLayout = R.layout.my_view)
public class MyView extends FrameLayout {
   ... // Initialization omitted

     @TextProp // Use this annotation for text.
     public void setTitle(CharSequence text) {
       titleView.setText(text);
     }

     @CallbackProp // Use this annotation for click listeners or other callbacks.
     public void clickListener(@Nullable OnClickListener listener) {
       setOnClickListener(listener
     }

     @ModelProp // Use this annotation for any other property types
     public void setBackgroundUrl(String url) {
       ...
     }
}

A setter method will be created on the generated model for each of these prop annotations.

When a view scrolls onto screen in the RecyclerView, Epoxy will take the values of each prop in the model and set them on the view.

If the models change while the view is on screen Epoxy will do an efficient partial update. For example, the MyViewModel_ checks the value of the url string and only updates the view when the url changes. If there are multiple props on the view and only a subset of them change, only the changed properties are updated.

Setters must have exactly one parameter, should have a void return type, and cannot be static or private.

Dependent Properties

Each setter can only take one parameter, and when binding a view Epoxy makes no guarantee for which order the setters are called in. This means that you cannot rely on one prop being set before another.

Instead, you may have your setters save their values to a field, and then use the @AfterPropsSet annotation on a method to have that method called after all props have been bound. You can then do your initialization in that method.

You can also annotate a (non private) field directly with @ModelProp to have Epoxy set that field value directly to avoid the overhead of creating a setter.

@ModelView(defaultLayout = R.layout.my_view)
public class MyView extends FrameLayout {
   ... // Initialization omitted

     @TextProp CharSequence text;
     @CallbackProp @Nullable OnClickListener clickListener;

     @AfterPropsSet
     void postBindSetup() {
       textView.setText(text);
       textView.setOnClickListener(clickListener);
     }
}

If there is demand we may support setters with multiple parameters (open an issue if you are interested in this).

String Resources

Epoxy will generate methods to use Android String and Plural resources if the GenerateStringOverloads option is included in the @ModelProp annotation. For example:

@ModelProp(options = Option.GenerateStringOverloads)
public void setTitle(CharSequence text) {

}

or alternatively the following can be used for convenience (it is interpreted the same way)

@TextProp
public void setTitle(CharSequence text) {

}

If this is used the parameter type must be CharSequence.

The generated model will have the following convenience methods for getting and setting the text:

  // The getter requires a context to resolve string resources
  public CharSequence getTitle(Context context) {
    ...
  }

  public TestViewModel_ title(CharSequence title) {
    ...
  }

  public TestViewModel_ title(@StringRes int stringRes) {
    ...
  }

  public TestViewModel_ title(@StringRes int stringRes, Object... formatArgs) {
    ...
  }

  public TestViewModel_ titleQuantityRes(@PluralsRes int pluralRes, int quantity,
      Object... formatArgs) {
    ...
  }

Setter Comments

Any javadoc on a ModelProp method will be included on the generated setter on the view's model. Additionally, the model's setter will have javadoc that specifies whether the prop is required or optional, what the default value is, and a link to the view's setter method.

Property Annotations

Any annotations included on a ModelProp setter parameter will be included on the generated model's method. This may be helpful for annotations such as @Nullable, @DimenRes, @FloatRange, and other Android support library annotations.

For example:

  @ModelProp
  void setCount(@IntRange(from = 0, to = 10) int value) {
   ...
  }

Optional and Default Values

There are a few options for specifying default values for props in order to make them optional.

For Objects

Props that are a subtype of Object (aka non-primitive types) are required by default. They can be made optional by adding a @Nullable annotation to the setter parameter. (Any annotation with the name "Nullable" will work)

@ModelProp
public void setImage(@Nullable MyObject param)

If this prop is not specified on the model then this method will be set to null when the view is bound.

For Primitives

Props that have a primitive type are always optional. If the user of the model does specify an explicit value for a prop then it will default to the default value of the primitive as specified by the Java Language.

For example, int will default to 0 and boolean will default to false.

It is not possible to make primitive props required.

For Strings

If you are using the @TextProp annotation you can specify a defaultRes parameter and pass a string resource to use as the default string value.

Custom Defaults

If you would like the default value to be something specific you can use the defaultValue annotation parameter for both Object and primitive prop types.

@ModelProp(defaultValue = "MY_DEFAULT")
public void setImage(MyObject param)

The string "MY_DEFAULT" represents a constant in the class that is named MY_DEFAULT. In this case Epoxy would expect that the view class define a field like static final MyObject MY_DEFAULT = foo; The constant must be static, final, and not private so Epoxy can reference it. The type of the constant must also match the type of the Prop.

The name of the constant must be used instead of referencing the constant directly since objects are not valid annotation parameters.

If the prop is an object and has both an @Nullable annotation and an explicit defaultValue set then the behavior is as follows:

  1. If no value for the prop is set on the model then the custom defaultValue will be applied
  2. If null is set for the prop value then null will be set on the view.

Planned Support

In the future we may support defining default values via method references so defaults can be dynamic and overridden by subclasses. Open an issue if this would be helpful for you.

Prop Groups

Sometimes you may want multiple setters to represent the same prop. This is common for overloaded setters, for example, you may allow setting an image via a url or via a drawable resource. Normally the generated model always sets every prop when the view is bound, but in this case setting both the url and drawable resource would lead to one of the values being overridden.

The solution is to tell Epoxy that the props are in the same "group". Only one prop per group will ever be set on the view. If the consumer of the model sets values for multiple props in the same group, only the last prop that is set is used.

Setters with the same method name are automatically placed in the same group. For example, the following method signatures would result in both props being placed in the same group.

@ModelProp
public void setImage(String url)

@ModelProp
public void setImage(@DrawableRes int drawableRes)

You can manually specify groups with the group parameter:

@ModelProp(group = "image")
public void setImageUrl(String url)

@ModelProp(group = "image")
public void setImageDrawable(@DrawableRes int drawableRes)

Since the drawableRes is a primitive, it has a default value of 0. Therefore, if no image is specified on the model, the view will have a default value set with setImageDrawable(0). However, you can specify an explicit default for the group with the standard default parameter:

@ModelProp(group = "image")
public void setImageUrl(String url)

@ModelProp(group = "image", defaultValue = "DEFAULT_DRAWABLE_RES")
public void setImageDrawable(@DrawableRes int drawableRes)

Only one prop per group can specify an explicit default value, otherwise an exception will be thrown at compile time.

If there are multiple primitive props in a group, and no explicit default value is set, one of the primitives will be set with its Java default value when the view is bound. (It is undefined which prop is chosen).

If one of the props in the group has a @Nullable annotation on its parameter, (and no explicit default is set), then that prop will be set to null as the group's default value.

If there are no primitive props in the group, and no props are @Nullable, then a value is required to be set for the group.

If there are primitive props in the group then it isn't possible to require a value to be set for the group, since primitives don't (yet) support required values.

Excluding Props from Model State

Every prop must have a type that implements equals and hashCode. This is necessary so that Epoxy can determine when the model's state has changed so it knows when to update the view. If a type does not provide an equals and hashCode implementation other than the default implementation on the Object class then an error will be thrown at compile time.

However, some common types, such as interfaces, do not implement equals and hashCode. A common example is View.OnClickListener. In the click listener case it is unlikely that the listener needs to contribute to model state; When models are rebuilt, a new anonymous class is usually created for the listener but it would normally have the same implementation. This is a very common pattern, and generally we only need to update the view if the listener goes from not being set to being set (and vice versa).

To accomplish this you can use the DoNotHash option.

  @ModelProp(options = Option.DoNotHash)
  public void setClickListener(@Nullable View.OnClickListener listener) {

  }

Epoxy will not use the equals and hashCode method on the listener object to calculate the model's state. However, the presence of the listener will change the model state. That is, if the listener goes from null to non null Epoxy will update the view with the new listener.

A good way to think about this is: "If my view is on screen and the hashCode of my prop changes, do I want the view to be updated accordingly?" If so, make sure the object's type implements equals and hashCode correctly, otherwise you can use DoNotHash

IgnoreRequireHashCode

Sometimes your prop must be a type of an interface or abstract class that doesn't yet implement equals or hashCode. However, you may know that at runtime the implementation of that type will implement equals and hashCode. In this case you still want Epoxy to use equals and hashCode to allow the prop to contribute to model state, but since the compile time type of the prop does not implement them Epoxy will throw an error.

To get around this you can use the option IgnoreRequireHashCode to indicate to Epoxy that you know the equals and hashCode implementations are missing, but to ignore that and use them anyway.

  @ModelProp(options = Option.IgnoreRequireHashCode)
  public void setObject(MyInterface myInterface) {
    ...
  }

An example case is generating objects with @AutoValue, where only the generated class implements equals and hashCode. (Since AutoValue is a common case, if Epoxy sees a type annotated with it it will actually allow the type to not have equals and hashCode.)

Alternatively, instead of using IgnoreRequireHashCode, you can simply add abstract stubs of equals and hashCode to your interface or abstract class to indicate to Epoxy that the runtime implementation of the prop type will correctly implement the methods.

Recycling Views

When a view is scrolled off screen it is unbound from its model and recycled by RecyclerView. If you would like to clean up resources at this time you can annotate one or more methods in the view with @OnViewRecycled.

@OnViewRecycled
public void clear(){
   ...
}

Any methods on the view with this annotations will be called when the view is unbound and recycled. This is a good time to release listeners (to prevent memory leaks) or large objects like bitmaps to free up memory. You could also use it to cancel running operations.

Methods with this annotation must not be private or static, and cannot have any parameters.

Automatically clearing nullable objects

If a prop is annotated with @Nullable then the NullOnRecycle option can be set on the prop's annotation to tell Epoxy to call the setter with a null value when the view is recycled.

  @ModelProp(options = Option.NullOnRecycle)
  public void setTitle(@Nullable CharSequence title) {
    ...
  }

This is a shortcut to manually doing this with OnViewRecycled like:

@OnViewRecycled
public void clear(){
   setTitle(null);
}

Both ways are equivalent, but the NullOnRecycle option is a nice shortcut for nullable props that you would like cleared. This can be helpful for objects like listeners or images.

Callback Props

If you are setting a listener or callback on the view you can use the @CallbackProp annotation instead of @ModelProp to automatically apply the NullOnRecycle and DoNotHash options, which are generally used with listeners.

This lets us write

@CallbackProp
public void setListener(@Nullable OnClickListener clickListener) {

}

instead of the longer

@ModelProp(options = {Option.NullOnRecycle, Option.DoNotHash})
public void setListener(@Nullable OnClickListener clickListener) {

}

This also helps enforce that all listeners are cleared when the view is recycled so there are no leaks.

This can only be used on params marked nullable.

Saved State

If your generated model should save view state (explained here) then you can set the saveViewState parameter to true in the ModelView annotation.

@ModelView(defaultLayout = R.layout.my_layout, saveViewState = true)
public class MyView extends View {
   ...
}

The generated model will then include this override

@Override
  public boolean shouldSaveViewState() {
    return true;
  }

Otherwise shouldSaveViewState will default to false.

Grid Span Size

By default, generated models will take up the full span count when used with a GridLayoutManager (see Grid Support for more details). If you would like them to instead take up a single span you can set the fullSpan parameter to false in the ModelView annotation.

@ModelView(defaultLayout = R.layout.my_layout, fullSpan = false)
public class MyView extends View {
   ...
}

If you would like more fine grain control over the model's span size you can set a SpanSizeOverrideCallback on the model when you use it in your EpoxyController.

model.spanSizeOverride(new SpanSizeOverrideCallback() {
      @Override
      public int getSpanSize(int totalSpanCount, int position, int itemCount) {
        return totalSpanCount / 2;
      }
    });

Base Models

By default, models generated for @ModelView views will extend EpoxyModel. However, you can specify a custom base class if you wish.

@ModelView(defaultLayout = R.layout.my_view, baseModelClass = CustomBaseModel.class))
public class MyView extends FrameLayout {
   ...
}

The base model must extend EpoxyModel, and could be something like this:

public abstract class CustomBaseModel<T extends View> extends EpoxyModel<T> {
   @EpoxyAttribute protected boolean baseModelBoolean;

  @Override
  public void bind(T view) {
    ...
  }
}

The base model can define EpoxyAttributes, which will result in the generated model including a setter, getter, and equals/hashCode entry. However, the base model is responsible for binding the attributes to the view however it needs.

If you would like the same base model used for all of your views you can set a package default. See Configuration for more details.

If a package default is set you can still override it by explicitly specifying a base model in a view.

View Interfaces

Since 2.7.0

If your view implements an interface, and at least one of the interface method implementations is annotated with a prop annotation, then the generated model will implement a new interface representing that view interface.

This allows generated models to take advantage of polymorphism; you can group them and set data by their common interface.

Example

Let's say a view implements this interface and the setClickable implementation is annotated with @ModelProp.

interface Clickable {
  void setClickable(boolean clickable);
  boolean isClickable();
}

A new ClickablModel_ interface will be generated, and the generated model will implement it. This interface will include just the setter, not the getter.

interface ClickableModel_ {
 ClickableModel_ clickable(boolean clickable);
 ClickableModel_ id(long id);
 // other standard model methods
}

Then you can cast different models to ClickableModel_ and access the shared props easily.

Details

The generated interface does not necessarily mirror the original view interface. Instead, Epoxy looks at all generated models with that view type, gets the subset of props that they all share, and makes the generated interface implement methods for those props.

The generated interface is in the same package as the view's interface.

The generated interface is the name of the original interface with Model_ as a suffix. If the interface declaration is nested in another class then the generated interface name will be prefixed with the name of the top level class, like TopLevelClass_InterfaceNameModel_.

Caveats

  • Does not support views inheriting interfaces
  • Does not support @ModelViews in other modules with the same interface
  • Does not support abstract views partially implementing an interface

These may be addressed in future releases if there is demand. Please open an issue if you need support :)

Configuration

The PackageModelViewConfig package annotation allows defining package wide configuration settings. These settings also apply to nested packages, unless a nested package defines its own PackageModelViewConfig settings.

To use this annotation, add a package-info.java file to the package containing your views, and annotated it with PackageModelViewConfig.

For example:

@PackageModelViewConfig(rClass = R.class)
package com.example.app;

import com.airbnb.epoxy.PackageModelViewConfig;
import com.example.app.R;

The only required parameter to the annotation is rClass, which defines the R class for the module.

Available Options

Default Base Model

You can specify a default base model to use for every view in the package with the optional defaultBaseModelClass parameter.

Default Layout Names

The defaultLayoutPattern parameter defines a layout naming scheme to expect for each view, so that the view's @ModelView annotation can omit the defaultLayout parameter.

defaultLayoutPattern accepts any string containing %s. %s represents the name of a given view in snake case.

For example:

@PackageModelViewConfig(rClass = R.class, defaultLayoutPattern = "view_holder_%s")
package com.example.app;

combined with the view

@ModelView
public class MyView extends FrameLayout {
   ...
}

would generate a model that uses a layout called R.layout.view_holder_my_view.

This is equivalent to manually defining the layout with

@ModelView(defaultLayout = R.layout.my_view)
public class MyView extends FrameLayout {
   ...
}

The package setting to provide a layout pattern helps omit code from each view and also helps to standardize naming conventions and expectations in your package.

Note You still have to manually create the layout file to declare the view's styling and layout params. This setting just saves you a step in linking the view with its appropriate layout.

Layout Overloads

Note: Kotlin classes require a workaround to use this. See below for more details

Set useLayoutOverloads to true to have Epoxy generate model methods for changing which layout file is used for the view. These are called "layout overloads".

If this is enabled, Epoxy looks for layout files whose name begins with the default layout name for a view. For example

@ModelView(defaultLayout = R.layout.my_view)
public class MyView extends FrameLayout {
   ...
}

would have a default layout of R.layout.my_view, and any other layout files that begin with my_view_ would be considered overloads for the model generated for MyView.

This is particularly useful for applying different styles to the view via the model.

So for this example, we might create the layout file R.layout.my_view_blue like this:

<?xml version="1.0" encoding="utf-8"?>
<com.example.MyView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/blue />

And with layout overloads enabled the MyViewModel_ would have these methods generated:

  @Override
  @LayoutRes
  protected int getDefaultLayout() {
    return R.layout.my_view;
  }

  public MyViewModel_ withBlueLayout() {
    layout(R.layout.my_view_blue);
    return this;
  }

This uses the same R.layout.my_view layout by default, but we can now optionally call myViewModel.withBlueLayout() to create a new view from the R.layout.my_view_blue to apply the blue styling.

Note that since Epoxy uses the layout file as the item view type, views created from different layout files won't be recycled together.

Layout overloads with Kotlin

Views written in Kotlin cannot use layout overloads if the layout is provided in the model view annotation like @ModelView(defaultLayout = R.layout.my_view). The layout name is unfortunately not preserved in the annotation processor so Epoxy cannot access it.

However, you can still use layout overloads if you use default layout names.

Set up your package info config annotation to enable layout overloads and specify your R class

@PackageModelViewConfig(rClass = R.class, useLayoutOverloads = true)
package com.example.app;

then create your view classes without a layout

@ModelView
public class MyView extends FrameLayout {
   ...
}

Epoxy will automatically look for a layout named R.layout.my_view. Any other layout files starting with my_view would be included in the generated model as layout overloads.