discussions-and-proposals icon indicating copy to clipboard operation
discussions-and-proposals copied to clipboard

[Android] Discussion: Support for Jetpack Compose

Open thevoiceless opened this issue 3 years ago • 50 comments

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?

thevoiceless avatar Jan 04 '22 21:01 thevoiceless

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: https://github.com/facebook/react-native/pull/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.

cortinico avatar Jan 05 '22 11:01 cortinico

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!

thevoiceless avatar Jan 05 '22 18:01 thevoiceless

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 :)

ShikaSD avatar Jan 05 '22 20:01 ShikaSD

@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?

thevoiceless avatar Jan 05 '22 21:01 thevoiceless

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: https://github.com/facebook/react-native-website/pull/2879

cortinico avatar Jan 05 '22 21:01 cortinico

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

thevoiceless avatar Jan 05 '22 21:01 thevoiceless

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 :)

ShikaSD avatar Jan 06 '22 00:01 ShikaSD

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...

thevoiceless avatar Jan 06 '22 17:01 thevoiceless

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 :)

ShikaSD avatar Jan 06 '22 18:01 ShikaSD

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 avatar Jan 10 '22 19:01 thevoiceless

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" 😅

thevoiceless avatar Jan 11 '22 02:01 thevoiceless

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 avatar Jan 11 '22 15:01 ShikaSD

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 avatar Jan 11 '22 15:01 ShikaSD

Exported our internal proof of concept from October last year: https://github.com/facebook/react-native/pull/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

ShikaSD avatar Jan 11 '22 16:01 ShikaSD

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 avatar Jan 11 '22 17:01 thevoiceless

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:

  • Reflection to invoke createAndInstallWindowRecomposer()
  • Opt-in to @InternalComposeUiApi and copy implementation details directly from Compose
  • Do the stuff you did in https://github.com/facebook/react-native/pull/32871

....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
        }
    }
}

https://github.com/facebook/react-native/pull/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?

thevoiceless avatar Jan 11 '22 22:01 thevoiceless

@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,

ShikaSD avatar Jan 12 '22 16:01 ShikaSD

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

thevoiceless avatar Jan 14 '22 01:01 thevoiceless

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 avatar Jan 14 '22 02:01 thevoiceless

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 🤔

thevoiceless avatar Jan 14 '22 02:01 thevoiceless

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.

ShikaSD avatar Jan 14 '22 04:01 ShikaSD

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 avatar Jan 14 '22 16:01 thevoiceless

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 avatar Jan 14 '22 18:01 thevoiceless

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

Edit: Just a guess, but perhaps related to https://github.com/software-mansion/react-native-screens/issues/843#issuecomment-832034119

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.

thevoiceless avatar Jan 14 '22 18:01 thevoiceless

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.

ShikaSD avatar Jan 14 '22 23:01 ShikaSD

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.

https://user-images.githubusercontent.com/1574103/181108249-84b6dbd4-b455-40e9-b292-648238767187.mov

thevoiceless avatar Jul 26 '22 20:07 thevoiceless

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!

thevoiceless avatar Jul 29 '22 19:07 thevoiceless

@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.

https://user-images.githubusercontent.com/47336142/228035357-d90447ae-4ce2-4ebf-a1c4-98554def2354.mp4

hurali97 avatar Mar 27 '23 18:03 hurali97

@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 🤔

thevoiceless avatar May 03 '23 22:05 thevoiceless

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 avatar May 24 '23 18:05 dm20