@onekeyfe/react-native-native-overlay 3.0.162-alpha.272 → 6.7.0-9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayAnimation.kt +5 -10
  2. package/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayEntryRootView.kt +23 -2
  3. package/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayHost.kt +17 -12
  4. package/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayView.kt +3 -1
  5. package/android/src/test/java/com/onekey/nativeoverlay/NativeOverlayBackTest.kt +295 -0
  6. package/android/src/test/java/com/onekey/nativeoverlay/NativeOverlayMotionTest.kt +101 -0
  7. package/docs/SPEC.md +164 -30
  8. package/ios/NativeOverlayContainerView.swift +20 -7
  9. package/ios/NativeOverlaySheetController.swift +35 -1
  10. package/lib/module/OverlayStore.js +13 -1
  11. package/lib/module/OverlayStore.js.map +1 -1
  12. package/lib/module/OverlayView.web.js +104 -25
  13. package/lib/module/OverlayView.web.js.map +1 -1
  14. package/lib/module/useOverlayController.js +11 -3
  15. package/lib/module/useOverlayController.js.map +1 -1
  16. package/lib/module/web/animateTransition.js +20 -12
  17. package/lib/module/web/animateTransition.js.map +1 -1
  18. package/lib/module/web/overlayLayers.js +16 -0
  19. package/lib/module/web/overlayLayers.js.map +1 -1
  20. package/lib/module/web/useKeyboardShift.js +1 -1
  21. package/lib/module/web/useKeyboardShift.js.map +1 -1
  22. package/lib/module/web/useSheetDrag.js +22 -2
  23. package/lib/module/web/useSheetDrag.js.map +1 -1
  24. package/lib/typescript/src/OverlayStore.d.ts +2 -0
  25. package/lib/typescript/src/OverlayStore.d.ts.map +1 -1
  26. package/lib/typescript/src/OverlayView.web.d.ts.map +1 -1
  27. package/lib/typescript/src/useOverlayController.d.ts.map +1 -1
  28. package/lib/typescript/src/web/animateTransition.d.ts +2 -2
  29. package/lib/typescript/src/web/animateTransition.d.ts.map +1 -1
  30. package/lib/typescript/src/web/overlayLayers.d.ts +1 -0
  31. package/lib/typescript/src/web/overlayLayers.d.ts.map +1 -1
  32. package/lib/typescript/src/web/useSheetDrag.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/OverlayStore.ts +13 -1
  35. package/src/OverlayView.web.tsx +193 -56
  36. package/src/useOverlayController.ts +11 -4
  37. package/src/web/animateTransition.ts +30 -11
  38. package/src/web/overlayLayers.ts +20 -0
  39. package/src/web/useKeyboardShift.ts +1 -1
  40. package/src/web/useSheetDrag.ts +23 -2
@@ -69,8 +69,9 @@ internal sealed class OverlayMotion {
69
69
  }
70
70
 
71
71
  /**
72
- * Analytic step response of `m·x'' + c·x' + k·x = 0`, identical to
73
- * Reanimated and `springProgressAt` in JS. Driving one ValueAnimator with it
72
+ * Analytic step response of `m·x'' + c·x' + k·x = 0`, with overdamped
73
+ * responses capped at critical damping to match UIKit's sheet response.
74
+ * Driving one ValueAnimator with it
74
75
  * keeps alpha, translation, scale and backdrop on the same clock; androidx
75
76
  * SpringAnimation would need one animation per property.
76
77
  */
@@ -89,19 +90,13 @@ internal class SpringInterpolator(private val spring: OverlayMotion.Spring) : Ti
89
90
 
90
91
  fun progressAt(spring: OverlayMotion.Spring, t: Double): Double {
91
92
  val w0 = sqrt(spring.stiffness / spring.mass)
92
- val zeta = spring.damping / (2 * sqrt(spring.stiffness * spring.mass))
93
+ val zeta = minOf(1.0, spring.damping / (2 * sqrt(spring.stiffness * spring.mass)))
93
94
  return when {
94
95
  zeta < 1 -> {
95
96
  val wd = w0 * sqrt(1 - zeta * zeta)
96
97
  1 - exp(-zeta * w0 * t) * (cos(wd * t) + (zeta * w0 / wd) * sin(wd * t))
97
98
  }
98
- zeta == 1.0 -> 1 - exp(-w0 * t) * (1 + w0 * t)
99
- else -> {
100
- val root = sqrt(zeta * zeta - 1)
101
- val r1 = -w0 * (zeta - root)
102
- val r2 = -w0 * (zeta + root)
103
- 1 - (r2 * exp(r1 * t) - r1 * exp(r2 * t)) / (r2 - r1)
104
- }
99
+ else -> 1 - exp(-w0 * t) * (1 + w0 * t)
105
100
  }
106
101
  }
107
102
 
@@ -60,7 +60,12 @@ internal class NativeOverlayEntryRootView(
60
60
  * below), non-blocking ones let it continue past the empty area.
61
61
  */
62
62
  override val pointerEvents: PointerEvents
63
- get() = if (blocking && isShownForInput) PointerEvents.AUTO else PointerEvents.BOX_NONE
63
+ get() = when {
64
+ blocking && isClosing -> PointerEvents.BOX_ONLY
65
+ isClosing -> PointerEvents.NONE
66
+ blocking && isShownForInput -> PointerEvents.AUTO
67
+ else -> PointerEvents.BOX_NONE
68
+ }
64
69
  var stackOrder = 0
65
70
  var levelOrder = 0
66
71
  /** Page scope: the owning page, used by the page host to hide the entry. */
@@ -74,6 +79,19 @@ internal class NativeOverlayEntryRootView(
74
79
  var dismissOnBackPress = true
75
80
  var dismissOnBackdropPress = false
76
81
  var isShownForInput = false
82
+ private var contentAccessibilityBeforeClose: Int? = null
83
+ var isClosing = false
84
+ set(value) {
85
+ if (field == value) return
86
+ field = value
87
+ if (value) {
88
+ contentAccessibilityBeforeClose = contentView.importantForAccessibility
89
+ contentView.importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
90
+ } else {
91
+ contentAccessibilityBeforeClose?.let { contentView.importantForAccessibility = it }
92
+ contentAccessibilityBeforeClose = null
93
+ }
94
+ }
77
95
  var onRequestDismiss: ((String) -> Unit)? = null
78
96
 
79
97
  val backdropView = View(reactContext).apply { alpha = 0f }
@@ -90,12 +108,13 @@ internal class NativeOverlayEntryRootView(
90
108
  /** `sheet` presentation: the draggable surface; otherwise null. */
91
109
  var sheetView: FrameLayout? = null
92
110
  private set
111
+ private var sheetAnimationView: CoordinatorLayout? = null
93
112
  private var sheetBehavior: BottomSheetBehavior<FrameLayout>? = null
94
113
  private var handleView: View? = null
95
114
  var onSheetHiddenByPan: (() -> Unit)? = null
96
115
 
97
116
  /** The view that enter / exit animations move. */
98
- val animatedView: View get() = sheetView ?: contentView
117
+ val animatedView: View get() = sheetAnimationView ?: contentView
99
118
 
100
119
  private val touchDispatcher = JSTouchDispatcher(this)
101
120
  private val pointerDispatcher = if (ReactFeatureFlags.dispatchPointerEvents) {
@@ -220,6 +239,7 @@ internal class NativeOverlayEntryRootView(
220
239
  },
221
240
  )
222
241
  sheetView = sheet
242
+ sheetAnimationView = coordinator
223
243
  sheetBehavior = behavior
224
244
  handleView = handle
225
245
  return sheet
@@ -350,6 +370,7 @@ internal class NativeOverlayEntryRootView(
350
370
  }
351
371
 
352
372
  override fun dispatchTouchEvent(event: MotionEvent): Boolean {
373
+ if (isClosing) return blocking
353
374
  if (!isShownForInput) return false
354
375
  if (event.actionMasked == MotionEvent.ACTION_DOWN) {
355
376
  // Non-blocking overlays (toasts) only take touches that land on content.
@@ -8,6 +8,7 @@ import android.widget.FrameLayout
8
8
  import androidx.activity.ComponentActivity
9
9
  import androidx.activity.OnBackPressedCallback
10
10
  import java.util.WeakHashMap
11
+ import java.lang.ref.WeakReference
11
12
 
12
13
  /** Mirrors `OVERLAY_LEVEL_ORDER`. */
13
14
  internal enum class NativeOverlayLevel(val order: Int) {
@@ -48,8 +49,8 @@ internal class NativeOverlayHost private constructor(
48
49
  )
49
50
  private val backCallback = object : OnBackPressedCallback(false) {
50
51
  override fun handleOnBackPressed() {
51
- topBlockingEntry()?.let { entry ->
52
- if (entry.dismissOnBackPress) entry.onRequestDismiss?.invoke("back")
52
+ topBackEntry()?.let { entry ->
53
+ if (!entry.isClosing && entry.dismissOnBackPress) entry.onRequestDismiss?.invoke("back")
53
54
  }
54
55
  }
55
56
  }
@@ -66,8 +67,8 @@ internal class NativeOverlayHost private constructor(
66
67
  * predictive back, so the dispatcher alone never runs first.
67
68
  */
68
69
  fun handleBackKey(event: KeyEvent): Boolean {
69
- val entry = topBlockingEntry() ?: return false
70
- if (event.action == KeyEvent.ACTION_UP && !event.isCanceled && entry.dismissOnBackPress) {
70
+ val entry = topBackEntry() ?: return false
71
+ if (event.action == KeyEvent.ACTION_UP && !event.isCanceled && !entry.isClosing && entry.dismissOnBackPress) {
71
72
  entry.onRequestDismiss?.invoke("back")
72
73
  }
73
74
  return true
@@ -108,14 +109,14 @@ internal class NativeOverlayHost private constructor(
108
109
  }
109
110
 
110
111
  fun onEntriesChanged() {
111
- val hasBlocking = topBlockingEntry() != null
112
- if (hasBlocking && !backCallback.isEnabled) {
112
+ val hasBackEntry = topBackEntry() != null
113
+ if (hasBackEntry && !backCallback.isEnabled) {
113
114
  // The most recently added callback wins; re-add so ours runs before
114
115
  // ReactActivity's and react-native-screens' callbacks.
115
116
  backCallback.remove()
116
117
  (activity as? ComponentActivity)?.onBackPressedDispatcher?.addCallback(backCallback)
117
118
  }
118
- backCallback.isEnabled = hasBlocking
119
+ backCallback.isEnabled = hasBackEntry
119
120
  updateAccessibility()
120
121
  }
121
122
 
@@ -133,11 +134,13 @@ internal class NativeOverlayHost private constructor(
133
134
  }
134
135
 
135
136
  /** Global overlays win; otherwise the topmost visible page overlay. */
136
- private fun topBlockingEntry(): NativeOverlayEntryRootView? =
137
- orderedEntries().lastOrNull { it.blocking && it.isShownForInput }
137
+ private fun topBackEntry(): NativeOverlayEntryRootView? =
138
+ orderedEntries().lastOrNull {
139
+ (it.blocking || it.dismissOnBackPress) && it.isShownForInput && (!it.isClosing || it.blocking)
140
+ }
138
141
  ?: shownPageHosts()
139
142
  .flatMap { it.shownEntries() }
140
- .lastOrNull { it.blocking }
143
+ .lastOrNull { (it.blocking || it.dismissOnBackPress) && (!it.isClosing || it.blocking) }
141
144
 
142
145
  private fun shownPageHosts(): List<NativeOverlayPageHostView> =
143
146
  NativeOverlayPageHostView.allHosts().filter {
@@ -160,9 +163,11 @@ internal class NativeOverlayHost private constructor(
160
163
  }
161
164
 
162
165
  companion object {
163
- private val hosts = WeakHashMap<Activity, NativeOverlayHost>()
166
+ private val hosts = WeakHashMap<Activity, WeakReference<NativeOverlayHost>>()
164
167
 
165
168
  fun of(activity: Activity): NativeOverlayHost =
166
- hosts.getOrPut(activity) { NativeOverlayHost(activity) }
169
+ hosts[activity]?.get() ?: NativeOverlayHost(activity).also {
170
+ hosts[activity] = WeakReference(it)
171
+ }
167
172
  }
168
173
  }
@@ -200,6 +200,7 @@ class NativeOverlayView(
200
200
  }
201
201
  cancelAnimators()
202
202
  phase = Phase.ENTERING
203
+ target.isClosing = false
203
204
  target.isShownForInput = true
204
205
  notifyHostEntriesChanged()
205
206
  if (blocking) {
@@ -226,7 +227,7 @@ class NativeOverlayView(
226
227
  val target = entry ?: return finishDismiss("programmatic")
227
228
  cancelAnimators()
228
229
  phase = Phase.EXITING
229
- target.isShownForInput = false
230
+ target.isClosing = true
230
231
  notifyHostEntriesChanged()
231
232
  val transition = config.exit.reduceMotionAdjusted(isReduceMotionEnabled())
232
233
  if (transition.kind == "none") return finishDismiss("programmatic")
@@ -241,6 +242,7 @@ class NativeOverlayView(
241
242
  phase = Phase.HIDDEN
242
243
  entry?.let { target ->
243
244
  target.isShownForInput = false
245
+ target.isClosing = false
244
246
  stageContent()
245
247
  host?.detach(target)
246
248
  pageHost?.detach(target)
@@ -0,0 +1,295 @@
1
+ package com.onekey.nativeoverlay
2
+
3
+ import android.view.KeyEvent
4
+ import android.view.MotionEvent
5
+ import android.view.View
6
+ import android.widget.FrameLayout
7
+ import androidx.activity.ComponentActivity
8
+ import com.facebook.react.bridge.BridgeReactContext
9
+ import com.facebook.react.uimanager.PointerEvents
10
+ import com.facebook.react.uimanager.ThemedReactContext
11
+ import com.facebook.react.uimanager.TouchTargetHelper
12
+ import com.facebook.react.views.view.ReactViewGroup
13
+ import java.lang.ref.WeakReference
14
+ import java.util.WeakHashMap
15
+ import org.junit.Assert.assertEquals
16
+ import org.junit.Assert.assertFalse
17
+ import org.junit.Assert.assertNotSame
18
+ import org.junit.Assert.assertSame
19
+ import org.junit.Assert.assertTrue
20
+ import org.junit.Before
21
+ import org.junit.Test
22
+ import org.junit.runner.RunWith
23
+ import org.robolectric.Robolectric
24
+ import org.robolectric.RobolectricTestRunner
25
+ import org.robolectric.annotation.Config
26
+
27
+ @RunWith(RobolectricTestRunner::class)
28
+ @Config(sdk = [34])
29
+ class NativeOverlayBackTest {
30
+ private lateinit var activity: ComponentActivity
31
+ private lateinit var context: ThemedReactContext
32
+ private lateinit var host: NativeOverlayHost
33
+ private val dismissals = mutableListOf<String>()
34
+
35
+ @Before
36
+ fun setUp() {
37
+ activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().visible().get()
38
+ val reactContext = BridgeReactContext(activity)
39
+ reactContext.onHostResume(activity)
40
+ context = ThemedReactContext(reactContext, activity, "OverlayBackTest", 1)
41
+ host = NativeOverlayHost.of(activity)
42
+ }
43
+
44
+ private fun entry(name: String, blocking: Boolean, dismissible: Boolean, order: Int = 0) =
45
+ NativeOverlayEntryRootView(context).apply {
46
+ this.blocking = blocking
47
+ dismissOnBackPress = dismissible
48
+ stackOrder = order
49
+ isShownForInput = true
50
+ onRequestDismiss = { dismissals.add("$name:$it") }
51
+ }
52
+
53
+ private fun back(action: Int = KeyEvent.ACTION_UP, flags: Int = 0) =
54
+ host.handleBackKey(KeyEvent(0L, 0L, action, KeyEvent.KEYCODE_BACK, 0, 0, 0, 0, flags))
55
+
56
+ @Test
57
+ fun dismissibleNonBlockingGlobalEntryWinsOverLowerBlocker() {
58
+ host.attach(entry("lower", true, true), NativeOverlayLevel.MODAL)
59
+ host.attach(entry("top", false, true), NativeOverlayLevel.TOAST)
60
+ assertTrue(back(KeyEvent.ACTION_DOWN))
61
+ assertEquals(emptyList<String>(), dismissals)
62
+ assertTrue(back())
63
+ assertEquals(listOf("top:back"), dismissals)
64
+ }
65
+
66
+ @Test
67
+ fun dispatcherHandlesNonBlockingEntryAndKeepsPageAccessible() {
68
+ val page = View(activity).apply { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES }
69
+ activity.setContentView(page)
70
+ val top = entry("top", false, true)
71
+ host.attach(top, NativeOverlayLevel.MODAL)
72
+ activity.onBackPressedDispatcher.onBackPressed()
73
+ assertEquals(listOf("top:back"), dismissals)
74
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_YES, page.importantForAccessibility)
75
+ host.detach(top)
76
+ assertFalse(back())
77
+ }
78
+
79
+ @Test
80
+ fun nonDismissibleBlockerConsumesBackWithoutClosingLowerEntry() {
81
+ host.attach(entry("lower", false, true), NativeOverlayLevel.MODAL)
82
+ host.attach(entry("lock", true, false), NativeOverlayLevel.LOCK)
83
+ assertTrue(back())
84
+ assertEquals(emptyList<String>(), dismissals)
85
+ }
86
+
87
+ @Test
88
+ fun passiveOrNotInputReadyEntriesDoNotTakeBack() {
89
+ host.attach(entry("passive", false, false), NativeOverlayLevel.TOAST)
90
+ host.attach(entry("hidden", true, true).apply { isShownForInput = false }, NativeOverlayLevel.LOCK)
91
+ assertFalse(back())
92
+ assertEquals(emptyList<String>(), dismissals)
93
+ }
94
+
95
+ @Test
96
+ fun canceledKeyUpDoesNotDismiss() {
97
+ host.attach(entry("top", false, true), NativeOverlayLevel.MODAL)
98
+ assertTrue(back(flags = KeyEvent.FLAG_CANCELED))
99
+ assertEquals(emptyList<String>(), dismissals)
100
+ }
101
+
102
+ @Test
103
+ fun dismissiblePageEntryWinsOverLowerPageBlockerAndSuspensionSkipsIt() {
104
+ val pageHost = NativeOverlayPageHostView(context).apply { hostKey = "page-back-test" }
105
+ activity.setContentView(pageHost)
106
+ val lower = entry("lower", true, true).apply { ownerKey = "owner" }
107
+ val top = entry("top", false, true, 1).apply { ownerKey = "owner" }
108
+ pageHost.attach(lower)
109
+ pageHost.attach(top)
110
+ assertTrue(back())
111
+ assertEquals(listOf("top:back"), dismissals)
112
+ pageHost.setSuspendedOwners("owner")
113
+ assertFalse(back())
114
+ pageHost.invalidateHost()
115
+ }
116
+
117
+ @Test
118
+ fun globalEntryTakesBackBeforePageEntryAndDismissalRestoresPageTarget() {
119
+ val pageHost = NativeOverlayPageHostView(context).apply { hostKey = "page-global-test" }
120
+ activity.setContentView(FrameLayout(activity).apply { addView(pageHost) })
121
+ pageHost.attach(entry("page", false, true))
122
+ val global = entry("global", false, true)
123
+ host.attach(global, NativeOverlayLevel.MODAL)
124
+ assertTrue(back())
125
+ host.detach(global)
126
+ assertTrue(back())
127
+ assertEquals(listOf("global:back", "page:back"), dismissals)
128
+ pageHost.invalidateHost()
129
+ }
130
+
131
+ @Test
132
+ fun closingGlobalBlockerKeepsTouchBackAndAccessibilityOwnership() {
133
+ var pageTouches = 0
134
+ val page = View(activity).apply {
135
+ setOnTouchListener { _, _ ->
136
+ pageTouches += 1
137
+ true
138
+ }
139
+ }
140
+ activity.setContentView(page)
141
+ val lower = entry("lower", true, true)
142
+ val top = entry("top", true, true, 1)
143
+ host.attach(lower, NativeOverlayLevel.MODAL)
144
+ host.attach(top, NativeOverlayLevel.MODAL)
145
+ top.isClosing = true
146
+ host.onEntriesChanged()
147
+
148
+ assertEquals(PointerEvents.BOX_ONLY, top.pointerEvents)
149
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, top.contentView.importantForAccessibility)
150
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, page.importantForAccessibility)
151
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, lower.importantForAccessibility)
152
+ assertTrue(back())
153
+ assertEquals(emptyList<String>(), dismissals)
154
+
155
+ val content = activity.findViewById<FrameLayout>(android.R.id.content)
156
+ val size = View.MeasureSpec.makeMeasureSpec(300, View.MeasureSpec.EXACTLY)
157
+ content.measure(size, size)
158
+ content.layout(0, 0, 300, 300)
159
+ val down = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 150f, 150f, 0)
160
+ try {
161
+ assertTrue(content.dispatchTouchEvent(down))
162
+ assertEquals(0, pageTouches)
163
+ } finally {
164
+ down.recycle()
165
+ }
166
+
167
+ top.isClosing = false
168
+ host.onEntriesChanged()
169
+ assertEquals(PointerEvents.AUTO, top.pointerEvents)
170
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, top.contentView.importantForAccessibility)
171
+ assertTrue(back())
172
+ assertEquals(listOf("top:back"), dismissals)
173
+ }
174
+
175
+ @Test
176
+ fun closingPageBlockerStillTakesBack() {
177
+ val pageHost = NativeOverlayPageHostView(context).apply { hostKey = "closing-page" }
178
+ activity.setContentView(pageHost)
179
+ val top = entry("page", true, true)
180
+ pageHost.attach(top)
181
+ top.isClosing = true
182
+ host.onEntriesChanged()
183
+
184
+ assertEquals(PointerEvents.BOX_ONLY, top.pointerEvents)
185
+ assertTrue(back())
186
+ assertEquals(emptyList<String>(), dismissals)
187
+ top.isClosing = false
188
+ host.onEntriesChanged()
189
+ assertEquals(PointerEvents.AUTO, top.pointerEvents)
190
+ assertTrue(back())
191
+ assertEquals(listOf("page:back"), dismissals)
192
+ pageHost.invalidateHost()
193
+ }
194
+
195
+ @Test
196
+ fun pageEntryHitTestingKeepsClosingContentAndLowerPageSeparate() {
197
+ val root = ReactViewGroup(context).apply { id = 101 }
198
+ val below = ReactViewGroup(context).apply { id = 102 }
199
+ val child = ReactViewGroup(context).apply { id = 103 }
200
+ val pageHost = NativeOverlayPageHostView(context).apply { hostKey = "page-hit-test" }
201
+ root.addView(below)
202
+ root.addView(pageHost)
203
+ activity.setContentView(root)
204
+ val overlay = entry("page", true, true)
205
+ overlay.contentView.addView(child)
206
+ pageHost.attach(overlay)
207
+
208
+ val size = View.MeasureSpec.makeMeasureSpec(300, View.MeasureSpec.EXACTLY)
209
+ root.measure(size, size)
210
+ root.layout(0, 0, 300, 300)
211
+ below.layout(0, 0, 300, 300)
212
+ pageHost.layout(0, 0, 300, 300)
213
+ child.layout(0, 0, 300, 300)
214
+
215
+ fun target() = TouchTargetHelper.findTargetTagForTouch(150f, 150f, root)
216
+ assertEquals(child.id, target())
217
+ overlay.isClosing = true
218
+ assertEquals(root.id, target())
219
+ overlay.updateBlocking(false)
220
+ assertEquals(below.id, target())
221
+ overlay.updateBlocking(true)
222
+ overlay.isClosing = false
223
+ assertEquals(child.id, target())
224
+ pageHost.invalidateHost()
225
+ }
226
+
227
+ @Test
228
+ fun reopeningCoveredClosingEntryRestoresItsContentAccessibility() {
229
+ activity.setContentView(View(activity))
230
+ val first = entry("first", true, true)
231
+ val second = entry("second", true, true)
232
+ host.attach(first, NativeOverlayLevel.MODAL)
233
+ first.isClosing = true
234
+ host.onEntriesChanged()
235
+ host.attach(second, NativeOverlayLevel.SECURE)
236
+
237
+ first.isClosing = false
238
+ host.onEntriesChanged()
239
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, first.importantForAccessibility)
240
+ host.detach(second)
241
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, first.importantForAccessibility)
242
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, first.contentView.importantForAccessibility)
243
+ }
244
+
245
+ @Test
246
+ fun closingNonBlockingEntryPassesTouchAndBackToLowerBlocker() {
247
+ val lower = entry("lower", true, true)
248
+ val toast = entry("toast", false, true)
249
+ host.attach(lower, NativeOverlayLevel.MODAL)
250
+ host.attach(toast, NativeOverlayLevel.TOAST)
251
+ toast.isClosing = true
252
+ host.onEntriesChanged()
253
+
254
+ assertEquals(PointerEvents.NONE, toast.pointerEvents)
255
+ val down = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 150f, 150f, 0)
256
+ try {
257
+ assertFalse(toast.dispatchTouchEvent(down))
258
+ } finally {
259
+ down.recycle()
260
+ }
261
+ assertTrue(back())
262
+ assertEquals(listOf("lower:back"), dismissals)
263
+ }
264
+
265
+ @Test
266
+ fun hostCacheReusesLiveHostAndSeparatesActivities() {
267
+ assertSame(host, NativeOverlayHost.of(activity))
268
+ val otherActivity = Robolectric.buildActivity(ComponentActivity::class.java).setup().get()
269
+ assertNotSame(host, NativeOverlayHost.of(otherActivity))
270
+ }
271
+
272
+ @Test
273
+ fun hostCacheRebuildsAfterWeakValueIsCleared() {
274
+ val field = NativeOverlayHost::class.java.getDeclaredField("hosts").apply {
275
+ isAccessible = true
276
+ }
277
+ val cache = field.get(null) as WeakHashMap<*, *>
278
+ (cache[activity] as WeakReference<*>).clear()
279
+ val replacement = NativeOverlayHost.of(activity)
280
+ assertNotSame(host, replacement)
281
+ assertSame(replacement, NativeOverlayHost.of(activity))
282
+ }
283
+
284
+ @Test
285
+ fun pageOnlyBackInterceptorRetainsItsHost() {
286
+ val pageHost = NativeOverlayPageHostView(context).apply { hostKey = "page-only" }
287
+ activity.setContentView(pageHost)
288
+ pageHost.attach(entry("page", false, true))
289
+ host.installBackInterceptor()
290
+ assertTrue(activity.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_BACK)))
291
+ assertEquals(listOf("page:back"), dismissals)
292
+ assertSame(host, NativeOverlayHost.of(activity))
293
+ pageHost.invalidateHost()
294
+ }
295
+ }
@@ -0,0 +1,101 @@
1
+ package com.onekey.nativeoverlay
2
+
3
+ import android.graphics.Color
4
+ import android.os.Looper
5
+ import android.view.View
6
+ import androidx.activity.ComponentActivity
7
+ import androidx.coordinatorlayout.widget.CoordinatorLayout
8
+ import com.facebook.react.bridge.BridgeReactContext
9
+ import com.facebook.react.uimanager.ThemedReactContext
10
+ import com.google.android.material.bottomsheet.BottomSheetBehavior
11
+ import org.junit.Assert.assertEquals
12
+ import org.junit.Assert.assertFalse
13
+ import org.junit.Assert.assertNotSame
14
+ import org.junit.Assert.assertSame
15
+ import org.junit.Assert.assertTrue
16
+ import org.junit.Test
17
+ import org.junit.runner.RunWith
18
+ import org.robolectric.Robolectric
19
+ import org.robolectric.RobolectricTestRunner
20
+ import org.robolectric.Shadows
21
+ import org.robolectric.annotation.Config
22
+ import java.time.Duration
23
+
24
+ @RunWith(RobolectricTestRunner::class)
25
+ @Config(sdk = [34])
26
+ class NativeOverlayMotionTest {
27
+ @Test
28
+ fun sheetAnimationMovesCoordinatorWithoutTakingMaterialsSheetTranslation() {
29
+ val activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().get()
30
+ val reactContext = BridgeReactContext(activity)
31
+ reactContext.onHostResume(activity)
32
+ val context = ThemedReactContext(reactContext, activity, "OverlayMotionTest", 1)
33
+ val entry = NativeOverlayEntryRootView(context)
34
+ assertSame(entry.contentView, entry.animatedView)
35
+
36
+ entry.configureSheet(500, 16f, Color.WHITE, showHandle = true, draggable = true)
37
+ val sheet = entry.sheetView!!
38
+ val animated = entry.animatedView
39
+ assertTrue(animated is CoordinatorLayout)
40
+ assertSame(animated, sheet.parent)
41
+ assertNotSame(sheet, animated)
42
+ assertTrue((sheet.layoutParams as CoordinatorLayout.LayoutParams).behavior is BottomSheetBehavior<*>)
43
+
44
+ animated.translationY = 42f
45
+ assertEquals(0f, sheet.translationY)
46
+ sheet.translationY = 7f
47
+ assertEquals(42f, animated.translationY)
48
+ assertEquals(7f, sheet.translationY)
49
+ }
50
+
51
+ @Test
52
+ fun quickMatchesMeasuredCriticalResponseWithoutChangingUnderdampedResponse() {
53
+ val quick = OverlayMotion.QUICK
54
+ assertEquals(292L, quick.durationMs())
55
+ assertEquals(0.8238140348, SpringInterpolator.progressAt(quick, 0.1), 0.000001)
56
+ assertEquals(0.9990003945, SpringInterpolator.progressAt(quick, 0.292), 0.000001)
57
+
58
+ val underdamped = OverlayMotion.Spring(1.0, 100.0, 5.0)
59
+ assertEquals(0.3929451508, SpringInterpolator.progressAt(underdamped, 0.1), 0.000001)
60
+ val timing = OverlayMotion.Timing(250L, 0.25f, 0.1f, 0.25f, 1f)
61
+ assertEquals(250L, timing.durationMs())
62
+ }
63
+
64
+ @Test
65
+ fun reopeningTheSameEntryDuringExitRetargetsItsAnimation() {
66
+ val activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().visible().get()
67
+ val reactContext = BridgeReactContext(activity)
68
+ reactContext.onHostResume(activity)
69
+ val context = ThemedReactContext(reactContext, activity, "OverlayMotionTest", 1)
70
+ val overlay = NativeOverlayView(context).apply {
71
+ presentation = "sheet"
72
+ sheetHeight = 200.0
73
+ addReactChild(View(context))
74
+ }
75
+ activity.setContentView(overlay)
76
+ var dismissals = 0
77
+ overlay.onDismissed = { dismissals++ }
78
+ overlay.visible = true
79
+ overlay.commitConfiguration()
80
+
81
+ val entryField = NativeOverlayView::class.java.getDeclaredField("entry").apply { isAccessible = true }
82
+ val original = entryField.get(overlay) as NativeOverlayEntryRootView
83
+ overlay.visible = false
84
+ overlay.commitConfiguration()
85
+ assertTrue(original.isClosing)
86
+
87
+ overlay.visible = true
88
+ overlay.commitConfiguration()
89
+ assertSame(original, entryField.get(overlay))
90
+ assertTrue(original.isShownForInput)
91
+ assertFalse(original.isClosing)
92
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, original.contentView.importantForAccessibility)
93
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(350))
94
+ assertEquals(0, dismissals)
95
+
96
+ overlay.visible = false
97
+ overlay.commitConfiguration()
98
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(350))
99
+ assertEquals(1, dismissals)
100
+ }
101
+ }