-
Notifications
You must be signed in to change notification settings - Fork 727
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for Async Layout inflation with EPoxy #1292
base: master
Are you sure you want to change the base?
Changes from all commits
9294301
389eae8
0f78ebd
4ee6603
302604c
5a219bc
df629f5
1c93907
5aa0d75
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
package com.airbnb.epoxy | ||
|
||
import android.content.Context | ||
import android.util.AttributeSet | ||
import android.widget.FrameLayout | ||
|
||
/** | ||
* Base class to support Async layout inflation with Epoxy. | ||
*/ | ||
@ModelView | ||
abstract class AsyncFrameLayout @JvmOverloads constructor( | ||
context: Context, | ||
attrs: AttributeSet? = null, | ||
defStyleAttr: Int = 0 | ||
) : FrameLayout(context, attrs, defStyleAttr), AsyncInflatedView { | ||
override var isInflated: Boolean = false | ||
override var pendingRunnables: ArrayList<Runnable> = ArrayList() | ||
|
||
@OnViewRecycled | ||
fun onRecycle() { | ||
onViewRecycled() | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
package com.airbnb.epoxy | ||
|
||
/** | ||
* Interface to support Async layout inflation with Epoxy | ||
* This is useful if we want to inflate views asyncronously, thereby improving the first layout time | ||
* | ||
* For example, let's say you have a page with first view as a header and then a video. The recycler | ||
* view will inflate both views before showing the first frame. Since video is a heavy view and take | ||
* a longer time to inflate, we can inflate it asyncronously and improve the time taken to show the | ||
* first frame. | ||
*/ | ||
interface AsyncInflatedView { | ||
/** | ||
* isInflated flag is set to true once the async inflation is completed. | ||
* It is used to run any methods/runnables that requires the view to be inflated. | ||
*/ | ||
var isInflated: Boolean | ||
|
||
/** | ||
* pendingRunnables keep a list of runnables, in order, that are waiting for the view to be | ||
* inflated. | ||
*/ | ||
var pendingRunnables: ArrayList<Runnable> | ||
|
||
/** | ||
* onInflationComplete method MUST be called after the view is asyncronously inflated. | ||
* It runs all pending runnables waiting for view inflation. | ||
*/ | ||
fun onInflationComplete() { | ||
isInflated = true | ||
for (runnable in pendingRunnables) { | ||
runnable.run() | ||
} | ||
pendingRunnables.clear() | ||
} | ||
|
||
/** | ||
* executeWhenInflated method is called by Epoxy to execute a runnable that depend on the | ||
* inflated view. If the view is already inflated, the runnable will immediately run, | ||
* otherwise it is added to the list of pending runnables. | ||
*/ | ||
fun executeWhenInflated(runnable: Runnable) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need a way of clearing these pending executions when the view is recycled? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch. Done. |
||
if (isInflated) { | ||
runnable.run() | ||
} else { | ||
pendingRunnables.add(runnable) | ||
} | ||
} | ||
|
||
/** | ||
* onViewRecycled method MUST be called when the view is recycled. It clears pending | ||
* runnable It runs all pending runnables waiting for view inflation. | ||
*/ | ||
fun onViewRecycled() { | ||
pendingRunnables.clear() | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -108,6 +108,15 @@ public void onBindViewHolder(EpoxyViewHolder holder, int position) { | |
|
||
@Override | ||
public void onBindViewHolder(EpoxyViewHolder holder, int position, List<Object> payloads) { | ||
if (holder.itemView instanceof AsyncInflatedView) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think Peter might've mentioned this earlier and I'm not completely familiar with the internals of Epoxy but is there a way where we could avoid these checks? We discussed an implementation where we potentially create an |
||
((AsyncInflatedView)holder.itemView).executeWhenInflated(() -> | ||
onBindViewHolderInternal(holder, holder.getBindingAdapterPosition(), payloads)); | ||
} else { | ||
onBindViewHolderInternal(holder, position, payloads); | ||
} | ||
} | ||
|
||
private void onBindViewHolderInternal(EpoxyViewHolder holder, int position, List<Object> payloads) { | ||
EpoxyModel<?> modelToShow = getModelForPosition(position); | ||
|
||
EpoxyModel<?> previouslyBoundModel = null; | ||
|
@@ -209,15 +218,27 @@ public boolean onFailedToRecycleView(EpoxyViewHolder holder) { | |
@CallSuper | ||
@Override | ||
public void onViewAttachedToWindow(EpoxyViewHolder holder) { | ||
//noinspection unchecked,rawtypes | ||
((EpoxyModel) holder.getModel()).onViewAttachedToWindow(holder.objectToBind()); | ||
if (holder.itemView instanceof AsyncInflatedView) { | ||
((AsyncInflatedView)holder.itemView).executeWhenInflated(() -> | ||
//noinspection unchecked,rawtypes | ||
((EpoxyModel) holder.getModel()).onViewAttachedToWindow(holder.objectToBind())); | ||
} else { | ||
//noinspection unchecked,rawtypes | ||
((EpoxyModel) holder.getModel()).onViewAttachedToWindow(holder.objectToBind()); | ||
} | ||
} | ||
|
||
@CallSuper | ||
@Override | ||
public void onViewDetachedFromWindow(EpoxyViewHolder holder) { | ||
//noinspection unchecked,rawtypes | ||
((EpoxyModel) holder.getModel()).onViewDetachedFromWindow(holder.objectToBind()); | ||
if (holder.itemView instanceof AsyncInflatedView) { | ||
((AsyncInflatedView)holder.itemView).executeWhenInflated(() -> | ||
//noinspection unchecked,rawtypes | ||
((EpoxyModel) holder.getModel()).onViewDetachedFromWindow(holder.objectToBind())); | ||
} else { | ||
//noinspection unchecked,rawtypes | ||
((EpoxyModel) holder.getModel()).onViewDetachedFromWindow(holder.objectToBind()); | ||
} | ||
} | ||
|
||
public void onSaveInstanceState(Bundle outState) { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,142 @@ | ||
package com.airbnb.epoxy.kotlinsample.models | ||
|
||
import android.content.Context | ||
import android.graphics.Color | ||
import android.util.AttributeSet | ||
import android.util.Log | ||
import android.view.View | ||
import android.widget.TextView | ||
import androidx.annotation.ColorInt | ||
import androidx.asynclayoutinflater.view.AsyncLayoutInflater | ||
import com.airbnb.epoxy.AfterPropsSet | ||
import com.airbnb.epoxy.AsyncFrameLayout | ||
import com.airbnb.epoxy.CallbackProp | ||
import com.airbnb.epoxy.ModelProp | ||
import com.airbnb.epoxy.ModelView | ||
import com.airbnb.epoxy.OnViewRecycled | ||
import com.airbnb.epoxy.OnVisibilityChanged | ||
import com.airbnb.epoxy.OnVisibilityStateChanged | ||
import com.airbnb.epoxy.TextProp | ||
import com.airbnb.epoxy.VisibilityState | ||
import com.airbnb.epoxy.kotlinsample.R | ||
|
||
// The ModelView annotation is used on Views to have models generated from those views. | ||
// This is pretty straightforward with Kotlin, but properties need some special handling. | ||
@ModelView(autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT) | ||
class AsyncItemCustomView @JvmOverloads constructor( | ||
context: Context, | ||
attrs: AttributeSet? = null, | ||
defStyleAttr: Int = 0 | ||
) : AsyncFrameLayout(context, attrs, defStyleAttr) { | ||
|
||
private val onVisibilityEventDrawable = OnVisibilityEventDrawable(context) | ||
|
||
private lateinit var textView: TextView | ||
|
||
init { | ||
AsyncLayoutInflater(context).inflate(R.layout.async_custom_view_item, this) { | ||
view, _, _ -> | ||
addView(view) | ||
textView = (findViewById(R.id.title)) | ||
textView.setCompoundDrawables(null, null, onVisibilityEventDrawable, null) | ||
textView.compoundDrawablePadding = (4 * resources.displayMetrics.density).toInt() | ||
onInflationComplete() | ||
} | ||
} | ||
|
||
// You can annotate your methods with @ModelProp | ||
// A default model property value can be set by using Kotlin default arguments, but you | ||
// must use JvmOverloads for Epoxy to handle it correctly. | ||
@JvmOverloads | ||
@ModelProp | ||
fun color(@ColorInt color: Int = Color.RED) { | ||
textView.setTextColor(color) | ||
} | ||
|
||
// Or if you need to store data in properties there are two options | ||
|
||
// 1.You can make it nullable like this and annotate the setter | ||
var listener: View.OnClickListener? = null | ||
@CallbackProp set | ||
|
||
// 2. Or you can use lateinit | ||
@TextProp | ||
lateinit var title: CharSequence | ||
|
||
@AfterPropsSet | ||
fun useProps() { | ||
// This is optional, and is called after the annotated properties above are set. | ||
// This is useful for using several properties in one method to guarantee they are all set first. | ||
textView.text = title | ||
textView.setOnClickListener(listener) | ||
} | ||
|
||
@OnVisibilityStateChanged | ||
fun onVisibilityStateChanged( | ||
@VisibilityState.Visibility visibilityState: Int | ||
) { | ||
when (visibilityState) { | ||
VisibilityState.VISIBLE -> { | ||
Log.d(TAG, "$title Visible") | ||
onVisibilityEventDrawable.visible = true | ||
} | ||
VisibilityState.INVISIBLE -> { | ||
Log.d(TAG, "$title Invisible") | ||
onVisibilityEventDrawable.visible = false | ||
} | ||
VisibilityState.FOCUSED_VISIBLE -> { | ||
Log.d(TAG, "$title FocusedVisible") | ||
onVisibilityEventDrawable.focusedVisible = true | ||
} | ||
VisibilityState.UNFOCUSED_VISIBLE -> { | ||
Log.d(TAG, "$title UnfocusedVisible") | ||
onVisibilityEventDrawable.focusedVisible = false | ||
} | ||
VisibilityState.PARTIAL_IMPRESSION_VISIBLE -> { | ||
Log.d(TAG, "$title PartialImpressionVisible") | ||
onVisibilityEventDrawable.partialImpression = true | ||
} | ||
VisibilityState.PARTIAL_IMPRESSION_INVISIBLE -> { | ||
Log.d(TAG, "$title PartialImpressionInVisible") | ||
onVisibilityEventDrawable.partialImpression = false | ||
} | ||
VisibilityState.FULL_IMPRESSION_VISIBLE -> { | ||
Log.d(TAG, "$title FullImpressionVisible") | ||
onVisibilityEventDrawable.fullImpression = true | ||
} | ||
} | ||
} | ||
|
||
@OnVisibilityChanged | ||
fun onVisibilityChanged( | ||
percentVisibleHeight: Float, | ||
percentVisibleWidth: Float, | ||
visibleHeight: Int, | ||
visibleWidth: Int | ||
) { | ||
Log.d( | ||
TAG, | ||
"$title onChanged ${percentVisibleHeight.toInt()} ${percentVisibleWidth.toInt()} " + | ||
"$visibleHeight $visibleWidth ${System.identityHashCode( | ||
this | ||
)}" | ||
) | ||
with(onVisibilityEventDrawable) { | ||
if ((percentVisibleHeight < 100 || percentVisibleWidth < 100) && fullImpression) { | ||
fullImpression = false | ||
} | ||
percentHeight = percentVisibleHeight | ||
percentWidth = percentVisibleWidth | ||
} | ||
} | ||
|
||
@OnViewRecycled | ||
fun clear() { | ||
onRecycle() | ||
onVisibilityEventDrawable.reset() | ||
} | ||
|
||
companion object { | ||
private const val TAG = "ItemCustomView" | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if we should provide a convenience function here that calls this. Maybe something like
That might make for less boiler plate when using it in the model view
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Though I guess that would then force a dependency on the async layout inflator in the core module which we probably don't want to do.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think so, we should keep it optional to keep the library as small as possible.