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

Introduce a generic data-binding-enabled RecyclerView adapter #172

Merged
merged 7 commits into from
Oct 8, 2019
10 changes: 7 additions & 3 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ android {
targetSdkVersion 28
versionCode 1
versionName "1.0"
multiDexEnabled true
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// https://developer.android.com/training/testing/junit-runner#ato-gradle
testInstrumentationRunnerArguments clearPackageData: 'true'
Expand Down Expand Up @@ -61,26 +62,29 @@ dependencies {
'androidx.constraintlayout:constraintlayout:1.1.3',
'androidx.core:core-ktx:1.0.2',
'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0-alpha03',
'androidx.multidex:multidex:2.0.1',
'androidx.recyclerview:recyclerview:1.0.0',
'com.google.dagger:dagger:2.24',
'com.google.guava:guava:28.1-android',
"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version",
"org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.1",
"org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1"
'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.1',
'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1',
)
testImplementation(
'androidx.test:core:1.2.0',
'androidx.test.espresso:espresso-core:3.2.0',
'androidx.test.espresso:espresso-intents:3.1.0',
'androidx.test.ext:junit:1.1.1',
'com.google.truth:truth:0.43',
'org.robolectric:robolectric:4.3',
)
androidTestImplementation(
'androidx.test:core:1.2.0',
'androidx.test.espresso:espresso-core:3.2.0',
'androidx.test.espresso:espresso-intents:3.1.0',
'androidx.test.ext:junit:1.1.1',
'androidx.test:runner:1.2.0',
'com.google.truth:truth:0.43',
'androidx.test.espresso:espresso-intents:3.1.0',
)
androidTestUtil(
'androidx.test:orchestrator:1.2.0',
Expand Down
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@
<activity android:name=".splash.SplashActivity"
android:theme="@style/SplashScreenTheme">
</activity>
<activity android:name=".testing.BindableAdapterTestActivity"/>
</application>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import org.oppia.app.fragment.FragmentComponent
import org.oppia.app.home.HomeActivity
import org.oppia.app.player.exploration.ExplorationActivity
import org.oppia.app.player.state.testing.StateFragmentTestActivity
import org.oppia.app.testing.BindableAdapterTestActivity
import org.oppia.app.topic.TopicActivity
import javax.inject.Provider

Expand All @@ -24,6 +25,7 @@ interface ActivityComponent {

fun inject(explorationActivity: ExplorationActivity)
fun inject(homeActivity: HomeActivity)
fun inject(bindableAdapterTestActivity: BindableAdapterTestActivity)
fun inject(topicActivity: TopicActivity)
fun inject(stateFragmentTestActivity: StateFragmentTestActivity)
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import org.oppia.app.home.HomeFragment
import org.oppia.app.player.exploration.ExplorationFragment
import org.oppia.app.player.state.StateFragment
import org.oppia.app.player.audio.AudioFragment
import org.oppia.app.testing.BindableAdapterTestFragment
import org.oppia.app.topic.TopicFragment
import org.oppia.app.topic.conceptcard.ConceptCardFragment
import org.oppia.app.topic.overview.TopicOverviewFragment
Expand All @@ -29,6 +30,7 @@ interface FragmentComponent {
fun inject(explorationFragment: ExplorationFragment)
fun inject(homeFragment: HomeFragment)
fun inject(stateFragment: StateFragment)
fun inject(bindableAdapterTestFragment: BindableAdapterTestFragment)
fun inject(topicFragment: TopicFragment)
fun inject(topicOverviewFragment: TopicOverviewFragment)
fun inject(topicPlayFragment: TopicPlayFragment)
Expand Down
208 changes: 208 additions & 0 deletions app/src/main/java/org/oppia/app/recyclerview/BindableAdapter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package org.oppia.app.recyclerview

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.databinding.ViewDataBinding
import androidx.recyclerview.widget.RecyclerView
import kotlin.reflect.KClass

/** A function that returns the type of view that can bind the specified data object. */
typealias ComputeViewType<T> = (T) -> Int

/**
* The default type of all views used for adapters that do not need to specify different view types.
*/
const val DEFAULT_VIEW_TYPE = 0

private typealias ViewHolderFactory<T> = (ViewGroup) -> BindableAdapter.BindableViewHolder<T>

/**
* A generic [RecyclerView.Adapter] that can be initialized using Android data-binding, and bind its
* own child views using Android data-binding (or custom View bind methods).
*
* This is loosely based on https://android.jlelse.eu/1bd08b4796b4 except the concept was extended
* to include seamlessly binding to views using data-binding in a type-safe and lifecycle-safe way.
*/
class BindableAdapter<T : Any> internal constructor(
private val computeViewType: ComputeViewType<T>,
private val viewHolderFactoryMap: Map<Int, ViewHolderFactory<T>>,
private val dataClassType: KClass<T>
) : RecyclerView.Adapter<BindableAdapter.BindableViewHolder<T>>() {
private val dataList: MutableList<T> = ArrayList()

// TODO(#170): Introduce support for stable IDs.

/** Sets the data of this adapter. This is expected to be called by Android via data-binding. */
fun setData(newDataList: List<T>) {
dataList.clear()
dataList += newDataList
// TODO(#171): Introduce diffing to notify subsets of the view to properly support animations
// rather than re-binding the entire list upon any change.
notifyDataSetChanged()
}

/**
* Sets the data of this adapter in the same way as [setData], except with a different type.
*
* This method ensures the type of data being passed in is compatible with the type of this
* adapter. This helps ensure type compatibility at runtime in cases where the generic type of the
* adapter object is lost.
*/
fun <T2 : Any> setDataUnchecked(newDataList: List<T2>) {
// NB: This check only works if the list has any data in it. Since we can't use a reified type
// here (due to Android data binding not supporting custom adapters with inline functions), this
// method will succeed if types are different for empty lists (that is, List<T1> == List<T2>
// when T1 is not assignable to T2). This likely won't have bad side effects since any time a
// non-empty list is attempted to be bound, this crash will be correctly triggered.
newDataList.firstOrNull()?.let {
check(it.javaClass.isAssignableFrom(dataClassType.java)) {
"Trying to bind incompatible data to adapter. Data class type: ${it.javaClass}, " +
"expected adapter class type: $dataClassType."
}
}
@Suppress("UNCHECKED_CAST") // This is safe. See the above check.
setData(newDataList as List<T>)
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindableViewHolder<T> {
val viewHolderFactory = viewHolderFactoryMap[viewType]
checkNotNull(viewHolderFactory) { "Encountered missing view factory for type: $viewType" }
return viewHolderFactory(parent)
}

override fun getItemCount(): Int {
return dataList.size
}

override fun getItemViewType(position: Int): Int {
return computeViewType(dataList[position])
}

override fun onBindViewHolder(holder: BindableViewHolder<T>, position: Int) {
holder.bind(dataList[position])
}

/** A generic [RecyclerView.ViewHolder] that generically binds data to the specified view. */
abstract class BindableViewHolder<T> internal constructor(
view: View
) : RecyclerView.ViewHolder(view) {
internal abstract fun bind(data: T)
}

/**
* Constructs a new [BindableAdapter]. Each type returned by the computer should have an
* associated view binder. If no computer is set, then [DEFAULT_VIEW_TYPE] is the default view
* type.
*
* Instances of [Builder] should be instantiated using [newBuilder].
*/
class Builder<T : Any>(private val dataClassType: KClass<T>) {
private var computeViewType: ComputeViewType<T> = { DEFAULT_VIEW_TYPE }
private var viewHolderFactoryMap: MutableMap<Int, ViewHolderFactory<T>> = HashMap()

/**
* Registers a function which returns the type of view a specific data item corresponds to. This defaults to always
* assuming all views are the same time: [DEFAULT_VIEW_TYPE].
*
* This function must be used if multiple views need to be supported by the [RecyclerView].
*
* @return this
*/
fun registerViewTypeComputer(computeViewType: ComputeViewType<T>): Builder<T> {
this.computeViewType = computeViewType
return this
}

/**
* Registers a [View] inflater and bind function for views of the specified view type (with default value
* [DEFAULT_VIEW_TYPE] for single-view [RecyclerView]s). Note that the viewType specified here must correspond to a
* view type registered in [registerViewTypeComputer] if non-default, or if any view type computer has been
* registered.
*
* The inflateView and bindView functions passed in here must not hold any references to UI objects except those
* that own the RecyclerView.
*
* @param inflateView function that takes a parent [ViewGroup] and returns a newly inflated [View] of type [V]
* @param bindView function that takes a [RecyclerView]-owned [View] of type [V] and binds a data element typed [T]
* to it
* @return this
*/
fun <V : View> registerViewBinder(
viewType: Int = DEFAULT_VIEW_TYPE, inflateView: (ViewGroup) -> V, bindView: (V, T) -> Unit
): Builder<T> {
checkViewTypeIsUnique(viewType)
val viewHolderFactory: ViewHolderFactory<T> = { viewGroup ->
// This is lifecycle-safe since it will become dereferenced when the factory method returns.
// The version referenced in the anonymous BindableViewHolder object should be copied into a
// class field that binds that reference's lifetime to the view holder's lifetime. This
// approach avoids needing to perform an unsafe cast later when binding the view.
val inflatedView = inflateView(viewGroup)
object : BindableViewHolder<T>(inflatedView) {
override fun bind(data: T) {
bindView(inflatedView, data)
}
}
}
viewHolderFactoryMap[viewType] = viewHolderFactory
return this
}

/**
* Behaves in the same way as [registerViewBinder] except the inflate and bind methods correspond to a [View]
* data-binding typed [DB].
*
* @param inflateDataBinding a function that inflates the root view of a data-bound layout (e.g.
* MyDataBinding::inflate). This may also be a function that initializes the data-binding with additional
* properties as necessary.
* @param setViewModel a function that initializes the view model in the data-bound view (e.g.
* MyDataBinding::setSpecialViewModel). This may also be a function that initializes the view model & other
* view-accessible properties as necessary.
* @return this
*/
fun <DB : ViewDataBinding> registerViewDataBinder(
viewType: Int = DEFAULT_VIEW_TYPE,
inflateDataBinding: (LayoutInflater, ViewGroup, Boolean) -> DB,
setViewModel: (DB, T) -> Unit
): Builder<T> {
checkViewTypeIsUnique(viewType)
val viewHolderFactory: ViewHolderFactory<T> = { viewGroup ->
// See registerViewBinder() comments for why this approach should be lifecycle safe and not
// introduce memory leaks.
val binding = inflateDataBinding(
LayoutInflater.from(viewGroup.context),
viewGroup,
/* attachToRoot= */ false
)
object : BindableViewHolder<T>(binding.root) {
override fun bind(data: T) {
setViewModel(binding, data)
}
}
}
viewHolderFactoryMap[viewType] = viewHolderFactory
return this
}

private fun checkViewTypeIsUnique(viewType: Int) {
check(!viewHolderFactoryMap.containsKey(viewType)) {
"Cannot register a second view binder for view type: $viewType (current binder: " +
"${viewHolderFactoryMap[viewType]}."
}
}

/** Returns a new [BindableAdapter]. */
fun build(): BindableAdapter<T> {
val computeViewType = this.computeViewType
check(viewHolderFactoryMap.isNotEmpty()) { "At least one view binder must be registered" }
return BindableAdapter(computeViewType, viewHolderFactoryMap, dataClassType)
}

companion object {
/** Returns a new [Builder]. */
inline fun <reified T : Any> newBuilder(): Builder<T> {
return Builder(T::class)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package org.oppia.app.recyclerview

import androidx.databinding.BindingAdapter
import androidx.lifecycle.LiveData
import androidx.recyclerview.widget.RecyclerView

/**
* Binds the specified generic data to the adapter of the [RecyclerView]. This is called by
* Android's data binding framework and should not be used directly. For reference:
* https://android.jlelse.eu/1bd08b4796b4.
*/
@BindingAdapter("data")
fun <T : Any> bindToRecyclerViewAdapter(recyclerView: RecyclerView, liveData: LiveData<List<T>>) {
liveData.value?.let { data ->
val adapter = recyclerView.adapter
checkNotNull(adapter) { "Cannot bind data to a RecyclerView missing its adapter." }
check(adapter is BindableAdapter<*>) { "Can only bind data to a BindableAdapter." }
adapter.setDataUnchecked(data)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package org.oppia.app.testing

import android.os.Bundle
import org.oppia.app.R
import org.oppia.app.activity.InjectableAppCompatActivity

// TODO(#59): Make this activity only included in relevant tests instead of all prod builds.
/** A test activity for the bindable RecyclerView adapter. */
class BindableAdapterTestActivity: InjectableAppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
activityComponent.inject(this)
setContentView(R.layout.test_activity)
supportFragmentManager.beginTransaction()
.add(R.id.test_fragment_placeholder, BindableAdapterTestFragment(), BINDABLE_TEST_FRAGMENT_TAG)
.commitNow()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package org.oppia.app.testing

import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import org.oppia.app.fragment.InjectableFragment
import javax.inject.Inject

const val BINDABLE_TEST_FRAGMENT_TAG = "bindable_adapter_test_fragment"

// TODO(#59): Make this fragment only included in relevant tests instead of all prod builds.
/** A test fragment for the bindable RecyclerView adapter. */
class BindableAdapterTestFragment: InjectableFragment() {
@Inject
lateinit var bindableAdapterTestFragmentPresenter: BindableAdapterTestFragmentPresenter

override fun onAttach(context: Context?) {
super.onAttach(context)
fragmentComponent.inject(this)
}

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return bindableAdapterTestFragmentPresenter.handleCreateView(inflater, container)
}
}
Loading