diff --git a/blessedDeps.gradle b/blessedDeps.gradle index f8066624f9..966515fa70 100644 --- a/blessedDeps.gradle +++ b/blessedDeps.gradle @@ -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" @@ -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", diff --git a/epoxy-adapter/src/main/java/com/airbnb/epoxy/AsyncFrameLayout.kt b/epoxy-adapter/src/main/java/com/airbnb/epoxy/AsyncFrameLayout.kt new file mode 100644 index 0000000000..093ae136af --- /dev/null +++ b/epoxy-adapter/src/main/java/com/airbnb/epoxy/AsyncFrameLayout.kt @@ -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 = ArrayList() + + @OnViewRecycled + fun onRecycle() { + onViewRecycled() + } +} diff --git a/epoxy-adapter/src/main/java/com/airbnb/epoxy/AsyncInflatedView.kt b/epoxy-adapter/src/main/java/com/airbnb/epoxy/AsyncInflatedView.kt new file mode 100644 index 0000000000..f6d89eca79 --- /dev/null +++ b/epoxy-adapter/src/main/java/com/airbnb/epoxy/AsyncInflatedView.kt @@ -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 + + /** + * 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) { + 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() + } +} diff --git a/epoxy-adapter/src/main/java/com/airbnb/epoxy/BaseEpoxyAdapter.java b/epoxy-adapter/src/main/java/com/airbnb/epoxy/BaseEpoxyAdapter.java index 9b9e32394a..8e153752bb 100644 --- a/epoxy-adapter/src/main/java/com/airbnb/epoxy/BaseEpoxyAdapter.java +++ b/epoxy-adapter/src/main/java/com/airbnb/epoxy/BaseEpoxyAdapter.java @@ -108,6 +108,15 @@ public void onBindViewHolder(EpoxyViewHolder holder, int position) { @Override public void onBindViewHolder(EpoxyViewHolder holder, int position, List payloads) { + if (holder.itemView instanceof AsyncInflatedView) { + ((AsyncInflatedView)holder.itemView).executeWhenInflated(() -> + onBindViewHolderInternal(holder, holder.getBindingAdapterPosition(), payloads)); + } else { + onBindViewHolderInternal(holder, position, payloads); + } + } + + private void onBindViewHolderInternal(EpoxyViewHolder holder, int position, List 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) { diff --git a/epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyControllerAdapter.java b/epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyControllerAdapter.java index e8dd9e70d1..91c1ec8c83 100644 --- a/epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyControllerAdapter.java +++ b/epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyControllerAdapter.java @@ -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 diff --git a/epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyVisibilityTracker.kt b/epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyVisibilityTracker.kt index 16b6b731d7..36cdf833d5 100644 --- a/epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyVisibilityTracker.kt +++ b/epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyVisibilityTracker.kt @@ -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]. @@ -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 diff --git a/gradle.properties b/gradle.properties index 19f9a8817f..754149e8b8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/kotlinsample/build.gradle b/kotlinsample/build.gradle index 49743e42d8..7f46ff60b1 100644 --- a/kotlinsample/build.gradle +++ b/kotlinsample/build.gradle @@ -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') diff --git a/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/MainActivity.kt b/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/MainActivity.kt index d191b3c1c6..4c630726c3 100644 --- a/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/MainActivity.kt +++ b/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/MainActivity.kt @@ -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 @@ -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) @@ -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") diff --git a/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/models/AsyncItemCustomView.kt b/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/models/AsyncItemCustomView.kt new file mode 100644 index 0000000000..d52cf085f0 --- /dev/null +++ b/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/models/AsyncItemCustomView.kt @@ -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" + } +} diff --git a/kotlinsample/src/main/res/layout/async_custom_view_item.xml b/kotlinsample/src/main/res/layout/async_custom_view_item.xml new file mode 100644 index 0000000000..ba9e00d06c --- /dev/null +++ b/kotlinsample/src/main/res/layout/async_custom_view_item.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file