-
Notifications
You must be signed in to change notification settings - Fork 727
Generating Models from View Annotations
If you use custom views, an EpoxyModel can be completely generated for each view based on annotations in the view.
- Basic Usage
- Adding Properties
- String Resources
- Setter Comments
- Property Annotations
- Optional and Default Values
- Prop Groups
- Excluding Props from Model State
- Recycling Views
- Saved State
- Grid Span Size
- Base Models
- View Interfaces
- Configuration
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 />
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.
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.
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).
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) {
...
}
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.
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) {
...
}
There are a few options for specifying default values for props in order to make them optional.
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.
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.
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.
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:
- If no value for the prop is set on the model then the custom
defaultValue
will be applied - If
null
is set for the prop value then null will be set on the view.
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.
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.
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
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.
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.
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.
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.
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.
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;
}
});
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.
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.
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.
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_
.
- 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 :)
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.
You can specify a default base model to use for every view in the package with the optional defaultBaseModelClass
parameter.
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.
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.
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.