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

Fixes #160: Integrating topic controller into Concept Card #198

Merged
merged 19 commits into from
Oct 11, 2019
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
5 changes: 3 additions & 2 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,11 @@ dependencies {
'androidx.constraintlayout:constraintlayout:1.1.3',
'androidx.core:core-ktx:1.0.2',
'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0-alpha03',
'androidx.recyclerview:recyclerview:1.0.0',
'com.google.dagger:dagger:2.24',
"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',
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 @@ -12,6 +12,7 @@
android:supportsRtl="true"
android:theme="@style/OppiaTheme">
<activity android:name=".player.exploration.ExplorationActivity"/>
<activity android:name="org.oppia.app.topic.conceptcard.testing.ConceptCardFragmentTestActivity"/>
<activity android:name="org.oppia.app.home.HomeActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import dagger.Subcomponent
import org.oppia.app.fragment.FragmentComponent
import org.oppia.app.home.HomeActivity
import org.oppia.app.player.exploration.ExplorationActivity
import org.oppia.app.topic.conceptcard.testing.ConceptCardFragmentTestActivity
import javax.inject.Provider

/** Root subcomponent for all activities. */
Expand All @@ -22,4 +23,5 @@ interface ActivityComponent {

fun inject(explorationActivity: ExplorationActivity)
fun inject(homeActivity: HomeActivity)
fun inject(conceptCardFragmentTestActivity: ConceptCardFragmentTestActivity)
}
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
jamesxu0 marked this conversation as resolved.
Show resolved Hide resolved

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 should 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>>,
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 holder 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
Expand Up @@ -7,10 +7,29 @@ import android.view.View
import android.view.ViewGroup
import org.oppia.app.R
import org.oppia.app.fragment.InjectableDialogFragment
import org.oppia.domain.topic.TEST_SKILL_ID_2
import javax.inject.Inject

private const val KEY_SKILL_ID = "SKILL_ID"

/* Fragment that displays a fullscreen dialog for concept cards */
class ConceptCardFragment : InjectableDialogFragment() {

companion object {
/**
* Creates a new instance of a DialogFragment to display content
* @param skillId Used in TopicController to get correct concept card data.
* @return [ConceptCardFragment]: DialogFragment
*/
fun newInstance(skillId: String): ConceptCardFragment {
val conceptCardFrag = ConceptCardFragment()
val args = Bundle()
args.putString(KEY_SKILL_ID, skillId)
conceptCardFrag.arguments = args
return conceptCardFrag
}
}

@Inject lateinit var conceptCardPresenter: ConceptCardPresenter

override fun onAttach(context: Context?) {
Expand All @@ -25,7 +44,9 @@ class ConceptCardFragment : InjectableDialogFragment() {

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
super.onCreateView(inflater, container, savedInstanceState)
return conceptCardPresenter.handleCreateView(inflater, container)
val args = checkNotNull(arguments) { "Expected arguments to be pass to ConceptCardFragment" }
val skillId = args.getString(KEY_SKILL_ID, TEST_SKILL_ID_2)
jamesxu0 marked this conversation as resolved.
Show resolved Hide resolved
return conceptCardPresenter.handleCreateView(inflater, container, skillId)
}

override fun onStart() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import org.oppia.app.R
import org.oppia.app.databinding.ConceptCardExampleViewBinding
import org.oppia.app.databinding.ConceptcardFragmentBinding
import org.oppia.app.fragment.FragmentScope
import org.oppia.app.model.SubtitledHtml
import org.oppia.app.recyclerview.BindableAdapter
import org.oppia.app.viewmodel.ViewModelProvider
import javax.inject.Inject

Expand All @@ -17,21 +21,38 @@ class ConceptCardPresenter @Inject constructor(
private val fragment: Fragment,
private val viewModelProvider: ViewModelProvider<ConceptCardViewModel>
){
fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?): View? {

/** Sets up data binding and adapter for RecyclerView */
fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?, skillId: String): View? {
val viewModel = getConceptCardViewModel()
viewModel.setSkillId(skillId)
val binding = ConceptcardFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false)
binding.conceptCardToolbar.setNavigationIcon(R.drawable.ic_close_white_24dp)
binding.conceptCardToolbar.setNavigationOnClickListener {
(fragment as? DialogFragment)?.dismiss()
}
binding.workedExamples.apply {
adapter = createRecyclerViewAdapter()
layoutManager = LinearLayoutManager(context)
jamesxu0 marked this conversation as resolved.
Show resolved Hide resolved
}

binding.let {
it.viewModel = getConceptCardViewModel()
it.viewModel = viewModel
it.lifecycleOwner = fragment
}

return binding.root
}

private fun getConceptCardViewModel(): ConceptCardViewModel {
return viewModelProvider.getForFragment(fragment, ConceptCardViewModel::class.java)
}

private fun createRecyclerViewAdapter(): BindableAdapter<SubtitledHtml> {
return BindableAdapter.Builder
.newBuilder<SubtitledHtml>()
.registerViewDataBinder(
inflateDataBinding = ConceptCardExampleViewBinding::inflate,
setViewModel = ConceptCardExampleViewBinding::setSubtitledHtml)
.build()
}
}
Loading