Skip to content
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
28 changes: 28 additions & 0 deletions .maestro/tests/material_top_bar_example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,34 @@ appId: com.pagerviewexample
- assertVisible:
text: 'Tab1'

- tapOn:
id: 'material-top-bar-scroll-list-button'

- extendedWaitUntil:
visible:
id: 'material-top-bar-list-item-30'
timeout: 5000

- tapOn:
id: 'material-top-bar-open-detail-button'

- extendedWaitUntil:
visible:
id: 'material-top-bar-detail-screen'
timeout: 5000

- pressKey: Back

- extendedWaitUntil:
visible:
id: 'material-top-bar-tab-1'
timeout: 5000

# The existing ComposeView and AndroidView host must survive the native-stack
# cover/reveal cycle, including the FlatList's native scroll position.
- assertVisible:
id: 'material-top-bar-list-item-30'

- tapOn: 'Tab2'

- extendedWaitUntil:
Expand Down
6 changes: 6 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ repositories {

def kotlin_version = getExtOrDefault('kotlinVersion')
def compose_version = getExtOrDefault('composeVersion')
def lifecycle_version = getExtOrDefault('lifecycleVersion')

dependencies {
//noinspection GradleDynamicVersion
Expand All @@ -246,6 +247,11 @@ dependencies {
implementation "androidx.compose.foundation:foundation:$compose_version"
implementation "androidx.compose.runtime:runtime:$compose_version"
implementation "androidx.compose.ui:ui:$compose_version"
// Needed for ComposeViewLifecycleOwner (see ComposePagerView.kt), which
// gives the ComposeView a self-owned Lifecycle instead of the ambient one.
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
testImplementation "androidx.arch.core:core-testing:2.2.0"
testImplementation "junit:junit:4.13.2"
}

if (isNewArchitectureEnabled()) {
Expand Down
1 change: 1 addition & 0 deletions android/gradle.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
PagerView_kotlinVersion=2.0.21
PagerView_composeVersion=1.7.8
PagerView_lifecycleVersion=2.6.1
PagerView_minSdkVersion=24
PagerView_targetSdkVersion=35
PagerView_compileSdkVersion=35
Expand Down
191 changes: 163 additions & 28 deletions android/src/main/java/com/reactnativepagerview/ComposePagerView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.UIManagerHelper
import com.reactnativepagerview.event.PageScrollEvent
Expand All @@ -37,7 +43,19 @@ import kotlin.math.sign
@OptIn(ExperimentalFoundationApi::class)
class ComposePagerView(context: Context) : FrameLayout(context) {
private val reactContext = context as ReactContext
private val composeView = ComposeView(context)
// react-native-screens fully removes and re-adds this screen's Fragment
// (rather than merely hiding it) while it's covered by another screen
// (see #1103), destroying that Fragment's view-tree Lifecycle. Compose's
// default composition-disposal strategies key off that ambient Lifecycle,
// so a ComposeView left to use them gets torn down on every cover/reveal
// cycle - and rebuilding the composition from scratch each time also
// discarded every page's native view host, resetting their state (e.g. a
// FlatList's scroll position, see #1104). Giving the ComposeView its own
// Lifecycle - one we control instead of the ambient, repeatedly-destroyed
// one - lets the composition (and each page's host) survive the cycle
// untouched. It only reaches DESTROYED for real in dispose() below.
private val composeLifecycleOwner = ComposeViewLifecycleOwner()
private var composeView: ComposeView? = null
private val pages = mutableStateListOf<View>()
private val scrollEnabledState = mutableStateOf(true)
private val orientationState = mutableStateOf(Orientation.Horizontal)
Expand All @@ -57,45 +75,68 @@ class ComposePagerView(context: Context) : FrameLayout(context) {
private val scrollCommandState = mutableStateOf<ScrollCommand?>(null)
private var lastEmittedScrollState: String? = null
private var lastEmittedPageSelected: Int? = null
private var didSetContent = false

init {
id = View.generateViewId()
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
isSaveEnabled = false

composeView.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
composeView.isSaveEnabled = false
composeView.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
applyOverScrollMode()
touchSlop = ViewConfiguration.get(context).scaledTouchSlop
}

private fun createComposeView(): ComposeView {
return ComposeView(context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
isSaveEnabled = false
layoutDirection = androidLayoutDirection()
overScrollMode = androidOverScrollMode()
// Bind our own Lifecycle before the composition is created, so Compose
// resolves it instead of walking up to the ambient (and repeatedly
// destroyed) Fragment view-tree Lifecycle - see the field comment above.
setViewTreeLifecycleOwner(composeLifecycleOwner)
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnLifecycleDestroyed(composeLifecycleOwner)
)
}
}

override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (composeView.parent == null) {
super.addView(composeView)
post {
measureAndLayoutComposeView()
}
}
if (!didSetContent) {
didSetContent = true
composeView.setContent {
// Read the ambient owner from our parent before installing the stable
// owner on the ComposeView below. This keeps the composition alive across
// Fragment view-tree replacement while still following pause/stop events
// from the currently active host.
composeLifecycleOwner.attach(findViewTreeLifecycleOwner()?.lifecycle)
if (composeView == null) {
val view = createComposeView()
composeView = view
super.addView(view)
view.setContent {
PagerContent()
}
}
post {
measureAndLayoutComposeView()
}
}

override fun onDetachedFromWindow() {
updateSameOrientationAncestorsGestureState(false)
if (composeView.parent === this) {
super.removeView(composeView)
didSetContent = false
}
// composeView is intentionally left attached and alive here: its
// Lifecycle is self-owned (see composeLifecycleOwner) and only reaches
// DESTROYED in dispose(). We pause it to CREATED instead, so lifecycle-
// aware content within pages (video players, nested Compose effects,
// screen-view tracking) stops doing work while covered - this doesn't
// dispose the composition, since DisposeOnLifecycleDestroyed only acts
// on ON_DESTROY.
composeLifecycleOwner.detach()
super.onDetachedFromWindow()
}

fun dispose() {
composeLifecycleOwner.destroy()
}

override fun dispatchTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
Expand Down Expand Up @@ -149,19 +190,20 @@ class ComposePagerView(context: Context) : FrameLayout(context) {
val width = width.takeIf { it > 0 } ?: measuredWidth
val height = height.takeIf { it > 0 } ?: measuredHeight
if (measureComposeView(width, height)) {
composeView.layout(0, 0, width, height)
composeView?.layout(0, 0, width, height)
}
}

private fun measureComposeView(
width: Int = measuredWidth,
height: Int = measuredHeight
): Boolean {
if (composeView.parent !== this || width <= 0 || height <= 0) {
val view = composeView
if (view == null || view.parent !== this || width <= 0 || height <= 0) {
return false
}

composeView.measure(
view.measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
)
Expand Down Expand Up @@ -238,13 +280,16 @@ class ComposePagerView(context: Context) : FrameLayout(context) {

fun setLayoutDirection(value: String) {
layoutDirectionState.value = if (value == "rtl") LayoutDirection.Rtl else LayoutDirection.Ltr
val androidLayoutDirection = if (layoutDirectionState.value == LayoutDirection.Rtl) {
layoutDirection = androidLayoutDirection()
composeView?.layoutDirection = androidLayoutDirection()
}

private fun androidLayoutDirection(): Int {
return if (layoutDirectionState.value == LayoutDirection.Rtl) {
View.LAYOUT_DIRECTION_RTL
} else {
View.LAYOUT_DIRECTION_LTR
}
layoutDirection = androidLayoutDirection
composeView.layoutDirection = androidLayoutDirection
}

fun setOffscreenPageLimit(value: Int) {
Expand All @@ -265,13 +310,17 @@ class ComposePagerView(context: Context) : FrameLayout(context) {
}

private fun applyOverScrollMode() {
val androidOverScrollMode = when (overScrollModeState.value) {
val androidOverScrollMode = androidOverScrollMode()
overScrollMode = androidOverScrollMode
composeView?.overScrollMode = androidOverScrollMode
}

private fun androidOverScrollMode(): Int {
return when (overScrollModeState.value) {
OverScrollMode.Never -> View.OVER_SCROLL_NEVER
OverScrollMode.Always -> View.OVER_SCROLL_ALWAYS
OverScrollMode.Auto -> View.OVER_SCROLL_IF_CONTENT_SCROLLS
}
overScrollMode = androidOverScrollMode
composeView.overScrollMode = androidOverScrollMode
}

private fun setSameOrientationChildGestureActive(value: Boolean) {
Expand Down Expand Up @@ -606,3 +655,89 @@ class ComposePagerView(context: Context) : FrameLayout(context) {
}
}
}

// A stable Lifecycle that follows the currently attached host through
// CREATED/STARTED/RESUMED, but only moves to DESTROYED when destroy() is
// called explicitly. This lets it survive react-native-screens replacing the
// ambient Fragment view-tree owner (see the field comment above).
internal class ComposeViewLifecycleOwner : LifecycleOwner, LifecycleEventObserver {
private val registry = LifecycleRegistry(this).apply {
currentState = Lifecycle.State.CREATED
}
private var hostLifecycle: Lifecycle? = null
private var isAttached = false
private var isDestroyed = false

override val lifecycle: Lifecycle
get() = registry

fun attach(lifecycle: Lifecycle?) {
if (isDestroyed) {
return
}

isAttached = true
setHostLifecycle(lifecycle)
updateState()
}

fun detach() {
if (isDestroyed) {
return
}

isAttached = false
setHostLifecycle(null)
updateState()
}

fun destroy() {
if (isDestroyed) {
return
}

isDestroyed = true
isAttached = false
setHostLifecycle(null)
registry.currentState = Lifecycle.State.DESTROYED
}

override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_DESTROY && source.lifecycle === hostLifecycle) {
setHostLifecycle(null)
}
updateState()
}

private fun setHostLifecycle(lifecycle: Lifecycle?) {
if (hostLifecycle === lifecycle) {
return
}

hostLifecycle?.removeObserver(this)
hostLifecycle = lifecycle
lifecycle?.addObserver(this)
}

private fun updateState() {
if (isDestroyed) {
return
}

registry.currentState = if (!isAttached) {
Lifecycle.State.CREATED
} else {
when (hostLifecycle?.currentState) {
Lifecycle.State.RESUMED -> Lifecycle.State.RESUMED
Lifecycle.State.STARTED -> Lifecycle.State.STARTED
// INITIALIZED is expected briefly when react-native-screens installs
// a replacement Fragment view tree. DESTROYED belongs to the old
// tree. Neither should destroy or block the retained composition.
Lifecycle.State.INITIALIZED,
Lifecycle.State.CREATED,
Lifecycle.State.DESTROYED,
null -> Lifecycle.State.CREATED
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ class PagerViewViewManager : ViewGroupManager<ComposePagerView>(), RNCViewPagerM
return true
}

override fun onDropViewInstance(view: ComposePagerView) {
view.dispose()
super.onDropViewInstance(view)
}

@ReactProp(name = "scrollEnabled", defaultBoolean = true)
override fun setScrollEnabled(view: ComposePagerView?, value: Boolean) {
if (view != null) {
Expand Down
Loading
Loading