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

Added parentFragmentViewModel() #247

Merged
merged 5 commits into from
Jul 11, 2019
Merged
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
56 changes: 55 additions & 1 deletion mvrx/src/main/kotlin/com/airbnb/mvrx/MvRxExtensions.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package com.airbnb.mvrx

import androidx.lifecycle.ViewModelProviders
import androidx.annotation.RestrictTo
import androidx.annotation.RestrictTo.Scope.LIBRARY
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.ViewModelProviders
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KClass
import kotlin.reflect.KProperty
Expand All @@ -29,6 +29,60 @@ inline fun <T, reified VM : BaseMvRxViewModel<S>, reified S : MvRxState> T.fragm
.apply { subscribe(this@fragmentViewModel, subscriber = { postInvalidate() }) }
}

/**
* Gets or creates a ViewModel scoped to a parent fragment. This delegate will walk up the parentFragment hierarchy
* until it finds a Fragment that can provide the correct ViewModel. If no parent fragments can provide the ViewModel,
* a new one will be created in top-most parent Fragment.
*/
inline fun <T, reified VM : BaseMvRxViewModel<S>, reified S : MvRxState> T.parentFragmentViewModel(
viewModelClass: KClass<VM> = VM::class,
crossinline keyFactory: () -> String = { viewModelClass.java.name }
): Lazy<VM> where T : Fragment, T : MvRxView = lifecycleAwareLazy(this) {
requireNotNull(parentFragment) { "There is no parent fragment for ${this::class.java.simpleName}!" }
val notFoundMessage by lazy { "There is no ViewModel of type ${VM::class.java.simpleName} for this Fragment!" }
val factory = MvRxFactory { error(notFoundMessage) }
var fragment: Fragment? = parentFragment
val key = keyFactory()
while (fragment != null) {
try {
return@lifecycleAwareLazy ViewModelProviders.of(fragment, factory).get(key, viewModelClass.java)
.apply { subscribe(this@parentFragmentViewModel, subscriber = { postInvalidate() }) }
} catch (e: java.lang.IllegalStateException) {
if (e.message == notFoundMessage) {
fragment = fragment.parentFragment
} else {
throw e
}
}
}

// ViewModel was not found. Create a new one in the top-most parent.
var topParentFragment = parentFragment
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

won't fragment already be the top parent fragment at this point? since the above while loop only breaks when there is no parent fragment?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like fragment would be null, but if seems like we could eliminate this loop if we track parentFragment in the above loop

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BenSchwab @elihart Yes, it would be null. I wrote it that way but the code was harder to read despite being shorter. Writing it this way made the intention of each section more explicit and easier to grok.

while (topParentFragment?.parentFragment != null) {
topParentFragment = topParentFragment.parentFragment
}
val viewModelContext = FragmentViewModelContext(this.requireActivity(), _fragmentArgsProvider(), topParentFragment!!)
return@lifecycleAwareLazy MvRxViewModelProvider.get(viewModelClass.java, S::class.java, viewModelContext, keyFactory())
.apply { subscribe(this@parentFragmentViewModel, subscriber = { postInvalidate() }) }
}

/**
* Gets or creates a ViewModel scoped to a target fragment. Throws [IllegalStateException] if there is no target fragment.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this use case seems weird to me, because the lifecycle of the target fragment isn't necessarily tied to the fragment that is calling this. Couldn't the target fragment be destroyed, and the viewmodel cleared, while this fragment is still using it?

is it that bad to share viewmodels via an activity scoped viewmodel in this case?

I'm not particularly opposed to it, but am curious to hear more about the need. Once we have these new delegates it would be great if we updated the wiki with a little guide about when we recommend using each

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@elihart I think it alleviates some of the potential ambiguity in the parentFragment case because it allows you to be explicit what the ViewModel will be scoped to.

I haven't actually needed it myself but @littleGnAl suggested it and it seems reasonable and was simple enough.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gpeal @elihart In our project, the targetFragment is generally used with DialogFragment, but MvRx does not officially support DialogFragment, I think this is not necessary.

If someone needs to get the ViewModel of the targetFragment , they can explicitly cast the targetFragment such as (targetFragment as MyTargetFragment).myTargetViewModel.

But if this function is needed, I think the logic should be similar to the logic of parentFragmentViewModel, because the targetFragment's ViewModel maybe already exist.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@littleGnAI, ah, the DialogFragment case makes sense to me

*/
inline fun <T, reified VM : BaseMvRxViewModel<S>, reified S : MvRxState> T.targetFragmentViewModel(
viewModelClass: KClass<VM> = VM::class,
crossinline keyFactory: () -> String = { viewModelClass.java.name }
): Lazy<VM> where T : Fragment, T : MvRxView = lifecycleAwareLazy(this) {
val targetFragment = requireNotNull(targetFragment) { "There is no target fragment for ${this::class.java.simpleName}!" }
MvRxViewModelProvider.get(
viewModelClass.java,
S::class.java,
FragmentViewModelContext(this.requireActivity(), targetFragment._fragmentArgsProvider(), targetFragment),
keyFactory()
)
.apply { subscribe(this@targetFragmentViewModel, subscriber = { postInvalidate() }) }
}

/**
* [activityViewModel] except it will throw [IllegalStateException] if the ViewModel doesn't already exist.
* Use this for screens in the middle of a flow that cannot reasonably be an entrypoint to the flow.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.fragment.app.Fragment
import org.junit.Assert.assertEquals
import org.junit.Test
Expand Down Expand Up @@ -463,4 +464,122 @@ class FragmentSubscriberTest : BaseTest() {
fun duplicateUniqueOnlySubscribeThrowIllegalStateException() {
createFragment<DuplicateUniqueSubscriberFragment, TestActivity>(containerId = CONTAINER_ID)
}

class ParentFragment : BaseMvRxFragment() {

val viewModel: ViewSubscriberViewModel by fragmentViewModel()

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) = FrameLayout(requireContext())

override fun invalidate() {
}
}

class ChildFragmentWithParentViewModel : BaseMvRxFragment() {

val viewModel: ViewSubscriberViewModel by parentFragmentViewModel()

override fun invalidate() {
}
}

@Test
fun testParentFragment() {
val (_, parentFragment) = createFragment<ParentFragment, TestActivity>(containerId = CONTAINER_ID)
val childFragment = ChildFragmentWithParentViewModel()
parentFragment.childFragmentManager.beginTransaction().add(childFragment, "child").commit()
assertEquals(parentFragment.viewModel, childFragment.viewModel)
}

class ParentFragmentWithoutViewModel : BaseMvRxFragment() {

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) = FrameLayout(requireContext())

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
childFragmentManager.beginTransaction()
.add(ChildFragmentWithParentViewModel(), "child1")
.commit()
childFragmentManager.beginTransaction()
.add(ChildFragmentWithParentViewModel(), "child2")
.commit()
}

override fun invalidate() {
}
}

@Test
fun testChildFragmentsCanShareViewModelWithoutParent() {
val (_, parentFragment) = createFragment<ParentFragmentWithoutViewModel, TestActivity>(containerId = CONTAINER_ID)
val childFragment1 = parentFragment.childFragmentManager.findFragmentByTag("child1") as ChildFragmentWithParentViewModel
val childFragment2 = parentFragment.childFragmentManager.findFragmentByTag("child2") as ChildFragmentWithParentViewModel
assertEquals(childFragment1.viewModel, childFragment2.viewModel)
}

class EmptyMvRxFragment : BaseMvRxFragment() {
override fun invalidate() {
}
}

@Test
fun testCreatesViewModelInTopMostFragment() {
val (_, parentFragment) = createFragment<ParentFragmentWithoutViewModel, TestActivity>(containerId = CONTAINER_ID)
val middleFragment = Fragment()
parentFragment.childFragmentManager.beginTransaction().add(middleFragment, "middle").commitNow()
val childFragment1 = ChildFragmentWithParentViewModel()
middleFragment.childFragmentManager.beginTransaction().add(childFragment1, "child1").commitNow()

val childFragment2 = ChildFragmentWithParentViewModel()
parentFragment.childFragmentManager.beginTransaction().add(childFragment2, "child2").commitNow()

assertEquals(childFragment1.viewModel, childFragment2.viewModel)
}

class FragmentWithTarget : BaseMvRxFragment() {
val viewModel: ViewSubscriberViewModel by targetFragmentViewModel()

var invalidateCount = 0

override fun invalidate() {
invalidateCount++
}
}

@Test
fun testTargetFragment() {
val (_, parentFragment) = createFragment<EmptyMvRxFragment, TestActivity>(containerId = CONTAINER_ID)
val targetFragment = EmptyMvRxFragment()
parentFragment.childFragmentManager.beginTransaction().add(targetFragment, "target").commitNow()
val fragmentWithTarget = FragmentWithTarget()
fragmentWithTarget.setTargetFragment(targetFragment, 123)
parentFragment.childFragmentManager.beginTransaction().add(fragmentWithTarget, "fragment-with-target").commitNow()
// Make sure subscribe works.
assertEquals(1, fragmentWithTarget.invalidateCount)
fragmentWithTarget.viewModel.setFoo(1)
assertEquals(2, fragmentWithTarget.invalidateCount)
}

@Test
fun testTargetFragmentsShareViewModel() {
val (_, parentFragment) = createFragment<EmptyMvRxFragment, TestActivity>(containerId = CONTAINER_ID)
val targetFragment = EmptyMvRxFragment()
parentFragment.childFragmentManager.beginTransaction().add(targetFragment, "target").commitNow()
val fragmentWithTarget1 = FragmentWithTarget()
fragmentWithTarget1.setTargetFragment(targetFragment, 123)
parentFragment.childFragmentManager.beginTransaction().add(fragmentWithTarget1, "fragment-with-target1").commitNow()
val fragmentWithTarget2 = FragmentWithTarget()
fragmentWithTarget2.setTargetFragment(targetFragment, 123)
parentFragment.childFragmentManager.beginTransaction().add(fragmentWithTarget2, "fragment-with-target2").commitNow()
assertEquals(fragmentWithTarget1.viewModel, fragmentWithTarget2.viewModel)
}

/**
* This would be [IllegalStateException] except it fails during the Fragment transaction so it's a RuntimeException.
*/
@Test(expected = RuntimeException::class)
fun testTargetFragmentWithoutTargetCrashes() {
val (_, parentFragment) = createFragment<EmptyMvRxFragment, TestActivity>(containerId = CONTAINER_ID)
val fragmentWithTarget = FragmentWithTarget()
parentFragment.childFragmentManager.beginTransaction().add(fragmentWithTarget, "fragment-with-target").commitNow()
}
}
7 changes: 7 additions & 0 deletions sample/src/main/java/com/airbnb/mvrx/sample/MainFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ class MainFragment : BaseFragment() {
clickListener { _ -> navigateTo(R.id.action_main_to_helloWorldEpoxyFragment) }
}

basicRow {
id("parent_fragments")
title("Parent/Child ViewModel")
subtitle(demonstrates("parentFragmentViewModel"))
clickListener { _ -> navigateTo(R.id.action_main_to_parentFragment) }
}

basicRow {
id("random_dad_joke")
title("Random Dad Joke")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.airbnb.mvrx.sample.features.parentfragment

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.airbnb.mvrx.BaseMvRxFragment
import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.sample.R
import com.airbnb.mvrx.withState
import kotlinx.android.synthetic.main.fragment_parent.textView

class ChildFragment : BaseMvRxFragment() {

private val viewModel: CounterViewModel by parentFragmentViewModel()

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_child, container, false)
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
textView.setOnClickListener {
viewModel.incrementCount()
}
}

override fun invalidate() = withState(viewModel) { state ->
textView.text = "ChildFragment: Count: ${state.count}"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.airbnb.mvrx.sample.features.parentfragment

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import com.airbnb.mvrx.BaseMvRxFragment
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.sample.R
import com.airbnb.mvrx.sample.core.MvRxViewModel
import com.airbnb.mvrx.withState
import kotlinx.android.synthetic.main.fragment_parent.textView
import kotlinx.android.synthetic.main.fragment_parent.toolbar

data class CounterState(val count: Int = 0) : MvRxState
class CounterViewModel(state: CounterState) : MvRxViewModel<CounterState>(state) {
fun incrementCount() = setState { copy(count = count + 1) }
}

class ParentFragment : BaseMvRxFragment() {

private val viewModel: CounterViewModel by fragmentViewModel()

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_parent, container, false)
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
toolbar.setupWithNavController(findNavController())
textView.setOnClickListener {
viewModel.incrementCount()
}
childFragmentManager.beginTransaction()
.replace(R.id.childContainer, ChildFragment())
.commit()
}

override fun invalidate() = withState(viewModel) { state ->
textView.text = "ParentFragment: Count: ${state.count}"
}
}
12 changes: 12 additions & 0 deletions sample/src/main/res/layout/fragment_child.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">

<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center" />

</FrameLayout>
25 changes: 25 additions & 0 deletions sample/src/main/res/layout/fragment_parent.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:navigationIcon="@drawable/ic_arrow_back"
/>
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:gravity="center"
android:layout_weight="1" />

<FrameLayout
android:id="@+id/childContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="4" />
</LinearLayout>
7 changes: 7 additions & 0 deletions sample/src/main/res/navigation/nav_graph.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@
app:exitAnim="@anim/anim_exit"
app:popEnterAnim="@anim/anim_pop_enter"
app:popExitAnim="@anim/anim_pop_exit" />
<action
android:id="@+id/action_main_to_parentFragment"
app:destination="@id/parentFragment" />
</fragment>

<fragment
Expand Down Expand Up @@ -79,4 +82,8 @@
<fragment
android:id="@+id/helloWorldFragment"
android:name="com.airbnb.mvrx.sample.features.helloworld.HelloWorldFragment" />
<fragment
android:id="@+id/parentFragment"
android:name="com.airbnb.mvrx.sample.features.parentfragment.ParentFragment"
android:label="ParentFragment" />
</navigation>