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

[Android] Discussion: Support for Jetpack Compose #446

Open
thevoiceless opened this issue Jan 4, 2022 · 50 comments
Open

[Android] Discussion: Support for Jetpack Compose #446

thevoiceless opened this issue Jan 4, 2022 · 50 comments

Comments

@thevoiceless
Copy link

thevoiceless commented Jan 4, 2022

Status as of April 2024

You should be able to use Compose with React Native, but it does not play nice with react-native-screens


The update to Gradle 7+ in RN 0.66+ means that we can now use Jetpack Compose to build React-like declarative UIs natively on Android. It's completely separate from the traditional View system but there are interoperability APIs to bridge the two worlds.

Thanks to AbstractComposeView, I was able to implement a basic proof-of-concept (edited for brevity):

class MyViewManager : SimpleViewManager<MyView>() {
    override fun createViewInstance(reactContext: ThemedReactContext) = MyView(reactContext)

    @ReactProp(name = "displayText")
    fun displayText(view: MyView, displayText: String) {
        view.displayText = displayText
    }
}

class MyView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
) : AbstractComposeView(context, attrs) {
    var displayText by mutableStateOf("")

    @Composable
    override fun Content() {
        Text(displayText)
    }
}

However!

Release builds crash with an IllegalStateException: ViewTreeLifecycleOwner not found ... when trying to display the Compose UI content. I haven't been able to nail down the exact reason why this only occurs in release builds, but I was able to figure out two workarounds:

  1. Update androidx.appcompat:appcompat to version 1.3.1+, discovered via this StackOverflow post and a few others; unfortunately this involved forking RN since it still uses version 1.0.2 which is from 2018 (!!!). Compose depends on some lifecycle and saved-state logic in androidx.activity.ComponentActivity, whereas ReactActivity currently ends up extending from the same-name-but-different-package androidx.core.app.ComponentActivity.

  2. Manually shim the missing logic using ViewTreeLifecycleOwner and ViewTreeSavedStateRegistryOwner; thankfully ReactActivity already implements LifecycleOwner via the older androidx.core.app.ComponentActivity, but you do need to add androidx.savedstate:savedstate-ktx version 1.1.0+ to your dependencies:

abstract class MyReactActivityDelegate(
    activity: ReactActivity,
    mainComponentName: String?,
) : ReactActivityDelegate(activity, mainComponentName) {

    private val shim = SavedStateRegistryOwnerShim(activity)

    @CallSuper
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        shim.onCreate(savedInstanceState)
    }

    private class SavedStateRegistryOwnerShim(
        private val activity: AppCompatActivity,
    ) : LifecycleOwner by activity, SavedStateRegistryOwner {

        private val controller = SavedStateRegistryController.create(this)
        override fun getSavedStateRegistry() = controller.savedStateRegistry

        fun onCreate(savedState: Bundle?) {
            activity.window.decorView.rootView.let { root ->
                ViewTreeLifecycleOwner.set(root, this)
                ViewTreeSavedStateRegistryOwner.set(root, this)
            }
            controller.performRestore(savedState)
        }
    }
}

class MyActivity : ReactActivity() {
    override fun createReactActivityDelegate() {
        return object : MyReactActivityDelegate(this, mainComponentName) { ... }
    }
}

As far as I can tell, both approaches prevent the crash and Compose seems to function as expected. However:

  • Option 1 involves forking RN and I'm not sure how the change may affect RN overall. I also assume that Compose will eventually require newer and newer AndroidX dependencies, so it would be nice to have them "officially" updated.

  • Option 2 is a kludge that may need to be periodically updated to match the AndroidX implementation, and I have no idea if it even fully implements all of the plumbing that Compose expects under the hood.

So, any chance we could get androidx.appcompat:appcompat updated to at least 1.3.1?

Or better yet, some kind of official support for Compose? Perhaps a SimpleComposeViewManager that directly accepts @Composable content?

@cortinico
Copy link
Member

Thanks for opening this discussion @thevoiceless

I'd like to loop in @ShikaSD and @mdvacca as they have relevant context on RN <=> Compose.

I think that we would like to add a sample components inside RN-Tester that uses a Composable to render. Potentially also adding a Guide to the website would be beneficial so that others could follow your approach.

As for the specific problem you point out:

Update androidx.appcompat:appcompat to version 1.3.1+

This seems like the way to go. You don't need to fork react-native, as specifying a dependency on androidx.appcompat:appcompat:1.3.+ inside your app build.gradle will force Gradle to pickup that dependency instead of the one provided by RN (1.0.2).

Specifically there was a crash reported that prevented the adoption of AppCompat 1.4.x, which was addressed a couple of months ago so you should be fine using AppCompat till 1.4.x.

That being said, it's actually a good call to update the version of AppCompat used by ReactNative to a newer one. I saw that some work was already done here: facebook/react-native#31620 so let's try to land that or a similar PR - cc @dulmandakh if you have bandwidth or if anyone else wants to pick this up, I'll be happy to review it.

@thevoiceless
Copy link
Author

Sounds good! I'm happy to help/contribute any way I can.

You don't need to fork react-native

Ah, looks like you're right; I'm using appcompat-resources in the project where I noticed the issue, not the full appcompat - my bad 😅

Thanks for looking into the appcompat issue!

@ShikaSD
Copy link

ShikaSD commented Jan 5, 2022

Hey, really happy to see someone trying Compose with React Native! Me and @mdvacca did a few related experiments in October last year, and it should be totally possible to use it with the legacy renderer the way you described, I think.

We were investigating Compose support with Fabric renderer, at the same time, and some of the required functionality is not really supported from Compose side (e.g. measure in background). I hacked around it for the sake of experiment, but not sure it can be considered "production-ready" yet with Fabric.

For now, we don't plan any official support for Compose (because of performance and feature related concerns), but will keep an eye on new developments/community feedback.

I also would love to hear about your experience if you manage to use a Compose-based view in your project :)

@thevoiceless
Copy link
Author

@ShikaSD I seem to recall seeing Fabric-related stuff in the stacktraces while investigating the appcompat issue; am I imagining things, or is it already enabled on Android?

@cortinico
Copy link
Member

or is it already enabled on Android?

It shouldn't be enabled by default, unless you specifically did it. Instructions on how to enable it are here: facebook/react-native-website#2879

@thevoiceless
Copy link
Author

Ah I definitely haven't done any of that, but might take a stab at it. Thanks!

@ShikaSD
Copy link

ShikaSD commented Jan 6, 2022

You might see Fabric related things in the stack traces even on legacy renderer because we adapted most of the Android native components from legacy renderer to work with both :)

@thevoiceless
Copy link
Author

For now, we don't plan any official support for Compose

@ShikaSD can you elaborate on this? Should I avoid investing any effort in using Compose if Fabric won't support it? Compose seems to be the future of UI on android, but Fabric seems to the future of UI in RN...

@ShikaSD
Copy link

ShikaSD commented Jan 6, 2022

Should I avoid investing any effort in using Compose

I think not, frankly the opposite :) My comment had more cautionary intention in case you are adopting Fabric right now. Compose support is just not ideal at the moment and it is going to be rough around the edges for some time :)

@thevoiceless
Copy link
Author

I think not, frankly the opposite

Awesome, I was hoping you'd say that! I'm working on proving out using Compose in various parts of my current project, so I'll keep you all posted with anything else that I find.

On that note: It seems to "just work" in DialogFragments with the updated appcompat as well 🙂

@thevoiceless
Copy link
Author

I believe I've found another issue:

I'm not 100% sure if this is the scenario to reproduce, but I have a component like

const Foo = () => {
  const [loading, setLoading] = React.useState(true)

  if (loading) {
    return (
      <View><ActivityIndicator /></View>
    )
  }

  return (
    <View><MyView /></View>
  )
}

where MyView is implemented as shown in my original post (i.e. SimpleViewManager returning an AbstractComposeView). However, when switching from the "loading" UI to the "loaded" UI, it appears that NativeViewHierarchyManager.updateLayout() is invoking

viewToUpdate.measure(
  View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
  View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY));

before viewToUpdate (in this case, my MyView) is attached to the window. This crashes with

java.lang.IllegalStateException: Cannot locate windowRecomposer; View ... is not attached to a window

because AbstractComposeView.onMeasure() calls ensureCompositionCreated() which calls resolveParentCompositionContext(). Unfortunately its onMeasure() is final so I can't simply add a check to no-op if not attached; the only workaround I've found is to extract some of the logic from View.createLifecycleAwareViewTreeRecomposer(): Recomposer declared in androidx.compose.ui.platform.WindowRecomposer.android.kt:

class MyView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
) : AbstractComposeView(context, attrs) {

    init {
        val currentThreadContext = AndroidUiDispatcher.CurrentThread
        val pausableClock = currentThreadContext[MonotonicFrameClock]?.let {
            PausableMonotonicFrameClock(it).apply { pause() }
        }
        val contextWithClock = currentThreadContext + (pausableClock ?: EmptyCoroutineContext)
        val recomposer = Recomposer(contextWithClock)
        setParentCompositionContext(recomposer)
    }

    override fun onAttachedToWindow() {
        setParentCompositionContext(null)
        super.onAttachedToWindow()
    }

    @Composable
    override fun Content() {
        // ...
    }
}

which ensures that some kind of parent composition context exists.....but I'm not exactly confident that this is "correct" 😅

@ShikaSD
Copy link

ShikaSD commented Jan 11, 2022

Yep, that's the issue we encountered with Fabric as well. I had to commit a few reflection crimes to get around that limitation, but maybe it is possible to just not measure the view until it is attached?

I don't remember the details on how legacy renderer works here, but maybe you could measure the view to the size of the parent (or just 0) and then resize when it is attached.

@ShikaSD
Copy link

ShikaSD commented Jan 11, 2022

If the only problem is the absense of composition context, it should be possible to extract it from parent view/activity context, and the set it similarly to the way you worked around this limitation (without resetting the composition context).

Compose attaches the recomposer to the view tag of android.R.id.content, IIRC, so if you can get hold on the activity, you can:

class MyView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
) : AbstractComposeView(context, attrs) {

    init {
        val activity = getActivity(context) // you probably want to unwrap it in case it is ContextWrapper
        val compositionContext = activity.findViewById(android.R.id.content)!!.compositionContext
        setParentCompositionContext(compositionContext)
    }

    override fun onAttachedToWindow() {
        // I don't think there's any need to re-attach here, as recomposer is created once per window.
        super.onAttachedToWindow()
    }

    @Composable
    override fun Content() {
        // ...
    }
}

This should work, albeit still very hacky :)

@ShikaSD
Copy link

ShikaSD commented Jan 11, 2022

Exported our internal proof of concept from October last year: facebook/react-native#32871
It tries to change a <Switch /> component to use Compose, supporting a subset of original props and events. All of it with Fabric only though, but seems like there are less issues with legacy renderer so far :)

cc @mdvacca

@thevoiceless
Copy link
Author

maybe it is possible to just not measure the view until it is attached

Unless I'm missing something, it looks like AbstractComposeView specifically forbids that by marking onMeasure() as final - it does expose an open fun internalOnMeasure() but that's not called until after ensureCompositionCreated(). It seems that any measurement workaround might have to be on the RN side of things - perhaps some kind of marker interface for special behavior? Just spitballing here...

sealed interface SpecialBehavior {
  interface MeasureOnlyWhenAttached : SpecialBehavior
  // ...
}

and then NativeViewHierarchyManager.updateLayout() would check if (view !is SpecialBehavior.MeasureOnlyWhenAttached)?

This should work, albeit still very hacky

Still looks better than my approach - I'll give it a shot!

Exported our internal proof of concept

Awesome, I'll take a look, thanks!

@thevoiceless
Copy link
Author

Compose attaches the recomposer to the view tag of android.R.id.content, IIRC, so if you can get hold on the activity, you can ...

I believe it actually uses the first child of android.R.id.content, and that strategy only works if another ComposeView has already been attached at some point (might even need to still be attached) which allows you to use findViewTreeCompositionContext(). The compositionContext is only assigned inside createAndInstallWindowRecomposer() invoked by the getter for windowRecomposer, and that getter is what checks isAttachedToWindow.

Unfortunately, createAndInstallWindowRecomposer() is internal and uses @DelicateCoroutinesApi, and WindowRecomposerFactory.LifecycleAware is marked as @InternalComposeUiApi - I'm guessing that's why you had to use reflection?

So it seems the options are:

....OR, since ReactRootView is a FrameLayout...

  • Throw a stub ComposeView into the view hierarchy, and use a base class that ensures the compositionContext exists
class MyActivity : ReactActivity() {
    override fun createReactActivityDelegate() {
        return object : ReactActivityDelegate(this, mainComponentName) {
            override fun createRootView() = createMyRootView(context)
                .apply { addView(StubComposeView(context) }
        }
    }
}

class StubComposeView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
) : AbstractComposeView(context, attrs) {
    init { isVisible = false }
    @Composable
    override fun Content() {}
}

abstract class MyBaseComposeView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
) : AbstractComposeView(context, attrs) {

    private val activity: Activity?
        get() {
            var candidate = context
            while (candidate !is Activity) {
                when (candidate) {
                    is ContextWrapper -> candidate = candidate.baseContext
                    else -> break
                }
            }
            return candidate as? Activity
        }

    private val activityCompositionContext: CompositionContext?
        get() = (activity?.findViewById<ViewGroup>(android.R.id.content))
            ?.children
            ?.mapNotNull { it.findViewTreeCompositionContext() }
            ?.firstOrNull()

    init {
        if (compositionContext == null) {
            compositionContext = findViewTreeCompositionContext() ?: activityCompositionContext
        }
    }
}

facebook/react-native#32871 seems closer to the "best" approach since you're integrating more with the Compose internals, but IMO this approach seems like a good tradeoff between effort and technical correctness without relying (too much) on internal details.

Thoughts?

@ShikaSD
Copy link

ShikaSD commented Jan 12, 2022

@thevoiceless Ah, interesting, I missed the point where they did it lazily, which makes a lot of sense, tbh

I guess for me calling the createAndInstallWindowRecomposer sounds like a good plan for the time being. Note that you don't really need reflection to access internal methods, as those methods are effectively public in Java 😅
It is still a hack, but feels like the best choice (in my opinion) until Compose UI team gives us a better way out of those guardrails,

@thevoiceless
Copy link
Author

Crafty! I'd totally forgotten about using Java to get around Kotlin's internal - that does appear to work as well 👍

@thevoiceless
Copy link
Author

thevoiceless commented Jan 14, 2022

I have run into another issue, although I think it's a bug with Compose instead of RN:

I'm using react-navigation to progress through screens A -> B -> C, pushing each onto the stack; B displays a native Compose view using the strategy from my initial post. Everything works as expected when progressing forward, but the Compose view does not appear on screen after pressing the "back" button to return to B from C.

Observations:

  • According to Flipper, the ComposeView is still present in the view hierarchy, attached, View.VISIBLE, and the correct size (width, measuredWidth, height, measuredHeight)
    • The view does not show in the Android Studio layout inspector after returning to B, but it did show before navigating to C
  • The view's Modifier.clickable(...) logic is not triggered when tapping where the it allegedly is
  • The view gets attached (and its composition created), measured, and laid out as expected when first navigating to B
  • With the default ViewCompositionStrategy, the composition is disposed when navigating to C. The composition is then recreated when the view is re-attached upon returning to B, but the view is not re-measured nor re-laid-out
    • I can recreate the initial sequence of events on re-attach by manually invoking measure(...) and layout(...) with the view's existing values, but the view does not appear
  • With ViewCompositionStrategy.DisposeOnLifecycleDestroyed, the composition is not disposed when navigating to C and that same composition is used when re-attaching in B, but the view does not appear
    • Same as above with measure(...) and layout(...)
  • invalidate(), requestLayout(), and forceLayout() have no effect

That all seems to rule out an issue with layout or measurement, so I started investigating Compose itself...

I tried updating values in the state:

var someValue by mutableStateOf(0)

override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    postDelayed({
        log("increment")
        someValue = someValue + 1
    }, 1000)
}

@Composable
override fun Content() {
    SideEffect { log("composition value $someValue") }
    // ...
}

It behaved as expected when first navigating to B, but indicated that recomposition was not happening after returning from C.

This led me deep into the guts of Compose, and I think I've narrowed it down to a bug in WrappedComposition and the interaction between owner.setOnViewTreeOwnersAvailable { ... } and its addedToLifecycle member. react-navigation uses Fragments under the hood by attaching/detaching them as you push/pop screens, which I believe is breaking some lifecycle-observation logic. Specifically:

  1. When navigating to B, initial composition invokes AbstractComposeView.setContent() which creates an AndroidComposeView and uses it as the owner for the WrappedComposition, then invokes its setContent() method
  2. WrappedComposition.setContent() calls owner.setOnViewTreeOwnersAvailable { ... }; none are immediately available because this is the first time setting everything up
  3. Eventually the AndroidComposeView is attached to the window and its ViewTreeOwners becomes available; addedToLifecycle is null so it grabs the lifecycle (i.e. LifecycleRegistry) from the lifecycleOwner (i.e. FragmentViewLifecycleOwner) and observes it.
    1. At this point, addedToLifecycle.currentState is INITIALIZED
  4. When the lifecycle reaches the CREATED state, setContent() is invoked again which ends up calling into the "real" setContent() for the composition.
    1. End result: The UI displays as expected
  5. When navigating to C, detaching B triggers Lifecycle.Event.ON_DESTROY and the composition is disposed but the same AndroidComposeView instance is kept around
    1. At this point, addedToLifecycle.currentState is DESTROYED
  6. When returning to B, the fragment is re-attached which sets up the composition again. However, setOnViewTreeOwnersAvailable { .. } returns immediately with the previous value because it's using the same AndroidComposeView as before - so it assigns addedToLifecycle and observes it without checking currentState, which is DESTROYED!
  7. The AndroidComposeView is eventually re-attached, once again triggering the setOnViewTreeOwnersAvailable { ... } callback with new data, but addedToLifecycle has already been assigned! So instead it checks if lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED), but at this point the lifecycle is only INITIALIZED. We never end up observing the correct lifecycle, so the logic ends there.
    1. End result: setContent() never gets called again

The issue still exists if you use ViewCompositionStrategy.DisposeOnLifecycleDestroyed with the activity; it doesn't invoke disposeComposition() on our AbstractComposeView, but internally the WrappedComposition still disposes itself when the fragment lifecycle is destroyed. AbstractComposeView only recreates the composition if it's null, even if if it's not-null-but-disposed. I'm not entirely sure why the ViewCompositionStrategy only seems to affect our AbstractComposeView....seems like a pretty big foot-gun if it doesn't actually affect any internals.

Thoughts?

@thevoiceless
Copy link
Author

I'm not 100% confident in my explanation; it seems like it'd be an obvious issue in any app using Compose with multiple fragments 🤔

@ShikaSD
Copy link

ShikaSD commented Jan 14, 2022

Great investigation! I think you are on the right track for the most of it, but I am not sure if the bug is in Compose view here.

When navigating to C, detaching B triggers Lifecycle.Event.ON_DESTROY and the composition is disposed but the same AndroidComposeView instance is kept around

I would expect the React surface to be destroyed in this case as well, did you track what keeps the view around? Assuming that composition is disposed and fragment is destroyed, view should be deleted as well.

@thevoiceless
Copy link
Author

thevoiceless commented Jan 14, 2022

I did not determine why the AndroidComposeView is staying around - AFAICT the only thing holding on to it is the AbstractComposeView itself, which adds it to itself in setContent(). The fragment isn't destroyed, only detached and re-attached, but that should result in destroying the view per the docs/SO.

Unfortunately I had to timebox my efforts to yesterday, so I don't think I'll be able to investigate this any further and will have to backtrack on using Compose for now 😞

@thevoiceless
Copy link
Author

I'm not sure if it's helpful, but this was my debugger output:

A -> B

MyView@124075016 onAttachedToWindow
MyView@124075016 create composition
MyView@124075016 setContent
addView AndroidComposeView@144268683 to MyView@124075016
AndroidComposeView@144268683 doSetContent
setContent: WrappedComposition@139419240 setOnViewTreeOwnersAvailable for owner AndroidComposeView@144268683
setOnViewTreeOwnersAvailable: vto not available yet
MyView@124075016 composition is now WrappedComposition@139419240
---
AndroidComposeView@144268683 onAttachedToWindow
vto now available
AndroidComposeView@144268683 vto callback ViewTreeOwners@151281446
vto callback: lifecycle is LifecycleRegistry@155076455 in state INITIALIZED, lifecycleOwner is FragmentViewLifecycleOwner@166721812
vto callback: WrappedComposition@139419240 observe lifecycle LifecycleRegistry@155076455 in state INITIALIZED
---
LifecycleRegistry@155076455 CREATED, source FragmentViewLifecycleOwner@166721812, calling setContent
setContent: WrappedComposition@139419240 setOnViewTreeOwnersAvailable for owner AndroidComposeView@144268683
setOnViewTreeOwnersAvailable: vto immediately available
AndroidComposeView@144268683 vto callback ViewTreeOwners@151281446
vto callback: lifecycle is LifecycleRegistry@155076455 in state CREATED, lifecycleOwner is FragmentViewLifecycleOwner@166721812
vto callback: WrappedComposition@139419240 already observing lifecycle LifecycleRegistry@155076455 in state CREATED, this lifecycle is LifecycleRegistry@155076455 in state CREATED
vto callback: CompositionImpl@239808920 'real' setContent

B -> C

AndroidComposeView@144268683 onDetachedFromWindow
MyView@124075016 onDetachedFromWindow
MyView@124075016 disposeComposition
WrappedComposition@139419240 dispose
CompositionImpl@239808920 dispose

C -> B

MyView@124075016 onAttachedToWindow
MyView@124075016 create composition
MyView@124075016 setContent
AndroidComposeView@144268683 doSetContent
setContent: WrappedComposition@163236301 setOnViewTreeOwnersAvailable for owner AndroidComposeView@144268683
setOnViewTreeOwnersAvailable: vto immediately available
AndroidComposeView@144268683 vto callback ViewTreeOwners@151281446
vto callback: lifecycle is LifecycleRegistry@155076455 in state DESTROYED, lifecycleOwner is FragmentViewLifecycleOwner@166721812
vto callback: WrappedComposition@163236301 observe lifecycle LifecycleRegistry@155076455 in state DESTROYED
---
MyView@124075016 composition is now WrappedComposition@163236301
---
AndroidComposeView@144268683 onAttachedToWindow
vto now available
AndroidComposeView@144268683 vto callback ViewTreeOwners@261264002
vto callback: lifecycle is LifecycleRegistry@211299731 in state INITIALIZED, lifecycleOwner is FragmentViewLifecycleOwner@224420304
vto callback: WrappedComposition@163236301 already observing lifecycle LifecycleRegistry@155076455 in state DESTROYED, this lifecycle is LifecycleRegistry@211299731 in state INITIALIZED

B -> A

AndroidComposeView@144268683 onDetachedFromWindow
MyView@124075016 onDetachedFromWindow
MyView@124075016 disposeComposition
WrappedComposition@163236301 dispose
CompositionImpl@230184436 dispose

@thevoiceless
Copy link
Author

thevoiceless commented Jan 14, 2022

And FWIW this is using react-native-screens in conjunction with react-navigation

Edit: Just a guess, but perhaps related to software-mansion/react-native-screens#843 (comment)

we cannot destroy and then make new views by restoring the state of the Fragment, since each view has its reactTag etc...we do not recreate the views of the Fragment, but rather call remove on the them when they become invisible and then add them back on the Screen becoming visible with the same Screen attached to it.

@ShikaSD
Copy link

ShikaSD commented Jan 14, 2022

I have a slight recollection about Fragment attach/detach methods and lifecycle, so maybe that's the issue that you are observing. It is still weird to me that view is not recreated after lifecycle is set to DESTROYED state, as that kinda indicates that Fragment was... destroyed?
detach documentation (https://developer.android.com/reference/androidx/fragment/app/FragmentTransaction?hl=en#detach(androidx.fragment.app.Fragment) suggests that view hierarchy should be destroyed as well, so I'd guess react-native-screens are doing something fishy to work around Fragment-RN incompatibility.

@thevoiceless
Copy link
Author

thevoiceless commented Jul 26, 2022

Had a bit of time to mess with this some more; unsurprisingly, that bug did not magically fix itself while I was away 😅

I've pushed a minimum reproducible example to https://github.com/thevoiceless/RN-Compose-Playground

It doesn't include any of the workarounds/modifications discussed above, just a barebones RN app created with --template react-native-template-typescript using @react-navigation/native-stack to move between screens. I also tried using @react-navigation/stack but it made no difference (branch: non-native-stack). I'm not super surprised since (I think) they both still use react-native-screens under the hood.

Untitled.mov

@thevoiceless
Copy link
Author

It appears the lifecycle/composition workarounds discussed earlier in this thread are no longer necessary as of:

  • RN 0.68
  • Compose compiler 1.3.0-rc01
  • Compose artifacts 1.2.0
  • Appcompat 1.4.2
  • Kotlin 1.7.10

I still see the disappearing issue with react-native-screens, but at least it's a step in the right direction!

@hurali97
Copy link

@thevoiceless @ShikaSD Pretty cool research done by you folks 🚀

I decided to find some answers to why RN-Screens won't work with compose view nicely. So here are the results:

When embedding a compose view in SimpleViewManager, while using react-native-screens, it doesn't play nicely when resuming the compose view.

I tried to look for the issues and based on this answer, I decided to do the same. But I couldn't do it in SimpleViewManager, so I had to use fragment to be able to listen to the life cycle methods.

When I embedded the compose view in a fragment, I didn't had to use that stackoverflow answer and just using fragments, solved the issue.

However there were some layout issue, which have been covered now.

We now have no issues with react-native-screens and compose view layouts fine.

Here's a PR to the playground @thevoiceless shared.

compose-view.mp4

@thevoiceless
Copy link
Author

@hurali97 Interesting! It seems a bit heavy-handed to host an entire Fragment just to show the composable, but good to know there's a workaround 🤔

@dm20
Copy link

dm20 commented May 24, 2023

Hi all, just wanted to report I am trying to do the same thing using AbstractComposeView in a Fabric component.

My only relevant dependencies are:
RN: 0.71.6

And in my Fabric component's build.gradle:
androidx.compose.ui:ui: 1.4.3

I still get

java.lang.IllegalStateException: Cannot locate windowRecomposer; View com.bsmlibrary.FabricComposable{54bed7b V.E...... ......I. 0,0-0,0 #1ba} is not attached to a window

@thevoiceless it sounded like you said this issue should go away in the new versions of RN and the compose dependency, but it's still happening for me :(

Here's what my AbstractComposable looks like:

import android.util.AttributeSet
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.AbstractComposeView
import com.example.components.MyView
import com.example.ui.theme.CustomTheme

// We create our custom UI component extending from AbstractComposeView
class FabricComposable @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AbstractComposeView(context, attrs, defStyleAttr) {

  // The Content function works as a Composable function so we can now define our Compose UI components to render.
    @Composable
    override fun Content() {
        CustomTheme() {

        }

        MyView()
    }
}

Has anyone found a way to make this work? Am I missing a dependency? Am I missing a piece of code in the AbstractComposeView?

@dm20
Copy link

dm20 commented May 25, 2023

also tried @hurali97 's solution but got the following error:

dispatchcommand called without surfaceid - fabric dispatchcommand must be called through fabric jsi api

@thevoiceless
Copy link
Author

@dm20 I haven't done anything with Fabric components yet 😕
I just updated https://github.com/thevoiceless/RN-Compose-Playground to RN 0.71.8 and Compose 1.4.2 yesterday; same approach as documented in this issue, and still seeing the same behavior

@dm20
Copy link

dm20 commented May 25, 2023

@thevoiceless thanks for your reply - I see, I'm confused because it looked like you were using the Fabric architecture. I am using Fabric architecture and am running into the "not attached to Window" issue. I wonder if I follow the same architecture as you would I be able to render my component...

Which architecture are you following for these native components? Is it the legacy React Native way of setting up a native component?

@dm20
Copy link

dm20 commented May 25, 2023

Ah I see...You add the package class in MainApplication..Interesting. Still curious how did you learn to do it this way? I haven't seen this in RN docs

@thevoiceless
Copy link
Author

thevoiceless commented May 25, 2023

@dm20 I'm doing things the "old" way. @ShikaSD shared a proof-of-concept using Fabric here: #446 (comment)

Beyond that, I don't know anything about using it yet (even without Compose)

But even if you switch to the old way, you'll run into the issue I showed here: #446 (comment)

@dm20
Copy link

dm20 commented May 25, 2023

Ok got it @thevoiceless thanks for clarifying.

I tried the old way, i.e. linking the package manually, used the exact same ViewManager class, then the same requireComponent usage in RN land. Still getting the "not attached to window" error! I wonder, is it because my app uses the new architecture? i.e. newArchEnabled=true in my gradle.properties

Also, I think the issue is not a problem for me. I just need to display one view with no navigation. Instead of navigation, the view redraws itself as the users advances through a flow

Thanks for the fabric example! I'll check it out

@thevoiceless
Copy link
Author

thevoiceless commented May 25, 2023

@dm20 Unfortunately I don't get much time to experiment with Compose + RN, so I have not tried the new architecture either 😅

Did you try the workaround from #446 (comment) ? The Compose code may have changed since then so I don't know if you can copy and paste exactly what's in that comment

Edit: Actually @ShikaSD specifically called this out as being an issue when using Fabric in #446 (comment)

@dm20
Copy link

dm20 commented May 25, 2023

@thevoiceless I am trying it right now, only problem is the getActivity method doesn't appear to be supported anymore in the same way...

@dm20
Copy link

dm20 commented May 25, 2023

Tried some workarounds getting the current activity from the ThemedReactContext using @ShikaSD suggested technique, still got "not attached to window" error 😢

@thevoiceless
Copy link
Author

Continuing my investigation from #446 (comment) ....

I used a breakpoint to assign addedToLifecycle = null in WrappedComposition.setContent() if its currentState is DESTROYED, and as far as I can tell - it works! Assigning null ensures that we observe the non-destroyed lifecycle.

Screen Shot 2023-05-25 at 5 05 39 PM

Untitled.mov

@thevoiceless
Copy link
Author

@ShikaSD
Copy link

ShikaSD commented May 27, 2023

I commented about this under the issue above, but wanted to mention it here as well.
ComposeView cannot be reused the same way as other ViewGroups, and you want to make sure the new view is created in fragment's onCreateView.

@thevoiceless
Copy link
Author

@ShikaSD does that mean this is related to https://issuetracker.google.com/issues/195342734 ?

Unfortunately I don't see an obvious way to hook into the lifecycle of fragments created by react-native-screens

@ShikaSD
Copy link

ShikaSD commented May 31, 2023

Yeah, most likely it is caused by the same recycling mechanism

@dwipalp1
Copy link

I am using the fabric native component to create abstract composeview and getting the following error at runtime an app gets crashes.

error: java.lang.AbstractMethodError: abstract method "void androidx.compose.ui.platform.AbstractComposeView.Content(androidx.compose.runtime.Composer, int)"

CustomView.kt

import android.content.Context
import android.graphics.Color
import android.view.Gravity
import androidx.activity.compose.setContent;
import android.util.AttributeSet
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.AbstractComposeView

class CustomView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
) : AbstractComposeView(context, attrs) {
    var displayText by mutableStateOf("")

    @Composable
    override fun Content() {
        Text(displayText)
    }
}


CustomViewManager.kt

import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.SimpleViewManager
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewManagerDelegate
import com.facebook.react.uimanager.annotations.ReactProp
import com.facebook.react.viewmanagers.RTNCustomViewManagerInterface
import com.facebook.react.viewmanagers.RTNCustomViewManagerDelegate

@ReactModule(name = CustomViewManager.NAME)
class CustomViewManager(context: ReactApplicationContext) : SimpleViewManager<CustomView>(), RTNCustomViewManagerInterface<CustomView> {
 private val delegate: ViewManagerDelegate<CustomView> = RTNCustomViewManagerDelegate(this)
 
  override fun getDelegate(): ViewManagerDelegate<CustomView> = delegate

  override fun getName(): String = NAME

  override fun createViewInstance(context: ThemedReactContext): CustomView = CustomView(context)

  @ReactProp(name = "text")
  override fun setText(view: CustomView, text: String?) {
    //view.text = text
  }

  companion object {
    const val NAME = "RTNCustomView"
  }
}

CustomViewPackage.kt

import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager

class CustomViewPackage : ReactPackage {
  override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
    listOf(CustomViewManager(reactContext))

  override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
    emptyList()
}

@Ehtisham95
Copy link

Ehtisham95 commented Jul 21, 2023

Thanks for trying Jetpack Compose in React native. Made my R&D much faster.

I am working on a Jetpack Compose native component and faced the same issue of IllegalStateException: ViewTreeLifecycleOwner not found ... when the component was wrapped in a <Modal> .

For reference versions are:
RN - 0.71.6
react-native-screens - 3.20.0

So far I have learned that there are two possible solutions:

  1. This one i.e. Create a Fragment and insert the Compose View in the fragment. This was an overkill for my component. But this can be useful if the view needs lifecycle events. Infact in the official documentation for Native UI Components, this is the mentioned method.

@thevoiceless @ShikaSD Pretty cool research done by you folks 🚀

I decided to find some answers to why RN-Screens won't work with compose view nicely. So here are the results:

When embedding a compose view in SimpleViewManager, while using react-native-screens, it doesn't play nicely when resuming the compose view.
I tried to look for the issues and based on this answer, I decided to do the same. But I couldn't do it in SimpleViewManager, so I had to use fragment to be able to listen to the life cycle methods.
When I embedded the compose view in a fragment, I didn't had to use that stackoverflow answer and just using fragments, solved the issue.
However there were some layout issue, which have been covered now.
We now have no issues with react-native-screens and compose view layouts fine.

Here's a PR to the playground @thevoiceless shared.

compose-view.mp4

  1. As I mentioned that I was facing the issue when the custom view was wrapped in a <Modal> container. So by wrapping the custom view with <View> instead of <Modal> resolved my issue. Not sure about the recomposition of the composable as layout Inspector in Android studio was unable to figure out that the View has a composable but the crash was resolved and the component was working correctly with props and events.

But I am looking forward for a better approach that will enable me to have no issue with 'react-native-screens'.

@chauhanazad
Copy link

In ContrainsLayout FilltoContraints not working, check below code.

ConstraintLayout (modifier = Modifier.background(color = Color.Black)){
        var inputValue : MutableState<String> = remember {
            mutableStateOf("")
        }
        var ans : MutableState<String> = remember{
            mutableStateOf("")
        }
        val card1 = createRef()
        val (row1, row2, row3, row4, row5) = createRefs()
Card(
            shape = RoundedCornerShape(0.dp,0.dp,20.dp,20.dp),
            modifier = Modifier
                .constrainAs(card1) {
                    top.linkTo(parent.top, margin = 0.dp)
                    start.linkTo(parent.start, margin = 0.dp)
                    end.linkTo(parent.end, margin = 0.dp)
                    bottom.linkTo(row1.top, margin = 10.dp)
                    height = Dimension.fillToConstraints
                },colors = CardDefaults.cardColors(
                containerColor = Color.DarkGray,
            )
        ) {
            TextField(
                value = inputValue.value,
                onValueChange = { },
                label = { Text("", color = Color.White, fontSize = 30.sp) },
                modifier = Modifier.fillMaxSize(),
                colors = TextFieldDefaults.textFieldColors(textColor = Color.White, containerColor = Color.DarkGray),
                textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.End),
            )
            TextField(
                value = ans.value,
                onValueChange = { },
                label = { Text("", color = Color.White, fontSize = 20.sp) },
                modifier = Modifier
                    .fillMaxWidth()
                    .height(50.dp),
                colors = TextFieldDefaults.textFieldColors(textColor = Color.White,containerColor = Color.DarkGray),
                textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.End)
            )
        }
        Row(modifier = Modifier.constrainAs(row1) {
            start.linkTo(parent.start, margin = 0.dp)
            end.linkTo(parent.end, margin = 10.dp)
            bottom.linkTo(row2.top, margin = 10.dp)
        }.background(color = Color.Black).border(0.dp,Color.Transparent), horizontalArrangement = Arrangement.spacedBy(15.dp)){...}

@emitchel
Copy link

emitchel commented Apr 7, 2024

@thevoiceless - thank you for dropping a detailed paper trail of your journey, specifically regarding the react-native-screens issue you've discussed. I, too, wanted to embed a native ComposeView inside a screen and have ultimately concluded that wrapping it within a Fragment is the best approach moving forward since overriding the nature of the composable lifecycle ultimately forces the composable to recompose, which isn't ideal in all scenarios. So unless react-native-screens starts treating their in house "Screen" fragments like normal fragments, instead of caching the views of fragment, as noted in that Google issue thread you created, our hands are tied. Created an issue on react-native-screens here software-mansion/react-native-screens#2098

That said, Wrapping a simple component in a Fragment comes with a whole different set of issues, specifically layout logistics. As seen here I'm hoping to come back to this thread with a solution that does all of that soon. ™️

Until then, I've come here to drop my award for the dirtiest hack around this problem. This isn't ideal for intense composable, be warned.

In the container that hosts the ComposeView, you can force a recomposition of the react component which allows the ComposeView to always recompose by updating the key.

  
  useFocusEffect(
    useCallback(() => {
      setKey(Date.now().toString());
      return () => {
        
      };
    }, [])

return (<MyComposeView key={key} />)

@thevoiceless
Copy link
Author

Thank you for creating that issue! I've transitioned away from React Native so this topic has fallen off of my radar. Per their reply, it sounds like they're at least aware of the issue: software-mansion/react-native-screens#2098 (comment)

@mensonones
Copy link

Thanks for trying Jetpack Compose in React native. Made my R&D much faster.

I am working on a Jetpack Compose native component and faced the same issue of IllegalStateException: ViewTreeLifecycleOwner not found ... when the component was wrapped in a <Modal> .

For reference versions are: RN - 0.71.6 react-native-screens - 3.20.0

So far I have learned that there are two possible solutions:

  1. This one i.e. Create a Fragment and insert the Compose View in the fragment. This was an overkill for my component. But this can be useful if the view needs lifecycle events. Infact in the official documentation for Native UI Components, this is the mentioned method.

@thevoiceless @ShikaSD Pretty cool research done by you folks 🚀
I decided to find some answers to why RN-Screens won't work with compose view nicely. So here are the results:

When embedding a compose view in SimpleViewManager, while using react-native-screens, it doesn't play nicely when resuming the compose view.
I tried to look for the issues and based on this answer, I decided to do the same. But I couldn't do it in SimpleViewManager, so I had to use fragment to be able to listen to the life cycle methods.
When I embedded the compose view in a fragment, I didn't had to use that stackoverflow answer and just using fragments, solved the issue.
However there were some layout issue, which have been covered now.
We now have no issues with react-native-screens and compose view layouts fine.

Here's a PR to the playground @thevoiceless shared.
compose-view.mp4

  1. As I mentioned that I was facing the issue when the custom view was wrapped in a <Modal> container. So by wrapping the custom view with <View> instead of <Modal> resolved my issue. Not sure about the recomposition of the composable as layout Inspector in Android studio was unable to figure out that the View has a composable but the crash was resolved and the component was working correctly with props and events.

But I am looking forward for a better approach that will enable me to have no issue with 'react-native-screens'.

Going through the same thing, my component renders in a View but when inside a Modal the app crash

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

11 participants