golia-expo-utils 1.0.6 → 1.0.8

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.
@@ -1,24 +1,20 @@
1
1
  package expo.modules.goliaexpoutils.segmentedControl
2
2
 
3
- // 引入扩展
4
3
  import android.annotation.SuppressLint
5
4
  import android.content.Context
6
- import android.view.ViewGroup
7
5
  import androidx.compose.foundation.layout.fillMaxSize
6
+ import androidx.compose.runtime.Composable
8
7
  import androidx.compose.runtime.mutableIntStateOf
9
8
  import androidx.compose.runtime.mutableStateOf
10
9
  import androidx.compose.ui.Modifier
11
- import androidx.compose.ui.platform.ComposeView
12
- import androidx.compose.ui.platform.ViewCompositionStrategy
13
10
  import expo.modules.goliaexpoutils.Config
14
- import expo.modules.goliaexpoutils.enforceLifecycle
11
+ import expo.modules.goliaexpoutils.utils.ExpoComposeView
15
12
  import expo.modules.kotlin.AppContext
16
13
  import expo.modules.kotlin.viewevent.EventDispatcher
17
- import expo.modules.kotlin.views.ExpoView
18
14
 
19
15
  @SuppressLint("ViewConstructor")
20
16
  class ReactSegmentedControl(context: Context, appContext: AppContext) :
21
- ExpoView(context, appContext) {
17
+ ExpoComposeView(context, appContext) {
22
18
 
23
19
  private val onValueChange by EventDispatcher()
24
20
 
@@ -31,87 +27,29 @@ class ReactSegmentedControl(context: Context, appContext: AppContext) :
31
27
  val hapticEnabledState = mutableStateOf(true)
32
28
  val enabledState = mutableStateOf(true)
33
29
 
34
- private var isContentSet = false
35
-
36
- private val composeView = ComposeView(context).apply {
37
- layoutParams = ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
38
- setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
39
- }
40
-
41
- init {
42
- clipChildren = false
43
- clipToPadding = false
44
- }
45
-
46
- override fun onAttachedToWindow() {
47
- super.onAttachedToWindow()
48
-
49
- // 1. 注入生命周期
50
- composeView.enforceLifecycle(context)
51
-
52
- // 2. 动态挂载
53
- if (composeView.parent == null) {
54
- addView(composeView)
55
- }
56
-
57
- // 3. 设置内容 (或复活)
58
- if (!isContentSet) {
59
- composeView.setContent {
60
- SegmentedControl(
61
- items = itemsState.value,
62
- selectedIndex = selectedIndexState.intValue,
63
- onValueChange = { index ->
64
- selectedIndexState.intValue = index
65
- val title = itemsState.value.getOrNull(index)?.title ?: ""
66
- if (appContext.reactContext != null) {
67
- try {
68
- onValueChange(mapOf("value" to title, "index" to index))
69
- } catch (_: Throwable) {
70
- }
71
- }
72
- },
73
- modifier = Modifier.fillMaxSize(),
74
- activeColor = activeColorState.value,
75
- ctrlBackgroundColor = ctrlBgColorState.value,
76
- textColor = textColorState.value,
77
- autoWidth = autoWidthState.value,
78
- hapticEnabled = hapticEnabledState.value,
79
- enabled = enabledState.value,
80
- glassExpansion = Config.Common.Glass.DEFAULT_EXPANSION,
81
- )
82
- }
83
- isContentSet = true
84
- }
85
-
86
- requestLayout()
87
- }
88
-
89
- override fun onDetachedFromWindow() {
90
- // 4. 必须移除,防止后台 Crash
91
- if (composeView.parent == this) {
92
- removeView(composeView)
93
- }
94
-
95
- // 5. 标记复活
96
- isContentSet = false
97
-
98
- super.onDetachedFromWindow()
99
- }
100
-
101
- // 6. 手动布局修正
102
- override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
103
- super.onLayout(changed, left, top, right, bottom)
104
-
105
- if (composeView.parent == this) {
106
- val width = right - left
107
- val height = bottom - top
108
-
109
- // 强行设置 ComposeView 的大小
110
- composeView.measure(
111
- MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
112
- MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
113
- )
114
- composeView.layout(0, 0, width, height)
115
- }
30
+ @Composable
31
+ override fun Content() {
32
+ SegmentedControl(
33
+ items = itemsState.value,
34
+ selectedIndex = selectedIndexState.intValue,
35
+ onValueChange = { index ->
36
+ selectedIndexState.intValue = index
37
+ val title = itemsState.value.getOrNull(index)?.title ?: ""
38
+ if (appContext.reactContext != null) {
39
+ try {
40
+ onValueChange(mapOf("value" to title, "index" to index))
41
+ } catch (_: Throwable) {
42
+ }
43
+ }
44
+ },
45
+ modifier = Modifier.fillMaxSize(),
46
+ activeColor = activeColorState.value,
47
+ ctrlBackgroundColor = ctrlBgColorState.value,
48
+ textColor = textColorState.value,
49
+ autoWidth = autoWidthState.value,
50
+ hapticEnabled = hapticEnabledState.value,
51
+ enabled = enabledState.value,
52
+ glassExpansion = Config.Common.Glass.DEFAULT_EXPANSION,
53
+ )
116
54
  }
117
55
  }
@@ -0,0 +1,160 @@
1
+ package expo.modules.goliaexpoutils.utils
2
+
3
+ import android.content.Context
4
+ import android.view.View
5
+ import android.view.ViewGroup
6
+ import android.widget.FrameLayout
7
+ import androidx.compose.runtime.Composable
8
+ import androidx.compose.ui.platform.ComposeView
9
+ import androidx.compose.ui.platform.ViewCompositionStrategy
10
+ import androidx.core.view.isNotEmpty
11
+ import expo.modules.kotlin.AppContext
12
+ import expo.modules.kotlin.views.ExpoView
13
+
14
+ /**
15
+ * 带有“替身机制”的 Compose 容器。
16
+ * 使用普通 View 占位以通过 Fabric 测量,仅在 Attach 后挂载 ComposeView。
17
+ */
18
+ abstract class ExpoComposeView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
19
+
20
+ private var isContentSet = false
21
+
22
+ // 1. 替身 View:一个极其普通的 View,唯一的任务就是占位置,防止 Fabric 测出 0 高度
23
+ private val dummyView = View(context).apply {
24
+ layoutParams = ViewGroup.LayoutParams(
25
+ ViewGroup.LayoutParams.MATCH_PARENT,
26
+ ViewGroup.LayoutParams.MATCH_PARENT
27
+ )
28
+ // 设置成透明,用户看不见
29
+ visibility = View.VISIBLE
30
+ }
31
+
32
+ // 2. 真身:ComposeView
33
+ internal val composeView = ComposeView(context).apply {
34
+ layoutParams = ViewGroup.LayoutParams(
35
+ ViewGroup.LayoutParams.MATCH_PARENT,
36
+ ViewGroup.LayoutParams.MATCH_PARENT
37
+ )
38
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
39
+ }
40
+
41
+ // 3. 原生子 View 容器
42
+ protected val childContainer = FrameLayout(context).apply {
43
+ layoutParams = ViewGroup.LayoutParams(
44
+ ViewGroup.LayoutParams.MATCH_PARENT,
45
+ ViewGroup.LayoutParams.MATCH_PARENT
46
+ )
47
+ }
48
+
49
+ init {
50
+ clipChildren = false
51
+ clipToPadding = false
52
+
53
+ // ✅ 关键策略:init 时只添加替身!
54
+ // Fabric 测量时会看到这个 MATCH_PARENT 的 View,分配正确尺寸。
55
+ // 因为它是普通 View,离屏测量 100% 安全。
56
+ super.addView(dummyView)
57
+
58
+ // ❌ 绝对不在这里添加 composeView
59
+ }
60
+
61
+ @Composable
62
+ abstract fun Content()
63
+
64
+ override fun onAttachedToWindow() {
65
+ // 注入生命周期 (防崩)
66
+ composeView.enforceLifecycle(context)
67
+
68
+ super.onAttachedToWindow()
69
+
70
+ // ✅ 关键策略:真身上场
71
+ // 只有真正上屏了,才把 ComposeView 加进来
72
+ if (composeView.parent == null) {
73
+ super.addView(composeView)
74
+ }
75
+
76
+ if (!isContentSet) {
77
+ composeView.setContent {
78
+ Content()
79
+ }
80
+ isContentSet = true
81
+ }
82
+
83
+ // 强力刷新:确保真身同步到替身已经获取的尺寸
84
+ composeView.visibility = View.VISIBLE
85
+ requestLayout()
86
+ post {
87
+ measure(
88
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
89
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
90
+ )
91
+ layout(left, top, right, bottom)
92
+ invalidate()
93
+ }
94
+ }
95
+
96
+ override fun onDetachedFromWindow() {
97
+ // ✅ 关键策略:真身退场
98
+ // 必须移除,防止 RNSAC 等库导致 View 变为 Detached 状态后,Fabric 依然对其进行测量导致崩溃
99
+ if (composeView.parent == this) {
100
+ super.removeView(composeView)
101
+ }
102
+
103
+ isContentSet = false
104
+ super.onDetachedFromWindow()
105
+ }
106
+
107
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
108
+ super.onLayout(changed, left, top, right, bottom)
109
+
110
+ val w = right - left
111
+ val h = bottom - top
112
+
113
+ // 1. 布局替身 (让系统开心)
114
+ if (dummyView.parent == this) {
115
+ dummyView.layout(0, 0, w, h)
116
+ }
117
+
118
+ // 2. 强行布局真身 (解决显示问题)
119
+ if (composeView.parent == this) {
120
+ composeView.measure(
121
+ MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY),
122
+ MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY)
123
+ )
124
+ composeView.layout(0, 0, w, h)
125
+ }
126
+ }
127
+
128
+ // --- View 管理 (只允许操作 childContainer) ---
129
+
130
+ override fun addView(child: View?, index: Int) {
131
+ // 内部 View 直接放行
132
+ if (child == composeView || child == dummyView || child == childContainer) {
133
+ super.addView(child, index)
134
+ return
135
+ }
136
+
137
+ // 拦截 RN 子 View
138
+ if (child != null) {
139
+ (child.parent as? ViewGroup)?.removeView(child)
140
+ childContainer.addView(child)
141
+ }
142
+ }
143
+
144
+ override fun removeView(view: View?) {
145
+ if (view == composeView || view == dummyView) {
146
+ super.removeView(view)
147
+ } else {
148
+ childContainer.removeView(view)
149
+ }
150
+ }
151
+
152
+ override fun removeViewAt(index: Int) {
153
+ // 保护内部 View 不被 RN 误删
154
+ if (childContainer.isNotEmpty()) {
155
+ try {
156
+ childContainer.removeViewAt(0)
157
+ } catch (_: Throwable) {}
158
+ }
159
+ }
160
+ }
@@ -1,4 +1,4 @@
1
- package expo.modules.goliaexpoutils
1
+ package expo.modules.goliaexpoutils.utils
2
2
 
3
3
  import android.content.Context
4
4
  import android.content.ContextWrapper
@@ -1,9 +1,7 @@
1
1
  package expo.modules.goliaexpoutils.waterView
2
2
 
3
- // 引入扩展函数
4
3
  import android.annotation.SuppressLint
5
4
  import android.content.Context
6
- import android.view.View
7
5
  import android.view.ViewGroup
8
6
  import android.widget.FrameLayout
9
7
  import androidx.compose.foundation.layout.Box
@@ -12,19 +10,15 @@ import androidx.compose.runtime.Composable
12
10
  import androidx.compose.runtime.mutableFloatStateOf
13
11
  import androidx.compose.runtime.mutableStateOf
14
12
  import androidx.compose.ui.Modifier
15
- import androidx.compose.ui.platform.ComposeView
16
13
  import androidx.compose.ui.platform.LocalDensity
17
- import androidx.compose.ui.platform.ViewCompositionStrategy
18
14
  import androidx.compose.ui.unit.dp
19
15
  import androidx.compose.ui.viewinterop.AndroidView
20
- import androidx.core.view.isNotEmpty
21
16
  import com.kyant.backdrop.backdrops.layerBackdrop
22
17
  import com.kyant.backdrop.backdrops.rememberLayerBackdrop
23
18
  import expo.modules.goliaexpoutils.Config
24
- import expo.modules.goliaexpoutils.enforceLifecycle
19
+ import expo.modules.goliaexpoutils.utils.ExpoComposeView
25
20
  import expo.modules.kotlin.AppContext
26
21
  import expo.modules.kotlin.viewevent.EventDispatcher
27
- import expo.modules.kotlin.views.ExpoView
28
22
  import kotlinx.coroutines.channels.Channel
29
23
  import kotlinx.coroutines.flow.Flow
30
24
  import kotlinx.coroutines.flow.receiveAsFlow
@@ -47,7 +41,8 @@ class WaterViewProps {
47
41
  }
48
42
 
49
43
  @SuppressLint("ViewConstructor")
50
- class ReactWaterView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
44
+ class ReactWaterView(context: Context, appContext: AppContext) :
45
+ ExpoComposeView(context, appContext) {
51
46
 
52
47
  val props = WaterViewProps()
53
48
  private val onMove by EventDispatcher()
@@ -55,117 +50,23 @@ class ReactWaterView(context: Context, appContext: AppContext) : ExpoView(contex
55
50
  private val onPressOut by EventDispatcher()
56
51
  private val commandChannel = Channel<WaterCommand>(Channel.BUFFERED)
57
52
 
58
- // 标记 Compose 内容是否已设置
59
- private var isContentSet = false
60
-
61
- private val childContainer = FrameLayout(context).apply {
62
- layoutParams = ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
63
- }
64
-
65
- private val composeView = ComposeView(context).apply {
66
- layoutParams = ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
67
- // 策略:Detach 时自动销毁 Composition 释放资源
68
- setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
69
- }
70
-
71
- init {
72
- clipChildren = false
73
- clipToPadding = false
74
- }
75
-
76
- override fun onAttachedToWindow() {
77
- super.onAttachedToWindow()
78
-
79
- // 1. 注入生命周期 (防崩关键)
80
- composeView.enforceLifecycle(context)
81
-
82
- // 2. 动态挂载 View (此时 Activity 肯定存在)
83
- if (composeView.parent == null) {
84
- addView(composeView)
85
- }
86
-
87
- // 3. 设置内容 (或复活)
88
- if (!isContentSet) {
89
- composeView.setContent {
90
- WaterViewEntry(
91
- childContainer = childContainer,
92
- props = props,
93
- commandFlow = commandChannel.receiveAsFlow(),
94
- onMoveDispatch = { x, y ->
95
- // 空安全保护,防止 Bridgeless 模式下 NPE
96
- if (appContext.reactContext != null) {
97
- try {
98
- onMove(mapOf("x" to x, "y" to y))
99
- } catch (_: Throwable) {
100
- }
101
- }
102
- },
103
- onPressInDispatch = { onPressIn(mapOf()) },
104
- onPressOutDispatch = { onPressOut(mapOf()) }
105
- )
106
- }
107
- isContentSet = true
108
- }
109
-
110
- // 4. 请求布局刷新
111
- requestLayout()
112
- }
113
-
114
- override fun onDetachedFromWindow() {
115
- // 5. 必须移除!防止 RNSAC 等库导致 View 处于 detached 状态时被 Fabric 测量从而引发 Crash
116
- if (composeView.parent == this) {
117
- removeView(composeView)
118
- }
119
-
120
- // 6. 标记复活:重置状态,允许下次 Attach 时重新 setContent (因为 DisposeOnDetached 已经销毁了引擎)
121
- isContentSet = false
122
-
123
- super.onDetachedFromWindow()
124
- }
125
-
126
- // ✅ 7. 终极修正:手动接管布局
127
- // 解决“View加上去了但是宽高为0不显示”的问题,无视 Fabric 的测量延迟
128
- override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
129
- super.onLayout(changed, left, top, right, bottom)
130
-
131
- if (composeView.parent == this) {
132
- val width = right - left
133
- val height = bottom - top
134
-
135
- // 强行设置 ComposeView 的大小等于父容器
136
- composeView.measure(
137
- MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
138
- MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
139
- )
140
- composeView.layout(0, 0, width, height)
141
- }
142
- }
143
-
144
- // View 管理:确保 composeView 在底部,RN 子 View 在 childContainer 中
145
- override fun addView(child: View?, index: Int) {
146
- if (child == composeView) {
147
- super.addView(child, index)
148
- } else if (child != null) {
149
- (child.parent as? ViewGroup)?.removeView(child)
150
- childContainer.addView(child)
151
- }
152
- }
153
-
154
- override fun removeView(view: View?) {
155
- if (view == composeView) {
156
- super.removeView(view)
157
- } else {
158
- childContainer.removeView(view)
159
- }
160
- }
161
-
162
- override fun removeViewAt(index: Int) {
163
- if (childContainer.isNotEmpty()) {
164
- try {
165
- childContainer.removeViewAt(0)
166
- } catch (_: Exception) {
167
- }
168
- }
53
+ @Composable
54
+ override fun Content() {
55
+ WaterViewEntry(
56
+ childContainer = childContainer,
57
+ props = props,
58
+ commandFlow = commandChannel.receiveAsFlow(),
59
+ onMoveDispatch = { x, y ->
60
+ if (appContext.reactContext != null) {
61
+ try {
62
+ onMove(mapOf("x" to x, "y" to y))
63
+ } catch (_: Throwable) {
64
+ }
65
+ }
66
+ },
67
+ onPressInDispatch = { onPressIn(mapOf()) },
68
+ onPressOutDispatch = { onPressOut(mapOf()) }
69
+ )
169
70
  }
170
71
 
171
72
  fun dispatchCommand(cmd: WaterCommand) {
@@ -173,7 +74,6 @@ class ReactWaterView(context: Context, appContext: AppContext) : ExpoView(contex
173
74
  }
174
75
  }
175
76
 
176
- // ... 下面的 Composable 代码保持不变 ...
177
77
  @Composable
178
78
  private fun WaterViewEntry(
179
79
  childContainer: FrameLayout,
@@ -225,5 +125,11 @@ private fun NativeChildrenContainer(
225
125
  viewContainer: FrameLayout,
226
126
  modifier: Modifier = Modifier
227
127
  ) {
228
- AndroidView(factory = { _ -> viewContainer }, modifier = modifier.fillMaxSize())
128
+ AndroidView(
129
+ factory = { _ ->
130
+ (viewContainer.parent as? ViewGroup)?.removeView(viewContainer)
131
+ viewContainer
132
+ },
133
+ modifier = modifier.fillMaxSize()
134
+ )
229
135
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "golia-expo-utils",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "expo utils provided by golia.jp",
5
5
  "main": "build/index.js",
6
6
  "homepage": "https://expo.golia.jp/golia-expo-utils",