Skip to content
Open
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
4 changes: 4 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,9 @@ 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"
}

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
129 changes: 101 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,10 @@ 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.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
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 +41,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 +73,64 @@ 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 {
composeLifecycleOwner.resume()
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.pause()
super.onDetachedFromWindow()
}

fun dispose() {
composeLifecycleOwner.destroy()
}

override fun dispatchTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
Expand Down Expand Up @@ -149,19 +184,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 +274,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 +304,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 +649,33 @@ class ComposePagerView(context: Context) : FrameLayout(context) {
}
}
}

// A Lifecycle that isn't derived from the ambient Fragment/Activity: it
// starts RESUMED and only moves to DESTROYED when destroy() is called
// explicitly, so it survives being covered/revealed by react-native-screens'
// Fragment remove+re-add (see the composeLifecycleOwner field comment on
// ComposePagerView above).
private class ComposeViewLifecycleOwner : LifecycleOwner {
private val registry = LifecycleRegistry(this).apply {
currentState = Lifecycle.State.RESUMED
}

override val lifecycle: Lifecycle
get() = registry

fun resume() {
if (registry.currentState != Lifecycle.State.DESTROYED) {
registry.currentState = Lifecycle.State.RESUMED
}
}

fun pause() {
if (registry.currentState != Lifecycle.State.DESTROYED) {
registry.currentState = Lifecycle.State.CREATED
}
}

fun destroy() {
registry.currentState = Lifecycle.State.DESTROYED
}
}
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