From 94ef1e1d57d30f8db2daff1bcc2deaa02ee16bc2 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sun, 27 Sep 2026 00:55:41 +0800 Subject: [PATCH 01/16] feat: support rich native lists inside pagers --- .../android/build.gradle | 7 + .../com/onekey/nativelist/HybridNativeList.kt | 20 ++ .../NativeListActivityDetailsView.kt | 179 ++++++++++++++ .../nativelist/NativeListActivityRenderer.kt | 25 +- .../NativeListContainerSlotAdapter.kt | 53 ++++ .../nativelist/NativeListEndAlignment.kt | 5 + .../nativelist/NativeListMediaPreviewSlot.kt | 203 ++++++++++++++++ .../nativelist/NativeListMediaTileRenderer.kt | 21 +- .../com/onekey/nativelist/NativeListModels.kt | 4 +- .../NativeListScrollPositionTracker.kt | 29 +++ .../NativeListSectionHeaderRenderer.kt | 17 +- .../com/onekey/nativelist/NativeListView.kt | 189 +++++++++++++-- .../values/native_scroll_coordinator_ids.xml | 4 + .../nativelist/NativeListEndAlignmentTest.kt | 34 +++ .../NativeListScrollPositionTrackerTest.kt | 28 +++ .../react-native-native-list/docs/DESIGN.md | 6 +- .../docs/ROW_TEMPLATES.md | 45 ++++ .../react-native-native-list/docs/SPEC.md | 206 +++++++++++++++- .../docs/STYLE_SPEC.md | 8 + .../ios/HybridNativeList.swift | 30 +++ .../ios/NativeListActivityDetailsView.swift | 229 ++++++++++++++++++ .../ios/NativeListActivityRenderer.swift | 26 +- .../ios/NativeListMediaPreviewSlot.swift | 199 +++++++++++++++ .../ios/NativeListMediaTileRenderer.swift | 14 +- .../ios/NativeListModels.swift | 5 +- .../ios/NativeListScrollPositionTracker.swift | 30 +++ .../ios/NativeListSectionHeaderRenderer.swift | 28 ++- .../ios/RNCNativeListView.swift | 115 ++++++++- .../NativeListActivityPresentationTests.swift | 43 ++++ .../ios/tests/NativeListLifecycleTests.swift | 36 +++ .../NativeListMediaVisibilityTests.swift | 42 ++++ ...NativeListScrollPositionTrackerTests.swift | 25 ++ .../react-native-native-list/package.json | 5 +- .../scripts/enable-container-slots.mjs | 58 +++++ .../scripts/enable-container-slots.test.mjs | 58 +++++ .../src/NativeList.nitro.ts | 3 + .../src/NativeList.tsx | 79 +++++- .../src/NativeList.types.ts | 15 +- .../src/NativeList.web.tsx | 5 + .../src/__tests__/activityRich.test.ts | 90 +++++++ .../src/__tests__/nativeMediaGrid.test.ts | 85 +++++++ .../react-native-native-list/src/models.ts | 39 ++- .../src/validation.ts | 150 +++++++++++- .../react-native-pager-view/README.md | 2 + .../CollapsiblePagerHost.kt | 18 ++ .../NativeScrollCoordinatorContributions.kt | 15 ++ .../values/native_scroll_coordinator_ids.xml | 4 + ...ativeScrollCoordinatorContributionsTest.kt | 26 ++ .../react-native-pager-view/docs/SPEC.md | 43 ++++ .../react-native-pager-view/package.json | 1 + 50 files changed, 2541 insertions(+), 60 deletions(-) create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActivityDetailsView.kt create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListContainerSlotAdapter.kt create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListEndAlignment.kt create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaPreviewSlot.kt create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListScrollPositionTracker.kt create mode 100644 native-views/react-native-native-list/android/src/main/res/values/native_scroll_coordinator_ids.xml create mode 100644 native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListEndAlignmentTest.kt create mode 100644 native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListScrollPositionTrackerTest.kt create mode 100644 native-views/react-native-native-list/ios/NativeListActivityDetailsView.swift create mode 100644 native-views/react-native-native-list/ios/NativeListMediaPreviewSlot.swift create mode 100644 native-views/react-native-native-list/ios/NativeListScrollPositionTracker.swift create mode 100644 native-views/react-native-native-list/ios/tests/NativeListActivityPresentationTests.swift create mode 100644 native-views/react-native-native-list/ios/tests/NativeListMediaVisibilityTests.swift create mode 100644 native-views/react-native-native-list/ios/tests/NativeListScrollPositionTrackerTests.swift create mode 100644 native-views/react-native-native-list/scripts/enable-container-slots.mjs create mode 100644 native-views/react-native-native-list/scripts/enable-container-slots.test.mjs create mode 100644 native-views/react-native-native-list/src/__tests__/activityRich.test.ts create mode 100644 native-views/react-native-native-list/src/__tests__/nativeMediaGrid.test.ts create mode 100644 native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollCoordinatorContributions.kt create mode 100644 native-views/react-native-pager-view/android/src/main/res/values/native_scroll_coordinator_ids.xml create mode 100644 native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollCoordinatorContributionsTest.kt create mode 100644 native-views/react-native-pager-view/docs/SPEC.md diff --git a/native-views/react-native-native-list/android/build.gradle b/native-views/react-native-native-list/android/build.gradle index 40b0db6c4..7f49a670a 100644 --- a/native-views/react-native-native-list/android/build.gradle +++ b/native-views/react-native-native-list/android/build.gradle @@ -94,11 +94,18 @@ repositories { } def kotlinVersion = getExtOrDefault("kotlinVersion") +// Keep native media preview formats aligned with react-native-video's default. +def media3Version = rootProject.ext.has("media3Version") ? rootProject.ext.get("media3Version") : "1.4.1" dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" implementation "androidx.recyclerview:recyclerview:1.4.0" + implementation "androidx.media3:media3-exoplayer:$media3Version" + implementation "androidx.media3:media3-exoplayer-hls:$media3Version" + implementation "androidx.media3:media3-exoplayer-dash:$media3Version" + implementation "androidx.media3:media3-ui:$media3Version" + implementation "androidx.media3:media3-datasource-okhttp:$media3Version" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation project(":react-native-nitro-modules") implementation project(":onekeyfe_react-native-native-logger") diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/HybridNativeList.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/HybridNativeList.kt index 3ed2d2934..356a36f95 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/HybridNativeList.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/HybridNativeList.kt @@ -11,6 +11,26 @@ class HybridNativeList(context: ThemedReactContext) : HybridNativeListSpec() { override val view: View = hostView + override var scrollPositionThresholdsJson: String = "" + set(value) { + if (field == value) return + field = value + dispatchToUi { hostView.setScrollPositionThresholdsJson(value) } + } + + override var onScrollPositionThresholdChange: ((isBeyondThreshold: Boolean) -> Unit)? = null + set(value) { + field = value + dispatchToUi { hostView.onScrollPositionThresholdChange = value } + } + + override var containerSlotHeightsJson: String = "[]" + set(value) { + if (field == value) return + field = value + dispatchToUi { hostView.setContainerSlotHeightsJson(value) } + } + override var snapshotJson: String = "" get() = field set(value) { diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActivityDetailsView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActivityDetailsView.kt new file mode 100644 index 000000000..720a9624c --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActivityDetailsView.kt @@ -0,0 +1,179 @@ +package com.margelo.nitro.nativelist + +import android.graphics.drawable.GradientDrawable +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.style.AbsoluteSizeSpan +import android.view.Gravity +import android.view.View +import android.widget.LinearLayout +import com.facebook.react.uimanager.ThemedReactContext +import kotlin.math.roundToInt +import org.json.JSONObject + +/** Bounded rich activity fields; no wallet or transaction semantics live here. */ +internal class NativeListActivityDetailsView(private val reactContext: ThemedReactContext) : LinearLayout(reactContext) { + var onAction: ((String, View, String, Int) -> Unit)? = null + private var sourceScale = false + private val main = LinearLayout(context).apply { orientation = HORIZONTAL; gravity = Gravity.CENTER_VERTICAL } + private val visual = NativeListLeadingVisual(reactContext) + private val identity = LinearLayout(context).apply { orientation = VERTICAL } + private val titleLine = LinearLayout(context).apply { orientation = HORIZONTAL; gravity = Gravity.CENTER_VERTICAL } + private val title = NativeListTextView(context) + private val description = NativeListTextView(context) + private val status = NativeListTextView(context) + private val amounts = LinearLayout(context).apply { orientation = VERTICAL } + private val fee = LinearLayout(context).apply { orientation = VERTICAL; gravity = Gravity.END } + private val feeLabel = NativeListTextView(context) + private val feeLine = LinearLayout(context).apply { orientation = HORIZONTAL; gravity = Gravity.END } + private val feePrimary = NativeListTextView(context) + private val feeSecondary = NativeListTextView(context) + private val actions = LinearLayout(context).apply { orientation = HORIZONTAL } + private val amountViews = mutableListOf() + private val badges = mutableListOf() + + init { + orientation = VERTICAL + titleLine.addView(title, LayoutParams(0, -2, 1f)) + identity.addView(titleLine) + identity.addView(description) + identity.addView(status) + feeLine.addView(feePrimary); feeLine.addView(feeSecondary) + fee.addView(feeLabel); fee.addView(feeLine) + main.addView(visual) + main.addView(identity, LayoutParams(0, -2, 1f)) + main.addView(amounts) + main.addView(fee) + addView(main, LayoutParams(-1, -2)) + addView(actions, LayoutParams(-1, -2)) + } + private fun dp(value: Int) = if (sourceScale) (value * resources.displayMetrics.density).roundToInt() else NativeListScale.dp(resources, value) + private fun px(value: Double) = (value * resources.displayMetrics.density).roundToInt() + private fun color(tone: String, theme: JSONObject?): Int { + val (key, fallback) = when (tone) { + "positive", "success" -> "positive" to "#218358" + "negative", "danger" -> "negative" to "#CE2C31" + "secondary" -> "secondaryText" to "#646464" + "warning" -> "warning" to "#AB6400" + "info" -> "info" to "#007BEF" + else -> "primaryText" to "#202020" + } + return parseNativeListColor(theme?.optString(key, fallback) ?: fallback) + } + private fun text(label: NativeListTextView, value: String, style: JSONObject?, size: Float, + ink: Int, line: Int, weight: String = "regular", lines: Int = 1) { + NativeListResolvedText.resolve(context, value, style, size, weight, ink, line, lines, sourceScale).bind(label) + } + fun bind(item: NativeListItem, theme: JSONObject?, sourceScale: Boolean) { + this.sourceScale = sourceScale + val data = item.json + val style = data.optJSONObject("style") ?: JSONObject() + val image = style.optJSONObject("image") ?: JSONObject() + val table = data.optString("presentation") == "table" + val gap = if (style.has("leadingGap")) px(style.optDouble("leadingGap")) else dp(12) + val iw = if (image.has("width")) px(image.optDouble("width")) else dp(40) + visual.layoutParams = LayoutParams(iw, if (image.has("height")) px(image.optDouble("height")) else dp(40)).apply { marginEnd = gap } + visual.bind(data.optJSONObject("leading") ?: JSONObject(), image, item.key, theme, false, sourceScale, data.optJSONObject("secondaryLeading")) + amounts.layoutParams = if (table) LayoutParams(0, -2, 1f).apply { marginStart = gap } else LayoutParams(-2, -2).apply { marginStart = gap } + text(title, data.optString("title"), style.optJSONObject("title"), 16f, color("primary", theme), 24, "medium") + text(description, data.optString("description"), style.optJSONObject("description"), 14f, color("secondary", theme), 20, lines = 2) + text(status, data.optString("status"), style.optJSONObject("status"), 12f, color("secondary", theme), 16) + description.setOnClickListener(null) + val descriptionKey = data.optString("descriptionActionKey") + description.isClickable = descriptionKey.isNotEmpty() + if (descriptionKey.isNotEmpty()) description.setOnClickListener { onAction?.invoke(descriptionKey, description, "description", 0) } + for (view in listOf(description, status)) (view.layoutParams as LayoutParams).topMargin = if (style.has("lineGap")) px(style.optDouble("lineGap")) else 0 + badges.forEach(titleLine::removeView); badges.clear() + val badgeData = data.optJSONArray("badges") + for (index in 0 until (badgeData?.length() ?: 0)) { + val descriptor = badgeData!!.getJSONObject(index) + val label = NativeListTextView(context) + val ink = color(descriptor.optString("tone"), theme) + text(label, descriptor.optString("text"), null, 12f, ink, 16, "medium") + label.setPadding(dp(4), 0, dp(4), 0) + label.background = GradientDrawable().apply { setColor((ink and 0x00ffffff) or 0x19000000); cornerRadius = dp(4).toFloat() } + titleLine.addView(label, LayoutParams(-2, -2).apply { marginStart = if (style.has("titleBadgeGap")) px(style.optDouble("titleBadgeGap")) else dp(4) }) + badges.add(label) + } + val amountData = data.optJSONArray("amounts") + val count = amountData?.length() ?: 0 + while (amountViews.size > count) { val child = amountViews.removeAt(amountViews.lastIndex); child.recycle(); amounts.removeView(child) } + while (amountViews.size < count) { val child = AmountView(); amountViews.add(child); amounts.addView(child) } + for (index in 0 until count) { + val descriptor = amountData!!.getJSONObject(index) + amountViews[index].bind(descriptor, "${item.key}:${descriptor.optString("key")}", style, theme, table) + amountViews[index].layoutParams = LayoutParams(-1, -2).apply { topMargin = if (index == 0) 0 else if (style.has("trailingGap")) px(style.optDouble("trailingGap")) else dp(2) } + } + val feeData = data.optJSONObject("fee") + fee.visibility = if (feeData == null) GONE else VISIBLE + fee.alpha = if (feeData?.optBoolean("hidden") == true) 0f else 1f + fee.layoutParams = LayoutParams(-2, -2).apply { marginStart = gap } + text(feeLabel, feeData?.optString("label") ?: "", style.optJSONObject("secondaryAmount"), 12f, color("secondary", theme), 16) + text(feePrimary, feeData?.optString("primary") ?: "", style.optJSONObject("secondaryAmount"), 14f, color("primary", theme), 20) + text(feeSecondary, feeData?.optString("secondary") ?: "", style.optJSONObject("secondaryAmount"), 12f, color("secondary", theme), 16) + (feeSecondary.layoutParams as LayoutParams).marginStart = dp(4) + applySegments(feePrimary, feeData?.optJSONArray("primaryTextSegments"), style.optJSONObject("secondaryAmount")?.optDouble("fontSize", 14.0) ?: 14.0) + applySegments(feeSecondary, feeData?.optJSONArray("secondaryTextSegments"), style.optJSONObject("secondaryAmount")?.optDouble("fontSize", 12.0) ?: 12.0) + actions.removeAllViews() + val actionData = data.optJSONArray("footerActions") + actions.visibility = if ((actionData?.length() ?: 0) == 0) GONE else VISIBLE + actions.layoutParams = LayoutParams(-1, -2).apply { marginStart = iw + gap; topMargin = dp(8) } + for (index in 0 until (actionData?.length() ?: 0)) { + val descriptor = actionData!!.getJSONObject(index) + val button = NativeListTextView(context) + text(button, descriptor.optString("label"), null, 14f, color(descriptor.optString("tone"), theme), 20, "medium") + button.setPadding(dp(8), dp(4), dp(8), dp(4)) + button.background = GradientDrawable().apply { setColor(parseNativeListColor(theme?.optString("strongBackground", "#F0F0F0") ?: "#F0F0F0")); cornerRadius = dp(8).toFloat() } + button.isEnabled = !data.optBoolean("disabled") && !descriptor.optBoolean("disabled") + button.setOnClickListener { onAction?.invoke(descriptor.optString("key"), button, "footerAction", index) } + actions.addView(button, LayoutParams(-2, -2).apply { marginEnd = dp(8) }) + } + } + private fun applySegments(label: NativeListTextView, segments: org.json.JSONArray?, fontSize: Double) { + if (segments == null || segments.length() == 0) return + val fullText = (0 until segments.length()).joinToString("") { segments.getJSONObject(it).optString("text") } + val styled = if (label.text.toString() == fullText) SpannableStringBuilder(label.text) else SpannableStringBuilder(fullText) + var start = 0 + for (index in 0 until segments.length()) { + val segment = segments.getJSONObject(index) + val end = start + segment.optString("text").length + if (segment.optString("style") == "subscript") { + // Match NumberSizeableText: shrink the zero count without moving its baseline. + val size = kotlin.math.ceil(fontSize * 0.6) * label.textSize / fontSize + styled.setSpan(AbsoluteSizeSpan(size.roundToInt()), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + start = end + } + label.text = styled + } + fun recycle() { visual.recycle(); amountViews.forEach { it.recycle() }; description.setOnClickListener(null); actions.removeAllViews() } + fun dispose() { recycle(); visual.dispose(); amountViews.forEach { it.dispose() } } + + private inner class AmountView : LinearLayout(context) { + private val leading = NativeListLeadingVisual(reactContext) + private val labels = LinearLayout(context).apply { orientation = VERTICAL } + private val primary = NativeListTextView(context) + private val secondary = NativeListTextView(context) + init { + orientation = HORIZONTAL; gravity = Gravity.CENTER_VERTICAL + labels.addView(primary); labels.addView(secondary) + addView(leading); addView(labels, LayoutParams(0, -2, 1f)) + } + fun bind(data: JSONObject, key: String, style: JSONObject, theme: JSONObject?, table: Boolean) { + val visualData = data.optJSONObject("leading") + leading.visibility = if (visualData == null) GONE else VISIBLE + leading.layoutParams = LayoutParams(dp(20), dp(20)).apply { marginEnd = dp(6) } + if (visualData != null) leading.bind(visualData, JSONObject().put("width", 20).put("height", 20), key, theme, false, sourceScale) else leading.recycle() + val primaryStyle = JSONObject(style.optJSONObject("primaryAmount")?.toString() ?: "{}") + if (!primaryStyle.has("alignment")) primaryStyle.put("alignment", if (table) "start" else "end") + text(primary, data.optString("text"), primaryStyle, 16f, color(data.optString("tone"), theme), 24, "medium") + applySegments(primary, data.optJSONArray("textSegments"), primaryStyle.optDouble("fontSize", 16.0)) + val secondaryStyle = JSONObject(style.optJSONObject("secondaryAmount")?.toString() ?: "{}") + if (!secondaryStyle.has("alignment")) secondaryStyle.put("alignment", if (table) "start" else "end") + text(secondary, data.optString("secondaryText"), secondaryStyle, 14f, color("secondary", theme), 20) + applySegments(secondary, data.optJSONArray("secondaryTextSegments"), secondaryStyle.optDouble("fontSize", 14.0)) + } + fun recycle() { leading.recycle() } + fun dispose() { leading.dispose() } + } +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt index 45d97f0d7..347b7eb4b 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt @@ -9,6 +9,7 @@ import org.json.JSONObject internal class NativeListActivityRowView(context: ThemedReactContext) : NativeListRendererRowView(context) { + private val details = NativeListActivityDetailsView(context) private val content = LinearLayout(context).apply { orientation = HORIZONTAL @@ -33,7 +34,7 @@ internal class NativeListActivityRowView(context: ThemedReactContext) : private val values = List(2) { NativeListTextView(context) } private val actions = LinearLayout(context).apply { orientation = HORIZONTAL } private val buttons = List(3) { NativeListTextView(context) } - override val assetFields = listOf("leading", "secondaryLeading") + override val assetFields = listOf("leading", "secondaryLeading", "amounts") init { titleLine.addView(title, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) @@ -48,10 +49,13 @@ internal class NativeListActivityRowView(context: ThemedReactContext) : content.addView(amounts) addView(content, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) addView(actions) + addView(details, LayoutParams(-1, -2)) + details.visibility = GONE + details.onAction = { key, view, source, slot -> emitAction(key, view, source, slot) } } override fun defaultHeight(item: NativeListItem, layout: String) = - if ((item.json.optJSONArray("footerActions")?.length() ?: 0) > 0) 104 else 60 + if (item.json.has("amounts")) 0 else if ((item.json.optJSONArray("footerActions")?.length() ?: 0) > 0) 104 else 60 override fun bindContent( item: NativeListItem, @@ -60,6 +64,21 @@ internal class NativeListActivityRowView(context: ThemedReactContext) : checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, ) { val data = item.json + val rich = data.has("amounts") + details.visibility = if (rich) VISIBLE else GONE + content.visibility = if (rich) GONE else VISIBLE + actions.visibility = if (rich) GONE else VISIBLE + if (rich) { + orientation = VERTICAL + val style = data.optJSONObject("style") ?: JSONObject() + val hp = if (style.has("horizontalPadding")) stylePx(style.optDouble("horizontalPadding")) else dp(if (data.optString("presentation") == "table") 16 else 12) + val vp = if (style.has("verticalPadding")) stylePx(style.optDouble("verticalPadding")) else dp(8) + setPadding(hp, vp, hp, vp) + visual.recycle() + details.bind(item, theme, sourceScale) + return + } + details.recycle() val style = data.optJSONObject("style") ?: JSONObject() val image = style.optJSONObject("image") ?: JSONObject() fun color(name: String, fallback: String) = @@ -233,11 +252,13 @@ internal class NativeListActivityRowView(context: ThemedReactContext) : override fun recycleContent() { visual.recycle() + details.recycle() buttons.forEach { it.setOnClickListener(null) } } override fun disposeContent() { visual.dispose() + details.dispose() buttons.forEach { it.setOnClickListener(null) } } } diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListContainerSlotAdapter.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListContainerSlotAdapter.kt new file mode 100644 index 000000000..09d537883 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListContainerSlotAdapter.kt @@ -0,0 +1,53 @@ +package com.margelo.nitro.nativelist + +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.recyclerview.widget.RecyclerView + +/** A persistent React container slot; never a template row or a selectable item. */ +internal class NativeListContainerSlotAdapter : RecyclerView.Adapter() { + class Holder(val container: FrameLayout) : RecyclerView.ViewHolder(container) + private var content: View? = null + private var height = 0 + private var enabled = true + + fun update(view: View?, heightPx: Int, visible: Boolean) { + val previousCount = itemCount + val changed = content !== view || height != heightPx || enabled != visible + content = view + height = heightPx.coerceAtLeast(0) + enabled = visible + val nextCount = itemCount + if (previousCount == 0 && nextCount == 1) notifyItemInserted(0) + else if (previousCount == 1 && nextCount == 0) notifyItemRemoved(0) + else if (nextCount == 1 && changed) notifyItemChanged(0) + } + + override fun getItemCount(): Int = if (enabled && content != null && height > 0) 1 else 0 + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { + val container = object : FrameLayout(parent.context) { + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + for (index in 0 until childCount) { + getChildAt(index).layout(0, 0, right - left, bottom - top) + } + } + } + container.layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height) + return Holder(container) + } + + override fun onBindViewHolder(holder: Holder, position: Int) { + holder.container.layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height) + holder.container.removeAllViews() + content?.let { view -> + (view.parent as? ViewGroup)?.removeView(view) + holder.container.addView(view, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height)) + } + } + + override fun onViewRecycled(holder: Holder) { + holder.container.removeAllViews() + } +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListEndAlignment.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListEndAlignment.kt new file mode 100644 index 000000000..9ab89d401 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListEndAlignment.kt @@ -0,0 +1,5 @@ +package com.margelo.nitro.nativelist + +// Negative offsets are required when a container slot is taller than the viewport. +internal fun nativeListEndAlignmentOffset(viewportEnd: Int, contentEnd: Int): Int = + viewportEnd - contentEnd diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaPreviewSlot.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaPreviewSlot.kt new file mode 100644 index 000000000..a1adcb09c --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaPreviewSlot.kt @@ -0,0 +1,203 @@ +package com.margelo.nitro.nativelist + +import android.graphics.Rect +import android.view.ViewTreeObserver +import android.view.Gravity +import android.view.TextureView +import android.view.View +import android.widget.FrameLayout +import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.VideoSize +import androidx.media3.common.util.UnstableApi +import androidx.media3.common.util.Util +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.ui.AspectRatioFrameLayout +import com.facebook.react.modules.network.ForwardingCookieHandler +import com.facebook.react.modules.network.OkHttpClientProvider +import com.facebook.react.uimanager.ThemedReactContext +import okhttp3.JavaNetCookieJar +import org.json.JSONObject + +/** Uses Media3 like native Video, but never starts playback or requests audio focus. */ +@OptIn(UnstableApi::class) +internal class NativeListMediaPreviewSlot(private val context: ThemedReactContext) { + val view: FrameLayout = object : FrameLayout(context) { + override fun onAttachedToWindow() { super.onAttachedToWindow(); observeViewport(); updateVisibility() } + override fun onDetachedFromWindow() { stopObservingViewport(); releasePlayer(); super.onDetachedFromWindow() } + override fun onWindowVisibilityChanged(visibility: Int) { super.onWindowVisibilityChanged(visibility); updateVisibility() } + override fun onVisibilityAggregated(isVisible: Boolean) { super.onVisibilityAggregated(isVisible); if (isVisible) updateVisibility() else releasePlayer() } + } + private val image = NativeListImageSlot(context) + private val video = AspectRatioFrameLayout(context) + private val texture = TextureView(context) + private var identity: String? = null + private var epoch = 0 + private var candidateGeneration = 0 + private var playerGeneration = 0 + private var result: Boolean? = null + private var completion: ((Boolean) -> Unit)? = null + private var player: ExoPlayer? = null + private var resumeVideo: (() -> Unit)? = null + private var viewportObserver: ViewTreeObserver? = null + private val visibleRect = Rect() + private val scrollListener = ViewTreeObserver.OnScrollChangedListener { updateVisibility() } + private val layoutListener = ViewTreeObserver.OnGlobalLayoutListener { updateVisibility() } + // Native property animations can move a retained pager page without a layout pass. + private val drawListener = ViewTreeObserver.OnPreDrawListener { updateVisibility(); true } + + init { + view.isClickable = false + view.isFocusable = false + view.descendantFocusability = android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS + video.addView(texture, FrameLayout.LayoutParams(-1, -1)) + view.addView(image.view, FrameLayout.LayoutParams(-1, -1)) + view.addView(video, FrameLayout.LayoutParams(-1, -1, Gravity.CENTER)) + video.visibility = View.GONE + video.setBackgroundColor(android.graphics.Color.BLACK) + texture.isOpaque = false + } + + fun bind(source: JSONObject, key: String, fit: String? = null, + placeholder: String = "#0000000F", probeOrder: List = listOf("image"), + completion: ((Boolean) -> Unit)? = null) { + val next = nativeListCanonical(JSONObject().put("source", source).put("key", key) + .put("fit", fit ?: "").put("order", org.json.JSONArray(probeOrder)).put("placeholder", placeholder)) + this.completion = completion + if (identity == next) { + result?.let { completion?.invoke(it) } + updateVisibility() + return + } + recycle() + identity = next + this.completion = completion + video.resizeMode = if ((fit ?: source.optString("contentFit")) == "cover") AspectRatioFrameLayout.RESIZE_MODE_ZOOM else AspectRatioFrameLayout.RESIZE_MODE_FIT + probe(source, key, fit, placeholder, probeOrder, 0, epoch) + } + + private fun probe(source: JSONObject, key: String, fit: String?, placeholder: String, + order: List, attempt: Int, token: Int) { + if (token != epoch) return + candidateGeneration++ + val candidate = candidateGeneration + resumeVideo = null + stopObservingViewport() + releasePlayer() + if (attempt >= order.size) { + result = false + completion?.invoke(false) + return + } + val finished: (Boolean) -> Unit = { loaded -> + if (epoch == token && candidateGeneration == candidate) { + if (loaded) { + if (result != true) { result = true; completion?.invoke(true) } + } else probe(source, key, fit, placeholder, order, attempt + 1, token) + } + } + if (order[attempt] == "image") { + video.visibility = View.GONE + image.view.visibility = View.VISIBLE + image.bind(source, key, fit = fit, placeholder = placeholder, completion = finished) + return + } + image.recycle() + image.view.visibility = View.GONE + video.visibility = View.VISIBLE + val uri = source.optString("uri") + if (uri.isBlank()) { finished(false); return } + val headers = source.optJSONObject("headers")?.let { values -> values.keys().asSequence().associateWith { values.optString(it) } } ?: emptyMap() + resumeVideo = start@{ + if (epoch != token || candidateGeneration != candidate || player != null) return@start + playerGeneration++ + val generation = playerGeneration + try { + val client = OkHttpClientProvider.getOkHttpClient().newBuilder() + .cookieJar(JavaNetCookieJar(ForwardingCookieHandler(context))).build() + val http = OkHttpDataSource.Factory(client).setDefaultRequestProperties(headers) + if (headers.keys.none { it.equals("User-Agent", ignoreCase = true) }) http.setUserAgent(Util.getUserAgent(context, context.packageName)) + val sources = DefaultMediaSourceFactory(DefaultDataSource.Factory(context, http)) + val buffers = DefaultLoadControl.Builder() + .setBufferDurationsMs(250, 1000, 250, 250) + .setTargetBufferBytes(2 * 1024 * 1024) + .setPrioritizeTimeOverSizeThresholds(false) + .setBackBuffer(0, false).build() + val next = ExoPlayer.Builder(context.applicationContext) + .setMediaSourceFactory(sources).setLoadControl(buffers).build() + player = next + next.volume = 0f + next.playWhenReady = false + next.setAudioAttributes(androidx.media3.common.AudioAttributes.DEFAULT, false) + next.setVideoTextureView(texture) + next.addListener(object : Player.Listener { + override fun onRenderedFirstFrame() { if (playerGeneration == generation) finished(true) } + override fun onPlayerError(error: PlaybackException) { if (playerGeneration == generation) finished(false) } + override fun onVideoSizeChanged(size: VideoSize) { + if (playerGeneration == generation && size.height > 0) video.setAspectRatio(size.width * size.pixelWidthHeightRatio / size.height) + } + }) + next.setMediaItem(MediaItem.fromUri(uri)) + next.prepare() + next.pause() + } catch (_: Exception) { + releasePlayer() + finished(false) + } + } + observeViewport() + updateVisibility() + } + + private fun observeViewport() { + if (viewportObserver != null || resumeVideo == null || !view.isAttachedToWindow) return + view.viewTreeObserver.takeIf { it.isAlive }?.let { + it.addOnScrollChangedListener(scrollListener) + it.addOnGlobalLayoutListener(layoutListener) + it.addOnPreDrawListener(drawListener) + viewportObserver = it + } + } + private fun stopObservingViewport() { + viewportObserver?.takeIf { it.isAlive }?.let { + it.removeOnScrollChangedListener(scrollListener) + it.removeOnGlobalLayoutListener(layoutListener) + it.removeOnPreDrawListener(drawListener) + } + viewportObserver = null + } + private fun updateVisibility() { + var ancestor: View? = view + var opaque = true + while (ancestor != null) { + if (ancestor.alpha <= 0.01f) { opaque = false; break } + ancestor = ancestor.parent as? View + } + if (opaque && view.isAttachedToWindow && view.windowVisibility == View.VISIBLE && view.isShown && + view.getGlobalVisibleRect(visibleRect) && !visibleRect.isEmpty) resumeVideo?.invoke() + else releasePlayer() + } + private fun releasePlayer() { + playerGeneration++ + player?.let { it.pause(); it.clearVideoTextureView(texture); it.release() } + player = null + } + fun recycle() { + epoch++; candidateGeneration++ + resumeVideo = null + stopObservingViewport() + releasePlayer() + image.recycle() + image.view.visibility = View.VISIBLE + video.visibility = View.GONE + video.setAspectRatio(0f) + identity = null; result = null; completion = null + } + fun dispose() { recycle(); image.dispose() } +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt index 03704432c..564081c8d 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt @@ -14,7 +14,7 @@ import org.json.JSONObject internal class NativeListMediaTileRowView(context: ThemedReactContext) : NativeListRendererRowView(context) { private val picture = FrameLayout(context) - private val image = NativeListImageSlot(context) + private val image = NativeListMediaPreviewSlot(context) private val network = NativeListImageSlot(context) private val errorIcon = OneKeyIconView(context) private val title = NativeListTextView(context) @@ -32,7 +32,7 @@ internal class NativeListMediaTileRowView(context: ThemedReactContext) : override val defaultCornerRadius = 16 override val showsSelection = false override val pressChangesBackground = false - override val assetFields = listOf("image", "networkImage") + override val assetFields = listOf("image", "media", "networkImage") override fun horizontalWidth(item: NativeListItem) = dp(200) @@ -141,15 +141,24 @@ internal class NativeListMediaTileRowView(context: ThemedReactContext) : errorIcon.tintColor = parseNativeListColor("#00000044") errorIcon.useSourceScale = sourceScale errorIcon.layoutParams = FrameLayout.LayoutParams(dp(24), dp(24), Gravity.CENTER) - if (state != "empty" && state != "error" && item.json.has("image")) + val media = item.json.optJSONObject("media") + val source = media?.optJSONObject("source") ?: item.json.optJSONObject("image") + val order = media?.optJSONArray("probeOrder")?.let { values -> + (0 until values.length()).map { values.optString(it) } + } ?: listOf("image") + if (state != "empty" && state != "error" && source != null) image.bind( - item.json.getJSONObject("image"), + source, "${item.key}:media", fit = imageStyle.optString("contentFit").takeIf { it.isNotEmpty() }, placeholder = theme?.optString("strongBackground", "#0000000F")?.takeIf(String::isNotEmpty) ?: "#0000000F", - ) + probeOrder = order, + ) { loaded -> + errorIcon.visibility = if (loaded) GONE else VISIBLE + if (!loaded) image.view.setBackgroundColor(tint("strongBackground", "#0000000F")) + } else image.recycle() // Legacy: only the empty/error states paint a slot fill; loaded images show // their own placeholder without a white backing. @@ -157,7 +166,7 @@ internal class NativeListMediaTileRowView(context: ThemedReactContext) : when (state) { "error" -> GradientDrawable().apply { setColor(tint("strongBackground", "#0000000F")) } "empty" -> GradientDrawable().apply { setColor(Color.WHITE) } - else -> null + else -> if (errorIcon.visibility == VISIBLE) GradientDrawable().apply { setColor(tint("strongBackground", "#0000000F")) } else null } image.view.outlineProvider = object : ViewOutlineProvider() { diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index 78b57f658..e3d5dbec2 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -156,6 +156,7 @@ internal data class NativeListConfig( val selectedKeys: LinkedHashSet, val reorderable: Boolean, val pullToRefresh: Boolean, + val refreshTriggerDistance: Double? = null, val refreshing: Boolean, val loadMore: Boolean, val endReachedThreshold: Double, @@ -194,7 +195,7 @@ internal data class NativeListConfig( generation = root.getInt("generation"), layout = layout.getString("kind"), orientation = layout.optString("orientation", "vertical"), - gridColumns = layout.optInt("gridColumns", 1).coerceIn(1, 4), + gridColumns = layout.optInt("gridColumns", 1).coerceIn(1, 7), stickyHeaders = layout.optBoolean("stickyHeaders", false), contentPadding = layout.optInt("contentPadding", 0).coerceAtLeast(0), contentPaddingHorizontal = layout.optionalNonNegativeInt("contentPaddingHorizontal"), @@ -206,6 +207,7 @@ internal data class NativeListConfig( selectedKeys = selectedKeys, reorderable = capabilities?.optBoolean("reorderable", false) ?: false, pullToRefresh = capabilities?.optBoolean("pullToRefresh", false) ?: false, + refreshTriggerDistance = capabilities?.optDouble("refreshTriggerDistance")?.takeIf { it.isFinite() && it > 0 }, refreshing = capabilities?.optBoolean("refreshing", false) ?: false, loadMore = capabilities?.optBoolean("loadMore", false) ?: false, endReachedThreshold = capabilities?.optDouble("endReachedThreshold", 0.2) ?: 0.2, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListScrollPositionTracker.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListScrollPositionTracker.kt new file mode 100644 index 000000000..8f2716b7f --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListScrollPositionTracker.kt @@ -0,0 +1,29 @@ +package com.margelo.nitro.nativelist + +/** A per-view hysteresis observer. Null means no bridge event is needed. */ +internal class NativeListScrollPositionTracker { + private var start: Double? = null + private var end: Double? = null + private var previous: Boolean? = null + + fun configure(start: Double?, end: Double?) { + this.start = null + this.end = null + previous = null + if (start == null || end == null || !start.isFinite() || !end.isFinite() || start < 0 || end <= start) return + this.start = start + this.end = end + } + + fun resetDelivery() { previous = null } + + fun update(offset: Double): Boolean? { + val start = start ?: return null + val end = end ?: return null + if (!offset.isFinite()) return null + val next = if (previous == true) offset > start else offset >= end + if (previous == next) return null + previous = next + return next + } +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt index ae48631bb..48d268f11 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt @@ -1,5 +1,6 @@ package com.margelo.nitro.nativelist +import android.content.res.ColorStateList import android.graphics.Paint import android.graphics.drawable.GradientDrawable import android.text.Spannable @@ -12,6 +13,7 @@ import android.view.Gravity import android.view.View import android.view.ViewGroup import android.widget.LinearLayout +import android.widget.ProgressBar import android.widget.TextView import androidx.core.widget.TextViewCompat import com.facebook.react.uimanager.ThemedReactContext @@ -24,6 +26,10 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : private val mainColumn = LinearLayout(context) private val titleLine = PackedTitleLineLayout(context) private val title = DottedUnderlineTextView(context) + private val titleSpinner = ProgressBar(context, null, android.R.attr.progressBarStyleSmall).apply { + isIndeterminate = true + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } private val subtitle = NativeListTextView(context) private val trailingColumn = LinearLayout(context) private val trailingViews = listOf(NativeListTextView(context)) @@ -64,7 +70,8 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : layout == "table" -> 28 item.json.optString("variant") == "summary" -> 80 item.json.optString("variant") == "gallery" -> 32 - item.json.optString("variant") == "history" -> 16 + item.json.optString("variant") == "history" -> + if (item.json.optBoolean("titleLoading")) 20 else 16 item.json.optString("value").isNotEmpty() && item.json.optJSONObject("checkbox") != null -> 40 else -> 36 } @@ -284,6 +291,7 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : } } applyTextStyle(item) + titleSpinner.indeterminateTintList = ColorStateList.valueOf(title.currentTextColor) style .optJSONObject("container") ?.optString("contentVerticalAlignment") @@ -403,6 +411,7 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : override fun recycleContent() { checkboxControl.reset() currentItem = null + titleSpinner.visibility = GONE } override fun disposeContent() { @@ -437,6 +446,12 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : // Linear/sectioned snapshots reserve the ListItem mx=8 at RecyclerView // level, so header-local insets below are source px minus that outer inset. val headerHorizontalInset = 12 + if (item.json.optBoolean("titleLoading")) { + titleSpinner.visibility = VISIBLE + addView(titleSpinner, LayoutParams(dp(20), dp(20)).apply { marginEnd = dp(8) }) + } else { + titleSpinner.visibility = GONE + } addView(mainColumn, weighted()) showText(title, item.json.optString("title"), 1) if (hasDottedTitle) { diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt index aa1b52fcd..3f12b4dad 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt @@ -31,6 +31,7 @@ import android.widget.SeekBar import android.widget.TextView import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager @@ -111,6 +112,37 @@ class NativeListView( data class End(val animated: Boolean) : ScrollRequest() } + private val coordinatorOffsetListener = Runnable { emitScrollPositionThresholdIfNeeded() } + private var logicalScrollDistancePx = 0 + private val scrollPositionTracker = NativeListScrollPositionTracker() + var onScrollPositionThresholdChange: ((Boolean) -> Unit)? = null + set(value) { + field = value + scrollPositionTracker.resetDelivery() + emitScrollPositionThresholdIfNeeded() + } + + fun setScrollPositionThresholdsJson(json: String) { + val config = runCatching { JSONObject(json) }.getOrNull() + scrollPositionTracker.configure(config?.optDouble("start"), config?.optDouble("end")) + emitScrollPositionThresholdIfNeeded() + } + + private fun emitScrollPositionThresholdIfNeeded() { + if (disposed) return + val callback = onScrollPositionThresholdChange ?: return + if (layoutManager.findFirstVisibleItemPosition() == 0) { + layoutManager.findViewByPosition(0)?.let { first -> + logicalScrollDistancePx = (recyclerView.paddingTop - layoutManager.getDecoratedTop(first)).coerceAtLeast(0) + } + } + val contributions = recyclerView.getTag(R.id.onekey_native_scroll_coordinator_consumed_offsets) as? Map<*, *> + val parentDistance = contributions?.values?.sumOf { (it as? Int)?.coerceAtLeast(0)?.toLong() ?: 0L } ?: 0L + val distance = logicalScrollDistancePx.toLong() + parentDistance + val next = scrollPositionTracker.update(distance.toDouble() / density) ?: return + callback(next) + } + var onRowAction: ((String) -> Unit)? = null var onActionAnchorInvalidated: ((String) -> Unit)? = null var onSelectionDelta: ((String) -> Unit)? = null @@ -125,9 +157,53 @@ class NativeListView( private var configuredBottomPaddingPx = 0 private val refreshLayout = SwipeRefreshLayout(context) private val refreshIndicatorTravelPx = refreshLayout.progressViewEndOffset + private val defaultRefreshTriggerDistancePx = (64 * resources.displayMetrics.density).roundToInt() private var refreshIndicatorOffsetPx = 0 private val contentContainer = FrameLayout(context) private val adapter = NativeListAdapter(reactContext) + private val containerSlots = mutableListOf() + private val slotHeights = IntArray(3) + private val headerSlotAdapter = NativeListContainerSlotAdapter() + private val emptySlotAdapter = NativeListContainerSlotAdapter() + private val footerSlotAdapter = NativeListContainerSlotAdapter() + private val scrollContentAdapter = ConcatAdapter(headerSlotAdapter, adapter, emptySlotAdapter, footerSlotAdapter) + + fun mountContainerSlot(view: View, index: Int) { + containerSlots.add(index.coerceIn(0, containerSlots.size), view) + updateContainerSlots() + } + + fun containerSlotCount(): Int = containerSlots.size + fun containerSlotAt(index: Int): View = containerSlots[index] + fun unmountContainerSlot(index: Int) { + val view = containerSlots.removeAt(index) + (view.parent as? ViewGroup)?.removeView(view) + updateContainerSlots() + } + + fun setContainerSlotHeightsJson(json: String) { + val values = runCatching { JSONArray(json) }.getOrNull() ?: return + if (values.length() != 3) return + for (index in 0..2) { + val value = values.optDouble(index) + if (!value.isFinite() || value < 0) return + } + for (index in 0..2) slotHeights[index] = (values.getDouble(index) * density).roundToInt() + updateContainerSlots() + } + + private fun updateContainerSlots() { + headerSlotAdapter.update(containerSlots.getOrNull(0), slotHeights[0], true) + emptySlotAdapter.update(containerSlots.getOrNull(1), slotHeights[1], config?.items?.isEmpty() != false) + footerSlotAdapter.update(containerSlots.getOrNull(2), slotHeights[2], true) + layoutManager.spanSizeLookup.invalidateSpanIndexCache() + recyclerView.requestLayout() + } + + private fun scrollPositionForRow(index: Int): Int = index + headerSlotAdapter.itemCount + private fun rowPositionForScroll(index: Int): Int = + if (index == RecyclerView.NO_POSITION) RecyclerView.NO_POSITION else index - headerSlotAdapter.itemCount + private val layoutManager = NativeListGridLayoutManager(context, 1) // OneKey patch: vertical lists retain vertical drags and leave horizontal drags to a parent pager. private val pagerGestureTouchSlop = ViewConfiguration.get(context).scaledTouchSlop @@ -333,7 +409,7 @@ class NativeListView( init { orientation = VERTICAL - recyclerView.adapter = adapter + recyclerView.adapter = scrollContentAdapter recyclerView.layoutManager = layoutManager recyclerView.itemAnimator = null recyclerView.addOnItemTouchListener(keyboardTapTouchListener) @@ -345,7 +421,9 @@ class NativeListView( layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { if (config?.layout != "grid") return 1 - val type = adapter.itemAt(position)?.type + val rowPosition = rowPositionForScroll(position) + if (rowPosition !in 0 until adapter.itemCount) return layoutManager.spanCount + val type = adapter.itemAt(rowPosition)?.type return if (type == "sectionHeader" || type == "system" || type == "action") { layoutManager.spanCount } else { @@ -443,6 +521,8 @@ class NativeListView( } override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + logicalScrollDistancePx = (logicalScrollDistancePx + dy).coerceAtLeast(0) + emitScrollPositionThresholdIfNeeded() if (dx != 0 || dy != 0) invalidateActionAnchor("scroll") syncSectionIndexToVisibleRows() scheduleVisibleEvent() @@ -469,7 +549,7 @@ class NativeListView( private fun keyboardTapIsHandled(recyclerView: RecyclerView, event: MotionEvent): Boolean { val child = recyclerView.findChildViewUnder(event.x, event.y) ?: return false - val position = recyclerView.getChildAdapterPosition(child) + val position = rowPositionForScroll(recyclerView.getChildAdapterPosition(child)) val item = adapter.itemAt(position) ?: return false val clickable = clickableDescendantAt(child, event.rawX.toInt(), event.rawY.toInt()) val accessoryHandlesTap = clickable != null && clickable !== child @@ -522,6 +602,8 @@ class NativeListView( override fun onAttachedToWindow() { super.onAttachedToWindow() + recyclerView.setTag(R.id.onekey_native_scroll_coordinator_offset_listener, coordinatorOffsetListener) + emitScrollPositionThresholdIfNeeded() startStickyHeaderTracking() updateSectionIndexAttachment() val first = config?.items?.firstOrNull() @@ -531,6 +613,9 @@ class NativeListView( } override fun onDetachedFromWindow() { + if (recyclerView.getTag(R.id.onekey_native_scroll_coordinator_offset_listener) === coordinatorOffsetListener) { + recyclerView.setTag(R.id.onekey_native_scroll_coordinator_offset_listener, null) + } stopStickyHeaderTracking() stopSectionIndexAttachmentTracking() scheduleSectionIndexAttachmentToList() @@ -562,6 +647,7 @@ class NativeListView( } }.toSet() config = next + updateContainerSlots() usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale } adapter.usesSelectorSourceScale = usesSelectorSourceScale adapter.theme = next.theme @@ -592,13 +678,14 @@ class NativeListView( previous?.items?.firstOrNull()?.type == "market" && next.items.firstOrNull()?.type == "market" && previous.items.firstOrNull()?.key != next.items.firstOrNull()?.key && - layoutManager.findFirstVisibleItemPosition() == 0 + rowPositionForScroll(layoutManager.findFirstVisibleItemPosition()) == 0 ) { - layoutManager.findViewByPosition(0)?.let { + layoutManager.findViewByPosition(scrollPositionForRow(0))?.let { layoutManager.getDecoratedTop(it) - recyclerView.paddingTop } } else null config = next + updateContainerSlots() usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale } adapter.usesSelectorSourceScale = usesSelectorSourceScale if (previous?.generation != next.generation) endReachedGeneration = null @@ -612,7 +699,7 @@ class NativeListView( updateLayout(next) adapter.submitList(next.items) { if (marketStartOffset != null) { - layoutManager.scrollToPositionWithOffset(0, marketStartOffset) + layoutManager.scrollToPositionWithOffset(scrollPositionForRow(0), marketStartOffset) } relayoutContents(preserveMarketPaginationAnchor) performPendingScrollIfNeeded() @@ -621,16 +708,17 @@ class NativeListView( if (next.items.isNotEmpty()) checkEndReached() } refreshLayout.isEnabled = next.pullToRefresh + refreshLayout.setDistanceToTriggerSync(next.refreshTriggerDistance?.let { (it * density).roundToInt() } ?: defaultRefreshTriggerDistancePx) refreshLayout.isRefreshing = next.refreshing bindFooter(next) updateReordering(next) } private fun captureVisibleMarketAnchor(): VisibleMarketAnchor? { - val position = layoutManager.findFirstVisibleItemPosition() + val position = rowPositionForScroll(layoutManager.findFirstVisibleItemPosition()).coerceAtLeast(0) if (position == RecyclerView.NO_POSITION) return null val item = adapter.itemAt(position) ?: return null - val view = layoutManager.findViewByPosition(position) ?: return null + val view = layoutManager.findViewByPosition(scrollPositionForRow(position)) ?: return null return VisibleMarketAnchor( key = item.key, offset = layoutManager.getDecoratedTop(view) - recyclerView.paddingTop, @@ -686,6 +774,7 @@ class NativeListView( previous.rowPressToggles != next.rowPressToggles || previous.reorderable != next.reorderable || previous.pullToRefresh != next.pullToRefresh || + previous.refreshTriggerDistance != next.refreshTriggerDistance || previous.refreshing != next.refreshing || previous.loadMore != next.loadMore || previous.endReachedThreshold != next.endReachedThreshold || @@ -837,6 +926,7 @@ class NativeListView( if (!marketQuoteOnly) invalidateActionAnchor("snapshot") val next = current.copy(items = nextItems, selectedKeys = selected) config = next + updateContainerSlots() usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale } adapter.usesSelectorSourceScale = usesSelectorSourceScale adapter.selectedKeys = selected @@ -940,7 +1030,32 @@ class NativeListView( true } is ScrollRequest.End -> { - if (current.items.isNotEmpty()) { + val finalSlotHeight = when { + footerSlotAdapter.itemCount > 0 -> slotHeights[2] + emptySlotAdapter.itemCount > 0 -> slotHeights[1] + adapter.itemCount == 0 && headerSlotAdapter.itemCount > 0 -> slotHeights[0] + else -> null + } + if (finalSlotHeight != null) { + val target = scrollContentAdapter.itemCount - 1 + if (request.animated) { + val manager = layoutManager + val scroller = object : LinearSmoothScroller(context) { + override fun calculateDyToMakeVisible(view: View, snapPreference: Int): Int = + nativeListEndAlignmentOffset( + manager.height - manager.paddingBottom, + manager.getDecoratedBottom(view), + ) + } + scroller.targetPosition = target + layoutManager.startSmoothScroll(scroller) + } else { + layoutManager.scrollToPositionWithOffset( + target, + nativeListEndAlignmentOffset(viewportLength(layoutManager), finalSlotHeight), + ) + } + } else if (current.items.isNotEmpty()) { performIndexScroll( current.items.lastIndex, request.animated, @@ -962,7 +1077,7 @@ class NativeListView( viewOffset: Double, ) { val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return - val visibleView = manager.findViewByPosition(index) + val visibleView = manager.findViewByPosition(scrollPositionForRow(index)) val viewPosition = if (alignment == "nearest") { val viewportStart = if (manager.orientation == RecyclerView.VERTICAL) { manager.paddingTop @@ -983,7 +1098,7 @@ class NativeListView( else -> return } } else { - val firstVisible = manager.findFirstVisibleItemPosition() + val firstVisible = rowPositionForScroll(manager.findFirstVisibleItemPosition()) if (firstVisible != RecyclerView.NO_POSITION && index < firstVisible) 0.0 else 1.0 } } else { @@ -1010,13 +1125,13 @@ class NativeListView( return smoothAlignmentDelta(manager, view, viewPosition, offsetPx) } } - smoothScroller.targetPosition = index + smoothScroller.targetPosition = scrollPositionForRow(index) manager.startSmoothScroll(smoothScroller) return } val provisionalOffset = (viewPosition * viewportLength(manager)).roundToInt() + offsetPx - manager.scrollToPositionWithOffset(index, provisionalOffset) + manager.scrollToPositionWithOffset(scrollPositionForRow(index), provisionalOffset) relayoutRecyclerView() alignAfterLayout(index, viewPosition, offsetPx) } @@ -1024,6 +1139,9 @@ class NativeListView( private fun performOffsetScroll(offset: Double, animated: Boolean) { val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return val target = (offset * density).roundToInt().coerceAtLeast(0) + if (!animated) { + logicalScrollDistancePx = target + } if (!animated) { recyclerView.stopScroll() manager.scrollToPositionWithOffset(0, -target) @@ -1055,7 +1173,7 @@ class NativeListView( ) { recyclerView.post { val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return@post - val view = manager.findViewByPosition(index) + val view = manager.findViewByPosition(scrollPositionForRow(index)) if (view == null) { if (attemptsRemaining > 0) { alignAfterLayout(index, viewPosition, viewOffset, attemptsRemaining - 1) @@ -1166,6 +1284,7 @@ class NativeListView( fun dispose() { if (disposed) return disposed = true + onScrollPositionThresholdChange = null if (!isAttachedToWindow) disposeResources() } @@ -1234,11 +1353,11 @@ class NativeListView( stickyHeaderHost.visibility = GONE return } - val first = layoutManager.findFirstVisibleItemPosition() + val first = rowPositionForScroll(layoutManager.findFirstVisibleItemPosition()) var position = first while (position >= 0 && adapter.itemAt(position)?.let(::isStickySectionHeader) != true) position-- val item = adapter.itemAt(position) - if (item == null || first == RecyclerView.NO_POSITION || (layoutManager.findViewByPosition(position)?.top ?: -1) > 0) { + if (item == null || first == RecyclerView.NO_POSITION || (layoutManager.findViewByPosition(scrollPositionForRow(position))?.top ?: -1) > 0) { stickyHeaderHost.visibility = GONE return } @@ -1267,7 +1386,7 @@ class NativeListView( var nextTop = Int.MAX_VALUE for (index in 0 until recyclerView.childCount) { val child = recyclerView.getChildAt(index) - val childPosition = recyclerView.getChildAdapterPosition(child) + val childPosition = rowPositionForScroll(recyclerView.getChildAdapterPosition(child)) if (childPosition > position && adapter.itemAt(childPosition)?.let(::isStickySectionHeader) == true) nextTop = minOf(nextTop, child.top) } stickyHeaderHost.translationY = minOf(0, nextTop - height).toFloat() @@ -1501,7 +1620,7 @@ class NativeListView( private fun syncSectionIndexToVisibleRows() { if (sectionIndexScrubbing || sectionIndexProgrammaticScroll || sectionIndexEntries.isEmpty()) return - val firstVisible = layoutManager.findFirstVisibleItemPosition() + val firstVisible = rowPositionForScroll(layoutManager.findFirstVisibleItemPosition()) if (firstVisible == RecyclerView.NO_POSITION) return sectionIndexView.setActiveIndex( sectionIndexEntries.indexOfLast { it.position <= firstVisible }.takeIf { it >= 0 }, @@ -1866,6 +1985,7 @@ class NativeListView( } override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { + if (viewHolder !is NativeListViewHolder) return makeMovementFlags(0, 0) val item = adapter.itemAt(viewHolder.bindingAdapterPosition) if (item == null || !item.isReorderable) return makeMovementFlags(0, 0) val dragFlags = if (next.orientation == "horizontal") ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT else ItemTouchHelper.UP or ItemTouchHelper.DOWN @@ -1877,6 +1997,7 @@ class NativeListView( source: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, ): Boolean { + if (source !is NativeListViewHolder || target !is NativeListViewHolder) return false val from = source.bindingAdapterPosition val to = target.bindingAdapterPosition val base = pendingReorder ?: adapter.currentList @@ -2088,7 +2209,7 @@ class NativeListView( ?.takeIf { it != RecyclerView.NO_POSITION } ?: destinationPosition val destinationRow = recyclerView - .findViewHolderForAdapterPosition(resolvedPosition) + .findViewHolderForAdapterPosition(scrollPositionForRow(resolvedPosition)) ?.let { it as? NativeListViewHolder } ?.rowView val groupRow = destinationRow?.takeIf { @@ -2251,8 +2372,20 @@ class NativeListView( Choreographer.getInstance().postFrameCallback { visibleEventScheduled = false val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return@postFrameCallback - val first = manager.findFirstVisibleItemPosition() - val last = manager.findLastVisibleItemPosition() + val visibleRows = (0 until recyclerView.childCount).mapNotNull { index -> + val child = recyclerView.getChildAt(index) + val holder = recyclerView.getChildViewHolder(child) as? NativeListViewHolder ?: return@mapNotNull null + val visible = if (manager.orientation == RecyclerView.VERTICAL) { + manager.getDecoratedBottom(child) > recyclerView.paddingTop && + manager.getDecoratedTop(child) < recyclerView.height - recyclerView.paddingBottom + } else { + manager.getDecoratedRight(child) > recyclerView.paddingLeft && + manager.getDecoratedLeft(child) < recyclerView.width - recyclerView.paddingRight + } + holder.bindingAdapterPosition.takeIf { visible && it != RecyclerView.NO_POSITION } + } + val first = visibleRows.minOrNull() ?: RecyclerView.NO_POSITION + val last = visibleRows.maxOrNull() ?: RecyclerView.NO_POSITION val firstKey = adapter.itemAt(first)?.key val lastKey = adapter.itemAt(last)?.key val signature = "$first:$last:${firstKey.orEmpty()}:${lastKey.orEmpty()}" @@ -2271,7 +2404,7 @@ class NativeListView( val current = config ?: return if (!current.loadMore || current.generation == endReachedGeneration || adapter.itemCount == 0) return val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return - val lastVisible = manager.findLastVisibleItemPosition() + val lastVisible = rowPositionForScroll(manager.findLastVisibleItemPosition()) val thresholdItems = max(1, ceil(adapter.itemCount * current.endReachedThreshold).toInt()) if (lastVisible >= adapter.itemCount - thresholdItems) { endReachedGeneration = current.generation @@ -2311,7 +2444,7 @@ class NativeListView( adapter.currentList.indexOfFirst { it.key == currentAnchor.key } } ?: RecyclerView.NO_POSITION if (anchor != null && anchorIndex >= 0) { - layoutManager.scrollToPositionWithOffset(anchorIndex, anchor.offset) + layoutManager.scrollToPositionWithOffset(scrollPositionForRow(anchorIndex), anchor.offset) } forceLayout() measure( @@ -2473,7 +2606,11 @@ private class ReorderPlaceholderDecoration( val isWalletSidebar = item.type == "identity" && item.json.optString("presentation") == "walletSidebar" if (!isWalletSidebar && item.type != "walletGroup") return - val view = parent.findViewHolderForAdapterPosition(position)?.itemView ?: return + val view = (0 until parent.childCount).asSequence().map(parent::getChildAt) + .firstOrNull { child -> + val holder = parent.getChildViewHolder(child) + holder is NativeListViewHolder && holder.bindingAdapterPosition == position + } ?: return bounds.set( (view.left + insetPx).toFloat(), view.top.toFloat(), @@ -2744,7 +2881,7 @@ private class ItemSpacingDecoration( parent: RecyclerView, state: RecyclerView.State, ) { - if (spacing <= 0) return + if (spacing <= 0 || parent.getChildViewHolder(view) !is NativeListViewHolder) return val horizontal = (parent.layoutManager as? LinearLayoutManager)?.orientation == RecyclerView.HORIZONTAL val item = view.tag as? NativeListItem val sourceWallet = item?.type == "identity" && item.json.optString("presentation") == "walletSidebar" && item.hasExplicitHeight @@ -2766,7 +2903,7 @@ private class SelectorBackgroundDecoration(private val adapter: NativeListAdapte override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { for (index in 0 until parent.childCount) { val child = parent.getChildAt(index) - val item = adapter.itemAt(parent.getChildAdapterPosition(child)) ?: continue + val item = adapter.itemAt(parent.getChildViewHolder(child).takeIf { it is NativeListViewHolder }?.bindingAdapterPosition ?: RecyclerView.NO_POSITION) ?: continue if (!item.json.optBoolean("backgroundFullWidth", false)) continue val color = resolveNativeListSelectorBackgroundColor( rowType = item.type, diff --git a/native-views/react-native-native-list/android/src/main/res/values/native_scroll_coordinator_ids.xml b/native-views/react-native-native-list/android/src/main/res/values/native_scroll_coordinator_ids.xml new file mode 100644 index 000000000..2f657f1f3 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/res/values/native_scroll_coordinator_ids.xml @@ -0,0 +1,4 @@ + + + + diff --git a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListEndAlignmentTest.kt b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListEndAlignmentTest.kt new file mode 100644 index 000000000..9dd34c671 --- /dev/null +++ b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListEndAlignmentTest.kt @@ -0,0 +1,34 @@ +package com.margelo.nitro.nativelist + +import org.junit.Assert.assertEquals +import org.junit.Test + +class NativeListEndAlignmentTest { + @Test fun tallFooterEndsAtViewportBottom() { + val viewportHeight = 600 + val footerHeight = 1000 + val start = nativeListEndAlignmentOffset(viewportHeight, footerHeight) + assertEquals(-400, start) + assertEquals(viewportHeight, start + footerHeight) + } + + @Test fun tallEmptySlotUsesTheSameEndAlignmentWithPadding() { + val viewportTop = 80 + val viewportBottom = 680 + val emptyHeight = 900 + val start = viewportTop + nativeListEndAlignmentOffset(viewportBottom - viewportTop, emptyHeight) + assertEquals(viewportBottom, start + emptyHeight) + } + + @Test fun animatedCorrectionMovesDecoratedBottomToViewportBottom() { + val decoratedBottom = 1240 + val viewportBottom = 680 + val correction = nativeListEndAlignmentOffset(viewportBottom, decoratedBottom) + assertEquals(-560, correction) + assertEquals(viewportBottom, decoratedBottom + correction) + } + + @Test fun shortFooterRetainsBottomAlignment() { + assertEquals(480, nativeListEndAlignmentOffset(600, 120)) + } +} diff --git a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListScrollPositionTrackerTest.kt b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListScrollPositionTrackerTest.kt new file mode 100644 index 000000000..d8ace409a --- /dev/null +++ b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListScrollPositionTrackerTest.kt @@ -0,0 +1,28 @@ +package com.margelo.nitro.nativelist + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class NativeListScrollPositionTrackerTest { + @Test fun hysteresisAndDisabledLifecycle() { + val tracker = NativeListScrollPositionTracker() + assertNull(tracker.update(200.0)) + tracker.configure(48.0, 160.0) + assertEquals(false, tracker.update(0.0)) + assertNull(tracker.update(159.0)) + assertEquals(true, tracker.update(160.0)) + assertNull(tracker.update(100.0)) + assertEquals(false, tracker.update(48.0)) + assertNull(tracker.update(49.0)) + tracker.configure(null, null) + assertNull(tracker.update(200.0)) + tracker.configure(48.0, 160.0) + assertEquals(true, tracker.update(200.0)) + tracker.resetDelivery() + assertEquals(true, tracker.update(200.0)) + assertNull(tracker.update(Double.NaN)) + tracker.configure(48.0, 48.0) + assertNull(tracker.update(48.0)) + } +} diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index 22fab9890..e14b9c4aa 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -43,8 +43,10 @@ applying them. Section headers and scrolling footer/status rows use the flattened row stream. `fixedFooter` is a separate descriptor outside that stream, and `emptyState` is used when content is empty; both reuse row binders and currently accept only -`action` or `system` through the public API. A generic public list-header slot is -not yet exposed. `groupId` and `groupPosition` preserve continuous card geometry +`action` or `system` through the public API. Native vertical lists additionally +expose `listHeader`, `listEmpty`, and `listFooter` React container slots. These +three fixed roots remain outside row identity and scroll with the native list; +see SPEC.md for mounting, index translation, measurement and platform limits. `groupId` and `groupPosition` preserve continuous card geometry without nesting lists inside cells. These placement/grouping rules belong to the container even when the displayed content uses an existing row template. diff --git a/native-views/react-native-native-list/docs/ROW_TEMPLATES.md b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md index 489f36439..1c2e2d26b 100644 --- a/native-views/react-native-native-list/docs/ROW_TEMPLATES.md +++ b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md @@ -143,6 +143,26 @@ separately from source implementation in STYLE_SPEC §9. - **Common-capability boundary:** `footerActions` are inside this activity row. The list footer is independently configured through the container. +- **Native rich amounts:** optional `amounts` replaces the legacy two strings, + with at most 32 unique keyed entries. Each entry supports a leading visual, + tone, primary/secondary text and subscript segments. The title supports at + most four keyed badges. `descriptionActionKey` emits a description action. + `presentation: 'table'` allocates equal identity/amount columns and an + optional fee column; `stacked` keeps right-aligned amounts beside identity. + `fee.hidden` reserves its column while hiding its content. These native + additions do not change the Web renderer. + +```text +stacked (phone) table (wide) +┌────┬────────────────┬───────────┐ ┌────┬────────────┬────────────┬─────────┐ +│icon│title + badges │amount 1 │ │icon│title/badges│amount 1 │fee label│ +│ │description ↗ │secondary │ │ │description │secondary │fee/fiat │ +│ │status │amount 2… │ │ │status │amount 2… │ │ +├────┴────────────────┴───────────┤ ├────┴────────────┴────────────┴─────────┤ +│ optional row action buttons│ │ optional row action buttons │ +└────────────────────────────────┘ └───────────────────────────────────────┘ +``` + ## message ![Message: title and timestamp over body text, with optional leading visual and thumbnail](images/row-message.svg) @@ -225,6 +245,19 @@ separately from source implementation in STYLE_SPEC §9. - **Use:** a gallery tile or browser preview. - **Required data:** `variant: gallery | browserPreview`, `title`. Optional image/image-state, subtitle, badge, network image and close-action key. +- **Native media preview:** optional `media: { source: ImageSource, probeOrder }` + supplies one or two distinct `image`/`video` candidates. It takes precedence + over `image`; explicit `imageState: empty | error` still suppresses loading. + A video candidate uses a muted paused native player, with no playback/controls or + touch target. Detail playback remains caller-owned. Failure advances once to + the next candidate, then displays the standard error placeholder. The same + media box and caption diagram apply; this adds no new layout region. + Web keeps the existing `image` field and does not execute media probing. +- **Native lifetime:** request identity includes row key, full source, fit, + placeholder and probe order. Rebinding only text preserves the preview; + changed identity or recycle cancels work and rejects late callbacks. Frame + loading owns at most one player per attached tile. Detach/background/recycle + releases player resources; no autoplay, audio focus or NativeList disk cache. - **Text style roles:** `title`, `subtitle`, `badge`. - **Layout boundary:** media stays above caption metadata; network/close/badge overlays keep their template anchors. A text style cannot change the grid @@ -256,6 +289,18 @@ separately from source implementation in STYLE_SPEC §9. ## sectionHeader +Native-only optional title loading presentation (`titleLoading: true`): + +```text +┌────────────────────────────────────────────────────────────┐ +│ [spinner 20pt] Title Value/control │ +└────────────────────────────────────────────────────────────┘ +``` + +The spinner inherits the resolved title color, has an 8-point trailing gap, +and stops/disappears when loading clears or the row is recycled. It is section +chrome and emits no row action. Omission preserves existing rendering. + ![Section header: title/subtitle and optional summary value, help or selection control](images/row-section-header.svg) - **Use:** a structural section heading or list summary, independent of the diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index 3397a849c..26fe49dd6 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -7,7 +7,8 @@ architecture and runtime evidence. ## Purpose and non-goals NativeList renders bounded data with fixed row templates on iOS, Android and -Web. It does not render arbitrary React children. Integrators own overflow +Web. Data rows do not render arbitrary React children. Native-only named container slots +may render React chrome outside the data-row stream. Integrators own overflow prevention; there is no automatic fitting or shrinking pass. ## Definitions and ownership @@ -52,6 +53,23 @@ image that already loaded. Common container styling clears what it wrote on the next bind, including properties it set on template children. UI mutation remains on the platform UI thread; [DESIGN.md](DESIGN.md) describes update and event ownership. +### Native section title loading indicator + +Status: implemented source; simulator rendering acceptance pending. +On iOS and Android, `sectionHeader.titleLoading` defaults to `false`. When +`true`, a 20-point native indeterminate indicator precedes the section heading +with an 8-point gap and inherits the resolved title color. It emits no action +and does not create a separate accessibility target. Clearing the field, full +rebind, recycling and disposal stop and hide the indicator; detached native views do +not keep a visible animation. The History template's minimum content height +increases from 16 to 20 points while loading. Explicit caller row/container +heights still take precedence and must fit their padding. Web does not render +this new native-only indicator; its existing section presentation is unchanged. + +Acceptance requires loading → idle → loading reuse, title color updates and +detachment on each native platform. A title's localized confirming text remains +caller-owned; the renderer does not infer transaction status. + ## Data, cache and identity `row.key` identifies data; renderer reuse identity describes compatible view @@ -356,3 +374,189 @@ audit; they are not new in this change. templates to 12 on native; Web defaults to 0 (STYLE_SPEC §6.2). - **DataRow linear layout.** See ROW_TEMPLATES.md: native unstyled linear rows keep the legacy single-label column. + +## Native scroll-position thresholds and tablet grids + +Status: Source implemented; native runtime acceptance pending. These additions are native +only. Existing Web rendering and caller behavior are unchanged. + +- `scrollPositionThresholds?: { start: number; end: number; enabled?: boolean }` + observes the list's logical distance from its content start, in logical pixels. + `start` and `end` must be finite, nonnegative, and `start < end`. Omitted or + `enabled: false` disables observation. +- `onScrollPositionThresholdChange({ isBeyondThreshold })` reports initial state + after enabling/configuration changes, then only hysteresis transitions: enter + when distance >= end, leave when distance <= start. No per-frame JS events. + Replacing the JS callback takes effect on the next event; disabling emits no event. + Configuration updates reset hysteresis before recomputing current position. +- iOS uses contentOffset + contentInset, excluding overscroll below zero. Android + accumulates consumed child scroll distance and rebases from first-item geometry. + Parent-pager header contributions and arbitrary index-jump acceptance are tracked + separately; no estimate-based parity claim is made for unverified jumps. +- `layout.gridColumns` accepts integers 2 through 7 on native. Native parsers + clamp malformed external JSON to 1 through 7 as before; public JS validation + rejects invalid requested counts. Grid geometry remains container-owned. + +Required acceptance: disabled/enable lifecycle, both equality boundaries, +oscillation inside the band, programmatic scroll, page/identity replacement, +refresh and header insets, grid 6/7 layout/rotation, and no stale callbacks after +disposal. Unit checks prove transition semantics, not rendered behavior. + +## Native media preview probing + +Status: Source implemented; native runtime acceptance pending. `mediaTile.media` +is an opt-in native-only preview descriptor: `{ source: ImageSource, probeOrder: +('image' | 'video')[] }`. The order contains one or two distinct candidates. +The caller supplies the order; the renderer never guesses asset semantics. +When provided, media takes precedence over `image`, unless `imageState` is +explicitly empty/error. Web keeps its existing `image` behavior; native callers +that also target Web must supply that existing image independently. + +Image uses the shared OneKeyImage lifecycle. Video uses a paused, muted AVPlayer +on iOS and Media3 ExoPlayer on Android, preserving the native Video source +families including streaming media instead of approximating them with a frame +extractor. Video defaults to contain; an explicit image fit may select cover. +There are no controls or video touch targets; the row owns press interaction. +Android preserves a black letterbox and iOS a transparent one, matching the +existing native Video views. No code calls play or requests audio focus. + +A failed candidate advances once; exhaustion displays the existing error +placeholder. New source/row identity resets probing; binding, candidate and +player generations reject stale callbacks, including A→B→A. Text-only updates +preserve the current request. A tile owns at most one native player. On iOS, +eligibility requires intersection with the window and every clipping/scroll +ancestor viewport; native scroll, bounds, layer transform/position and visibility +observations release retained offscreen pager pages. Detach, background, +invisibility and recycle release the player; reattachment rebuilds its retained +source while remaining paused. Android observes native global scroll/layout and +pre-draw geometry (including property animations), checking ancestor alpha and +getGlobalVisibleRect before allocating a player. These observers do not schedule +frames or emit JavaScript events. +Player count is bounded by intersecting native video tiles, including partially +visible tiles; there is no numeric cap that blanks visible iPad cells. iOS requests +one second of forward buffer; AVFoundation treats this as an advisory preference, +not a hard byte limit. Android uses a 250–1000 ms forward buffer, zero back buffer +and a 2 MiB target allocator; Media3 may exceed a target by allocation granularity. +These are per-player buffer targets, not hard total-process memory limits. +Platform decoder/surface overhead is additional. No +process-wide player cache or NativeList media disk cache is introduced. Source headers are never +logged. Android uses Media3 1.4.1 (matching native Video's default), including +HLS/DASH, UI and OkHttp data source; the existing root media3Version override is +honored. Cookie handling uses the React Native cookie store without replacing +the global HTTP client's cookie jar. + +Required runtime cases: image→video, video→image, exhaustion, file/HTTP/HLS, +non-square fit, silent paused initial frame, same-source text update, quick +A→B→A reuse, detach/background and return, row tap, grid resize, and absence of +leaked audio/player resources. Static API checks do not prove these cases. + +## Native container slots (source implementation, runtime verification pending) + +`listHeader`, `listEmpty` and `listFooter` accept React nodes on iOS and Android +vertical lists only. They are optional and default to absent. They are common +container capabilities, not row templates, and MUST NOT be used for per-item +rendering. Horizontal orientation with any supplied slot is rejected. Web ignores +these new native-only props; existing Web lists and callers retain their behavior. + +The order is header, data rows (or empty slot when there are no data rows), footer. +Slots scroll with the native list. `fixedFooter` remains a separate fixed native +row descriptor. `scrollToEnd` aligns the final scrolling slot bottom with the +viewport end, including footer/empty/header-only slots taller than the viewport. Callers using `listEmpty` MUST omit `snapshot.emptyState`, whose +existing native descriptor becomes a data item. Horizontal content padding applies +to slots as well as data rows. Slot React trees own their internal layout and +business actions; NativeList owns their scroll placement and lifecycle. + +The wrapper mounts exactly three non-collapsible slot roots and reports measured +heights through `containerSlotHeightsJson` in header/empty/footer order. Native +validates three finite, non-negative heights. Slot updates MUST NOT alter data-row +keys or public row indices. No per-scroll-frame JS position updates are used. +iOS mounts slot roots inside UICollectionView and reserves native layout space. +Android uses ConcatAdapter with three single-item slot adapters around the existing +data adapter; adapter binding indices stay local while layout-manager positions +are explicitly translated. Slots span all grid columns, do not select/reorder, +and do not emit row actions, visible-row indices, or image-prefetch work. + +Fabric mount/unmount ownership remains explicit even when RecyclerView detaches a +slot holder. The native manager retains at most three roots until Fabric unmount; +recycling detaches slot content without disposing its React tree. Unmount removes +that exact view. Native list disposal MUST NOT dispatch pending row/slot events. +The generated Nitro 0.37 leaf managers require deterministic child-forwarding +customization: `scripts/enable-container-slots.mjs` runs after nitrogen and rejects +unexpected generator output. Generated files are never maintained by hand. + +Acceptance requires both platforms: dynamic header height, empty/nonempty changes, +scrolling footer with pagination, grid full-span slots, scrolling from interactive +slot content, refresh from header/empty, row-index scroll APIs, reuse/unmount, and +rapid account changes. Source/type checks do not establish this runtime acceptance. + + +## Refresh trigger distance + +Optional `snapshot.capabilities.refreshTriggerDistance` is a positive finite +logical-pixel distance. Omission preserves the platform control's default. +iOS adds a release-trigger fallback based on the deepest normalized overscroll +(contentOffset plus contentInset.top); the fallback and UIRefreshControl emit +at most one refresh action per drag. Android passes the distance to +SwipeRefreshLayout's native trigger setting. These native controls differ in +rubber-band/drag scaling, so equal values do not promise equal finger travel. +The caller continues to own refreshing state, completion, and optional haptic feedback. +The container does not emit a second haptic for the release fallback. The fallback is +inactive unless pull-to-refresh is enabled; no product-specific threshold is +hardcoded into the module. + +## Native rich activity presentation + +Status: Source implemented; native runtime acceptance pending. Optional `amounts` +supersedes legacy `primaryAmount`/`secondaryAmount` only on native. It accepts at +most 32 keyed lines, each with formatted text, optional leading visual, tone, +secondary text and subscript text segments. Segments are caller-formatted and +never interpreted as numbers by NativeList. Omission preserves the legacy tree. + +`presentation: stacked` places the amount lines after the identity column and +right-aligns them; `table` uses equal identity/amount columns, left-aligns amount +lines and adds an optional trailing fee column. Fee supports label, primary and +secondary text with their own segments; `hidden` preserves its column width +while hiding its contents. Up to four uniquely keyed title badges use declared +tones. `descriptionActionKey` makes only the description emit that action with +source `description`; footer actions retain their existing source/keys and +disabled behavior. All actions use the current host binding epoch. + +Rich rows measure the declared line/visual metrics; there is no six-line native +truncation. The existing height override contract still applies. RTL follows +semantic leading/trailing anchors. Clearing rich fields returns to the legacy +renderer, and removing amount lines recycles their visual slots. Web rendering +remains unchanged; rich fields are native-only until a separate Web migration. + +Acceptance: 1/2/6/32 lines, changed keys/order, paired icons and network overlays, +small-number segments in amounts/fiat/fee (ceil(0.6 × parent font size), unchanged +baseline, matching NumberSizeableText), badge tone/clear, hidden fee, disabled +actions, description anchors, dark/RTL/font scaling and explicit/automatic height. + + +### Android ancestor scroll distance + +Threshold hysteresis combines the primary RecyclerView's actual consumed scroll +with all native ancestor header contributions. CollapsiblePagerView publishes +weak-owner contributions through the namespaced View-tag protocol documented in +its SPEC.md; no pager dependency or product semantics are required. Absence is +zero. NativeList owns one native listener, registers it on attach, and removes +only its own listener on detach. Header-only scrolling therefore crosses the +same logical Home threshold as iOS normalized content scrolling, without a JS +scroll-frame event. Changes to the listener or public callbacks do not replace +native resource ownership. + +Child distance uses actual onScrolled dy and rebases from first-item geometry at +top; scrollToOffset seeds a known explicit offset. It does not use RecyclerView's +average-height scrollbar estimate. Continuous scrolling and return-to-top are +covered by this policy; exact classification immediately after arbitrary distant +scrollToIndex jumps remains pending measured-prefix correction and MUST NOT be +claimed as verified. A later top anchor restores exact distance. + + +### Container follow-up verification + +The pure Android end-alignment policy has four focused JVM tests: tall footer, +tall empty slot with viewport padding, animated decorated-bottom correction, +and short footer. These validate geometry, not actual RecyclerView animation or +Fabric lifecycle. Native simulator acceptance remains required. The iOS fallback +retains one refresh action per drag and leaves haptic feedback to its caller. diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index f10846bc3..faac16523 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -976,3 +976,11 @@ the contract; no shared cross-language renderer or code generator is required. before treating the refactor as device-accepted. Overflow fitting is the integrating developer's responsibility. + +### Native tablet grid allocation + +`layout.gridColumns` accepts integers 2 through 7. The container derives tile +width from available width, content padding and spacing; mediaTile styles do +not choose the column count. This extends native parser limits from four to +seven without changing existing 2/3/4 geometry. Runtime acceptance for 6/7 +columns, rotation, and text scaling remains required. diff --git a/native-views/react-native-native-list/ios/HybridNativeList.swift b/native-views/react-native-native-list/ios/HybridNativeList.swift index 3967c0de4..20ded9e85 100644 --- a/native-views/react-native-native-list/ios/HybridNativeList.swift +++ b/native-views/react-native-native-list/ios/HybridNativeList.swift @@ -7,6 +7,35 @@ final class HybridNativeList: HybridNativeListSpec { var view: UIView { hostView ?? disposedView } + var scrollPositionThresholdsJson: String = "" { + didSet { + guard scrollPositionThresholdsJson != oldValue else { return } + runOnMain { [weak self] in + guard let self else { return } + self.hostView?.setScrollPositionThresholdsJson(self.scrollPositionThresholdsJson) + } + } + } + + var onScrollPositionThresholdChange: ((Bool) -> Void)? { + didSet { + runOnMain { [weak self] in + guard let self else { return } + self.hostView?.onScrollPositionThresholdChange = self.onScrollPositionThresholdChange + } + } + } + + var containerSlotHeightsJson: String = "[]" { + didSet { + guard containerSlotHeightsJson != oldValue else { return } + runOnMain { [weak self] in + guard let self else { return } + self.hostView?.setContainerSlotHeightsJson(self.containerSlotHeightsJson) + } + } + } + var snapshotJson: String = "" { didSet { guard snapshotJson != oldValue else { return } @@ -156,6 +185,7 @@ final class HybridNativeList: HybridNativeListSpec { onReorder = nil onEndReached = nil onVisibleRangeChanged = nil + onScrollPositionThresholdChange = nil hostView.dispose() self.hostView = nil snapshotJson = "" diff --git a/native-views/react-native-native-list/ios/NativeListActivityDetailsView.swift b/native-views/react-native-native-list/ios/NativeListActivityDetailsView.swift new file mode 100644 index 000000000..9d7792e2a --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListActivityDetailsView.swift @@ -0,0 +1,229 @@ +import UIKit + +/// The activity template's bounded rich presentation; all values are caller-formatted. +final class NativeListActivityDetailsView: UIStackView { + var onAction: ((String, UIView, String, Int) -> Void)? + private let main = UIStackView() + private let visual = NativeListLeadingVisual() + private let identity = UIStackView() + private let titleLine = UIStackView() + private let title = NativeListTextLabel() + private let descriptionLabel = NativeListTextLabel() + private let status = NativeListTextLabel() + private let amounts = UIStackView() + private let fee = UIStackView() + private let feeLabel = NativeListTextLabel() + private let feeLine = UIStackView() + private let feePrimary = NativeListTextLabel() + private let feeSecondary = NativeListTextLabel() + private let actions = UIStackView() + private var amountViews: [NativeListActivityAmountView] = [] + private var badgeViews: [NativeListTextLabel] = [] + private var buttons: [UIButton] = [] + private var actionKeys: [String] = [] + private var descriptionKey = "" + private var dimensions: [NSLayoutConstraint] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + axis = .vertical; alignment = .fill; spacing = 8 + main.axis = .horizontal; main.alignment = .center; main.spacing = 12 + identity.axis = .vertical; identity.alignment = .fill + titleLine.axis = .horizontal; titleLine.alignment = .center; titleLine.spacing = 4 + titleLine.addArrangedSubview(title) + [titleLine, descriptionLabel, status].forEach(identity.addArrangedSubview) + amounts.axis = .vertical; amounts.alignment = .fill; amounts.spacing = 2 + fee.axis = .vertical; fee.alignment = .trailing + feeLine.axis = .horizontal; feeLine.spacing = 4 + [feePrimary, feeSecondary].forEach(feeLine.addArrangedSubview) + [feeLabel, feeLine].forEach(fee.addArrangedSubview) + [visual, identity, amounts, fee].forEach(main.addArrangedSubview) + [main, actions].forEach(addArrangedSubview) + actions.axis = .horizontal; actions.alignment = .leading; actions.spacing = 8 + actions.isLayoutMarginsRelativeArrangement = true + descriptionLabel.isUserInteractionEnabled = true + descriptionLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(descriptionPressed))) + title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + identity.setContentHuggingPriority(.defaultLow, for: .horizontal) + amounts.setContentHuggingPriority(.defaultHigh, for: .horizontal) + } + required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + static func measure(_ data: [String: Any], width: CGFloat) -> CGFloat { + let style = data.dictionary("style") ?? [:] + let primary = style.dictionary("primaryAmount") ?? [:] + let secondary = style.dictionary("secondaryAmount") ?? [:] + let rows = data.dictionaries("amounts") + let gap = CGFloat(style.double("trailingGap", default: 2)) + let amountsHeight = rows.reduce(CGFloat(0)) { height, row in + height + CGFloat(primary.double("lineHeight", default: 24)) + + (row.string("secondaryText").isEmpty ? 0 : CGFloat(secondary.double("lineHeight", default: 20))) + } + CGFloat(max(0, rows.count - 1)) * gap + let titleHeight = CGFloat(style.dictionary("title")?.double("lineHeight", default: 24) ?? 24) + let description = NativeListResolvedText(data.string("description"), style: style.dictionary("description"), + size: 14, color: .label, lineHeight: 20, lines: 2) + let identityHeight = titleHeight + description.measure(width: max(1, width * 0.4 - 52)) + + (data.string("status").isEmpty ? 0 : 16) + let imageHeight = CGFloat(style.dictionary("image")?.double("height", default: 40) ?? 40) + return max(imageHeight, identityHeight, amountsHeight) + + CGFloat(style.double("verticalPadding", default: 8)) * 2 + + (data.dictionaries("footerActions").isEmpty ? 0 : 40) + } + + func bind(_ item: NativeListItem, theme: [String: Any]?) { + let data = item.data + let style = data.dictionary("style") ?? [:] + let image = style.dictionary("image") ?? [:] + let table = data.string("presentation") == "table" + let gap = CGFloat(style.double("leadingGap", default: 12)) + main.spacing = gap + identity.spacing = CGFloat(style.double("lineGap")) + amounts.spacing = CGFloat(style.double("trailingGap", default: 2)) + titleLine.spacing = CGFloat(style.double("titleBadgeGap", default: 4)) + NSLayoutConstraint.deactivate(dimensions) + dimensions = [ + visual.widthAnchor.constraint(equalToConstant: CGFloat(image.double("width", default: 40))), + visual.heightAnchor.constraint(equalToConstant: CGFloat(image.double("height", default: 40))), + amounts.widthAnchor.constraint(lessThanOrEqualTo: main.widthAnchor, multiplier: table ? 0.42 : 0.5), + ] + if table { dimensions.append(amounts.widthAnchor.constraint(equalTo: identity.widthAnchor)) } + NSLayoutConstraint.activate(dimensions) + visual.bind(data.dictionary("leading") ?? [:], style: image, key: item.key, theme: theme, + isUnread: false, secondaryVisual: data.dictionary("secondaryLeading")) + NativeListResolvedText(data.string("title"), style: style.dictionary("title"), size: 16, + weight: .medium, color: nativeListColor(theme, "primaryText", "#202020"), lineHeight: 24, lines: 1).bind(title) + NativeListResolvedText(data.string("description"), style: style.dictionary("description"), size: 14, + color: nativeListColor(theme, "secondaryText", "#646464"), lineHeight: 20, lines: 2).bind(descriptionLabel) + NativeListResolvedText(data.string("status"), style: style.dictionary("status"), size: 12, + color: nativeListColor(theme, "secondaryText", "#646464"), lineHeight: 16, lines: 1).bind(status) + descriptionKey = data.string("descriptionActionKey") + descriptionLabel.isUserInteractionEnabled = !descriptionKey.isEmpty + descriptionLabel.accessibilityTraits = descriptionKey.isEmpty ? .staticText : .button + for badge in badgeViews { titleLine.removeArrangedSubview(badge); badge.removeFromSuperview() } + badgeViews = data.dictionaries("badges").map { descriptor in + let label = NativeListTextLabel() + let tone = descriptor.string("tone") + let tint = nativeListActivityTone(tone, theme: theme) + NativeListResolvedText(" \(descriptor.string("text")) ", style: nil, size: 12, + weight: .medium, color: tint, lineHeight: 16, lines: 1).bind(label) + label.backgroundColor = tint.withAlphaComponent(0.1) + label.layer.cornerRadius = 4; label.clipsToBounds = true + label.setContentCompressionResistancePriority(.required, for: .horizontal) + titleLine.addArrangedSubview(label) + return label + } + let descriptors = data.dictionaries("amounts") + while amountViews.count > descriptors.count { + let view = amountViews.removeLast(); view.recycle(); amounts.removeArrangedSubview(view); view.removeFromSuperview() + } + while amountViews.count < descriptors.count { + let view = NativeListActivityAmountView(); amountViews.append(view); amounts.addArrangedSubview(view) + } + for (index, descriptor) in descriptors.enumerated() { + amountViews[index].bind(descriptor, key: "\(item.key):\(descriptor.string("key"))", style: style, theme: theme, table: table) + } + let feeData = data.dictionary("fee") + fee.isHidden = feeData == nil + fee.alpha = feeData?.bool("hidden") == true ? 0 : 1 + NativeListResolvedText(feeData?.string("label") ?? "", style: style.dictionary("secondaryAmount"), + size: 12, color: nativeListColor(theme, "secondaryText", "#646464"), lineHeight: 16, lines: 1).bind(feeLabel) + NativeListResolvedText(feeData?.string("primary") ?? "", style: style.dictionary("secondaryAmount"), + size: 14, color: nativeListColor(theme, "primaryText", "#202020"), lineHeight: 20, lines: 1).bind(feePrimary) + NativeListResolvedText(feeData?.string("secondary") ?? "", style: style.dictionary("secondaryAmount"), + size: 12, color: nativeListColor(theme, "secondaryText", "#646464"), lineHeight: 16, lines: 1).bind(feeSecondary) + for button in buttons { actions.removeArrangedSubview(button); button.removeFromSuperview() } + buttons = [] + nativeListActivitySegments(feePrimary, feeData?.dictionaries("primaryTextSegments") ?? []) + nativeListActivitySegments(feeSecondary, feeData?.dictionaries("secondaryTextSegments") ?? []) + let actionData = data.dictionaries("footerActions") + actionKeys = actionData.map { $0.string("key") } + actions.isHidden = actionData.isEmpty + actions.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 0, leading: CGFloat(image.double("width", default: 40)) + gap, bottom: 0, trailing: 0) + for (index, descriptor) in actionData.enumerated() { + let button = UIButton(type: .system); button.tag = index + button.setTitle(descriptor.string("label"), for: .normal) + button.titleLabel?.font = nativeListFont(ofSize: 14, weight: .medium) + button.setTitleColor(nativeListActivityTone(descriptor.string("tone"), theme: theme), for: .normal) + button.backgroundColor = nativeListColor(theme, "strongBackground", "#F0F0F0") + button.layer.cornerRadius = 8 + button.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) + button.isEnabled = !data.bool("disabled") && !descriptor.bool("disabled") + button.addTarget(self, action: #selector(actionPressed(_:)), for: .touchUpInside) + buttons.append(button); actions.addArrangedSubview(button) + } + let spacer = UIView(); spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + // The last flexible spacer is separate from the fixed action buttons. + actions.arrangedSubviews.filter { !($0 is UIButton) }.forEach { actions.removeArrangedSubview($0); $0.removeFromSuperview() } + actions.addArrangedSubview(spacer) + } + @objc private func descriptionPressed() { onAction?(descriptionKey, descriptionLabel, "description", 0) } + @objc private func actionPressed(_ button: UIButton) { + guard button.tag < actionKeys.count else { return } + onAction?(actionKeys[button.tag], button, "footerAction", button.tag) + } + func recycle() { + visual.recycle(); amountViews.forEach { $0.recycle() }; actionKeys = []; descriptionKey = "" + } +} + +private func nativeListActivityTone(_ tone: String, theme: [String: Any]?) -> UIColor { + switch tone { + case "positive", "success": return nativeListColor(theme, "positive", "#218358") + case "negative", "danger": return nativeListColor(theme, "negative", "#CE2C31") + case "secondary": return nativeListColor(theme, "secondaryText", "#646464") + case "warning": return nativeListColor(theme, "warning", "#AB6400") + case "info": return nativeListColor(theme, "info", "#007BEF") + default: return nativeListColor(theme, "primaryText", "#202020") + } +} + +private final class NativeListActivityAmountView: UIStackView { + private let visual = NativeListLeadingVisual() + private let labels = UIStackView() + private let primary = NativeListTextLabel() + private let secondary = NativeListTextLabel() + override init(frame: CGRect) { + super.init(frame: frame) + axis = .horizontal; alignment = .center; spacing = 6 + labels.axis = .vertical; labels.alignment = .fill + [primary, secondary].forEach(labels.addArrangedSubview) + [visual, labels].forEach(addArrangedSubview) + NSLayoutConstraint.activate([visual.widthAnchor.constraint(equalToConstant: 20), visual.heightAnchor.constraint(equalToConstant: 20)]) + } + required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + func bind(_ data: [String: Any], key: String, style: [String: Any], theme: [String: Any]?, table: Bool) { + let leading = data.dictionary("leading") + visual.isHidden = leading == nil + if let leading { visual.bind(leading, style: ["width": 20, "height": 20], key: key, theme: theme, isUnread: false) } else { visual.recycle() } + var primaryStyle = style.dictionary("primaryAmount") ?? [:] + if primaryStyle["alignment"] == nil { primaryStyle["alignment"] = table ? "start" : "end" } + let resolved = NativeListResolvedText(data.string("text"), style: primaryStyle, size: 16, + weight: .medium, color: nativeListActivityTone(data.string("tone"), theme: theme), lineHeight: 24, lines: 1, tabular: true) + resolved.bind(primary) + nativeListActivitySegments(primary, data.dictionaries("textSegments")) + var secondaryStyle = style.dictionary("secondaryAmount") ?? [:] + if secondaryStyle["alignment"] == nil { secondaryStyle["alignment"] = table ? "start" : "end" } + NativeListResolvedText(data.string("secondaryText"), style: secondaryStyle, size: 14, + color: nativeListColor(theme, "secondaryText", "#646464"), lineHeight: 20, lines: 1).bind(secondary) + nativeListActivitySegments(secondary, data.dictionaries("secondaryTextSegments")) + } + func recycle() { visual.recycle() } +} + +private func nativeListActivitySegments(_ label: NativeListTextLabel, _ segments: [[String: Any]]) { + guard !segments.isEmpty else { return } + let text = segments.map { $0.string("text") }.joined() + let attributes = (label.attributedText?.length ?? 0) > 0 + ? label.attributedText!.attributes(at: 0, effectiveRange: nil) + : [.font: label.font as Any, .foregroundColor: label.textColor as Any] + let value = NSMutableAttributedString(string: text, attributes: attributes) + var offset = 0 + for segment in segments { + let length = (segment.string("text") as NSString).length + if segment.string("style") == "subscript" { + value.addAttributes([.font: label.font.withSize(ceil(label.font.pointSize * 0.6))], range: NSRange(location: offset, length: length)) + } + offset += length + } + label.attributedText = value +} diff --git a/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift b/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift index 1f1223bf0..f6bdebfbd 100644 --- a/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift @@ -4,9 +4,11 @@ final class NativeListActivityCell: NativeListRendererCell { override class func measure( _ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String ) -> CGFloat? { + if item.data["amounts"] != nil { return NativeListActivityDetailsView.measure(item.data, width: width) } return item.data.dictionaries("footerActions").isEmpty ? 60 : 100 } + private let details = NativeListActivityDetailsView() private let visual = NativeListLeadingVisual() private let column = UIStackView() private let titleLine = UIStackView() @@ -22,7 +24,7 @@ final class NativeListActivityCell: NativeListRendererCell { private let buttons = (0..<3).map { _ in UIButton(type: .system) } private var actionKeys: [String] = [] private var dimensions: [NSLayoutConstraint] = [] - override var assetFields: [String] { ["leading", "secondaryLeading"] } + override var assetFields: [String] { ["leading", "secondaryLeading", "amounts"] } override init(frame: CGRect) { super.init(frame: frame) @@ -60,7 +62,11 @@ final class NativeListActivityCell: NativeListRendererCell { button.addTarget(self, action: #selector(pressed(_:)), for: .touchUpInside) actions.addArrangedSubview(button) } - [visual, column, amounts].forEach(root.addArrangedSubview) + [visual, column, amounts, details].forEach(root.addArrangedSubview) + details.isHidden = true + details.onAction = { [weak self] key, view, source, slot in + self?.emitAction(key, from: view, source: source, slot: slot) + } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func bindContent( @@ -68,6 +74,21 @@ final class NativeListActivityCell: NativeListRendererCell { checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String ) { let data = item.data + let rich = data["amounts"] != nil + details.isHidden = !rich + [visual, column, amounts].forEach { $0.isHidden = rich } + root.axis = rich ? .vertical : .horizontal + root.alignment = rich ? .fill : .center + if rich { + let style = data.dictionary("style") ?? [:] + let hp = CGFloat(style.double("horizontalPadding", default: data.string("presentation") == "table" ? 16 : 12)) + let vp = CGFloat(style.double("verticalPadding", default: 8)) + contentInsets = UIEdgeInsets(top: vp, left: hp, bottom: vp, right: hp) + visual.recycle() + details.bind(item, theme: theme) + return + } + details.recycle() let style = data.dictionary("style") ?? [:] let image = style.dictionary("image") ?? [:] root.spacing = 12 @@ -173,6 +194,7 @@ final class NativeListActivityCell: NativeListRendererCell { } override func recycleContent() { visual.recycle() + details.recycle() actionKeys = [] buttons.forEach { $0.isHidden = true } } diff --git a/native-views/react-native-native-list/ios/NativeListMediaPreviewSlot.swift b/native-views/react-native-native-list/ios/NativeListMediaPreviewSlot.swift new file mode 100644 index 000000000..8bf89e09a --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListMediaPreviewSlot.swift @@ -0,0 +1,199 @@ +import AVFoundation +import UIKit + +private final class NativeListPausedVideoView: UIView { + var onWindowChanged: (() -> Void)? + var onLayoutChanged: (() -> Void)? + override class var layerClass: AnyClass { AVPlayerLayer.self } + var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer } + override func didMoveToWindow() { super.didMoveToWindow(); onWindowChanged?() } + override func layoutSubviews() { super.layoutSubviews(); onLayoutChanged?() } +} + +/// Uses the same AVPlayer source semantics as native Video, always paused and muted. +final class NativeListMediaPreviewSlot { + let view = UIView() + private let image = NativeListImageSlot() + private let video = NativeListPausedVideoView() + private var identity: String? + private var epoch = 0 + private var candidateGeneration = 0 + private var playerGeneration = 0 + private var result: Bool? + private var completion: ((Bool) -> Void)? + private var player: AVPlayer? + private var itemObservation: NSKeyValueObservation? + private var readyObservation: NSKeyValueObservation? + private var resumeVideo: (() -> Void)? + private var lifecycleObservers: [NSObjectProtocol] = [] + private var viewportObservations: [NSKeyValueObservation] = [] + + init() { + view.isUserInteractionEnabled = false + for child in [image.view, video] { + child.frame = view.bounds + child.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(child) + } + video.clipsToBounds = true + video.isHidden = true + video.onWindowChanged = { [weak self] in self?.observeViewport(); self?.updateVisibility() } + video.onLayoutChanged = { [weak self] in self?.updateVisibility() } + lifecycleObservers = [ + NotificationCenter.default.addObserver(forName: UIApplication.willResignActiveNotification, object: nil, queue: .main) { [weak self] _ in self?.releasePlayer() }, + NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in self?.updateVisibility() }, + ] + } + + func bind( + _ source: [String: Any], key: String, fit: String? = nil, + placeholder: String = "#0000000F", probeOrder: [String] = ["image"], + completion: ((Bool) -> Void)? = nil + ) { + let next = NativeListImageSlot.signature([ + "source": source, "key": key, "fit": fit ?? "", "order": probeOrder, + "placeholder": placeholder, + ]) + self.completion = completion + if identity == next { + if let result { completion?(result) } + updateVisibility() + return + } + recycle() + identity = next + self.completion = completion + video.playerLayer.videoGravity = (fit ?? source.string("contentFit")) == "cover" ? .resizeAspectFill : .resizeAspect + probe(source, key: key, fit: fit, placeholder: placeholder, order: probeOrder, attempt: 0, token: epoch) + } + + private func probe( + _ source: [String: Any], key: String, fit: String?, placeholder: String, + order: [String], attempt: Int, token: Int + ) { + guard token == epoch else { return } + candidateGeneration &+= 1 + let candidate = candidateGeneration + resumeVideo = nil + viewportObservations.removeAll() + releasePlayer() + guard attempt < order.count else { + result = false + completion?(false) + return + } + let finished: (Bool) -> Void = { [weak self] loaded in + guard let self, self.epoch == token, self.candidateGeneration == candidate else { return } + if loaded { + guard self.result != true else { return } + self.result = true + self.completion?(true) + } else { + self.probe(source, key: key, fit: fit, placeholder: placeholder, order: order, attempt: attempt + 1, token: token) + } + } + if order[attempt] == "image" { + video.isHidden = true + image.view.isHidden = false + image.bind(source, key: key, fit: fit, placeholder: placeholder, completion: finished) + return + } + image.recycle() + image.view.isHidden = true + video.isHidden = false + guard let url = URL(string: source.string("uri")), !source.string("uri").isEmpty else { finished(false); return } + resumeVideo = { [weak self] in + guard let self, self.epoch == token, self.candidateGeneration == candidate, self.player == nil else { return } + self.playerGeneration &+= 1 + let generation = self.playerGeneration + let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": source.dictionary("headers") ?? [:]]) + let item = AVPlayerItem(asset: asset) + item.preferredForwardBufferDuration = 1 + let player = AVPlayer(playerItem: item) + player.isMuted = true + player.pause() + self.player = player + self.video.playerLayer.player = player + self.itemObservation = item.observe(\.status, options: [.initial, .new]) { [weak self] item, _ in + DispatchQueue.main.async { + guard let self, self.playerGeneration == generation else { return } + if item.status == .failed { finished(false) } + } + } + self.readyObservation = self.video.playerLayer.observe(\.isReadyForDisplay, options: [.initial, .new]) { [weak self] layer, _ in + DispatchQueue.main.async { + guard let self, self.playerGeneration == generation else { return } + if layer.isReadyForDisplay { finished(true) } + } + } + } + observeViewport() + updateVisibility() + } + + private func observeViewport() { + viewportObservations.removeAll() + guard resumeVideo != nil, video.window != nil else { return } + var ancestor = video.superview + while let current = ancestor { + if let scrollView = current as? UIScrollView { + viewportObservations.append(scrollView.observe(\.contentOffset, options: [.new]) { [weak self] _, _ in self?.updateVisibility() }) + viewportObservations.append(scrollView.observe(\.bounds, options: [.new]) { [weak self] _, _ in self?.updateVisibility() }) + } + // Pager or host layouts may translate a retained page without scrolling it. + viewportObservations.append(current.layer.observe(\.transform, options: [.new]) { [weak self] _, _ in self?.updateVisibility() }) + viewportObservations.append(current.layer.observe(\.position, options: [.new]) { [weak self] _, _ in self?.updateVisibility() }) + viewportObservations.append(current.layer.observe(\.isHidden, options: [.new]) { [weak self] _, _ in self?.updateVisibility() }) + viewportObservations.append(current.layer.observe(\.opacity, options: [.new]) { [weak self] _, _ in self?.updateVisibility() }) + ancestor = current.superview + } + } + + /// Retained pager pages can have a window while lying outside its clipped viewport. + static func intersectsViewport(_ view: UIView) -> Bool { + guard let window = view.window, !view.bounds.isEmpty else { return false } + var visible = view.convert(view.bounds, to: window).intersection(window.bounds) + var ancestor: UIView? = view + while let current = ancestor { + if current.isHidden || current.alpha <= 0.01 { return false } + if current.clipsToBounds || current is UIScrollView { + visible = visible.intersection(current.convert(current.bounds, to: window)) + } + if visible.isNull || visible.isEmpty { return false } + ancestor = current.superview + } + return true + } + + private func updateVisibility() { + if Self.intersectsViewport(video) && UIApplication.shared.applicationState == .active { resumeVideo?() } + else { releasePlayer() } + } + private func releasePlayer() { + playerGeneration &+= 1 + itemObservation = nil + readyObservation = nil + player?.pause() + video.playerLayer.player = nil + player?.replaceCurrentItem(with: nil) + player = nil + } + func recycle() { + epoch &+= 1 + candidateGeneration &+= 1 + resumeVideo = nil + viewportObservations.removeAll() + releasePlayer() + image.recycle() + image.view.isHidden = false + video.isHidden = true + identity = nil + result = nil + completion = nil + } + deinit { + lifecycleObservers.forEach(NotificationCenter.default.removeObserver) + player?.pause() + player?.replaceCurrentItem(with: nil) + } +} diff --git a/native-views/react-native-native-list/ios/NativeListMediaTileRenderer.swift b/native-views/react-native-native-list/ios/NativeListMediaTileRenderer.swift index ed7da9449..153a6b9c1 100644 --- a/native-views/react-native-native-list/ios/NativeListMediaTileRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListMediaTileRenderer.swift @@ -8,7 +8,7 @@ final class NativeListMediaTileCell: NativeListRendererCell { } private let picture = UIView() - private let image = NativeListImageSlot() + private let image = NativeListMediaPreviewSlot() private let network = NativeListImageSlot() private let errorIcon = UIImageView() private let title = NativeListTextLabel() @@ -27,7 +27,7 @@ final class NativeListMediaTileCell: NativeListRendererCell { override var defaultCornerRadius: CGFloat { 16 } override var showsSelection: Bool { false } override var pressChangesBackground: Bool { false } - override var assetFields: [String] { ["image", "networkImage"] } + override var assetFields: [String] { ["image", "media", "networkImage"] } override var isHighlighted: Bool { didSet { picture.alpha = isHighlighted ? 0.8 : 1 } } override init(frame: CGRect) { @@ -120,7 +120,8 @@ final class NativeListMediaTileCell: NativeListRendererCell { errorIcon.isHidden = state != "error" errorIcon.image = nativeListIcon(named: "ImageSquareWavesOutline") errorIcon.tintColor = UIColor(nativeListHex: "#00000044", fallback: .lightGray) - if state != "empty", state != "error", let source = item.data.dictionary("image") { + let media = item.data.dictionary("media") + if state != "empty", state != "error", let source = media?.dictionary("source") ?? item.data.dictionary("image") { // Legacy source-backed media had a transparent container. Clear only a fill this // renderer applied, so OneKeyImage keeps ownership of its own placeholder color. if ownsImageFill { @@ -129,7 +130,12 @@ final class NativeListMediaTileCell: NativeListRendererCell { } image.bind( source, key: "\(item.key):media", fit: imageStyle["contentFit"] as? String, - placeholder: placeholder) + placeholder: placeholder, probeOrder: media?["probeOrder"] as? [String] ?? ["image"] + ) { [weak self] loaded in + self?.errorIcon.isHidden = loaded + self?.ownsImageFill = !loaded + self?.image.view.backgroundColor = loaded ? .clear : UIColor(nativeListHex: placeholder, fallback: .lightGray) + } } else { image.recycle() image.view.backgroundColor = diff --git a/native-views/react-native-native-list/ios/NativeListModels.swift b/native-views/react-native-native-list/ios/NativeListModels.swift index 1698ec7bc..3c86ccc34 100644 --- a/native-views/react-native-native-list/ios/NativeListModels.swift +++ b/native-views/react-native-native-list/ios/NativeListModels.swift @@ -93,6 +93,7 @@ struct NativeListConfig { var selectedKeys: Set let reorderable: Bool let pullToRefresh: Bool + let refreshTriggerDistance: CGFloat? var refreshing: Bool let loadMore: Bool let endReachedThreshold: Double @@ -144,7 +145,7 @@ struct NativeListConfig { generation: generation, layout: layout, orientation: layoutData["orientation"] as? String ?? "vertical", - gridColumns: min(4, max(1, layoutData["gridColumns"] as? Int ?? 1)), + gridColumns: min(7, max(1, layoutData["gridColumns"] as? Int ?? 1)), stickyHeaders: layoutData["stickyHeaders"] as? Bool ?? false, contentPadding: contentPadding, contentPaddingHorizontal: contentPaddingHorizontal, @@ -156,6 +157,8 @@ struct NativeListConfig { selectedKeys: selectedKeys, reorderable: capabilities?["reorderable"] as? Bool ?? false, pullToRefresh: capabilities?["pullToRefresh"] as? Bool ?? false, + refreshTriggerDistance: (capabilities?["refreshTriggerDistance"] as? Double) + .flatMap { $0.isFinite && $0 > 0 ? CGFloat($0) : nil }, refreshing: capabilities?["refreshing"] as? Bool ?? false, loadMore: capabilities?["loadMore"] as? Bool ?? false, endReachedThreshold: capabilities?["endReachedThreshold"] as? Double ?? 0.2, diff --git a/native-views/react-native-native-list/ios/NativeListScrollPositionTracker.swift b/native-views/react-native-native-list/ios/NativeListScrollPositionTracker.swift new file mode 100644 index 000000000..5ffe60508 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListScrollPositionTracker.swift @@ -0,0 +1,30 @@ +import Foundation + +/// A per-view hysteresis observer. A nil result means no bridge event is needed. +struct NativeListScrollPositionTracker { + private var start: Double? + private var end: Double? + private var previous: Bool? + + mutating func configure(json: String) { + start = nil + end = nil + previous = nil + guard let data = json.data(using: .utf8), + let values = try? JSONSerialization.jsonObject(with: data) as? [String: Double], + let start = values["start"], let end = values["end"], + start.isFinite, end.isFinite, start >= 0, end > start else { return } + self.start = start + self.end = end + } + + mutating func resetDelivery() { previous = nil } + + mutating func update(offset: Double) -> Bool? { + guard let start, let end, offset.isFinite else { return nil } + let next = previous == true ? offset > start : offset >= end + guard previous != next else { return nil } + previous = next + return next + } +} diff --git a/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift b/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift index a93464cd7..4705c8814 100644 --- a/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift @@ -99,7 +99,7 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { if layout == "table" { return 28 } if item.data.string("presentation") == "networkSelector" { return 47 } if item.data.string("variant") == "history" { - return 16 + return item.data.bool("titleLoading") ? 20 : 16 } if item.data.string("variant") == "summary" { return 68 } if item.data.string("variant") == "gallery" { return 32 } @@ -110,6 +110,7 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { private let mainStack = UIStackView() private let titleRowStack = UIStackView() private let titleLabel = NativeListDottedUnderlineLabel() + private let titleSpinner = UIActivityIndicatorView(style: .medium) private let subtitleLabel = NativeListTextLabel() private let trailingStack = UIStackView() private let accessoryButtons = [NativeListAccessoryButton(type: .system)] @@ -132,6 +133,12 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { root.axis = .horizontal root.alignment = .center root.spacing = 12 + titleSpinner.translatesAutoresizingMaskIntoConstraints = false + titleSpinner.isAccessibilityElement = false + NSLayoutConstraint.activate([ + titleSpinner.widthAnchor.constraint(equalToConstant: 20), + titleSpinner.heightAnchor.constraint(equalToConstant: 20), + ]) mainStack.axis = .vertical mainStack.alignment = .fill mainStack.spacing = 2 @@ -174,6 +181,7 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { selectorTypographyRestorers = [] currentItem = item currentTheme = theme + titleSpinner.stopAnimating() NSLayoutConstraint.deactivate(selectorConstraints) selectorConstraints = [] root.arrangedSubviews.forEach { @@ -244,6 +252,8 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { if style["lineGap"] != nil { mainStack.spacing = CGFloat(style.double("lineGap")) } if style["trailingGap"] != nil { trailingStack.spacing = CGFloat(style.double("trailingGap")) } applyTextStyle(item) + titleSpinner.color = titleLabel.textColor + updateTitleSpinner() contentInsets = UIEdgeInsets( top: topInset, left: leftInset, bottom: -bottomInset, right: -rightInset) root.alignment = @@ -301,6 +311,18 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { override func recycleContent() { checkboxControl.reset() currentItem = nil + titleSpinner.stopAnimating() + } + override func didMoveToWindow() { + super.didMoveToWindow() + updateTitleSpinner() + } + private func updateTitleSpinner() { + if window != nil, currentItem?.data.bool("titleLoading") == true { + titleSpinner.startAnimating() + } else { + titleSpinner.stopAnimating() + } } @objc private func titlePressed() { if let item = currentItem { @@ -324,6 +346,10 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { layout: String, _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String ) { + if item.data.bool("titleLoading") { + root.addArrangedSubview(titleSpinner) + root.setCustomSpacing(8, after: titleSpinner) + } root.addArrangedSubview(mainStack) let variant = item.data.string("variant") // OneKey patch: title help is a separate target from checkbox and value actions. diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index 286513007..6aebfbbc6 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -4,6 +4,51 @@ import UIKit import UniformTypeIdentifiers final class NativeListView: UIView { + private var refreshEmittedForDrag = false + private var minimumDragOffset: CGFloat = 0 + private var reactContainerSlots: [UIView] = [] + private var containerSlotHeights: [CGFloat] = [0, 0, 0] + + @objc func mountContainerSlot(_ view: UIView, atIndex index: Int) { + reactContainerSlots.insert(view, at: min(index, reactContainerSlots.count)) + collectionView.addSubview(view) + setNeedsLayout() + } + + @objc func unmountContainerSlot(_ view: UIView) { + reactContainerSlots.removeAll { $0 === view } + view.removeFromSuperview() + } + + func setContainerSlotHeightsJson(_ json: String) { + guard let data = json.data(using: .utf8), + let heights = try? JSONSerialization.jsonObject(with: data) as? [Double], + heights.count == 3, heights.allSatisfy({ $0.isFinite && $0 >= 0 }) else { return } + containerSlotHeights = heights.map { CGFloat($0) } + if let config { configureLayout(config) } + setNeedsLayout() + } + + private func layoutContainerSlots() { + guard reactContainerSlots.count == 3 else { return } + let empty = config?.items.isEmpty ?? true + let header = containerSlotHeights[0] + let emptyHeight = empty ? containerSlotHeights[1] : 0 + let footer = containerSlotHeights[2] + let paddingTop = config?.contentPaddingTop ?? 0 + let paddingBottom = config?.contentPaddingBottom ?? 0 + let footerY = max(paddingTop + header + emptyHeight, + collectionView.contentSize.height - paddingBottom - footer) + let positions = [paddingTop, paddingTop + header, footerY] + for (index, view) in reactContainerSlots.enumerated() { + view.isHidden = index == 1 && !empty + let horizontalPadding = config?.contentPaddingHorizontal ?? 0 + view.frame = CGRect(x: horizontalPadding, y: positions[index], + width: max(0, collectionView.bounds.width - horizontalPadding * 2), + height: containerSlotHeights[index]) + } + } + private final class ActionAnchorRecord { let token: String weak var sourceView: UIView? @@ -47,6 +92,26 @@ final class NativeListView: UIView { case end(Bool) } + private var scrollPositionTracker = NativeListScrollPositionTracker() + var onScrollPositionThresholdChange: ((Bool) -> Void)? { + didSet { + scrollPositionTracker.resetDelivery() + emitScrollPositionThresholdIfNeeded() + } + } + + func setScrollPositionThresholdsJson(_ json: String) { + scrollPositionTracker.configure(json: json) + emitScrollPositionThresholdIfNeeded() + } + + private func emitScrollPositionThresholdIfNeeded() { + guard let callback = onScrollPositionThresholdChange, + let next = scrollPositionTracker.update(offset: Double(max(0, + collectionView.contentOffset.y + collectionView.contentInset.top))) else { return } + callback(next) + } + var onRowAction: ((String) -> Void)? var onActionAnchorInvalidated: ((String) -> Void)? var onSelectionDelta: ((String) -> Void)? @@ -60,7 +125,11 @@ final class NativeListView: UIView { } private let flowLayout = NativeListFlowLayout() - private lazy var collectionView = NativeListCollectionView(frame: .zero, collectionViewLayout: flowLayout) + private lazy var collectionView: NativeListCollectionView = { + let view = NativeListCollectionView(frame: .zero, collectionViewLayout: flowLayout) + view.onContentLayout = { [weak self] in self?.layoutContainerSlots() } + return view + }() private let footerContainer = UIView() private var footerCell: NativeListRowHost = NativeListRendererRegistry.create(.action) private var footerRendererKey = NativeListRendererKey.action @@ -333,6 +402,7 @@ final class NativeListView: UIView { rebindRowsForLayoutDirection(config) } performPendingScrollIfNeeded() + layoutContainerSlots() } // A layout-direction change may arrive without a layout pass (semantic attribute or trait). @@ -693,11 +763,14 @@ final class NativeListView: UIView { flowLayout.minimumLineSpacing = config.itemSpacing flowLayout.minimumInteritemSpacing = config.itemSpacing flowLayout.sectionInset = UIEdgeInsets( - top: config.contentPaddingTop, + top: config.contentPaddingTop + containerSlotHeights[0], left: config.contentPaddingHorizontal + (isRightToLeft ? indexGutter : 0), - bottom: config.contentPaddingBottom, + bottom: config.contentPaddingBottom + containerSlotHeights[2], right: config.contentPaddingHorizontal + (isRightToLeft ? 0 : indexGutter) ) + flowLayout.minimumContainerContentHeight = config.items.isEmpty + ? config.contentPaddingTop + config.contentPaddingBottom + containerSlotHeights.reduce(0, +) + : 0 flowLayout.stickyItemIndexes = config.stickyHeaders ? Set(config.items.enumerated().compactMap { $0.element.type == "sectionHeader" && $0.element.data.bool("sticky", default: true) && $0.element.data.string("variant") != "summary" @@ -1539,6 +1612,7 @@ final class NativeListView: UIView { current.rowPressToggles == next.rowPressToggles, current.reorderable == next.reorderable, current.pullToRefresh == next.pullToRefresh, + current.refreshTriggerDistance == next.refreshTriggerDistance, current.refreshing == next.refreshing, current.loadMore == next.loadMore, current.endReachedThreshold == next.endReachedThreshold, @@ -1856,6 +1930,7 @@ final class NativeListView: UIView { onReorder = nil onEndReached = nil onVisibleRangeChanged = nil + onScrollPositionThresholdChange = nil } private func createActionAnchor(origin: NativeListActionOrigin) -> [String: Any]? { @@ -1921,6 +1996,9 @@ final class NativeListView: UIView { } @objc private func refreshTriggered() { + guard !disposed, config?.pullToRefresh == true, + config?.refreshing != true, !refreshEmittedForDrag else { return } + refreshEmittedForDrag = true emit(onRowAction, ["actionKey": "nativeList.refresh"]) } @@ -2142,7 +2220,26 @@ extension NativeListView: UICollectionViewDelegateFlowLayout { return item.isRowPressEnabled } + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + minimumDragOffset = 0 + refreshEmittedForDrag = false + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + minimumDragOffset = min(minimumDragOffset, scrollView.contentOffset.y + scrollView.contentInset.top) + guard let threshold = config?.refreshTriggerDistance, + config?.pullToRefresh == true, config?.refreshing != true, + minimumDragOffset <= -threshold, !refreshEmittedForDrag else { return } + collectionView.refreshControl?.beginRefreshing() + refreshTriggered() + } + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollView.isDragging { + minimumDragOffset = min(minimumDragOffset, scrollView.contentOffset.y + scrollView.contentInset.top) + } + layoutContainerSlots() + emitScrollPositionThresholdIfNeeded() invalidateActionAnchor(reason: "scroll") syncSectionIndexToVisibleRows() emitVisibleRangeIfNeeded() @@ -2205,6 +2302,13 @@ extension NativeListView: UICollectionViewDragDelegate, UICollectionViewDropDele // quick tap highlighted and unhighlighted a row within one frame and the pressed background never // rendered. Deliver touches immediately, as Android rows do. private final class NativeListCollectionView: UICollectionView { + var onContentLayout: (() -> Void)? + + override func layoutSubviews() { + super.layoutSubviews() + onContentLayout?() + } + override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { super.init(frame: frame, collectionViewLayout: layout) delaysContentTouches = false @@ -2223,6 +2327,7 @@ private final class NativeListCollectionView: UICollectionView { final class NativeListFlowLayout: UICollectionViewFlowLayout { var stickyItemIndexes: Set = [] + var minimumContainerContentHeight: CGFloat = 0 private var horizontalAttributes: [IndexPath: UICollectionViewLayoutAttributes] = [:] private var horizontalContentSize: CGSize = .zero @@ -2251,7 +2356,9 @@ final class NativeListFlowLayout: UICollectionViewFlowLayout { } override var collectionViewContentSize: CGSize { - scrollDirection == .horizontal ? horizontalContentSize : super.collectionViewContentSize + if scrollDirection == .horizontal { return horizontalContentSize } + let size = super.collectionViewContentSize + return CGSize(width: size.width, height: max(size.height, minimumContainerContentHeight)) } override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { diff --git a/native-views/react-native-native-list/ios/tests/NativeListActivityPresentationTests.swift b/native-views/react-native-native-list/ios/tests/NativeListActivityPresentationTests.swift new file mode 100644 index 000000000..adc4a20d8 --- /dev/null +++ b/native-views/react-native-native-list/ios/tests/NativeListActivityPresentationTests.swift @@ -0,0 +1,43 @@ +import UIKit +import XCTest +@testable import NativeListModule + +@MainActor +final class NativeListActivityPresentationTests: XCTestCase { + func testSmallNumberRunsPreserveBaselineAcrossAmountsFiatAndFee() throws { + func segments(_ prefix: String, _ suffix: String) -> [[String: Any]] { + [["text": prefix], ["text": "5", "style": "subscript"], ["text": suffix]] + } + let item = try NativeListItem(data: [ + "type": "activity", "key": "transfer", "title": "Transfer", + "leading": ["kind": "icon", "name": "ArrowTopOutline"], + "amounts": [[ + "key": "receive", "text": "0.051 ETH", "textSegments": segments("0.0", "1 ETH"), + "secondaryText": "$0.051", "secondaryTextSegments": segments("$0.0", "1"), + ]], + "fee": [ + "primary": "0.051 GAS", "primaryTextSegments": segments("0.0", "1 GAS"), + "secondary": "€0.051", "secondaryTextSegments": segments("€0.0", "1"), + ], + ]) + let view = NativeListActivityDetailsView() + view.bind(item, theme: nil) + func labels(in view: UIView) -> [UILabel] { + (view as? UILabel).map { [$0] } ?? view.subviews.flatMap { labels(in: $0) } + } + let cases: [(String, Int, CGFloat)] = [ + ("0.051 ETH", 3, 16), ("$0.051", 4, 14), + ("0.051 GAS", 3, 14), ("€0.051", 4, 12), + ] + let rendered = labels(in: view) + for (text, countIndex, parentSize) in cases { + let label = try XCTUnwrap(rendered.first { $0.attributedText?.string == text }) + let value = try XCTUnwrap(label.attributedText) + let font = try XCTUnwrap(value.attribute(.font, at: countIndex, effectiveRange: nil) as? UIFont) + XCTAssertEqual(font.pointSize, ceil(parentSize * 0.6)) + let baseline = value.attribute(.baselineOffset, at: 0, effectiveRange: nil) as? NSNumber + let countBaseline = value.attribute(.baselineOffset, at: countIndex, effectiveRange: nil) as? NSNumber + XCTAssertEqual(countBaseline?.doubleValue ?? 0, baseline?.doubleValue ?? 0) + } + } +} diff --git a/native-views/react-native-native-list/ios/tests/NativeListLifecycleTests.swift b/native-views/react-native-native-list/ios/tests/NativeListLifecycleTests.swift index 7e3adcdbf..2e86258fe 100644 --- a/native-views/react-native-native-list/ios/tests/NativeListLifecycleTests.swift +++ b/native-views/react-native-native-list/ios/tests/NativeListLifecycleTests.swift @@ -4,6 +4,42 @@ import XCTest @MainActor final class NativeListLifecycleTests: XCTestCase { + func testSectionLoadingStopsOnRebindReuseAndDetach() throws { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + let controller = UIViewController() + window.rootViewController = controller + window.isHidden = false + defer { window.isHidden = true } + let cell = NativeListSectionHeaderCell(frame: CGRect(x: 0, y: 0, width: 320, height: 40)) + controller.view.addSubview(cell) + func bind(loading: Bool) throws { + let item = try NativeListItem(data: [ + "key": "section", "type": "sectionHeader", "sectionKey": "pending", + "title": "Confirming", "variant": "history", "titleLoading": loading, + "style": ["title": ["color": "#FFAA00"]], + ]) + cell.bind(item: item, theme: nil, layout: "sectioned", selected: false, + checkboxState: { _, _, fallback in fallback }) + } + try bind(loading: true) + let spinner = try XCTUnwrap(cell.root.arrangedSubviews.first as? UIActivityIndicatorView) + XCTAssertTrue(spinner.isAnimating) + XCTAssertFalse(spinner.isAccessibilityElement) + XCTAssertEqual(spinner.color, UIColor(nativeListHex: "#FFAA00", fallback: .clear)) + + try bind(loading: false) + XCTAssertFalse(spinner.isAnimating) + XCTAssertNil(spinner.superview) + try bind(loading: true) + XCTAssertTrue(spinner.isAnimating) + cell.removeFromSuperview() + XCTAssertFalse(spinner.isAnimating) + controller.view.addSubview(cell) + XCTAssertTrue(spinner.isAnimating) + cell.prepareForReuse() + XCTAssertFalse(spinner.isAnimating) + } + func testRebindingAndReuseInvalidateThePreviousActionEpoch() throws { let cell = NativeListActionCell(frame: CGRect(x: 0, y: 0, width: 320, height: 60)) let first = try NativeListItem(data: ["key": "row", "type": "action", "title": "First"]) diff --git a/native-views/react-native-native-list/ios/tests/NativeListMediaVisibilityTests.swift b/native-views/react-native-native-list/ios/tests/NativeListMediaVisibilityTests.swift new file mode 100644 index 000000000..cd97a6d4b --- /dev/null +++ b/native-views/react-native-native-list/ios/tests/NativeListMediaVisibilityTests.swift @@ -0,0 +1,42 @@ +import UIKit +import XCTest +@testable import NativeListModule + +@MainActor +final class NativeListMediaVisibilityTests: XCTestCase { + func testRetainedPagerAndListCellsRequireViewportIntersection() { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + let controller = UIViewController() + window.rootViewController = controller + window.isHidden = false + defer { window.isHidden = true } + let pager = UIScrollView(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + pager.contentSize = CGSize(width: 640, height: 480) + controller.view.addSubview(pager) + let list = UIScrollView(frame: CGRect(x: 320, y: 0, width: 320, height: 480)) + list.contentSize = CGSize(width: 320, height: 960) + pager.addSubview(list) + let tile = UIView(frame: CGRect(x: 10, y: 10, width: 100, height: 100)) + list.addSubview(tile) + + XCTAssertNotNil(tile.window, "Pager retains the offscreen cell in the window") + XCTAssertFalse(NativeListMediaPreviewSlot.intersectsViewport(tile)) + pager.contentOffset.x = 320 + XCTAssertTrue(NativeListMediaPreviewSlot.intersectsViewport(tile)) + list.contentOffset.y = 110 + XCTAssertFalse(NativeListMediaPreviewSlot.intersectsViewport(tile)) + list.contentOffset.y = 100 + XCTAssertTrue(NativeListMediaPreviewSlot.intersectsViewport(tile), "Partially visible previews remain eligible") + list.transform = CGAffineTransform(translationX: 320, y: 0) + XCTAssertFalse(NativeListMediaPreviewSlot.intersectsViewport(tile)) + list.transform = .identity + XCTAssertTrue(NativeListMediaPreviewSlot.intersectsViewport(tile)) + list.isHidden = true + XCTAssertFalse(NativeListMediaPreviewSlot.intersectsViewport(tile)) + list.isHidden = false + list.alpha = 0 + XCTAssertFalse(NativeListMediaPreviewSlot.intersectsViewport(tile)) + tile.removeFromSuperview() + XCTAssertFalse(NativeListMediaPreviewSlot.intersectsViewport(tile)) + } +} diff --git a/native-views/react-native-native-list/ios/tests/NativeListScrollPositionTrackerTests.swift b/native-views/react-native-native-list/ios/tests/NativeListScrollPositionTrackerTests.swift new file mode 100644 index 000000000..98c6b3fe8 --- /dev/null +++ b/native-views/react-native-native-list/ios/tests/NativeListScrollPositionTrackerTests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import NativeListModule + +final class NativeListScrollPositionTrackerTests: XCTestCase { + func testHysteresisAndDisabledLifecycle() { + var tracker = NativeListScrollPositionTracker() + XCTAssertNil(tracker.update(offset: 200)) + tracker.configure(json: "{\"start\":48,\"end\":160}") + XCTAssertEqual(tracker.update(offset: 0), false) + XCTAssertNil(tracker.update(offset: 159)) + XCTAssertEqual(tracker.update(offset: 160), true) + XCTAssertNil(tracker.update(offset: 100)) + XCTAssertEqual(tracker.update(offset: 48), false) + XCTAssertNil(tracker.update(offset: 49)) + tracker.configure(json: "") + XCTAssertNil(tracker.update(offset: 200)) + tracker.configure(json: "{\"start\":48,\"end\":160}") + XCTAssertEqual(tracker.update(offset: 200), true) + tracker.resetDelivery() + XCTAssertEqual(tracker.update(offset: 200), true) + XCTAssertNil(tracker.update(offset: .nan)) + tracker.configure(json: "{\"start\":48,\"end\":48}") + XCTAssertNil(tracker.update(offset: 48)) + } +} diff --git a/native-views/react-native-native-list/package.json b/native-views/react-native-native-list/package.json index c4ae342a8..a0af1b0db 100644 --- a/native-views/react-native-native-list/package.json +++ b/native-views/react-native-native-list/package.json @@ -30,12 +30,13 @@ "!android/build", "!android/app/build", "!**/__tests__", - "!**/*.map" + "!**/*.map", + "scripts" ], "scripts": { "clean": "del-cli lib", "prepare": "del-cli lib && bob build && cp -r nitrogen lib/nitrogen", - "nitrogen": "nitrogen", + "nitrogen": "nitrogen && node scripts/enable-container-slots.mjs", "typecheck": "tsc -b", "lint": "eslint \"**/*.{js,ts,tsx}\"", "test": "jest --runInBand", diff --git a/native-views/react-native-native-list/scripts/enable-container-slots.mjs b/native-views/react-native-native-list/scripts/enable-container-slots.mjs new file mode 100644 index 000000000..2c108b292 --- /dev/null +++ b/native-views/react-native-native-list/scripts/enable-container-slots.mjs @@ -0,0 +1,58 @@ +// Nitro 0.37 emits leaf view managers. Container slots need explicit Fabric +// child ownership, reapplied deterministically after every nitrogen build. +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const android = join(root, 'nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt'); +const ios = join(root, 'nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm'); +const marker = '// NativeList container slot mounting'; +let kotlin = readFileSync(android, 'utf8'); +if (!kotlin.includes(marker)) { + if (!kotlin.includes('SimpleViewManager')) throw new Error('Unexpected Nitro Android manager shape'); + kotlin = kotlin.replace('import android.view.View\n', 'import android.view.View\nimport android.view.ViewGroup\n'); + kotlin = kotlin.replace('import com.facebook.react.uimanager.SimpleViewManager', 'import com.facebook.react.uimanager.ViewGroupManager'); + kotlin = kotlin.replace('SimpleViewManager()', 'ViewGroupManager()'); + kotlin = kotlin.replaceAll('view: View', 'view: ViewGroup'); + kotlin = kotlin.replace('): View {', '): ViewGroup {').replace('): View? {', '): ViewGroup? {'); + kotlin = kotlin.replace('val view = hybridView.view', 'val view = hybridView.view as ViewGroup'); + kotlin = kotlin.replace('return hybridView.view', 'return hybridView.view as ViewGroup'); + kotlin = kotlin.replace(' override fun getName(): String {', ` ${marker} + override fun needsCustomLayoutForChildren(): Boolean = true + override fun addView(parent: ViewGroup, child: View, index: Int) { + (parent as NativeListView).mountContainerSlot(child, index) + } + override fun getChildCount(parent: ViewGroup): Int = (parent as NativeListView).containerSlotCount() + override fun getChildAt(parent: ViewGroup, index: Int): View = (parent as NativeListView).containerSlotAt(index) + override fun removeViewAt(parent: ViewGroup, index: Int) { + (parent as NativeListView).unmountContainerSlot(index) + } + override fun removeAllViews(parent: ViewGroup) { + while (getChildCount(parent) > 0) removeViewAt(parent, getChildCount(parent) - 1) + } + + override fun getName(): String {`); + writeFileSync(android, kotlin); +} +let objc = readFileSync(ios, 'utf8'); +if (!objc.includes(marker)) { + if (!objc.includes('- (void) updateView {')) throw new Error('Unexpected Nitro iOS component shape'); + objc = objc.replace('@implementation HybridNativeListComponent {', `@interface UIView (NativeListContainerSlots) +- (void)mountContainerSlot:(UIView *)view atIndex:(NSInteger)index; +- (void)unmountContainerSlot:(UIView *)view; +@end + +@implementation HybridNativeListComponent {`); + objc = objc.replace('- (void) updateView {', `${marker} +- (void)mountChildComponentView:(UIView *)child index:(NSInteger)index { + [self.contentView mountContainerSlot:child atIndex:index]; +} + +- (void)unmountChildComponentView:(UIView *)child index:(NSInteger)index { + [self.contentView unmountContainerSlot:child]; +} + +- (void) updateView {`); + writeFileSync(ios, objc); +} diff --git a/native-views/react-native-native-list/scripts/enable-container-slots.test.mjs b/native-views/react-native-native-list/scripts/enable-container-slots.test.mjs new file mode 100644 index 000000000..2ad5b783f --- /dev/null +++ b/native-views/react-native-native-list/scripts/enable-container-slots.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const script = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'enable-container-slots.mjs'), 'utf8'); +const androidPath = 'nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt'; +const iosPath = 'nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm'; + +function fixture(android, ios) { + const root = mkdtempSync(join(tmpdir(), 'native-list-slots-')); + for (const [relative, content] of [['scripts/enable-container-slots.mjs', script], [androidPath, android], [iosPath, ios]]) { + const path = join(root, relative); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); + } + return root; +} + +test('post-nitrogen child ownership transformation is idempotent', () => { + const root = fixture(`import android.view.View +import com.facebook.react.uimanager.SimpleViewManager +public class HybridNativeListManager: SimpleViewManager() { + override fun getName(): String { return "NativeList" } + override fun createViewInstance(reactContext: ThemedReactContext): View { + val view = hybridView.view + return view + } +}`, `@implementation HybridNativeListComponent { +} +- (void) updateView { +}`); + try { + const path = join(root, 'scripts/enable-container-slots.mjs'); + execFileSync(process.execPath, [path]); + const firstAndroid = readFileSync(join(root, androidPath), 'utf8'); + const firstIOS = readFileSync(join(root, iosPath), 'utf8'); + assert.match(firstAndroid, /ViewGroupManager/); + assert.match(firstAndroid, /unmountContainerSlot\(index\)/); + assert.match(firstIOS, /mountChildComponentView/); + assert.match(firstIOS, /unmountContainerSlot:child/); + execFileSync(process.execPath, [path]); + assert.equal(readFileSync(join(root, androidPath), 'utf8'), firstAndroid); + assert.equal(readFileSync(join(root, iosPath), 'utf8'), firstIOS); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('unexpected nitrogen output fails instead of silently losing child mounting', () => { + const root = fixture('unrecognized generator output', '- (void) updateView {'); + try { + const result = spawnSync(process.execPath, [join(root, 'scripts/enable-container-slots.mjs')]); + assert.notEqual(result.status, 0); + assert.match(result.stderr.toString(), /Unexpected Nitro Android manager shape/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/native-views/react-native-native-list/src/NativeList.nitro.ts b/native-views/react-native-native-list/src/NativeList.nitro.ts index ee0b0ab59..3df74c81f 100644 --- a/native-views/react-native-native-list/src/NativeList.nitro.ts +++ b/native-views/react-native-native-list/src/NativeList.nitro.ts @@ -11,6 +11,9 @@ import type { */ export interface NativeListNativeProps extends HybridViewProps { snapshotJson: string; + containerSlotHeightsJson: string; + scrollPositionThresholdsJson: string; + onScrollPositionThresholdChange?: (isBeyondThreshold: boolean) => void; keyboardDismissMode: NativeListKeyboardDismissMode; keyboardShouldPersistTaps: NativeListKeyboardShouldPersistTaps; onRowAction?: (payloadJson: string) => void; diff --git a/native-views/react-native-native-list/src/NativeList.tsx b/native-views/react-native-native-list/src/NativeList.tsx index 5381726f6..965452244 100644 --- a/native-views/react-native-native-list/src/NativeList.tsx +++ b/native-views/react-native-native-list/src/NativeList.tsx @@ -4,6 +4,7 @@ import React, { useImperativeHandle, useMemo, useRef, + useState, } from 'react'; import { OneKeyImageCache, @@ -13,6 +14,7 @@ import { NativeAvatarPrefetchModel, NativeAvatarPrefetchQueue, } from './avatarPrefetch'; +import { View } from 'react-native'; import { callback, getHostComponent } from 'react-native-nitro-modules'; import type { NativeListMethods, @@ -96,6 +98,10 @@ export const NativeList = forwardRef( function NativeList( { snapshot, + listHeader, + listFooter, + listEmpty, + scrollPositionThresholds, webVirtualizationEnabled: _webVirtualizationEnabled, webSectionIndexContainerRef: _webSectionIndexContainerRef, keyboardDismissMode = 'none', @@ -106,6 +112,7 @@ export const NativeList = forwardRef( onReorder, onEndReached, onVisibleRangeChanged, + onScrollPositionThresholdChange, onRefresh, onScrollToIndexFailed, initialScrollIndex, @@ -116,6 +123,21 @@ export const NativeList = forwardRef( }, forwardedRef ) { + const [slotHeights, setSlotHeights] = useState([0, 0, 0]); + const containerSlotHeightsJson = JSON.stringify(slotHeights); + const slotInset = + snapshot.layout.contentPaddingHorizontal ?? + snapshot.layout.contentPadding ?? + 0; + if ( + snapshot.layout.orientation === 'horizontal' && + (listHeader || listEmpty || listFooter) + ) { + throw new Error( + 'NativeList container slots require vertical orientation' + ); + } + const slotContents = [listHeader, listEmpty, listFooter]; const normalizedKeyboardShouldPersistTaps = keyboardShouldPersistTaps === true ? 'always' @@ -130,6 +152,7 @@ export const NativeList = forwardRef( onReorder, onEndReached, onVisibleRangeChanged, + onScrollPositionThresholdChange, onRefresh, onScrollToIndexFailed, }); @@ -140,9 +163,29 @@ export const NativeList = forwardRef( onReorder, onEndReached, onVisibleRangeChanged, + onScrollPositionThresholdChange, onRefresh, onScrollToIndexFailed, }; + const scrollPositionThresholdsJson = useMemo(() => { + if ( + !scrollPositionThresholds || + scrollPositionThresholds.enabled === false + ) + return ''; + const { start, end } = scrollPositionThresholds; + if ( + !Number.isFinite(start) || + !Number.isFinite(end) || + start < 0 || + end <= start + ) { + throw new Error( + 'scrollPositionThresholds requires 0 <= start < end and finite values' + ); + } + return JSON.stringify({ start, end }); + }, [scrollPositionThresholds]); const snapshotJson = useMemo(() => serializeSnapshot(snapshot), [snapshot]); const snapshotRef = useRef(snapshot); const previousSnapshotJsonRef = useRef(snapshotJson); @@ -427,6 +470,13 @@ export const NativeList = forwardRef( parsePayload(payloadJson) ); }), + onScrollPositionThresholdChange: callback( + (isBeyondThreshold: boolean) => { + callbacksRef.current.onScrollPositionThresholdChange?.({ + isBeyondThreshold, + }); + } + ), onVisibleRangeChanged: callback((payloadJson: string) => { const payload = parsePayload(payloadJson); const previous = avatarRangeRef.current; @@ -450,9 +500,36 @@ export const NativeList = forwardRef( {...viewProps} {...nativeCallbacks} snapshotJson={snapshotJson} + containerSlotHeightsJson={containerSlotHeightsJson} + scrollPositionThresholdsJson={scrollPositionThresholdsJson} keyboardDismissMode={keyboardDismissMode} keyboardShouldPersistTaps={normalizedKeyboardShouldPersistTaps} - /> + > + {slotContents.map((content, index) => ( + { + const height = Math.ceil(event.nativeEvent.layout.height); + setSlotHeights((previous) => + previous[index] === height + ? previous + : previous.map((value, slot) => + slot === index ? height : value + ) + ); + }} + > + {content} + + ))} + ); } ); diff --git a/native-views/react-native-native-list/src/NativeList.types.ts b/native-views/react-native-native-list/src/NativeList.types.ts index 448796e56..1d18f89fa 100644 --- a/native-views/react-native-native-list/src/NativeList.types.ts +++ b/native-views/react-native-native-list/src/NativeList.types.ts @@ -1,4 +1,4 @@ -import type { RefObject } from 'react'; +import type { ReactNode, RefObject } from 'react'; import type { View, ViewProps } from 'react-native'; import type { ActionAnchorInvalidatedEvent, @@ -119,6 +119,10 @@ export type NativeListRef = Readonly<{ export type NativeListProps = Omit & Readonly<{ snapshot: NativeListSnapshot; + /** Native-only, container-owned scroll content. Never rendered as a data row. */ + listHeader?: ReactNode; + listFooter?: ReactNode; + listEmpty?: ReactNode; /** Web only. Defaults to true and is ignored by the native host. */ webVirtualizationEnabled?: boolean; /** Web only. Hosts the section index inside this container. */ @@ -136,6 +140,15 @@ export type NativeListProps = Omit & onReorder?: (event: ReorderEvent) => void; onEndReached?: (event: EndReachedEvent) => void; onVisibleRangeChanged?: (event: VisibleRangeChangedEvent) => void; + /** Native only. Emits initial state and hysteresis transitions, not scroll frames. */ + scrollPositionThresholds?: Readonly<{ + start: number; + end: number; + enabled?: boolean; + }>; + onScrollPositionThresholdChange?: ( + event: Readonly<{ isBeyondThreshold: boolean }> + ) => void; onRefresh?: () => void; onScrollToIndexFailed?: (info: ScrollToIndexFailedInfo) => void; }> & diff --git a/native-views/react-native-native-list/src/NativeList.web.tsx b/native-views/react-native-native-list/src/NativeList.web.tsx index 40aec4bcb..bd3cbd4f5 100644 --- a/native-views/react-native-native-list/src/NativeList.web.tsx +++ b/native-views/react-native-native-list/src/NativeList.web.tsx @@ -82,6 +82,11 @@ export const NativeList = forwardRef( function NativeList( { snapshot, + listHeader: _listHeader, + listEmpty: _listEmpty, + listFooter: _listFooter, + scrollPositionThresholds: _scrollPositionThresholds, + onScrollPositionThresholdChange: _onScrollPositionThresholdChange, webVirtualizationEnabled = true, webSectionIndexContainerRef, keyboardDismissMode: _keyboardDismissMode, diff --git a/native-views/react-native-native-list/src/__tests__/activityRich.test.ts b/native-views/react-native-native-list/src/__tests__/activityRich.test.ts new file mode 100644 index 000000000..f55bf05bd --- /dev/null +++ b/native-views/react-native-native-list/src/__tests__/activityRich.test.ts @@ -0,0 +1,90 @@ +import type { ActivityRow, NativeListSnapshot } from '../models'; +import { validatePatches, validateSnapshot } from '../validation'; + +const row: ActivityRow = { + type: 'activity', + key: 'record', + title: 'Transfer', + leading: { kind: 'icon', name: 'coin' }, +}; +const snapshot = (value: ActivityRow): NativeListSnapshot => ({ + schemaVersion: 1, + generation: 0, + layout: { kind: 'linear' }, + rows: [value], +}); + +test('legacy amounts remain valid and rich lines preserve explicit segments', () => { + expect(() => + validateSnapshot( + snapshot({ ...row, primaryAmount: '+1 ETH', secondaryAmount: '$2' }) + ) + ).not.toThrow(); + expect(() => + validateSnapshot( + snapshot({ + ...row, + presentation: 'table', + amounts: [ + { + key: 'receive', + text: '+0.012 ETH', + textSegments: [ + { text: '+0.0' }, + { text: '12', style: 'subscript' }, + { text: ' ETH' }, + ], + tone: 'positive', + }, + ], + fee: { label: 'Fee', primary: '0.001 ETH', hidden: true }, + descriptionActionKey: 'open-address', + }) + ) + ).not.toThrow(); +}); + +test('rich amount limit is 32 without silent truncation', () => { + const amounts = Array.from({ length: 32 }, (_, index) => ({ + key: String(index), + text: '1 token', + })); + expect(() => validateSnapshot(snapshot({ ...row, amounts }))).not.toThrow(); + expect(() => + validateSnapshot( + snapshot({ + ...row, + amounts: [...amounts, { key: 'overflow', text: '1 token' }], + }) + ) + ).toThrow(/32/); +}); + +test('duplicate amount identity is rejected for snapshot and patch', () => { + const amounts = [ + { key: 'same', text: '1' }, + { key: 'same', text: '2' }, + ]; + expect(() => validateSnapshot(snapshot({ ...row, amounts }))).toThrow( + /duplicate amount/ + ); + expect(() => + validatePatches([{ type: 'activity', key: row.key, changes: { amounts } }]) + ).toThrow(/duplicate amount/); +}); + +test('activity badges have bounded unique identities', () => { + const badges = Array.from({ length: 4 }, (_, index) => ({ + key: String(index), + text: 'Status', + })); + expect(() => validateSnapshot(snapshot({ ...row, badges }))).not.toThrow(); + expect(() => + validateSnapshot( + snapshot({ ...row, badges: [...badges, { key: 'extra', text: 'Extra' }] }) + ) + ).toThrow(/badges/); + expect(() => + validateSnapshot(snapshot({ ...row, badges: [badges[0]!, badges[0]!] })) + ).toThrow(/duplicate badge/); +}); diff --git a/native-views/react-native-native-list/src/__tests__/nativeMediaGrid.test.ts b/native-views/react-native-native-list/src/__tests__/nativeMediaGrid.test.ts new file mode 100644 index 000000000..8772366c8 --- /dev/null +++ b/native-views/react-native-native-list/src/__tests__/nativeMediaGrid.test.ts @@ -0,0 +1,85 @@ +import type { MediaTileRow, NativeListSnapshot } from '../models'; +import { validateSnapshot, validatePatches } from '../validation'; + +const media: MediaTileRow = { + type: 'mediaTile', + key: 'preview', + variant: 'gallery', + title: 'Preview', + media: { + source: { uri: 'https://example.com/asset', width: 160, height: 160 }, + probeOrder: ['image', 'video'], + }, +}; +const snapshot = ( + gridColumns: NativeListSnapshot['layout']['gridColumns'] +): NativeListSnapshot => ({ + schemaVersion: 1, + generation: 1, + layout: { kind: 'grid', gridColumns }, + rows: [media], +}); + +describe('native tablet grid and media preview contract', () => { + test.each([2, 3, 4, 5, 6, 7] as const)( + 'accepts %i grid columns', + (columns) => { + expect(() => validateSnapshot(snapshot(columns))).not.toThrow(); + } + ); + test.each([0, 1, 2.5, 8, NaN, Infinity])( + 'rejects invalid grid count %s', + (columns) => { + expect(() => validateSnapshot(snapshot(columns as 2))).toThrow(); + } + ); + test('allows reversed explicit fallback order and media patches', () => { + expect(() => + validatePatches([ + { + type: 'mediaTile', + key: 'preview', + changes: { + media: { + source: { + uri: 'https://example.com/video.mp4', + width: 160, + height: 160, + }, + probeOrder: ['video', 'image'], + }, + }, + }, + ]) + ).not.toThrow(); + }); + test.each([ + { order: [] }, + { order: ['image', 'image'] }, + { order: ['video', 'image', 'video'] }, + { order: ['unsupported'] }, + ])('rejects invalid probe order $order', ({ order }) => { + const probeOrder = order as NonNullable< + MediaTileRow['media'] + >['probeOrder']; + const value = snapshot(2); + expect(() => + validateSnapshot({ + ...value, + rows: [ + { + ...media, + media: { + source: { + uri: 'https://example.com/asset', + width: 160, + height: 160, + }, + probeOrder, + }, + }, + ], + }) + ).toThrow(/probeOrder/); + }); +}); diff --git a/native-views/react-native-native-list/src/models.ts b/native-views/react-native-native-list/src/models.ts index 9873ef469..5794eecd8 100644 --- a/native-views/react-native-native-list/src/models.ts +++ b/native-views/react-native-native-list/src/models.ts @@ -517,6 +517,16 @@ export type RailRow = RowBase & style?: RailRowStyle; }>; +export type ActivityAmount = Readonly<{ + key: string; + text: string; + textSegments?: readonly ValueTextSegment[]; + leading?: LeadingVisual; + tone?: TextTone; + secondaryText?: string; + secondaryTextSegments?: readonly ValueTextSegment[]; +}>; + export type ActivityRow = RowBase & Readonly<{ type: 'activity'; @@ -527,6 +537,18 @@ export type ActivityRow = RowBase & status?: string; primaryAmount?: string; secondaryAmount?: string; + amounts?: readonly ActivityAmount[]; + badges?: readonly BadgeModel[]; + presentation?: 'stacked' | 'table'; + fee?: Readonly<{ + label?: string; + primary: string; + primaryTextSegments?: readonly ValueTextSegment[]; + secondary?: string; + secondaryTextSegments?: readonly ValueTextSegment[]; + hidden?: boolean; + }>; + descriptionActionKey?: string; footerActions?: readonly FooterAction[]; style?: ActivityRowStyle; }>; @@ -601,6 +623,11 @@ export type MediaTileRow = RowBase & Readonly<{ type: 'mediaTile'; variant: 'gallery' | 'browserPreview'; + /** Native preview; video candidates display a silent first frame, never autoplay. */ + media?: Readonly<{ + source: ImageSource; + probeOrder: readonly ('image' | 'video')[]; + }>; image?: ImageSource; imageState?: 'empty' | 'error'; networkImage?: ImageSource; @@ -650,6 +677,7 @@ export type SectionHeaderRow = RowBase & indexTitle?: string; variant?: 'summary' | 'gallery' | 'history'; title: string; + titleLoading?: boolean; subtitle?: string; value?: string; valueActionKey?: string; @@ -795,7 +823,7 @@ export type NativeListSnapshot = Readonly<{ layout: Readonly<{ kind: NativeListLayout; orientation?: NativeListOrientation; - gridColumns?: 2 | 3 | 4; + gridColumns?: 2 | 3 | 4 | 5 | 6 | 7; stickyHeaders?: boolean; contentPadding?: number; contentPaddingHorizontal?: number; @@ -813,6 +841,7 @@ export type NativeListSnapshot = Readonly<{ reorderable?: boolean; pullToRefresh?: boolean; refreshing?: boolean; + refreshTriggerDistance?: number; loadMore?: boolean; endReachedThreshold?: number; sectionIndex?: SectionIndexConfig; @@ -882,6 +911,11 @@ export type RowPatch = | 'title' | 'description' | 'status' + | 'amounts' + | 'badges' + | 'presentation' + | 'fee' + | 'descriptionActionKey' | 'primaryAmount' | 'secondaryAmount' | 'leading' @@ -957,6 +991,7 @@ export type RowPatch = | CommonPatchFields | 'image' | 'imageState' + | 'media' | 'networkImage' | 'title' | 'subtitle' @@ -993,6 +1028,7 @@ export type RowPatch = SectionHeaderRow, | CommonPatchFields | 'variant' + | 'titleLoading' | 'title' | 'subtitle' | 'value' @@ -1036,6 +1072,7 @@ export type NativeListActionSource = | 'leadingAction' | 'trailingAccessory' | 'footerAction' + | 'description' | 'mediaClose'; export type NativeListWindowRect = Readonly<{ diff --git a/native-views/react-native-native-list/src/validation.ts b/native-views/react-native-native-list/src/validation.ts index f928c3add..0cd79e04b 100644 --- a/native-views/react-native-native-list/src/validation.ts +++ b/native-views/react-native-native-list/src/validation.ts @@ -175,6 +175,11 @@ const TEXT_STYLE_KEYS_BY_ROW_TYPE: Readonly< 'title', 'description', 'status', + 'amounts', + 'badges', + 'presentation', + 'fee', + 'descriptionActionKey', 'primaryAmount', 'secondaryAmount', ], @@ -183,7 +188,7 @@ const TEXT_STYLE_KEYS_BY_ROW_TYPE: Readonly< market: ['title', 'subtitle', 'price', 'change'], mediaTile: ['title', 'subtitle', 'badge'], metricCard: ['title', 'value', 'subtitle', 'trend'], - sectionHeader: ['title', 'subtitle', 'value'], + sectionHeader: ['titleLoading', 'title', 'subtitle', 'value'], action: ['title', 'value'], system: ['title', 'message', 'actionText'], }; @@ -917,7 +922,84 @@ function assertVisual(row: RowModel, path: string): void { }); } -function assertActivity(row: ActivityRow, path: string): void { +function assertActivity(row: Partial, path: string): void { + assertText(row.title, `${path}.title`); + assertText(row.description, `${path}.description`); + if ( + row.presentation !== undefined && + !['stacked', 'table'].includes(row.presentation) + ) + fail(`${path}.presentation`, 'must be stacked or table'); + if (row.descriptionActionKey !== undefined) + assertKey(row.descriptionActionKey, `${path}.descriptionActionKey`); + if ((row.amounts?.length ?? 0) > 32) + fail(`${path}.amounts`, 'supports at most 32 amount lines'); + const amountKeys = new Set(); + row.amounts?.forEach((amount, index) => { + const amountPath = `${path}.amounts[${index}]`; + assertKey(amount.key, `${amountPath}.key`); + if (amountKeys.has(amount.key)) fail(amountPath, 'duplicate amount key'); + amountKeys.add(amount.key); + assertText(amount.text, `${amountPath}.text`); + assertLeadingVisual(amount.leading, `${amountPath}.leading`); + assertText(amount.secondaryText, `${amountPath}.secondaryText`); + if ( + amount.tone !== undefined && + !['primary', 'secondary', 'positive', 'negative'].includes(amount.tone) + ) + fail(`${amountPath}.tone`, 'invalid text tone'); + amount.textSegments?.forEach((segment, segmentIndex) => { + assertText( + segment.text, + `${amountPath}.textSegments[${segmentIndex}].text` + ); + if (segment.style !== undefined && segment.style !== 'subscript') + fail(amountPath, 'invalid text segment style'); + }); + }); + const assertActivitySegments = ( + segments: readonly { text: string; style?: string }[] | undefined, + segmentPath: string + ) => + segments?.forEach((segment, index) => { + assertText(segment.text, `${segmentPath}[${index}].text`); + if (segment.style !== undefined && segment.style !== 'subscript') + fail(segmentPath, 'invalid text segment style'); + }); + row.amounts?.forEach((amount, index) => + assertActivitySegments( + amount.secondaryTextSegments, + `${path}.amounts[${index}].secondaryTextSegments` + ) + ); + assertActivitySegments( + row.fee?.primaryTextSegments, + `${path}.fee.primaryTextSegments` + ); + assertActivitySegments( + row.fee?.secondaryTextSegments, + `${path}.fee.secondaryTextSegments` + ); + assertText(row.fee?.label, `${path}.fee.label`); + assertText(row.fee?.primary, `${path}.fee.primary`); + assertText(row.fee?.secondary, `${path}.fee.secondary`); + if ((row.badges?.length ?? 0) > 4) + fail(`${path}.badges`, 'supports at most 4 activity badges'); + const badgeKeys = new Set(); + row.badges?.forEach((badge, index) => { + assertKey(badge.key, `${path}.badges[${index}].key`); + if (badgeKeys.has(badge.key)) fail(`${path}.badges`, 'duplicate badge key'); + badgeKeys.add(badge.key); + assertText(badge.text, `${path}.badges[${index}].text`); + if ( + badge.tone !== undefined && + !['neutral', 'info', 'success', 'warning', 'danger'].includes(badge.tone) + ) + fail(`${path}.badges`, 'invalid badge tone'); + }); + if (row.fee?.hidden !== undefined && typeof row.fee.hidden !== 'boolean') + fail(`${path}.fee.hidden`, 'must be boolean'); + if ((row.footerActions?.length ?? 0) > MAX_FOOTER_ACTIONS) { fail( `${path}.footerActions`, @@ -1119,6 +1201,7 @@ function assertRow( assertMarketRow(row, path); break; case 'mediaTile': + assertMediaPreview(row.media, `${path}.media`); assertImage(row.image, `${path}.image`); if ( row.imageState !== undefined && @@ -1126,10 +1209,14 @@ function assertRow( ) { fail(`${path}.imageState`, 'must be empty or error when provided'); } - if (row.imageState === undefined && row.image === undefined) { + if ( + row.imageState === undefined && + row.image === undefined && + row.media === undefined + ) { fail( `${path}.image`, - 'is required unless imageState is empty or error' + 'is required unless media is provided or imageState is empty or error' ); } assertImage(row.networkImage, `${path}.networkImage`); @@ -1168,6 +1255,11 @@ function assertRow( } break; case 'sectionHeader': + if ( + row.titleLoading !== undefined && + typeof row.titleLoading !== 'boolean' + ) + fail(`${path}.titleLoading`, 'must be boolean'); assertKey(row.sectionKey, `${path}.sectionKey`); if ( row.presentation !== undefined && @@ -1292,14 +1384,57 @@ function assertGroups(rows: readonly RowModel[]): void { } } +function assertMediaPreview(value: unknown, path: string) { + if (value === undefined) return; + if (!value || typeof value !== 'object' || Array.isArray(value)) + fail(path, 'must be a media descriptor'); + const media = value as { + source?: import('./models').ImageSource; + probeOrder?: unknown; + }; + assertImage(media.source, `${path}.source`); + if (!media.source) fail(`${path}.source`, 'is required'); + const order = media.probeOrder; + if ( + !Array.isArray(order) || + order.length < 1 || + order.length > 2 || + order.some((kind) => kind !== 'image' && kind !== 'video') || + new Set(order).size !== order.length + ) { + fail( + `${path}.probeOrder`, + 'must contain one or two distinct image/video candidates' + ); + } +} + export function validateSnapshot( snapshot: NativeListSnapshot ): NativeListSnapshot { + if ( + snapshot.capabilities?.refreshTriggerDistance !== undefined && + (!Number.isFinite(snapshot.capabilities.refreshTriggerDistance) || + snapshot.capabilities.refreshTriggerDistance <= 0) + ) + fail( + 'capabilities.refreshTriggerDistance', + 'must be a positive finite number' + ); + assertPlainSerializable(snapshot, 'snapshot', new Set()); if (snapshot.schemaVersion !== 1) fail('snapshot.schemaVersion', 'must be 1'); if (!Number.isSafeInteger(snapshot.generation) || snapshot.generation < 0) { fail('snapshot.generation', 'must be a non-negative safe integer'); } + if ( + snapshot.layout.gridColumns !== undefined && + (!Number.isInteger(snapshot.layout.gridColumns) || + snapshot.layout.gridColumns < 2 || + snapshot.layout.gridColumns > 7) + ) { + fail('snapshot.layout.gridColumns', 'must be an integer from 2 to 7'); + } if (snapshot.layout.kind === 'grid' && !snapshot.layout.gridColumns) { fail('snapshot.layout.gridColumns', 'is required for grid layout'); } @@ -1508,6 +1643,7 @@ function assertPatchChanges(patch: RowPatch, index: number): void { assertLeadingVisual(patch.changes.visual, `${path}.visual`); break; case 'activity': + assertActivity(patch.changes, path); assertText(patch.changes.title, `${path}.title`); assertText(patch.changes.description, `${path}.description`); assertText(patch.changes.status, `${path}.status`); @@ -1576,6 +1712,7 @@ function assertPatchChanges(patch: RowPatch, index: number): void { ); break; case 'mediaTile': + assertMediaPreview(patch.changes.media, `${path}.media`); assertImage(patch.changes.image, `${path}.image`); if ( patch.changes.imageState !== undefined && @@ -1623,6 +1760,11 @@ function assertPatchChanges(patch: RowPatch, index: number): void { } break; case 'sectionHeader': + if ( + patch.changes.titleLoading !== undefined && + typeof patch.changes.titleLoading !== 'boolean' + ) + fail(`${path}.titleLoading`, 'must be boolean'); assertSectionHeaderVariant(patch.changes.variant, `${path}.variant`); assertText(patch.changes.title, `${path}.title`); assertText(patch.changes.subtitle, `${path}.subtitle`); diff --git a/native-views/react-native-pager-view/README.md b/native-views/react-native-pager-view/README.md index 329c1ed0b..bc0165929 100644 --- a/native-views/react-native-pager-view/README.md +++ b/native-views/react-native-pager-view/README.md @@ -75,3 +75,5 @@ including unmounted pages that would show blank. Thank you again to Callstack and everyone who contributes to `react-native-pager-view` 💙 + +The native ancestor-scroll contribution protocol is specified in [docs/SPEC.md](docs/SPEC.md). diff --git a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt index 3ace4c94d..1326a9275 100644 --- a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt +++ b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt @@ -508,6 +508,19 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne applyHeaderOffset() } + private fun publishConsumedOffset(recycler: RecyclerView, remove: Boolean = false) { + val key = R.id.onekey_native_scroll_coordinator_consumed_offsets + val existing = recycler.getTag(key) as? NativeScrollCoordinatorContributions + val contributions = existing ?: if (remove) return else NativeScrollCoordinatorContributions().also { + recycler.setTag(key, it) + } + val changed = if (remove) contributions.clearOwner(this) else contributions.update(this, headerOffsetPx) + if (changed) { + (recycler.getTag(R.id.onekey_native_scroll_coordinator_offset_listener) as? Runnable)?.run() + } + if (contributions.isEmpty()) recycler.setTag(key, null) + } + private fun applyHeaderOffset() { val offset = headerOffsetPx.toFloat() pager.translationY = -offset @@ -515,6 +528,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne stickyHeaderView?.translationY = -offset nativeTabBarView.translationY = -offset nativeSubHeaderView.translationY = -offset + observedRecyclerView?.let { publishConsumedOffset(it) } } private fun pageKey(index: Int): String = pageKeys.getOrNull(index) ?: "page-$index" @@ -616,12 +630,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne private fun attachRecyclerObserver(recycler: RecyclerView) { if (observedRecyclerView === recycler) { applyRecyclerInsets(recycler, selectedPage) + publishConsumedOffset(recycler) return } detachPrimaryScrollObserver() observedRecyclerView = recycler applyRecyclerInsets(recycler, selectedPage) + publishConsumedOffset(recycler) val listener = object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { val contentOffset = recyclerView.computeVerticalScrollOffset() @@ -646,6 +662,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne val listener = observedScrollListener if (recycler != null) { pageOffsets[currentPageKey()] = recycler.computeVerticalScrollOffset() + publishConsumedOffset(recycler, remove = true) } if (recycler != null && listener != null) recycler.removeOnScrollListener(listener) if (recycler != null) { @@ -835,6 +852,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne } private fun restoreRecyclerInsets(recycler: RecyclerView) { + publishConsumedOffset(recycler, remove = true) val original = originalRecyclerPadding.remove(recycler) ?: return updateRecyclerPaddingOwnership(recycler, original) restoredRecyclerKeys.remove(recycler) diff --git a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollCoordinatorContributions.kt b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollCoordinatorContributions.kt new file mode 100644 index 000000000..4d215394d --- /dev/null +++ b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollCoordinatorContributions.kt @@ -0,0 +1,15 @@ +package com.reactnativepagerview + +import java.util.WeakHashMap + +/** Native tag payload; leaf observers can read it as Map without a pager dependency. */ +internal class NativeScrollCoordinatorContributions : WeakHashMap() { + fun update(owner: Any, pixels: Int): Boolean { + val value = pixels.coerceAtLeast(0) + if (get(owner) == value) return false + put(owner, value) + return true + } + + fun clearOwner(owner: Any): Boolean = remove(owner) != null +} diff --git a/native-views/react-native-pager-view/android/src/main/res/values/native_scroll_coordinator_ids.xml b/native-views/react-native-pager-view/android/src/main/res/values/native_scroll_coordinator_ids.xml new file mode 100644 index 000000000..2f657f1f3 --- /dev/null +++ b/native-views/react-native-pager-view/android/src/main/res/values/native_scroll_coordinator_ids.xml @@ -0,0 +1,4 @@ + + + + diff --git a/native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollCoordinatorContributionsTest.kt b/native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollCoordinatorContributionsTest.kt new file mode 100644 index 000000000..e8ca861e7 --- /dev/null +++ b/native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollCoordinatorContributionsTest.kt @@ -0,0 +1,26 @@ +package com.reactnativepagerview + +/** Standalone JVM regression: compile together with the production helper. */ +fun main() { + val outer = Any() + val inner = Any() + val firstChild = NativeScrollCoordinatorContributions() + check(firstChild.update(outer, 120)) + check(firstChild.update(inner, 60)) + check(firstChild.values.sum() == 180) + check(!firstChild.update(outer, 120)) + check(firstChild.update(outer, 30)) + check(firstChild.values.sum() == 90) + check(firstChild.clearOwner(outer)) + check(firstChild.values.sum() == 60) + check(!firstChild.clearOwner(outer)) + val secondChild = NativeScrollCoordinatorContributions() + check(secondChild.update(outer, 30)) + check(secondChild.values.sum() == 30) + check(firstChild.values.sum() == 60) + check(firstChild.clearOwner(inner)) + check(firstChild.isEmpty()) + check(secondChild.update(outer, -1)) + check(secondChild.values.sum() == 0) + println("Native coordinator contributions: nested owners, duplicate updates, cleanup, and replacement passed") +} diff --git a/native-views/react-native-pager-view/docs/SPEC.md b/native-views/react-native-pager-view/docs/SPEC.md new file mode 100644 index 000000000..8230222f5 --- /dev/null +++ b/native-views/react-native-pager-view/docs/SPEC.md @@ -0,0 +1,43 @@ +# PagerView native scrolling contract + +Existing PagerView and CollapsiblePagerView APIs are documented in README.md. +This section defines the added Android coordination boundary; it does not change +React props, iOS/Web behavior, page retention, or row ownership. + +## Ancestor consumed-scroll contributions + +Status: source implementation; real nested-pager runtime verification pending. +Android collapsible pagers consume header travel before the primary RecyclerView +receives content-scroll deltas. A leaf threshold observer needs both distances. +The coordinator publishes a non-negative physical-pixel contribution under its +own weak owner identity on the active primary RecyclerView. Nested coordinators +contribute independently; summing their values gives ancestor-consumed travel. + +The native View tag named `onekey_native_scroll_coordinator_consumed_offsets` +stores a WeakHashMap with owner-view keys and integer pixel values. The optional +`onekey_native_scroll_coordinator_offset_listener` tag holds a Runnable owned by +the leaf. The publisher updates only its own entry and invokes the listener only +when that entry changes. An absent map/listener means zero/no observer. +This protocol depends only on Android View/resources and Java collections; the +pager MUST NOT depend on NativeList or infer Home/business semantics. + +On switching the primary child, restoring insets, or detaching, the coordinator +removes only its own contribution. It MUST NOT retain an old RecyclerView or +another coordinator. Zero contributions may remain while the owner is attached; +weak keys prevent abandoned owners from becoming resource roots. The leaf owns +listener registration and cleanup. All operations occur on the UI thread; there +are no JavaScript scroll-frame callbacks in this protocol. + +A new child receives current header travel before threshold evaluation. Native +list offsets remain local to the leaf, while consumers of this protocol add all +ancestor contributions exactly once. Programmatic scrolling/header restoration +must publish the resulting header offset through the same path. + +## Acceptance + +The focused JVM helper test covers independent ancestor contributions and removal. +Required Android runtime cases are one/nested collapsible pagers, header-only +scrolling, reversal to expanded top, switching pages, native child replacement, +and detach/remount without stale offsets. iOS uses its existing normalized +UIScrollView offset and requires no equivalent tag protocol. Source/helper +checks do not establish the Android runtime acceptance above. diff --git a/native-views/react-native-pager-view/package.json b/native-views/react-native-pager-view/package.json index e797c7a7a..353f05b48 100644 --- a/native-views/react-native-pager-view/package.json +++ b/native-views/react-native-pager-view/package.json @@ -21,6 +21,7 @@ "lib", "android", "ios", + "docs", "react-native-pager-view.podspec", "!ios/build", "!android/build", From 890ae49c8cd62a6a4f5190558168d65431eab0bb Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sun, 27 Sep 2026 02:49:55 +0800 Subject: [PATCH 02/16] fix: preserve native pager content and media lifecycle --- .../nativelist/NativeListMediaPreviewSlot.kt | 22 +++++-- .../react-native-native-list/docs/SPEC.md | 15 ++++- .../src/NativeList.tsx | 7 ++- .../src/__tests__/NativeList.ref.test.tsx | 31 ++++++++++ .../CollapsiblePagerHost.kt | 58 ++++++++++++++++--- .../NativeScrollerScrollAwayInsets.kt | 17 ++++++ .../NativeScrollerScrollAwayInsetsTest.kt | 37 ++++++++++++ .../react-native-pager-view/docs/SPEC.md | 34 +++++++++++ 8 files changed, 206 insertions(+), 15 deletions(-) create mode 100644 native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt create mode 100644 native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaPreviewSlot.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaPreviewSlot.kt index a1adcb09c..db43627da 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaPreviewSlot.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaPreviewSlot.kt @@ -43,6 +43,7 @@ internal class NativeListMediaPreviewSlot(private val context: ThemedReactContex private var playerGeneration = 0 private var result: Boolean? = null private var completion: ((Boolean) -> Unit)? = null + private var pendingCompletion: Runnable? = null private var player: ExoPlayer? = null private var resumeVideo: (() -> Unit)? = null private var viewportObserver: ViewTreeObserver? = null @@ -95,17 +96,26 @@ internal class NativeListMediaPreviewSlot(private val context: ThemedReactContex completion?.invoke(false) return } - val finished: (Boolean) -> Unit = { loaded -> - if (epoch == token && candidateGeneration == candidate) { + fun finished(loaded: Boolean, expectedPlayerGeneration: Int? = null) { + if (epoch != token || candidateGeneration != candidate) return + pendingCompletion?.let(view::removeCallbacks) + // Glide forbids clearing/rebinding a request from either terminal callback. + // Media3 callbacks also defer release so the emitting player is not reentered. + val task = Runnable { + pendingCompletion = null + if (epoch != token || candidateGeneration != candidate || + (expectedPlayerGeneration != null && playerGeneration != expectedPlayerGeneration)) return@Runnable if (loaded) { if (result != true) { result = true; completion?.invoke(true) } } else probe(source, key, fit, placeholder, order, attempt + 1, token) } + pendingCompletion = task + view.post(task) } if (order[attempt] == "image") { video.visibility = View.GONE image.view.visibility = View.VISIBLE - image.bind(source, key, fit = fit, placeholder = placeholder, completion = finished) + image.bind(source, key, fit = fit, placeholder = placeholder, completion = { loaded -> finished(loaded) }) return } image.recycle() @@ -137,8 +147,8 @@ internal class NativeListMediaPreviewSlot(private val context: ThemedReactContex next.setAudioAttributes(androidx.media3.common.AudioAttributes.DEFAULT, false) next.setVideoTextureView(texture) next.addListener(object : Player.Listener { - override fun onRenderedFirstFrame() { if (playerGeneration == generation) finished(true) } - override fun onPlayerError(error: PlaybackException) { if (playerGeneration == generation) finished(false) } + override fun onRenderedFirstFrame() { if (playerGeneration == generation) finished(true, generation) } + override fun onPlayerError(error: PlaybackException) { if (playerGeneration == generation) finished(false, generation) } override fun onVideoSizeChanged(size: VideoSize) { if (playerGeneration == generation && size.height > 0) video.setAspectRatio(size.width * size.pixelWidthHeightRatio / size.height) } @@ -190,6 +200,8 @@ internal class NativeListMediaPreviewSlot(private val context: ThemedReactContex } fun recycle() { epoch++; candidateGeneration++ + pendingCompletion?.let(view::removeCallbacks) + pendingCompletion = null resumeVideo = null stopObservingViewport() releasePlayer() diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index 26fe49dd6..e813329c5 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -423,7 +423,14 @@ existing native Video views. No code calls play or requests audio focus. A failed candidate advances once; exhaustion displays the existing error placeholder. New source/row identity resets probing; binding, candidate and player generations reject stale callbacks, including A→B→A. Text-only updates -preserve the current request. A tile owns at most one native player. On iOS, +preserve the current request. On Android, terminal image/player completion is +posted to the next UI queue turn before advancing probes or notifying the row; +it MUST NOT clear or rebind a Glide request inside its terminal listener. +Posted work rechecks binding/candidate generations, and player callbacks also +recheck the player generation. Recycling removes queued completion work before +releasing resources. This repairs the image-to-video fallback crash without +changing probe order or the terminal result contract; native runtime regression +acceptance is still required. A tile owns at most one native player. On iOS, eligibility requires intersection with the window and every clipping/scroll ancestor viewport; native scroll, bounds, layer transform/position and visibility observations release retained offscreen pager pages. Detach, background, @@ -500,6 +507,12 @@ at most one refresh action per drag. Android passes the distance to SwipeRefreshLayout's native trigger setting. These native controls differ in rubber-band/drag scaling, so equal values do not promise equal finger travel. The caller continues to own refreshing state, completion, and optional haptic feedback. +On native, the row-less `nativeList.refresh` bridge envelope is container-owned: +the JS wrapper consumes it and invokes only `onRefresh`, when supplied. It MUST +NOT also invoke the public `onRowAction`, even when `onRefresh` is absent. +An actual row action carrying a `rowKey` remains a row action, including one +whose caller-defined action key matches this internal name. Web retains its +legacy dual notification behavior; this native correction does not change Web. The container does not emit a second haptic for the release fallback. The fallback is inactive unless pull-to-refresh is enabled; no product-specific threshold is hardcoded into the module. diff --git a/native-views/react-native-native-list/src/NativeList.tsx b/native-views/react-native-native-list/src/NativeList.tsx index 965452244..1316ca266 100644 --- a/native-views/react-native-native-list/src/NativeList.tsx +++ b/native-views/react-native-native-list/src/NativeList.tsx @@ -434,8 +434,13 @@ export const NativeList = forwardRef( }), onRowAction: callback((payloadJson: string) => { const payload = parsePayload(payloadJson); - if (payload.actionKey === 'nativeList.refresh') + if ( + payload.actionKey === 'nativeList.refresh' && + payload.rowKey === undefined + ) { callbacksRef.current.onRefresh?.(); + return; + } callbacksRef.current.onRowAction?.(payload); }), onActionAnchorInvalidated: callback((payloadJson: string) => { diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.ref.test.tsx b/native-views/react-native-native-list/src/__tests__/NativeList.ref.test.tsx index dd93e30b3..b4c152629 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.ref.test.tsx +++ b/native-views/react-native-native-list/src/__tests__/NativeList.ref.test.tsx @@ -16,6 +16,7 @@ const mockNativeMethods = { }; let mockOnSelectionDelta: (payloadJson: string) => void; let mockOnReorder: (payloadJson: string) => void; +let mockOnRowAction: (payloadJson: string) => void; const mockWebEngine = { applySnapshot: jest.fn(), setVirtualizationEnabled: jest.fn(), @@ -59,9 +60,11 @@ jest.mock('react-native-nitro-modules', () => ({ hybridRef: (ref: typeof mockNativeMethods) => void; onSelectionDelta: (payloadJson: string) => void; onReorder: (payloadJson: string) => void; + onRowAction: (payloadJson: string) => void; }) { mockOnSelectionDelta = props.onSelectionDelta; mockOnReorder = props.onReorder; + mockOnRowAction = props.onRowAction; const ReactForMock = require('react') as typeof React; ReactForMock.useEffect(() => props.hybridRef(mockNativeMethods), [props]); return null; @@ -114,6 +117,34 @@ const snapshot: NativeListSnapshot = { describe('NativeList imperative ref', () => { beforeEach(() => jest.clearAllMocks()); + it.each([true, false])( + 'consumes native container refresh separately from row actions (refresh callback: %s)', + async (hasRefreshCallback) => { + const { NativeList } = jest.requireActual( + '../NativeList' + ) as typeof import('../NativeList'); + const onRefresh = jest.fn(); + const onRowAction = jest.fn(); + await act(async () => { + TestRenderer.create( + + ); + }); + act(() => mockOnRowAction('{"actionKey":"nativeList.refresh"}')); + expect(onRefresh).toHaveBeenCalledTimes(hasRefreshCallback ? 1 : 0); + expect(onRowAction).not.toHaveBeenCalled(); + + const rowAction = { rowKey: 'a-0', actionKey: 'nativeList.refresh' }; + act(() => mockOnRowAction(JSON.stringify(rowAction))); + expect(onRowAction).toHaveBeenCalledWith(rowAction); + expect(onRefresh).toHaveBeenCalledTimes(hasRefreshCallback ? 1 : 0); + } + ); + it('dispatches initial position and every RN-compatible scroll API', async () => { const { NativeList } = jest.requireActual( '../NativeList' diff --git a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt index 1326a9275..ef99a13ad 100644 --- a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt +++ b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt @@ -20,6 +20,7 @@ import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 import com.facebook.react.R as ReactR import com.facebook.react.uimanager.events.NativeGestureUtil +import com.facebook.react.views.scroll.ReactScrollView import com.margelo.nitro.nativelogger.OneKeyLog import java.util.WeakHashMap import kotlin.math.max @@ -792,11 +793,29 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne } updateScrollerPaddingOwnership(scroller, original) val topInset = headerHeightPx + stickyHeaderHeightPx - val top = original.top + topInset scroller.clipToPadding = false - if (scroller.paddingLeft != original.left || scroller.paddingTop != top || - scroller.paddingRight != original.right || scroller.paddingBottom != original.bottom) { - scroller.setPadding(original.left, top, original.right, original.bottom) + if (scroller is ReactScrollView) { + // Fabric owns the content child's layout, so ScrollView padding alone + // cannot move it. RN's native ScrollAway API also updates Fabric culling. + val state = scroller.reactScrollViewScrollState + val scrollAwayTop = nativeScrollerScrollAwayTop( + state.scrollAwayPaddingTop, original.appliedTopInset, topInset, + ) + if (state.scrollAwayPaddingTop != scrollAwayTop || + scroller.getChildAt(0)?.translationY != scrollAwayTop.toFloat()) { + scroller.setScrollAwayPaddingEnabledUnstable(scrollAwayTop, state.scrollAwayPaddingBottom) + } + val bottom = original.bottom + topInset + if (scroller.paddingLeft != original.left || scroller.paddingTop != original.top || + scroller.paddingRight != original.right || scroller.paddingBottom != bottom) { + scroller.setPadding(original.left, original.top, original.right, bottom) + } + } else { + val top = original.top + topInset + if (scroller.paddingLeft != original.left || scroller.paddingTop != top || + scroller.paddingRight != original.right || scroller.paddingBottom != original.bottom) { + scroller.setPadding(original.left, top, original.right, original.bottom) + } } original.appliedTopInset = topInset @@ -821,13 +840,29 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne scroller: ScrollView, original: ScrollerPadding, ) { - val expectedTop = original.top + original.appliedTopInset + val isReactScroller = scroller is ReactScrollView + if (scroller is ReactScrollView) { + val state = scroller.reactScrollViewScrollState + if (nativeScrollerPaddingWasResetByScrollAway( + original.appliedTopInset, + scroller.paddingLeft, scroller.paddingTop, scroller.paddingRight, scroller.paddingBottom, + state.scrollAwayPaddingTop, state.scrollAwayPaddingBottom, + )) { + // RN may replay ScrollAway state before this pass. Its setter clears + // caller padding; that native reset must not become our new baseline. + return + } + } + val ownedTop = if (isReactScroller) 0 else original.appliedTopInset + val ownedBottom = if (isReactScroller) original.appliedTopInset else 0 if (scroller.paddingLeft != original.left) original.left = scroller.paddingLeft - if (scroller.paddingTop != expectedTop) { - original.top = (scroller.paddingTop - original.appliedTopInset).coerceAtLeast(0) + if (scroller.paddingTop != original.top + ownedTop) { + original.top = (scroller.paddingTop - ownedTop).coerceAtLeast(0) } if (scroller.paddingRight != original.right) original.right = scroller.paddingRight - if (scroller.paddingBottom != original.bottom) original.bottom = scroller.paddingBottom + if (scroller.paddingBottom != original.bottom + ownedBottom) { + original.bottom = (scroller.paddingBottom - ownedBottom).coerceAtLeast(0) + } if (scroller.clipToPadding) original.clipToPadding = true } @@ -864,6 +899,13 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne val original = originalScrollerPadding.remove(scroller) ?: return updateScrollerPaddingOwnership(scroller, original) restoredScrollerKeys.remove(scroller) + if (scroller is ReactScrollView) { + val state = scroller.reactScrollViewScrollState + scroller.setScrollAwayPaddingEnabledUnstable( + nativeScrollerScrollAwayTop(state.scrollAwayPaddingTop, original.appliedTopInset, 0), + state.scrollAwayPaddingBottom, + ) + } scroller.clipToPadding = original.clipToPadding scroller.setPadding(original.left, original.top, original.right, original.bottom) } diff --git a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt new file mode 100644 index 000000000..4eea3c325 --- /dev/null +++ b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt @@ -0,0 +1,17 @@ +package com.reactnativepagerview + +// Replace only this pager's contribution; another native owner may share the scroller. +internal fun nativeScrollerScrollAwayTop(currentTop: Int, previousInset: Int, nextInset: Int): Int = + (currentTop - previousInset).coerceAtLeast(0) + nextInset + +// ReactScrollView's ScrollAway setter always writes this exact padding shape. +internal fun nativeScrollerPaddingWasResetByScrollAway( + appliedInset: Int, + left: Int, + top: Int, + right: Int, + bottom: Int, + scrollAwayTop: Int, + scrollAwayBottom: Int, +): Boolean = appliedInset > 0 && left == 0 && top == 0 && right == 0 && + bottom == scrollAwayTop + scrollAwayBottom diff --git a/native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt b/native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt new file mode 100644 index 000000000..f161a57ab --- /dev/null +++ b/native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt @@ -0,0 +1,37 @@ +package com.reactnativepagerview + +/** Standalone JVM contract regression for Fabric scroller inset ownership. */ +fun main() { + val initialTop = 24 + val firstHeader = 182 + 48 + val initialApplied = nativeScrollerScrollAwayTop(initialTop, 0, firstHeader) + check(initialApplied == 254) + check(nativeScrollerScrollAwayTop(initialApplied, firstHeader, firstHeader) == initialApplied) + val measuredHeader = 310 + 48 + val resized = nativeScrollerScrollAwayTop(initialApplied, firstHeader, measuredHeader) + check(resized == initialTop + measuredHeader) + // A second pager's contribution survives the first pager's detach. + val nested = nativeScrollerScrollAwayTop(resized, 0, 100) + val outerDetached = nativeScrollerScrollAwayTop(nested, measuredHeader, 0) + check(outerDetached == initialTop + 100) + check(nativeScrollerScrollAwayTop(outerDetached, 100, 0) == initialTop) + // Fabric may reset state while replacing its content root; do not apply a negative baseline. + check(nativeScrollerScrollAwayTop(0, measuredHeader, firstHeader) == firstHeader) + // Caller L/R/B = 16/16/40 must survive RN's setter replay after our 230px inset. + val caller = intArrayOf(16, 0, 16, 40) + val rnReplay = intArrayOf(0, 0, 0, 230) + val keepCaller = nativeScrollerPaddingWasResetByScrollAway( + 230, rnReplay[0], rnReplay[1], rnReplay[2], rnReplay[3], 230, 0, + ) + check(keepCaller) + val restored = if (keepCaller) caller else rnReplay + check(restored.contentEquals(intArrayOf(16, 0, 16, 40))) + // Nonzero native bottom state is part of RN's reset signature too. + check(nativeScrollerPaddingWasResetByScrollAway(230, 0, 0, 0, 278, 254, 24)) + // Actual caller updates, including clearing all padding, remain observable. + check(!nativeScrollerPaddingWasResetByScrollAway(230, 24, 0, 24, 290, 230, 0)) + check(!nativeScrollerPaddingWasResetByScrollAway(230, 0, 0, 0, 0, 230, 0)) + // Initial discovery must capture caller values before this pager owns an inset. + check(!nativeScrollerPaddingWasResetByScrollAway(0, 0, 0, 0, 230, 230, 0)) + println("NativeScroller ScrollAway ownership: initial, repeat, measured resize, nested detach, Fabric reset, RN padding replay, and caller updates passed") +} diff --git a/native-views/react-native-pager-view/docs/SPEC.md b/native-views/react-native-pager-view/docs/SPEC.md index 8230222f5..a93e73e22 100644 --- a/native-views/react-native-pager-view/docs/SPEC.md +++ b/native-views/react-native-pager-view/docs/SPEC.md @@ -41,3 +41,37 @@ scrolling, reversal to expanded top, switching pages, native child replacement, and detach/remount without stale offsets. iOS uses its existing normalized UIScrollView offset and requires no equivalent tag protocol. Source/helper checks do not establish the Android runtime acceptance above. + + +## Android Fabric NativeScroller content insets + +A ReactScrollView's content root is laid out by Fabric. Native `setPadding(top)` +alone MUST NOT be used to reserve a collapsible header: ReactScrollView does not +perform the platform ScrollView child layout, so that leaves rows under the +header. For the supported RN 0.86 runtime, the coordinator uses +`setScrollAwayPaddingEnabledUnstable` to offset the content root and synchronize +Fabric content-origin/culling state. The equivalent inset is added to the native +scroll range at the bottom; it is not added a second time at the top. Ordinary +Android ScrollView and RecyclerView retain their existing inset paths. + +Each coordinator replaces only its previous header-plus-sticky contribution. +Measured-height changes do not accumulate insets; detach restores the remaining +ScrollAway state and caller padding, including another native owner's inset. +Content-root replacement reapplies the native offset. No Home layout values, +new React props, or per-frame JS updates are introduced; iOS/Web are unchanged. + +The focused JVM policy regression covers an existing inset, repeated application, +measured-header resize, nested-owner detach, and Fabric state reset. The API call +is compile-checked against the installed React Android 0.86.2 artifact. Runtime +acceptance requires first content below the expanded sticky tabs, collapse and +expand without overlap, retained-page switching, header height changes, and +reaching the actual final content without an extra header-sized blank footer. + +RN's ScrollAway setter (including native state replay when Fabric culling flags +are enabled) resets physical padding to `(0, 0, 0, scrollAwayTop + scrollAwayBottom)`. +While owning an inset, the coordinator recognizes that exact native reset and +retains saved caller padding; other physical-padding updates remain observable. +The focused policy regression includes nonzero caller left/right/bottom padding, +nonzero prior native insets, explicit caller padding changes/clear, and initial +discovery before ownership. Current Home zero-padding behavior is unchanged by +this ownership guard. From 37c9be6c1e3e603b23943479dcf7f0dbf7abe49e Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sun, 27 Sep 2026 11:09:49 +0800 Subject: [PATCH 03/16] fix: clamp ios native list offsets after content shrink --- .../react-native-native-list/docs/SPEC.md | 9 ++ .../ios/RNCNativeListView.swift | 50 ++++++++++- .../ios/tests/NativeListLifecycleTests.swift | 87 +++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index e813329c5..92cfb91a6 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -466,6 +466,15 @@ rendering. Horizontal orientation with any supplied slot is rejected. Web ignore these new native-only props; existing Web lists and callers retain their behavior. The order is header, data rows (or empty slot when there are no data rows), footer. +On iOS, after the laid-out vertical content shrinks, an offset beyond the new +bottom is corrected to the current adjusted-inset-aware maximum. In-range offsets +and negative pull overscroll remain unchanged. Correction waits until active +dragging, deceleration and refresh finish; it is not an unconditional layout or +snapshot reset to the top. The native content-offset change remains observable to +ancestor pager coordinators so their saved logical offsets stay synchronized. +Recycling/disposal cancels deferred correction. Runtime acceptance includes a +scrolled retained-page round trip followed by empty content with scrolling slots. + Slots scroll with the native list. `fixedFooter` remains a separate fixed native row descriptor. `scrollToEnd` aligns the final scrolling slot bottom with the viewport end, including footer/empty/header-only slots taller than the viewport. Callers using `listEmpty` MUST omit `snapshot.emptyState`, whose diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index 6aebfbbc6..6313ff0ac 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -8,6 +8,9 @@ final class NativeListView: UIView { private var minimumDragOffset: CGFloat = 0 private var reactContainerSlots: [UIView] = [] private var containerSlotHeights: [CGFloat] = [0, 0, 0] + private var lastLaidOutContentHeight: CGFloat? + private var needsContentShrinkCorrection = false + private var contentShrinkCorrectionScheduled = false @objc func mountContainerSlot(_ view: UIView, atIndex index: Int) { reactContainerSlots.insert(view, at: min(index, reactContainerSlots.count)) @@ -127,7 +130,10 @@ final class NativeListView: UIView { private let flowLayout = NativeListFlowLayout() private lazy var collectionView: NativeListCollectionView = { let view = NativeListCollectionView(frame: .zero, collectionViewLayout: flowLayout) - view.onContentLayout = { [weak self] in self?.layoutContainerSlots() } + view.onContentLayout = { [weak self] in + self?.layoutContainerSlots() + self?.contentLayoutDidChange() + } return view }() private let footerContainer = UIView() @@ -743,6 +749,39 @@ final class NativeListView: UIView { collectionView.setContentOffset(point, animated: animated) } + private func contentLayoutDidChange() { + let height = collectionView.contentSize.height + if config?.orientation != "horizontal", + let previous = lastLaidOutContentHeight, height < previous { + needsContentShrinkCorrection = true + } + lastLaidOutContentHeight = height + scheduleContentShrinkCorrection() + } + + private func scheduleContentShrinkCorrection() { + guard needsContentShrinkCorrection, !contentShrinkCorrectionScheduled, !disposed else { return } + contentShrinkCorrectionScheduled = true + // Wait for the collection layout and ancestor pager inset observers to settle. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.contentShrinkCorrectionScheduled = false + guard !self.disposed, self.needsContentShrinkCorrection, + !self.collectionView.isTracking, !self.collectionView.isDragging, + !self.collectionView.isDecelerating, + self.collectionView.refreshControl?.isRefreshing != true, + self.config?.refreshing != true else { return } + self.needsContentShrinkCorrection = false + guard self.config?.orientation != "horizontal", self.collectionView.bounds.height > 0 else { return } + let insets = self.collectionView.adjustedContentInset + let maximum = max(-insets.top, + self.collectionView.contentSize.height - self.collectionView.bounds.height + insets.bottom) + if self.collectionView.contentOffset.y > maximum { + self.setScrollOffset(self.collectionView.contentOffset.y, animated: false) + } + } + } + func setRefreshing(_ refreshing: Bool) { guard var current = config else { return } current.refreshing = refreshing @@ -751,6 +790,7 @@ final class NativeListView: UIView { collectionView.refreshControl?.beginRefreshing() } else { collectionView.refreshControl?.endRefreshing() + scheduleContentShrinkCorrection() } } @@ -1420,6 +1460,7 @@ final class NativeListView: UIView { } else { collectionView.refreshControl = nil } + if !config.refreshing { scheduleContentShrinkCorrection() } } private func configureFooter(_ config: NativeListConfig) { @@ -1923,6 +1964,8 @@ final class NativeListView: UIView { itemsByKey.removeAll() sectionIndexEntries.removeAll() pendingScrollRequest = nil + needsContentShrinkCorrection = false + lastLaidOutContentHeight = nil lastVisibleRange = nil onRowAction = nil onActionAnchorInvalidated = nil @@ -2226,6 +2269,7 @@ extension NativeListView: UICollectionViewDelegateFlowLayout { } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + defer { if !decelerate { scheduleContentShrinkCorrection() } } minimumDragOffset = min(minimumDragOffset, scrollView.contentOffset.y + scrollView.contentInset.top) guard let threshold = config?.refreshTriggerDistance, config?.pullToRefresh == true, config?.refreshing != true, @@ -2234,6 +2278,10 @@ extension NativeListView: UICollectionViewDelegateFlowLayout { refreshTriggered() } + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + scheduleContentShrinkCorrection() + } + func scrollViewDidScroll(_ scrollView: UIScrollView) { if scrollView.isDragging { minimumDragOffset = min(minimumDragOffset, scrollView.contentOffset.y + scrollView.contentInset.top) diff --git a/native-views/react-native-native-list/ios/tests/NativeListLifecycleTests.swift b/native-views/react-native-native-list/ios/tests/NativeListLifecycleTests.swift index 2e86258fe..71e7ae8c1 100644 --- a/native-views/react-native-native-list/ios/tests/NativeListLifecycleTests.swift +++ b/native-views/react-native-native-list/ios/tests/NativeListLifecycleTests.swift @@ -118,6 +118,93 @@ final class NativeListLifecycleTests: XCTestCase { return String(decoding: data, as: UTF8.self) } + func testContentShrinkCorrectsRestoredOffsetAfterPagerInsetsAndUpdatesObserver() throws { + let view = try makeScrollingList() + let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first) + list.contentInset = UIEdgeInsets(top: 108, left: 0, bottom: 0, right: 0) + list.contentOffset.y = 600 + var savedLogicalOffset = list.contentOffset.y + list.contentInset.top + let offsetObserver = list.observe(\.contentOffset, options: [.new]) { list, _ in + savedLogicalOffset = list.contentOffset.y + list.contentInset.top + } + // Model the pager's synchronous content-size observer and cached-offset restoration. + let sizeObserver = list.observe(\.contentSize, options: [.old, .new]) { list, change in + if change.oldValue != change.newValue, list.contentSize.height < 200 { + list.contentInset.bottom = max(0, list.bounds.height - 44 - list.contentSize.height) + list.contentOffset.y = 600 + } + } + view.applySnapshotJson(try scrollingSnapshot(count: 0)) + view.layoutIfNeeded() + list.layoutIfNeeded() + drainMainQueue() + XCTAssertEqual(list.contentSize.height, 132, accuracy: 0.5) + XCTAssertEqual(list.contentOffset.y, -44, accuracy: 0.5) + XCTAssertEqual(savedLogicalOffset, 64, accuracy: 0.5) + withExtendedLifetime((offsetObserver, sizeObserver)) {} + } + + func testContentShrinkPreservesInRangeAndNegativePullOffsets() throws { + for offset in [CGFloat(120), CGFloat(-160)] { + let view = try makeScrollingList() + let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first) + list.contentInset.top = 108 + view.applySnapshotJson(try scrollingSnapshot(count: 20)) + view.layoutIfNeeded() + list.layoutIfNeeded() + // Preserve a valid offset or a refresh pull while shrink correction is queued. + list.contentOffset.y = offset + drainMainQueue() + XCTAssertEqual(list.contentOffset.y, offset, accuracy: 0.5) + } + } + + func testContentShrinkWaitsForRefreshCompletion() throws { + let view = try makeScrollingList() + let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first) + view.applySnapshotJson(try scrollingSnapshot(count: 0, refreshing: true)) + view.layoutIfNeeded() + list.layoutIfNeeded() + list.contentOffset.y = 600 + drainMainQueue() + XCTAssertEqual(list.contentOffset.y, 600, accuracy: 0.5) + view.setRefreshing(false) + drainMainQueue() + let inset = list.adjustedContentInset + let maximum = max(-inset.top, list.contentSize.height - list.bounds.height + inset.bottom) + XCTAssertEqual(list.contentOffset.y, maximum, accuracy: 0.5) + } + + private func makeScrollingList() throws -> NativeListView { + let view = NativeListView(frame: CGRect(x: 0, y: 0, width: 320, height: 708)) + for index in 0..<3 { view.mountContainerSlot(UIView(), atIndex: index) } + view.setContainerSlotHeightsJson("[44,44,44]") + view.applySnapshotJson(try scrollingSnapshot(count: 40)) + view.layoutIfNeeded() + let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first) + list.layoutIfNeeded() + drainMainQueue() + return view + } + + private func scrollingSnapshot(count: Int, refreshing: Bool = false) throws -> String { + let rows: [[String: Any]] = (0.. NativeListRowHost? { view.subviews.lazy.compactMap { container in container.subviews.compactMap { $0 as? NativeListRowHost }.first From 6deffeb6b2f536f9776898112f29209c042f9ab0 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sun, 27 Sep 2026 11:22:59 +0800 Subject: [PATCH 04/16] fix: bound restored ios pager offsets to current content --- .../react-native-pager-view/docs/SPEC.md | 20 ++++++++++++++-- .../RNCCollapsiblePagerReleasedStatePolicy.h | 8 +++++++ .../RNCCollapsiblePagerReleasedStatePolicy.m | 13 +++++++++++ .../RNCCollapsiblePagerViewComponentView.mm | 9 ++++++++ ...ollapsiblePagerReleasedStatePolicyTests.mm | 23 +++++++++++++++++++ 5 files changed, 71 insertions(+), 2 deletions(-) diff --git a/native-views/react-native-pager-view/docs/SPEC.md b/native-views/react-native-pager-view/docs/SPEC.md index a93e73e22..b32f479db 100644 --- a/native-views/react-native-pager-view/docs/SPEC.md +++ b/native-views/react-native-pager-view/docs/SPEC.md @@ -1,8 +1,24 @@ # PagerView native scrolling contract Existing PagerView and CollapsiblePagerView APIs are documented in README.md. -This section defines the added Android coordination boundary; it does not change -React props, iOS/Web behavior, page retention, or row ownership. +The sections below define native coordination boundaries without new React props. + +## iOS cached-offset restoration after content shrink + +When restoring a retained page's cached vertical offset, the pager limits only +the upper bound to the current content height, viewport and applied insets. A +cache captured before rows were removed MUST NOT restore an offset below the new +content bottom, including when removal occurs during a page transition. Valid +deep offsets and the existing lower-bound/header alignment rules are unchanged. +This check runs only on explicit restoration, not on scroll frames; UIKit pull +overscroll and refresh settlement retain their existing ownership. An unmeasured +zero-height viewport defers this upper-bound limit to a later restoration. + +The focused policy test covers shrink after the list has already corrected its +offset, valid deep offsets, negative pull offsets and an unmeasured viewport. +Runtime acceptance requires clearing a retained page during a page transition, +then returning without an extra drag to expose its header/empty/footer slots. + ## Ancestor consumed-scroll contributions diff --git a/native-views/react-native-pager-view/ios/RNCCollapsiblePagerReleasedStatePolicy.h b/native-views/react-native-pager-view/ios/RNCCollapsiblePagerReleasedStatePolicy.h index 078dfb4a1..9a92e75f8 100644 --- a/native-views/react-native-pager-view/ios/RNCCollapsiblePagerReleasedStatePolicy.h +++ b/native-views/react-native-pager-view/ios/RNCCollapsiblePagerReleasedStatePolicy.h @@ -9,4 +9,12 @@ FOUNDATION_EXPORT void RNCPruneReleasedPageStates( NSArray *validPageKeys ); +FOUNDATION_EXPORT CGFloat RNCLimitRestoredScrollOffset( + CGFloat offset, + CGFloat contentHeight, + CGFloat viewportHeight, + CGFloat topInset, + CGFloat bottomInset +); + NS_ASSUME_NONNULL_END diff --git a/native-views/react-native-pager-view/ios/RNCCollapsiblePagerReleasedStatePolicy.m b/native-views/react-native-pager-view/ios/RNCCollapsiblePagerReleasedStatePolicy.m index 132233a81..8345a0973 100644 --- a/native-views/react-native-pager-view/ios/RNCCollapsiblePagerReleasedStatePolicy.m +++ b/native-views/react-native-pager-view/ios/RNCCollapsiblePagerReleasedStatePolicy.m @@ -1,5 +1,18 @@ #import "RNCCollapsiblePagerReleasedStatePolicy.h" +CGFloat RNCLimitRestoredScrollOffset( + CGFloat offset, + CGFloat contentHeight, + CGFloat viewportHeight, + CGFloat topInset, + CGFloat bottomInset +) +{ + if (viewportHeight <= 0) return offset; + CGFloat maximum = MAX(-topInset, contentHeight - viewportHeight + bottomInset); + return MIN(offset, maximum); +} + void RNCPruneReleasedPageStates( NSMutableDictionary *releasedStates, NSArray *validPageKeys diff --git a/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm b/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm index 33e5c005f..2ab1859de 100644 --- a/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm +++ b/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm @@ -2580,6 +2580,15 @@ - (void)applyInsetsToScrollView:(UIScrollView *)scrollView } targetOffset = restoredLogicalOffset - next.top; targetOffset = MAX(targetOffset, -next.top + _headerOffset); + // An inactive list may shrink after its offset observer was detached. + // Do not overwrite its corrected offset with a now-unreachable cached one. + targetOffset = RNCLimitRestoredScrollOffset( + targetOffset, + scrollView.contentSize.height, + scrollView.bounds.size.height, + next.top, + next.bottom + ); } // A bottom-inset change must not cancel UIKit's refresh or bounce settlement. if (restore || previousTopInset != next.top) { diff --git a/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerReleasedStatePolicyTests.mm b/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerReleasedStatePolicyTests.mm index f9d28a76b..7e18a709e 100644 --- a/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerReleasedStatePolicyTests.mm +++ b/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerReleasedStatePolicyTests.mm @@ -7,6 +7,29 @@ @interface RNCCollapsiblePagerReleasedStatePolicyTests : XCTestCase @implementation RNCCollapsiblePagerReleasedStatePolicyTests +- (void)testRestorationAfterInactiveContentShrinkCannotReapplyStaleCache +{ + // The list already corrected to -44 while inactive, but the pager still + // retains the old logical offset captured before rows were removed. + CGFloat savedLogicalOffset = 513.67; + CGFloat restored = RNCLimitRestoredScrollOffset( + savedLogicalOffset - 108, 132, 708, 108, 532); + XCTAssertEqualWithAccuracy(restored, -44, 0.001); + XCTAssertEqualWithAccuracy(restored + 108, 64, 0.001); + XCTAssertEqualWithAccuracy( + RNCLimitRestoredScrollOffset(restored, 132, 708, 108, 532), restored, 0.001); +} + +- (void)testRestorationPreservesValidDeepAndNegativeOffsets +{ + XCTAssertEqualWithAccuracy( + RNCLimitRestoredScrollOffset(600, 2400, 708, 108, 0), 600, 0.001); + XCTAssertEqualWithAccuracy( + RNCLimitRestoredScrollOffset(-160, 132, 708, 108, 532), -160, 0.001); + XCTAssertEqualWithAccuracy( + RNCLimitRestoredScrollOffset(600, 0, 0, 108, 0), 600, 0.001); +} + - (void)testPrunesRemovedKeysAndKeepsStableKeysAcrossReorders { NSObject *alpha = [NSObject new]; From 330d12166b2f68d0858c44ebede8e343059b2c55 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sun, 27 Sep 2026 11:53:53 +0800 Subject: [PATCH 05/16] fix: constrain ios pager header hit forwarding to viewport --- .../react-native-pager-view/docs/SPEC.md | 15 ++++ .../RNCCollapsiblePagerViewComponentView.mm | 10 +++ .../tests/RNCCollapsiblePagerHitTestTests.mm | 80 +++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerHitTestTests.mm diff --git a/native-views/react-native-pager-view/docs/SPEC.md b/native-views/react-native-pager-view/docs/SPEC.md index b32f479db..80ad0c839 100644 --- a/native-views/react-native-pager-view/docs/SPEC.md +++ b/native-views/react-native-pager-view/docs/SPEC.md @@ -3,6 +3,21 @@ Existing PagerView and CollapsiblePagerView APIs are documented in README.md. The sections below define native coordination boundaries without new React props. +## iOS shared-header hit testing + +Smooth-header hit forwarding is restricted to the pager's own interactive +viewport. A collapsed header clipped above that viewport MUST NOT intercept +touches intended for a sibling toolbar. Hidden, disabled and effectively +transparent pagers do not forward touches. Fabric `pointerEvents="none"` and +`"box-only"` retain their superclass behavior; `"auto"` and `"box-none"` may +forward to visible shared-header children inside the pager. + +The shared header's internal hit testing still supports sticky children that +have moved outside the header host's original bounds while remaining inside the +pager. Focused UIKit tests cover sibling-toolbar hit delivery, pinned-child +delivery, pointer-event modes and interaction flags. Device acceptance includes +toolbar taps with expanded and collapsed headers on retained pages. + ## iOS cached-offset restoration after content shrink When restoring a retained page's cached vertical offset, the pager limits only diff --git a/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm b/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm index 2ab1859de..56b89f92f 100644 --- a/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm +++ b/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm @@ -1145,6 +1145,16 @@ - (instancetype)initWithFrame:(CGRect)frame - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { + // Shared headers can sit above their clipped viewport after collapsing. + // Forward only viewport touches, so invisible headers cannot cover siblings. + if (!self.userInteractionEnabled || self.hidden || self.alpha < 0.01 || + ![self pointInside:point withEvent:event]) return nil; + const auto &viewProps = + *std::static_pointer_cast(_props); + if (viewProps.pointerEvents == PointerEventsMode::None || + viewProps.pointerEvents == PointerEventsMode::BoxOnly) { + return [super hitTest:point withEvent:event]; + } if (_nativeSmoothHeaderScrollEnabled && !_isBeingRecycled && _sharedHeaderHostView.superview != _containerView) { CGPoint headerPoint = [_sharedHeaderHostView convertPoint:point fromView:self]; diff --git a/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerHitTestTests.mm b/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerHitTestTests.mm new file mode 100644 index 000000000..c20e1b18e --- /dev/null +++ b/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerHitTestTests.mm @@ -0,0 +1,80 @@ +#import + +#import "RNCCollapsiblePagerViewComponentView.h" +#import + +using namespace facebook::react; + +@interface RNCCollapsiblePagerHitTestTests : XCTestCase +@property (nonatomic, strong) UIView *root; +@property (nonatomic, strong) UIButton *toolbar; +@property (nonatomic, strong) RNCCollapsiblePagerViewComponentView *pager; +@property (nonatomic, strong) UIButton *sticky; +@end + +@implementation RNCCollapsiblePagerHitTestTests + +- (void)setPointerEvents:(PointerEventsMode)mode +{ + auto props = std::make_shared(); + props->nativeSmoothHeaderScrollEnabled = true; + props->pointerEvents = mode; + [self.pager updateProps:props oldProps:nullptr]; +} + +- (void)setUp +{ + [super setUp]; + self.root = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 320, 600)]; + self.toolbar = [[UIButton alloc] initWithFrame:CGRectMake(0, 36, 320, 64)]; + [self.root addSubview:self.toolbar]; + self.pager = [[RNCCollapsiblePagerViewComponentView alloc] init]; + [self setPointerEvents:PointerEventsMode::Auto]; + self.pager.frame = CGRectMake(0, 100, 320, 500); + [self.root addSubview:self.pager]; + + // Model smooth mode's header reparenting and its collapsed, clipped content. + UIView *scroll = [[UIView alloc] initWithFrame:self.pager.bounds]; + [self.pager addSubview:scroll]; + UIView *header = [self.pager valueForKey:@"sharedHeaderHostView"]; + for (UIView *child in header.subviews.copy) [child removeFromSuperview]; + [scroll addSubview:header]; + header.frame = CGRectMake(0, -64, 320, 108); + [header addSubview:[[UIButton alloc] initWithFrame:CGRectMake(0, 0, 320, 64)]]; + self.sticky = [[UIButton alloc] initWithFrame:CGRectMake(0, 140, 320, 44)]; + [header addSubview:self.sticky]; +} + +- (void)testCollapsedHeaderDoesNotInterceptSiblingToolbar +{ + XCTAssertEqual([self.root hitTest:CGPointMake(20, 80) withEvent:nil], self.toolbar); +} + +- (void)testPinnedChildOutsideHeaderHostStillReceivesViewportTouches +{ + XCTAssertEqual([self.pager hitTest:CGPointMake(20, 90) withEvent:nil], self.sticky); + [self setPointerEvents:PointerEventsMode::BoxNone]; + XCTAssertEqual([self.pager hitTest:CGPointMake(20, 90) withEvent:nil], self.sticky); +} + +- (void)testFabricPointerEventsAreNotBypassed +{ + [self setPointerEvents:PointerEventsMode::None]; + XCTAssertNil([self.pager hitTest:CGPointMake(20, 90) withEvent:nil]); + [self setPointerEvents:PointerEventsMode::BoxOnly]; + XCTAssertEqual([self.pager hitTest:CGPointMake(20, 90) withEvent:nil], self.pager); +} + +- (void)testNoninteractivePagerDoesNotForwardHeaderTouches +{ + self.pager.hidden = YES; + XCTAssertNil([self.pager hitTest:CGPointMake(20, 90) withEvent:nil]); + self.pager.hidden = NO; + self.pager.userInteractionEnabled = NO; + XCTAssertNil([self.pager hitTest:CGPointMake(20, 90) withEvent:nil]); + self.pager.userInteractionEnabled = YES; + self.pager.alpha = 0.005; + XCTAssertNil([self.pager hitTest:CGPointMake(20, 90) withEvent:nil]); +} + +@end From 549252063b8114520540826e8403d255678f18ed Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sun, 27 Sep 2026 19:58:09 +0800 Subject: [PATCH 06/16] fix: position ios refresh indicators above pager headers --- .../react-native-pager-view/docs/SPEC.md | 25 +++ .../RNCCollapsiblePagerViewComponentView.mm | 162 +++++++++++++++++ .../RNCCollapsiblePagerRefreshControlTests.mm | 167 ++++++++++++++++++ 3 files changed, 354 insertions(+) create mode 100644 native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerRefreshControlTests.mm diff --git a/native-views/react-native-pager-view/docs/SPEC.md b/native-views/react-native-pager-view/docs/SPEC.md index 80ad0c839..6cfe5f4d1 100644 --- a/native-views/react-native-pager-view/docs/SPEC.md +++ b/native-views/react-native-pager-view/docs/SPEC.md @@ -3,6 +3,31 @@ Existing PagerView and CollapsiblePagerView APIs are documented in README.md. The sections below define native coordination boundaries without new React props. +## iOS refresh indicator placement + +Status: source implementation; real refresh-cycle acceptance pending. +In smooth-header mode, the pager shifts the refresh control's sublayers upward +by its header-plus-sticky height. It does not change the refresh control frame, +bounds, scroll offset, refresh state or JavaScript callback. RN's caller-owned +`progressViewOffset` remains untouched, including dynamic updates whose bounds +origin could otherwise collide with a coordinator-applied value. + +One internal state per UIRefreshControl combines weak pager-owner contributions. +It preserves the original sublayer transform and clipping setting. Repeated +attachment and header resizing replace an owner's contribution. Layer clipping +is disabled while coordinating so it cannot hide the translated indicator. +A control replacement, scroll-view release, pager recycling or destruction +removes only that owner's contribution and restores the baseline when none remain. +Controls installed after the scroller is attached are also coordinated. Native +UICollectionView controls and RN ScrollView controls use this same UIKit boundary. + +Native layer observations reapply the contribution after an external sublayer +transform change, without observing or interpreting UIKit/RN bounds writes. +There is no per-frame JavaScript work. Acceptance must cover real UIKit pulling +and refresh completion, caller offset changes, late installation/replacement, +retained-page switching, clipping and nested-owner cleanup. Programmatic UIKit +unit tests do not substitute for real pull-gesture visibility acceptance. + ## iOS shared-header hit testing Smooth-header hit forwarding is restricted to the pager's own interactive diff --git a/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm b/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm index 56b89f92f..46bee5203 100644 --- a/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm +++ b/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm @@ -11,6 +11,7 @@ #import "React/RCTScrollViewComponentView.h" #import "React/RCTSurfaceTouchHandler.h" #import "React/RCTTouchHandler.h" +#import // OneKey patch: deduplicate structural mutation parents before scanning the page. #include @@ -18,6 +19,116 @@ using namespace facebook::react; static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerContentOffsetContext; +static void *RNCRefreshControlLayerContext = &RNCRefreshControlLayerContext; +static void *RNCScrollRefreshControlContext = &RNCScrollRefreshControlContext; +static char RNCRefreshControlOffsetKey; + +@interface RNCRefreshControlOffsetState : NSObject ++ (void)setOffset:(CGFloat)offset forControl:(UIRefreshControl *)control owner:(id)owner; ++ (void)removeControl:(UIRefreshControl *)control owner:(id)owner; +@end + +@implementation RNCRefreshControlOffsetState { + __weak UIRefreshControl *_control; + NSMapTable *_offsets; + CATransform3D _baselineTransform; + CATransform3D _appliedTransform; + BOOL _originalMasksToBounds; + BOOL _applying; + BOOL _observing; +} + +- (instancetype)initWithControl:(UIRefreshControl *)control +{ + if ((self = [super init])) { + _control = control; + _baselineTransform = control.layer.sublayerTransform; + _originalMasksToBounds = control.layer.masksToBounds; + _offsets = [NSMapTable weakToStrongObjectsMapTable]; + [control.layer addObserver:self forKeyPath:@"sublayerTransform" + options:NSKeyValueObservingOptionNew + context:RNCRefreshControlLayerContext]; + [control.layer addObserver:self forKeyPath:@"masksToBounds" + options:NSKeyValueObservingOptionNew + context:RNCRefreshControlLayerContext]; + _observing = YES; + } + return self; +} + +- (void)apply +{ + UIRefreshControl *control = _control; + if (control == nil) return; + CGFloat offset = 0; + for (NSNumber *value in _offsets.objectEnumerator) offset += value.doubleValue; + CATransform3D transform = CATransform3DConcat( + _baselineTransform, CATransform3DMakeTranslation(0, -offset, 0)); + _appliedTransform = transform; + _applying = YES; + // Visual placement must not change UIKit's refresh geometry or RN's bounds + // origin (progressViewOffset). Allow the shifted indicator outside its host. + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + control.layer.masksToBounds = _offsets.count == 0 ? _originalMasksToBounds : NO; + if (!CATransform3DEqualToTransform(control.layer.sublayerTransform, transform)) { + control.layer.sublayerTransform = transform; + } + [CATransaction commit]; + _applying = NO; +} + ++ (void)setOffset:(CGFloat)offset forControl:(UIRefreshControl *)control owner:(id)owner +{ + if (control == nil) return; + RNCRefreshControlOffsetState *state = objc_getAssociatedObject(control, &RNCRefreshControlOffsetKey); + if (state == nil) { + state = [[self alloc] initWithControl:control]; + objc_setAssociatedObject(control, &RNCRefreshControlOffsetKey, state, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + [state->_offsets setObject:@(offset) forKey:owner]; + [state apply]; +} + ++ (void)removeControl:(UIRefreshControl *)control owner:(id)owner +{ + if (control == nil) return; + RNCRefreshControlOffsetState *state = objc_getAssociatedObject(control, &RNCRefreshControlOffsetKey); + if (state == nil) return; + [state->_offsets removeObjectForKey:owner]; + [state apply]; + if (state->_offsets.count == 0) { + [control.layer removeObserver:state forKeyPath:@"sublayerTransform" context:RNCRefreshControlLayerContext]; + [control.layer removeObserver:state forKeyPath:@"masksToBounds" context:RNCRefreshControlLayerContext]; + state->_observing = NO; + objc_setAssociatedObject(control, &RNCRefreshControlOffsetKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } +} + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object + change:(NSDictionary *)change context:(void *)context +{ + if (context == RNCRefreshControlLayerContext) { + if (_applying) return; + if ([keyPath isEqualToString:@"sublayerTransform"]) { + CATransform3D next = [change[NSKeyValueChangeNewKey] CATransform3DValue]; + if (CATransform3DEqualToTransform(next, _appliedTransform)) return; + _baselineTransform = next; + } + [self apply]; + return; + } + [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; +} + +- (void)dealloc +{ + if (_observing && _control != nil) { + [_control.layer removeObserver:self forKeyPath:@"sublayerTransform" context:RNCRefreshControlLayerContext]; + [_control.layer removeObserver:self forKeyPath:@"masksToBounds" context:RNCRefreshControlLayerContext]; + } +} +@end static NSString *const RNCCollapsiblePagerNativeScrollerIDPrefix = @"rnc-collapsible-pager-native-scroller:"; @@ -973,6 +1084,8 @@ - (void)scheduleScrollObserverRetryForCurrentPage; - (void)preparePageForHorizontalTransitionAtIndex:(NSInteger)pageIndex; - (void)updateSharedHeaderPressCancellationGesture; - (void)detachSharedHeaderPressCancellationGesture; +- (void)updateRefreshControlForScrollView:(UIScrollView *)scrollView; +- (void)releaseRefreshControlForScrollView:(UIScrollView *)scrollView; - (void)sharedHeaderPressCancellationGestureChanged:(UIPanGestureRecognizer *)recognizer; - (void)outerPagerGestureChanged:(UIPanGestureRecognizer *)recognizer; - (void)verticalPagerGuardGestureChanged:(UIPanGestureRecognizer *)recognizer; @@ -1023,6 +1136,8 @@ @implementation RNCCollapsiblePagerViewComponentView { NSString *_retainedPages; NSMutableDictionary *_pageOffsets; NSMapTable *_originalInsets; + NSMapTable *_managedRefreshControls; + NSHashTable *_refreshControlObservedScrollViews; NSMapTable *_appliedTopInsets; NSMapTable *_originalIndicatorInsets; NSMapTable *_originalAlwaysBounceVertical; @@ -1066,6 +1181,8 @@ - (instancetype)initWithFrame:(CGRect)frame _pageOffsets = [NSMutableDictionary new]; _releasedPageScrollStates = [NSMutableDictionary new]; _originalInsets = [NSMapTable weakToStrongObjectsMapTable]; + _managedRefreshControls = [NSMapTable weakToWeakObjectsMapTable]; + _refreshControlObservedScrollViews = [NSHashTable weakObjectsHashTable]; _appliedTopInsets = [NSMapTable weakToStrongObjectsMapTable]; _originalIndicatorInsets = [NSMapTable weakToStrongObjectsMapTable]; _originalAlwaysBounceVertical = [NSMapTable weakToStrongObjectsMapTable]; @@ -1201,6 +1318,9 @@ - (void)didMoveToWindow - (void)dealloc { + for (UIScrollView *scrollView in _refreshControlObservedScrollViews.allObjects) { + [self releaseRefreshControlForScrollView:scrollView]; + } [self detachSharedHeaderPressCancellationGesture]; } @@ -1739,6 +1859,9 @@ - (void)prepareForRecycle [self detachSharedHeaderPressCancellationGesture]; [self detachScrollObserver]; [self restoreSharedHeadersToContainer]; + for (UIScrollView *scrollView in _refreshControlObservedScrollViews.allObjects) { + [self releaseRefreshControlForScrollView:scrollView]; + } for (UIScrollView *scrollView in _originalInsets.keyEnumerator) { NSValue *value = [_originalInsets objectForKey:scrollView]; if (value != nil) { @@ -2428,10 +2551,44 @@ - (void)observedListPanChanged:(UIPanGestureRecognizer *)recognizer } } +- (void)updateRefreshControlForScrollView:(UIScrollView *)scrollView +{ + if (!_nativeSmoothHeaderScrollEnabled || _isBeingRecycled) { + [self releaseRefreshControlForScrollView:scrollView]; + return; + } + if (![_refreshControlObservedScrollViews containsObject:scrollView]) { + [_refreshControlObservedScrollViews addObject:scrollView]; + [scrollView addObserver:self forKeyPath:@"refreshControl" + options:NSKeyValueObservingOptionNew + context:RNCScrollRefreshControlContext]; + } + UIRefreshControl *previous = [_managedRefreshControls objectForKey:scrollView]; + UIRefreshControl *current = scrollView.refreshControl; + if (previous != current) { + [RNCRefreshControlOffsetState removeControl:previous owner:self]; + [_managedRefreshControls removeObjectForKey:scrollView]; + if (current != nil) [_managedRefreshControls setObject:current forKey:scrollView]; + } + [RNCRefreshControlOffsetState setOffset:_headerHeight + _stickyHeaderHeight + forControl:current owner:self]; +} + +- (void)releaseRefreshControlForScrollView:(UIScrollView *)scrollView +{ + if ([_refreshControlObservedScrollViews containsObject:scrollView]) { + [scrollView removeObserver:self forKeyPath:@"refreshControl" context:RNCScrollRefreshControlContext]; + [_refreshControlObservedScrollViews removeObject:scrollView]; + } + [RNCRefreshControlOffsetState removeControl:[_managedRefreshControls objectForKey:scrollView] owner:self]; + [_managedRefreshControls removeObjectForKey:scrollView]; +} + // OneKey patch: release observers, headers and saved state while the scroll view // still represents the old list, before Fabric applies its next owner's props. - (void)restoreScrollViewState:(UIScrollView *)scrollView resetSavedOffset:(BOOL)resetSavedOffset { + [self releaseRefreshControlForScrollView:scrollView]; if (scrollView == _observedScrollView) { [self detachScrollObserver]; if (resetSavedOffset) _pageOffsets[self.currentPageKey] = @(_headerOffset); @@ -2548,6 +2705,7 @@ - (void)applyInsetsToScrollView:(UIScrollView *)scrollView // This host already includes the fixed header and safe area in its frame. // UIKit automatic adjustment would add them a second time to short pages. scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + [self updateRefreshControlForScrollView:scrollView]; CGFloat previousTopInset = scrollView.contentInset.top; CGFloat previousLogicalOffset = scrollView.contentOffset.y + previousTopInset; NSNumber *appliedTop = [_appliedTopInsets objectForKey:scrollView]; @@ -2636,6 +2794,10 @@ - (void)observeValueForKeyPath:(NSString *)keyPath change:(NSDictionary *)change context:(void *)context { + if (context == RNCScrollRefreshControlContext) { + [self updateRefreshControlForScrollView:(UIScrollView *)object]; + return; + } if (context == RNCCollapsiblePagerContentOffsetContext && object == _observedScrollView) { if ([keyPath isEqualToString:@"contentSize"]) { NSValue *oldSize = change[NSKeyValueChangeOldKey]; diff --git a/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerRefreshControlTests.mm b/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerRefreshControlTests.mm new file mode 100644 index 000000000..b4ebd2dee --- /dev/null +++ b/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerRefreshControlTests.mm @@ -0,0 +1,167 @@ +#import +#import "RNCCollapsiblePagerViewComponentView.h" + +@interface RNCCollapsiblePagerViewComponentView (RefreshControlTesting) +- (void)updateRefreshControlForScrollView:(UIScrollView *)scrollView; +- (void)releaseRefreshControlForScrollView:(UIScrollView *)scrollView; +@end + +@interface RNCCollapsiblePagerRefreshControlTests : XCTestCase +@end + +@implementation RNCCollapsiblePagerRefreshControlTests + +- (RNCCollapsiblePagerViewComponentView *)pagerWithHeader:(CGFloat)height +{ + RNCCollapsiblePagerViewComponentView *pager = [RNCCollapsiblePagerViewComponentView new]; + [pager setValue:@YES forKey:@"nativeSmoothHeaderScrollEnabled"]; + [pager setValue:@(height) forKey:@"headerHeight"]; + [pager setValue:@44 forKey:@"stickyHeaderHeight"]; + return pager; +} + +- (void)testLateControlAndCallerOffsetUpdatesPreserveBaseline +{ + RNCCollapsiblePagerViewComponentView *pager = [self pagerWithHeader:64]; + UIScrollView *scroll = [[UIScrollView alloc] initWithFrame:CGRectMake(0, 0, 320, 600)]; + [pager updateRefreshControlForScrollView:scroll]; + UIRefreshControl *control = [UIRefreshControl new]; + control.bounds = CGRectMake(3, -12, 320, 60); + scroll.refreshControl = control; + XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -108, 0.001); + XCTAssertEqualWithAccuracy(control.bounds.origin.y, -12, 0.001); + [pager updateRefreshControlForScrollView:scroll]; + XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -108, 0.001); + XCTAssertEqualWithAccuracy(control.bounds.origin.y, -12, 0.001); + control.layer.sublayerTransform = control.layer.sublayerTransform; + // Also cover an explicit no-op KVO notification, even if CALayer's setter + // suppresses notifications for equal transforms on the current OS. + [control.layer willChangeValueForKey:@"sublayerTransform"]; + [control.layer didChangeValueForKey:@"sublayerTransform"]; + XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -108, 0.001); + + // Fabric's progressViewOffset setter replaces bounds.origin.y directly. + control.bounds = CGRectMake(3, -20, 320, 60); + XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -108, 0.001); + XCTAssertEqualWithAccuracy(control.bounds.origin.y, -20, 0.001); + [pager setValue:@100 forKey:@"headerHeight"]; + [pager updateRefreshControlForScrollView:scroll]; + XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -144, 0.001); + XCTAssertEqualWithAccuracy(control.bounds.origin.y, -20, 0.001); + [pager releaseRefreshControlForScrollView:scroll]; + XCTAssertEqualWithAccuracy(control.bounds.origin.y, -20, 0.001); + XCTAssertEqualWithAccuracy(control.bounds.origin.x, 3, 0.001); +} + +- (void)testUIKitSizeChangesAndRefreshCycleDoNotAccumulateOffset +{ + RNCCollapsiblePagerViewComponentView *pager = [self pagerWithHeader:64]; + UIScrollView *scroll = [[UIScrollView alloc] initWithFrame:CGRectMake(0, 0, 320, 600)]; + UIRefreshControl *control = [UIRefreshControl new]; + scroll.refreshControl = control; + [pager updateRefreshControlForScrollView:scroll]; + CGRect bounds = control.bounds; + bounds.size = CGSizeMake(320, 60); + control.bounds = bounds; + XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -108, 0.001); + XCTAssertEqualWithAccuracy(control.bounds.origin.y, 0, 0.001); + [control beginRefreshing]; + [control layoutIfNeeded]; + [pager updateRefreshControlForScrollView:scroll]; + XCTAssertTrue(control.isRefreshing); + XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -108, 0.001); + XCTAssertEqualWithAccuracy(control.bounds.origin.y, 0, 0.001); + [control endRefreshing]; + [control layoutIfNeeded]; + XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -108, 0.001); + XCTAssertEqualWithAccuracy(control.bounds.origin.y, 0, 0.001); + [pager releaseRefreshControlForScrollView:scroll]; + XCTAssertEqualWithAccuracy(control.bounds.origin.y, 0, 0.001); + XCTAssertTrue(CATransform3DIsIdentity(control.layer.sublayerTransform)); +} + +- (void)testReplacementAndNestedOwnerReleaseRestoreOnlyTheirContributions +{ + RNCCollapsiblePagerViewComponentView *first = [self pagerWithHeader:64]; + RNCCollapsiblePagerViewComponentView *second = [self pagerWithHeader:20]; + UICollectionView *scroll = [[UICollectionView alloc] initWithFrame:CGRectMake(0, 0, 320, 600) + collectionViewLayout:[UICollectionViewFlowLayout new]]; + UIRefreshControl *original = [UIRefreshControl new]; + scroll.refreshControl = original; + [first updateRefreshControlForScrollView:scroll]; + [second updateRefreshControlForScrollView:scroll]; + XCTAssertEqualWithAccuracy(original.layer.sublayerTransform.m42, -172, 0.001); + [first releaseRefreshControlForScrollView:scroll]; + XCTAssertEqualWithAccuracy(original.layer.sublayerTransform.m42, -64, 0.001); + + UIRefreshControl *replacement = [UIRefreshControl new]; + replacement.bounds = CGRectMake(0, -8, 320, 60); + scroll.refreshControl = replacement; + XCTAssertTrue(CATransform3DIsIdentity(original.layer.sublayerTransform)); + XCTAssertEqualWithAccuracy(replacement.layer.sublayerTransform.m42, -64, 0.001); + XCTAssertEqualWithAccuracy(replacement.bounds.origin.y, -8, 0.001); + [second prepareForRecycle]; + XCTAssertEqualWithAccuracy(replacement.bounds.origin.y, -8, 0.001); + XCTAssertTrue(CATransform3DIsIdentity(replacement.layer.sublayerTransform)); + scroll.refreshControl = nil; +} + +- (void)testPagerDestructionRestoresControl +{ + UIScrollView *scroll = [UIScrollView new]; + UIRefreshControl *control = [UIRefreshControl new]; + scroll.refreshControl = control; + @autoreleasepool { + RNCCollapsiblePagerViewComponentView *pager = [self pagerWithHeader:64]; + [pager updateRefreshControlForScrollView:scroll]; + XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -108, 0.001); + XCTAssertEqualWithAccuracy(control.bounds.origin.y, 0, 0.001); + } + XCTAssertEqualWithAccuracy(control.bounds.origin.y, 0, 0.001); + XCTAssertTrue(CATransform3DIsIdentity(control.layer.sublayerTransform)); +} + +- (void)testCallerOffsetCollisionDoesNotAffectVisualContribution +{ + RNCCollapsiblePagerViewComponentView *pager = [self pagerWithHeader:56]; + UIScrollView *scroll = [UIScrollView new]; + UIRefreshControl *control = [UIRefreshControl new]; + scroll.refreshControl = control; + control.bounds = CGRectMake(0, -200, 320, 60); + [pager updateRefreshControlForScrollView:scroll]; + control.bounds = CGRectMake(0, -100, 320, 60); + XCTAssertEqualWithAccuracy(control.bounds.origin.y, -100, 0.001); + XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -100, 0.001); + [pager releaseRefreshControlForScrollView:scroll]; + XCTAssertEqualWithAccuracy(control.bounds.origin.y, -100, 0.001); + XCTAssertTrue(CATransform3DIsIdentity(control.layer.sublayerTransform)); +} + +- (void)testNestedOwnerDestructionPreservesBaselineAndRestoresClipping +{ + RNCCollapsiblePagerViewComponentView *second = [self pagerWithHeader:20]; + UIScrollView *scroll = [UIScrollView new]; + UIRefreshControl *control = [UIRefreshControl new]; + scroll.refreshControl = control; + CATransform3D baseline = CATransform3DMakeTranslation(7, 13, 0); + control.layer.sublayerTransform = baseline; + control.layer.masksToBounds = YES; + @autoreleasepool { + RNCCollapsiblePagerViewComponentView *first = [self pagerWithHeader:64]; + [first updateRefreshControlForScrollView:scroll]; + [second updateRefreshControlForScrollView:scroll]; + XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, 13 - 172, 0.001); + XCTAssertFalse(control.layer.masksToBounds); + } + XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, 13 - 64, 0.001); + // A UIKit layer reset must not discard the remaining visual contribution. + control.layer.sublayerTransform = baseline; + control.layer.masksToBounds = YES; + XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, 13 - 64, 0.001); + XCTAssertFalse(control.layer.masksToBounds); + [second releaseRefreshControlForScrollView:scroll]; + XCTAssertTrue(CATransform3DEqualToTransform(control.layer.sublayerTransform, baseline)); + XCTAssertTrue(control.layer.masksToBounds); +} + +@end From 93890747a201188f1ff3f4492deb9d4ec21db0fd Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sun, 27 Sep 2026 20:12:46 +0800 Subject: [PATCH 07/16] test: mount refresh control in visible window --- .../tests/RNCCollapsiblePagerRefreshControlTests.mm | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerRefreshControlTests.mm b/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerRefreshControlTests.mm index b4ebd2dee..109d7b7d6 100644 --- a/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerRefreshControlTests.mm +++ b/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerRefreshControlTests.mm @@ -57,8 +57,20 @@ - (void)testUIKitSizeChangesAndRefreshCycleDoNotAccumulateOffset { RNCCollapsiblePagerViewComponentView *pager = [self pagerWithHeader:64]; UIScrollView *scroll = [[UIScrollView alloc] initWithFrame:CGRectMake(0, 0, 320, 600)]; + // UIKit ignores offscreen beginRefreshing calls. Exercise the actual refresh + // lifecycle in a visible window rather than weakening the state assertion. + UIWindow *window = [[UIWindow alloc] initWithFrame:scroll.frame]; + UIViewController *controller = [UIViewController new]; + window.rootViewController = controller; + scroll.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + [controller.view addSubview:scroll]; + window.hidden = NO; + [self addTeardownBlock:^{ window.hidden = YES; }]; UIRefreshControl *control = [UIRefreshControl new]; scroll.refreshControl = control; + [window layoutIfNeeded]; + [scroll layoutIfNeeded]; + XCTAssertEqual(control.window, window); [pager updateRefreshControlForScrollView:scroll]; CGRect bounds = control.bounds; bounds.size = CGSizeMake(320, 60); @@ -73,6 +85,7 @@ - (void)testUIKitSizeChangesAndRefreshCycleDoNotAccumulateOffset XCTAssertEqualWithAccuracy(control.bounds.origin.y, 0, 0.001); [control endRefreshing]; [control layoutIfNeeded]; + XCTAssertFalse(control.isRefreshing); XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -108, 0.001); XCTAssertEqualWithAccuracy(control.bounds.origin.y, 0, 0.001); [pager releaseRefreshControlForScrollView:scroll]; From 3a6fa207f45ed4fcb15d40b31c6031f39dba28b0 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sun, 27 Sep 2026 20:39:20 +0800 Subject: [PATCH 08/16] docs: record native home refresh acceptance --- native-views/react-native-pager-view/docs/SPEC.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/native-views/react-native-pager-view/docs/SPEC.md b/native-views/react-native-pager-view/docs/SPEC.md index 6cfe5f4d1..019968516 100644 --- a/native-views/react-native-pager-view/docs/SPEC.md +++ b/native-views/react-native-pager-view/docs/SPEC.md @@ -5,7 +5,14 @@ The sections below define native coordination boundaries without new React props ## iOS refresh indicator placement -Status: source implementation; real refresh-cycle acceptance pending. +Status: implemented; iOS Home pull-gesture visibility verified on 2026-09-27. +Spot, Perps and DeFi (RN NativeScroller), plus NFT and History (NativeList), +each pass header-origin and content-origin pulls: ten recordings show a visible +indicator above the shared header, one refresh event and normal disappearance. +This verifies UI feedback and event dispatch, not downstream request completion. +The Pager UIKit suite passes 14 tests, including six refresh-control cases +(native-tests run 36318243869). Rapid nested gestures and iPad geometry remain +separate acceptance work. In smooth-header mode, the pager shifts the refresh control's sublayers upward by its header-plus-sticky height. It does not change the refresh control frame, bounds, scroll offset, refresh state or JavaScript callback. RN's caller-owned From 2e1c1412a5587c23a248167d8c8e1f5a7c0e2296 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 28 Sep 2026 09:25:43 +0800 Subject: [PATCH 09/16] fix: coordinate android pager refresh indicator placement --- .../CollapsiblePagerHost.kt | 52 +++++++++++++++++ .../NativeScrollerScrollAwayInsets.kt | 20 +++++++ .../NativeScrollerScrollAwayInsetsTest.kt | 58 +++++++++++++++++++ .../react-native-pager-view/docs/SPEC.md | 34 +++++++++++ 4 files changed, 164 insertions(+) diff --git a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt index ef99a13ad..4e0e63bb8 100644 --- a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt +++ b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt @@ -9,6 +9,7 @@ import android.view.ViewConfiguration import android.view.ViewGroup import android.view.ViewTreeObserver import android.widget.HorizontalScrollView +import android.widget.ImageView import android.widget.ScrollView // OneKey patch: FrameLayout is inherited through NestedScrollableHost. // import android.widget.FrameLayout @@ -17,11 +18,14 @@ import androidx.core.view.NestedScrollingParentHelper import androidx.core.view.ViewCompat import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.CircularProgressDrawable import androidx.viewpager2.widget.ViewPager2 import com.facebook.react.R as ReactR import com.facebook.react.uimanager.events.NativeGestureUtil import com.facebook.react.views.scroll.ReactScrollView +import com.facebook.react.views.swiperefresh.ReactSwipeRefreshLayout import com.margelo.nitro.nativelogger.OneKeyLog +import java.lang.ref.WeakReference import java.util.WeakHashMap import kotlin.math.max import kotlin.math.min @@ -116,6 +120,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne private val restoredRecyclerKeys = WeakHashMap() private val originalScrollerPadding = WeakHashMap() private val restoredScrollerKeys = WeakHashMap() + private val scrollerRefreshIndicators = WeakHashMap>() private val pageOffsets = HashMap() private var observedRecyclerView: RecyclerView? = null private var observedScrollListener: RecyclerView.OnScrollListener? = null @@ -604,6 +609,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne } private fun prepareAdjacentPages() { + // Retained pages and replaced controls may leave the attached view tree. + for ((scroller, indicator) in scrollerRefreshIndicators.toMap()) { + if (nativeScrollerRefreshIndicator(scroller) !== indicator.get()) { + restoreNativeScrollerRefreshIndicator(scroller) + } + } val first = max(0, selectedPage - 1) val last = min(adapter.itemCount - 1, selectedPage + 1) for (index in first..last) { @@ -794,6 +805,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne updateScrollerPaddingOwnership(scroller, original) val topInset = headerHeightPx + stickyHeaderHeightPx scroller.clipToPadding = false + applyNativeScrollerRefreshIndicator(scroller, topInset) if (scroller is ReactScrollView) { // Fabric owns the content child's layout, so ScrollView padding alone // cannot move it. RN's native ScrollAway API also updates Fabric culling. @@ -896,6 +908,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne } private fun restoreNativeScrollerInsets(scroller: ScrollView) { + restoreNativeScrollerRefreshIndicator(scroller) val original = originalScrollerPadding.remove(scroller) ?: return updateScrollerPaddingOwnership(scroller, original) restoredScrollerKeys.remove(scroller) @@ -910,6 +923,44 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne scroller.setPadding(original.left, original.top, original.right, original.bottom) } + private fun nativeScrollerRefreshIndicator(scroller: ScrollView): View? { + if (!nativeSmoothHeaderScrollEnabled) return null + var ancestor = scroller.parent + while (ancestor != null && ancestor !== this) ancestor = ancestor.parent + if (ancestor !== this) return null + val refresh = scroller.parent as? ReactSwipeRefreshLayout ?: return null + for (index in 0 until refresh.childCount) { + val child = refresh.getChildAt(index) + if (child is ImageView && child.drawable is CircularProgressDrawable) return child + } + return null + } + + private fun applyNativeScrollerRefreshIndicator(scroller: ScrollView, inset: Int) { + val indicator = nativeScrollerRefreshIndicator(scroller) + if (scrollerRefreshIndicators[scroller]?.get() !== indicator) { + restoreNativeScrollerRefreshIndicator(scroller) + } + if (indicator == null) return + if (scrollerRefreshIndicators[scroller]?.get() !== indicator) { + scrollerRefreshIndicators[scroller] = WeakReference(indicator) + } + val translation = refreshIndicatorTranslations.getOrPut(indicator) { + NativeScrollerRefreshTranslation(indicator.translationY) + } + // Moving only the drawing preserves RN's offsets and AndroidX's active pull/refresh animation. + // setProgressViewOffset would reset that animation and clear isRefreshing. + val next = translation.update(indicator.translationY, this, inset.toFloat()) + if (indicator.translationY != next) indicator.translationY = next + } + + private fun restoreNativeScrollerRefreshIndicator(scroller: ScrollView) { + val indicator = scrollerRefreshIndicators.remove(scroller)?.get() ?: return + val translation = refreshIndicatorTranslations[indicator] ?: return + indicator.translationY = translation.update(indicator.translationY, this, null) + if (translation.isEmpty) refreshIndicatorTranslations.remove(indicator) + } + private fun headerRegionAt(y: Float): String? { val headerBottom = headerHeightPx - headerOffsetPx val stickyBottom = headerHeightPx + stickyHeaderHeightPx - headerOffsetPx @@ -1232,6 +1283,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne } companion object { + private val refreshIndicatorTranslations = WeakHashMap() const val NATIVE_SCROLLER_ID_PREFIX = "rnc-collapsible-pager-native-scroller:" const val PAGE_SLOT_OFFSET = 2 } diff --git a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt index 4eea3c325..53960b951 100644 --- a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt +++ b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt @@ -1,5 +1,7 @@ package com.reactnativepagerview +import java.util.WeakHashMap + // Replace only this pager's contribution; another native owner may share the scroller. internal fun nativeScrollerScrollAwayTop(currentTop: Int, previousInset: Int, nextInset: Int): Int = (currentTop - previousInset).coerceAtLeast(0) + nextInset @@ -15,3 +17,21 @@ internal fun nativeScrollerPaddingWasResetByScrollAway( scrollAwayBottom: Int, ): Boolean = appliedInset > 0 && left == 0 && top == 0 && right == 0 && bottom == scrollAwayTop + scrollAwayBottom + +internal class NativeScrollerRefreshTranslation(initialTranslation: Float) { + private val contributions = WeakHashMap() + private var baseline = initialTranslation + private var lastApplied = initialTranslation + + val isEmpty: Boolean + get() = contributions.isEmpty() + + fun update(currentTranslation: Float, owner: Any, inset: Float?): Float { + // Native refresh layout owns top/scale, while an external translation is + // an absolute baseline rather than another copy of our previous inset. + if (currentTranslation != lastApplied) baseline = currentTranslation + if (inset == null) contributions.remove(owner) else contributions[owner] = inset + lastApplied = baseline + contributions.values.sum() + return lastApplied + } +} diff --git a/native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt b/native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt index f161a57ab..7a6857423 100644 --- a/native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt +++ b/native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt @@ -34,4 +34,62 @@ fun main() { // Initial discovery must capture caller values before this pager owns an inset. check(!nativeScrollerPaddingWasResetByScrollAway(0, 0, 0, 0, 230, 230, 0)) println("NativeScroller ScrollAway ownership: initial, repeat, measured resize, nested detach, Fabric reset, RN padding replay, and caller updates passed") + + val firstOwner = Any() + val secondOwner = Any() + val translation = NativeScrollerRefreshTranslation(7.5f) + check(translation.isEmpty) + var current = translation.update(7.5f, firstOwner, 230f) + check(current == 237.5f) + check(!translation.isEmpty) + current = translation.update(current, firstOwner, 230f) + check(current == 237.5f) + current = translation.update(current, firstOwner, 358f) + check(current == 365.5f) + current = translation.update(current, secondOwner, 64f) + check(current == 429.5f) + current = translation.update(current, firstOwner, null) + check(current == 71.5f) + check(!translation.isEmpty) + current = translation.update(current, secondOwner, null) + check(current == 7.5f) + check(translation.isEmpty) + + // Reversing release order must restore the same original translation. + current = translation.update(current, firstOwner, 230f) + current = translation.update(current, secondOwner, 64f) + current = translation.update(current, secondOwner, null) + check(current == 237.5f) + current = translation.update(current, firstOwner, null) + check(current == 7.5f) + check(translation.isEmpty) + + // A caller's absolute translation update replaces the baseline, not its inset. + current = translation.update(current, firstOwner, 230f) + check(current == 237.5f) + current = translation.update(-12.5f, firstOwner, 230f) + check(current == 217.5f) + current = translation.update(current, secondOwner, 64f) + check(current == 281.5f) + current = translation.update(current, firstOwner, null) + check(current == 51.5f) + current = translation.update(current, secondOwner, null) + check(current == -12.5f) + check(translation.isEmpty) + + // Zero is a real owned contribution; only null releases ownership. + current = translation.update(current, firstOwner, 0f) + check(current == -12.5f) + check(!translation.isEmpty) + current = translation.update(current, secondOwner, 0f) + current = translation.update(current, firstOwner, null) + check(current == -12.5f) + check(!translation.isEmpty) + current = translation.update(current, secondOwner, null) + check(current == -12.5f) + check(translation.isEmpty) + current = translation.update(9f, firstOwner, null) + check(current == 9f) + check(translation.isEmpty) + println("NativeScroller refresh translation: repeat, resize, nested release orders, external baseline, zero contribution, and final restoration passed") } diff --git a/native-views/react-native-pager-view/docs/SPEC.md b/native-views/react-native-pager-view/docs/SPEC.md index 019968516..3934e2cd9 100644 --- a/native-views/react-native-pager-view/docs/SPEC.md +++ b/native-views/react-native-pager-view/docs/SPEC.md @@ -3,6 +3,40 @@ Existing PagerView and CollapsiblePagerView APIs are documented in README.md. The sections below define native coordination boundaries without new React props. +## Android NativeScroller refresh indicator placement + +Status: implemented; Android API 36 Home pull-gesture visibility verified on +2026-09-28. Spot, Perps and DeFi (RN NativeScroller), plus the unchanged NFT and +History (NativeList), each pass header-origin and content-origin pulls. Ten +recordings show a visible indicator below the shared tabs, one refresh event and +normal disappearance. This verifies UI feedback and event dispatch, not +downstream request completion. Dynamic caller offset, refresh-time header resize +and nested-pager replacement remain separate device acceptance work. +In smooth-header mode, each pager contributes its header-plus-sticky height in +physical pixels to the RN NativeScroller refresh indicator's `translationY`. +The coordinator identifies only the refresh layout's direct indicator child; +it MUST NOT translate the content ScrollView or change refresh offsets, bounds, +scale, animation, visibility, trigger distance, refreshing state or callbacks. +RN retains ownership of `progressViewOffset`, including dynamic caller updates. +NativeList keeps its existing independent refresh-indicator positioning. + +One state per indicator combines weak owner contributions on the UI thread. +Repeated application and header resizing replace only the calling owner's +contribution. An external translation different from the last applied value +becomes the new baseline. Removing an owner preserves the remaining contributions; +removing the final owner restores the latest baseline. A zero contribution still +has an owner until explicitly released. State MUST NOT strongly retain an owner +or the indicator. Late mounting, indicator replacement and scroller/pager detach +use the existing native layout lifecycle; there is no JavaScript frame callback. + +The standalone JVM policy test covers nonzero baselines, repeated application, +resize, nested owners removed in either order, external translation updates, +zero contributions and final restoration. It does not prove Android view +discovery, actual refresh state or rendered placement. Runtime acceptance requires +header-origin and content-origin pulls on NativeScroller pages, caller offset +changes, refresh-time layout changes, replacement/detach, and visible disappearance +after completion; existing NativeList pages must remain unchanged. + ## iOS refresh indicator placement Status: implemented; iOS Home pull-gesture visibility verified on 2026-09-27. From a99d260d882df3ce6a13f7ae331b751e79483929 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 28 Sep 2026 10:43:58 +0800 Subject: [PATCH 10/16] fix: coordinate Android pager refresh placement and release --- .../com/onekey/nativelist/NativeListView.kt | 2 + .../values/native_scroll_coordinator_ids.xml | 1 + .../react-native-native-list/docs/SPEC.md | 14 ++ .../CollapsiblePagerHost.kt | 191 ++++++++++++++---- .../NativeScrollerScrollAwayInsets.kt | 26 ++- .../values/native_scroll_coordinator_ids.xml | 1 + .../NativeScrollerScrollAwayInsetsTest.kt | 84 +++----- .../react-native-pager-view/docs/SPEC.md | 100 ++++++--- 8 files changed, 287 insertions(+), 132 deletions(-) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt index 3f12b4dad..69c8a1e18 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt @@ -592,6 +592,8 @@ class NativeListView( } private fun updateRefreshIndicatorOffset(offset: Int) { + // Let an ancestor render above its shared header without guessing caller offsets. + refreshLayout.setTag(R.id.onekey_native_scroll_coordinator_refresh_inset, offset) if (refreshIndicatorOffsetPx == offset) return refreshIndicatorOffsetPx = offset val refreshing = refreshLayout.isRefreshing diff --git a/native-views/react-native-native-list/android/src/main/res/values/native_scroll_coordinator_ids.xml b/native-views/react-native-native-list/android/src/main/res/values/native_scroll_coordinator_ids.xml index 2f657f1f3..6caab29a6 100644 --- a/native-views/react-native-native-list/android/src/main/res/values/native_scroll_coordinator_ids.xml +++ b/native-views/react-native-native-list/android/src/main/res/values/native_scroll_coordinator_ids.xml @@ -1,4 +1,5 @@ + diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index 92cfb91a6..3e8f42325 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -526,6 +526,20 @@ The container does not emit a second haptic for the release fallback. The fallba inactive unless pull-to-refresh is enabled; no product-specific threshold is hardcoded into the module. +## Android refresh foreground geometry + +Status: implemented; NFT/History Home placement verified on Android on 2026-09-28. +The refresh container publishes its actual coordinator-added indicator inset in +physical pixels under the native View tag +`onekey_native_scroll_coordinator_refresh_inset`. Zero means no added inset; +the value MUST match the offset actually applied by the existing controller, +including all ancestor padding contributions. A coordinating outer Pager can +subtract this metadata once when drawing the native indicator above shared +headers. This metadata does not change refresh trigger distance, callback, +refreshing state, ordinary-list placement or row templates. It introduces no +JavaScript API or dependency on Pager classes. The value follows changes to the +applied inset and remains valid while that inset is retained across detach. + ## Native rich activity presentation Status: Source implemented; native runtime acceptance pending. Optional `amounts` diff --git a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt index 4e0e63bb8..5c8449241 100644 --- a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt +++ b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt @@ -1,6 +1,8 @@ package com.reactnativepagerview import android.content.Context +import android.graphics.Canvas +import android.graphics.Matrix import android.graphics.Rect import android.os.SystemClock import android.view.MotionEvent @@ -19,11 +21,11 @@ import androidx.core.view.ViewCompat import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.CircularProgressDrawable +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.viewpager2.widget.ViewPager2 import com.facebook.react.R as ReactR import com.facebook.react.uimanager.events.NativeGestureUtil import com.facebook.react.views.scroll.ReactScrollView -import com.facebook.react.views.swiperefresh.ReactSwipeRefreshLayout import com.margelo.nitro.nativelogger.OneKeyLog import java.lang.ref.WeakReference import java.util.WeakHashMap @@ -120,7 +122,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne private val restoredRecyclerKeys = WeakHashMap() private val originalScrollerPadding = WeakHashMap() private val restoredScrollerKeys = WeakHashMap() - private val scrollerRefreshIndicators = WeakHashMap>() + private var foregroundRefreshIndicator: WeakReference? = null + private var refreshForegroundWasVisible = false + private val refreshForegroundMatrix = Matrix() + private val refreshForegroundOrigin = FloatArray(2) + private val refreshForegroundAncestors = ArrayList() + private var refreshForegroundAncestorAlpha = 1f private val pageOffsets = HashMap() private var observedRecyclerView: RecyclerView? = null private var observedScrollListener: RecyclerView.OnScrollListener? = null @@ -139,6 +146,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne private var headerDownEvent: MotionEvent? = null private var headerHasHorizontalChild = false private var forwardedScrollable: View? = null + private var pendingRefreshPull: WeakReference? = null private var nativeGestureStarted = false private var pressCancelled = false private val pageContentLayoutListener = ViewTreeObserver.OnPreDrawListener { @@ -314,6 +322,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne } fun removeReactChild(child: View) { + releaseRefreshForeground() val index = logicalChildren.indexOf(child) if (index < 0) return logicalChildren.removeAt(index) @@ -339,6 +348,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne } fun removeAllReactChildren() { + releaseRefreshForeground() detachPrimaryScrollObserver() for (recycler in originalRecyclerPadding.keys.toList()) { restoreRecyclerInsets(recycler) @@ -361,8 +371,10 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne fun reactChildAt(index: Int) = logicalChildren[index] fun selectPage(position: Int) { + pendingRefreshPull = null if (adapter.itemCount == 0) return detachPrimaryScrollObserver() + releaseRefreshForeground() selectedPage = position.coerceIn(0, adapter.itemCount - 1) val savedOffset = pageOffsets[currentPageKey()] ?: primaryScrollableForPage(selectedPage)?.let(::verticalScrollOffset) @@ -609,12 +621,6 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne } private fun prepareAdjacentPages() { - // Retained pages and replaced controls may leave the attached view tree. - for ((scroller, indicator) in scrollerRefreshIndicators.toMap()) { - if (nativeScrollerRefreshIndicator(scroller) !== indicator.get()) { - restoreNativeScrollerRefreshIndicator(scroller) - } - } val first = max(0, selectedPage - 1) val last = min(adapter.itemCount - 1, selectedPage + 1) for (index in first..last) { @@ -623,6 +629,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne is ScrollView -> applyNativeScrollerInsets(scrollable, index) } } + updateRefreshForeground() } private fun attachPrimaryScrollObserver() { @@ -805,7 +812,6 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne updateScrollerPaddingOwnership(scroller, original) val topInset = headerHeightPx + stickyHeaderHeightPx scroller.clipToPadding = false - applyNativeScrollerRefreshIndicator(scroller, topInset) if (scroller is ReactScrollView) { // Fabric owns the content child's layout, so ScrollView padding alone // cannot move it. RN's native ScrollAway API also updates Fabric culling. @@ -908,7 +914,6 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne } private fun restoreNativeScrollerInsets(scroller: ScrollView) { - restoreNativeScrollerRefreshIndicator(scroller) val original = originalScrollerPadding.remove(scroller) ?: return updateScrollerPaddingOwnership(scroller, original) restoredScrollerKeys.remove(scroller) @@ -923,12 +928,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne scroller.setPadding(original.left, original.top, original.right, original.bottom) } - private fun nativeScrollerRefreshIndicator(scroller: ScrollView): View? { - if (!nativeSmoothHeaderScrollEnabled) return null - var ancestor = scroller.parent - while (ancestor != null && ancestor !== this) ancestor = ancestor.parent - if (ancestor !== this) return null - val refresh = scroller.parent as? ReactSwipeRefreshLayout ?: return null + private fun refreshLayoutFor(scrollable: View): SwipeRefreshLayout? { + if (scrollable !is ScrollView && scrollable !is RecyclerView) return null + var ancestor = scrollable.parent + while (ancestor != null && ancestor !is SwipeRefreshLayout) ancestor = ancestor.parent + return ancestor as? SwipeRefreshLayout + } + + private fun refreshIndicatorFor(refresh: SwipeRefreshLayout): ImageView? { for (index in 0 until refresh.childCount) { val child = refresh.getChildAt(index) if (child is ImageView && child.drawable is CircularProgressDrawable) return child @@ -936,29 +943,119 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne return null } - private fun applyNativeScrollerRefreshIndicator(scroller: ScrollView, inset: Int) { - val indicator = nativeScrollerRefreshIndicator(scroller) - if (scrollerRefreshIndicators[scroller]?.get() !== indicator) { - restoreNativeScrollerRefreshIndicator(scroller) + private fun refreshForegroundOwner(scrollable: View): CollapsiblePagerHost? { + var owner: CollapsiblePagerHost? = null + var ancestor = scrollable.parent + while (ancestor != null) { + if (ancestor is CollapsiblePagerHost && ancestor.nativeSmoothHeaderScrollEnabled && + ancestor.isAttachedToWindow && ancestor.isShown && + ancestor.primaryScrollableForPage(ancestor.selectedPage) === scrollable) { + owner = ancestor + } + ancestor = ancestor.parent + } + return owner + } + + private fun updateRefreshForeground() { + val scrollable = primaryScrollableForPage(selectedPage) + val refresh = scrollable?.takeIf { refreshForegroundOwner(it) === this }?.let(::refreshLayoutFor) + val indicator = refresh?.let(::refreshIndicatorFor) + if (foregroundRefreshIndicator?.get() !== indicator) releaseRefreshForeground() + if (indicator != null) { + if (foregroundRefreshIndicator?.get() !== indicator) { + foregroundRefreshIndicator = WeakReference(indicator) + } + val state = refreshIndicatorAlphas.getOrPut(indicator) { + NativeRefreshForegroundAlpha(indicator.alpha) + } + val alpha = state.update(indicator.alpha, this, acquire = true) + if (indicator.alpha != alpha) indicator.alpha = alpha } - if (indicator == null) return - if (scrollerRefreshIndicators[scroller]?.get() !== indicator) { - scrollerRefreshIndicators[scroller] = WeakReference(indicator) + val visible = indicator?.isShown == true && + (refreshIndicatorAlphas[indicator]?.drawingAlpha ?: 0f) > 0f + // The foreground lies outside the original child's invalidation rectangle. + // Repaint once on disappearance as well as throughout the native animation. + if (visible || refreshForegroundWasVisible) postInvalidateOnAnimation() + refreshForegroundWasVisible = visible + } + + private fun releaseRefreshForeground() { + val indicator = foregroundRefreshIndicator?.get() + foregroundRefreshIndicator = null + if (indicator != null) { + refreshIndicatorAlphas[indicator]?.let { state -> + indicator.alpha = state.update(indicator.alpha, this, acquire = false) + if (state.isEmpty) refreshIndicatorAlphas.remove(indicator) + } } - val translation = refreshIndicatorTranslations.getOrPut(indicator) { - NativeScrollerRefreshTranslation(indicator.translationY) + if (indicator != null || refreshForegroundWasVisible) invalidate() + refreshForegroundWasVisible = false + } + + private fun refreshMatrixInHost(refresh: View): Boolean { + refreshForegroundMatrix.reset() + refreshForegroundAncestorAlpha = 1f + refreshForegroundAncestors.clear() + try { + var current: View? = refresh + while (current != null && current !== this) { + refreshForegroundAncestors.add(current) + current = current.parent as? View + } + if (current !== this) return false + for (index in refreshForegroundAncestors.lastIndex downTo 0) { + val child = refreshForegroundAncestors[index] + val parent = child.parent as? View ?: return false + refreshForegroundMatrix.preTranslate( + (child.left - parent.scrollX).toFloat(), (child.top - parent.scrollY).toFloat(), + ) + refreshForegroundMatrix.preConcat(child.matrix) + refreshForegroundAncestorAlpha *= child.alpha + } + return true + } finally { + refreshForegroundAncestors.clear() } - // Moving only the drawing preserves RN's offsets and AndroidX's active pull/refresh animation. - // setProgressViewOffset would reset that animation and clear isRefreshing. - val next = translation.update(indicator.translationY, this, inset.toFloat()) - if (indicator.translationY != next) indicator.translationY = next } - private fun restoreNativeScrollerRefreshIndicator(scroller: ScrollView) { - val indicator = scrollerRefreshIndicators.remove(scroller)?.get() ?: return - val translation = refreshIndicatorTranslations[indicator] ?: return - indicator.translationY = translation.update(indicator.translationY, this, null) - if (translation.isEmpty) refreshIndicatorTranslations.remove(indicator) + override fun dispatchDraw(canvas: Canvas) { + // The original parent advances native refresh animations and their listeners. + super.dispatchDraw(canvas) + val indicator = foregroundRefreshIndicator?.get() ?: return + val scrollable = primaryScrollableForPage(selectedPage) ?: return + if (refreshForegroundOwner(scrollable) !== this) return + val refresh = refreshLayoutFor(scrollable) ?: return + if (refreshIndicatorFor(refresh) !== indicator || !indicator.isShown || + indicator.width <= 0 || indicator.height <= 0 || !refreshMatrixInHost(refresh)) return + val alpha = (refreshIndicatorAlphas[indicator]?.drawingAlpha ?: return) * + refreshForegroundAncestorAlpha + if (alpha <= 0f) return + refreshForegroundOrigin[0] = 0f + refreshForegroundOrigin[1] = 0f + refreshForegroundMatrix.mapPoints(refreshForegroundOrigin) + val appliedInset = (refresh.getTag(R.id.onekey_native_scroll_coordinator_refresh_inset) as? Number) + ?.toFloat() ?: 0f + val save = canvas.save() + try { + // Pin the refresh container's origin to this Host top while retaining its + // horizontal page position, native scale/pivot, and caller translation. + canvas.translate(0f, -refreshForegroundOrigin[1]) + canvas.concat(refreshForegroundMatrix) + canvas.translate(indicator.left.toFloat(), nativeRefreshForegroundTop(indicator.top, appliedInset)) + canvas.concat(indicator.matrix) + if (alpha < 1f) { + canvas.saveLayerAlpha( + 0f, 0f, indicator.width.toFloat(), indicator.height.toFloat(), + (alpha * 255f).roundToInt().coerceIn(0, 255), + ) + } + // Direct draw deliberately bypasses the alpha used to hide the original + // RenderNode. Do not advance Animation.getTransformation a second time. + indicator.draw(canvas) + } finally { + canvas.restoreToCount(save) + } } private fun headerRegionAt(y: Float): String? { @@ -1185,10 +1282,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne axes and ViewCompat.SCROLL_AXIS_VERTICAL != 0 && isCurrentPageTarget(target) override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) { + if (type == ViewCompat.TYPE_TOUCH) pendingRefreshPull = null nestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type) } override fun onStopNestedScroll(target: View, type: Int) { + if (pendingRefreshPull?.get() === target) pendingRefreshPull = null nestedScrollingParentHelper.onStopNestedScroll(target, type) onHeaderOffsetChanged?.invoke() } @@ -1219,10 +1318,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne type: Int, consumed: IntArray, ) { - if (!isCurrentPageTarget(target) || dyUnconsumed >= 0 || headerOffsetPx <= 0) return + if (!isCurrentPageTarget(target) || dyUnconsumed >= 0) return val used = max(dyUnconsumed, -headerOffsetPx) setHeaderOffset(headerOffsetPx + used) consumed[1] += used + if (type == ViewCompat.TYPE_TOUCH && dyUnconsumed < used && + target is SwipeRefreshLayout && target.isEnabled) { + pendingRefreshPull = WeakReference(target) + } } override fun onNestedScroll( @@ -1244,6 +1347,20 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne ) } + override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean { + val refresh = pendingRefreshPull?.get() + val scrollable = primaryScrollableForPage(selectedPage) + if (nativeSmoothHeaderScrollEnabled && refresh != null && refresh === target && isCurrentPageTarget(target) && + headerOffsetPx == 0 && scrollable != null && !scrollable.canScrollVertically(-1) && + refresh.isEnabled && !refresh.isRefreshing && + refreshIndicatorFor(refresh)?.visibility == View.VISIBLE) { + // A small release reversal must settle the current refresh pull, not fling + // content upward and make its first positive offset collapse the header. + return true + } + return super.onNestedPreFling(target, velocityX, velocityY) + } + override fun getNestedScrollAxes() = nestedScrollingParentHelper.nestedScrollAxes override fun onAttachedToWindow() { @@ -1259,6 +1376,8 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne } override fun onDetachedFromWindow() { + pendingRefreshPull = null + releaseRefreshForeground() attachmentGeneration += 1 headerDownEvent?.recycle() headerDownEvent = null @@ -1283,7 +1402,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne } companion object { - private val refreshIndicatorTranslations = WeakHashMap() + private val refreshIndicatorAlphas = WeakHashMap() const val NATIVE_SCROLLER_ID_PREFIX = "rnc-collapsible-pager-native-scroller:" const val PAGE_SLOT_OFFSET = 2 } diff --git a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt index 53960b951..ef92e0eee 100644 --- a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt +++ b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt @@ -18,20 +18,24 @@ internal fun nativeScrollerPaddingWasResetByScrollAway( ): Boolean = appliedInset > 0 && left == 0 && top == 0 && right == 0 && bottom == scrollAwayTop + scrollAwayBottom -internal class NativeScrollerRefreshTranslation(initialTranslation: Float) { - private val contributions = WeakHashMap() - private var baseline = initialTranslation - private var lastApplied = initialTranslation +// The refresh controller publishes its actual combined ancestor compensation. +internal fun nativeRefreshForegroundTop(indicatorTop: Int, appliedInset: Float): Float = + indicatorTop - appliedInset + +internal class NativeRefreshForegroundAlpha(initialAlpha: Float) { + private val owners = WeakHashMap() + private var baseline = initialAlpha + private var lastApplied = initialAlpha val isEmpty: Boolean - get() = contributions.isEmpty() + get() = owners.isEmpty() + val drawingAlpha: Float + get() = baseline - fun update(currentTranslation: Float, owner: Any, inset: Float?): Float { - // Native refresh layout owns top/scale, while an external translation is - // an absolute baseline rather than another copy of our previous inset. - if (currentTranslation != lastApplied) baseline = currentTranslation - if (inset == null) contributions.remove(owner) else contributions[owner] = inset - lastApplied = baseline + contributions.values.sum() + fun update(currentAlpha: Float, owner: Any, acquire: Boolean): Float { + if (currentAlpha != lastApplied) baseline = currentAlpha + if (acquire) owners[owner] = Unit else owners.remove(owner) + lastApplied = if (owners.isEmpty()) baseline else 0f return lastApplied } } diff --git a/native-views/react-native-pager-view/android/src/main/res/values/native_scroll_coordinator_ids.xml b/native-views/react-native-pager-view/android/src/main/res/values/native_scroll_coordinator_ids.xml index 2f657f1f3..6caab29a6 100644 --- a/native-views/react-native-pager-view/android/src/main/res/values/native_scroll_coordinator_ids.xml +++ b/native-views/react-native-pager-view/android/src/main/res/values/native_scroll_coordinator_ids.xml @@ -1,4 +1,5 @@ + diff --git a/native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt b/native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt index 7a6857423..283aed019 100644 --- a/native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt +++ b/native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt @@ -37,59 +37,39 @@ fun main() { val firstOwner = Any() val secondOwner = Any() - val translation = NativeScrollerRefreshTranslation(7.5f) - check(translation.isEmpty) - var current = translation.update(7.5f, firstOwner, 230f) - check(current == 237.5f) - check(!translation.isEmpty) - current = translation.update(current, firstOwner, 230f) - check(current == 237.5f) - current = translation.update(current, firstOwner, 358f) - check(current == 365.5f) - current = translation.update(current, secondOwner, 64f) - check(current == 429.5f) - current = translation.update(current, firstOwner, null) - check(current == 71.5f) - check(!translation.isEmpty) - current = translation.update(current, secondOwner, null) - check(current == 7.5f) - check(translation.isEmpty) + val alpha = NativeRefreshForegroundAlpha(0.75f) + check(alpha.isEmpty) + var current = alpha.update(0.75f, firstOwner, acquire = true) + check(current == 0f && alpha.drawingAlpha == 0.75f && !alpha.isEmpty) + current = alpha.update(current, firstOwner, acquire = true) + check(current == 0f && alpha.drawingAlpha == 0.75f) + current = alpha.update(current, secondOwner, acquire = true) + current = alpha.update(current, firstOwner, acquire = false) + check(current == 0f && !alpha.isEmpty) + current = alpha.update(current, secondOwner, acquire = false) + check(current == 0.75f && alpha.isEmpty) - // Reversing release order must restore the same original translation. - current = translation.update(current, firstOwner, 230f) - current = translation.update(current, secondOwner, 64f) - current = translation.update(current, secondOwner, null) - check(current == 237.5f) - current = translation.update(current, firstOwner, null) - check(current == 7.5f) - check(translation.isEmpty) + current = alpha.update(current, firstOwner, acquire = true) + current = alpha.update(current, secondOwner, acquire = true) + current = alpha.update(current, secondOwner, acquire = false) + check(current == 0f) + current = alpha.update(current, firstOwner, acquire = false) + check(current == 0.75f && alpha.isEmpty) - // A caller's absolute translation update replaces the baseline, not its inset. - current = translation.update(current, firstOwner, 230f) - check(current == 237.5f) - current = translation.update(-12.5f, firstOwner, 230f) - check(current == 217.5f) - current = translation.update(current, secondOwner, 64f) - check(current == 281.5f) - current = translation.update(current, firstOwner, null) - check(current == 51.5f) - current = translation.update(current, secondOwner, null) - check(current == -12.5f) - check(translation.isEmpty) + current = alpha.update(current, firstOwner, acquire = true) + current = alpha.update(0.4f, firstOwner, acquire = true) + check(current == 0f && alpha.drawingAlpha == 0.4f) + current = alpha.update(current, firstOwner, acquire = false) + check(current == 0.4f && alpha.isEmpty) - // Zero is a real owned contribution; only null releases ownership. - current = translation.update(current, firstOwner, 0f) - check(current == -12.5f) - check(!translation.isEmpty) - current = translation.update(current, secondOwner, 0f) - current = translation.update(current, firstOwner, null) - check(current == -12.5f) - check(!translation.isEmpty) - current = translation.update(current, secondOwner, null) - check(current == -12.5f) - check(translation.isEmpty) - current = translation.update(9f, firstOwner, null) - check(current == 9f) - check(translation.isEmpty) - println("NativeScroller refresh translation: repeat, resize, nested release orders, external baseline, zero contribution, and final restoration passed") + val transparent = NativeRefreshForegroundAlpha(0f) + check(transparent.update(0f, firstOwner, acquire = true) == 0f) + check(!transparent.isEmpty && transparent.drawingAlpha == 0f) + check(transparent.update(0f, firstOwner, acquire = false) == 0f && transparent.isEmpty) + // RN caller offset is already in top; an absent coordinator inset is zero. + check(nativeRefreshForegroundTop(37, 0f) == 37f) + // A list publishes the sum of both actual ancestor insets; subtract it once. + check(nativeRefreshForegroundTop(37 + 230 + 64, 294f) == 37f) + check(nativeRefreshForegroundTop(-40 + 294, 294f) == -40f) + println("Refresh foreground policy: repeat, owner transfer/release, external alpha, transparent baseline, caller offset, and combined nested inset passed") } diff --git a/native-views/react-native-pager-view/docs/SPEC.md b/native-views/react-native-pager-view/docs/SPEC.md index 3934e2cd9..fdc5e18d6 100644 --- a/native-views/react-native-pager-view/docs/SPEC.md +++ b/native-views/react-native-pager-view/docs/SPEC.md @@ -3,39 +3,73 @@ Existing PagerView and CollapsiblePagerView APIs are documented in README.md. The sections below define native coordination boundaries without new React props. -## Android NativeScroller refresh indicator placement - -Status: implemented; Android API 36 Home pull-gesture visibility verified on -2026-09-28. Spot, Perps and DeFi (RN NativeScroller), plus the unchanged NFT and -History (NativeList), each pass header-origin and content-origin pulls. Ten -recordings show a visible indicator below the shared tabs, one refresh event and -normal disappearance. This verifies UI feedback and event dispatch, not -downstream request completion. Dynamic caller offset, refresh-time header resize -and nested-pager replacement remain separate device acceptance work. -In smooth-header mode, each pager contributes its header-plus-sticky height in -physical pixels to the RN NativeScroller refresh indicator's `translationY`. -The coordinator identifies only the refresh layout's direct indicator child; -it MUST NOT translate the content ScrollView or change refresh offsets, bounds, -scale, animation, visibility, trigger distance, refreshing state or callbacks. -RN retains ownership of `progressViewOffset`, including dynamic caller updates. -NativeList keeps its existing independent refresh-indicator positioning. - -One state per indicator combines weak owner contributions on the UI thread. -Repeated application and header resizing replace only the calling owner's -contribution. An external translation different from the last applied value -becomes the new baseline. Removing an owner preserves the remaining contributions; -removing the final owner restores the latest baseline. A zero contribution still -has an owner until explicitly released. State MUST NOT strongly retain an owner -or the indicator. Late mounting, indicator replacement and scroller/pager detach -use the existing native layout lifecycle; there is no JavaScript frame callback. - -The standalone JVM policy test covers nonzero baselines, repeated application, -resize, nested owners removed in either order, external translation updates, -zero contributions and final restoration. It does not prove Android view -discovery, actual refresh state or rendered placement. Runtime acceptance requires -header-origin and content-origin pulls on NativeScroller pages, caller offset -changes, refresh-time layout changes, replacement/detach, and visible disappearance -after completion; existing NativeList pages must remain unchanged. +## Android refresh foreground coordination + +Status: implemented; Android Home simulator placement verified on 2026-09-28. +The previous below-tab placement is not accepted: the Home header can place the +indicator in the middle of asset rows. In smooth-header mode the outermost +coordinator MUST draw the active primary scroller's native refresh indicator +above the shared headers at the coordinator's top. SwipeRefreshLayout retains +trigger, animation, native visibility and refresh-state ownership. The indicator +MUST NOT be reparented, and the coordinator MUST NOT reset progress offsets. + +NativeScroller and NativeList use the same foreground drawing boundary. A native +View tag, `onekey_native_scroll_coordinator_refresh_inset`, reports only the +refresh controller's actual coordinator-applied inset. An absent tag means zero. +The outer drawer subtracts that inset once, preserving caller geometry and the +native indicator matrix. This is native drawing metadata, with no JS API or row +semantics. Nested owners MUST NOT draw duplicate indicators. + +The coordinator suppresses only the indicator's original drawing and restores +its prior alpha when ownership ends, the target changes, or the pager detaches. +An inactive page MUST NOT appear in the foreground. The foreground must clear +when the indicator disappears. Native animation transformations MUST NOT be +advanced a second time during drawing. Focused policy checks and device evidence +must cover alpha restoration, nested ownership, page changes and dismissal. +All five Home tabs require top-positioned feedback and stable header position +through pull, release and refresh completion. Source/compile success is not +runtime acceptance. Existing iOS/Web behavior is unchanged. + +The five Home tabs were recorded from both header and content origins: the +foreground arc appears at the coordinator top and dismisses normally. The fast +release reversal that previously changed header offset from 0 to 767 px now +consumes the pending pull fling and keeps the header expanded. These recordings +verify visual feedback, not downstream network completion or new callback counts. +Production-helper tests cover alpha restoration and combined native insets; +real nested-owner replacement, dynamic caller geometry and detach remain separate +acceptance work. Direct foreground View drawing preserves the native circle and +arc but does not reproduce the original RenderNode elevation shadow. + +## Android refresh release settlement + +Status: implemented; the captured release reversal passes a device A/B check. +A downward touch gesture that leaves unconsumed travel for the active +SwipeRefreshLayout is a pending native refresh pull. While that same gesture's +indicator is visible, the shared header is expanded, and primary content remains +at the top, the coordinator MUST consume nested pre-fling on release. A small +upward reversal before lifting must settle the pull instead of starting a +content fling and abruptly collapsing the header. SwipeRefreshLayout still owns +whether the release triggers or cancels refresh. + +The pending target is weak, scoped to the current touch nested-scroll gesture, +and cleared at its stop or replacement. An old indicator's exit animation alone +is not a pending pull. Ordinary upward swipes, content scrolled away from the +top, inactive pages and non-smooth mode retain existing fling behavior. A pull +whose indicator is already hidden no longer qualifies. AndroidX can leave the +indicator visible when reverse travel exactly exhausts the pull; that same +gesture may still be settled instead of flung. Required regression cases include immediate reversal release, +a stationary release, below-threshold cancellation, ordinary upward fling and +header expansion before refresh. No JS timeout or data-refresh suppression is +introduced. + +## Superseded Android indicator placement + +The alpha.257 approach translated the RN indicator below the shared tabs. +Its ten Home checks established visibility and event dispatch only. Subsequent +2026-09-28 feedback showed the indicator over asset rows; those checks do not +establish correct placement. The foreground coordination contract above replaces +that translation policy. NativeList keeps its controller offset internally and +publishes only its actual native inset for foreground drawing. ## iOS refresh indicator placement From 5c6d3406a607c5b18f9c0072223731db4a7ab863 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 28 Sep 2026 20:44:11 +0800 Subject: [PATCH 11/16] feat: own native scroller engines and pager gesture coordination --- .../CollapsiblePagerView.web.test.tsx | 49 +- .../react-native-pager-view/README.md | 55 +- .../CollapsiblePagerHost.kt | 91 ++- .../NativeScrollerView.kt | 573 ++++++++++++++++++ .../NativeScrollerViewManager.kt | 130 ++++ .../PagerViewViewPackage.kt | 2 +- .../android/src/main/jni/CMakeLists.txt | 19 + .../android/src/main/jni/pagerview.h | 11 + .../RNCNativeScrollerComponentDescriptor.h | 8 + .../pagerview/RNCNativeScrollerShadowNode.cpp | 15 + .../pagerview/RNCNativeScrollerShadowNode.h | 23 + .../pagerview/RNCNativeScrollerState.h | 33 + .../react-native-pager-view/docs/SPEC.md | 192 ++++++ .../RNCCollapsiblePagerViewComponentView.mm | 188 +++++- .../ios/RNCNativeScrollerComponentView.h | 17 + .../ios/RNCNativeScrollerComponentView.mm | 479 +++++++++++++++ ...CCollapsiblePagerPressCancellationTests.mm | 346 +++++++++++ ...psiblePagerPressCancellationWiringTests.mm | 109 ++++ .../ios/tests/RNCNativeScrollerTests.mm | 252 ++++++++ .../react-native-pager-view/package.json | 7 +- .../react-native-pager-view.podspec | 3 +- .../react-native.config.js | 15 + .../src/CollapsiblePagerView.tsx | 8 +- .../src/NativeScroller.tsx | 305 +++++++++- .../src/NativeScrollerNativeComponent.ts | 101 +++ .../src/__tests__/NativeScroller.test.tsx | 285 +++++++++ 26 files changed, 3257 insertions(+), 59 deletions(-) create mode 100644 native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerView.kt create mode 100644 native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerViewManager.kt create mode 100644 native-views/react-native-pager-view/android/src/main/jni/CMakeLists.txt create mode 100644 native-views/react-native-pager-view/android/src/main/jni/pagerview.h create mode 100644 native-views/react-native-pager-view/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerComponentDescriptor.h create mode 100644 native-views/react-native-pager-view/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.cpp create mode 100644 native-views/react-native-pager-view/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.h create mode 100644 native-views/react-native-pager-view/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerState.h create mode 100644 native-views/react-native-pager-view/ios/RNCNativeScrollerComponentView.h create mode 100644 native-views/react-native-pager-view/ios/RNCNativeScrollerComponentView.mm create mode 100644 native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm create mode 100644 native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerPressCancellationWiringTests.mm create mode 100644 native-views/react-native-pager-view/ios/tests/RNCNativeScrollerTests.mm create mode 100644 native-views/react-native-pager-view/react-native.config.js create mode 100644 native-views/react-native-pager-view/src/NativeScrollerNativeComponent.ts create mode 100644 native-views/react-native-pager-view/src/__tests__/NativeScroller.test.tsx diff --git a/example/react-native/__tests__/CollapsiblePagerView.web.test.tsx b/example/react-native/__tests__/CollapsiblePagerView.web.test.tsx index 5a215a67a..88dbc69ba 100644 --- a/example/react-native/__tests__/CollapsiblePagerView.web.test.tsx +++ b/example/react-native/__tests__/CollapsiblePagerView.web.test.tsx @@ -109,6 +109,7 @@ describe('CollapsiblePagerView native wrapper', () => { const pressTab = async ( animationEnabled: boolean | undefined, position: number, + initialPage = 0, ) => { let renderer!: ReactTestRenderer.ReactTestRenderer; await ReactTestRenderer.act(() => { @@ -116,6 +117,7 @@ describe('CollapsiblePagerView native wrapper', () => { {pages(nativeTabBar.items.length)} @@ -141,30 +143,29 @@ describe('CollapsiblePagerView native wrapper', () => { }); }; - it('animates the page change by default', async () => { - await pressTab(undefined, 3); - - expect(NativeCollapsiblePagerCommands.setPage).toHaveBeenCalledTimes(1); - expect(NativeCollapsiblePagerCommands.setPage).toHaveBeenCalledWith( - expect.anything(), - 3, - ); - expect( - NativeCollapsiblePagerCommands.setPageWithoutAnimation, - ).not.toHaveBeenCalled(); - }); - - it('jumps straight to the pressed page when animation is disabled', async () => { - await pressTab(false, 3); - - expect( - NativeCollapsiblePagerCommands.setPageWithoutAnimation, - ).toHaveBeenCalledTimes(1); - expect( - NativeCollapsiblePagerCommands.setPageWithoutAnimation, - ).toHaveBeenCalledWith(expect.anything(), 3); - expect(NativeCollapsiblePagerCommands.setPage).not.toHaveBeenCalled(); - }); + it.each([ + [undefined, 0, 1, true], + [undefined, 0, 3, false], + [undefined, 3, 2, true], + [undefined, 3, 0, false], + [true, 0, 3, true], + [false, 0, 1, false], + [false, 0, 3, false], + ] as const)( + 'uses animation=%s from %s to %s with animated command=%s', + async (animationEnabled, initialPage, target, animated) => { + await pressTab(animationEnabled, target, initialPage); + const command = animated + ? NativeCollapsiblePagerCommands.setPage + : NativeCollapsiblePagerCommands.setPageWithoutAnimation; + const otherCommand = animated + ? NativeCollapsiblePagerCommands.setPageWithoutAnimation + : NativeCollapsiblePagerCommands.setPage; + expect(command).toHaveBeenCalledTimes(1); + expect(command).toHaveBeenCalledWith(expect.anything(), target); + expect(otherCommand).not.toHaveBeenCalled(); + }, + ); }); }); diff --git a/native-views/react-native-pager-view/README.md b/native-views/react-native-pager-view/README.md index bc0165929..956e83a72 100644 --- a/native-views/react-native-pager-view/README.md +++ b/native-views/react-native-pager-view/README.md @@ -68,12 +68,59 @@ Web scroll child can opt in by exposing a DOM element with movement, so React is not updated on each vertical scroll frame. On iOS and Android, `nativeTabBar` renders the tab bar natively and -`onNativeTabPress` reports presses. A press animates to the pressed page by -default. Set `nativeTabPressAnimationEnabled={false}` to jump straight there, -so pressing a distant tab does not scroll through every page in between, -including unmounted pages that would show blank. +`onNativeTabPress` reports presses. By default, adjacent tab presses animate and +non-adjacent presses jump directly to the target without passing intermediate +pages. Set `nativeTabPressAnimationEnabled={true}` to always animate, or `false` +to always jump. Swipe gestures and imperative page commands are unchanged. Thank you again to Callstack and everyone who contributes to `react-native-pager-view` 💙 The native ancestor-scroll contribution protocol is specified in [docs/SPEC.md](docs/SPEC.md). + +## NativeScroller compatibility + +The independent iOS/Android `NativeScroller` has passing native builds and +focused simulator checks. The complete acceptance matrix remains open; see +[docs/SPEC.md](docs/SPEC.md) for the verified cases and limits. It is a +non-virtualized vertical scroller with one internal React content View. Fabric/Yoga lays out React children and content padding; size +changes are sent to native through layout props, not a JavaScript scroll loop. +Shared Fabric native state tracks local scroll displacement and ancestor header +travel separately, without per-frame JavaScript coordination. Descendant hit +testing and `measureInWindow` use that state; device acceptance must compare the +reported coordinates with visible React content after scrolling and page return. +The automatic flexGrow minimum uses a native-reported usable content height, +with static pager reservations accounted for once. This internal size event is +not public API and does not run on scroll frames. Before it arrives the automatic +minimum is zero; a larger explicit numeric minHeight remains authoritative. +Refresh insets and content-dependent bottom fill do not change this minimum. +Web continues using its existing ScrollView implementation. + +The supported ScrollView subset is `contentContainerStyle` (including padding, +height, numeric minHeight and flexGrow), `onContentSizeChange`, `scrollEnabled`, +`nestedScrollEnabled`, indicators, `bounces`, `alwaysBounceVertical`, +`contentInset`, keyboard dismissal/persist-tap modes, `scrollEventThrottle`, and +the scroll/drag/momentum callbacks. Standard View props remain available. +`decelerationRate` applies on iOS; Android uses AndroidX's native fling physics. +`bounces`/`alwaysBounceVertical` and refresh tint/title are iOS controls; Android +uses refresh colors/background/size and does not expose a bounce policy here. +Tap-to-dismiss uses RN 0.86's JavaScript TextInput registry to preserve taps on +another input and child-handled presses; keyboard behavior requires device checks. +Horizontal scrolling, sticky rows, snapping, zoom, automatic keyboard insets and +the full ScrollView responder API are not provided. + +Pass a standard `` as +`refreshControl`. Its enabled, tint/colors, background, offset, title/title color +and size props map to the component's native refresh controller; the RN refresh +element is not mounted. Refreshing remains controlled even if the callback does +not change the value. Arbitrary custom refresh components are not supported. + +`NativeScrollerRef` exposes `scrollTo`, `scrollToEnd`, `flashScrollIndicators`, +host measurement/focus methods, `getNativeScrollRef` and `getScrollableNode`. +`pagerScrollKey` remains the stable primary-scroller identity. Native command +events use the ScrollView event shape; UIKit/AndroidX continue owning gestures +and momentum. Animated commands whose clamped target differs from the current +position emit a momentum begin/end pair; a no-op emits no new pair. A new command, +new touch, content-root replacement or detach ends the previous active sequence. +The focused JS tests verify wrapper routing, not native scrolling, +Yoga rendering, keyboard interaction or refresh placement on a device. diff --git a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt index 5c8449241..a97115f64 100644 --- a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt +++ b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt @@ -122,6 +122,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne private val restoredRecyclerKeys = WeakHashMap() private val originalScrollerPadding = WeakHashMap() private val restoredScrollerKeys = WeakHashMap() + private val ownedScrollerKeys = WeakHashMap() private var foregroundRefreshIndicator: WeakReference? = null private var refreshForegroundWasVisible = false private val refreshForegroundMatrix = Matrix() @@ -131,6 +132,9 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne private val pageOffsets = HashMap() private var observedRecyclerView: RecyclerView? = null private var observedScrollListener: RecyclerView.OnScrollListener? = null + private var observedOwnedScroller: NativeScrollerViewport? = null + private var observedOwnedScrollerKey: String? = null + private var observedOwnedScrollerListener: ViewTreeObserver.OnScrollChangedListener? = null private var observedNativeScroller: ScrollView? = null private var observedNativeScrollerListener: ViewTreeObserver.OnScrollChangedListener? = null private var attachmentGeneration = 0 @@ -340,6 +344,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne findExplicitNativeScrollerInView(child) ?: findVerticalScrollableView(child)) { is RecyclerView -> restoreRecyclerInsets(scrollable) is ScrollView -> restoreNativeScrollerInsets(scrollable) + is NativeScrollerViewport -> restoreOwnedScrollerInsets(scrollable) } adapter.removePage(child) log("page-detach slot=${index - PAGE_SLOT_OFFSET} nativePages=${adapter.itemCount}") @@ -356,6 +361,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne for (scroller in originalScrollerPadding.keys.toList()) { restoreNativeScrollerInsets(scroller) } + for (scroller in ownedScrollerKeys.keys.toList()) restoreOwnedScrollerInsets(scroller) headerView?.let { view -> super.removeView(view) } stickyHeaderView?.let { view -> super.removeView(view) } headerView = null @@ -408,7 +414,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne fun attachedPageCount(): Int = (pager.getChildAt(0) as? RecyclerView)?.childCount ?: 0 fun observedScrollableCount(): Int = - originalRecyclerPadding.size + originalScrollerPadding.size + originalRecyclerPadding.size + originalScrollerPadding.size + ownedScrollerKeys.size private fun layoutPagerIfRequested() { val recycler = pager.getChildAt(0) as? RecyclerView ?: return @@ -505,6 +511,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne private fun maximumHeaderOffset(): Int { val scrollable = observedRecyclerView + ?: observedOwnedScroller ?: observedNativeScroller ?: primaryScrollableForPage(selectedPage) ?: return headerHeightPx @@ -526,7 +533,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne applyHeaderOffset() } - private fun publishConsumedOffset(recycler: RecyclerView, remove: Boolean = false) { + private fun publishConsumedOffset(recycler: View, remove: Boolean = false) { val key = R.id.onekey_native_scroll_coordinator_consumed_offsets val existing = recycler.getTag(key) as? NativeScrollCoordinatorContributions val contributions = existing ?: if (remove) return else NativeScrollCoordinatorContributions().also { @@ -547,6 +554,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne nativeTabBarView.translationY = -offset nativeSubHeaderView.translationY = -offset observedRecyclerView?.let { publishConsumedOffset(it) } + observedOwnedScroller?.let { publishConsumedOffset(it) } } private fun pageKey(index: Int): String = pageKeys.getOrNull(index) ?: "page-$index" @@ -573,6 +581,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne } private fun findExplicitNativeScrollerInView(view: View): View? { + if (view is NativeScrollerView) return view.viewport if (view !== this && view is CollapsiblePagerHost) { return view.currentPrimaryScrollableForParent() } @@ -589,6 +598,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne } private fun findVerticalScrollableView(view: View): View? { + if (view is NativeScrollerView) return view.viewport if (view !== this && view is CollapsiblePagerHost) { return view.currentPrimaryScrollableForParent() } @@ -617,6 +627,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne private fun verticalScrollOffset(view: View): Int = when (view) { is RecyclerView -> view.computeVerticalScrollOffset() is ScrollView -> view.scrollY + is NativeScrollerViewport -> view.scrollY else -> 0 } @@ -627,6 +638,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne when (val scrollable = primaryScrollableForPage(index)) { is RecyclerView -> applyRecyclerInsets(scrollable, index) is ScrollView -> applyNativeScrollerInsets(scrollable, index) + is NativeScrollerViewport -> applyOwnedScrollerInsets(scrollable, index) } } updateRefreshForeground() @@ -634,7 +646,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne private fun attachPrimaryScrollObserver() { if (selectedPage !in 0 until adapter.itemCount) return - val previous = observedRecyclerView ?: observedNativeScroller + val previous = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller if (previous != null && !isDescendant(previous, adapter.pageAt(selectedPage))) { detachPrimaryScrollObserver() // A replacement scroller starts at the beginning while retaining header collapse. @@ -643,6 +655,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne when (val scrollable = primaryScrollableForPage(selectedPage)) { is RecyclerView -> attachRecyclerObserver(scrollable) is ScrollView -> attachNativeScrollerObserver(scrollable) + is NativeScrollerViewport -> attachOwnedScrollerObserver(scrollable) } } @@ -736,10 +749,63 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne } private fun detachPrimaryScrollObserver() { + detachOwnedScrollerObserver() detachRecyclerObserver() detachNativeScrollerObserver() } + private fun applyOwnedScrollerInsets(scroller: NativeScrollerViewport, pageIndex: Int) { + scroller.host.setPagerInset(this, headerHeightPx + stickyHeaderHeightPx) + publishConsumedOffset(scroller) + val key = pageKey(pageIndex) + if (ownedScrollerKeys[scroller] == key) return + ownedScrollerKeys[scroller] = key + postForCurrentAttachment { + if (ownedScrollerKeys[scroller] == key && scroller.isAttachedToWindow) { + scroller.scrollTo(0, pageOffsets[key] ?: 0) + } + } + } + + private fun restoreOwnedScrollerInsets(scroller: NativeScrollerViewport) { + scroller.stopMotion() + ownedScrollerKeys.remove(scroller) + publishConsumedOffset(scroller, remove = true) + scroller.host.setPagerInset(this, null) + } + + private fun attachOwnedScrollerObserver(scroller: NativeScrollerViewport) { + if (observedOwnedScroller === scroller && observedOwnedScrollerKey == currentPageKey()) { + applyOwnedScrollerInsets(scroller, selectedPage) + return + } + detachPrimaryScrollObserver() + observedOwnedScroller = scroller + observedOwnedScrollerKey = currentPageKey() + val key = currentPageKey() + applyOwnedScrollerInsets(scroller, selectedPage) + val listener = ViewTreeObserver.OnScrollChangedListener { + val contentOffset = scroller.scrollY + pageOffsets[key] = contentOffset + if (contentOffset > 0 && headerOffsetPx < headerHeightPx) setHeaderOffset(headerHeightPx) + } + observedOwnedScrollerListener = listener + scroller.viewTreeObserver.addOnScrollChangedListener(listener) + } + + private fun detachOwnedScrollerObserver() { + val scroller = observedOwnedScroller ?: return + scroller.stopMotion() + publishConsumedOffset(scroller, remove = true) + observedOwnedScrollerKey?.let { pageOffsets[it] = scroller.scrollY } + observedOwnedScrollerListener?.let { + if (scroller.viewTreeObserver.isAlive) scroller.viewTreeObserver.removeOnScrollChangedListener(it) + } + observedOwnedScroller = null + observedOwnedScrollerKey = null + observedOwnedScrollerListener = null + } + private fun applyRecyclerInsets(recycler: RecyclerView, pageIndex: Int) { val original = originalRecyclerPadding.getOrPut(recycler) { RecyclerPadding( @@ -929,7 +995,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne } private fun refreshLayoutFor(scrollable: View): SwipeRefreshLayout? { - if (scrollable !is ScrollView && scrollable !is RecyclerView) return null + if (scrollable !is ScrollView && scrollable !is RecyclerView && scrollable !is NativeScrollerViewport) return null var ancestor = scrollable.parent while (ancestor != null && ancestor !is SwipeRefreshLayout) ancestor = ancestor.parent return ancestor as? SwipeRefreshLayout @@ -1302,10 +1368,18 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne val used = min(dy, maximumOffset - headerOffsetPx) setHeaderOffset(headerOffsetPx + used) consumed[1] += used - } else if (dy < 0 && headerOffsetPx > 0 && !target.canScrollVertically(-1)) { - val used = max(dy, -headerOffsetPx) - setHeaderOffset(headerOffsetPx + used) - consumed[1] += used + } else if (dy < 0 && headerOffsetPx > 0) { + // Refresh wrappers forward nested scrolls but do not scroll themselves. + val canScrollUp = if (target is SwipeRefreshLayout) { + target.canChildScrollUp() + } else { + target.canScrollVertically(-1) + } + if (!canScrollUp) { + val used = max(dy, -headerOffsetPx) + setHeaderOffset(headerOffsetPx + used) + consumed[1] += used + } } } @@ -1394,6 +1468,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne for (scroller in originalScrollerPadding.keys.toList()) { restoreNativeScrollerInsets(scroller) } + for (scroller in ownedScrollerKeys.keys.toList()) restoreOwnedScrollerInsets(scroller) log( "host-detach generation=$attachmentGeneration inner=$selectedPage " + "outer=${outerPagerIndex()} nativePages=${adapter.itemCount}", diff --git a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerView.kt b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerView.kt new file mode 100644 index 000000000..4faaa5fe3 --- /dev/null +++ b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerView.kt @@ -0,0 +1,573 @@ +package com.reactnativepagerview + +import android.content.Context +import android.graphics.Color +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.VelocityTracker +import android.view.ViewConfiguration +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.FrameLayout +import androidx.core.view.ViewCompat +import androidx.core.widget.NestedScrollView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.StateWrapper +import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.Event +import com.facebook.react.uimanager.events.NativeGestureUtil +import java.util.WeakHashMap +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.roundToInt + +/** Fabric owns the React child; this host owns only its native viewport and extent. */ +class NativeScrollerView(context: Context) : FrameLayout(context) { + internal val viewport = NativeScrollerViewport(context, this) + private val refresh = NativeScrollerRefreshLayout(context) + private val contentOriginFrame = object : FrameLayout(context) { + override fun onMeasure(widthSpec: Int, heightSpec: Int) { + setMeasuredDimension(MeasureSpec.getSize(widthSpec), MeasureSpec.getSize(heightSpec)) + } + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) = Unit + } + private val contentFrame = object : FrameLayout(context) { + override fun onMeasure(widthSpec: Int, heightSpec: Int) { + setMeasuredDimension( + max(MeasureSpec.getSize(widthSpec), contentWidthPx + insetLeft + insetRight), + contentHeightPx + contentTop + insetBottom, + ) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + // Only the native origin layer moves. Fabric owns its React child's layout/transform. + contentOriginFrame.measure( + MeasureSpec.makeMeasureSpec(contentWidthPx, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(contentHeightPx, MeasureSpec.EXACTLY), + ) + contentOriginFrame.layout(insetLeft, contentTop, insetLeft + contentWidthPx, contentTop + contentHeightPx) + } + } + private val pagerInsets = WeakHashMap() + private var reactChild: View? = null + private var fabricState: StateWrapper? = null + private var lastOriginX: Int? = null + private var lastOriginY: Int? = null + private var lastAncestorY: Int? = null + private var lastContentViewportHeight: Int? = null + private val consumedOffsetListener = Runnable { publishContentOrigin() } + private var lastScrollEvent = 0L + private var laidOut = false + private var requestedRefresh = false + private var refreshOffset = 0 + private var appliedRefreshOffset: Int? = null + private var appliedRefreshSize = SwipeRefreshLayout.DEFAULT + private var layoutPosted = false + internal var contentWidthPx = 0 + internal var contentHeightPx = 0 + internal var insetTop = 0 + internal var insetRight = 0 + internal var insetBottom = 0 + internal var insetLeft = 0 + internal var scrollEventThrottle = 0.0 + internal var keyboardDismissMode = "none" + internal var keyboardShouldPersistTaps = "never" + internal var refreshEnabled = false + set(value) { + field = value + refresh.isEnabled = value && viewport.scrollEnabled + } + internal val contentTop: Int + get() = insetTop + pagerInsets.values.sum() + + init { + clipChildren = true + contentFrame.clipChildren = false + contentOriginFrame.clipChildren = false + contentFrame.addView(contentOriginFrame) + viewport.isFillViewport = false + viewport.isSmoothScrollingEnabled = false + viewport.clipToPadding = false + viewport.addView(contentFrame, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) + refresh.addView(viewport, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + super.addView(refresh, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + refresh.isEnabled = false + refresh.setColorSchemeColors(Color.GRAY) + refresh.setOnChildScrollUpCallback { _, _ -> viewport.canScrollVertically(-1) } + refresh.setOnRefreshListener { emit("topRefresh", Arguments.createMap()) } + refresh.onTouchStream = { event -> viewport.recordTouchEvent(event) } + refresh.onTouchStreamFinished = { viewport.releaseTouchTracking() } + refresh.onGestureStart = { event -> viewport.beginDrag(event) } + refresh.onGestureEnd = { event -> viewport.endDrag(event) } + } + + internal fun setFabricState(wrapper: StateWrapper?) { + fabricState = wrapper + if (wrapper == null) { + lastOriginX = null + lastOriginY = null + lastAncestorY = null + lastContentViewportHeight = null + } else { + val state = wrapper.stateData + if (state == null || !state.hasKey("contentOriginX") || !state.hasKey("contentOriginY") || + !state.hasKey("ancestorOffsetX") || !state.hasKey("ancestorOffsetY") || + lastOriginX == null || lastOriginY == null || lastAncestorY == null || + abs(state.getDouble("contentOriginX") - px(lastOriginX!!)) > 0.001 || + abs(state.getDouble("contentOriginY") - px(lastOriginY!!)) > 0.001 || + abs(state.getDouble("ancestorOffsetX")) > 0.001 || + abs(state.getDouble("ancestorOffsetY") - px(lastAncestorY!!)) > 0.001) { + lastOriginX = null + lastOriginY = null + lastAncestorY = null + } + publishContentOrigin() + } + } + + internal fun publishContentOrigin() { + val wrapper = fabricState ?: return + val ancestors = viewport.getTag(R.id.onekey_native_scroll_coordinator_consumed_offsets) + as? NativeScrollCoordinatorContributions + val x = insetLeft - viewport.scrollX + val y = contentTop - viewport.scrollY + val ancestorY = -(ancestors?.values?.sum() ?: 0) + if (lastOriginX == x && lastOriginY == y && lastAncestorY == ancestorY) return + lastOriginX = x + lastOriginY = y + lastAncestorY = ancestorY + wrapper.updateState(Arguments.createMap().apply { + putDouble("contentOriginX", px(x)) + putDouble("contentOriginY", px(y)) + putDouble("ancestorOffsetX", 0.0) + putDouble("ancestorOffsetY", px(ancestorY)) + }) + } + + internal fun setContentSize(width: Int? = null, height: Int? = null) { + val nextWidth = width?.coerceAtLeast(0) ?: contentWidthPx + val nextHeight = height?.coerceAtLeast(0) ?: contentHeightPx + if (nextWidth == contentWidthPx && nextHeight == contentHeightPx) return + contentWidthPx = nextWidth + contentHeightPx = nextHeight + scheduleLayout() + } + + internal fun setPagerInset(owner: View, inset: Int?) { + val next = inset?.coerceAtLeast(0) + if (pagerInsets[owner] == next) return + if (next == null) pagerInsets.remove(owner) else pagerInsets[owner] = next + scheduleLayout() + } + + internal fun addReactChild(child: View, index: Int) { + require(index == 0 && reactChild == null) { "NativeScroller requires one React content view" } + viewport.stopMotion() + reactChild = child + contentOriginFrame.addView(child) + scheduleLayout() + } + + internal fun removeReactChild() { + viewport.stopMotion() + reactChild?.let(contentOriginFrame::removeView) + reactChild = null + contentWidthPx = 0 + contentHeightPx = 0 + scheduleLayout() + } + + internal fun reactChildCount() = if (reactChild == null) 0 else 1 + internal fun reactChildAt(index: Int): View { + require(index == 0) + return requireNotNull(reactChild) + } + + internal fun scheduleLayout() { + if (layoutPosted) return + layoutPosted = true + post { + layoutPosted = false + if (isAttachedToWindow && width > 0 && height > 0) layoutContents() + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec)) + refresh.measure( + MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY), + ) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + layoutContents() + } + + private fun layoutContents() { + val x = viewport.scrollX + val y = viewport.scrollY + contentFrame.forceLayout() + viewport.forceLayout() + refresh.forceLayout() + refresh.measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), + ) + refresh.layout(0, 0, width, height) + // Reapply the current anchor after content shrink; NestedScrollView clamps it. + viewport.scrollTo(x, y) + laidOut = true + publishContentViewport() + publishContentOrigin() + applyRefreshOffset() + if (refresh.isRefreshing != requestedRefresh) refresh.isRefreshing = requestedRefresh + } + + internal fun setRefreshing(value: Boolean) { + requestedRefresh = value + if (laidOut) refresh.isRefreshing = value + } + + internal fun setRefreshOffset(value: Int) { + refreshOffset = value + applyRefreshOffset() + } + + internal fun setRefreshSize(value: String?) { + val size = if (value == "large") SwipeRefreshLayout.LARGE else SwipeRefreshLayout.DEFAULT + if (appliedRefreshSize == size) return + appliedRefreshSize = size + refresh.setSize(size) + appliedRefreshOffset = null + applyRefreshOffset() + } + + private fun applyRefreshOffset() { + if (!laidOut || appliedRefreshOffset == refreshOffset) return + val diameter = refresh.progressCircleDiameter + val wasRefreshing = refresh.isRefreshing + refresh.setProgressViewOffset( + false, refreshOffset - diameter, refreshOffset + dip(64.0) - diameter, + ) + appliedRefreshOffset = refreshOffset + // This component reserves content insets, not refresh-control insets. + refresh.setTag(R.id.onekey_native_scroll_coordinator_refresh_inset, 0) + if (wasRefreshing) refresh.isRefreshing = true + } + + internal fun setRefreshColors(colors: IntArray) { + refresh.setColorSchemeColors(*(if (colors.isEmpty()) intArrayOf(Color.GRAY) else colors)) + } + internal fun setRefreshBackground(color: Int) = refresh.setProgressBackgroundColorSchemeColor(color) + internal fun setScrollEnabled(value: Boolean) { + viewport.scrollEnabled = value + refresh.isEnabled = refreshEnabled && value + if (!value) viewport.stopMotion() + } + + internal fun scrollToPosition(x: Int, y: Int, animated: Boolean) { + viewport.stopMotion() + if (animated) viewport.scrollToAnimated(y) + else viewport.scrollTo(0, y.coerceAtLeast(0)) + emitScroll(force = true) + } + + internal fun scrollToEnd(animated: Boolean) = scrollToPosition( + 0, (contentHeightPx + contentTop + insetBottom - viewport.height).coerceAtLeast(0), animated, + ) + + internal fun emitScroll(force: Boolean = false) { + if (!isAttachedToWindow) return + val now = SystemClock.uptimeMillis() + if (!force && scrollEventThrottle > 16 && now - lastScrollEvent < scrollEventThrottle) return + lastScrollEvent = now + emit("topScroll", scrollPayload()) + } + + internal fun emitLifecycle(name: String, velocity: Double = 0.0) { + if (!isAttachedToWindow) return + emit(name, scrollPayload(velocity)) + } + + private fun scrollPayload(velocity: Double = 0.0): WritableMap = Arguments.createMap().apply { + fun pair(first: String, firstValue: Double, second: String, secondValue: Double) = + Arguments.createMap().apply { putDouble(first, firstValue); putDouble(second, secondValue) } + putMap("contentOffset", pair("x", px(viewport.scrollX), "y", px(viewport.scrollY))) + putMap("contentSize", pair("width", px(contentWidthPx), "height", px(contentHeightPx))) + putMap("layoutMeasurement", pair("width", px(width), "height", px(height))) + putMap("velocity", pair("x", 0.0, "y", velocity)) + putMap("targetContentOffset", pair("x", px(viewport.scrollX), "y", px(viewport.scrollY))) + putMap("contentInset", Arguments.createMap().apply { + putDouble("top", px(insetTop)); putDouble("right", px(insetRight)) + putDouble("bottom", px(insetBottom)); putDouble("left", px(insetLeft)) + }) + putDouble("zoomScale", 1.0) + } + + private fun publishContentViewport() { + val availableHeight = (viewport.height - contentTop - insetBottom).coerceAtLeast(0) + if (lastContentViewportHeight == availableHeight) return + if (emit("topContentViewportChange", Arguments.createMap().apply { + putDouble("height", px(availableHeight)) + })) { + lastContentViewportHeight = availableHeight + } + } + + private fun emit(name: String, payload: WritableMap): Boolean { + if (!isAttachedToWindow || id == View.NO_ID) return false + val reactContext = context as? ReactContext ?: return false + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) ?: return false + dispatcher.dispatchEvent( + NativeScrollerEvent(UIManagerHelper.getSurfaceId(this), id, name, payload), + ) + return true + } + + internal fun dismissKeyboard() { + (context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager) + ?.hideSoftInputFromWindow(windowToken, 0) + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + viewport.setTag(R.id.onekey_native_scroll_coordinator_offset_listener, consumedOffsetListener) + publishContentOrigin() + scheduleLayout() + } + + override fun onDetachedFromWindow() { + lastContentViewportHeight = null + viewport.stopMotion() + viewport.setTag(R.id.onekey_native_scroll_coordinator_offset_listener, null) + super.onDetachedFromWindow() + } + + internal fun dip(value: Double) = PixelUtil.toPixelFromDIP(value).roundToInt() + private fun px(value: Int) = PixelUtil.toDIPFromPixel(value.toFloat()).toDouble() +} + +internal class NativeScrollerViewport(context: Context, internal val host: NativeScrollerView) : NestedScrollView(context) { + var scrollEnabled = true + private var dragging = false + private var momentum = false + private var stopping = false + private var startingProgrammaticScroll = false + private var downY = 0f + private var velocityTracker: VelocityTracker? = null + private var activePointerId = MotionEvent.INVALID_POINTER_ID + private val maximumVelocity = ViewConfiguration.get(context).scaledMaximumFlingVelocity + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + + init { + isNestedScrollingEnabled = true + isVerticalScrollBarEnabled = true + overScrollMode = OVER_SCROLL_NEVER + } + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + if (!scrollEnabled) return false + if (event.actionMasked == MotionEvent.ACTION_DOWN) downY = event.y + val intercepted = super.onInterceptTouchEvent(event) + if (intercepted && event.actionMasked == MotionEvent.ACTION_MOVE) beginDrag(event) + return intercepted + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (!scrollEnabled) return false + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> downY = event.y + MotionEvent.ACTION_MOVE -> { + val pointerIndex = event.findPointerIndex(activePointerId) + if (pointerIndex >= 0 && abs(event.getY(pointerIndex) - downY) > touchSlop) beginDrag(event) + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> endDrag(event) + } + return super.onTouchEvent(event) + } + + // Record at the refresh boundary so interception does not truncate the pointer history. + internal fun recordTouchEvent(event: MotionEvent) { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + stopMotion() + velocityTracker = VelocityTracker.obtain() + activePointerId = event.getPointerId(0) + downY = event.y + } else if (event.actionMasked == MotionEvent.ACTION_POINTER_DOWN) { + activePointerId = event.getPointerId(event.actionIndex) + downY = event.getY(event.actionIndex) + } else if (event.actionMasked == MotionEvent.ACTION_POINTER_UP && + event.getPointerId(event.actionIndex) == activePointerId) { + val replacement = if (event.actionIndex == 0) 1 else 0 + activePointerId = if (replacement < event.pointerCount) event.getPointerId(replacement) + else MotionEvent.INVALID_POINTER_ID + if (replacement < event.pointerCount) downY = event.getY(replacement) + velocityTracker?.clear() + } + velocityTracker?.addMovement(event) + } + + internal fun releaseTouchTracking() { + velocityTracker?.recycle() + velocityTracker = null + activePointerId = MotionEvent.INVALID_POINTER_ID + } + + internal fun beginDrag(event: MotionEvent) { + if (dragging) return + dragging = true + if (host.keyboardDismissMode != "none") host.dismissKeyboard() + NativeGestureUtil.notifyNativeGestureStarted(host, event) + host.emitLifecycle("topScrollBeginDrag") + } + + internal fun endDrag(event: MotionEvent) { + if (!dragging) return + dragging = false + host.emitScroll(force = true) + val tracker = velocityTracker + val velocity = if (event.actionMasked == MotionEvent.ACTION_UP && tracker != null && + activePointerId != MotionEvent.INVALID_POINTER_ID) { + tracker.computeCurrentVelocity(1000, maximumVelocity.toFloat()) + tracker.getYVelocity(activePointerId) / 1000.0 / resources.displayMetrics.density + } else 0.0 + host.emitLifecycle("topScrollEndDrag", velocity) + NativeGestureUtil.notifyNativeGestureEnded(host, event) + } + + override fun fling(velocityY: Int) { + finishMomentum() + momentum = true + host.emitLifecycle("topMomentumScrollBegin", velocityY / 1000.0 / resources.displayMetrics.density) + // AndroidX uses one unbounded trajectory with pre/content/post nested consumption. + super.fling(velocityY) + } + + internal fun scrollToAnimated(y: Int) { + val child = getChildAt(0) ?: return + val margins = child.layoutParams as FrameLayout.LayoutParams + val range = (child.height + margins.topMargin + margins.bottomMargin - + height + paddingTop + paddingBottom).coerceAtLeast(0) + val target = y.coerceIn(0, range) + if (target == scrollY) return + momentum = true + host.emitLifecycle("topMomentumScrollBegin") + startingProgrammaticScroll = true + try { + // AndroidX stops nested scrolling while starting its programmatic animation. + super.smoothScrollTo(0, target) + } finally { + startingProgrammaticScroll = false + } + // Commands within AndroidX's animation gap execute immediately instead of animating. + if (scrollY == target) finishMomentum() + } + + override fun stopNestedScroll(type: Int) { + super.stopNestedScroll(type) + if (type == ViewCompat.TYPE_NON_TOUCH && !stopping && !startingProgrammaticScroll) finishMomentum() + } + + internal fun stopMotion() { + if (dragging) { + val now = SystemClock.uptimeMillis() + val cancel = MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL, 0f, 0f, 0) + try { endDrag(cancel) } finally { cancel.recycle() } + } + releaseTouchTracking() + stopping = true + try { + // Replace the same AndroidX trajectory with zero velocity without touching its smooth-scroll clock. + super.fling(0) + super.stopNestedScroll(ViewCompat.TYPE_NON_TOUCH) + } finally { + stopping = false + } + finishMomentum() + } + + private fun finishMomentum() { + if (!momentum) return + momentum = false + host.emitScroll(force = true) + host.emitLifecycle("topMomentumScrollEnd") + } + + override fun onScrollChanged(x: Int, y: Int, oldX: Int, oldY: Int) { + super.onScrollChanged(x, y, oldX, oldY) + host.publishContentOrigin() + host.emitScroll() + } + + internal fun flashIndicators() = awakenScrollBars() +} + +private class NativeScrollerRefreshLayout(context: Context) : SwipeRefreshLayout(context) { + var onTouchStream: ((MotionEvent) -> Unit)? = null + var onTouchStreamFinished: (() -> Unit)? = null + var onGestureStart: ((MotionEvent) -> Unit)? = null + var onGestureEnd: ((MotionEvent) -> Unit)? = null + private var downX = 0f + private var downY = 0f + private var horizontal = false + private var intercepted = false + private val slop = ViewConfiguration.get(context).scaledTouchSlop + + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + onTouchStream?.invoke(event) + try { + return super.dispatchTouchEvent(event) + } finally { + if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) { + onTouchStreamFinished?.invoke() + } + } + } + + override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { + super.requestDisallowInterceptTouchEvent(disallowIntercept) + parent?.requestDisallowInterceptTouchEvent(disallowIntercept) + } + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + downX = event.x + downY = event.y + horizontal = false + intercepted = false + } else if (event.actionMasked == MotionEvent.ACTION_MOVE && abs(event.x - downX) > slop && + abs(event.x - downX) > abs(event.y - downY)) { + horizontal = true + } + if (horizontal) return false + val result = super.onInterceptTouchEvent(event) + if (result && !intercepted) { + intercepted = true + onGestureStart?.invoke(event) + } + return result + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + val result = super.onTouchEvent(event) + if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) { + if (intercepted) onGestureEnd?.invoke(event) + intercepted = false + } + return result + } +} + +private class NativeScrollerEvent( + surface: Int, tag: Int, private val name: String, private val payload: WritableMap, +) : Event(surface, tag) { + override fun getEventName() = name + override fun canCoalesce() = name == "topScroll" + override fun getEventData() = payload +} diff --git a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerViewManager.kt b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerViewManager.kt new file mode 100644 index 000000000..1a45cb30a --- /dev/null +++ b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerViewManager.kt @@ -0,0 +1,130 @@ +package com.reactnativepagerview + +import android.graphics.Color +import android.view.View +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.StateWrapper +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.viewmanagers.RNCNativeScrollerManagerDelegate +import com.facebook.react.viewmanagers.RNCNativeScrollerManagerInterface + +@ReactModule(name = NativeScrollerViewManager.NAME) +class NativeScrollerViewManager : ViewGroupManager(), + RNCNativeScrollerManagerInterface { + private val delegate: ViewManagerDelegate = RNCNativeScrollerManagerDelegate(this) + override fun getDelegate() = delegate + override fun getName() = NAME + override fun createViewInstance(context: ThemedReactContext) = NativeScrollerView(context) + override fun needsCustomLayoutForChildren() = false + override fun addView(parent: NativeScrollerView, child: View, index: Int) = parent.addReactChild(child, index) + override fun getChildCount(parent: NativeScrollerView) = parent.reactChildCount() + override fun getChildAt(parent: NativeScrollerView, index: Int) = parent.reactChildAt(index) + override fun removeViewAt(parent: NativeScrollerView, index: Int) { + require(index == 0) + parent.removeReactChild() + } + override fun removeAllViews(parent: NativeScrollerView) = parent.removeReactChild() + override fun onDropViewInstance(view: NativeScrollerView) { + view.viewport.stopMotion() + view.setFabricState(null) + super.onDropViewInstance(view) + } + override fun updateState(view: NativeScrollerView, props: ReactStylesDiffMap, stateWrapper: StateWrapper): Any? { + view.setFabricState(stateWrapper) + return null + } + override fun onAfterUpdateTransaction(view: NativeScrollerView) { + super.onAfterUpdateTransaction(view) + view.scheduleLayout() + } + + @ReactProp(name = "contentWidth") + override fun setContentWidth(view: NativeScrollerView, value: Double) = view.setContentSize(width = view.dip(value)) + @ReactProp(name = "contentHeight") + override fun setContentHeight(view: NativeScrollerView, value: Double) = view.setContentSize(height = view.dip(value)) + @ReactProp(name = "scrollEnabled", defaultBoolean = true) + override fun setScrollEnabled(view: NativeScrollerView, value: Boolean) = view.setScrollEnabled(value) + @ReactProp(name = "nestedScrollEnabled", defaultBoolean = true) + override fun setNestedScrollEnabled(view: NativeScrollerView, value: Boolean) { + if (!value) view.viewport.stopMotion() + view.viewport.isNestedScrollingEnabled = value + } + @ReactProp(name = "showsVerticalScrollIndicator", defaultBoolean = true) + override fun setShowsVerticalScrollIndicator(view: NativeScrollerView, value: Boolean) { + view.viewport.isVerticalScrollBarEnabled = value + } + @ReactProp(name = "showsHorizontalScrollIndicator") + override fun setShowsHorizontalScrollIndicator(view: NativeScrollerView, value: Boolean) { + view.viewport.isHorizontalScrollBarEnabled = value + } + // UIKit-only properties: Android keeps the platform NestedScrollView fling/edge policy. + @ReactProp(name = "bounces", defaultBoolean = true) + override fun setBounces(view: NativeScrollerView, value: Boolean) = Unit + @ReactProp(name = "alwaysBounceVertical", defaultBoolean = true) + override fun setAlwaysBounceVertical(view: NativeScrollerView, value: Boolean) = Unit + @ReactProp(name = "decelerationRate", defaultDouble = 0.998) + override fun setDecelerationRate(view: NativeScrollerView, value: Double) = Unit + @ReactProp(name = "keyboardDismissMode") + override fun setKeyboardDismissMode(view: NativeScrollerView, value: String?) { + view.keyboardDismissMode = value ?: "none" + } + @ReactProp(name = "keyboardShouldPersistTaps") + override fun setKeyboardShouldPersistTaps(view: NativeScrollerView, value: String?) { + // The JS responder wrapper owns never/handled/always and TextInput exceptions. + view.keyboardShouldPersistTaps = value ?: "never" + } + @ReactProp(name = "scrollEventThrottle") + override fun setScrollEventThrottle(view: NativeScrollerView, value: Double) { + view.scrollEventThrottle = value.coerceAtLeast(0.0) + } + @ReactProp(name = "contentInsetTop") + override fun setContentInsetTop(view: NativeScrollerView, value: Double) { view.insetTop = view.dip(value).coerceAtLeast(0) } + @ReactProp(name = "contentInsetRight") + override fun setContentInsetRight(view: NativeScrollerView, value: Double) { view.insetRight = view.dip(value).coerceAtLeast(0) } + @ReactProp(name = "contentInsetBottom") + override fun setContentInsetBottom(view: NativeScrollerView, value: Double) { view.insetBottom = view.dip(value).coerceAtLeast(0) } + @ReactProp(name = "contentInsetLeft") + override fun setContentInsetLeft(view: NativeScrollerView, value: Double) { view.insetLeft = view.dip(value).coerceAtLeast(0) } + @ReactProp(name = "refreshEnabled") + override fun setRefreshEnabled(view: NativeScrollerView, value: Boolean) { view.refreshEnabled = value } + @ReactProp(name = "refreshing") + override fun setRefreshing(view: NativeScrollerView, value: Boolean) = view.setRefreshing(value) + @ReactProp(name = "refreshTintColor", customType = "Color") + override fun setRefreshTintColor(view: NativeScrollerView, value: Int?) { + // Android's refreshColors determines the native arc; tintColor is iOS-only. + } + @ReactProp(name = "refreshColors", customType = "ColorArray") + override fun setRefreshColors(view: NativeScrollerView, value: ReadableArray?) { + view.setRefreshColors(if (value == null) intArrayOf(Color.GRAY) else IntArray(value.size()) { value.getInt(it) }) + } + @ReactProp(name = "refreshProgressBackgroundColor", customType = "Color") + override fun setRefreshProgressBackgroundColor(view: NativeScrollerView, value: Int?) = + view.setRefreshBackground(value ?: Color.WHITE) + @ReactProp(name = "refreshProgressViewOffset") + override fun setRefreshProgressViewOffset(view: NativeScrollerView, value: Double) = view.setRefreshOffset(view.dip(value)) + @ReactProp(name = "refreshTitle") + override fun setRefreshTitle(view: NativeScrollerView, value: String?) = Unit + @ReactProp(name = "refreshTitleColor", customType = "Color") + override fun setRefreshTitleColor(view: NativeScrollerView, value: Int?) = Unit + @ReactProp(name = "refreshSize") + override fun setRefreshSize(view: NativeScrollerView, value: String?) = view.setRefreshSize(value) + + override fun receiveCommand(view: NativeScrollerView, commandId: String, args: ReadableArray?) = + delegate.receiveCommand(view, commandId, args) + override fun setRefreshState(view: NativeScrollerView, refreshing: Boolean) = view.setRefreshing(refreshing) + override fun scrollTo(view: NativeScrollerView, x: Double, y: Double, animated: Boolean) = + view.scrollToPosition(view.dip(x), view.dip(y), animated) + override fun scrollToEnd(view: NativeScrollerView, animated: Boolean) = view.scrollToEnd(animated) + override fun flashScrollIndicators(view: NativeScrollerView) { view.viewport.flashIndicators() } + + override fun getExportedCustomDirectEventTypeConstants(): MutableMap> = + listOf("Scroll", "ScrollBeginDrag", "ScrollEndDrag", "MomentumScrollBegin", "MomentumScrollEnd", "Refresh", "ContentViewportChange") + .associate { "top$it" to mapOf("registrationName" to "on$it") }.toMutableMap() + + companion object { const val NAME = "RNCNativeScroller" } +} diff --git a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/PagerViewViewPackage.kt b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/PagerViewViewPackage.kt index 614e598ae..0ac3bdb9e 100644 --- a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/PagerViewViewPackage.kt +++ b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/PagerViewViewPackage.kt @@ -12,6 +12,6 @@ class PagerViewPackage : ReactPackage { } override fun createViewManagers(reactContext: ReactApplicationContext): List> { - return listOf(PagerViewViewManager(), CollapsiblePagerViewManager()) + return listOf(PagerViewViewManager(), CollapsiblePagerViewManager(), NativeScrollerViewManager()) } } diff --git a/native-views/react-native-pager-view/android/src/main/jni/CMakeLists.txt b/native-views/react-native-pager-view/android/src/main/jni/CMakeLists.txt new file mode 100644 index 000000000..ce0832f2f --- /dev/null +++ b/native-views/react-native-pager-view/android/src/main/jni/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.13) + +set(PAGER_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../../../..) +set(PAGER_GENERATED ${PAGER_ROOT}/android/build/generated/source/codegen/jni) +set(PAGER_COMMON ${PAGER_ROOT}/common/cpp) +file(GLOB PAGER_GENERATED_SOURCES CONFIGURE_DEPENDS + ${PAGER_GENERATED}/*.cpp + ${PAGER_GENERATED}/react/renderer/components/pagerview/*.cpp) + +add_library(react_codegen_pagerview OBJECT + ${PAGER_GENERATED_SOURCES} + ${PAGER_COMMON}/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.cpp) +target_include_directories(react_codegen_pagerview PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${PAGER_COMMON} + ${PAGER_GENERATED} + ${PAGER_GENERATED}/react/renderer/components/pagerview) +target_link_libraries(react_codegen_pagerview fbjni jsi reactnative) +target_compile_reactnative_options(react_codegen_pagerview PRIVATE) diff --git a/native-views/react-native-pager-view/android/src/main/jni/pagerview.h b/native-views/react-native-pager-view/android/src/main/jni/pagerview.h new file mode 100644 index 000000000..8c32d9318 --- /dev/null +++ b/native-views/react-native-pager-view/android/src/main/jni/pagerview.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include +#include +#include + +namespace facebook::react { +JSI_EXPORT std::shared_ptr pagerview_ModuleProvider( + const std::string &moduleName, const JavaTurboModule::InitParams ¶ms); +} // namespace facebook::react diff --git a/native-views/react-native-pager-view/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerComponentDescriptor.h b/native-views/react-native-pager-view/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerComponentDescriptor.h new file mode 100644 index 000000000..66d973d12 --- /dev/null +++ b/native-views/react-native-pager-view/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerComponentDescriptor.h @@ -0,0 +1,8 @@ +#pragma once + +#include +#include + +namespace facebook::react { +using RNCNativeScrollerComponentDescriptor = ConcreteComponentDescriptor; +} // namespace facebook::react diff --git a/native-views/react-native-pager-view/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.cpp b/native-views/react-native-pager-view/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.cpp new file mode 100644 index 000000000..93fbeaaf9 --- /dev/null +++ b/native-views/react-native-pager-view/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.cpp @@ -0,0 +1,15 @@ +#include + +namespace facebook::react { + +const char RNCNativeScrollerComponentName[] = "RNCNativeScroller"; + +Point RNCNativeScrollerShadowNode::getContentOriginOffset(bool includeTransform) const { + const auto state = getStateData(); + const auto origin = state.contentOrigin; + const auto transform = includeTransform ? getTransform() : Transform::Identity(); + const auto result = transform * Vector{origin.x, origin.y, 0.0f, 1.0f}; + return {result.x + state.ancestorOffset.x, result.y + state.ancestorOffset.y}; +} + +} // namespace facebook::react diff --git a/native-views/react-native-pager-view/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.h b/native-views/react-native-pager-view/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.h new file mode 100644 index 000000000..8a2313c84 --- /dev/null +++ b/native-views/react-native-pager-view/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace facebook::react { + +JSI_EXPORT extern const char RNCNativeScrollerComponentName[]; + +class JSI_EXPORT RNCNativeScrollerShadowNode final + : public ConcreteViewShadowNode { + public: + using ConcreteViewShadowNode::ConcreteViewShadowNode; + Point getContentOriginOffset(bool includeTransform) const override; +}; + +} // namespace facebook::react diff --git a/native-views/react-native-pager-view/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerState.h b/native-views/react-native-pager-view/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerState.h new file mode 100644 index 000000000..7a575cfc8 --- /dev/null +++ b/native-views/react-native-pager-view/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerState.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#ifdef RN_SERIALIZABLE_STATE +#include +#endif + +namespace facebook::react { + +// Local content displacement and ancestor translation have different transform spaces. +class RNCNativeScrollerState final { + public: + Point contentOrigin{}; + Point ancestorOffset{}; + RNCNativeScrollerState() = default; + explicit RNCNativeScrollerState(Point origin) : contentOrigin(origin) {} + +#ifdef RN_SERIALIZABLE_STATE + RNCNativeScrollerState(const RNCNativeScrollerState &, folly::dynamic data) + : contentOrigin({static_cast(data["contentOriginX"].getDouble()), + static_cast(data["contentOriginY"].getDouble())}), + ancestorOffset({static_cast(data.getDefault("ancestorOffsetX", 0.0).getDouble()), + static_cast(data.getDefault("ancestorOffsetY", 0.0).getDouble())}) {} + + folly::dynamic getDynamic() const { + return folly::dynamic::object("contentOriginX", contentOrigin.x) + ("contentOriginY", contentOrigin.y) + ("ancestorOffsetX", ancestorOffset.x)("ancestorOffsetY", ancestorOffset.y); + } +#endif +}; + +} // namespace facebook::react diff --git a/native-views/react-native-pager-view/docs/SPEC.md b/native-views/react-native-pager-view/docs/SPEC.md index fdc5e18d6..a24b4f41d 100644 --- a/native-views/react-native-pager-view/docs/SPEC.md +++ b/native-views/react-native-pager-view/docs/SPEC.md @@ -3,6 +3,177 @@ Existing PagerView and CollapsiblePagerView APIs are documented in README.md. The sections below define native coordination boundaries without new React props. +## Independent NativeScroller migration + +Status: implemented in source. Both native builds pass. Android focused emulator +cases below and the iOS same-source UIKit harness pass; the full acceptance +matrix remains incomplete. Device evidence is scoped below. +NativeScroller keeps React children and their Fabric/Yoga layout, but owns its +vertical native scrolling implementation: AndroidX NestedScrollView on Android +and UIScrollView on iOS. It MUST NOT inherit ReactScrollView/RCTScrollView or +require rebuilding ReactAndroid. Web retains its existing implementation. + +The initial compatibility contract covers existing Home callers: viewport style, +contentContainerStyle including padding and flexGrow, dynamically mounted React +children, vertical scroll enablement/indicator, stable pagerScrollKey, scroll +throttling, onScroll/onScrollBeginDrag/onScrollEndDrag and momentum callbacks, +content-size notification, and scrollTo/scrollToEnd commands. Standard RN +RefreshControl JSX remains a supported input; its props and onRefresh callback +are adapted to owned native refresh controls rather than mounting an RN-private +scroll-view implementation. Supported properties and intentional platform limits +MUST be explicit in the exported types. Unknown RN ScrollView features MUST NOT +be silently advertised as supported. No business data or Home-specific geometry +belongs in the native component. + +This is a vertical, non-virtualized subset, not a drop-in implementation of every +ScrollView prop. Horizontal scrolling, sticky rows, snapping, zoom and automatic +keyboard insets are outside this contract. iOS owns bounces and decelerationRate; +Android retains AndroidX fling physics without a public friction override. +Keyboard persist-tap behavior is coordinated by the JS responder wrapper using +RN 0.86's TextInput registry, while native owns drag-to-dismiss. The RN-private +registry dependency and keyboard behavior need validation on RN upgrades. +Arbitrary custom refresh components are not supported. + +Native views, gestures, insets and refresh controllers belong to the UI thread. +React content layout remains Fabric-owned. Layout-size updates may cross the +bridge when dimensions change; native scrolling and header coordination MUST NOT +depend on per-frame JS updates. This is a main/UI component, with no background +runtime, persistent storage or cross-runtime heap state. + +The wrapper mounts one non-collapsible React content View. Native MUST preserve +its Fabric-assigned frame and transform. A shared Fabric ShadowNode reads native +state describing local content displacement and separate ancestor displacement; +it MUST apply the ancestor displacement outside the content transform. UIKit +updates this state from contentOffset; Android updates it from content origin, +scroll position and pager consumption. Equal values are deduplicated, and a +replacement state wrapper is synchronized with the current native position. +These native state updates do not require per-frame JavaScript callbacks. +Fabric hit testing and descendant measureInWindow MUST include the displacement, +so measurement follows visible content after scroll, inset changes and page +return. Geometry acceptance MUST compare a measured React child with its visible +position, including the last child after scrollToEnd. + +The automatic flexGrow minimum MUST use the native usable content viewport, +not the raw host height. An internal onContentViewportChange({height}) event +reports logical units only when static geometry changes; it is not a public +NativeScroller prop. Android reports viewport height minus caller/pager top +reservation and caller bottom inset. Its pager viewport already includes the +extra collapsible-header height. iOS reports bounds height minus caller top and +bottom insets and the sum of pager-owned sticky reservations. Both describe the +React area available with the shared header collapsed. The height MUST NOT +depend on contentSize, current header offset, refresh temporary insets or iOS +synthetic bottom fill, which would create a layout feedback loop. Owners MUST +remove their reservation on release. The current value MUST be delivered when +the view first becomes able to dispatch its event and after reattachment. An +event queued before its initial emitter is ready MUST be delivered when that +emitter becomes available. Replacing the emitter on the same attached view does +not require another event when the usable height is unchanged. + +Before the first native viewport event, the wrapper contributes no positive +automatic minimum. It combines valid native height with an explicit numeric +caller minHeight using the greater value; nonnumeric explicit minima remain +untouched. Content padding remains part of Yoga layout. Long-to-short replacement +MUST clamp the local offset without leaving a pager-inset-sized artificial scroll +range or hiding the first React child. Tests and device acceptance MUST cover +long-to-empty/restore, page return, header/sticky resizing, refresh completion +without minimum-height changes, and an intentionally larger caller minimum. + +Android MUST dispatch touch and non-touch nested scrolling continuously across +the content/header boundary. Content returns to its start before downward travel +expands the header; upward travel collapses the header before scrolling content. +Consumed and remaining distance MUST be accounted for once. Only touch pull can +trigger refresh; inertial movement MUST NOT trigger refresh. iOS MUST preserve a +single UIScrollView trajectory through the inset/header range. Existing Pager +refresh foreground placement and shared-header press cancellation remain in +force. NativeList and unrelated RN ScrollViews retain their implementations. + +New touch, page deactivation, replacement of the mounted React content root and +detach MUST stop stale inertia. Every started drag/momentum sequence has one +terminal notification. An animated scrollTo/scrollToEnd command with a different +clamped target emits one momentum begin/end pair, including AndroidX's immediate +execution branch for closely spaced commands. A target equal to the current +position emits no new pair. A new command ends any previous active sequence +before starting its own, and non-animated movement does not start momentum; +recycled views MUST NOT emit into an old owner. Content shrink clamps unreachable +offsets without a blank tail; resize/inset changes MUST NOT accumulate offsets. +Refresh is controlled by the caller and emits once per qualifying gesture. + +Acceptance requires both native builds, focused contract checks and iOS/Android +recordings: expanded/collapsed Home Spot/Perps/DeFi, short/empty and dynamically +resized content, fast return through the header boundary, new-touch interruption, +adjacent/distant page changes, horizontal gestures versus row presses, pull from +header/content, refresh completion, retained-page return, imperative +animation/no-op/interruption, and React child measureInWindow versus visible +geometry. History/NFT and Market/Discover are regression controls. Source/type/build success alone is not +device acceptance. Implementation findings and remaining limits are recorded +after validation, before this section is promoted to runtime verified. + +Android acceptance checkpoint (2026-09-28): the official local-shell/local-vendor +DevSession build passed, with custom Fabric descriptor registration verified. +On the dedicated emulator, native field inspection confirmed NativeScrollerView +and its AndroidX viewport. Home Spot/Perps/DeFi return gestures crossed the +content/header boundary. In the final short-fill binary, Perps content offset +1389 reached zero and the same non-touch momentum continued header offset +755 -> 648 -> 441 -> 217 -> 56 before natural exhaustion. This proves continuity, +not that every gesture must fully expand the header. + +The first valid binary exposed an artificial 284 px short-content range. After the +usable-viewport correction, Gallery long-to-empty replacement had local offset 0 +with short text visible; restoring rows and scrollToEnd reached the final child. +measureInWindow y746.7/h72.0 matched native visible bounds y1960/h189px at +pixel density2.625. Short-content refresh advanced the fixture counter once and +controlled completion removed the indicator. Home three-page refresh visibility, +History/NFT vertical scrolling and Market/Browser horizontal controls passed on +the preceding valid binary; these control implementations were unchanged by the +short-fill correction. Evidence is recorded in the app task's ignored +`ignore/independent-native-scroller/android-acceptance.md`, alongside screenshots, +recording chunks and native field samples. + +This checkpoint does not cover a physical handset, keyboard/multi-touch/accessory +interactions, exhaustive event counts and command interruption, explicit larger +caller minHeight, header/sticky resizing or the full refresh matrix. Existing +development warnings were observed. Android evidence does not establish iOS +acceptance, and source/build success is not a substitute for its runtime cases. + +iOS acceptance checkpoint (2026-09-28): the final official local-shell/local-vendor +build passed and launched on the dedicated external-image simulator. Nine +same-source UIKit harness methods and 36 assertions pass, including negative-pull +preservation during shrink, caller inset ownership, static usable height and +refresh/recycle interruption. This is UIKit execution, not a formal XCTest run. + +On the final binary, Home Spot/Perps/DeFi horizontal transitions and top-positioned +refresh feedback passed. Gallery scrollToEnd reached the last React row; +measureInWindow y786/h72 matched its native frame. Long-to-empty replacement, +restoring rows, controlled refresh counter 0 -> 1 with indicator dismissal, +normal row press and retained-page return passed. History/NFT scrolling and +media, Discover Browser -> DeFi and Market vertical scrolling plus Trending -> +Stocks horizontal transition passed without unintended detail navigation. +The final device is left on expanded Home Spot. Evidence is in the app task's +`ignore/independent-native-scroller/ios-acceptance.md` and associated recordings. + +These iOS gestures use public XCTest coordinate drags. Earlier private synthesized +pan attempts had inconsistent timing and are excluded; their cause is not proven. +Gallery's floating application tab bar overlaps part of the last row, so matching +coordinates do not establish an unobscured bottom viewport. Existing development +warnings were observed. Keyboard, multi-touch, physical-device performance and +the full lifecycle/interaction matrix remain unverified. These are local source +builds; the currently installed npm version does not yet contain this migration. + +## Native tab-press animation default + +On iOS and Android, an omitted `nativeTabPressAnimationEnabled` MUST animate a +press to an adjacent page and jump directly to a non-adjacent page. Adjacency is +the absolute difference between the target index and the currently selected +index, not the last requested target. Explicit `true` continues to animate all +tab presses, and explicit `false` continues to jump for all tab presses. +Page retention, selection callbacks, swipe gestures, imperative `setPage` / +`setPageWithoutAnimation` commands and Web behavior retain their contracts. +Callers rendering their own JS tab bar choose the matching imperative command. + +Status: implemented; focused native-wrapper tests cover both directions and +explicit overrides. Home applies this policy to its JS tab bar, with iOS and +Android simulator recordings verifying distant jumps and adjacent transitions. + ## Android refresh foreground coordination Status: implemented; Android Home simulator placement verified on 2026-09-28. @@ -62,6 +233,27 @@ a stationary release, below-threshold cancellation, ordinary upward fling and header expansion before refresh. No JS timeout or data-refresh suppression is introduced. +## Android downward scroll ownership + +Status: implemented; Android Home History runtime verified on 2026-09-28. +Downward scrolling MUST move the active content back to its top before expanding +the shared header. A refresh wrapper forwarding nested scrolling MUST report its +child's ability to scroll upward; the wrapper's own scroll range is not evidence +that the content is at the top. SwipeRefreshLayout's public canChildScrollUp() +contract, including caller overrides, supplies this check. Other nested-scroll +targets retain their own canScrollVertically(-1) check. + +The correction changes only the downward pre-scroll eligibility check. Existing +delta consumption, content-observer alignment, refresh ownership and release +settlement remain unchanged. Runtime comparison captured premature expansion +with content offset 1960 px before the fix. Afterward the header stayed collapsed +while content returned to zero, and expanded only at the top, without an observer +forced collapse. Debugger state traces prove ownership, not frame timing; +recordings with the debugger detached cover History rapid direction reversal +and top refresh. +Spot's scroll-then-refresh release-reversal sequence still jumps in both the +baseline and corrected builds; that existing NativeScroller case remains open. + ## Superseded Android indicator placement The alpha.257 approach translated the RN indicator below the shared tabs. diff --git a/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm b/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm index 46bee5203..9d6c6caed 100644 --- a/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm +++ b/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm @@ -1,4 +1,5 @@ #import "RNCCollapsiblePagerViewComponentView.h" +#import "RNCNativeScrollerComponentView.h" #import "RNCCollapsiblePagerReleasedStatePolicy.h" #import @@ -12,6 +13,7 @@ #import "React/RCTSurfaceTouchHandler.h" #import "React/RCTTouchHandler.h" #import +#import // OneKey patch: deduplicate structural mutation parents before scanning the page. #include @@ -1019,6 +1021,114 @@ - (BOOL)canPreventGestureRecognizer:(UIGestureRecognizer *)preventedGestureRecog @end +// This observer never competes for scrolling. The owning pager explicitly +// recognizes it only after its real pan begins, so UIKit terminates RN presses. +@interface RNCCollapsiblePagerContentPressCancellationGestureRecognizer : UIGestureRecognizer +@property (nonatomic, weak) UIGestureRecognizer *reactTouchHandler; +- (void)cancelPressForPan:(UIPanGestureRecognizer *)pan; +- (void)observeIncomingTouch:(UITouch *)touch; +@end + +@implementation RNCCollapsiblePagerContentPressCancellationGestureRecognizer { + __weak UITouch *_trackedTouch; + UIEvent *_touchEvent; + BOOL _multipleSurfaceTouchesObserved; +} + +- (BOOL)canPreventGestureRecognizer:(UIGestureRecognizer *)other +{ + return other == self.reactTouchHandler; +} + +- (void)observeIncomingTouch:(UITouch *)touch +{ + if (_trackedTouch != nil && touch != _trackedTouch && + [touch.view isDescendantOfView:self.reactTouchHandler.view]) { + _multipleSurfaceTouchesObserved = YES; + } +} + +- (void)recordEvent:(UIEvent *)event +{ + _touchEvent = event; + NSUInteger count = 0; + for (UITouch *touch in event.allTouches) { + if (touch.phase != UITouchPhaseEnded && touch.phase != UITouchPhaseCancelled && + [touch.view isDescendantOfView:self.reactTouchHandler.view]) ++count; + } + if (count > 1) _multipleSurfaceTouchesObserved = YES; +} + +- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event +{ + if (_trackedTouch != nil || touches.count != 1) { + _multipleSurfaceTouchesObserved = YES; + if (self.state == UIGestureRecognizerStatePossible) { + self.state = UIGestureRecognizerStateFailed; + } + return; + } + _trackedTouch = touches.anyObject; + [self recordEvent:event]; +} + +- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event +{ + [self recordEvent:event]; +} + +- (void)cancelPressForPan:(UIPanGestureRecognizer *)pan +{ + UIGestureRecognizer *handler = self.reactTouchHandler; + UITouch *touch = _trackedTouch; + if (self.state != UIGestureRecognizerStatePossible || _multipleSurfaceTouchesObserved || + pan.state != UIGestureRecognizerStateBegan || touch == nil || + touch.phase == UITouchPhaseEnded || touch.phase == UITouchPhaseCancelled || + handler.view == nil || self.view != handler.view.superview || + handler.state == UIGestureRecognizerStateFailed || + handler.state == UIGestureRecognizerStateCancelled || + handler.state == UIGestureRecognizerStateEnded) return; + + // RN cancellation belongs to a Surface, not one finger. Do not terminate an + // unrelated, already active touch on that Surface during a multi-touch sequence. + NSUInteger activeTouches = 0; + for (UITouch *activeTouch in _touchEvent.allTouches) { + if (activeTouch.phase == UITouchPhaseEnded || + activeTouch.phase == UITouchPhaseCancelled) continue; + if ([activeTouch.view isDescendantOfView:handler.view]) ++activeTouches; + } + if (activeTouches != 1) return; + self.state = UIGestureRecognizerStateBegan; +} + +- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event +{ + if (![touches containsObject:_trackedTouch]) return; + self.state = self.state == UIGestureRecognizerStateBegan + ? UIGestureRecognizerStateEnded : UIGestureRecognizerStateFailed; + _trackedTouch = nil; + _touchEvent = nil; +} + +- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event +{ + if (![touches containsObject:_trackedTouch]) return; + self.state = self.state == UIGestureRecognizerStateBegan + ? UIGestureRecognizerStateCancelled : UIGestureRecognizerStateFailed; + _trackedTouch = nil; + _touchEvent = nil; +} + +- (void)reset +{ + [super reset]; + _trackedTouch = nil; + _touchEvent = nil; + _multipleSurfaceTouchesObserved = NO; +} + +@end + @interface RNCCollapsiblePagerOuterPagerPanGestureRecognizer : UIPanGestureRecognizer @property (nonatomic, strong) NSHashTable *blockedPagerGestures; @end @@ -1065,7 +1175,8 @@ @interface RNCCollapsiblePagerViewComponentView () < UIPageViewControllerDelegate, UIScrollViewDelegate, UIGestureRecognizerDelegate, - RCTMountingTransactionObserving + RCTMountingTransactionObserving, + RNCNativeScrollerInsetCoordinator > - (void)finishPagerScrollEmittingSelection:(BOOL)emitSelection; - (void)completeTransitionOnNextRunLoopForGeneration:(NSUInteger)generation @@ -1151,6 +1262,7 @@ @implementation RNCCollapsiblePagerViewComponentView { __weak UIView *_sharedHeaderPressCancellationGestureHost; __weak UIGestureRecognizer *_reactTouchHandler; UIPanGestureRecognizer *_sharedHeaderPressCancellationGesture; + RNCCollapsiblePagerContentPressCancellationGestureRecognizer *_contentPressCancellationGesture; RNCCollapsiblePagerOuterPagerPanGestureRecognizer *_sharedHeaderOuterPagerGesture; UIPanGestureRecognizer *_verticalPagerGesture; BOOL _observingContentOffset; @@ -1295,6 +1407,9 @@ - (void)didMoveToWindow { [super didMoveToWindow]; [self updateSharedHeaderPressCancellationGesture]; + for (UIScrollView *scrollView in _originalInsets.keyEnumerator) { + [self updateNativeScrollerViewport:scrollView stickyHeight:self.window == nil ? 0 : _stickyHeaderHeight]; + } // OneKey patch: removing a decelerating page from its window can suppress // UIKit's final scroll callback. Restore the last acknowledged page on reattach. if (self.window != nil && _isPagerDragging && @@ -1318,6 +1433,9 @@ - (void)didMoveToWindow - (void)dealloc { + for (UIScrollView *scrollView in _originalInsets.keyEnumerator) { + [self updateNativeScrollerViewport:scrollView stickyHeight:0]; + } for (UIScrollView *scrollView in _refreshControlObservedScrollViews.allObjects) { [self releaseRefreshControlForScrollView:scrollView]; } @@ -1863,6 +1981,7 @@ - (void)prepareForRecycle [self releaseRefreshControlForScrollView:scrollView]; } for (UIScrollView *scrollView in _originalInsets.keyEnumerator) { + [self updateNativeScrollerViewport:scrollView stickyHeight:0]; NSValue *value = [_originalInsets objectForKey:scrollView]; if (value != nil) { UIEdgeInsets original = value.UIEdgeInsetsValue; @@ -2130,6 +2249,9 @@ - (UIScrollView *)findExplicitNativeScrollerInView:(UIView *)view return [(RNCCollapsiblePagerViewComponentView *)view currentVerticalScrollViewForParentPager]; } + if ([view isKindOfClass:RNCNativeScrollerComponentView.class]) { + return ((RNCNativeScrollerComponentView *)view).scrollView; + } if ([view isKindOfClass:RCTScrollViewComponentView.class]) { RCTScrollViewComponentView *componentView = (RCTScrollViewComponentView *)view; @@ -2344,10 +2466,18 @@ - (void)detachScrollObserver } _observingContentOffset = NO; _observedScrollView = nil; + if ([scrollView.superview isKindOfClass:RNCNativeScrollerComponentView.class]) { + [(RNCNativeScrollerComponentView *)scrollView.superview stopScrolling]; + } } - (void)detachSharedHeaderPressCancellationGesture { + if (_contentPressCancellationGesture.view != nil) { + [_contentPressCancellationGesture.view removeGestureRecognizer:_contentPressCancellationGesture]; + } + _contentPressCancellationGesture.reactTouchHandler = nil; + _contentPressCancellationGesture = nil; if (_sharedHeaderPressCancellationGesture.view != nil) { [_sharedHeaderPressCancellationGesture.view removeGestureRecognizer:_sharedHeaderPressCancellationGesture]; @@ -2432,6 +2562,18 @@ - (void)updateSharedHeaderPressCancellationGesture _sharedHeaderOuterPagerGesture.maximumNumberOfTouches = 1; } _sharedHeaderOuterPagerGesture.blockedPagerGestures = outerPagerGestures; + if (_contentPressCancellationGesture == nil) { + _contentPressCancellationGesture = + [RNCCollapsiblePagerContentPressCancellationGestureRecognizer new]; + _contentPressCancellationGesture.delegate = self; + // RN's external-recognizer path cancels its responder immediately only + // when this flag is set; otherwise reset can be deferred until finger-up. + _contentPressCancellationGesture.cancelsTouchesInView = YES; + _contentPressCancellationGesture.delaysTouchesBegan = NO; + _contentPressCancellationGesture.delaysTouchesEnded = NO; + } + _contentPressCancellationGesture.reactTouchHandler = reactTouchHandler; + [gestureHost addGestureRecognizer:_contentPressCancellationGesture]; _reactTouchHandler = reactTouchHandler; _sharedHeaderPressCancellationGestureHost = gestureHost; [gestureHost addGestureRecognizer:_sharedHeaderPressCancellationGesture]; @@ -2588,6 +2730,7 @@ - (void)releaseRefreshControlForScrollView:(UIScrollView *)scrollView // still represents the old list, before Fabric applies its next owner's props. - (void)restoreScrollViewState:(UIScrollView *)scrollView resetSavedOffset:(BOOL)resetSavedOffset { + [self updateNativeScrollerViewport:scrollView stickyHeight:0]; [self releaseRefreshControlForScrollView:scrollView]; if (scrollView == _observedScrollView) { [self detachScrollObserver]; @@ -2705,6 +2848,7 @@ - (void)applyInsetsToScrollView:(UIScrollView *)scrollView // This host already includes the fixed header and safe area in its frame. // UIKit automatic adjustment would add them a second time to short pages. scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + [self updateNativeScrollerViewport:scrollView stickyHeight:_stickyHeaderHeight]; [self updateRefreshControlForScrollView:scrollView]; CGFloat previousTopInset = scrollView.contentInset.top; CGFloat previousLogicalOffset = scrollView.contentOffset.y + previousTopInset; @@ -2765,6 +2909,31 @@ - (void)applyInsetsToScrollView:(UIScrollView *)scrollView if (pageIndex == _currentIndex) [self updateHeaderForScrollView:scrollView]; } +- (void)updateNativeScrollerViewport:(UIScrollView *)scrollView stickyHeight:(CGFloat)height +{ + if ([scrollView.superview isKindOfClass:RNCNativeScrollerComponentView.class]) { + [(RNCNativeScrollerComponentView *)scrollView.superview setPagerStickyHeight:height forOwner:self]; + } +} + +- (void)nativeScroller:(RNCNativeScrollerComponentView *)scroller + callerInsetChangedFrom:(UIEdgeInsets)previous to:(UIEdgeInsets)next +{ + UIScrollView *scrollView = scroller.scrollView; + NSValue *stored = [_originalInsets objectForKey:scrollView]; + if (stored == nil) return; + UIEdgeInsets original = stored.UIEdgeInsetsValue; + original.top += next.top - previous.top; + original.left += next.left - previous.left; + original.bottom += next.bottom - previous.bottom; + original.right += next.right - previous.right; + [_originalInsets setObject:[NSValue valueWithUIEdgeInsets:original] forKey:scrollView]; + NSNumber *appliedTop = [_appliedTopInsets objectForKey:scrollView]; + if (appliedTop != nil) { + [_appliedTopInsets setObject:@(appliedTop.doubleValue + next.top - previous.top) forKey:scrollView]; + } +} + - (void)reapplyInsetsToObservedScrollView { [self restoreDetachedScrollInsets]; @@ -3030,6 +3199,9 @@ - (void)drainPendingGoTo - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView { + if (_nativeSmoothHeaderScrollEnabled && scrollView == _pagerScrollView) { + [_contentPressCancellationGesture cancelPressForPan:scrollView.panGestureRecognizer]; + } if (_directNativePagerEnabled) { [self preparePageForHorizontalTransitionAtIndex:_currentIndex - 1]; [self preparePageForHorizontalTransitionAtIndex:_currentIndex + 1]; @@ -3375,6 +3547,14 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceive (unsigned long)_sharedHeaderOuterPagerGesture.blockedPagerGestures.count]); return YES; } + if (gestureRecognizer == _contentPressCancellationGesture) { + [_contentPressCancellationGesture observeIncomingTouch:touch]; + UIView *touchView = touch.view; + return _nativeSmoothHeaderScrollEnabled && !_isBeingRecycled && + _contentPressCancellationGesture.view != nil && + [touchView isDescendantOfView:_pagerScrollView] && + ![touchView isDescendantOfView:_sharedHeaderHostView]; + } if (gestureRecognizer == _sharedHeaderPressCancellationGesture) { if (!_nativeSmoothHeaderScrollEnabled || _isBeingRecycled || _sharedHeaderPressCancellationGestureHost == nil) return NO; @@ -3415,6 +3595,12 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer ![other isKindOfClass:RCTTouchHandler.class] && ![_sharedHeaderOuterPagerGesture.blockedPagerGestures containsObject:other]; } + if (gestureRecognizer == _contentPressCancellationGesture || + otherGestureRecognizer == _contentPressCancellationGesture) { + UIGestureRecognizer *other = gestureRecognizer == _contentPressCancellationGesture + ? otherGestureRecognizer : gestureRecognizer; + return other != _contentPressCancellationGesture.reactTouchHandler; + } if (gestureRecognizer == _sharedHeaderPressCancellationGesture || otherGestureRecognizer == _sharedHeaderPressCancellationGesture) { UIGestureRecognizer *other = gestureRecognizer == _sharedHeaderPressCancellationGesture diff --git a/native-views/react-native-pager-view/ios/RNCNativeScrollerComponentView.h b/native-views/react-native-pager-view/ios/RNCNativeScrollerComponentView.h new file mode 100644 index 000000000..b2e5d8be4 --- /dev/null +++ b/native-views/react-native-pager-view/ios/RNCNativeScrollerComponentView.h @@ -0,0 +1,17 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface RNCNativeScrollerComponentView : RCTViewComponentView +@property (nonatomic, readonly) UIScrollView *scrollView; +- (void)stopScrolling; +- (void)setPagerStickyHeight:(CGFloat)height forOwner:(id)owner; +@end + +// Native inset ownership stays separate from Fabric/Yoga content layout. +@protocol RNCNativeScrollerInsetCoordinator +- (void)nativeScroller:(RNCNativeScrollerComponentView *)scroller + callerInsetChangedFrom:(UIEdgeInsets)previous to:(UIEdgeInsets)next; +@end + +NS_ASSUME_NONNULL_END diff --git a/native-views/react-native-pager-view/ios/RNCNativeScrollerComponentView.mm b/native-views/react-native-pager-view/ios/RNCNativeScrollerComponentView.mm new file mode 100644 index 000000000..96f331571 --- /dev/null +++ b/native-views/react-native-pager-view/ios/RNCNativeScrollerComponentView.mm @@ -0,0 +1,479 @@ +#import "RNCNativeScrollerComponentView.h" + +#import +#import +#import +#import +#import + +using namespace facebook::react; + +@interface RNCNativeVerticalScrollView : UIScrollView +@end +@implementation RNCNativeVerticalScrollView +- (BOOL)touchesShouldCancelInContentView:(UIView *)view { return YES; } +@end + +template +static Event RNCScrollerMetrics(UIScrollView *scroll, CGPoint velocity, CGPoint target) +{ + UIEdgeInsets inset = scroll.contentInset; + return { + .contentInset = {.top = inset.top, .right = inset.right, .bottom = inset.bottom, .left = inset.left}, + .contentOffset = {.x = scroll.contentOffset.x, .y = scroll.contentOffset.y}, + .contentSize = {.width = scroll.contentSize.width, .height = scroll.contentSize.height}, + .layoutMeasurement = {.width = scroll.bounds.size.width, .height = scroll.bounds.size.height}, + .velocity = {.x = velocity.x, .y = velocity.y}, + .targetContentOffset = {.x = target.x, .y = target.y}, + .zoomScale = 1, + }; +} + +@interface RNCNativeScrollerComponentView () +@end + +@implementation RNCNativeScrollerComponentView { + RNCNativeScrollerShadowNode::ConcreteState::Shared _state; + RNCNativeVerticalScrollView *_scrollView; + UIView *_reactContent; + UIRefreshControl *_refreshControl; + UIEdgeInsets _callerInset; + BOOL _dragActive; + BOOL _momentumActive; + BOOL _refreshEventSentForDrag; + BOOL _needsContentClamp; + BOOL _recycled; + BOOL _stopping; + NSUInteger _generation; + CFTimeInterval _lastScrollEvent; + NSTimeInterval _scrollEventThrottle; + NSMapTable *_pagerStickyHeights; + NSNumber *_lastContentViewportHeight; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider +{ + return concreteComponentDescriptorProvider(); +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if ((self = [super initWithFrame:frame])) { + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + _pagerStickyHeights = [NSMapTable weakToStrongObjectsMapTable]; + _scrollView = [[RNCNativeVerticalScrollView alloc] initWithFrame:self.bounds]; + _scrollView.delegate = self; + _scrollView.alwaysBounceVertical = YES; + _scrollView.alwaysBounceHorizontal = NO; + _scrollView.showsHorizontalScrollIndicator = NO; + _scrollView.directionalLockEnabled = YES; + _scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + self.contentView = _scrollView; + } + return self; +} + +- (UIScrollView *)scrollView { return _scrollView; } + +- (void)mountChildComponentView:(UIView *)child index:(NSInteger)index +{ + // The wrapper supplies one Yoga-owned content view. Pager-owned shared headers + // are separate UIScrollView children and must never be removed or repositioned. + if (_reactContent != nil && _reactContent != child) { + [self stopScrolling]; + [_reactContent removeFromSuperview]; + } + _reactContent = child; + [_scrollView insertSubview:child atIndex:0]; +} + +- (void)unmountChildComponentView:(UIView *)child index:(NSInteger)index +{ + if (child != _reactContent) return; + [self stopScrolling]; + [child removeFromSuperview]; + _reactContent = nil; +} + +- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps +{ + const auto &next = *std::static_pointer_cast(props); + const auto &previous = *std::static_pointer_cast(_props); + BOOL wasRecycled = _recycled; + _recycled = NO; + _scrollView.delegate = self; + if (!next.scrollEnabled && _scrollView.scrollEnabled) [self stopScrolling]; + _scrollView.scrollEnabled = next.scrollEnabled; + _scrollView.showsVerticalScrollIndicator = next.showsVerticalScrollIndicator; + _scrollView.showsHorizontalScrollIndicator = next.showsHorizontalScrollIndicator; + _scrollView.bounces = next.bounces; + if (wasRecycled || previous.alwaysBounceVertical != next.alwaysBounceVertical) { + _scrollView.alwaysBounceVertical = next.alwaysBounceVertical; + } + _scrollView.decelerationRate = isfinite(next.decelerationRate) + ? MIN(1, MAX(0, next.decelerationRate)) : UIScrollViewDecelerationRateNormal; + switch (next.keyboardDismissMode) { + case RNCNativeScrollerKeyboardDismissMode::OnDrag: + _scrollView.keyboardDismissMode = UIScrollViewKeyboardDismissModeOnDrag; break; + case RNCNativeScrollerKeyboardDismissMode::Interactive: + _scrollView.keyboardDismissMode = UIScrollViewKeyboardDismissModeInteractive; break; + default: _scrollView.keyboardDismissMode = UIScrollViewKeyboardDismissModeNone; break; + } + _scrollEventThrottle = isfinite(next.scrollEventThrottle) + ? MAX(0, next.scrollEventThrottle) / 1000.0 : 0; + if (_scrollEventThrottle <= 0.016) _scrollEventThrottle = 0; + + UIEdgeInsets inset = UIEdgeInsetsMake( + isfinite(next.contentInsetTop) ? next.contentInsetTop : 0, + isfinite(next.contentInsetLeft) ? next.contentInsetLeft : 0, + isfinite(next.contentInsetBottom) ? next.contentInsetBottom : 0, + isfinite(next.contentInsetRight) ? next.contentInsetRight : 0); + if (!UIEdgeInsetsEqualToEdgeInsets(inset, _callerInset)) { + UIEdgeInsets oldInset = _callerInset; + UIEdgeInsets current = _scrollView.contentInset; + _callerInset = inset; + _scrollView.contentInset = UIEdgeInsetsMake( + current.top + inset.top - oldInset.top, current.left + inset.left - oldInset.left, + current.bottom + inset.bottom - oldInset.bottom, current.right + inset.right - oldInset.right); + for (UIView *owner = self.superview; owner != nil; owner = owner.superview) { + if ([owner conformsToProtocol:@protocol(RNCNativeScrollerInsetCoordinator)]) { + [(id)owner nativeScroller:self + callerInsetChangedFrom:oldInset to:inset]; + } + } + } + + BOOL createdRefreshControl = next.refreshEnabled && _refreshControl == nil; + if (createdRefreshControl) { + _refreshControl = [UIRefreshControl new]; + [_refreshControl addTarget:self action:@selector(refreshTriggered:) + forControlEvents:UIControlEventValueChanged]; + _scrollView.refreshControl = _refreshControl; + } else if (!next.refreshEnabled && _refreshControl != nil) { + [_refreshControl endRefreshing]; + _scrollView.refreshControl = nil; + _refreshControl = nil; + } + if (_refreshControl != nil) { + _refreshControl.tintColor = next.refreshTintColor ? RCTUIColorFromSharedColor(next.refreshTintColor) : nil; + NSString *title = RCTNSStringFromString(next.refreshTitle); + UIColor *titleColor = next.refreshTitleColor ? RCTUIColorFromSharedColor(next.refreshTitleColor) : nil; + _refreshControl.attributedTitle = title.length == 0 ? nil : [[NSAttributedString alloc] + initWithString:title attributes:titleColor == nil ? @{} : @{NSForegroundColorAttributeName: titleColor}]; + if (previous.refreshProgressViewOffset != next.refreshProgressViewOffset || createdRefreshControl) { + CGRect bounds = _refreshControl.bounds; + bounds.origin.y = isfinite(next.refreshProgressViewOffset) ? -next.refreshProgressViewOffset : 0; + _refreshControl.bounds = bounds; + } + if (next.refreshing && !_refreshControl.refreshing) { + [_refreshControl beginRefreshing]; + } else if (!next.refreshing && (previous.refreshing || createdRefreshControl)) { + [_refreshControl endRefreshing]; + } + } + + CGSize size = CGSizeMake(isfinite(next.contentWidth) ? MAX(0, next.contentWidth) : 0, + isfinite(next.contentHeight) ? MAX(0, next.contentHeight) : 0); + if (!CGSizeEqualToSize(size, _scrollView.contentSize)) { + _needsContentClamp |= size.height < _scrollView.contentSize.height; + CGPoint offset = _scrollView.contentOffset; + BOOL wasPulling = offset.y < -_scrollView.adjustedContentInset.top; + _scrollView.contentSize = size; + // UIKit can synchronously discard a pull when content becomes empty. + // Upper-range shrink correction remains owned by the deferred clamp. + if (wasPulling && _scrollView.contentOffset.y != offset.y) { + [_scrollView setContentOffset:CGPointMake(_scrollView.contentOffset.x, offset.y) animated:NO]; + } + [self scheduleContentClamp]; + } + BOOL refreshEnded = previous.refreshing && !next.refreshing; + [super updateProps:props oldProps:oldProps]; + if (refreshEnded) [self scheduleContentClamp]; + [self updateFabricContentOrigin]; + [self emitContentViewportChangeIfNeeded]; +} + +- (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics + oldLayoutMetrics:(const LayoutMetrics &)oldLayoutMetrics +{ + CGSize previous = _scrollView.bounds.size; + [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics]; + [self updateFabricContentOrigin]; + [self emitContentViewportChangeIfNeeded]; + if (!CGSizeEqualToSize(previous, _scrollView.bounds.size)) { + _needsContentClamp = YES; + [self scheduleContentClamp]; + } +} + +- (CGFloat)contentViewportHeight +{ + CGFloat stickyHeight = 0; + for (NSNumber *height in _pagerStickyHeights.objectEnumerator) stickyHeight += height.doubleValue; + return MAX(0, _scrollView.bounds.size.height - _callerInset.top - _callerInset.bottom - stickyHeight); +} + +- (void)setPagerStickyHeight:(CGFloat)height forOwner:(id)owner +{ + if (owner == nil) return; + CGFloat next = isfinite(height) ? MAX(0, height) : 0; + if (next == 0) [_pagerStickyHeights removeObjectForKey:owner]; + else [_pagerStickyHeights setObject:@(next) forKey:owner]; + [self emitContentViewportChangeIfNeeded]; +} + +- (void)emitContentViewportChangeIfNeeded +{ + if (_recycled || !_eventEmitter) return; + CGFloat height = [self contentViewportHeight]; + if (_lastContentViewportHeight != nil && _lastContentViewportHeight.doubleValue == height) return; + _lastContentViewportHeight = @(height); + std::static_pointer_cast(_eventEmitter) + ->onContentViewportChange({.height = height}); +} + +- (void)updateEventEmitter:(const EventEmitter::Shared &)eventEmitter +{ + if (_eventEmitter != eventEmitter) _lastContentViewportHeight = nil; + [super updateEventEmitter:eventEmitter]; + [self emitContentViewportChangeIfNeeded]; +} + +- (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState +{ + _state = std::static_pointer_cast(state); + [self updateFabricContentOrigin]; +} + +- (void)updateFabricContentOrigin +{ + if (!_state || _recycled) return; + facebook::react::Point origin = {-static_cast(_scrollView.contentOffset.x), + -static_cast(_scrollView.contentOffset.y)}; + _state->updateState([origin](const RNCNativeScrollerShadowNode::ConcreteState::Data &oldData) + -> RNCNativeScrollerShadowNode::ConcreteState::SharedData { + if (oldData.contentOrigin == origin) return nullptr; + auto next = oldData; + next.contentOrigin = origin; + return std::make_shared(next); + }); +} + +- (void)scheduleContentClamp +{ + if (!_needsContentClamp) return; + NSUInteger generation = _generation; + __weak __typeof__(self) weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + __strong __typeof__(self) self = weakSelf; + if (self == nil || self->_generation != generation || self->_recycled) return; + [self clampContentOffsetIfNeeded]; + }); +} + +- (void)clampContentOffsetIfNeeded +{ + if (!_needsContentClamp || _scrollView.dragging || _scrollView.decelerating || + _refreshControl.refreshing || _scrollView.bounds.size.height <= 0) return; + _needsContentClamp = NO; + CGFloat maximum = MAX(-_scrollView.adjustedContentInset.top, + _scrollView.contentSize.height - _scrollView.bounds.size.height + _scrollView.adjustedContentInset.bottom); + if (_scrollView.contentOffset.y > maximum) { + [_scrollView setContentOffset:CGPointMake(_scrollView.contentOffset.x, maximum) animated:NO]; + } +} + +- (void)refreshTriggered:(UIRefreshControl *)control +{ + if (_recycled || _refreshEventSentForDrag || control != _refreshControl) return; + const auto &props = *std::static_pointer_cast(_props); + if (!props.refreshEnabled || props.refreshing) return; + _refreshEventSentForDrag = YES; + if (_eventEmitter) { + std::static_pointer_cast(_eventEmitter)->onRefresh({}); + } +} + +// Every delegate path and imperative interruption uses the same terminal owner. +- (void)emitScrollEvent:(NSInteger)kind velocity:(CGPoint)velocity target:(CGPoint)target +{ + if (_recycled || !_eventEmitter) return; + const auto emitter = std::static_pointer_cast(_eventEmitter); + switch (kind) { + case 0: emitter->onScroll(RNCScrollerMetrics(_scrollView, velocity, target)); break; + case 1: emitter->onScrollBeginDrag(RNCScrollerMetrics(_scrollView, velocity, target)); break; + case 2: emitter->onScrollEndDrag(RNCScrollerMetrics(_scrollView, velocity, target)); break; + case 3: emitter->onMomentumScrollBegin(RNCScrollerMetrics(_scrollView, velocity, target)); break; + case 4: emitter->onMomentumScrollEnd(RNCScrollerMetrics(_scrollView, velocity, target)); break; + } +} + +- (void)finishMomentum +{ + if (!_momentumActive) return; + _momentumActive = NO; + [self emitScrollEvent:4 velocity:CGPointZero target:_scrollView.contentOffset]; +} + +- (void)stopScrolling +{ + if (_stopping) return; + _stopping = YES; + [_scrollView setContentOffset:_scrollView.contentOffset animated:NO]; + if (_dragActive) { + _dragActive = NO; + [self emitScrollEvent:2 velocity:CGPointZero target:_scrollView.contentOffset]; + BOOL enabled = _scrollView.panGestureRecognizer.enabled; + _scrollView.panGestureRecognizer.enabled = NO; + _scrollView.panGestureRecognizer.enabled = enabled; + } + [self finishMomentum]; + _stopping = NO; +} + +- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView +{ + if (_recycled) return; + [self finishMomentum]; + _dragActive = YES; + _refreshEventSentForDrag = NO; + _lastScrollEvent = 0; + [self emitScrollEvent:1 velocity:CGPointZero target:scrollView.contentOffset]; +} + +- (void)scrollViewDidScroll:(UIScrollView *)scrollView +{ + if (_recycled) return; + [self updateFabricContentOrigin]; + CFTimeInterval now = CACurrentMediaTime(); + if (_lastScrollEvent != 0 && now - _lastScrollEvent < _scrollEventThrottle) return; + _lastScrollEvent = now; + [self emitScrollEvent:0 velocity:CGPointZero target:scrollView.contentOffset]; +} + +- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView + withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)target +{ + if (_recycled || !_dragActive) return; + _dragActive = NO; + [self emitScrollEvent:2 velocity:velocity target:*target]; +} + +- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate +{ + if (_recycled) return; + if (_dragActive) { + _dragActive = NO; + [self emitScrollEvent:2 velocity:CGPointZero target:scrollView.contentOffset]; + } + if (!decelerate) [self scheduleContentClamp]; +} + +- (void)scrollViewWillBeginDecelerating:(UIScrollView *)scrollView +{ + if (_recycled || _stopping || _momentumActive) return; + _momentumActive = YES; + [self emitScrollEvent:3 velocity:CGPointZero target:scrollView.contentOffset]; +} + +- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView +{ + if (_recycled) return; + [self finishMomentum]; + _lastScrollEvent = 0; + [self scrollViewDidScroll:scrollView]; + [self scheduleContentClamp]; +} + +- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView +{ + [self scrollViewDidEndDecelerating:scrollView]; +} + +- (void)scrollTo:(double)x y:(double)y animated:(BOOL)animated +{ + if (_recycled || !isfinite(x) || !isfinite(y)) return; + [self stopScrolling]; + UIEdgeInsets inset = _scrollView.adjustedContentInset; + CGPoint target = CGPointMake(MAX(-inset.left, MIN(x, + MAX(-inset.left, _scrollView.contentSize.width - _scrollView.bounds.size.width + inset.right))), + MAX(-inset.top, MIN(y, + MAX(-inset.top, _scrollView.contentSize.height - _scrollView.bounds.size.height + inset.bottom)))); + if (CGPointEqualToPoint(target, _scrollView.contentOffset)) return; + if (animated) { + _momentumActive = YES; + [self emitScrollEvent:3 velocity:CGPointZero target:target]; + } + [_scrollView setContentOffset:target animated:animated]; +} + +- (void)scrollToEnd:(BOOL)animated +{ + [self scrollTo:_scrollView.contentOffset.x + y:_scrollView.contentSize.height - _scrollView.bounds.size.height + _scrollView.adjustedContentInset.bottom + animated:animated]; +} + +- (void)setRefreshState:(BOOL)refreshing +{ + [self setRefreshing:refreshing]; +} + +- (void)setRefreshing:(BOOL)refreshing +{ + if (_recycled || _refreshControl == nil) return; + if (refreshing) { + if (!_refreshControl.refreshing) [_refreshControl beginRefreshing]; + } else { + [_refreshControl endRefreshing]; + [self scheduleContentClamp]; + } +} + +- (void)flashScrollIndicators { [_scrollView flashScrollIndicators]; } + +- (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args +{ + RCTRNCNativeScrollerHandleCommand(self, commandName, args); +} + +- (void)didMoveToWindow +{ + [super didMoveToWindow]; + if (self.window == nil) { + [self stopScrolling]; + [_pagerStickyHeights removeAllObjects]; + [self emitContentViewportChangeIfNeeded]; + } else { + const auto &props = *std::static_pointer_cast(_props); + if (props.refreshing) [_refreshControl beginRefreshing]; + [self scheduleContentClamp]; + } +} + +- (void)prepareForRecycle +{ + [self stopScrolling]; + _recycled = YES; + _scrollView.delegate = nil; + ++_generation; + _state.reset(); + [_pagerStickyHeights removeAllObjects]; + _lastContentViewportHeight = nil; + [_refreshControl endRefreshing]; + _scrollView.refreshControl = nil; + _refreshControl = nil; + [_reactContent removeFromSuperview]; + _reactContent = nil; + _scrollView.contentInset = UIEdgeInsetsZero; + _scrollView.contentSize = CGSizeZero; + _scrollView.contentOffset = CGPointZero; + _callerInset = UIEdgeInsetsZero; + _needsContentClamp = NO; + _refreshEventSentForDrag = NO; + _lastScrollEvent = 0; + [super prepareForRecycle]; +} + +@end diff --git a/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm b/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm new file mode 100644 index 000000000..45d508010 --- /dev/null +++ b/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm @@ -0,0 +1,346 @@ +#import +#import +#import + +// Private declarations access the production class compiled into the pod. +@interface RNCCollapsiblePagerContentPressCancellationGestureRecognizer : UIGestureRecognizer +@property (nonatomic, weak) UIGestureRecognizer *reactTouchHandler; +- (void)cancelPressForPan:(UIPanGestureRecognizer *)pan; +- (void)observeIncomingTouch:(UITouch *)touch; +@end + +// UIKit can reset terminal states immediately outside real touch delivery. +// Observe requests while retaining the production implementation and UIKit setter. +@interface RNCPressCancellationObservedBridge : RNCCollapsiblePagerContentPressCancellationGestureRecognizer +@property (nonatomic, strong) NSMutableArray *requestedStates; +@end +@implementation RNCPressCancellationObservedBridge +- (void)setState:(UIGestureRecognizerState)state +{ + if (self.requestedStates == nil) self.requestedStates = [NSMutableArray new]; + [self.requestedStates addObject:@(state)]; + [super setState:state]; +} +@end + +// These doubles supply input facts only. The production bridge owns its state +// transitions; this suite does not claim to synthesize UIKit touch delivery. +@interface RNCPressCancellationTestTouch : UITouch +@property (nonatomic, strong) UIView *testView; +@property (nonatomic, assign) UITouchPhase testPhase; +@end +@implementation RNCPressCancellationTestTouch +- (UIView *)view { return self.testView; } +- (UITouchPhase)phase { return self.testPhase; } +@end + +@interface RNCPressCancellationTestEvent : UIEvent +@property (nonatomic, copy) NSSet *testTouches; +@end +@implementation RNCPressCancellationTestEvent +- (NSSet *)allTouches { return self.testTouches; } +@end + +@interface RNCPressCancellationTestPan : UIPanGestureRecognizer +@property (nonatomic, assign) UIGestureRecognizerState testState; +@end +@implementation RNCPressCancellationTestPan +- (UIGestureRecognizerState)state { return self.testState; } +@end + +@interface RNCCollapsiblePagerPressCancellationTests : XCTestCase +@property (nonatomic, strong) UIView *host; +@property (nonatomic, strong) UIView *surface; +@property (nonatomic, strong) UIView *content; +@property (nonatomic, strong) RCTSurfaceTouchHandler *handler; +@property (nonatomic, strong) RNCPressCancellationObservedBridge *bridge; +@property (nonatomic, strong) RNCPressCancellationTestPan *pan; +@property (nonatomic, strong) RNCPressCancellationTestTouch *touch; +@end + +@implementation RNCCollapsiblePagerPressCancellationTests + +- (void)setUp +{ + [super setUp]; + self.host = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 320, 600)]; + self.surface = [[UIView alloc] initWithFrame:self.host.bounds]; + self.content = [[UIView alloc] initWithFrame:self.surface.bounds]; + [self.host addSubview:self.surface]; + [self.surface addSubview:self.content]; + self.handler = [RCTSurfaceTouchHandler new]; + [self.handler attachToView:self.surface]; + Class bridgeClass = NSClassFromString(@"RNCCollapsiblePagerContentPressCancellationGestureRecognizer"); + XCTAssertNotNil(bridgeClass); + self.bridge = [RNCPressCancellationObservedBridge new]; + self.bridge.reactTouchHandler = self.handler; + [self.host addGestureRecognizer:self.bridge]; + self.pan = [RNCPressCancellationTestPan new]; + self.pan.testState = UIGestureRecognizerStateBegan; + self.touch = [self touchInView:self.content phase:UITouchPhaseBegan]; +} + +- (void)tearDown +{ + [self.host removeGestureRecognizer:self.bridge]; + [self.handler detachFromView:self.surface]; + self.bridge = nil; + self.handler = nil; + self.touch = nil; + self.pan = nil; + self.content = nil; + self.surface = nil; + self.host = nil; + [super tearDown]; +} + +- (RNCPressCancellationTestTouch *)touchInView:(UIView *)view phase:(UITouchPhase)phase +{ + RNCPressCancellationTestTouch *touch = [RNCPressCancellationTestTouch new]; + touch.testView = view; + touch.testPhase = phase; + return touch; +} + +- (RNCPressCancellationTestEvent *)eventWithTouches:(NSSet *)touches +{ + RNCPressCancellationTestEvent *event = [RNCPressCancellationTestEvent new]; + event.testTouches = touches; + return event; +} + +- (void)beginSingleTouch +{ + [self.bridge touchesBegan:[NSSet setWithObject:self.touch] + withEvent:[self eventWithTouches:[NSSet setWithObject:self.touch]]]; +} + +- (void)testPreventionTargetsExactHandlerAndUsesExternalHost +{ + RCTSurfaceTouchHandler *otherHandler = [RCTSurfaceTouchHandler new]; + UIPanGestureRecognizer *nativePan = [UIPanGestureRecognizer new]; + XCTAssertTrue([self.bridge canPreventGestureRecognizer:self.handler]); + XCTAssertFalse([self.bridge canPreventGestureRecognizer:otherHandler]); + XCTAssertFalse([self.bridge canPreventGestureRecognizer:nativePan]); + XCTAssertTrue([self.handler canBePreventedByGestureRecognizer:self.bridge]); + + [self.content addGestureRecognizer:nativePan]; + XCTAssertFalse([self.handler canBePreventedByGestureRecognizer:nativePan]); +} + +- (void)testOnlyBeganPanWithTrackedLiveTouchActivates +{ + [self.bridge cancelPressForPan:self.pan]; + XCTAssertEqual(self.bridge.state, UIGestureRecognizerStatePossible); + [self beginSingleTouch]; + for (NSNumber *state in @[@(UIGestureRecognizerStatePossible), + @(UIGestureRecognizerStateChanged), + @(UIGestureRecognizerStateFailed), + @(UIGestureRecognizerStateEnded)]) { + self.pan.testState = (UIGestureRecognizerState)state.integerValue; + [self.bridge cancelPressForPan:self.pan]; + XCTAssertEqual(self.bridge.state, UIGestureRecognizerStatePossible); + } + self.pan.testState = UIGestureRecognizerStateBegan; + [self.bridge cancelPressForPan:self.pan]; + XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan); +} + +- (void)testEndedAndCancelledOriginTouchesCannotActivate +{ + [self beginSingleTouch]; + for (NSNumber *phase in @[@(UITouchPhaseEnded), @(UITouchPhaseCancelled)]) { + self.touch.testPhase = (UITouchPhase)phase.integerValue; + [self.bridge cancelPressForPan:self.pan]; + XCTAssertEqual(self.bridge.state, UIGestureRecognizerStatePossible); + } +} + +- (void)testSecondActiveTouchOutsidePagerButOnSameSurfaceBlocksCancellation +{ + UIView *sibling = [UIView new]; + [self.surface addSubview:sibling]; + RNCPressCancellationTestTouch *second = [self touchInView:sibling phase:UITouchPhaseStationary]; + [self beginSingleTouch]; + [self.bridge touchesMoved:[NSSet setWithObject:self.touch] + withEvent:[self eventWithTouches:[NSSet setWithObjects:self.touch, second, nil]]]; + [self.bridge cancelPressForPan:self.pan]; + XCTAssertEqual(self.bridge.state, UIGestureRecognizerStatePossible); +} + +- (void)testOtherSurfaceAndFinishedTouchesDoNotCountAsActiveSurfaceTouches +{ + UIView *otherSurface = [UIView new]; + [self.host addSubview:otherSurface]; + RNCPressCancellationTestTouch *other = [self touchInView:otherSurface phase:UITouchPhaseMoved]; + RNCPressCancellationTestTouch *ended = [self touchInView:self.surface phase:UITouchPhaseEnded]; + RNCPressCancellationTestTouch *cancelled = [self touchInView:self.content phase:UITouchPhaseCancelled]; + [self.bridge touchesBegan:[NSSet setWithObject:self.touch] + withEvent:[self eventWithTouches:[NSSet setWithObjects:self.touch, other, ended, cancelled, nil]]]; + [self.bridge cancelPressForPan:self.pan]; + XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan); +} + +- (void)testMultipleOriginTouchesFailWithoutRecognizing +{ + RNCPressCancellationTestTouch *second = [self touchInView:self.content phase:UITouchPhaseBegan]; + NSSet *touches = [NSSet setWithObjects:self.touch, second, nil]; + [self.bridge touchesBegan:touches withEvent:[self eventWithTouches:touches]]; + XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateFailed)]); + XCTAssertNotEqual(self.bridge.state, UIGestureRecognizerStateBegan); + [self.bridge cancelPressForPan:self.pan]; + XCTAssertNotEqual(self.bridge.state, UIGestureRecognizerStateBegan); +} + +- (void)testSecondTouchAfterRecognitionDoesNotFailOrEndTheTrackedGesture +{ + [self beginSingleTouch]; + [self.bridge cancelPressForPan:self.pan]; + XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan); + RNCPressCancellationTestTouch *second = [self touchInView:self.content phase:UITouchPhaseBegan]; + RNCPressCancellationTestEvent *event = + [self eventWithTouches:[NSSet setWithObjects:self.touch, second, nil]]; + NSUInteger requestCount = self.bridge.requestedStates.count; + XCTAssertNoThrow([self.bridge touchesBegan:[NSSet setWithObject:second] withEvent:event]); + XCTAssertEqual(self.bridge.requestedStates.count, requestCount); + XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan); + second.testPhase = UITouchPhaseEnded; + [self.bridge touchesEnded:[NSSet setWithObject:second] withEvent:event]; + XCTAssertEqual(self.bridge.requestedStates.count, requestCount); + XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan); + self.touch.testPhase = UITouchPhaseEnded; + [self.bridge touchesEnded:[NSSet setWithObject:self.touch] withEvent:event]; + XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateEnded)]); + XCTAssertNotEqual(self.bridge.state, UIGestureRecognizerStateBegan); +} + +- (void)testSecondTouchCancellationDoesNotCancelTheTrackedGesture +{ + [self beginSingleTouch]; + [self.bridge cancelPressForPan:self.pan]; + RNCPressCancellationTestTouch *second = [self touchInView:self.content phase:UITouchPhaseBegan]; + RNCPressCancellationTestEvent *event = + [self eventWithTouches:[NSSet setWithObjects:self.touch, second, nil]]; + [self.bridge touchesBegan:[NSSet setWithObject:second] withEvent:event]; + second.testPhase = UITouchPhaseCancelled; + [self.bridge touchesCancelled:[NSSet setWithObject:second] withEvent:event]; + XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan); + self.touch.testPhase = UITouchPhaseCancelled; + [self.bridge touchesCancelled:[NSSet setWithObject:self.touch] withEvent:event]; + XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateCancelled)]); + XCTAssertNotEqual(self.bridge.state, UIGestureRecognizerStateBegan); +} + +- (void)testRejectedSameSurfaceTouchRemainsLatchedUntilReset +{ + UIView *sibling = [UIView new]; + [self.surface addSubview:sibling]; + RNCPressCancellationTestTouch *second = [self touchInView:sibling phase:UITouchPhaseBegan]; + [self beginSingleTouch]; + // The pager delegate observes this touch even when it rejects delivery to the bridge. + [self.bridge observeIncomingTouch:second]; + second.testPhase = UITouchPhaseEnded; + [self.bridge touchesMoved:[NSSet setWithObject:self.touch] + withEvent:[self eventWithTouches:[NSSet setWithObject:self.touch]]]; + [self.bridge cancelPressForPan:self.pan]; + XCTAssertEqual(self.bridge.state, UIGestureRecognizerStatePossible); + [self.bridge reset]; + [self beginSingleTouch]; + [self.bridge cancelPressForPan:self.pan]; + XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan); +} + +- (void)testObservedOtherSurfaceTouchDoesNotLatchCurrentSurface +{ + UIView *otherSurface = [UIView new]; + [self.host addSubview:otherSurface]; + RNCPressCancellationTestTouch *second = [self touchInView:otherSurface phase:UITouchPhaseBegan]; + [self beginSingleTouch]; + [self.bridge observeIncomingTouch:second]; + [self.bridge cancelPressForPan:self.pan]; + XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan); +} + +- (void)testHostMismatchDoesNotCancel +{ + [self.host removeGestureRecognizer:self.bridge]; + [self.surface addGestureRecognizer:self.bridge]; + [self beginSingleTouch]; + [self.bridge cancelPressForPan:self.pan]; + XCTAssertNotEqual(self.bridge.state, UIGestureRecognizerStateBegan); + [self.surface removeGestureRecognizer:self.bridge]; +} + +- (void)testResetDropsPendingTouchAndAllowsANewSequence +{ + [self beginSingleTouch]; + [self.bridge reset]; + [self.bridge cancelPressForPan:self.pan]; + XCTAssertEqual(self.bridge.state, UIGestureRecognizerStatePossible); + [self beginSingleTouch]; + [self.bridge cancelPressForPan:self.pan]; + XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan); +} + +- (void)testRecognizedSequenceEndsAndReleasesEvent +{ + __weak UIEvent *weakEvent; + @autoreleasepool { + RNCPressCancellationTestEvent *event = [self eventWithTouches:[NSSet setWithObject:self.touch]]; + weakEvent = event; + [self.bridge touchesBegan:[NSSet setWithObject:self.touch] withEvent:event]; + [self.bridge cancelPressForPan:self.pan]; + XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan); + self.touch.testPhase = UITouchPhaseEnded; + [self.bridge touchesEnded:[NSSet setWithObject:self.touch] withEvent:event]; + XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateEnded)]); + XCTAssertNotEqual(self.bridge.state, UIGestureRecognizerStateBegan); + } + XCTAssertNil(weakEvent); +} + +- (void)testUnrecognizedCancellationFailsAndReleasesEvent +{ + __weak UIEvent *weakEvent; + @autoreleasepool { + RNCPressCancellationTestEvent *event = [self eventWithTouches:[NSSet setWithObject:self.touch]]; + weakEvent = event; + [self.bridge touchesBegan:[NSSet setWithObject:self.touch] withEvent:event]; + self.touch.testPhase = UITouchPhaseCancelled; + [self.bridge touchesCancelled:[NSSet setWithObject:self.touch] withEvent:event]; + XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateFailed)]); + XCTAssertNotEqual(self.bridge.state, UIGestureRecognizerStateBegan); + } + XCTAssertNil(weakEvent); +} + +- (void)testRecognizedCancellationTerminatesTheBridge +{ + [self beginSingleTouch]; + [self.bridge cancelPressForPan:self.pan]; + XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan); + self.touch.testPhase = UITouchPhaseCancelled; + [self.bridge touchesCancelled:[NSSet setWithObject:self.touch] + withEvent:[self eventWithTouches:[NSSet setWithObject:self.touch]]]; + XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateCancelled)]); + XCTAssertNotEqual(self.bridge.state, UIGestureRecognizerStateBegan); +} + +- (void)testHandlerBindingIsWeak +{ + __weak UIGestureRecognizer *weakHandler; + @autoreleasepool { + UIGestureRecognizer *handler = [UIGestureRecognizer new]; + weakHandler = handler; + self.bridge.reactTouchHandler = handler; + XCTAssertTrue([self.bridge canPreventGestureRecognizer:handler]); + XCTAssertFalse([self.bridge canPreventGestureRecognizer:self.handler]); + } + XCTAssertNil(weakHandler); + XCTAssertNil(self.bridge.reactTouchHandler); + [self beginSingleTouch]; + [self.bridge cancelPressForPan:self.pan]; + XCTAssertEqual(self.bridge.state, UIGestureRecognizerStatePossible); +} + +@end diff --git a/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerPressCancellationWiringTests.mm b/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerPressCancellationWiringTests.mm new file mode 100644 index 000000000..26e629171 --- /dev/null +++ b/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerPressCancellationWiringTests.mm @@ -0,0 +1,109 @@ +#import +#import + +#import "RNCCollapsiblePagerViewComponentView.h" +#import + +using namespace facebook::react; + +@interface RNCPressWiringTestTouch : UITouch +@property (nonatomic, strong) UIView *testView; +@end +@implementation RNCPressWiringTestTouch +- (UIView *)view { return self.testView; } +@end + +@interface RNCCollapsiblePagerPressCancellationWiringTests : XCTestCase +@property (nonatomic, strong) UIWindow *window; +@property (nonatomic, strong) UIView *host; +@property (nonatomic, strong) UIView *surface; +@property (nonatomic, strong) RCTSurfaceTouchHandler *handler; +@property (nonatomic, strong) RNCCollapsiblePagerViewComponentView *pager; +@end + +@implementation RNCCollapsiblePagerPressCancellationWiringTests + +- (void)setUp +{ + [super setUp]; + // Window membership installs the bridge without presenting or taking focus. + self.window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 600)]; + self.host = [[UIView alloc] initWithFrame:self.window.bounds]; + self.surface = [[UIView alloc] initWithFrame:self.host.bounds]; + [self.window addSubview:self.host]; + [self.host addSubview:self.surface]; + self.handler = [RCTSurfaceTouchHandler new]; + [self.handler attachToView:self.surface]; + self.pager = [[RNCCollapsiblePagerViewComponentView alloc] init]; + auto props = std::make_shared(); + props->nativeSmoothHeaderScrollEnabled = true; + [self.pager updateProps:props oldProps:nullptr]; + self.pager.frame = self.surface.bounds; + [self.surface addSubview:self.pager]; +} + +- (void)tearDown +{ + [self.pager removeFromSuperview]; + [self.handler detachFromView:self.surface]; + self.pager = nil; + self.handler = nil; + self.surface = nil; + self.host = nil; + self.window = nil; + [super tearDown]; +} + +- (void)testInstalledBridgeCancelsTouchesAndFiltersItsContentScope +{ + UIGestureRecognizer *bridge = [self.pager valueForKey:@"contentPressCancellationGesture"]; + XCTAssertNotNil(bridge); + XCTAssertTrue([bridge isKindOfClass:NSClassFromString(@"RNCCollapsiblePagerContentPressCancellationGestureRecognizer")]); + XCTAssertEqual(bridge.view, self.host); + XCTAssertEqual([bridge valueForKey:@"reactTouchHandler"], self.handler); + XCTAssertTrue(bridge.cancelsTouchesInView); + XCTAssertFalse(bridge.delaysTouchesBegan); + XCTAssertFalse(bridge.delaysTouchesEnded); + XCTAssertTrue([self.handler canBePreventedByGestureRecognizer:bridge]); + + UIScrollView *scrollView = [self.pager valueForKey:@"pagerScrollView"]; + XCTAssertNotNil(scrollView); + UIView *content = [UIView new]; + [scrollView addSubview:content]; + UIView *header = [self.pager valueForKey:@"sharedHeaderHostView"]; + // Smooth mode reparents the shared header into the same content subtree. + [scrollView addSubview:header]; + UIView *headerButton = [UIView new]; + [header addSubview:headerButton]; + RNCPressWiringTestTouch *touch = [RNCPressWiringTestTouch new]; + id delegate = bridge.delegate; + XCTAssertEqual((id)delegate, self.pager); + touch.testView = content; + XCTAssertTrue([delegate gestureRecognizer:bridge shouldReceiveTouch:touch]); + touch.testView = headerButton; + XCTAssertFalse([delegate gestureRecognizer:bridge shouldReceiveTouch:touch]); + touch.testView = self.surface; + XCTAssertFalse([delegate gestureRecognizer:bridge shouldReceiveTouch:touch]); +} + +- (void)testWindowDetachUnbindsInstalledBridge +{ + UIGestureRecognizer *bridge = [self.pager valueForKey:@"contentPressCancellationGesture"]; + XCTAssertNotNil(bridge); + [self.pager removeFromSuperview]; + XCTAssertNil(bridge.view); + XCTAssertNil([bridge valueForKey:@"reactTouchHandler"]); + XCTAssertNil([self.pager valueForKey:@"contentPressCancellationGesture"]); +} + +- (void)testRecycleUnbindsInstalledBridge +{ + UIGestureRecognizer *bridge = [self.pager valueForKey:@"contentPressCancellationGesture"]; + XCTAssertNotNil(bridge); + [self.pager prepareForRecycle]; + XCTAssertNil(bridge.view); + XCTAssertNil([bridge valueForKey:@"reactTouchHandler"]); + XCTAssertNil([self.pager valueForKey:@"contentPressCancellationGesture"]); +} + +@end diff --git a/native-views/react-native-pager-view/ios/tests/RNCNativeScrollerTests.mm b/native-views/react-native-pager-view/ios/tests/RNCNativeScrollerTests.mm new file mode 100644 index 000000000..d0f7d63ba --- /dev/null +++ b/native-views/react-native-pager-view/ios/tests/RNCNativeScrollerTests.mm @@ -0,0 +1,252 @@ +#import +#import "RNCNativeScrollerComponentView.h" +#import "RNCCollapsiblePagerViewComponentView.h" +#import + +using namespace facebook::react; + +@interface RNCNativeScrollerComponentView (Tests) +- (void)emitScrollEvent:(NSInteger)kind velocity:(CGPoint)velocity target:(CGPoint)target; +- (void)clampContentOffsetIfNeeded; +- (void)scrollTo:(double)x y:(double)y animated:(BOOL)animated; +- (void)setRefreshing:(BOOL)refreshing; +- (CGFloat)contentViewportHeight; +@end + +@interface RNCCollapsiblePagerViewComponentView (ScrollerTests) +- (void)applyInsetsToScrollView:(UIScrollView *)scrollView pageIndex:(NSInteger)index restore:(BOOL)restore; +- (void)restoreScrollViewState:(UIScrollView *)scrollView resetSavedOffset:(BOOL)reset; +@end + +@interface RNCObservedScroller : RNCNativeScrollerComponentView +@property (nonatomic, strong) NSMutableArray *events; +@end +@implementation RNCObservedScroller +- (void)emitScrollEvent:(NSInteger)kind velocity:(CGPoint)velocity target:(CGPoint)target +{ + if (self.events == nil) self.events = [NSMutableArray new]; + [self.events addObject:@(kind)]; + [super emitScrollEvent:kind velocity:velocity target:target]; +} +@end + +@interface RNCNativeScrollerTests : XCTestCase +@property (nonatomic, strong) RNCObservedScroller *scroller; +@end +@implementation RNCNativeScrollerTests + +- (void)setUp +{ + [super setUp]; + self.scroller = [RNCObservedScroller new]; + LayoutMetrics metrics; + metrics.frame.size = {320, 600}; + [self.scroller updateLayoutMetrics:metrics oldLayoutMetrics:{}]; +} + +- (void)tearDown +{ + [self.scroller prepareForRecycle]; + self.scroller = nil; + [super tearDown]; +} + +- (void)testContentSizeDoesNotRelayoutReactContentOrSharedHeader +{ + RCTViewComponentView *content = [RCTViewComponentView new]; + content.frame = CGRectMake(0, 0, 320, 900); + [self.scroller mountChildComponentView:content index:0]; + UIView *header = [[UIView alloc] initWithFrame:CGRectMake(0, -200, 320, 200)]; + [self.scroller.scrollView addSubview:header]; + auto props = std::make_shared(); + props->contentWidth = 320; + props->contentHeight = 900; + [self.scroller updateProps:props oldProps:nullptr]; + XCTAssertEqualWithAccuracy(self.scroller.scrollView.contentSize.height, 900, 0.01); + XCTAssertTrue(CGRectEqualToRect(content.frame, CGRectMake(0, 0, 320, 900))); + XCTAssertTrue(CGRectEqualToRect(header.frame, CGRectMake(0, -200, 320, 200))); + XCTAssertEqual(header.superview, self.scroller.scrollView); + XCTAssertFalse([self.scroller.scrollView isKindOfClass:NSClassFromString(@"RCTEnhancedScrollView")]); +} + +- (void)testShrinkClampsOnlyUnreachableUpperOffset +{ + auto props = std::make_shared(); + props->contentWidth = 320; + props->contentHeight = 1500; + [self.scroller updateProps:props oldProps:nullptr]; + self.scroller.scrollView.contentInset = UIEdgeInsetsMake(200, 0, 468, 0); + self.scroller.scrollView.contentOffset = CGPointMake(0, 800); + auto shorter = std::make_shared(); + shorter->contentWidth = 320; + shorter->contentHeight = 132; + [self.scroller updateProps:shorter oldProps:props]; + [self.scroller clampContentOffsetIfNeeded]; + XCTAssertEqualWithAccuracy(self.scroller.scrollView.contentOffset.y, 0, 0.01); + self.scroller.scrollView.contentOffset = CGPointMake(0, -240); + auto empty = std::make_shared(); + empty->contentWidth = 320; + empty->contentHeight = 0; + [self.scroller updateProps:empty oldProps:shorter]; + [self.scroller clampContentOffsetIfNeeded]; + XCTAssertEqualWithAccuracy(self.scroller.scrollView.contentOffset.y, -240, 0.01); +} + +- (void)testNativeInterruptionEndsEachDragAndMomentumOnlyOnce +{ + UIScrollView *scroll = self.scroller.scrollView; + id delegate = scroll.delegate; + [delegate scrollViewWillBeginDragging:scroll]; + [self.scroller stopScrolling]; + [delegate scrollViewDidEndDragging:scroll willDecelerate:NO]; + XCTAssertEqualObjects(self.scroller.events, (@[@1, @2])); + [self.scroller.events removeAllObjects]; + [delegate scrollViewWillBeginDecelerating:scroll]; + [self.scroller stopScrolling]; + [delegate scrollViewDidEndDecelerating:scroll]; + NSPredicate *terminal = [NSPredicate predicateWithFormat:@"SELF == 4"]; + XCTAssertEqual([self.scroller.events filteredArrayUsingPredicate:terminal].count, 1u); + XCTAssertEqualObjects(self.scroller.events.firstObject, @3); +} + +- (void)testRefreshReplacementAndRecycleDoNotKeepOldControl +{ + auto props = std::make_shared(); + props->refreshEnabled = true; + props->refreshProgressViewOffset = 24; + [self.scroller updateProps:props oldProps:nullptr]; + UIRefreshControl *original = self.scroller.scrollView.refreshControl; + XCTAssertNotNil(original); + XCTAssertEqualWithAccuracy(original.bounds.origin.y, -24, 0.01); + [self.scroller prepareForRecycle]; + XCTAssertNil(self.scroller.scrollView.refreshControl); + [self.scroller updateProps:props oldProps:props]; + XCTAssertNotNil(self.scroller.scrollView.refreshControl); + XCTAssertNotEqual(self.scroller.scrollView.refreshControl, original); + XCTAssertEqualWithAccuracy(self.scroller.scrollView.refreshControl.bounds.origin.y, -24, 0.01); + [self.scroller setRefreshing:NO]; + XCTAssertFalse(self.scroller.scrollView.refreshControl.refreshing); +} + +- (void)testCallerInsetUpdatesSurvivePagerReapplyAndRelease +{ + RNCCollapsiblePagerViewComponentView *pager = [RNCCollapsiblePagerViewComponentView new]; + auto pagerProps = std::make_shared(); + pagerProps->headerHeight = 120; + pagerProps->stickyHeaderHeight = 40; + [pager updateProps:pagerProps oldProps:nullptr]; + [pager addSubview:self.scroller]; + auto props = std::make_shared(); + props->contentWidth = 320; + props->contentHeight = 1500; + props->contentInsetTop = 10; + [self.scroller updateProps:props oldProps:nullptr]; + [pager applyInsetsToScrollView:self.scroller.scrollView pageIndex:0 restore:NO]; + XCTAssertEqualWithAccuracy(self.scroller.scrollView.contentInset.top, 170, 0.01); + auto updated = std::make_shared(); + updated->contentWidth = 320; + updated->contentHeight = 1500; + updated->contentInsetTop = 30; + updated->contentInsetBottom = 20; + [self.scroller updateProps:updated oldProps:props]; + [pager applyInsetsToScrollView:self.scroller.scrollView pageIndex:0 restore:NO]; + XCTAssertEqualWithAccuracy(self.scroller.scrollView.contentInset.top, 190, 0.01); + [pager restoreScrollViewState:self.scroller.scrollView resetSavedOffset:YES]; + XCTAssertEqualWithAccuracy(self.scroller.scrollView.contentInset.top, 30, 0.01); + XCTAssertEqualWithAccuracy(self.scroller.scrollView.contentInset.bottom, 20, 0.01); + [self.scroller removeFromSuperview]; +} + +- (void)testRecycledDelegateCannotStartASequenceForTheNextOwner +{ + UIScrollView *scroll = self.scroller.scrollView; + id oldDelegate = scroll.delegate; + [self.scroller prepareForRecycle]; + XCTAssertNil(scroll.delegate); + [oldDelegate scrollViewWillBeginDragging:scroll]; + [oldDelegate scrollViewWillBeginDecelerating:scroll]; + auto props = std::make_shared(); + [self.scroller updateProps:props oldProps:nullptr]; + XCTAssertEqual(scroll.delegate, self.scroller); + [self.scroller stopScrolling]; + NSPredicate *terminal = [NSPredicate predicateWithFormat:@"SELF == 2 OR SELF == 4"]; + XCTAssertEqual([self.scroller.events filteredArrayUsingPredicate:terminal].count, 0u); +} + +- (void)testPagerExplicitlyFindsIndependentScrollView +{ + RNCCollapsiblePagerViewComponentView *pager = [RNCCollapsiblePagerViewComponentView new]; + UIView *page = [UIView new]; + [page addSubview:self.scroller]; + SEL finder = NSSelectorFromString(@"findExplicitNativeScrollerInView:"); + UIScrollView *(*find)(id, SEL, UIView *) = + (UIScrollView *(*)(id, SEL, UIView *))[pager methodForSelector:finder]; + XCTAssertEqual(find(pager, finder, page), self.scroller.scrollView); +} + +- (void)testContentViewportUsesOnlyCallerInsetsAndStaticStickyHeight +{ + RNCCollapsiblePagerViewComponentView *pager = [RNCCollapsiblePagerViewComponentView new]; + auto pagerProps = std::make_shared(); + pagerProps->headerHeight = 120; + pagerProps->stickyHeaderHeight = 40; + [pager updateProps:pagerProps oldProps:nullptr]; + [pager addSubview:self.scroller]; + auto props = std::make_shared(); + props->contentWidth = 320; + props->contentHeight = 20; + props->contentInsetTop = 10; + props->contentInsetBottom = 20; + [self.scroller updateProps:props oldProps:nullptr]; + [pager applyInsetsToScrollView:self.scroller.scrollView pageIndex:0 restore:NO]; + XCTAssertEqualWithAccuracy([self.scroller contentViewportHeight], 530, 0.01); + // Synthetic bottom fill and temporary refresh insets are not caller geometry. + self.scroller.scrollView.contentInset = UIEdgeInsetsMake(230, 0, 510, 0); + XCTAssertEqualWithAccuracy([self.scroller contentViewportHeight], 530, 0.01); + auto filled = std::make_shared(); + filled->contentWidth = 320; + filled->contentHeight = 530; + filled->contentInsetTop = 10; + filled->contentInsetBottom = 20; + [self.scroller updateProps:filled oldProps:props]; + XCTAssertEqualWithAccuracy([self.scroller contentViewportHeight], 530, 0.01); + LayoutMetrics metrics; + metrics.frame.size = {320, 700}; + [self.scroller updateLayoutMetrics:metrics oldLayoutMetrics:{}]; + XCTAssertEqualWithAccuracy([self.scroller contentViewportHeight], 630, 0.01); + auto updated = std::make_shared(); + updated->contentWidth = 320; + updated->contentHeight = 530; + updated->contentInsetTop = 10; + updated->contentInsetBottom = 30; + [self.scroller updateProps:updated oldProps:filled]; + XCTAssertEqualWithAccuracy([self.scroller contentViewportHeight], 620, 0.01); + auto tallerSticky = std::make_shared(); + tallerSticky->headerHeight = 250; + tallerSticky->stickyHeaderHeight = 60; + [pager updateProps:tallerSticky oldProps:pagerProps]; + [pager applyInsetsToScrollView:self.scroller.scrollView pageIndex:0 restore:NO]; + XCTAssertEqualWithAccuracy([self.scroller contentViewportHeight], 600, 0.01); + [pager restoreScrollViewState:self.scroller.scrollView resetSavedOffset:YES]; + XCTAssertEqualWithAccuracy([self.scroller contentViewportHeight], 660, 0.01); + [self.scroller removeFromSuperview]; +} + +- (void)testContentViewportReleasesIndependentPagerContributions +{ + NSObject *outer = [NSObject new]; + NSObject *inner = [NSObject new]; + [self.scroller setPagerStickyHeight:40 forOwner:outer]; + [self.scroller setPagerStickyHeight:20 forOwner:inner]; + XCTAssertEqualWithAccuracy([self.scroller contentViewportHeight], 540, 0.01); + [self.scroller setPagerStickyHeight:40 forOwner:outer]; + XCTAssertEqualWithAccuracy([self.scroller contentViewportHeight], 540, 0.01); + [self.scroller setPagerStickyHeight:0 forOwner:outer]; + XCTAssertEqualWithAccuracy([self.scroller contentViewportHeight], 580, 0.01); + [self.scroller prepareForRecycle]; + auto props = std::make_shared(); + [self.scroller updateProps:props oldProps:nullptr]; + XCTAssertEqualWithAccuracy([self.scroller contentViewportHeight], 600, 0.01); +} + +@end diff --git a/native-views/react-native-pager-view/package.json b/native-views/react-native-pager-view/package.json index 353f05b48..1c6189222 100644 --- a/native-views/react-native-pager-view/package.json +++ b/native-views/react-native-pager-view/package.json @@ -33,7 +33,9 @@ "!**/__fixtures__", "!**/__mocks__", "!**/.*", - "!**/*.map" + "!**/*.map", + "common", + "react-native.config.js" ], "scripts": { "clean": "del-cli lib", @@ -99,7 +101,8 @@ "ios": { "componentProvider": { "RNCViewPager": "RNCPagerViewComponentView", - "RNCCollapsiblePagerView": "RNCCollapsiblePagerViewComponentView" + "RNCCollapsiblePagerView": "RNCCollapsiblePagerViewComponentView", + "RNCNativeScroller": "RNCNativeScrollerComponentView" } } } diff --git a/native-views/react-native-pager-view/react-native-pager-view.podspec b/native-views/react-native-pager-view/react-native-pager-view.podspec index 300bc0a19..9dc5a90f3 100644 --- a/native-views/react-native-pager-view/react-native-pager-view.podspec +++ b/native-views/react-native-pager-view/react-native-pager-view.podspec @@ -13,7 +13,8 @@ Pod::Spec.new do |s| s.ios.deployment_target = "15.1" s.source = { :git => "https://github.com/nicholasxuu/app-modules.git", :tag => "#{s.version}" } - s.source_files = "ios/**/*.{h,m,mm}" + s.source_files = "ios/**/*.{h,m,mm}", "common/cpp/**/*.{h,cpp}" + s.pod_target_xcconfig = { "HEADER_SEARCH_PATHS" => '"$(PODS_TARGET_SRCROOT)/common/cpp"' } s.exclude_files = "ios/tests/**/*" s.test_spec "Tests" do |test_spec| diff --git a/native-views/react-native-pager-view/react-native.config.js b/native-views/react-native-pager-view/react-native.config.js new file mode 100644 index 000000000..32d870da3 --- /dev/null +++ b/native-views/react-native-pager-view/react-native.config.js @@ -0,0 +1,15 @@ +module.exports = { + dependency: { + platforms: { + android: { + libraryName: 'pagerview', + componentDescriptors: [ + 'RNCViewPagerComponentDescriptor', + 'RNCCollapsiblePagerViewComponentDescriptor', + 'RNCNativeScrollerComponentDescriptor', + ], + cmakeListsPath: 'src/main/jni/CMakeLists.txt', + }, + }, + }, +}; diff --git a/native-views/react-native-pager-view/src/CollapsiblePagerView.tsx b/native-views/react-native-pager-view/src/CollapsiblePagerView.tsx index b07f615d6..e6aacda3c 100644 --- a/native-views/react-native-pager-view/src/CollapsiblePagerView.tsx +++ b/native-views/react-native-pager-view/src/CollapsiblePagerView.tsx @@ -130,9 +130,8 @@ export interface CollapsiblePagerViewProps nativeTabBar?: CollapsiblePagerNativeTabBarConfig; onNativeTabPress?: (event: CollapsiblePagerViewOnNativeTabPressEvent) => void; /** - * Animates the page change started by a native tab press. Defaults to true. - * Set false to jump straight to the pressed page, so a distant tab does not - * scroll through every page in between, including unmounted ones. + * Defaults to animating adjacent native tab presses and jumping directly to + * non-adjacent pages. Set true to always animate, or false to always jump. */ nativeTabPressAnimationEnabled?: boolean; /** Optional native secondary sticky header for iOS and Android. */ @@ -411,7 +410,8 @@ export class CollapsiblePagerView extends React.PureComponent< ) { this.dispatchNativeTabPageCommand( position, - this.props.nativeTabPressAnimationEnabled ?? true + this.props.nativeTabPressAnimationEnabled ?? + Math.abs(position - this.state.selectedPage) <= 1 ); } this.props.onNativeTabPress?.(event); diff --git a/native-views/react-native-pager-view/src/NativeScroller.tsx b/native-views/react-native-pager-view/src/NativeScroller.tsx index 8c2f1a38c..00f3ba054 100644 --- a/native-views/react-native-pager-view/src/NativeScroller.tsx +++ b/native-views/react-native-pager-view/src/NativeScroller.tsx @@ -1,34 +1,311 @@ import * as React from "react"; -import { ScrollView, type ScrollViewProps } from "react-native"; +import { + findNodeHandle, + Keyboard, + Platform, + StyleSheet, + TextInput, + View, + type GestureResponderEvent, + type NativeMethods, + type RefreshControlProps, + type ScrollView, + type ScrollViewProps, + type ViewProps, +} from "react-native"; + +import NativeScrollerHost, { Commands } from "./NativeScrollerNativeComponent"; export const NATIVE_SCROLLER_NATIVE_ID_PREFIX = "rnc-collapsible-pager-native-scroller"; -export interface NativeScrollerProps extends ScrollViewProps { - /** Stable identity used when a page replaces or remounts its primary scroller. */ +/** Supported vertical subset; horizontal, snapping, zoom and sticky rows are not supported. */ +export interface NativeScrollerProps + extends ViewProps, + Pick< + ScrollViewProps, + | "contentContainerStyle" + | "onContentSizeChange" + | "scrollEnabled" + | "nestedScrollEnabled" + | "showsVerticalScrollIndicator" + | "showsHorizontalScrollIndicator" + | "bounces" + | "alwaysBounceVertical" + | "contentInset" + | "keyboardDismissMode" + | "keyboardShouldPersistTaps" + | "scrollEventThrottle" + | "decelerationRate" + | "onScroll" + | "onScrollBeginDrag" + | "onScrollEndDrag" + | "onMomentumScrollBegin" + | "onMomentumScrollEnd" + > { pagerScrollKey?: string; + /** iOS only. Android retains AndroidX's native fling physics. */ + decelerationRate?: ScrollViewProps["decelerationRate"]; + /** Standard RefreshControl props are mapped to the independently owned native controller. */ + refreshControl?: React.ReactElement; +} + +type HostRef = React.ElementRef; + +export interface NativeScrollerRef + extends NativeMethods, + Pick< + React.ElementRef, + "scrollTo" | "scrollToEnd" | "flashScrollIndicators" + > { + getNativeScrollRef(): HostRef | null; + getScrollableNode(): number | null; } -/** - * Primary non-virtualized vertical scroller for a CollapsiblePagerView page. - */ +// The RN 0.86 JS registry distinguishes another input from an unhandled tap. +// TextInput.State's public facade does not export this query. +const textInputState = + require("react-native/Libraries/Components/TextInput/TextInputState") + .default as { + isTextInput(target: GestureResponderEvent["target"]): boolean; + }; + +const styles = StyleSheet.create({ + viewport: { flexGrow: 1, flexShrink: 1, overflow: "hidden" }, + content: { position: "absolute", top: 0, left: 0, right: 0, flexShrink: 0 }, +}); + export const NativeScroller = React.forwardRef< - React.ElementRef, + NativeScrollerRef, NativeScrollerProps >(function NativeScroller( - { nativeID, nestedScrollEnabled = true, pagerScrollKey, ...props }, - ref + { + children, + nativeID, + pagerScrollKey, + style, + contentContainerStyle, + onLayout, + onContentSizeChange, + refreshControl, + nestedScrollEnabled = true, + contentInset, + decelerationRate = "normal", + keyboardShouldPersistTaps = "never", + onScroll, + onScrollBeginDrag, + onMomentumScrollBegin, + onMomentumScrollEnd, + onStartShouldSetResponder, + onStartShouldSetResponderCapture, + onResponderGrant, + onResponderRelease, + onResponderTerminationRequest, + ...props + }, + forwardedRef ) { + const hostRef = React.useRef(null); const generatedId = React.useId(); - const scrollKey = pagerScrollKey ?? nativeID ?? generatedId; + const [contentViewportHeight, setContentViewportHeight] = React.useState(0); + const [contentSize, setContentSize] = React.useState({ width: 0, height: 0 }); + const [refreshEpoch, requestRefreshSync] = React.useReducer( + (value: number) => value + 1, + 0 + ); + const contentSizeRef = React.useRef(contentSize); + const scrolledSinceGrant = React.useRef(false); + const momentum = React.useRef(false); + const grantedDuringMomentum = React.useRef(false); + const persistTaps = + keyboardShouldPersistTaps === true + ? "always" + : keyboardShouldPersistTaps === false + ? "never" + : keyboardShouldPersistTaps; + const contentStyle = StyleSheet.flatten(contentContainerStyle); + const refresh = refreshControl?.props; + const rate = + typeof decelerationRate === "number" + ? decelerationRate + : decelerationRate === "fast" + ? Platform.OS === "ios" + ? 0.99 + : 0.9 + : Platform.OS === "ios" + ? 0.998 + : 0.985; + + React.useEffect(() => { + if (hostRef.current) + Commands.setRefreshState(hostRef.current, refresh?.refreshing ?? false); + }, [refresh?.refreshing, refreshEpoch]); + + React.useImperativeHandle( + forwardedRef, + () => ({ + scrollTo(options, x, animated) { + const host = hostRef.current; + if (!host) return; + if (typeof options === "number") { + Commands.scrollTo(host, x ?? 0, options, animated !== false); + } else { + Commands.scrollTo( + host, + options?.x ?? 0, + options?.y ?? 0, + options?.animated !== false + ); + } + }, + scrollToEnd(options) { + if (hostRef.current) + Commands.scrollToEnd(hostRef.current, options?.animated !== false); + }, + flashScrollIndicators() { + if (hostRef.current) Commands.flashScrollIndicators(hostRef.current); + }, + getNativeScrollRef: () => hostRef.current, + getScrollableNode: () => findNodeHandle(hostRef.current) ?? null, + measure: (...args) => hostRef.current?.measure(...args), + measureInWindow: (...args) => hostRef.current?.measureInWindow(...args), + measureLayout: (...args) => hostRef.current?.measureLayout(...args), + setNativeProps: (nativeProps) => + hostRef.current?.setNativeProps(nativeProps), + focus: () => hostRef.current?.focus(), + blur: () => hostRef.current?.blur(), + }), + [] + ); + + const keyboardIsDismissible = () => + Boolean( + TextInput.State.currentlyFocusedInput() && + (Keyboard.isVisible() || + (Platform.OS === "android" && Number(Platform.Version) < 30)) && + Keyboard.metrics()?.height !== 0 + ); return ( - + contentWidth={contentSize.width} + contentHeight={contentSize.height} + contentInsetTop={contentInset?.top ?? 0} + contentInsetRight={contentInset?.right ?? 0} + contentInsetBottom={contentInset?.bottom ?? 0} + contentInsetLeft={contentInset?.left ?? 0} + decelerationRate={rate} + keyboardShouldPersistTaps={persistTaps} + refreshEnabled={Boolean(refresh) && refresh?.enabled !== false} + refreshing={refresh?.refreshing ?? false} + refreshTintColor={refresh?.tintColor} + refreshColors={refresh?.colors} + refreshProgressBackgroundColor={refresh?.progressBackgroundColor} + refreshProgressViewOffset={refresh?.progressViewOffset ?? 0} + refreshTitle={refresh?.title} + refreshTitleColor={refresh?.titleColor} + refreshSize={refresh?.size === "large" ? "large" : "default"} + onRefresh={() => { + try { + refresh?.onRefresh?.(); + } finally { + requestRefreshSync(); + } + }} + onLayout={onLayout} + onContentViewportChange={(event) => { + const height = event.nativeEvent.height; + if (!Number.isFinite(height) || height < 0) return; + setContentViewportHeight((previous) => + previous === height ? previous : height + ); + }} + onScroll={(event) => { + scrolledSinceGrant.current = true; + onScroll?.(event); + }} + onScrollBeginDrag={(event) => { + scrolledSinceGrant.current = true; + onScrollBeginDrag?.(event); + }} + onMomentumScrollBegin={(event) => { + momentum.current = true; + onMomentumScrollBegin?.(event); + }} + onMomentumScrollEnd={(event) => { + momentum.current = false; + onMomentumScrollEnd?.(event); + }} + onStartShouldSetResponder={(event) => + onStartShouldSetResponder?.(event) ?? + (persistTaps === "handled" && + keyboardIsDismissible() && + event.target !== TextInput.State.currentlyFocusedInput()) + } + onStartShouldSetResponderCapture={(event) => + onStartShouldSetResponderCapture?.(event) ?? + (momentum.current || + (persistTaps === "never" && + keyboardIsDismissible() && + typeof event.target !== "number" && + !textInputState.isTextInput(event.target))) + } + onResponderGrant={(event) => { + scrolledSinceGrant.current = false; + grantedDuringMomentum.current = momentum.current; + onResponderGrant?.(event); + }} + onResponderRelease={(event) => { + onResponderRelease?.(event); + if ( + persistTaps !== "always" && + keyboardIsDismissible() && + event.target !== TextInput.State.currentlyFocusedInput() && + !scrolledSinceGrant.current && + !grantedDuringMomentum.current + ) + Keyboard.dismiss(); + }} + onResponderTerminationRequest={(event) => + onResponderTerminationRequest?.(event) ?? true + } + > + 0 && + (contentStyle?.minHeight === undefined || + typeof contentStyle.minHeight === "number") + ? { + minHeight: Math.max( + contentViewportHeight, + contentStyle?.minHeight ?? 0 + ), + } + : undefined, + styles.content, + ]} + onLayout={(event) => { + const { width, height } = event.nativeEvent.layout; + const previous = contentSizeRef.current; + if (previous.width === width && previous.height === height) return; + const next = { width, height }; + contentSizeRef.current = next; + setContentSize(next); + onContentSizeChange?.(width, height); + }} + > + {children} + + ); }); diff --git a/native-views/react-native-pager-view/src/NativeScrollerNativeComponent.ts b/native-views/react-native-pager-view/src/NativeScrollerNativeComponent.ts new file mode 100644 index 000000000..f2c482e47 --- /dev/null +++ b/native-views/react-native-pager-view/src/NativeScrollerNativeComponent.ts @@ -0,0 +1,101 @@ +import type * as React from "react"; +import { + codegenNativeCommands, + codegenNativeComponent, + type ColorValue, + type HostComponent, + type ViewProps, +} from "react-native"; +import type { + DirectEventHandler, + Double, + WithDefault, +} from "react-native/Libraries/Types/CodegenTypes"; + +export type NativeScrollerScrollEvent = Readonly<{ + contentInset: Readonly<{ + top: Double; + right: Double; + bottom: Double; + left: Double; + }>; + contentOffset: Readonly<{ x: Double; y: Double }>; + contentSize: Readonly<{ width: Double; height: Double }>; + layoutMeasurement: Readonly<{ width: Double; height: Double }>; + velocity: Readonly<{ x: Double; y: Double }>; + targetContentOffset: Readonly<{ x: Double; y: Double }>; + zoomScale: Double; +}>; + +export interface NativeProps extends ViewProps { + contentWidth?: WithDefault; + contentHeight?: WithDefault; + scrollEnabled?: WithDefault; + nestedScrollEnabled?: WithDefault; + showsVerticalScrollIndicator?: WithDefault; + showsHorizontalScrollIndicator?: WithDefault; + bounces?: WithDefault; + alwaysBounceVertical?: WithDefault; + keyboardDismissMode?: WithDefault<"none" | "on-drag" | "interactive", "none">; + keyboardShouldPersistTaps?: WithDefault< + "never" | "always" | "handled", + "never" + >; + scrollEventThrottle?: WithDefault; + decelerationRate?: WithDefault; + contentInsetTop?: WithDefault; + contentInsetRight?: WithDefault; + contentInsetBottom?: WithDefault; + contentInsetLeft?: WithDefault; + refreshEnabled?: WithDefault; + refreshing?: WithDefault; + refreshTintColor?: ColorValue; + refreshColors?: ReadonlyArray; + refreshProgressBackgroundColor?: ColorValue; + refreshProgressViewOffset?: WithDefault; + refreshTitle?: string; + refreshTitleColor?: ColorValue; + refreshSize?: WithDefault<"default" | "large", "default">; + onContentViewportChange?: DirectEventHandler>; + onRefresh?: DirectEventHandler; + onScroll?: DirectEventHandler; + onScrollBeginDrag?: DirectEventHandler; + onScrollEndDrag?: DirectEventHandler; + onMomentumScrollBegin?: DirectEventHandler; + onMomentumScrollEnd?: DirectEventHandler; +} + +type NativeScrollerHost = HostComponent; + +export interface NativeCommands { + scrollTo: ( + viewRef: React.ElementRef, + x: Double, + y: Double, + animated: boolean + ) => void; + scrollToEnd: ( + viewRef: React.ElementRef, + animated: boolean + ) => void; + flashScrollIndicators: ( + viewRef: React.ElementRef + ) => void; + setRefreshState: ( + viewRef: React.ElementRef, + refreshing: boolean + ) => void; +} + +export const Commands: NativeCommands = codegenNativeCommands({ + supportedCommands: [ + "scrollTo", + "scrollToEnd", + "flashScrollIndicators", + "setRefreshState", + ], +}); + +export default codegenNativeComponent("RNCNativeScroller", { + interfaceOnly: true, +}) as NativeScrollerHost; diff --git a/native-views/react-native-pager-view/src/__tests__/NativeScroller.test.tsx b/native-views/react-native-pager-view/src/__tests__/NativeScroller.test.tsx new file mode 100644 index 000000000..30c14041c --- /dev/null +++ b/native-views/react-native-pager-view/src/__tests__/NativeScroller.test.tsx @@ -0,0 +1,285 @@ +import * as React from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { + Keyboard, + StyleSheet, + View, + type GestureResponderEvent, + type LayoutChangeEvent, +} from "react-native"; +import { NativeScroller, type NativeScrollerRef } from "../NativeScroller"; +import NativeScrollerHost, { Commands } from "../NativeScrollerNativeComponent"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const mockFocusedInput = { input: "focused" }; +const mockOtherInput = { input: "other" }; +const mockView = { view: true }; +jest.mock("react-native", () => ({ + View: "View", + Platform: { OS: "ios", Version: 26 }, + StyleSheet: { + create: (styles: object) => styles, + flatten: (style: object | object[]) => + Object.assign({}, ...[style].flat(Infinity).filter(Boolean)), + }, + findNodeHandle: () => 42, + Keyboard: { + isVisible: jest.fn(() => true), + metrics: () => ({ height: 250 }), + dismiss: jest.fn(), + }, + TextInput: { + State: { + currentlyFocusedInput: () => mockFocusedInput, + }, + }, +})); +jest.mock("react-native/Libraries/Components/TextInput/TextInputState", () => ({ + __esModule: true, + default: { + isTextInput: (target: object) => + target === mockFocusedInput || target === mockOtherInput, + }, +})); +jest.mock("../NativeScrollerNativeComponent", () => { + const ReactMock = jest.requireActual("react"); + return { + __esModule: true, + default: ReactMock.forwardRef((props: object, ref) => + ReactMock.createElement("NativeHost", { ...props, ref }) + ), + Commands: { + scrollTo: jest.fn(), + scrollToEnd: jest.fn(), + flashScrollIndicators: jest.fn(), + setRefreshState: jest.fn(), + }, + }; +}); + +const nativeHandle = { + measure: jest.fn(), + measureInWindow: jest.fn(), + measureLayout: jest.fn(), + setNativeProps: jest.fn(), + focus: jest.fn(), + blur: jest.fn(), +}; +const layout = (width: number, height: number) => + ({ + nativeEvent: { layout: { x: 0, y: 0, width, height } }, + } as LayoutChangeEvent); +const touch = (target: object) => + ({ + target, + nativeEvent: { target, touches: [] }, + } as unknown as GestureResponderEvent); + +describe("NativeScroller bridge", () => { + let renderer: ReactTestRenderer; + const host = () => renderer.root.findByType(NativeScrollerHost).props; + const content = () => renderer.root.findByType(View).props; + const mount = (element: React.ReactElement) => + act(() => { + renderer = create(element, { createNodeMock: () => nativeHandle }); + }); + beforeEach(() => jest.clearAllMocks()); + afterEach(() => { + if (renderer) act(() => renderer.unmount()); + }); + + it("mounts one Yoga content view and reports changed dimensions without a layout feedback loop", () => { + const onContentSizeChange = jest.fn(); + const onLayout = jest.fn(); + mount( + + + + ); + expect(host().nativeID).toBe("rnc-collapsible-pager-native-scroller:spot"); + expect(host().nestedScrollEnabled).toBe(true); + expect(content().collapsable).toBe(false); + act(() => host().onLayout(layout(320, 600))); + expect(onLayout).toHaveBeenCalledTimes(1); + expect(StyleSheet.flatten(content().style).minHeight).toBe(100); + act(() => host().onContentViewportChange({ nativeEvent: { height: 500 } })); + expect(StyleSheet.flatten(content().style)).toMatchObject({ + paddingBottom: 24, + minHeight: 500, + position: "absolute", + }); + act(() => content().onLayout(layout(320, 900))); + act(() => content().onLayout(layout(320, 900))); + expect(host().contentWidth).toBe(320); + expect(host().contentHeight).toBe(900); + expect(onContentSizeChange).toHaveBeenCalledTimes(1); + act(() => content().onLayout(layout(320, 600))); + expect(onContentSizeChange).toHaveBeenLastCalledWith(320, 600); + }); + + it("fills only the native content viewport after inset changes and long-content shrink", () => { + const onContentSizeChange = jest.fn(); + mount( + + ); + expect(StyleSheet.flatten(content().style).minHeight).toBe(0); + act(() => content().onLayout(layout(320, 3000))); + act(() => + host().onContentViewportChange({ nativeEvent: { height: 1577 } }) + ); + act(() => + host().onContentViewportChange({ nativeEvent: { height: 1577 } }) + ); + expect(StyleSheet.flatten(content().style)).toMatchObject({ + minHeight: 1577, + paddingBottom: 24, + }); + expect(onContentSizeChange).toHaveBeenCalledTimes(1); + act(() => content().onLayout(layout(320, 1577))); + expect(host().contentHeight).toBe(1577); + act(() => + host().onContentViewportChange({ nativeEvent: { height: 1500 } }) + ); + expect(StyleSheet.flatten(content().style).minHeight).toBe(1500); + act(() => host().onContentViewportChange({ nativeEvent: { height: -1 } })); + expect(StyleSheet.flatten(content().style).minHeight).toBe(1500); + }); + + it("preserves an explicit content minimum above the usable native viewport", () => { + mount( + + ); + act(() => + host().onContentViewportChange({ nativeEvent: { height: 1577 } }) + ); + expect(StyleSheet.flatten(content().style).minHeight).toBe(2000); + }); + + it("maps commands and host measurement through a stable public ref", () => { + const ref = React.createRef(); + mount(); + const firstRef = ref.current; + act(() => ref.current?.scrollTo({ y: 60, animated: false })); + expect(Commands.scrollTo).toHaveBeenLastCalledWith( + nativeHandle, + 0, + 60, + false + ); + act(() => ref.current?.scrollTo(80, 10, true)); + expect(Commands.scrollTo).toHaveBeenLastCalledWith( + nativeHandle, + 10, + 80, + true + ); + act(() => ref.current?.scrollToEnd()); + expect(Commands.scrollToEnd).toHaveBeenLastCalledWith(nativeHandle, true); + const callback = jest.fn(); + ref.current?.measure(callback); + expect(nativeHandle.measure).toHaveBeenCalledWith(callback); + expect(ref.current?.getScrollableNode()).toBe(42); + expect(host().nestedScrollEnabled).toBe(false); + act(() => host().onContentViewportChange({ nativeEvent: { height: 600 } })); + expect(ref.current).toBe(firstRef); + }); + + it("maps refresh props without mounting the RN refresh child and restores an unchanged controlled false", () => { + const onRefresh = jest.fn(); + const refresh = React.createElement("RefreshControl", { + refreshing: false, + onRefresh, + tintColor: "red", + colors: ["blue"], + progressViewOffset: 20, + }); + mount(); + expect( + renderer.root.findAllByType("RefreshControl" as React.ElementType) + ).toHaveLength(0); + expect(host()).toMatchObject({ + refreshEnabled: true, + refreshing: false, + refreshTintColor: "red", + refreshColors: ["blue"], + refreshProgressViewOffset: 20, + }); + jest.mocked(Commands.setRefreshState).mockClear(); + act(() => host().onRefresh()); + expect(onRefresh).toHaveBeenCalledTimes(1); + expect(Commands.setRefreshState).toHaveBeenCalledWith(nativeHandle, false); + }); + + it("uses the latest controlled refresh value after the refresh callback updates React state", () => { + function Controlled() { + const [refreshing, setRefreshing] = React.useState(false); + return ( + setRefreshing(true), + })} + /> + ); + } + mount(); + act(() => host().onRefresh()); + expect(Commands.setRefreshState).toHaveBeenLastCalledWith( + nativeHandle, + true + ); + }); + + it("never captures a different TextInput and handled only claims unhandled bubbled taps", () => { + mount(); + expect(host().onStartShouldSetResponderCapture(touch(mockOtherInput))).toBe( + false + ); + expect(host().onStartShouldSetResponderCapture(touch(mockView))).toBe(true); + act(() => + renderer.update() + ); + expect(host().onStartShouldSetResponderCapture(touch(mockView))).toBe( + false + ); + expect(host().onStartShouldSetResponder(touch(mockView))).toBe(true); + act(() => host().onResponderGrant(touch(mockView))); + act(() => host().onResponderRelease(touch(mockView))); + expect(Keyboard.dismiss).toHaveBeenCalledTimes(1); + }); + + it("forwards native events and does not dismiss keyboard after a scroll or persist-always tap", () => { + const onScroll = jest.fn(); + mount( + + ); + const event = { nativeEvent: { contentOffset: { x: 0, y: 5 } } }; + act(() => host().onResponderGrant(touch(mockView))); + act(() => host().onScroll(event)); + act(() => host().onResponderRelease(touch(mockView))); + expect(onScroll).toHaveBeenCalledWith(event); + expect(Keyboard.dismiss).not.toHaveBeenCalled(); + act(() => + renderer.update() + ); + expect(host().onStartShouldSetResponderCapture(touch(mockView))).toBe( + false + ); + expect(host().onStartShouldSetResponder(touch(mockView))).toBe(false); + }); +}); From 3c3206f23807779881d95c254fc73334f31cb130 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 28 Sep 2026 20:58:13 +0800 Subject: [PATCH 12/16] chore: sync pager podspec checksum --- example/react-native/ios/Podfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/react-native/ios/Podfile.lock b/example/react-native/ios/Podfile.lock index 0018b6316..8867cbabe 100644 --- a/example/react-native/ios/Podfile.lock +++ b/example/react-native/ios/Podfile.lock @@ -3527,7 +3527,7 @@ SPEC CHECKSUMS: React-microtasksnativemodule: f413ee411341fb3c34d20e85e9f6f524921fab15 React-mutationobservernativemodule: 58f6d2adf7d98e72b241608dfa2ddce414d2a4e7 react-native-native-list: c586598e9a4902586b091b060d8b594013dc442a - react-native-pager-view: c41389cfc805ce60b5980924a480f982ae261e7f + react-native-pager-view: d0165b7fba601f2ce11f4798622122002562c82c react-native-safe-area-context: 91a90d98c310adcc90a511e5aeb6046d7c19d885 react-native-tab-view: b62691e2b0492c55dc436cf2f971c27f4a0165cc React-NativeModulesApple: 167e532fb9bae30f3c66636b8ee0092bab263667 From 484553be3972e875e916ecc09ca510c24e11aa0e Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 28 Sep 2026 21:25:16 +0800 Subject: [PATCH 13/16] test: attach pager press fixture to a UIKit window --- ...CCollapsiblePagerPressCancellationTests.mm | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm b/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm index 45d508010..925905aa0 100644 --- a/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm +++ b/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm @@ -49,6 +49,7 @@ - (UIGestureRecognizerState)state { return self.testState; } @end @interface RNCCollapsiblePagerPressCancellationTests : XCTestCase +@property (nonatomic, strong) UIWindow *window; @property (nonatomic, strong) UIView *host; @property (nonatomic, strong) UIView *surface; @property (nonatomic, strong) UIView *content; @@ -63,9 +64,14 @@ @implementation RNCCollapsiblePagerPressCancellationTests - (void)setUp { [super setUp]; + XCTAssertTrue(NSThread.isMainThread); + // Register the recognizers with a UIKit gesture environment without taking focus. + self.window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 600)]; + self.window.hidden = NO; self.host = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 320, 600)]; self.surface = [[UIView alloc] initWithFrame:self.host.bounds]; self.content = [[UIView alloc] initWithFrame:self.surface.bounds]; + [self.window addSubview:self.host]; [self.host addSubview:self.surface]; [self.surface addSubview:self.content]; self.handler = [RCTSurfaceTouchHandler new]; @@ -75,6 +81,8 @@ - (void)setUp self.bridge = [RNCPressCancellationObservedBridge new]; self.bridge.reactTouchHandler = self.handler; [self.host addGestureRecognizer:self.bridge]; + XCTAssertEqual(self.bridge.view.window, self.window); + XCTAssertEqual(self.handler.view.window, self.window); self.pan = [RNCPressCancellationTestPan new]; self.pan.testState = UIGestureRecognizerStateBegan; self.touch = [self touchInView:self.content phase:UITouchPhaseBegan]; @@ -90,7 +98,10 @@ - (void)tearDown self.pan = nil; self.content = nil; self.surface = nil; + [self.host removeFromSuperview]; self.host = nil; + self.window.hidden = YES; + self.window = nil; [super tearDown]; } @@ -143,6 +154,8 @@ - (void)testOnlyBeganPanWithTrackedLiveTouchActivates } self.pan.testState = UIGestureRecognizerStateBegan; [self.bridge cancelPressForPan:self.pan]; + XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateBegan)], + @"The production bridge must request recognition before UIKit retains Began"); XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan); } @@ -178,6 +191,8 @@ - (void)testOtherSurfaceAndFinishedTouchesDoNotCountAsActiveSurfaceTouches [self.bridge touchesBegan:[NSSet setWithObject:self.touch] withEvent:[self eventWithTouches:[NSSet setWithObjects:self.touch, other, ended, cancelled, nil]]]; [self.bridge cancelPressForPan:self.pan]; + XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateBegan)], + @"The production bridge must request recognition before UIKit retains Began"); XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan); } @@ -196,6 +211,8 @@ - (void)testSecondTouchAfterRecognitionDoesNotFailOrEndTheTrackedGesture { [self beginSingleTouch]; [self.bridge cancelPressForPan:self.pan]; + XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateBegan)], + @"The production bridge must request recognition before UIKit retains Began"); XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan); RNCPressCancellationTestTouch *second = [self touchInView:self.content phase:UITouchPhaseBegan]; RNCPressCancellationTestEvent *event = @@ -247,6 +264,8 @@ - (void)testRejectedSameSurfaceTouchRemainsLatchedUntilReset [self.bridge reset]; [self beginSingleTouch]; [self.bridge cancelPressForPan:self.pan]; + XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateBegan)], + @"The production bridge must request recognition before UIKit retains Began"); XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan); } @@ -258,6 +277,8 @@ - (void)testObservedOtherSurfaceTouchDoesNotLatchCurrentSurface [self beginSingleTouch]; [self.bridge observeIncomingTouch:second]; [self.bridge cancelPressForPan:self.pan]; + XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateBegan)], + @"The production bridge must request recognition before UIKit retains Began"); XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan); } @@ -279,6 +300,8 @@ - (void)testResetDropsPendingTouchAndAllowsANewSequence XCTAssertEqual(self.bridge.state, UIGestureRecognizerStatePossible); [self beginSingleTouch]; [self.bridge cancelPressForPan:self.pan]; + XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateBegan)], + @"The production bridge must request recognition before UIKit retains Began"); XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan); } @@ -290,6 +313,8 @@ - (void)testRecognizedSequenceEndsAndReleasesEvent weakEvent = event; [self.bridge touchesBegan:[NSSet setWithObject:self.touch] withEvent:event]; [self.bridge cancelPressForPan:self.pan]; + XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateBegan)], + @"The production bridge must request recognition before UIKit retains Began"); XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan); self.touch.testPhase = UITouchPhaseEnded; [self.bridge touchesEnded:[NSSet setWithObject:self.touch] withEvent:event]; @@ -318,6 +343,8 @@ - (void)testRecognizedCancellationTerminatesTheBridge { [self beginSingleTouch]; [self.bridge cancelPressForPan:self.pan]; + XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateBegan)], + @"The production bridge must request recognition before UIKit retains Began"); XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan); self.touch.testPhase = UITouchPhaseCancelled; [self.bridge touchesCancelled:[NSSet setWithObject:self.touch] From 65b7114e562ad332dccca5e2f32852ae3bf7f3e7 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 28 Sep 2026 22:41:16 +0800 Subject: [PATCH 14/16] test: drive pager press cancellation through real UIKit touches --- ...CCollapsiblePagerPressCancellationTests.mm | 394 +++++++++++++++++- 1 file changed, 390 insertions(+), 4 deletions(-) diff --git a/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm b/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm index 925905aa0..b2201bcd9 100644 --- a/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm +++ b/native-views/react-native-pager-view/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm @@ -1,6 +1,202 @@ #import #import #import +#import +#import +#import +#import + +#import "RNCCollapsiblePagerViewComponentView.h" +#import + +using namespace facebook::react; + +#pragma mark - UIKit gesture environment and touch delivery + +// Pod unit tests run in xctest without an application. UIKit then has no +// gesture environment: every programmatic recognizer state change, including +// Began on a plain UIGestureRecognizer, is dropped. Create the application +// singleton (what UIApplicationMain does first) so recognizers run under the +// real UIGestureEnvironment. Touches below are delivered through +// -[UIWindow sendEvent:], the path UIApplication uses for touch events, using +// the same private UITouch/UIEvent/IOHIDEvent calls as KIF. These are +// test-only; a missing symbol fails the test instead of skipping it. +@interface RNCPressCancellationTestApplication : UIApplication +@end + +@implementation RNCPressCancellationTestApplication +// UIApplicationMain never ran, so there is no event run loop. UIScrollView +// pushes a tracking run-loop mode when its real pan begins; scheduling is +// outside the recognizer semantics under test. +- (void)_pushRunLoopMode:(id)mode requester:(id)requester reason:(id)reason {} +- (void)_popRunLoopMode:(id)mode requester:(id)requester reason:(id)reason {} +@end + +static BOOL RNCEnsureUIKitGestureEnvironment(void) +{ + if (UIApplication.sharedApplication != nil) return YES; + void (*instantiate)(Class) = + (void (*)(Class))dlsym(RTLD_DEFAULT, "UIApplicationInstantiateSingleton"); + if (instantiate == NULL) return NO; + instantiate(RNCPressCancellationTestApplication.class); + return UIApplication.sharedApplication != nil; +} + +typedef struct __IOHIDEvent *RNCTestHIDEventRef; + +@interface UITouch (RNCPressCancellationSynthesis) +- (void)setWindow:(UIWindow *)window; +- (void)setView:(UIView *)view; +- (void)setGestureView:(UIView *)view; +- (void)setPhase:(UITouchPhase)phase; +- (void)setTimestamp:(NSTimeInterval)timestamp; +- (void)setTapCount:(NSUInteger)tapCount; +- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious; +- (void)_setIsTapToClick:(BOOL)isTapToClick; +- (void)_setHidEvent:(RNCTestHIDEventRef)event; +@end + +@interface UIEvent (RNCPressCancellationSynthesis) +- (instancetype)_init; +- (void)_clearTouches; +- (void)_addTouch:(UITouch *)touch forDelayedDelivery:(BOOL)delayed; +- (void)_setHIDEvent:(RNCTestHIDEventRef)event; +@end + +// Delivers one finger or several simultaneous fingers through a window. +@interface RNCSynthesizedTouchDriver : NSObject +- (instancetype)initWithWindow:(UIWindow *)window; +@property (nonatomic, readonly) NSString *unavailableReason; +- (UITouch *)beginAt:(CGPoint)windowPoint; +- (void)move:(UITouch *)touch to:(CGPoint)windowPoint; +- (void)end:(UITouch *)touch; +@end + +@implementation RNCSynthesizedTouchDriver { + UIWindow *_window; + UIEvent *_event; + NSMutableArray *_activeTouches; + NSTimeInterval _timestamp; + RNCTestHIDEventRef (*_createHand)(CFAllocatorRef, uint64_t, uint32_t, uint32_t, uint32_t, + uint32_t, uint32_t, double, double, double, double, double, + Boolean, Boolean, uint32_t); + RNCTestHIDEventRef (*_createFinger)(CFAllocatorRef, uint64_t, uint32_t, uint32_t, uint32_t, + double, double, double, double, double, double, double, + double, double, double, Boolean, Boolean, uint32_t); + void (*_setIntegerValue)(RNCTestHIDEventRef, uint32_t, long, uint32_t); + void (*_appendEvent)(RNCTestHIDEventRef, RNCTestHIDEventRef, uint32_t); + void (*_setSenderID)(RNCTestHIDEventRef, uint64_t); +} + +- (instancetype)initWithWindow:(UIWindow *)window +{ + if (self = [super init]) { + _window = window; + _activeTouches = [NSMutableArray new]; + _timestamp = NSProcessInfo.processInfo.systemUptime; + void *iokit = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_NOW); + _createHand = (decltype(_createHand))dlsym(iokit, "IOHIDEventCreateDigitizerEvent"); + _createFinger = + (decltype(_createFinger))dlsym(iokit, "IOHIDEventCreateDigitizerFingerEventWithQuality"); + _setIntegerValue = + (decltype(_setIntegerValue))dlsym(iokit, "IOHIDEventSetIntegerValueWithOptions"); + _appendEvent = (decltype(_appendEvent))dlsym(iokit, "IOHIDEventAppendEvent"); + _setSenderID = (decltype(_setSenderID))dlsym(iokit, "IOHIDEventSetSenderID"); + Class eventClass = NSClassFromString(@"UITouchesEvent"); + if (!RNCEnsureUIKitGestureEnvironment()) { + _unavailableReason = @"UIApplication singleton unavailable"; + } else if (_createHand == NULL || _createFinger == NULL || _setIntegerValue == NULL || + _appendEvent == NULL || _setSenderID == NULL) { + _unavailableReason = @"IOHIDEvent symbols unavailable"; + } else if (eventClass == Nil || ![eventClass instancesRespondToSelector:@selector(_init)] || + ![eventClass instancesRespondToSelector:@selector(_addTouch:forDelayedDelivery:)] || + ![UITouch instancesRespondToSelector:@selector(_setLocationInWindow:resetPrevious:)] || + ![UITouch instancesRespondToSelector:@selector(_setHidEvent:)]) { + _unavailableReason = @"UITouch/UITouchesEvent synthesis selectors unavailable"; + } else { + _event = [[eventClass alloc] _init]; + } + } + return self; +} + +- (RNCTestHIDEventRef)newHIDEvent +{ + static const uint32_t kDigitizerDisplayIntegrated = (11 << 16) | 25; + static const uint32_t kIsBuiltIn = 4; + static const uint32_t kOptions = (uint32_t)-268435456; + uint64_t machTime = mach_absolute_time(); + // Hand transducer, touch mask. + RNCTestHIDEventRef hand = + _createHand(kCFAllocatorDefault, machTime, 3, 0, 0, 1 << 1, 0, 0, 0, 0, 0, 0, 0, 0, 0); + _setIntegerValue(hand, kDigitizerDisplayIntegrated, 1, kOptions); + _setIntegerValue(hand, kIsBuiltIn, 1, kOptions); + _setSenderID(hand, 0x000000010000027F); + uint32_t index = 0; + for (UITouch *touch in _activeTouches) { + UITouchPhase phase = touch.phase; + // Position for moves; range and touch for contact changes. + uint32_t mask = phase == UITouchPhaseMoved ? (1 << 2) : ((1 << 0) | (1 << 1)); + Boolean down = phase != UITouchPhaseEnded && phase != UITouchPhaseCancelled; + CGPoint point = [touch locationInView:_window]; + RNCTestHIDEventRef finger = _createFinger(kCFAllocatorDefault, machTime, ++index, 2, mask, + point.x, point.y, 0, 0, 0, 5, 5, 1, 1, 1, + down, down, 0); + _setIntegerValue(finger, kDigitizerDisplayIntegrated, 1, kOptions); + _appendEvent(hand, finger, 0); + CFRelease(finger); + } + return hand; +} + +- (void)sendChangingTouch:(UITouch *)changed phase:(UITouchPhase)phase +{ + NSAssert(_event != nil, @"%@", _unavailableReason); + _timestamp += 1.0 / 60.0; + for (UITouch *touch in _activeTouches) { + [touch setPhase:touch == changed ? phase : UITouchPhaseStationary]; + [touch setTimestamp:_timestamp]; + } + RNCTestHIDEventRef hid = [self newHIDEvent]; + for (UITouch *touch in _activeTouches) [touch _setHidEvent:hid]; + [_event _setHIDEvent:hid]; + if (phase == UITouchPhaseBegan) [_event _addTouch:changed forDelayedDelivery:NO]; + CFRelease(hid); + [_window sendEvent:_event]; + if (phase == UITouchPhaseEnded || phase == UITouchPhaseCancelled) { + [_activeTouches removeObjectIdenticalTo:changed]; + if (_activeTouches.count == 0) [_event _clearTouches]; + } +} + +- (UITouch *)beginAt:(CGPoint)windowPoint +{ + if (_activeTouches.count == 0) [_event _clearTouches]; + UITouch *touch = [UITouch new]; + UIView *view = [_window hitTest:windowPoint withEvent:nil]; + [touch setWindow:_window]; + [touch _setLocationInWindow:windowPoint resetPrevious:YES]; + [touch setView:view]; + if ([touch respondsToSelector:@selector(setGestureView:)]) [touch setGestureView:view]; + [touch _setIsTapToClick:NO]; + [touch setTapCount:1]; + [_activeTouches addObject:touch]; + [self sendChangingTouch:touch phase:UITouchPhaseBegan]; + return touch; +} + +- (void)move:(UITouch *)touch to:(CGPoint)windowPoint +{ + [touch _setLocationInWindow:windowPoint resetPrevious:NO]; + [self sendChangingTouch:touch phase:UITouchPhaseMoved]; +} + +- (void)end:(UITouch *)touch +{ + [self sendChangingTouch:touch phase:UITouchPhaseEnded]; +} + +@end // Private declarations access the production class compiled into the pod. @interface RNCCollapsiblePagerContentPressCancellationGestureRecognizer : UIGestureRecognizer @@ -9,8 +205,8 @@ - (void)cancelPressForPan:(UIPanGestureRecognizer *)pan; - (void)observeIncomingTouch:(UITouch *)touch; @end -// UIKit can reset terminal states immediately outside real touch delivery. -// Observe requests while retaining the production implementation and UIKit setter. +// Terminal states are reset by UIKit once it processes them. Observe requests +// while retaining the production implementation and UIKit setter. @interface RNCPressCancellationObservedBridge : RNCCollapsiblePagerContentPressCancellationGestureRecognizer @property (nonatomic, strong) NSMutableArray *requestedStates; @end @@ -23,8 +219,9 @@ - (void)setState:(UIGestureRecognizerState)state } @end -// These doubles supply input facts only. The production bridge owns its state -// transitions; this suite does not claim to synthesize UIKit touch delivery. +// These doubles supply input facts to the bridge's decision logic directly. +// The production bridge owns its state transitions. End-to-end UIKit delivery +// through the real pager is covered by the delivery suite below. @interface RNCPressCancellationTestTouch : UITouch @property (nonatomic, strong) UIView *testView; @property (nonatomic, assign) UITouchPhase testPhase; @@ -65,6 +262,8 @@ - (void)setUp { [super setUp]; XCTAssertTrue(NSThread.isMainThread); + // Without an application UIKit drops every programmatic recognizer state. + XCTAssertTrue(RNCEnsureUIKitGestureEnvironment()); // Register the recognizers with a UIKit gesture environment without taking focus. self.window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 600)]; self.window.hidden = NO; @@ -371,3 +570,190 @@ - (void)testHandlerBindingIsWeak } @end + +#pragma mark - Real UIKit delivery through the production pager + +// Records what RN's Fabric touch handler observes. Its reset dispatches +// touchCancel for every still-registered touch, which is how a Pressable loses +// its press when an external recognizer prevents the handler. +@interface RNCRecordingSurfaceTouchHandler : RCTSurfaceTouchHandler +@property (nonatomic, strong) NSMutableArray *events; +@property (nonatomic, assign) NSInteger registeredTouches; +@end + +@implementation RNCRecordingSurfaceTouchHandler +- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event +{ + [super touchesBegan:touches withEvent:event]; + self.registeredTouches += touches.count; + [self.events addObject:@"began"]; +} +- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event +{ + [super touchesMoved:touches withEvent:event]; + if (![self.events.lastObject isEqualToString:@"moved"]) [self.events addObject:@"moved"]; +} +- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event +{ + [super touchesEnded:touches withEvent:event]; + self.registeredTouches -= touches.count; + [self.events addObject:@"ended"]; +} +- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event +{ + [super touchesCancelled:touches withEvent:event]; + self.registeredTouches -= touches.count; + [self.events addObject:@"cancelled"]; +} +- (void)reset +{ + NSInteger pending = self.registeredTouches; + [super reset]; + self.registeredTouches = 0; + if (pending > 0) [self.events addObject:@"reset-cancelled"]; +} +@end + +@interface RNCCollapsiblePagerPressCancellationDeliveryTests : XCTestCase +@property (nonatomic, strong) UIWindow *window; +@property (nonatomic, strong) UIView *surface; +@property (nonatomic, strong) RNCRecordingSurfaceTouchHandler *handler; +@property (nonatomic, strong) RNCCollapsiblePagerViewComponentView *pager; +@property (nonatomic, strong) RNCSynthesizedTouchDriver *driver; +@property (nonatomic, strong) UIView *firstPage; +@end + +@implementation RNCCollapsiblePagerPressCancellationDeliveryTests + +- (void)setUp +{ + [super setUp]; + self.window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 800)]; + self.window.hidden = NO; + UIView *host = [[UIView alloc] initWithFrame:self.window.bounds]; + self.surface = [[UIView alloc] initWithFrame:host.bounds]; + [self.window addSubview:host]; + [host addSubview:self.surface]; + self.handler = [RNCRecordingSurfaceTouchHandler new]; + self.handler.events = [NSMutableArray new]; + [self.handler attachToView:self.surface]; + + self.pager = [RNCCollapsiblePagerViewComponentView new]; + auto props = std::make_shared(); + props->nativeSmoothHeaderScrollEnabled = true; + props->headerHeight = 120; + props->stickyHeaderHeight = 44; + [self.pager updateProps:props oldProps:nullptr]; + self.pager.frame = self.surface.bounds; + [self.surface addSubview:self.pager]; + NSArray *> *children = @[ + [RCTViewComponentView new], // header + [RCTViewComponentView new], // sticky header + [RCTViewComponentView new], // page 0 + [RCTViewComponentView new], // page 1 + ]; + children[0].frame = CGRectMake(0, 0, 390, 120); + children[1].frame = CGRectMake(0, 0, 390, 44); + [children enumerateObjectsUsingBlock:^(UIView *child, + NSUInteger index, + BOOL *stop) { + [self.pager mountChildComponentView:child index:(NSInteger)index]; + }]; + self.firstPage = children[2]; + [self.window layoutIfNeeded]; + [self drainMainQueue]; + self.firstPage.frame = self.firstPage.superview.bounds; + self.driver = [[RNCSynthesizedTouchDriver alloc] initWithWindow:self.window]; +} + +- (void)tearDown +{ + [self.pager removeFromSuperview]; + [self.handler detachFromView:self.surface]; + self.window.hidden = YES; + self.driver = nil; + self.firstPage = nil; + self.pager = nil; + self.handler = nil; + self.surface = nil; + self.window = nil; + [super tearDown]; +} + +- (void)drainMainQueue +{ + XCTestExpectation *drained = [self expectationWithDescription:@"main queue drained"]; + dispatch_async(dispatch_get_main_queue(), ^{ [drained fulfill]; }); + [self waitForExpectations:@[drained] timeout:2]; +} + +- (void)assertFixtureIsReadyForDelivery +{ + XCTAssertNil(self.driver.unavailableReason); + UIGestureRecognizer *bridge = [self.pager valueForKey:@"contentPressCancellationGesture"]; + UIScrollView *pagerScrollView = [self.pager valueForKey:@"pagerScrollView"]; + XCTAssertNotNil(bridge); + XCTAssertEqual([bridge valueForKey:@"reactTouchHandler"], self.handler); + XCTAssertEqual((id)pagerScrollView.delegate, self.pager); + XCTAssertGreaterThan(pagerScrollView.contentSize.width, CGRectGetWidth(pagerScrollView.bounds)); + // The touch must start on React page content, outside the shared header. + CGPoint point = [self pressPoint]; + UIView *hit = [self.window hitTest:point withEvent:nil]; + XCTAssertTrue(hit == self.firstPage || [hit isDescendantOfView:self.firstPage], + @"hit view %@", hit); +} + +- (CGPoint)pressPoint +{ + return CGPointMake(200, 600); +} + +- (void)testHorizontalPagerDragCancelsReactPressBeforeFingerLifts +{ + [self assertFixtureIsReadyForDelivery]; + UIScrollView *pagerScrollView = [self.pager valueForKey:@"pagerScrollView"]; + CGPoint point = [self pressPoint]; + UITouch *touch = [self.driver beginAt:point]; + XCTAssertEqualObjects(self.handler.events, @[@"began"]); + XCTAssertEqual(self.handler.registeredTouches, 1); + + for (NSInteger step = 1; step <= 8 && !pagerScrollView.isDragging; ++step) { + [self.driver move:touch to:CGPointMake(point.x - 12 * step, point.y)]; + } + XCTAssertTrue(pagerScrollView.isDragging, @"the real pager pan must begin"); + // Finger is still down: RN must already have cancelled its responder. + XCTAssertEqual(self.handler.registeredTouches, 0); + XCTAssertTrue([self.handler.events containsObject:@"reset-cancelled"] || + [self.handler.events containsObject:@"cancelled"], + @"handler events %@", self.handler.events); + XCTAssertFalse([self.handler.events containsObject:@"ended"]); + NSUInteger eventsBeforeLift = self.handler.events.count; + + [self.driver end:touch]; + // UIKit must not deliver the lift to the prevented handler as a press end. + XCTAssertFalse([self.handler.events containsObject:@"ended"], @"events %@", self.handler.events); + XCTAssertEqual(self.handler.events.count, eventsBeforeLift); +} + +- (void)testStationaryTapKeepsReactPress +{ + [self assertFixtureIsReadyForDelivery]; + UITouch *touch = [self.driver beginAt:[self pressPoint]]; + [self.driver end:touch]; + XCTAssertEqualObjects(self.handler.events, (@[@"began", @"ended"])); + XCTAssertEqual(self.handler.registeredTouches, 0); +} + +- (void)testSmallMovementBelowPanThresholdKeepsReactPress +{ + [self assertFixtureIsReadyForDelivery]; + UIScrollView *pagerScrollView = [self.pager valueForKey:@"pagerScrollView"]; + CGPoint point = [self pressPoint]; + UITouch *touch = [self.driver beginAt:point]; + [self.driver move:touch to:CGPointMake(point.x - 2, point.y)]; + XCTAssertFalse(pagerScrollView.isDragging); + [self.driver end:touch]; + XCTAssertEqualObjects(self.handler.events, (@[@"began", @"moved", @"ended"])); +} + +@end From c797370bd749eadc1220c0fab43357a31b18c7ee Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 28 Sep 2026 22:41:16 +0800 Subject: [PATCH 15/16] fix: release pager scroll observers and restore clamped offsets --- .../CollapsiblePagerView.web.test.tsx | 102 ++++++++++++++++++ .../react-native-pager-view/README.md | 4 + .../CollapsiblePagerHost.kt | 96 ++++++++++++----- .../NativeScrollerPendingRestore.kt | 26 +++++ .../NativeScrollerView.kt | 47 ++++++-- .../NativeScrollerViewManager.kt | 17 ++- .../NativeScrollerPendingRestoreTest.kt | 31 ++++++ .../react-native-pager-view/docs/SPEC.md | 23 +++- .../src/CollapsiblePagerView.tsx | 12 ++- 9 files changed, 315 insertions(+), 43 deletions(-) create mode 100644 native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerPendingRestore.kt create mode 100644 native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollerPendingRestoreTest.kt diff --git a/example/react-native/__tests__/CollapsiblePagerView.web.test.tsx b/example/react-native/__tests__/CollapsiblePagerView.web.test.tsx index 88dbc69ba..88bad24f9 100644 --- a/example/react-native/__tests__/CollapsiblePagerView.web.test.tsx +++ b/example/react-native/__tests__/CollapsiblePagerView.web.test.tsx @@ -166,6 +166,108 @@ describe('CollapsiblePagerView native wrapper', () => { expect(otherCommand).not.toHaveBeenCalled(); }, ); + + const renderTabPager = async () => { + let renderer!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create( + + {pages(nativeTabBar.items.length)} + , + { createNodeMock: () => ({}) }, + ); + }); + const nativeHost = renderer.root.find( + node => typeof node.props.retainedPages === 'string', + ); + const press = async (position: number, runFrame = true) => { + await ReactTestRenderer.act(() => { + nativeHost.props.onNativeTabPress({ + nativeEvent: { position, key: nativeTabBar.items[position].key }, + }); + }); + if (runFrame) { + await ReactTestRenderer.act(() => { + jest.runOnlyPendingTimers(); + }); + } + }; + const select = async (position: number) => { + await ReactTestRenderer.act(() => { + nativeHost.props.onPageSelected({ nativeEvent: { position } }); + }); + }; + return { renderer, press, select }; + }; + + const lastCommand = () => { + const animated = jest.mocked(NativeCollapsiblePagerCommands.setPage).mock + .invocationCallOrder; + const jumped = jest.mocked( + NativeCollapsiblePagerCommands.setPageWithoutAnimation, + ).mock.invocationCallOrder; + const lastAnimated = Math.max(-1, ...animated); + const lastJump = Math.max(-1, ...jumped); + return lastAnimated > lastJump + ? { + animated: true, + target: jest.mocked(NativeCollapsiblePagerCommands.setPage).mock + .lastCall?.[1], + } + : { + animated: false, + target: jest.mocked( + NativeCollapsiblePagerCommands.setPageWithoutAnimation, + ).mock.lastCall?.[1], + }; + }; + + it.each([ + [2, true], + [0, false], + [1, false], + ] as const)( + 'measures adjacency from an in-flight jump target when pressing %s', + async (target, animated) => { + const { renderer, press } = await renderTabPager(); + await press(3); + expect(lastCommand()).toEqual({ animated: false, target: 3 }); + // Native has not reported page 3 yet; the wrapper state still says 0. + await press(target); + expect(lastCommand()).toEqual({ animated, target }); + await ReactTestRenderer.act(() => { + renderer.unmount(); + }); + }, + ); + + it('ignores a superseded target that never reached native', async () => { + const { renderer, press } = await renderTabPager(); + await press(3, false); + await press(2); + expect(NativeCollapsiblePagerCommands.setPage).not.toHaveBeenCalled(); + expect(lastCommand()).toEqual({ animated: false, target: 2 }); + await ReactTestRenderer.act(() => { + renderer.unmount(); + }); + }); + + it('measures adjacency from the reported page after the transition', async () => { + const { renderer, press, select } = await renderTabPager(); + await press(3); + await select(3); + await press(1); + expect(lastCommand()).toEqual({ animated: false, target: 1 }); + await select(1); + await press(0); + expect(lastCommand()).toEqual({ animated: true, target: 0 }); + await ReactTestRenderer.act(() => { + renderer.unmount(); + }); + }); }); }); diff --git a/native-views/react-native-pager-view/README.md b/native-views/react-native-pager-view/README.md index 956e83a72..121a3e605 100644 --- a/native-views/react-native-pager-view/README.md +++ b/native-views/react-native-pager-view/README.md @@ -73,6 +73,10 @@ non-adjacent presses jump directly to the target without passing intermediate pages. Set `nativeTabPressAnimationEnabled={true}` to always animate, or `false` to always jump. Swipe gestures and imperative page commands are unchanged. +> **Behavior change:** earlier versions animated every native tab press when +> `nativeTabPressAnimationEnabled` was omitted. Pass +> `nativeTabPressAnimationEnabled={true}` to keep that behavior. + Thank you again to Callstack and everyone who contributes to `react-native-pager-view` 💙 diff --git a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt index a97115f64..1792ffbff 100644 --- a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt +++ b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt @@ -135,8 +135,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne private var observedOwnedScroller: NativeScrollerViewport? = null private var observedOwnedScrollerKey: String? = null private var observedOwnedScrollerListener: ViewTreeObserver.OnScrollChangedListener? = null + private var observedOwnedScrollerTreeObserver: ViewTreeObserver? = null private var observedNativeScroller: ScrollView? = null private var observedNativeScrollerListener: ViewTreeObserver.OnScrollChangedListener? = null + private var observedNativeScrollerTreeObserver: ViewTreeObserver? = null + private var observedPageRemoved = false private var attachmentGeneration = 0 private val touchSlopPx = ViewConfiguration.get(context).scaledTouchSlop private val hostIdentity = System.identityHashCode(this) @@ -340,6 +343,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne stickyHeaderView = null } else -> { + val observed = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller + if (observed != null && isDescendant(observed, child)) { + // The page detaches only later; its scroller must not keep writing this key. + detachPrimaryScrollObserver(recordOffset = false) + observedPageRemoved = true + } when (val scrollable = findExplicitNativeScrollerInView(child) ?: findVerticalScrollableView(child)) { is RecyclerView -> restoreRecyclerInsets(scrollable) @@ -554,7 +563,8 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne nativeTabBarView.translationY = -offset nativeSubHeaderView.translationY = -offset observedRecyclerView?.let { publishConsumedOffset(it) } - observedOwnedScroller?.let { publishConsumedOffset(it) } + // Adjacent owned pages are translated too; unchanged values are not republished. + for (scroller in ownedScrollerKeys.keys) publishConsumedOffset(scroller) } private fun pageKey(index: Int): String = pageKeys.getOrNull(index) ?: "page-$index" @@ -647,7 +657,8 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne private fun attachPrimaryScrollObserver() { if (selectedPage !in 0 until adapter.itemCount) return val previous = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller - if (previous != null && !isDescendant(previous, adapter.pageAt(selectedPage))) { + if (observedPageRemoved || previous != null && !isDescendant(previous, adapter.pageAt(selectedPage))) { + observedPageRemoved = false detachPrimaryScrollObserver() // A replacement scroller starts at the beginning while retaining header collapse. pageOffsets[currentPageKey()] = 0 @@ -689,11 +700,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne ) } - private fun detachRecyclerObserver() { + private fun detachRecyclerObserver(recordOffset: Boolean) { val recycler = observedRecyclerView val listener = observedScrollListener if (recycler != null) { - pageOffsets[currentPageKey()] = recycler.computeVerticalScrollOffset() + if (recordOffset) pageOffsets[currentPageKey()] = recycler.computeVerticalScrollOffset() publishConsumedOffset(recycler, remove = true) } if (recycler != null && listener != null) recycler.removeOnScrollListener(listener) @@ -716,27 +727,35 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne detachPrimaryScrollObserver() observedNativeScroller = scroller applyNativeScrollerInsets(scroller, selectedPage) - val listener = ViewTreeObserver.OnScrollChangedListener { - val contentOffset = scroller.scrollY - pageOffsets[currentPageKey()] = contentOffset - if (contentOffset > 0 && headerOffsetPx < headerHeightPx) { - setHeaderOffset(headerHeightPx) + val listener = object : ViewTreeObserver.OnScrollChangedListener { + override fun onScrollChanged() { + if (observedNativeScrollerListener !== this) { + removeScrollChangedListener(null, scroller, this) + return + } + val contentOffset = scroller.scrollY + pageOffsets[currentPageKey()] = contentOffset + if (contentOffset > 0 && headerOffsetPx < headerHeightPx) { + setHeaderOffset(headerHeightPx) + } } } observedNativeScrollerListener = listener - scroller.viewTreeObserver.addOnScrollChangedListener(listener) + observedNativeScrollerTreeObserver = scroller.viewTreeObserver.also { + it.addOnScrollChangedListener(listener) + } log( "scroller-observer-attach inner=$selectedPage key=${currentPageKey()} " + "scroller=${System.identityHashCode(scroller)}", ) } - private fun detachNativeScrollerObserver() { + private fun detachNativeScrollerObserver(recordOffset: Boolean) { val scroller = observedNativeScroller val listener = observedNativeScrollerListener - if (scroller != null) pageOffsets[currentPageKey()] = scroller.scrollY - if (scroller != null && listener != null && scroller.viewTreeObserver.isAlive) { - scroller.viewTreeObserver.removeOnScrollChangedListener(listener) + if (scroller != null && recordOffset) pageOffsets[currentPageKey()] = scroller.scrollY + if (scroller != null && listener != null) { + removeScrollChangedListener(observedNativeScrollerTreeObserver, scroller, listener) } if (scroller != null) { log( @@ -746,12 +765,25 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne } observedNativeScroller = null observedNativeScrollerListener = null + observedNativeScrollerTreeObserver = null } - private fun detachPrimaryScrollObserver() { - detachOwnedScrollerObserver() - detachRecyclerObserver() - detachNativeScrollerObserver() + private fun detachPrimaryScrollObserver(recordOffset: Boolean = true) { + detachOwnedScrollerObserver(recordOffset) + detachRecyclerObserver(recordOffset) + detachNativeScrollerObserver(recordOffset) + } + + private fun removeScrollChangedListener( + registered: ViewTreeObserver?, + scroller: View, + listener: ViewTreeObserver.OnScrollChangedListener, + ) { + // A detached scroller returns a new floating observer, while the listener is + // still registered on the window observer. Try every instance that can own it. + for (observer in arrayOf(registered, scroller.viewTreeObserver, viewTreeObserver)) { + if (observer != null && observer.isAlive) observer.removeOnScrollChangedListener(listener) + } } private fun applyOwnedScrollerInsets(scroller: NativeScrollerViewport, pageIndex: Int) { @@ -762,13 +794,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne ownedScrollerKeys[scroller] = key postForCurrentAttachment { if (ownedScrollerKeys[scroller] == key && scroller.isAttachedToWindow) { - scroller.scrollTo(0, pageOffsets[key] ?: 0) + scroller.host.restoreScrollOffset(pageOffsets[key] ?: 0) } } } private fun restoreOwnedScrollerInsets(scroller: NativeScrollerViewport) { scroller.stopMotion() + scroller.host.cancelPendingScrollRestore() ownedScrollerKeys.remove(scroller) publishConsumedOffset(scroller, remove = true) scroller.host.setPagerInset(this, null) @@ -784,26 +817,35 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne observedOwnedScrollerKey = currentPageKey() val key = currentPageKey() applyOwnedScrollerInsets(scroller, selectedPage) - val listener = ViewTreeObserver.OnScrollChangedListener { - val contentOffset = scroller.scrollY - pageOffsets[key] = contentOffset - if (contentOffset > 0 && headerOffsetPx < headerHeightPx) setHeaderOffset(headerHeightPx) + val listener = object : ViewTreeObserver.OnScrollChangedListener { + override fun onScrollChanged() { + if (observedOwnedScrollerListener !== this) { + removeScrollChangedListener(null, scroller, this) + return + } + val contentOffset = scroller.scrollY + pageOffsets[key] = contentOffset + if (contentOffset > 0 && headerOffsetPx < headerHeightPx) setHeaderOffset(headerHeightPx) + } } observedOwnedScrollerListener = listener - scroller.viewTreeObserver.addOnScrollChangedListener(listener) + observedOwnedScrollerTreeObserver = scroller.viewTreeObserver.also { + it.addOnScrollChangedListener(listener) + } } - private fun detachOwnedScrollerObserver() { + private fun detachOwnedScrollerObserver(recordOffset: Boolean) { val scroller = observedOwnedScroller ?: return scroller.stopMotion() publishConsumedOffset(scroller, remove = true) - observedOwnedScrollerKey?.let { pageOffsets[it] = scroller.scrollY } + if (recordOffset) observedOwnedScrollerKey?.let { pageOffsets[it] = scroller.scrollY } observedOwnedScrollerListener?.let { - if (scroller.viewTreeObserver.isAlive) scroller.viewTreeObserver.removeOnScrollChangedListener(it) + removeScrollChangedListener(observedOwnedScrollerTreeObserver, scroller, it) } observedOwnedScroller = null observedOwnedScrollerKey = null observedOwnedScrollerListener = null + observedOwnedScrollerTreeObserver = null } private fun applyRecyclerInsets(recycler: RecyclerView, pageIndex: Int) { diff --git a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerPendingRestore.kt b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerPendingRestore.kt new file mode 100644 index 000000000..6ac0e5618 --- /dev/null +++ b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerPendingRestore.kt @@ -0,0 +1,26 @@ +package com.reactnativepagerview + +/** + * A pager-owned offset restoration that NestedScrollView clamped because the + * React content height had not reached native yet. It is reapplied on content + * layout until reached or cancelled by user/command/content replacement. + */ +internal class NativeScrollerPendingRestore { + var target: Int? = null + private set + + fun request(target: Int, reached: Int) { + this.target = if (reached == target) null else target + } + + /** The offset to reapply after a content layout pass. */ + fun anchor(current: Int): Int = target ?: current + + fun settle(reached: Int) { + if (target == reached) target = null + } + + fun cancel() { + target = null + } +} diff --git a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerView.kt b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerView.kt index 4faaa5fe3..2c3bb042a 100644 --- a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerView.kt +++ b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerView.kt @@ -64,6 +64,8 @@ class NativeScrollerView(context: Context) : FrameLayout(context) { private var lastScrollEvent = 0L private var laidOut = false private var requestedRefresh = false + private var refreshStatePending = false + private val pendingRestore = NativeScrollerPendingRestore() private var refreshOffset = 0 private var appliedRefreshOffset: Int? = null private var appliedRefreshSize = SwipeRefreshLayout.DEFAULT @@ -175,6 +177,7 @@ class NativeScrollerView(context: Context) : FrameLayout(context) { internal fun removeReactChild() { viewport.stopMotion() + pendingRestore.cancel() reactChild?.let(contentOriginFrame::removeView) reactChild = null contentWidthPx = 0 @@ -221,19 +224,35 @@ class NativeScrollerView(context: Context) : FrameLayout(context) { ) refresh.layout(0, 0, width, height) // Reapply the current anchor after content shrink; NestedScrollView clamps it. - viewport.scrollTo(x, y) + // A pager restoration clamped before content reached native resumes as it grows. + viewport.scrollTo(x, pendingRestore.anchor(y)) + pendingRestore.settle(viewport.scrollY) laidOut = true publishContentViewport() publishContentOrigin() applyRefreshOffset() - if (refresh.isRefreshing != requestedRefresh) refresh.isRefreshing = requestedRefresh + // Apply only a prop received before the first layout. Reapplying it on later + // layouts would hide a gesture-started spinner before JS commits refreshing. + if (refreshStatePending) { + refreshStatePending = false + if (refresh.isRefreshing != requestedRefresh) refresh.isRefreshing = requestedRefresh + } } internal fun setRefreshing(value: Boolean) { requestedRefresh = value - if (laidOut) refresh.isRefreshing = value + if (laidOut) refresh.isRefreshing = value else refreshStatePending = true + } + + /** Scrolls to a pager-owned saved offset, retrying while content is too short to reach it. */ + internal fun restoreScrollOffset(y: Int) { + val target = y.coerceAtLeast(0) + viewport.scrollTo(0, target) + pendingRestore.request(target, viewport.scrollY) } + internal fun cancelPendingScrollRestore() = pendingRestore.cancel() + internal fun setRefreshOffset(value: Int) { refreshOffset = value applyRefreshOffset() @@ -273,6 +292,7 @@ class NativeScrollerView(context: Context) : FrameLayout(context) { internal fun scrollToPosition(x: Int, y: Int, animated: Boolean) { viewport.stopMotion() + pendingRestore.cancel() if (animated) viewport.scrollToAnimated(y) else viewport.scrollTo(0, y.coerceAtLeast(0)) emitScroll(force = true) @@ -395,6 +415,7 @@ internal class NativeScrollerViewport(context: Context, internal val host: Nativ // Record at the refresh boundary so interception does not truncate the pointer history. internal fun recordTouchEvent(event: MotionEvent) { if (event.actionMasked == MotionEvent.ACTION_DOWN) { + host.cancelPendingScrollRestore() stopMotion() velocityTracker = VelocityTracker.obtain() activePointerId = event.getPointerId(0) @@ -422,6 +443,8 @@ internal class NativeScrollerViewport(context: Context, internal val host: Nativ internal fun beginDrag(event: MotionEvent) { if (dragging) return dragging = true + // Header-forwarded touches bypass the refresh boundary's touch stream. + host.cancelPendingScrollRestore() if (host.keyboardDismissMode != "none") host.dismissKeyboard() NativeGestureUtil.notifyNativeGestureStarted(host, event) host.emitLifecycle("topScrollBeginDrag") @@ -481,13 +504,17 @@ internal class NativeScrollerViewport(context: Context, internal val host: Nativ try { endDrag(cancel) } finally { cancel.recycle() } } releaseTouchTracking() - stopping = true - try { - // Replace the same AndroidX trajectory with zero velocity without touching its smooth-scroll clock. - super.fling(0) - super.stopNestedScroll(ViewCompat.TYPE_NON_TOUCH) - } finally { - stopping = false + // An idle view has nothing to stop. A zero fling would still start and stop a + // non-touch nested scroll, which ancestors observe as a finished gesture. + if (momentum || hasNestedScrollingParent(ViewCompat.TYPE_NON_TOUCH)) { + stopping = true + try { + // Replace the same AndroidX trajectory with zero velocity without touching its smooth-scroll clock. + super.fling(0) + super.stopNestedScroll(ViewCompat.TYPE_NON_TOUCH) + } finally { + stopping = false + } } finishMomentum() } diff --git a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerViewManager.kt b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerViewManager.kt index 1a45cb30a..1e76e4aab 100644 --- a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerViewManager.kt +++ b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NativeScrollerViewManager.kt @@ -2,7 +2,9 @@ package com.reactnativepagerview import android.graphics.Color import android.view.View +import com.facebook.react.bridge.ColorPropConverter import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableType import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.ReactStylesDiffMap import com.facebook.react.uimanager.StateWrapper @@ -100,7 +102,20 @@ class NativeScrollerViewManager : ViewGroupManager(), } @ReactProp(name = "refreshColors", customType = "ColorArray") override fun setRefreshColors(view: NativeScrollerView, value: ReadableArray?) { - view.setRefreshColors(if (value == null) intArrayOf(Color.GRAY) else IntArray(value.size()) { value.getInt(it) }) + if (value == null) { + view.setRefreshColors(intArrayOf(Color.GRAY)) + return + } + val colors = ArrayList(value.size()) + for (index in 0 until value.size()) { + // PlatformColor/DynamicColorIOS values arrive as maps, as in RN's SwipeRefreshLayoutManager. + when (value.getType(index)) { + ReadableType.Map -> ColorPropConverter.getColor(value.getMap(index), view.context)?.let(colors::add) + ReadableType.Number -> colors.add(value.getInt(index)) + else -> Unit + } + } + view.setRefreshColors(colors.toIntArray()) } @ReactProp(name = "refreshProgressBackgroundColor", customType = "Color") override fun setRefreshProgressBackgroundColor(view: NativeScrollerView, value: Int?) = diff --git a/native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollerPendingRestoreTest.kt b/native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollerPendingRestoreTest.kt new file mode 100644 index 000000000..b6288aa15 --- /dev/null +++ b/native-views/react-native-pager-view/android/src/test/java/com/reactnativepagerview/NativeScrollerPendingRestoreTest.kt @@ -0,0 +1,31 @@ +package com.reactnativepagerview + +/** Standalone JVM regression: compile together with the production helper. */ +fun main() { + val restore = NativeScrollerPendingRestore() + + // Reachable immediately: nothing remains pending and layout keeps the current anchor. + restore.request(target = 640, reached = 640) + check(restore.target == null) + check(restore.anchor(320) == 320) + + // Content height has not reached native: NestedScrollView clamped the request to 0. + restore.request(target = 640, reached = 0) + check(restore.target == 640) + check(restore.anchor(0) == 640) + // Partial growth keeps retrying the original target, not the partially clamped offset. + restore.settle(reached = 300) + check(restore.target == 640) + check(restore.anchor(300) == 640) + // Once reached, later layouts keep the live position again. + restore.settle(reached = 640) + check(restore.target == null) + check(restore.anchor(900) == 900) + + // User touch, commands and content replacement cancel a pending target. + restore.request(target = 480, reached = 120) + restore.cancel() + check(restore.target == null) + check(restore.anchor(120) == 120) + println("Native scroller pending restore: clamp retry, partial growth, settle and cancel passed") +} diff --git a/native-views/react-native-pager-view/docs/SPEC.md b/native-views/react-native-pager-view/docs/SPEC.md index a24b4f41d..dbb2d2cce 100644 --- a/native-views/react-native-pager-view/docs/SPEC.md +++ b/native-views/react-native-pager-view/docs/SPEC.md @@ -96,7 +96,13 @@ position emits no new pair. A new command ends any previous active sequence before starting its own, and non-animated movement does not start momentum; recycled views MUST NOT emit into an old owner. Content shrink clamps unreachable offsets without a blank tail; resize/inset changes MUST NOT accumulate offsets. +On Android, a pager offset restoration that is clamped because React content has +not reached native yet is reapplied on content layout until reached; a new touch, +drag, scroll command, content-root replacement or pager release cancels it. +Stopping an idle scroller MUST NOT start or end a non-touch nested scroll. Refresh is controlled by the caller and emits once per qualifying gesture. +Android applies a refreshing value when it changes (or at first layout); later +layout passes do not reassert it over a gesture-started indicator. Acceptance requires both native builds, focused contract checks and iOS/Android recordings: expanded/collapsed Home Spot/Perps/DeFi, short/empty and dynamically @@ -161,17 +167,22 @@ builds; the currently installed npm version does not yet contain this migration. ## Native tab-press animation default +Behavior change: previously an omitted `nativeTabPressAnimationEnabled` animated +every native tab press. Callers that relied on that default MUST pass `true`. + On iOS and Android, an omitted `nativeTabPressAnimationEnabled` MUST animate a press to an adjacent page and jump directly to a non-adjacent page. Adjacency is -the absolute difference between the target index and the currently selected -index, not the last requested target. Explicit `true` continues to animate all +the absolute difference between the target index and the current page. While a +tab command that native has already received is in flight, its target is the +current page; otherwise it is the selected index. A request superseded before it +reached native is ignored. Explicit `true` continues to animate all tab presses, and explicit `false` continues to jump for all tab presses. Page retention, selection callbacks, swipe gestures, imperative `setPage` / `setPageWithoutAnimation` commands and Web behavior retain their contracts. Callers rendering their own JS tab bar choose the matching imperative command. -Status: implemented; focused native-wrapper tests cover both directions and -explicit overrides. Home applies this policy to its JS tab bar, with iOS and +Status: implemented; focused native-wrapper tests cover both directions, +explicit overrides and in-flight/superseded command targets. Home applies this policy to its JS tab bar, with iOS and Android simulator recordings verifying distant jumps and adjacent transitions. ## Android refresh foreground coordination @@ -355,6 +366,10 @@ A new child receives current header travel before threshold evaluation. Native list offsets remain local to the leaf, while consumers of this protocol add all ancestor contributions exactly once. Programmatic scrolling/header restoration must publish the resulting header offset through the same path. +Every NativeScroller viewport the coordinator owns, including adjacent pages it +translates, receives each header offset change, not only the observed page. +Content-offset observers are removed from the observer instance they were +registered on, and a removed page's scroller stops writing offsets immediately. ## Acceptance diff --git a/native-views/react-native-pager-view/src/CollapsiblePagerView.tsx b/native-views/react-native-pager-view/src/CollapsiblePagerView.tsx index e6aacda3c..8fed7f591 100644 --- a/native-views/react-native-pager-view/src/CollapsiblePagerView.tsx +++ b/native-views/react-native-pager-view/src/CollapsiblePagerView.tsx @@ -190,6 +190,10 @@ export class CollapsiblePagerView extends React.PureComponent< private latestNativeTabTarget: number | null = null; + // Target of the last tab command native has received and not yet reported + // as selected. Commands superseded before their frame never reach native. + private dispatchedNativeTabTarget: number | null = null; + private latestNativeSelectedPage = this.state.selectedPage; private nativePagerScrollState: OnPageScrollStateChangedEventData["pageScrollState"] = @@ -282,6 +286,7 @@ export class CollapsiblePagerView extends React.PureComponent< if (commandId !== this.nativeTabCommandId || !this.nativeRef) { return; } + this.dispatchedNativeTabTarget = position; if (animated) { CollapsiblePagerViewNativeCommands.setPage( this.nativeRef, @@ -354,6 +359,7 @@ export class CollapsiblePagerView extends React.PureComponent< const transitionComplete = pendingTarget === position; if (transitionComplete) { this.latestNativeTabTarget = null; + this.dispatchedNativeTabTarget = null; this.nativeTabCommandQueuedDuringScroll = false; } this.setState( @@ -386,6 +392,7 @@ export class CollapsiblePagerView extends React.PureComponent< ) { ++this.nativeTabCommandId; this.latestNativeTabTarget = null; + this.dispatchedNativeTabTarget = null; this.setState( { transientRetainedPages: [] }, this.notifyMountedPagesChanged @@ -408,10 +415,13 @@ export class CollapsiblePagerView extends React.PureComponent< (position !== this.state.selectedPage || this.latestNativeTabTarget !== null) ) { + // Selection state lags an in-flight transition until native reports it. + const currentPage = + this.dispatchedNativeTabTarget ?? this.state.selectedPage; this.dispatchNativeTabPageCommand( position, this.props.nativeTabPressAnimationEnabled ?? - Math.abs(position - this.state.selectedPage) <= 1 + Math.abs(position - currentPage) <= 1 ); } this.props.onNativeTabPress?.(event); From d28c4febca7b1760c661a12aebbd4b05ffb1c495 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 28 Sep 2026 22:41:17 +0800 Subject: [PATCH 16/16] fix: align native list slots, media players and scroll thresholds --- .../nativelist/NativeListMediaPreviewSlot.kt | 57 ++++++++- .../NativeListScrollPositionTracker.kt | 30 +++++ .../com/onekey/nativelist/NativeListView.kt | 115 ++++++++++++++++-- .../NativeListScrollPositionTrackerTest.kt | 16 +++ .../react-native-native-list/docs/SPEC.md | 57 ++++++--- .../docs/STYLE_SPEC.md | 6 +- .../ios/NativeListActivityDetailsView.swift | 92 +++++++++++--- .../ios/NativeListMediaPreviewSlot.swift | 66 +++++++++- .../ios/NativeListResolvedText.swift | 8 ++ .../ios/RNCNativeListView.swift | 36 ++++-- .../NativeListActivityPresentationTests.swift | 54 ++++++++ .../ios/tests/NativeListLifecycleTests.swift | 28 +++++ .../NativeListMediaVisibilityTests.swift | 67 ++++++++++ .../src/__tests__/activityRich.test.ts | 35 ++++++ .../react-native-native-list/src/models.ts | 1 + .../src/validation.ts | 7 +- 16 files changed, 610 insertions(+), 65 deletions(-) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaPreviewSlot.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaPreviewSlot.kt index db43627da..f831dd29f 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaPreviewSlot.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaPreviewSlot.kt @@ -1,11 +1,13 @@ package com.margelo.nitro.nativelist +import android.graphics.Bitmap import android.graphics.Rect import android.view.ViewTreeObserver import android.view.Gravity import android.view.TextureView import android.view.View import android.widget.FrameLayout +import android.widget.ImageView import androidx.annotation.OptIn import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException @@ -25,7 +27,10 @@ import com.facebook.react.uimanager.ThemedReactContext import okhttp3.JavaNetCookieJar import org.json.JSONObject -/** Uses Media3 like native Video, but never starts playback or requests audio focus. */ +/** + * Uses Media3 like native Video, but never starts playback or requests audio focus. A player + * lives only until its first frame is captured; the tile then shows that still frame. + */ @OptIn(UnstableApi::class) internal class NativeListMediaPreviewSlot(private val context: ThemedReactContext) { val view: FrameLayout = object : FrameLayout(context) { @@ -37,6 +42,7 @@ internal class NativeListMediaPreviewSlot(private val context: ThemedReactContex private val image = NativeListImageSlot(context) private val video = AspectRatioFrameLayout(context) private val texture = TextureView(context) + private val frame = ImageView(context) private var identity: String? = null private var epoch = 0 private var candidateGeneration = 0 @@ -58,6 +64,9 @@ internal class NativeListMediaPreviewSlot(private val context: ThemedReactContex view.isFocusable = false view.descendantFocusability = android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS video.addView(texture, FrameLayout.LayoutParams(-1, -1)) + frame.scaleType = ImageView.ScaleType.FIT_XY + frame.visibility = View.GONE + video.addView(frame, FrameLayout.LayoutParams(-1, -1)) view.addView(image.view, FrameLayout.LayoutParams(-1, -1)) view.addView(video, FrameLayout.LayoutParams(-1, -1, Gravity.CENTER)) video.visibility = View.GONE @@ -147,7 +156,11 @@ internal class NativeListMediaPreviewSlot(private val context: ThemedReactContex next.setAudioAttributes(androidx.media3.common.AudioAttributes.DEFAULT, false) next.setVideoTextureView(texture) next.addListener(object : Player.Listener { - override fun onRenderedFirstFrame() { if (playerGeneration == generation) finished(true, generation) } + override fun onRenderedFirstFrame() { + if (playerGeneration != generation) return + finished(true, generation) + captureFirstFrame(token, candidate, generation, FIRST_FRAME_CAPTURE_ATTEMPTS) + } override fun onPlayerError(error: PlaybackException) { if (playerGeneration == generation) finished(false, generation) } override fun onVideoSizeChanged(size: VideoSize) { if (playerGeneration == generation && size.height > 0) video.setAspectRatio(size.width * size.pixelWidthHeightRatio / size.height) @@ -165,6 +178,40 @@ internal class NativeListMediaPreviewSlot(private val context: ThemedReactContex updateVisibility() } + /** + * The rendered frame reaches the TextureView asynchronously, so a transparent copy is + * retried on later frames. If no frame can be copied the paused player stays attached. + */ + private fun captureFirstFrame(token: Int, candidate: Int, generation: Int, attemptsLeft: Int) { + view.postOnAnimation { + if (epoch != token || candidateGeneration != candidate || playerGeneration != generation) return@postOnAnimation + // Releasing first would make the queued success completion drop itself as stale. + if (result != true) { + captureFirstFrame(token, candidate, generation, attemptsLeft) + return@postOnAnimation + } + val still = if (texture.isAvailable && texture.width > 0 && texture.height > 0) { + runCatching { texture.getBitmap(texture.width, texture.height) }.getOrNull() + } else null + if (still == null || !hasVisiblePixels(still)) { + still?.recycle() + if (attemptsLeft > 1) captureFirstFrame(token, candidate, generation, attemptsLeft - 1) + return@postOnAnimation + } + frame.setImageBitmap(still) + frame.visibility = View.VISIBLE + resumeVideo = null + stopObservingViewport() + releasePlayer() + } + } + + private fun hasVisiblePixels(bitmap: Bitmap): Boolean { + val columns = intArrayOf(bitmap.width / 4, bitmap.width / 2, bitmap.width * 3 / 4) + val rows = intArrayOf(bitmap.height / 4, bitmap.height / 2, bitmap.height * 3 / 4) + return columns.any { x -> rows.any { y -> (bitmap.getPixel(x, y) ushr 24) != 0 } } + } + private fun observeViewport() { if (viewportObserver != null || resumeVideo == null || !view.isAttachedToWindow) return view.viewTreeObserver.takeIf { it.isAlive }?.let { @@ -209,7 +256,13 @@ internal class NativeListMediaPreviewSlot(private val context: ThemedReactContex image.view.visibility = View.VISIBLE video.visibility = View.GONE video.setAspectRatio(0f) + frame.setImageDrawable(null) + frame.visibility = View.GONE identity = null; result = null; completion = null } + + private companion object { + const val FIRST_FRAME_CAPTURE_ATTEMPTS = 6 + } fun dispose() { recycle(); image.dispose() } } diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListScrollPositionTracker.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListScrollPositionTracker.kt index 8f2716b7f..1437a08fa 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListScrollPositionTracker.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListScrollPositionTracker.kt @@ -15,6 +15,8 @@ internal class NativeListScrollPositionTracker { this.end = end } + val isConfigured: Boolean get() = start != null && end != null + fun resetDelivery() { previous = null } fun update(offset: Double): Boolean? { @@ -27,3 +29,31 @@ internal class NativeListScrollPositionTracker { return next } } + +/** + * Content extent above [first]: whole grid lines (tallest item per line) that end before the + * line containing [first]. Span sizes follow GridLayoutManager's default line breaking. + */ +internal fun nativeListScrollPrefixPx( + first: Int, + spanCount: Int, + spanSize: (Int) -> Int, + extent: (Int) -> Int, +): Long { + val columns = spanCount.coerceAtLeast(1) + var prefix = 0L + var lineExtent = 0 + var lineSpans = 0 + for (position in 0 until first) { + val span = spanSize(position).coerceIn(1, columns) + if (lineSpans + span > columns) { + prefix += lineExtent + lineExtent = 0 + lineSpans = 0 + } + lineSpans += span + lineExtent = maxOf(lineExtent, extent(position)) + } + if (first > 0 && lineSpans + spanSize(first).coerceIn(1, columns) > columns) prefix += lineExtent + return prefix +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt index 69c8a1e18..51742b3ae 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt @@ -58,6 +58,21 @@ private class NativeListGridLayoutManager( context: Context, spanCount: Int, ) : GridLayoutManager(context, spanCount) { + var onChildLaidOut: ((View, Int) -> Unit)? = null + var onLayoutPassCompleted: (() -> Unit)? = null + + // Scroll fills and layout passes both place children here, including rows that a + // positional jump lays out and immediately leaves offscreen. + override fun layoutDecoratedWithMargins(child: View, left: Int, top: Int, right: Int, bottom: Int) { + super.layoutDecoratedWithMargins(child, left, top, right, bottom) + onChildLaidOut?.invoke(child, if (orientation == RecyclerView.VERTICAL) bottom - top else right - left) + } + + override fun onLayoutCompleted(state: RecyclerView.State) { + super.onLayoutCompleted(state) + onLayoutPassCompleted?.invoke() + } + override fun removeAndRecycleViewAt(index: Int, recycler: RecyclerView.Recycler) { val child = getChildAt(index) ?: return removeViewAt(index) @@ -115,26 +130,32 @@ class NativeListView( private val coordinatorOffsetListener = Runnable { emitScrollPositionThresholdIfNeeded() } private var logicalScrollDistancePx = 0 private val scrollPositionTracker = NativeListScrollPositionTracker() + private val measuredRowExtentsPx = HashMap() + private var measuredRowExtentSumPx = 0L var onScrollPositionThresholdChange: ((Boolean) -> Unit)? = null set(value) { field = value scrollPositionTracker.resetDelivery() - emitScrollPositionThresholdIfNeeded() + emitScrollPositionThresholdIfNeeded(resync = true) } fun setScrollPositionThresholdsJson(json: String) { val config = runCatching { JSONObject(json) }.getOrNull() scrollPositionTracker.configure(config?.optDouble("start"), config?.optDouble("end")) - emitScrollPositionThresholdIfNeeded() + emitScrollPositionThresholdIfNeeded(resync = true) } - private fun emitScrollPositionThresholdIfNeeded() { + /** + * Continuous scrolling accumulates the exact consumed dy. Layout passes and positional + * jumps (scrollToIndex/End, anchor restores, inserts above) report no dy, so they re-derive + * the distance from the laid-out first item plus the extents of every item before it. + */ + private fun emitScrollPositionThresholdIfNeeded(resync: Boolean = false) { if (disposed) return val callback = onScrollPositionThresholdChange ?: return - if (layoutManager.findFirstVisibleItemPosition() == 0) { - layoutManager.findViewByPosition(0)?.let { first -> - logicalScrollDistancePx = (recyclerView.paddingTop - layoutManager.getDecoratedTop(first)).coerceAtLeast(0) - } + if (!scrollPositionTracker.isConfigured) return + if (resync || layoutManager.findFirstVisibleItemPosition() == 0) { + measuredScrollDistancePx()?.let { logicalScrollDistancePx = it } } val contributions = recyclerView.getTag(R.id.onekey_native_scroll_coordinator_consumed_offsets) as? Map<*, *> val parentDistance = contributions?.values?.sumOf { (it as? Int)?.coerceAtLeast(0)?.toLong() ?: 0L } ?: 0L @@ -154,6 +175,7 @@ class NativeListView( private val density = resources.displayMetrics.density private val recyclerView = RecyclerView(context) private var configuredTopPaddingPx = 0 + private var slotInsetPx = 0 private var configuredBottomPaddingPx = 0 private val refreshLayout = SwipeRefreshLayout(context) private val refreshIndicatorTravelPx = refreshLayout.progressViewEndOffset @@ -201,6 +223,52 @@ class NativeListView( } private fun scrollPositionForRow(index: Int): Int = index + headerSlotAdapter.itemCount + + private fun recordLaidOutRowExtent(child: View, extentPx: Int) { + val holder = recyclerView.getChildViewHolder(child) as? NativeListViewHolder ?: return + if (holder.bindingAdapter !== adapter) return + val key = adapter.itemAt(holder.bindingAdapterPosition)?.key ?: return + val previous = measuredRowExtentsPx.put(key, extentPx) + measuredRowExtentSumPx += extentPx - (previous ?: 0) + } + + private fun pruneMeasuredRowExtents(items: List) { + if (measuredRowExtentsPx.isEmpty()) return + val keys = items.mapTo(HashSet(items.size)) { it.key } + measuredRowExtentsPx.keys.retainAll(keys) + measuredRowExtentSumPx = measuredRowExtentsPx.values.sumOf { it.toLong() } + } + + /** Rows skipped by a jump have never been laid out; explicit heights beat the average. */ + private fun estimatedScrollExtentPx(position: Int): Int { + val headerCount = headerSlotAdapter.itemCount + if (position < headerCount) return slotHeights[0] + val row = position - headerCount + if (row >= adapter.itemCount) { + return if (row - adapter.itemCount < emptySlotAdapter.itemCount) slotHeights[1] else slotHeights[2] + } + val item = adapter.itemAt(row) + item?.key?.let(measuredRowExtentsPx::get)?.let { return it } + val explicit = item?.styledHeight ?: item?.json?.optDouble("height")?.takeIf { it.isFinite() } + if (explicit != null) return (explicit * density).roundToInt() + return if (measuredRowExtentsPx.isEmpty()) 0 else (measuredRowExtentSumPx / measuredRowExtentsPx.size).toInt() + } + + private fun measuredScrollDistancePx(): Int? { + if (layoutManager.orientation != RecyclerView.VERTICAL) return null + val first = layoutManager.findFirstVisibleItemPosition() + if (first == RecyclerView.NO_POSITION) return null + val view = layoutManager.findViewByPosition(first) ?: return null + val prefix = nativeListScrollPrefixPx( + first, + layoutManager.spanCount, + layoutManager.spanSizeLookup::getSpanSize, + ::estimatedScrollExtentPx, + ) + val topMargin = (view.layoutParams as? ViewGroup.MarginLayoutParams)?.topMargin ?: 0 + val top = layoutManager.getDecoratedTop(view) - topMargin + return (prefix + recyclerView.paddingTop - top).coerceIn(0L, Int.MAX_VALUE.toLong()).toInt() + } private fun rowPositionForScroll(index: Int): Int = if (index == RecyclerView.NO_POSITION) RecyclerView.NO_POSITION else index - headerSlotAdapter.itemCount @@ -417,6 +485,9 @@ class NativeListView( recyclerView.addItemDecoration(reorderPlaceholderDecoration) // OneKey patch: full-width selector header backgrounds do not change row content insets. recyclerView.addItemDecoration(SelectorBackgroundDecoration(adapter)) + recyclerView.addItemDecoration(ContainerSlotInsetDecoration { slotInsetPx }) + layoutManager.onChildLaidOut = ::recordLaidOutRowExtent + layoutManager.onLayoutPassCompleted = { emitScrollPositionThresholdIfNeeded(resync = true) } recyclerView.setHasFixedSize(false) layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { @@ -522,7 +593,7 @@ class NativeListView( override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { logicalScrollDistancePx = (logicalScrollDistancePx + dy).coerceAtLeast(0) - emitScrollPositionThresholdIfNeeded() + emitScrollPositionThresholdIfNeeded(resync = dx == 0 && dy == 0) if (dx != 0 || dy != 0) invalidateActionAnchor("scroll") syncSectionIndexToVisibleRows() scheduleVisibleEvent() @@ -687,6 +758,7 @@ class NativeListView( } } else null config = next + pruneMeasuredRowExtents(next.items) updateContainerSlots() usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale } adapter.usesSelectorSourceScale = usesSelectorSourceScale @@ -1300,6 +1372,10 @@ class NativeListView( actionAnchor = null pendingScrollRequest = null visibleEventScheduled = false + layoutManager.onChildLaidOut = null + layoutManager.onLayoutPassCompleted = null + measuredRowExtentsPx.clear() + measuredRowExtentSumPx = 0L reorderTouchHandler?.removeCallbacksAndMessages(null) reorderTouchHandler = null reorderTouchListener?.let(recyclerView::removeOnItemTouchListener) @@ -1332,6 +1408,8 @@ class NativeListView( // Content/theme snapshots must replace only the padding owned by NativeList. val coordinatorTopPadding = (recyclerView.paddingTop - configuredTopPaddingPx).coerceAtLeast(0) val coordinatorBottomPadding = (recyclerView.paddingBottom - configuredBottomPaddingPx).coerceAtLeast(0) + // Slot roots are laid out by React at the unscaled JS inset, without the index gutter. + slotInsetPx = (horizontalPadding * density).roundToInt() configuredTopPaddingPx = dp(topPadding) configuredBottomPaddingPx = dp(bottomPadding) recyclerView.setPaddingRelative( @@ -2298,6 +2376,8 @@ class NativeListView( val child = recyclerView.findChildViewUnder(event.x, event.y) candidate = child ?.let(recyclerView::getChildViewHolder) + // Container-slot holders report binding position 0 in their own adapter. + ?.takeIf { holder -> holder is NativeListViewHolder && holder.bindingAdapter === adapter } ?.takeIf { holder -> adapter.itemAt(holder.bindingAdapterPosition)?.let { item -> // OneKey patch: any wallet group member can initiate the group drag. @@ -2896,6 +2976,25 @@ private class ItemSpacingDecoration( } } +/** + * React lays out slot content at `contentPaddingHorizontal ?? contentPadding` logical units + * from each edge. Row padding may be scaled or widened by the section-index gutter, so slot + * holders offset themselves (possibly negatively) back to the JavaScript inset. + */ +private class ContainerSlotInsetDecoration(private val insetPx: () -> Int) : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: android.graphics.Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State, + ) { + if (parent.getChildViewHolder(view) !is NativeListContainerSlotAdapter.Holder) return + val inset = insetPx() + outRect.left = inset - parent.paddingLeft + outRect.right = inset - parent.paddingRight + } +} + internal fun isStickySectionHeader(item: NativeListItem): Boolean = item.type == "sectionHeader" && item.json.optBoolean("sticky", true) && item.json.optString("variant") != "summary" diff --git a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListScrollPositionTrackerTest.kt b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListScrollPositionTrackerTest.kt index d8ace409a..31e4b1c8b 100644 --- a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListScrollPositionTrackerTest.kt +++ b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListScrollPositionTrackerTest.kt @@ -25,4 +25,20 @@ class NativeListScrollPositionTrackerTest { tracker.configure(48.0, 48.0) assertNull(tracker.update(48.0)) } + + @Test fun measuredPrefixMatchesLinearAndGridLines() { + val heights = intArrayOf(30, 80, 80, 80, 40, 90, 60) + // Linear: every item before the first visible one. + assertEquals(0L, nativeListScrollPrefixPx(0, 1, { 1 }, heights::get)) + assertEquals(30L + 80 + 80, nativeListScrollPrefixPx(3, 1, { 1 }, heights::get)) + // Grid of 3 with a full-span header at 0: lines [0], [1,2,3], [4,5,6]. + val span = { position: Int -> if (position == 0) 3 else 1 } + assertEquals(30L, nativeListScrollPrefixPx(1, 3, span, heights::get)) + assertEquals(30L, nativeListScrollPrefixPx(2, 3, span, heights::get)) + assertEquals(30L + 80, nativeListScrollPrefixPx(4, 3, span, heights::get)) + assertEquals(30L + 80, nativeListScrollPrefixPx(6, 3, span, heights::get)) + // The tallest cell defines a grid line. + val uneven = intArrayOf(10, 20, 50, 5) + assertEquals(50L, nativeListScrollPrefixPx(3, 3, { 1 }, uneven::get)) + } } diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index 3e8f42325..373184dbe 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -390,9 +390,10 @@ only. Existing Web rendering and caller behavior are unchanged. Replacing the JS callback takes effect on the next event; disabling emits no event. Configuration updates reset hysteresis before recomputing current position. - iOS uses contentOffset + contentInset, excluding overscroll below zero. Android - accumulates consumed child scroll distance and rebases from first-item geometry. - Parent-pager header contributions and arbitrary index-jump acceptance are tracked - separately; no estimate-based parity claim is made for unverified jumps. + accumulates consumed child scroll distance and, after every layout pass or + zero-delta scroll, re-derives it from the measured extents above the first laid-out + item (see Android ancestor scroll distance). Parent-pager header contributions are + tracked separately. - `layout.gridColumns` accepts integers 2 through 7 on native. Native parsers clamp malformed external JSON to 1 through 7 as before; public JS validation rejects invalid requested counts. Grid geometry remains container-owned. @@ -433,14 +434,22 @@ changing probe order or the terminal result contract; native runtime regression acceptance is still required. A tile owns at most one native player. On iOS, eligibility requires intersection with the window and every clipping/scroll ancestor viewport; native scroll, bounds, layer transform/position and visibility -observations release retained offscreen pager pages. Detach, background, -invisibility and recycle release the player; reattachment rebuilds its retained -source while remaining paused. Android observes native global scroll/layout and +observations release retained offscreen pager pages. A player lives only until +its first rendered frame is copied (Android TextureView bitmap, iOS +AVPlayerItemVideoOutput pixel buffer); the tile then shows that still frame with the +same fit and letterbox and releases the player and its viewport observers. If no frame +can be copied after a few frames, the paused player stays attached. Detach, +background, invisibility and recycle release a player still awaiting its first frame; +reattachment rebuilds its retained source while remaining paused. The still frame is +kept across detach and same-identity rebinding and dropped on recycle or identity +change. Android observes native global scroll/layout and pre-draw geometry (including property animations), checking ancestor alpha and getGlobalVisibleRect before allocating a player. These observers do not schedule frames or emit JavaScript events. -Player count is bounded by intersecting native video tiles, including partially -visible tiles; there is no numeric cap that blanks visible iPad cells. iOS requests +Live player count is bounded by intersecting native video tiles that have not yet +captured a frame, including partially visible tiles; there is no numeric cap that +blanks visible iPad cells. Per-draw (Android) and ancestor-layer KVO (iOS) viewport +observation exists only while a tile awaits its first frame. iOS requests one second of forward buffer; AVFoundation treats this as an advisory preference, not a hard byte limit. Android uses a 250–1000 ms forward buffer, zero back buffer and a 2 MiB target allocator; Media3 may exceed a target by allocation granularity. @@ -479,14 +488,20 @@ Slots scroll with the native list. `fixedFooter` remains a separate fixed native row descriptor. `scrollToEnd` aligns the final scrolling slot bottom with the viewport end, including footer/empty/header-only slots taller than the viewport. Callers using `listEmpty` MUST omit `snapshot.emptyState`, whose existing native descriptor becomes a data item. Horizontal content padding applies -to slots as well as data rows. Slot React trees own their internal layout and +to slots as well as data rows: every platform places a slot root exactly +`contentPaddingHorizontal ?? contentPadding` logical units from each edge, the same +width React lays it out at. Android's narrow-screen row scale and section-index gutter +apply to data rows only. Slot React trees own their internal layout and business actions; NativeList owns their scroll placement and lifecycle. The wrapper mounts exactly three non-collapsible slot roots and reports measured heights through `containerSlotHeightsJson` in header/empty/footer order. Native validates three finite, non-negative heights. Slot updates MUST NOT alter data-row keys or public row indices. No per-scroll-frame JS position updates are used. -iOS mounts slot roots inside UICollectionView and reserves native layout space. +iOS mounts each slot root inside a list-owned host view in UICollectionView and +reserves native layout space. The host owns the slot's vertical position and clips to +the reported height, so React frame updates cannot move a slot out of place and a slot +whose height has not been reported yet (or has just grown) never draws over rows. Android uses ConcatAdapter with three single-item slot adapters around the existing data adapter; adapter binding indices stay local while layout-manager positions are explicitly translated. Slots span all grid columns, do not select/reorder, @@ -558,7 +573,10 @@ source `description`; footer actions retain their existing source/keys and disabled behavior. All actions use the current host binding epoch. Rich rows measure the declared line/visual metrics; there is no six-line native -truncation. The existing height override contract still applies. RTL follows +truncation. iOS automatic height mirrors the rendered stacks: `lineGap` between the +visible title/description/status lines, the description measured at the identity +column's actual width (stacked or table, including the fee column), fee height and the +footer-action button height. Android sizes the row to its content. The existing height override contract still applies. RTL follows semantic leading/trailing anchors. Clearing rich fields returns to the legacy renderer, and removing amount lines recycles their visual slots. Web rendering remains unchanged; rich fields are native-only until a separate Web migration. @@ -581,12 +599,17 @@ same logical Home threshold as iOS normalized content scrolling, without a JS scroll-frame event. Changes to the listener or public callbacks do not replace native resource ownership. -Child distance uses actual onScrolled dy and rebases from first-item geometry at -top; scrollToOffset seeds a known explicit offset. It does not use RecyclerView's -average-height scrollbar estimate. Continuous scrolling and return-to-top are -covered by this policy; exact classification immediately after arbitrary distant -scrollToIndex jumps remains pending measured-prefix correction and MUST NOT be -claimed as verified. A later top anchor restores exact distance. +Child distance uses actual onScrolled dy while scrolling continuously. Programmatic +jumps (scrollToIndex/Key/End/Offset), anchor restores and inserts or height changes +above the viewport report no dy, so every layout pass and zero-delta scroll re-derives +the distance as the extent of all grid lines before the first laid-out item plus its +offset from the padded top. Extents are those actually laid out (recorded per row key, +including rows a jump lays out and leaves offscreen, pruned to the current rows); a row +that was never laid out uses its explicit height, else the mean laid-out row extent. +Slots use their reported heights. The result is exact once every item above the +viewport has been laid out and an estimate only across never-laid-out rows; it does not +use RecyclerView's average-height scrollbar estimate. Runtime acceptance for distant +jumps remains pending. ### Container follow-up verification diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index faac16523..0d0de5e23 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -249,7 +249,7 @@ Each cell describes the same public contract on **Web + iOS + Android**. | `identity` | title, subtitle, tertiary, badge, value, valueSecondary | padding, leadingGap, lineGap, titleBadgeGap, trailingGap, image | | `walletGroup` | None; members own their identity styles | padding | | `rail` | title, badge, status | padding, leadingGap, titleBadgeGap, trailingGap, image | -| `activity` | title, description, status, primaryAmount, secondaryAmount | padding, leadingGap, lineGap, trailingGap, image | +| `activity` | title, description, status, primaryAmount, secondaryAmount | padding, leadingGap, lineGap, titleBadgeGap, trailingGap, image | | `message` | title, body, time | padding, leadingGap, lineGap, image | | `dataRow` | columns, columnSecondary, index | padding, leadingGap, lineGap, titleBadgeGap, image | | `market` | title, subtitle, price, change | padding, leadingGap, lineGap, titleBadgeGap, trailingGap, image; Market-specific properties below | @@ -265,7 +265,9 @@ its existing template-specific style surface on every platform. Gap meanings are structural: `leadingGap` separates the primary visual from content; `lineGap` separates content text rows (primary/secondary per data column); -`titleBadgeGap` separates a title and its badges (vertical for walletSidebar); +`titleBadgeGap` separates a title and its badges (vertical for walletSidebar; +for activity, the native failure label or rich title badges; Web has no activity +badge target); `trailingGap` separates trailing values/controls (title/badge to status for rail). `walletGroup` padding applies only to the group; it never cascades into members. Composite metric-card `lineGap` separates heading/metric blocks and dividers; it diff --git a/native-views/react-native-native-list/ios/NativeListActivityDetailsView.swift b/native-views/react-native-native-list/ios/NativeListActivityDetailsView.swift index 9d7792e2a..5b5f7dc9a 100644 --- a/native-views/react-native-native-list/ios/NativeListActivityDetailsView.swift +++ b/native-views/react-native-native-list/ios/NativeListActivityDetailsView.swift @@ -49,25 +49,87 @@ final class NativeListActivityDetailsView: UIStackView { } required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + private static let sizingButton: UIButton = { + let button = UIButton(type: .system) + button.titleLabel?.font = nativeListFont(ofSize: 14, weight: .medium) + button.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) + return button + }() + + /// Mirrors bind(): the same resolved texts, stack spacings and column widths, so the + /// automatic height matches the rendered stacks. static func measure(_ data: [String: Any], width: CGFloat) -> CGFloat { let style = data.dictionary("style") ?? [:] - let primary = style.dictionary("primaryAmount") ?? [:] - let secondary = style.dictionary("secondaryAmount") ?? [:] - let rows = data.dictionaries("amounts") - let gap = CGFloat(style.double("trailingGap", default: 2)) - let amountsHeight = rows.reduce(CGFloat(0)) { height, row in - height + CGFloat(primary.double("lineHeight", default: 24)) + - (row.string("secondaryText").isEmpty ? 0 : CGFloat(secondary.double("lineHeight", default: 20))) - } + CGFloat(max(0, rows.count - 1)) * gap - let titleHeight = CGFloat(style.dictionary("title")?.double("lineHeight", default: 24) ?? 24) + let table = data.string("presentation") == "table" + let image = style.dictionary("image") ?? [:] + let horizontalPadding = CGFloat(style.double("horizontalPadding", default: table ? 16 : 12)) + let verticalPadding = CGFloat(style.double("verticalPadding", default: 8)) + let lineGap = CGFloat(style.double("lineGap")) + let gap = CGFloat(style.double("leadingGap", default: 12)) + let imageWidth = CGFloat(image.double("width", default: 40)) + let imageHeight = CGFloat(image.double("height", default: 40)) + let mainWidth = max(0, width - horizontalPadding * 2) + + let amounts = data.dictionaries("amounts").map { amount -> (width: CGFloat, height: CGFloat) in + let primary = NativeListResolvedText(amount.string("text"), style: style.dictionary("primaryAmount"), + size: 16, weight: .medium, color: .label, lineHeight: 24, lines: 1, tabular: true) + let secondary = NativeListResolvedText(amount.string("secondaryText"), style: style.dictionary("secondaryAmount"), + size: 14, color: .label, lineHeight: 20, lines: 1) + let visual: CGFloat = amount.dictionary("leading") == nil ? 0 : 20 + return ( + (visual > 0 ? visual + 6 : 0) + max(primary.measureWidth(), secondary.measureWidth()), + max(visual, primary.measure(width: .greatestFiniteMagnitude) + secondary.measure(width: .greatestFiniteMagnitude)) + ) + } + let amountsGap = CGFloat(style.double("trailingGap", default: 2)) + let amountsHeight = amounts.reduce(CGFloat(0)) { $0 + $1.height } + CGFloat(max(0, amounts.count - 1)) * amountsGap + let amountsWidth = amounts.map(\.width).max() ?? 0 + + var feeWidth: CGFloat = 0 + var feeHeight: CGFloat = 0 + if let feeData = data.dictionary("fee") { + let secondaryStyle = style.dictionary("secondaryAmount") + let label = NativeListResolvedText(feeData.string("label"), style: secondaryStyle, size: 12, color: .label, lineHeight: 16, lines: 1) + let primary = NativeListResolvedText(feeData.string("primary"), style: secondaryStyle, size: 14, color: .label, lineHeight: 20, lines: 1) + let secondary = NativeListResolvedText(feeData.string("secondary"), style: secondaryStyle, size: 12, color: .label, lineHeight: 16, lines: 1) + let lineWidth = primary.measureWidth() + secondary.measureWidth() + + (primary.text.isEmpty || secondary.text.isEmpty ? 0 : 4) + feeWidth = max(label.measureWidth(), lineWidth) + feeHeight = label.measure(width: .greatestFiniteMagnitude) + + max(primary.measure(width: .greatestFiniteMagnitude), secondary.measure(width: .greatestFiniteMagnitude)) + } + let feeColumn = data.dictionary("fee") == nil ? 0 : gap + feeWidth + let identityWidth: CGFloat + if table { + let shared = (mainWidth - imageWidth - gap * 2 - feeColumn) / 2 + identityWidth = min(mainWidth * 0.42, shared) + } else { + identityWidth = mainWidth - imageWidth - gap * 2 - feeColumn - min(amountsWidth, mainWidth * 0.5) + } + + let title = NativeListResolvedText(data.string("title"), style: style.dictionary("title"), size: 16, + weight: .medium, color: .label, lineHeight: 24, lines: 1) + let titleHeight = max( + title.measure(width: .greatestFiniteMagnitude), + data.dictionaries("badges").isEmpty ? 0 : 16) let description = NativeListResolvedText(data.string("description"), style: style.dictionary("description"), size: 14, color: .label, lineHeight: 20, lines: 2) - let identityHeight = titleHeight + description.measure(width: max(1, width * 0.4 - 52)) + - (data.string("status").isEmpty ? 0 : 16) - let imageHeight = CGFloat(style.dictionary("image")?.double("height", default: 40) ?? 40) - return max(imageHeight, identityHeight, amountsHeight) + - CGFloat(style.double("verticalPadding", default: 8)) * 2 + - (data.dictionaries("footerActions").isEmpty ? 0 : 40) + let status = NativeListResolvedText(data.string("status"), style: style.dictionary("status"), + size: 12, color: .label, lineHeight: 16, lines: 1) + var identityHeight = titleHeight + for text in [description, status] where !text.text.isEmpty { + identityHeight += lineGap + text.measure(width: max(1, identityWidth)) + } + + var actionsHeight: CGFloat = 0 + let actions = data.dictionaries("footerActions") + if !actions.isEmpty { + actionsHeight = actions.reduce(CGFloat(0)) { height, action in + sizingButton.setTitle(action.string("label"), for: .normal) + return max(height, ceil(sizingButton.intrinsicContentSize.height)) + } + 8 + } + return max(imageHeight, identityHeight, amountsHeight, feeHeight) + actionsHeight + verticalPadding * 2 } func bind(_ item: NativeListItem, theme: [String: Any]?) { diff --git a/native-views/react-native-native-list/ios/NativeListMediaPreviewSlot.swift b/native-views/react-native-native-list/ios/NativeListMediaPreviewSlot.swift index 8bf89e09a..d3ce0a518 100644 --- a/native-views/react-native-native-list/ios/NativeListMediaPreviewSlot.swift +++ b/native-views/react-native-native-list/ios/NativeListMediaPreviewSlot.swift @@ -10,11 +10,16 @@ private final class NativeListPausedVideoView: UIView { override func layoutSubviews() { super.layoutSubviews(); onLayoutChanged?() } } -/// Uses the same AVPlayer source semantics as native Video, always paused and muted. +/// Uses the same AVPlayer source semantics as native Video, always paused and muted. A player +/// lives only until its first frame is copied; the tile then shows that still frame. final class NativeListMediaPreviewSlot { let view = UIView() private let image = NativeListImageSlot() private let video = NativeListPausedVideoView() + private let frameView = UIImageView() + private var videoOutput: AVPlayerItemVideoOutput? + private static let frameContext = CIContext(options: [.cacheIntermediates: false]) + private static let frameCaptureAttempts = 6 private var identity: String? private var epoch = 0 private var candidateGeneration = 0 @@ -37,6 +42,10 @@ final class NativeListMediaPreviewSlot { } video.clipsToBounds = true video.isHidden = true + frameView.frame = video.bounds + frameView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + frameView.isHidden = true + video.addSubview(frameView) video.onWindowChanged = { [weak self] in self?.observeViewport(); self?.updateVisibility() } video.onLayoutChanged = { [weak self] in self?.updateVisibility() } lifecycleObservers = [ @@ -63,7 +72,9 @@ final class NativeListMediaPreviewSlot { recycle() identity = next self.completion = completion - video.playerLayer.videoGravity = (fit ?? source.string("contentFit")) == "cover" ? .resizeAspectFill : .resizeAspect + let cover = (fit ?? source.string("contentFit")) == "cover" + video.playerLayer.videoGravity = cover ? .resizeAspectFill : .resizeAspect + frameView.contentMode = cover ? .scaleAspectFill : .scaleAspectFit probe(source, key: key, fit: fit, placeholder: placeholder, order: probeOrder, attempt: 0, token: epoch) } @@ -109,6 +120,11 @@ final class NativeListMediaPreviewSlot { let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": source.dictionary("headers") ?? [:]]) let item = AVPlayerItem(asset: asset) item.preferredForwardBufferDuration = 1 + let output = AVPlayerItemVideoOutput(pixelBufferAttributes: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + ]) + item.add(output) + self.videoOutput = output let player = AVPlayer(playerItem: item) player.isMuted = true player.pause() @@ -123,7 +139,9 @@ final class NativeListMediaPreviewSlot { self.readyObservation = self.video.playerLayer.observe(\.isReadyForDisplay, options: [.initial, .new]) { [weak self] layer, _ in DispatchQueue.main.async { guard let self, self.playerGeneration == generation else { return } - if layer.isReadyForDisplay { finished(true) } + guard layer.isReadyForDisplay else { return } + finished(true) + self.captureFirstFrame(generation: generation, attemptsLeft: Self.frameCaptureAttempts) } } } @@ -131,6 +149,42 @@ final class NativeListMediaPreviewSlot { updateVisibility() } + /// A paused item may not have handed its preroll frame to the output yet, so copying retries + /// on later runloop turns. If no frame arrives the paused player stays attached. + private func captureFirstFrame(generation: Int, attemptsLeft: Int) { + guard playerGeneration == generation, let player, let item = player.currentItem, let output = videoOutput else { return } + let time = item.currentTime() + if let buffer = output.copyPixelBuffer(forItemTime: time, itemTimeForDisplay: nil), + let still = Self.frameContext.createCGImage(CIImage(cvPixelBuffer: buffer), from: CGRect( + x: 0, y: 0, width: CVPixelBufferGetWidth(buffer), height: CVPixelBufferGetHeight(buffer))) { + let transform = item.tracks.lazy.compactMap(\.assetTrack).first { $0.mediaType == .video }?.preferredTransform + frameView.image = UIImage(cgImage: still, scale: 1, orientation: Self.orientation(transform)) + frameView.isHidden = false + resumeVideo = nil + viewportObservations.removeAll() + releasePlayer() + return + } + guard attemptsLeft > 1 else { return } + // An exact seek re-delivers the paused preroll frame to newly attached outputs. + if attemptsLeft == Self.frameCaptureAttempts { + player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.captureFirstFrame(generation: generation, attemptsLeft: attemptsLeft - 1) + } + } + + private static func orientation(_ transform: CGAffineTransform?) -> UIImage.Orientation { + guard let transform else { return .up } + switch (transform.a, transform.b, transform.c, transform.d) { + case (0, 1, -1, 0): return .right + case (0, -1, 1, 0): return .left + case (-1, 0, 0, -1): return .down + default: return .up + } + } + private func observeViewport() { viewportObservations.removeAll() guard resumeVideo != nil, video.window != nil else { return } @@ -149,6 +203,9 @@ final class NativeListMediaPreviewSlot { } } + var hasLivePlayer: Bool { player != nil } + var capturedFrame: UIImage? { frameView.image } + /// Retained pager pages can have a window while lying outside its clipped viewport. static func intersectsViewport(_ view: UIView) -> Bool { guard let window = view.window, !view.bounds.isEmpty else { return false } @@ -173,6 +230,7 @@ final class NativeListMediaPreviewSlot { playerGeneration &+= 1 itemObservation = nil readyObservation = nil + videoOutput = nil player?.pause() video.playerLayer.player = nil player?.replaceCurrentItem(with: nil) @@ -187,6 +245,8 @@ final class NativeListMediaPreviewSlot { image.recycle() image.view.isHidden = false video.isHidden = true + frameView.image = nil + frameView.isHidden = true identity = nil result = nil completion = nil diff --git a/native-views/react-native-native-list/ios/NativeListResolvedText.swift b/native-views/react-native-native-list/ios/NativeListResolvedText.swift index 48f0f89c2..e8f90c0a9 100644 --- a/native-views/react-native-native-list/ios/NativeListResolvedText.swift +++ b/native-views/react-native-native-list/ios/NativeListResolvedText.swift @@ -104,6 +104,14 @@ struct NativeListResolvedText { return cache }() + /// Single-line natural width of the full-size text (subscript runs only make it narrower). + func measureWidth() -> CGFloat { + guard !text.isEmpty else { return 0 } + return ceil(attributed(direction: .leftToRight).boundingRect( + with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil).width) + } + func measure(width: CGFloat) -> CGFloat { guard !text.isEmpty else { return 0 } let width = max(1, width) diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index 6313ff0ac..c58d5957e 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -6,21 +6,34 @@ import UniformTypeIdentifiers final class NativeListView: UIView { private var refreshEmittedForDrag = false private var minimumDragOffset: CGFloat = 0 - private var reactContainerSlots: [UIView] = [] private var containerSlotHeights: [CGFloat] = [0, 0, 0] + // Fabric keeps assigning each slot root the frame React computed (inset, 0, width, height). + // Each root therefore lives in a list-owned host whose origin the list alone controls, so a + // React layout update can never move a slot out of its native position, even for a frame. + private var reactContainerSlots: [(content: UIView, host: UIView)] = [] private var lastLaidOutContentHeight: CGFloat? private var needsContentShrinkCorrection = false private var contentShrinkCorrectionScheduled = false @objc func mountContainerSlot(_ view: UIView, atIndex index: Int) { - reactContainerSlots.insert(view, at: min(index, reactContainerSlots.count)) - collectionView.addSubview(view) + let host = UIView() + // Clipping to the reported height keeps new content from overlapping rows until the list + // has reserved space for it. + host.clipsToBounds = true + host.addSubview(view) + reactContainerSlots.insert((view, host), at: min(index, reactContainerSlots.count)) + collectionView.addSubview(host) + layoutContainerSlots() setNeedsLayout() } @objc func unmountContainerSlot(_ view: UIView) { - reactContainerSlots.removeAll { $0 === view } - view.removeFromSuperview() + reactContainerSlots.removeAll { slot in + guard slot.content === view else { return false } + view.removeFromSuperview() + slot.host.removeFromSuperview() + return true + } } func setContainerSlotHeightsJson(_ json: String) { @@ -29,11 +42,11 @@ final class NativeListView: UIView { heights.count == 3, heights.allSatisfy({ $0.isFinite && $0 >= 0 }) else { return } containerSlotHeights = heights.map { CGFloat($0) } if let config { configureLayout(config) } + layoutContainerSlots() setNeedsLayout() } private func layoutContainerSlots() { - guard reactContainerSlots.count == 3 else { return } let empty = config?.items.isEmpty ?? true let header = containerSlotHeights[0] let emptyHeight = empty ? containerSlotHeights[1] : 0 @@ -43,12 +56,11 @@ final class NativeListView: UIView { let footerY = max(paddingTop + header + emptyHeight, collectionView.contentSize.height - paddingBottom - footer) let positions = [paddingTop, paddingTop + header, footerY] - for (index, view) in reactContainerSlots.enumerated() { - view.isHidden = index == 1 && !empty - let horizontalPadding = config?.contentPaddingHorizontal ?? 0 - view.frame = CGRect(x: horizontalPadding, y: positions[index], - width: max(0, collectionView.bounds.width - horizontalPadding * 2), - height: containerSlotHeights[index]) + for (index, slot) in reactContainerSlots.prefix(3).enumerated() { + slot.host.isHidden = index == 1 && !empty + // React places the root at the horizontal inset inside this full-width host. + slot.host.frame = CGRect(x: 0, y: positions[index], + width: collectionView.bounds.width, height: containerSlotHeights[index]) } } diff --git a/native-views/react-native-native-list/ios/tests/NativeListActivityPresentationTests.swift b/native-views/react-native-native-list/ios/tests/NativeListActivityPresentationTests.swift index adc4a20d8..bd25ad284 100644 --- a/native-views/react-native-native-list/ios/tests/NativeListActivityPresentationTests.swift +++ b/native-views/react-native-native-list/ios/tests/NativeListActivityPresentationTests.swift @@ -40,4 +40,58 @@ final class NativeListActivityPresentationTests: XCTestCase { XCTAssertEqual(countBaseline?.doubleValue ?? 0, baseline?.doubleValue ?? 0) } } + + func testAutomaticHeightMatchesRenderedStacks() throws { + let amount: [String: Any] = ["key": "send", "text": "-0.5 ETH", "secondaryText": "$1,000.00"] + let cases: [(String, [String: Any])] = [ + ("stacked single-line", [ + "presentation": "stacked", "description": "Jan 1 • 0x1234…abcd", "amounts": [amount], + "style": ["horizontalPadding": 20, "verticalPadding": 12, "lineGap": 0, + "title": ["fontSize": 16, "lineHeight": 24, "fontWeight": "medium", "lines": 1], + "description": ["fontSize": 14, "lineHeight": 20, "lines": 1]], + ]), + ("table line gap without secondary amount", [ + "presentation": "table", "description": "0x1234…abcd", + "amounts": [["key": "approve", "text": "Unlimited USDC"]], + "fee": ["label": "Network fee", "primary": "0.0001 ETH", "secondary": "$0.20"], + "badges": [["key": "risk", "text": "Scam", "tone": "danger"]], + "style": ["horizontalPadding": 20, "verticalPadding": 12, "lineGap": 4, + "title": ["fontSize": 14, "lineHeight": 20, "fontWeight": "medium", "lines": 1], + "description": ["fontSize": 14, "lineHeight": 20, "lines": 1]], + ]), + ("wrapped description, status and actions", [ + "description": String(repeating: "Contract interaction with a long label ", count: 3), + "status": "Pending", "amounts": [amount, ["key": "receive", "text": "+1 USDC"]], + "footerActions": [["key": "speedUp", "label": "Speed up"], ["key": "cancel", "label": "Cancel"]], + "style": ["lineGap": 6], + ]), + ] + for (name, fields) in cases { + var data: [String: Any] = [ + "type": "activity", "key": name, "title": "Send", + "leading": ["kind": "icon", "name": "ArrowTopOutline"], + ] + fields.forEach { data[$0.key] = $0.value } + let item = try NativeListItem(data: data) + let width: CGFloat = 390 + let style = data["style"] as? [String: Any] ?? [:] + let table = data["presentation"] as? String == "table" + let horizontal = CGFloat(style.double("horizontalPadding", default: table ? 16 : 12)) + let vertical = CGFloat(style.double("verticalPadding", default: 8)) + let container = UIView(frame: CGRect(x: 0, y: 0, width: width - horizontal * 2, height: 1000)) + let view = NativeListActivityDetailsView() + view.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(view) + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: container.leadingAnchor), + view.trailingAnchor.constraint(equalTo: container.trailingAnchor), + view.topAnchor.constraint(equalTo: container.topAnchor), + ]) + view.bind(item, theme: nil) + container.layoutIfNeeded() + container.layoutIfNeeded() + let rendered = ceil(view.frame.height) + vertical * 2 + XCTAssertEqual(NativeListActivityDetailsView.measure(data, width: width), rendered, accuracy: 1, name) + } + } } diff --git a/native-views/react-native-native-list/ios/tests/NativeListLifecycleTests.swift b/native-views/react-native-native-list/ios/tests/NativeListLifecycleTests.swift index 71e7ae8c1..8bcc538bb 100644 --- a/native-views/react-native-native-list/ios/tests/NativeListLifecycleTests.swift +++ b/native-views/react-native-native-list/ios/tests/NativeListLifecycleTests.swift @@ -175,6 +175,34 @@ final class NativeListLifecycleTests: XCTestCase { XCTAssertEqual(list.contentOffset.y, maximum, accuracy: 0.5) } + func testReactSlotFramesCannotMoveSlotsOutOfNativePlacement() throws { + let view = NativeListView(frame: CGRect(x: 0, y: 0, width: 320, height: 708)) + // Fabric assigns each root the frame React computed: horizontal inset, top 0. + let slots = (0..<3).map { _ in UIView(frame: CGRect(x: 16, y: 0, width: 288, height: 44)) } + for (index, slot) in slots.enumerated() { view.mountContainerSlot(slot, atIndex: index) } + view.applySnapshotJson(try scrollingSnapshot(count: 3)) + view.layoutIfNeeded() + let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first) + list.layoutIfNeeded() + let header = try XCTUnwrap(slots[0].superview) + XCTAssertTrue(header.clipsToBounds) + XCTAssertEqual(header.bounds.height, 0, "An unmeasured header cannot draw over the first rows") + + view.setContainerSlotHeightsJson("[44,44,60]") + view.layoutIfNeeded() + list.layoutIfNeeded() + XCTAssertEqual(slots[0].convert(CGPoint.zero, to: list), CGPoint(x: 16, y: 0)) + let footerY = 44 + 3 * 80 as CGFloat + XCTAssertEqual(slots[2].convert(CGPoint.zero, to: list).y, footerY) + // A React height update rewrites the root frame before the new height reaches native. + slots[2].frame = CGRect(x: 16, y: 0, width: 288, height: 90) + XCTAssertEqual(slots[2].convert(CGPoint.zero, to: list), CGPoint(x: 16, y: footerY)) + XCTAssertTrue(slots[1].superview?.isHidden == true, "The empty slot stays hidden with rows") + + view.unmountContainerSlot(slots[2]) + XCTAssertNil(slots[2].superview) + } + private func makeScrollingList() throws -> NativeListView { let view = NativeListView(frame: CGRect(x: 0, y: 0, width: 320, height: 708)) for index in 0..<3 { view.mountContainerSlot(UIView(), atIndex: index) } diff --git a/native-views/react-native-native-list/ios/tests/NativeListMediaVisibilityTests.swift b/native-views/react-native-native-list/ios/tests/NativeListMediaVisibilityTests.swift index cd97a6d4b..67a3c107e 100644 --- a/native-views/react-native-native-list/ios/tests/NativeListMediaVisibilityTests.swift +++ b/native-views/react-native-native-list/ios/tests/NativeListMediaVisibilityTests.swift @@ -40,3 +40,70 @@ final class NativeListMediaVisibilityTests: XCTestCase { XCTAssertFalse(NativeListMediaPreviewSlot.intersectsViewport(tile)) } } + +@MainActor +final class NativeListMediaFrameCaptureTests: XCTestCase { + func testVisibleVideoReleasesPlayerAfterCapturingFirstFrame() throws { + let url = try writeVideo() + defer { try? FileManager.default.removeItem(at: url) } + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + window.rootViewController = UIViewController() + window.isHidden = false + defer { window.isHidden = true } + let slot = NativeListMediaPreviewSlot() + slot.view.frame = CGRect(x: 0, y: 0, width: 120, height: 120) + window.rootViewController?.view.addSubview(slot.view) + slot.view.layoutIfNeeded() + + var loaded: Bool? + slot.bind(["uri": url.absoluteString], key: "video", probeOrder: ["video"]) { loaded = $0 } + XCTAssertTrue(slot.hasLivePlayer, "A visible tile starts one paused player") + let deadline = Date().addingTimeInterval(10) + while slot.capturedFrame == nil, Date() < deadline { + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + } + XCTAssertEqual(loaded, true) + let frame = try XCTUnwrap(slot.capturedFrame, "The first frame replaces the live player") + XCTAssertEqual(frame.size, CGSize(width: 64, height: 48)) + XCTAssertFalse(slot.hasLivePlayer) + + // Leaving and re-entering the viewport keeps the still without rebuilding a player. + slot.view.removeFromSuperview() + window.rootViewController?.view.addSubview(slot.view) + slot.bind(["uri": url.absoluteString], key: "video", probeOrder: ["video"]) + XCTAssertFalse(slot.hasLivePlayer) + XCTAssertNotNil(slot.capturedFrame) + slot.recycle() + XCTAssertNil(slot.capturedFrame) + } + + /// A 64×48, ten-frame H.264 MP4 (generated with AVAssetWriter on macOS); the simulator + /// has no reliable H.264 encoder for writing one at test time. + private static let videoBase64 = [ + "AAAAHGZ0eXBtcDQyAAAAAWlzb21tcDQxbXA0MgAAAAFtZGF0AAAAAAAAAXQAAAA1BgUtR1ZK3FxMQz+U78URPNFDqAEAAAMA", + "AQMAAAMAAQIAAeYACwAAAwAAAwAArDoMA5ErAYAAAAA8JbggH94I5Uz/gak9b71ouT+PwABUezojLMLPpBfbqJ15oD1BM5L2", + "vvLSFvvy+AABSCrnn2hX8nKgIiU0AAAAHSHhEF/Eku94/c0JBdrVAqcS2Y43GYAJD/p9QzWAAAAAGSGogoS/nFT7cZbZ8xnp", + "PB3mw6AB/xojtyEAAAAXAajBj/+nyteRkHQKsy3f1gAFJajGDXAAAAAVAajDj/9TDF3o0LWPJnRoAJ7STV8lAAAAGSHjIaIl", + "/wHKBDW4y227MDl54AH+VxDvqGgAAAAUIakGhL9JSwD9lXLr+gAf8c7Z0iAAAAATAalFiT9cnuAKgJxY0AGV0YL85AAAABEB", + "qUeJP1ye5zMOKAAol0Uv0AAAABQh5SWiJf8BygQQ1yj2ACX52k6mwAAAA0xtb292AAAAbG12aGQAAAAA5uAsIObgLCAAAAJY", + "AAACWAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAACAAAC2HRyYWsAAABcdGtoZAAAAAHm4Cwg5uAsIAAAAAEAAAAAAAACWAAAAAAAAAAAAAAAAAAAAAAAAQAA", + "AAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAQAAAADAAAAAAACRlZHRzAAAAHGVsc3QAAAAAAAAAAQAAAlgAAAB4", + "AAEAAAAAAlBtZGlhAAAAIG1kaGQAAAAA5uAsIObgLCAAAAJYAAACWFXEAAAAAAAxaGRscgAAAAAAAAAAdmlkZQAAAAAAAAAA", + "AAAAAENvcmUgTWVkaWEgVmlkZW8AAAAB921pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAA", + "AQAAAAx1cmwgAAAAAQAAAbdzdGJsAAAAoXN0c2QAAAAAAAAAAQAAAJFhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAA", + "MABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAJ2F2Y0MBZAAL/+EADCdkAAus", + "VlDDeBBhlAEABCjuPLD9+PgAAAAACmZpZWwBAAAAAApjaHJtAAAAAAAYc3R0cwAAAAAAAAABAAAACgAAADwAAABgY3R0cwAA", + "AAAAAAAKAAAAAQAAAHgAAAABAAABLAAAAAEAAAB4AAAAAQAAAAAAAAABAAAAPAAAAAEAAAEsAAAAAQAAAHgAAAABAAAAAAAA", + "AAEAAAA8AAAAAQAAAHgAAAAUc3RzcwAAAAAAAAABAAAAAQAAABZzZHRwAAAAACAQEBgYEBAYGBAAAAAcc3RzYwAAAAAAAAAB", + "AAAAAQAAAAoAAAABAAAAPHN0c3oAAAAAAAAAAAAAAAoAAAB5AAAAIQAAAB0AAAAbAAAAGQAAAB0AAAAYAAAAFwAAABUAAAAY", + "AAAAFHN0Y28AAAAAAAAAAQAAACw=", + ].joined() + + private func writeVideo() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("native-list-frame-\(UUID().uuidString).mp4") + try XCTUnwrap(Data(base64Encoded: Self.videoBase64)).write(to: url) + return url + } +} diff --git a/native-views/react-native-native-list/src/__tests__/activityRich.test.ts b/native-views/react-native-native-list/src/__tests__/activityRich.test.ts index f55bf05bd..393b5b844 100644 --- a/native-views/react-native-native-list/src/__tests__/activityRich.test.ts +++ b/native-views/react-native-native-list/src/__tests__/activityRich.test.ts @@ -88,3 +88,38 @@ test('activity badges have bounded unique identities', () => { validateSnapshot(snapshot({ ...row, badges: [badges[0]!, badges[0]!] })) ).toThrow(/duplicate badge/); }); + +test('rich data fields are not style keys; activity accepts titleBadgeGap', () => { + for (const key of [ + 'amounts', + 'badges', + 'presentation', + 'fee', + 'descriptionActionKey', + ]) { + expect(() => + validateSnapshot( + snapshot({ + ...row, + style: { [key]: { fontSize: 12 } }, + } as unknown as ActivityRow) + ) + ).toThrow(/is not a style key/); + } + expect(() => + validateSnapshot(snapshot({ ...row, style: { titleBadgeGap: 6 } })) + ).not.toThrow(); + expect(() => + validateSnapshot({ + ...snapshot(row), + rows: [ + { + type: 'sectionHeader', + key: 'header', + title: 'Today', + style: { titleLoading: { fontSize: 12 } }, + } as unknown as ActivityRow, + ], + }) + ).toThrow(/is not a style key/); +}); diff --git a/native-views/react-native-native-list/src/models.ts b/native-views/react-native-native-list/src/models.ts index 5794eecd8..7c9310201 100644 --- a/native-views/react-native-native-list/src/models.ts +++ b/native-views/react-native-native-list/src/models.ts @@ -128,6 +128,7 @@ export const ROW_BOX_STYLE_KEYS_BY_TYPE = { 'verticalPadding', 'leadingGap', 'lineGap', + 'titleBadgeGap', 'trailingGap', 'image', ], diff --git a/native-views/react-native-native-list/src/validation.ts b/native-views/react-native-native-list/src/validation.ts index 0cd79e04b..119815f4a 100644 --- a/native-views/react-native-native-list/src/validation.ts +++ b/native-views/react-native-native-list/src/validation.ts @@ -175,11 +175,6 @@ const TEXT_STYLE_KEYS_BY_ROW_TYPE: Readonly< 'title', 'description', 'status', - 'amounts', - 'badges', - 'presentation', - 'fee', - 'descriptionActionKey', 'primaryAmount', 'secondaryAmount', ], @@ -188,7 +183,7 @@ const TEXT_STYLE_KEYS_BY_ROW_TYPE: Readonly< market: ['title', 'subtitle', 'price', 'change'], mediaTile: ['title', 'subtitle', 'badge'], metricCard: ['title', 'value', 'subtitle', 'trend'], - sectionHeader: ['titleLoading', 'title', 'subtitle', 'value'], + sectionHeader: ['title', 'subtitle', 'value'], action: ['title', 'value'], system: ['title', 'message', 'actionText'], };