@momo-kits/arrangement-view 0.0.0-stage → 0.167.1-beta.14
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.
- package/ArrangementView.podspec +30 -0
- package/LICENSE +20 -0
- package/android/build.gradle +64 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/arrangementview/ArrangementView.kt +242 -0
- package/android/src/main/java/com/arrangementview/ArrangementViewManager.kt +36 -0
- package/android/src/main/java/com/arrangementview/ArrangementViewPackage.kt +17 -0
- package/ios/ArrangementHost.swift +206 -0
- package/ios/RNArrangementPane.h +5 -0
- package/ios/RNArrangementPane.mm +47 -0
- package/ios/RNArrangementPaneShadowNode.h +44 -0
- package/ios/RNArrangementView.h +3 -0
- package/ios/RNArrangementView.mm +77 -0
- package/package.json +52 -4
- package/src/ArrangementPaneNativeComponent.ts +7 -0
- package/src/ArrangementView.android.tsx +96 -0
- package/src/ArrangementView.ios.tsx +66 -0
- package/src/ArrangementView.tsx +11 -0
- package/src/ArrangementViewNativeComponent.ts +46 -0
- package/src/SplitView.tsx +114 -0
- package/src/arrange.ts +151 -0
- package/src/hinge.ts +95 -0
- package/src/index.tsx +14 -0
- package/src/slots.ts +94 -0
- package/src/types.ts +41 -0
- package/README.md +0 -3
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = "ArrangementView"
|
|
7
|
+
s.version = package["version"]
|
|
8
|
+
s.summary = package["description"]
|
|
9
|
+
s.homepage = package["homepage"]
|
|
10
|
+
s.license = package["license"]
|
|
11
|
+
s.authors = package["author"]
|
|
12
|
+
|
|
13
|
+
s.platforms = { :ios => "15.1" }
|
|
14
|
+
s.swift_version = "5.0"
|
|
15
|
+
s.frameworks = "SwiftUI"
|
|
16
|
+
|
|
17
|
+
# Runtime availability alone cannot compile symbols absent from an older SDK.
|
|
18
|
+
sdk_version = `xcrun --sdk iphoneos --show-sdk-version`.strip
|
|
19
|
+
has_arrangement = Gem::Version.new(sdk_version) >= Gem::Version.new("27.1")
|
|
20
|
+
s.pod_target_xcconfig = {
|
|
21
|
+
"DEFINES_MODULE" => "YES",
|
|
22
|
+
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" => "$(inherited) #{has_arrangement ? 'RAV_HAS_ARRANGEMENT' : ''}"
|
|
23
|
+
}
|
|
24
|
+
s.source = { :git => "https://www.npmjs.com/.git", :tag => "#{s.version}" }
|
|
25
|
+
|
|
26
|
+
s.source_files = "ios/**/*.{h,m,mm,swift,cpp}"
|
|
27
|
+
s.private_header_files = "ios/**/*.h"
|
|
28
|
+
|
|
29
|
+
install_modules_dependencies(s)
|
|
30
|
+
end
|
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thiago Brezinski
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
5
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
6
|
+
in the Software without restriction, including without limitation the rights
|
|
7
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
9
|
+
furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all
|
|
12
|
+
copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
17
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
18
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
19
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20
|
+
SOFTWARE.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
buildscript {
|
|
2
|
+
ext.ArrangementView = [
|
|
3
|
+
kotlinVersion: "2.1.20",
|
|
4
|
+
minSdkVersion: 24,
|
|
5
|
+
compileSdkVersion: 36,
|
|
6
|
+
targetSdkVersion: 36
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
ext.getExtOrDefault = { prop ->
|
|
10
|
+
if (rootProject.ext.has(prop)) {
|
|
11
|
+
return rootProject.ext.get(prop)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return ArrangementView[prop]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
repositories {
|
|
18
|
+
google()
|
|
19
|
+
mavenCentral()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
dependencies {
|
|
23
|
+
classpath "com.android.tools.build:gradle:8.11.0"
|
|
24
|
+
// noinspection DifferentKotlinGradleVersion
|
|
25
|
+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
apply plugin: "com.android.library"
|
|
30
|
+
apply plugin: "kotlin-android"
|
|
31
|
+
apply plugin: "com.facebook.react"
|
|
32
|
+
|
|
33
|
+
android {
|
|
34
|
+
namespace "com.arrangementview"
|
|
35
|
+
|
|
36
|
+
compileSdkVersion getExtOrDefault("compileSdkVersion")
|
|
37
|
+
|
|
38
|
+
defaultConfig {
|
|
39
|
+
minSdkVersion getExtOrDefault("minSdkVersion")
|
|
40
|
+
targetSdkVersion getExtOrDefault("targetSdkVersion")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
buildTypes {
|
|
44
|
+
release {
|
|
45
|
+
minifyEnabled false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
lint {
|
|
50
|
+
disable "GradleCompatible"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
compileOptions {
|
|
54
|
+
sourceCompatibility JavaVersion.VERSION_1_8
|
|
55
|
+
targetCompatibility JavaVersion.VERSION_1_8
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
dependencies {
|
|
60
|
+
implementation "com.facebook.react:react-android"
|
|
61
|
+
// Jetpack WindowManager reports the fold; window-java adapts its Flow to a callback.
|
|
62
|
+
implementation "androidx.window:window:1.5.1"
|
|
63
|
+
implementation "androidx.window:window-java:1.5.1"
|
|
64
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
package com.arrangementview
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Rect
|
|
5
|
+
import android.hardware.Sensor
|
|
6
|
+
import android.hardware.SensorEvent
|
|
7
|
+
import android.hardware.SensorEventListener
|
|
8
|
+
import android.hardware.SensorManager
|
|
9
|
+
import android.os.Build
|
|
10
|
+
import android.view.View
|
|
11
|
+
import android.view.ViewTreeObserver
|
|
12
|
+
import androidx.core.content.ContextCompat
|
|
13
|
+
import androidx.core.util.Consumer
|
|
14
|
+
import androidx.window.java.layout.WindowInfoTrackerCallbackAdapter
|
|
15
|
+
import androidx.window.layout.FoldingFeature
|
|
16
|
+
import androidx.window.layout.WindowInfoTracker
|
|
17
|
+
import androidx.window.layout.WindowLayoutInfo
|
|
18
|
+
import com.facebook.react.bridge.Arguments
|
|
19
|
+
import com.facebook.react.bridge.WritableMap
|
|
20
|
+
import com.facebook.react.uimanager.PixelUtil
|
|
21
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
22
|
+
import com.facebook.react.uimanager.UIManagerHelper
|
|
23
|
+
import com.facebook.react.uimanager.events.Event
|
|
24
|
+
import com.facebook.react.views.view.ReactViewGroup
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Android has no system arrangement container, so this view only reports what the layout policy
|
|
28
|
+
* in src/arrange.ts needs: its size, its window's size, the fold crossing it, and the hinge. React
|
|
29
|
+
* lays out the panes.
|
|
30
|
+
*/
|
|
31
|
+
class ArrangementView(private val reactContext: ThemedReactContext) :
|
|
32
|
+
ReactViewGroup(reactContext), SensorEventListener {
|
|
33
|
+
|
|
34
|
+
var observeHinge = true
|
|
35
|
+
set(value) {
|
|
36
|
+
if (field == value) return
|
|
37
|
+
field = value
|
|
38
|
+
updateHingeSensor()
|
|
39
|
+
// JS resets to unavailable when disabled; re-enabling must replay the current state.
|
|
40
|
+
lastHinge = null
|
|
41
|
+
emitHinge()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private val windowInfo = WindowInfoTrackerCallbackAdapter(WindowInfoTracker.getOrCreate(reactContext))
|
|
45
|
+
private val sensorManager = reactContext.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
|
46
|
+
private val hingeSensor: Sensor? =
|
|
47
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
48
|
+
sensorManager.getDefaultSensor(Sensor.TYPE_HINGE_ANGLE)
|
|
49
|
+
} else {
|
|
50
|
+
null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private var fold: FoldingFeature? = null
|
|
54
|
+
private var hingeDegrees: Float? = null
|
|
55
|
+
private var lastHinge: Triple<Boolean, Double, String>? = null
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Yoga's padding and border for this view. Fabric does not apply them to the Android view,
|
|
59
|
+
* because Yoga has already positioned the descendants. JS lays the panes out inside a wrapper
|
|
60
|
+
* that fills the content box, so geometry is reported in that box.
|
|
61
|
+
*/
|
|
62
|
+
val contentInsets = Rect()
|
|
63
|
+
|
|
64
|
+
fun setContentInsets(left: Int, top: Int, right: Int, bottom: Int) {
|
|
65
|
+
contentInsets.set(left, top, right, bottom)
|
|
66
|
+
// Moving padding from one edge to another keeps the size and moves the fold.
|
|
67
|
+
emitGeometry()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Geometry is withheld until the first WindowLayoutInfo so JS never arranges without the fold.
|
|
71
|
+
private var hasWindowLayout = false
|
|
72
|
+
private val foldInView = Rect()
|
|
73
|
+
private var lastGeometry: List<Any?>? = null
|
|
74
|
+
|
|
75
|
+
private val onWindowLayout = Consumer<WindowLayoutInfo> { info ->
|
|
76
|
+
fold = info.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()
|
|
77
|
+
hasWindowLayout = true
|
|
78
|
+
emitGeometry()
|
|
79
|
+
emitHinge()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Fabric positions views without a layout pass, so ancestors can move this view without any
|
|
83
|
+
// callback reaching it. Checking before each draw is cheap and sees every such change.
|
|
84
|
+
private val onPreDraw = ViewTreeObserver.OnPreDrawListener {
|
|
85
|
+
emitGeometry()
|
|
86
|
+
true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
override fun onAttachedToWindow() {
|
|
90
|
+
super.onAttachedToWindow()
|
|
91
|
+
val activity = reactContext.currentActivity
|
|
92
|
+
if (activity != null) {
|
|
93
|
+
windowInfo.addWindowLayoutInfoListener(
|
|
94
|
+
activity, ContextCompat.getMainExecutor(reactContext), onWindowLayout)
|
|
95
|
+
} else {
|
|
96
|
+
hasWindowLayout = true
|
|
97
|
+
}
|
|
98
|
+
viewTreeObserver.addOnPreDrawListener(onPreDraw)
|
|
99
|
+
updateHingeSensor()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
override fun onDetachedFromWindow() {
|
|
103
|
+
super.onDetachedFromWindow()
|
|
104
|
+
windowInfo.removeWindowLayoutInfoListener(onWindowLayout)
|
|
105
|
+
viewTreeObserver.removeOnPreDrawListener(onPreDraw)
|
|
106
|
+
sensorManager.unregisterListener(this)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private fun updateHingeSensor() {
|
|
110
|
+
sensorManager.unregisterListener(this)
|
|
111
|
+
if (observeHinge && isAttachedToWindow && hingeSensor != null) {
|
|
112
|
+
sensorManager.registerListener(this, hingeSensor, SensorManager.SENSOR_DELAY_NORMAL)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
override fun onSensorChanged(event: SensorEvent) {
|
|
117
|
+
hingeDegrees = event.values[0]
|
|
118
|
+
emitHinge()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
|
122
|
+
|
|
123
|
+
private fun emitHinge() {
|
|
124
|
+
if (!observeHinge) return
|
|
125
|
+
val degrees = hingeDegrees
|
|
126
|
+
// WindowManager names the open postures. It reports no fold for a closed device, whose app
|
|
127
|
+
// runs on the cover display, so only there does the angle decide.
|
|
128
|
+
val status =
|
|
129
|
+
when {
|
|
130
|
+
fold?.state == FoldingFeature.State.FLAT -> "fullyOpen"
|
|
131
|
+
fold?.state == FoldingFeature.State.HALF_OPENED -> "partiallyOpen"
|
|
132
|
+
degrees != null && degrees < CLOSED_DEGREES -> "closed"
|
|
133
|
+
else -> "unknown"
|
|
134
|
+
}
|
|
135
|
+
val available = fold != null || hingeSensor != null
|
|
136
|
+
val radians = if (degrees != null) Math.toRadians(degrees.toDouble()) else Double.NaN
|
|
137
|
+
val hinge = Triple(available, radians, status)
|
|
138
|
+
if (hinge == lastHinge) return
|
|
139
|
+
lastHinge = hinge
|
|
140
|
+
dispatch(
|
|
141
|
+
HINGE_EVENT,
|
|
142
|
+
Arguments.createMap().apply {
|
|
143
|
+
putBoolean("available", available)
|
|
144
|
+
putDouble("angle", radians)
|
|
145
|
+
putString("status", status)
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private fun emitGeometry() {
|
|
150
|
+
if (!hasWindowLayout) return
|
|
151
|
+
val fold = fold
|
|
152
|
+
if (fold != null) {
|
|
153
|
+
// Fold bounds are in window coordinates. Use this view's layout position rather than
|
|
154
|
+
// getLocationInWindow so a screen transition's transform doesn't drag the fold along.
|
|
155
|
+
var x = 0
|
|
156
|
+
var y = 0
|
|
157
|
+
var view: View = this
|
|
158
|
+
while (true) {
|
|
159
|
+
x += view.left
|
|
160
|
+
y += view.top
|
|
161
|
+
val parent = view.parent as? View ?: break
|
|
162
|
+
x -= parent.scrollX
|
|
163
|
+
y -= parent.scrollY
|
|
164
|
+
view = parent
|
|
165
|
+
}
|
|
166
|
+
foldInView.set(fold.bounds)
|
|
167
|
+
foldInView.offset(-x - contentInsets.left, -y - contentInsets.top)
|
|
168
|
+
} else {
|
|
169
|
+
foldInView.setEmpty()
|
|
170
|
+
}
|
|
171
|
+
val width = width - contentInsets.left - contentInsets.right
|
|
172
|
+
val height = height - contentInsets.top - contentInsets.bottom
|
|
173
|
+
// Material sizes panes by the window, as iOS size classes are by the scene.
|
|
174
|
+
val window = rootView
|
|
175
|
+
val geometry =
|
|
176
|
+
listOf(
|
|
177
|
+
width,
|
|
178
|
+
height,
|
|
179
|
+
window.width,
|
|
180
|
+
window.height,
|
|
181
|
+
Rect(foldInView),
|
|
182
|
+
fold?.orientation,
|
|
183
|
+
fold?.isSeparating,
|
|
184
|
+
fold?.state)
|
|
185
|
+
if (geometry == lastGeometry) return
|
|
186
|
+
lastGeometry = geometry
|
|
187
|
+
dispatch(
|
|
188
|
+
GEOMETRY_EVENT,
|
|
189
|
+
Arguments.createMap().apply {
|
|
190
|
+
putDouble("width", dp(width))
|
|
191
|
+
putDouble("height", dp(height))
|
|
192
|
+
putMap(
|
|
193
|
+
"window",
|
|
194
|
+
Arguments.createMap().apply {
|
|
195
|
+
putDouble("width", dp(window.width))
|
|
196
|
+
putDouble("height", dp(window.height))
|
|
197
|
+
})
|
|
198
|
+
if (fold != null) {
|
|
199
|
+
putMap(
|
|
200
|
+
"fold",
|
|
201
|
+
Arguments.createMap().apply {
|
|
202
|
+
putDouble("x", dp(foldInView.left))
|
|
203
|
+
putDouble("y", dp(foldInView.top))
|
|
204
|
+
putDouble("width", dp(foldInView.width()))
|
|
205
|
+
putDouble("height", dp(foldInView.height()))
|
|
206
|
+
putString(
|
|
207
|
+
"orientation",
|
|
208
|
+
if (fold.orientation == FoldingFeature.Orientation.VERTICAL) "vertical"
|
|
209
|
+
else "horizontal")
|
|
210
|
+
putBoolean("separating", fold.isSeparating)
|
|
211
|
+
putBoolean("halfOpened", fold.state == FoldingFeature.State.HALF_OPENED)
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private fun dp(pixels: Int): Double = PixelUtil.toDIPFromPixel(pixels.toFloat()).toDouble()
|
|
218
|
+
|
|
219
|
+
// getEventDispatcher(context), its replacement, does not exist in React Native 0.83.
|
|
220
|
+
@Suppress("DEPRECATION")
|
|
221
|
+
private fun dispatch(name: String, payload: WritableMap) {
|
|
222
|
+
UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)
|
|
223
|
+
?.dispatchEvent(ArrangementEvent(UIManagerHelper.getSurfaceId(this), id, name, payload))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private class ArrangementEvent(
|
|
227
|
+
surfaceId: Int,
|
|
228
|
+
viewId: Int,
|
|
229
|
+
private val name: String,
|
|
230
|
+
private val payload: WritableMap,
|
|
231
|
+
) : Event<ArrangementEvent>(surfaceId, viewId) {
|
|
232
|
+
override fun getEventName(): String = name
|
|
233
|
+
|
|
234
|
+
override fun getEventData(): WritableMap = payload
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
companion object {
|
|
238
|
+
const val HINGE_EVENT = "topHingeChange"
|
|
239
|
+
const val GEOMETRY_EVENT = "topGeometryChange"
|
|
240
|
+
private const val CLOSED_DEGREES = 5f
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package com.arrangementview
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
4
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
5
|
+
import com.facebook.react.uimanager.annotations.ReactProp
|
|
6
|
+
import com.facebook.react.views.view.ReactViewGroup
|
|
7
|
+
import com.facebook.react.views.view.ReactViewManager
|
|
8
|
+
|
|
9
|
+
// Extends the plain View manager so every View style (borders, overflow, ...) keeps working.
|
|
10
|
+
// `arrangement` and `axes` are deliberately not handled here: JS arranges the panes on Android.
|
|
11
|
+
@ReactModule(name = ArrangementViewManager.NAME)
|
|
12
|
+
class ArrangementViewManager : ReactViewManager() {
|
|
13
|
+
override fun getName(): String = NAME
|
|
14
|
+
|
|
15
|
+
override fun createViewInstance(context: ThemedReactContext): ReactViewGroup =
|
|
16
|
+
ArrangementView(context)
|
|
17
|
+
|
|
18
|
+
@ReactProp(name = "observeHinge", defaultBoolean = true)
|
|
19
|
+
fun setObserveHinge(view: ArrangementView, value: Boolean) {
|
|
20
|
+
view.observeHinge = value
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
override fun setPadding(view: ReactViewGroup, left: Int, top: Int, right: Int, bottom: Int) {
|
|
24
|
+
(view as ArrangementView).setContentInsets(left, top, right, bottom)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> =
|
|
28
|
+
(super.getExportedCustomDirectEventTypeConstants() ?: mutableMapOf()).apply {
|
|
29
|
+
put(ArrangementView.HINGE_EVENT, mapOf("registrationName" to "onHingeChange"))
|
|
30
|
+
put(ArrangementView.GEOMETRY_EVENT, mapOf("registrationName" to "onGeometryChange"))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
companion object {
|
|
34
|
+
const val NAME = "RNArrangementView"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
package com.arrangementview
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.BaseReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
7
|
+
import com.facebook.react.uimanager.ViewManager
|
|
8
|
+
|
|
9
|
+
class ArrangementViewPackage : BaseReactPackage() {
|
|
10
|
+
override fun createViewManagers(
|
|
11
|
+
reactContext: ReactApplicationContext
|
|
12
|
+
): List<ViewManager<*, *>> = listOf(ArrangementViewManager())
|
|
13
|
+
|
|
14
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = null
|
|
15
|
+
|
|
16
|
+
override fun getReactModuleInfoProvider() = ReactModuleInfoProvider { emptyMap() }
|
|
17
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import Combine
|
|
3
|
+
|
|
4
|
+
private final class ArrangementModel: ObservableObject {
|
|
5
|
+
@Published var primary: UIView?
|
|
6
|
+
@Published var secondary: UIView?
|
|
7
|
+
@Published var arrangement = "split"
|
|
8
|
+
@Published var axes = "both"
|
|
9
|
+
@Published var observeHinge = true
|
|
10
|
+
@Published var forceSplit = false
|
|
11
|
+
@Published var primaryRatio: Double = 0
|
|
12
|
+
var hingeChanged: ((Bool, Double, String) -> Void)?
|
|
13
|
+
var paneLayout: ((UIView, CGRect) -> Void)?
|
|
14
|
+
weak var host: UIView?
|
|
15
|
+
|
|
16
|
+
var supportedAxes: Axis.Set {
|
|
17
|
+
switch axes {
|
|
18
|
+
case "horizontal": return .horizontal
|
|
19
|
+
case "vertical": return .vertical
|
|
20
|
+
default: return [.horizontal, .vertical]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// UIKit is only the React Native / SwiftUI hosting boundary. All arrangement
|
|
26
|
+
// decisions and hinge observation below use SwiftUI, including the fallback.
|
|
27
|
+
private final class PaneContainer: UIView {
|
|
28
|
+
weak var model: ArrangementModel?
|
|
29
|
+
let pane: UIView
|
|
30
|
+
|
|
31
|
+
init(pane: UIView, model: ArrangementModel) {
|
|
32
|
+
self.pane = pane
|
|
33
|
+
self.model = model
|
|
34
|
+
super.init(frame: .zero)
|
|
35
|
+
clipsToBounds = true
|
|
36
|
+
addSubview(pane)
|
|
37
|
+
}
|
|
38
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
|
|
39
|
+
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
40
|
+
let target = super.hitTest(point, with: event)
|
|
41
|
+
return target === self ? nil : target
|
|
42
|
+
}
|
|
43
|
+
override func layoutSubviews() {
|
|
44
|
+
super.layoutSubviews()
|
|
45
|
+
guard let model, let host = model.host else { return }
|
|
46
|
+
model.paneLayout?(pane, convert(bounds, to: host))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private struct ReactPane: UIViewRepresentable {
|
|
51
|
+
let pane: UIView
|
|
52
|
+
let model: ArrangementModel
|
|
53
|
+
func makeUIView(context: Context) -> PaneContainer { PaneContainer(pane: pane, model: model) }
|
|
54
|
+
func updateUIView(_ view: PaneContainer, context: Context) { view.setNeedsLayout() }
|
|
55
|
+
@available(iOS 16.0, *)
|
|
56
|
+
func sizeThatFits(_ proposal: ProposedViewSize, uiView: PaneContainer, context: Context) -> CGSize? {
|
|
57
|
+
CGSize(width: proposal.width ?? 0, height: proposal.height ?? 0)
|
|
58
|
+
}
|
|
59
|
+
static func dismantleUIView(_ view: PaneContainer, coordinator: ()) {
|
|
60
|
+
// A style change may already have moved the same React view to its new slot.
|
|
61
|
+
if view.pane.superview === view { view.pane.removeFromSuperview() }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private struct ArrangementContent: View {
|
|
66
|
+
@ObservedObject var model: ArrangementModel
|
|
67
|
+
|
|
68
|
+
@ViewBuilder private var primary: some View {
|
|
69
|
+
if let pane = model.primary { ReactPane(pane: pane, model: model) }
|
|
70
|
+
}
|
|
71
|
+
@ViewBuilder private var secondary: some View {
|
|
72
|
+
if let pane = model.secondary { ReactPane(pane: pane, model: model) }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@ViewBuilder var body: some View {
|
|
76
|
+
if model.forceSplit && model.arrangement == "split" {
|
|
77
|
+
HStack(spacing: 0) {
|
|
78
|
+
primary.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
79
|
+
secondary.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
adaptive
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@ViewBuilder private var adaptive: some View {
|
|
87
|
+
#if RAV_HAS_ARRANGEMENT
|
|
88
|
+
if #available(iOS 27.1, *) {
|
|
89
|
+
nativeArrangement
|
|
90
|
+
.onHingeChange(isEnabled: model.observeHinge) { _, context in
|
|
91
|
+
guard let hinge = context.hinge else {
|
|
92
|
+
model.hingeChanged?(false, 0, "unknown")
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
let status: String
|
|
96
|
+
switch hinge.status {
|
|
97
|
+
case .closed: status = "closed"
|
|
98
|
+
case .partiallyOpen: status = "partiallyOpen"
|
|
99
|
+
case .fullyOpen: status = "fullyOpen"
|
|
100
|
+
default: status = "unknown"
|
|
101
|
+
}
|
|
102
|
+
model.hingeChanged?(true, hinge.angle.radians, status)
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
fallback
|
|
106
|
+
}
|
|
107
|
+
#else
|
|
108
|
+
fallback
|
|
109
|
+
#endif
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#if RAV_HAS_ARRANGEMENT
|
|
113
|
+
@available(iOS 27.1, *)
|
|
114
|
+
@ViewBuilder private var nativeArrangement: some View {
|
|
115
|
+
if model.arrangement == "overlay" {
|
|
116
|
+
ArrangementView { primary } secondary: { secondary }
|
|
117
|
+
.arrangementViewStyle(.overlay.axes(model.supportedAxes))
|
|
118
|
+
} else {
|
|
119
|
+
ArrangementView {
|
|
120
|
+
primary.splitArrangementLayoutRatio(model.primaryRatio > 0 ? CGFloat(model.primaryRatio) : nil)
|
|
121
|
+
} secondary: {
|
|
122
|
+
secondary
|
|
123
|
+
}
|
|
124
|
+
.arrangementViewStyle(.split.axes(model.supportedAxes))
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
#endif
|
|
128
|
+
|
|
129
|
+
@ViewBuilder private var fallback: some View {
|
|
130
|
+
if model.arrangement == "overlay" {
|
|
131
|
+
ZStack { secondary; primary }
|
|
132
|
+
} else {
|
|
133
|
+
// Deterministic compact fallback. The secondary React tree stays mounted.
|
|
134
|
+
primary
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@objc(RNArrangementHost)
|
|
140
|
+
public final class RNArrangementHost: UIView {
|
|
141
|
+
private let model = ArrangementModel()
|
|
142
|
+
private var controller: UIHostingController<ArrangementContent>!
|
|
143
|
+
|
|
144
|
+
@objc public var onHingeChange: ((Bool, Double, String) -> Void)? {
|
|
145
|
+
didSet { model.hingeChanged = onHingeChange }
|
|
146
|
+
}
|
|
147
|
+
@objc public var onPaneLayout: ((UIView, CGRect) -> Void)? {
|
|
148
|
+
didSet { model.paneLayout = onPaneLayout }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@objc public override init(frame: CGRect) {
|
|
152
|
+
super.init(frame: frame)
|
|
153
|
+
model.host = self
|
|
154
|
+
controller = UIHostingController(rootView: ArrangementContent(model: model))
|
|
155
|
+
if #available(iOS 16.4, *) { controller.safeAreaRegions = [] }
|
|
156
|
+
controller.view.backgroundColor = .clear
|
|
157
|
+
addSubview(controller.view)
|
|
158
|
+
clipsToBounds = true
|
|
159
|
+
}
|
|
160
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
|
|
161
|
+
|
|
162
|
+
@objc public func setPanes(_ primary: UIView?, secondary: UIView?) {
|
|
163
|
+
if model.primary !== primary { model.primary = primary }
|
|
164
|
+
if model.secondary !== secondary { model.secondary = secondary }
|
|
165
|
+
}
|
|
166
|
+
@objc public func configure(
|
|
167
|
+
_ arrangement: String, axes: String, observeHinge: Bool, forceSplit: Bool, primaryRatio: Double
|
|
168
|
+
) {
|
|
169
|
+
if model.forceSplit != forceSplit { model.forceSplit = forceSplit }
|
|
170
|
+
if model.primaryRatio != primaryRatio { model.primaryRatio = primaryRatio }
|
|
171
|
+
if model.arrangement != arrangement { model.arrangement = arrangement }
|
|
172
|
+
if model.axes != axes { model.axes = axes }
|
|
173
|
+
if model.observeHinge != observeHinge {
|
|
174
|
+
model.observeHinge = observeHinge
|
|
175
|
+
if !observeHinge { model.hingeChanged?(false, 0, "unknown") }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
public override func layoutSubviews() {
|
|
179
|
+
super.layoutSubviews()
|
|
180
|
+
attachController()
|
|
181
|
+
controller.view.frame = bounds
|
|
182
|
+
}
|
|
183
|
+
public override func didMoveToWindow() {
|
|
184
|
+
super.didMoveToWindow()
|
|
185
|
+
if window == nil { detachController() } else { attachController() }
|
|
186
|
+
}
|
|
187
|
+
private func attachController() {
|
|
188
|
+
guard window != nil else { return }
|
|
189
|
+
var responder: UIResponder? = superview
|
|
190
|
+
while let next = responder {
|
|
191
|
+
if let parent = next as? UIViewController {
|
|
192
|
+
guard controller.parent !== parent else { return }
|
|
193
|
+
detachController()
|
|
194
|
+
parent.addChild(controller)
|
|
195
|
+
controller.didMove(toParent: parent)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
responder = next.next
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
private func detachController() {
|
|
202
|
+
guard controller.parent != nil else { return }
|
|
203
|
+
controller.willMove(toParent: nil)
|
|
204
|
+
controller.removeFromParent()
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#import "RNArrangementPane.h"
|
|
2
|
+
#import "RNArrangementPaneShadowNode.h"
|
|
3
|
+
using namespace facebook::react;
|
|
4
|
+
|
|
5
|
+
@implementation RNArrangementPane {
|
|
6
|
+
ArrangementPaneShadowNode::ConcreteState::Shared _paneState;
|
|
7
|
+
CGRect _nativeFrame;
|
|
8
|
+
BOOL _hasNativeFrame;
|
|
9
|
+
}
|
|
10
|
+
+ (ComponentDescriptorProvider)componentDescriptorProvider {
|
|
11
|
+
return concreteComponentDescriptorProvider<ArrangementPaneComponentDescriptor>();
|
|
12
|
+
}
|
|
13
|
+
+ (BOOL)shouldBeRecycled { return NO; }
|
|
14
|
+
- (instancetype)initWithFrame:(CGRect)frame {
|
|
15
|
+
if (self = [super initWithFrame:frame]) {
|
|
16
|
+
_props = std::make_shared<const RNArrangementPaneProps>();
|
|
17
|
+
self.clipsToBounds = YES;
|
|
18
|
+
}
|
|
19
|
+
return self;
|
|
20
|
+
}
|
|
21
|
+
- (void)updateNativeFrame:(CGRect)frame {
|
|
22
|
+
_nativeFrame = frame;
|
|
23
|
+
_hasNativeFrame = YES;
|
|
24
|
+
self.frame = (CGRect){CGPointZero, frame.size};
|
|
25
|
+
[self synchronizeState];
|
|
26
|
+
}
|
|
27
|
+
- (void)synchronizeState {
|
|
28
|
+
if (!_paneState || !_hasNativeFrame) return;
|
|
29
|
+
const auto &old = _paneState->getData();
|
|
30
|
+
facebook::react::Size size{(Float)_nativeFrame.size.width, (Float)_nativeFrame.size.height};
|
|
31
|
+
facebook::react::Point origin{(Float)_nativeFrame.origin.x, (Float)_nativeFrame.origin.y};
|
|
32
|
+
if (old.measured && old.size == size && old.origin == origin) return;
|
|
33
|
+
_paneState->updateState(ArrangementPaneState{size, origin, true});
|
|
34
|
+
}
|
|
35
|
+
- (void)updateState:(State::Shared const &)state oldState:(State::Shared const &)oldState {
|
|
36
|
+
[super updateState:state oldState:oldState];
|
|
37
|
+
_paneState = std::static_pointer_cast<const ArrangementPaneShadowNode::ConcreteState>(state);
|
|
38
|
+
[self synchronizeState];
|
|
39
|
+
}
|
|
40
|
+
- (void)updateLayoutMetrics:(LayoutMetrics const &)metrics oldLayoutMetrics:(LayoutMetrics const &)oldMetrics {
|
|
41
|
+
// SwiftUI owns this view's frame. Fabric still receives metrics for descendants,
|
|
42
|
+
// onLayout and measurement; stale commits must not resize the native pane.
|
|
43
|
+
auto nativeMetrics = metrics;
|
|
44
|
+
if (_hasNativeFrame) nativeMetrics.frame = {{0, 0}, {(Float)_nativeFrame.size.width, (Float)_nativeFrame.size.height}};
|
|
45
|
+
[super updateLayoutMetrics:nativeMetrics oldLayoutMetrics:oldMetrics];
|
|
46
|
+
}
|
|
47
|
+
@end
|