Skip to content
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

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions blessedDeps.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ rootProject.ext.ANDROID_DATA_BINDING = "1.3.1"
rootProject.ext.ANDROID_RUNTIME_VERSION = "4.1.1.4"
rootProject.ext.ANDROID_TEST_RUNNER = "1.4.0"
rootProject.ext.ASSERTJ_VERSION = "1.7.1"
rootProject.ext.ASYNCLAYOUTINFLATOR_VERSION = "1.0.0"
rootProject.ext.AUTO_VALUE_VERSION = "1.7.4"
rootProject.ext.GLIDE_VERSION = "4.12.0"
rootProject.ext.GOOGLE_TESTING_COMPILE_VERSION = "0.19"
Expand Down Expand Up @@ -78,6 +79,7 @@ rootProject.ext.deps = [
androidTestRules : "androidx.test:rules:$ANDROID_TEST_RUNNER",
androidTestRunner : "androidx.test:runner:$ANDROID_TEST_RUNNER",
assertj : "org.assertj:assertj-core:$ASSERTJ_VERSION",
asyncLayoutInflater : "androidx.asynclayoutinflater:asynclayoutinflater:$ASYNCLAYOUTINFLATOR_VERSION",
autoValue : "com.google.auto.value:auto-value:$AUTO_VALUE_VERSION",
composeMaterial : "androidx.compose.material:material:$COMPOSE_VERSION",
composeUi : "androidx.compose.ui:ui:$COMPOSE_VERSION",
Expand Down
23 changes: 23 additions & 0 deletions epoxy-adapter/src/main/java/com/airbnb/epoxy/AsyncFrameLayout.kt
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()
}
}
57 changes: 57 additions & 0 deletions epoxy-adapter/src/main/java/com/airbnb/epoxy/AsyncInflatedView.kt
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() {
Copy link
Collaborator

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

fun ViewGroup.inflateAsync(@LayoutRes layoutRes: Int, parent: ViewGroup, callback: OnInflateFinishedListener) {
    AsyncLayoutInflater(context).inflate(R.layout.async_custom_view_item, this) {
            view, resId, parent ->
        addView(view)
        callback.onInflateFinished(view, resId, parent)
        onInflationComplete()
    }
}

That might make for less boiler plate when using it in the model view

Copy link
Collaborator

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.

Copy link
Author

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.

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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The 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?

Copy link
Author

Choose a reason for hiding this comment

The 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()
}
}
29 changes: 25 additions & 4 deletions epoxy-adapter/src/main/java/com/airbnb/epoxy/BaseEpoxyAdapter.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The 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 AsycEpoxyModel class (similar to how we handled it for Compose interoperability - https://github.com/airbnb/epoxy/blob/master/epoxy-compose/src/main/java/com/airbnb/epoxy/ComposeInterop.kt)

((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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,23 @@ public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
@Override
public void onViewAttachedToWindow(@NonNull EpoxyViewHolder holder) {
super.onViewAttachedToWindow(holder);
epoxyController.onViewAttachedToWindow(holder, holder.getModel());
if (holder.itemView instanceof AsyncInflatedView) {
((AsyncInflatedView)holder.itemView).executeWhenInflated(() ->
epoxyController.onViewAttachedToWindow(holder, holder.getModel()));
} else {
epoxyController.onViewAttachedToWindow(holder, holder.getModel());
}
}

@Override
public void onViewDetachedFromWindow(@NonNull EpoxyViewHolder holder) {
super.onViewDetachedFromWindow(holder);
epoxyController.onViewDetachedFromWindow(holder, holder.getModel());
if (holder.itemView instanceof AsyncInflatedView) {
((AsyncInflatedView)holder.itemView).executeWhenInflated(() ->
epoxyController.onViewDetachedFromWindow(holder, holder.getModel()));
} else {
epoxyController.onViewDetachedFromWindow(holder, holder.getModel());
}
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import androidx.annotation.IdRes
import androidx.annotation.IntRange
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.viewmodeladapter.R
import java.util.ArrayList
import java.util.HashMap

/**
* A simple way to track visibility events on [com.airbnb.epoxy.EpoxyModel].
Expand Down Expand Up @@ -206,6 +204,16 @@ class EpoxyVisibilityTracker {
* @param eventOriginForDebug a debug strings used for logs
*/
private fun processChild(child: View, detachEvent: Boolean, eventOriginForDebug: String) {
if (child is AsyncInflatedView) {
child.executeWhenInflated {
processChildInternal(child, detachEvent, eventOriginForDebug)
}
} else {
processChildInternal(child, detachEvent, eventOriginForDebug)
}
}

private fun processChildInternal(child: View, detachEvent: Boolean, eventOriginForDebug: String) {

// Only if attached
val recyclerView = attachedRecyclerView ?: return
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
VERSION_NAME=5.0.0-beta06
VERSION_NAME=5.0.0-beta05
GROUP=com.airbnb.android
POM_DESCRIPTION=Epoxy is a system for composing complex screens with a ReyclerView in Android.
POM_URL=https://github.com/airbnb/epoxy
Expand Down
1 change: 1 addition & 0 deletions kotlinsample/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ android {
dependencies {
implementation rootProject.deps.androidRecyclerView
implementation rootProject.deps.androidAppcompat
implementation rootProject.deps.asyncLayoutInflater
implementation project(':epoxy-databinding')
implementation project(':epoxy-adapter')
implementation project(':epoxy-annotations')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.airbnb.epoxy.group
import com.airbnb.epoxy.kotlinsample.helpers.carouselNoSnapBuilder
import com.airbnb.epoxy.kotlinsample.models.ItemDataClass
import com.airbnb.epoxy.kotlinsample.models.ItemViewBindingDataClass
import com.airbnb.epoxy.kotlinsample.models.asyncItemCustomView
import com.airbnb.epoxy.kotlinsample.models.carouselItemCustomView
import com.airbnb.epoxy.kotlinsample.models.coloredSquareView
import com.airbnb.epoxy.kotlinsample.models.decoratedLinearGroup
Expand Down Expand Up @@ -141,6 +142,16 @@ class MainActivity : AppCompatActivity() {
}
}

asyncItemCustomView {
id("custom view $i")
color(Color.GREEN)
title("Async Row - Open sticky header activity")
listener { _ ->
Toast.makeText(this@MainActivity, "clicked", Toast.LENGTH_LONG).show()
startActivity(Intent(this@MainActivity, StickyHeaderActivity::class.java))
}
}

itemCustomView {
id("custom view $i")
color(Color.MAGENTA)
Expand All @@ -151,6 +162,16 @@ class MainActivity : AppCompatActivity() {
}
}

asyncItemCustomView {
id("custom view $i")
color(Color.MAGENTA)
title("Async Row - Open Drag and Dropt activity")
listener { _ ->
Toast.makeText(this@MainActivity, "clicked", Toast.LENGTH_LONG).show()
startActivity(Intent(this@MainActivity, DragAndDropActivity::class.java))
}
}

itemEpoxyHolder {
id("view holder $i")
title("this is a View Holder item")
Expand Down
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"
}
}
Loading