@erkamyaman/capacitor-foldable 7.0.0
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/ErkamyamanCapacitorFoldable.podspec +17 -0
- package/LICENSE +21 -0
- package/Package.swift +28 -0
- package/README.md +457 -0
- package/android/build.gradle +71 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/kotlin/io/github/erkamyaman/foldable/DisplayModes.kt +115 -0
- package/android/src/main/kotlin/io/github/erkamyaman/foldable/Foldable.kt +189 -0
- package/android/src/main/kotlin/io/github/erkamyaman/foldable/FoldablePlugin.kt +333 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/dist/docs.json +584 -0
- package/dist/esm/css.d.ts +8 -0
- package/dist/esm/css.js +29 -0
- package/dist/esm/css.js.map +1 -0
- package/dist/esm/definitions.d.ts +267 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.js +9 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/polyfill.d.ts +19 -0
- package/dist/esm/polyfill.js +76 -0
- package/dist/esm/polyfill.js.map +1 -0
- package/dist/esm/segments.d.ts +8 -0
- package/dist/esm/segments.js +26 -0
- package/dist/esm/segments.js.map +1 -0
- package/dist/esm/size-class.d.ts +2 -0
- package/dist/esm/size-class.js +33 -0
- package/dist/esm/size-class.js.map +1 -0
- package/dist/esm/web.d.ts +21 -0
- package/dist/esm/web.js +46 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +222 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +225 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/FoldablePlugin/Foldable.swift +156 -0
- package/ios/Sources/FoldablePlugin/FoldablePlugin.swift +194 -0
- package/ios/Sources/FoldablePlugin/ReservedRegionFoldProvider.swift +69 -0
- package/ios/Sources/FoldablePlugin/UIKitFoldSource.swift +53 -0
- package/ios/Tests/FoldablePluginTests/FoldableTests.swift +104 -0
- package/ios/Tests/FoldablePluginTests/ReservedRegionFoldProviderTests.swift +143 -0
- package/package.json +96 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
struct NativeFold {
|
|
5
|
+
var state: String
|
|
6
|
+
var isSeparating: Bool
|
|
7
|
+
var hingeOrientation: String?
|
|
8
|
+
var hingeBounds: CGRect?
|
|
9
|
+
var occludedBounds: CGRect?
|
|
10
|
+
var activeDisplay: String?
|
|
11
|
+
var cameraBounds: [CGRect] = []
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
protocol FoldProvider {
|
|
15
|
+
var isFoldable: Bool { get }
|
|
16
|
+
var supportsTabletop: Bool { get }
|
|
17
|
+
func fold(in view: UIView?) -> NativeFold?
|
|
18
|
+
func hingeAngle() -> Double?
|
|
19
|
+
func observe(_ view: UIView, onChange: @escaping () -> Void)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
struct UnsupportedFoldProvider: FoldProvider {
|
|
23
|
+
var isFoldable: Bool { false }
|
|
24
|
+
var supportsTabletop: Bool { false }
|
|
25
|
+
|
|
26
|
+
func fold(in view: UIView?) -> NativeFold? {
|
|
27
|
+
return nil
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func hingeAngle() -> Double? {
|
|
31
|
+
return nil
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func observe(_ view: UIView, onChange: @escaping () -> Void) {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@objc public class Foldable: NSObject {
|
|
38
|
+
private let provider: FoldProvider
|
|
39
|
+
|
|
40
|
+
init(provider: FoldProvider) {
|
|
41
|
+
self.provider = provider
|
|
42
|
+
super.init()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
override public convenience init() {
|
|
46
|
+
self.init(provider: Foldable.defaultProvider())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private static func defaultProvider() -> FoldProvider {
|
|
50
|
+
#if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
|
|
51
|
+
if #available(iOS 27.1, *) {
|
|
52
|
+
return ReservedRegionFoldProvider(source: UIKitFoldSource())
|
|
53
|
+
}
|
|
54
|
+
#endif
|
|
55
|
+
return UnsupportedFoldProvider()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@objc public func isDeviceFoldable() -> Bool {
|
|
59
|
+
return provider.isFoldable
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@objc public func supportsTabletop() -> Bool {
|
|
63
|
+
return provider.supportsTabletop
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public func getFoldState(in view: UIView? = nil) -> [String: Any] {
|
|
67
|
+
guard let fold = provider.fold(in: view) else {
|
|
68
|
+
return ["state": "flat", "isSeparating": false, "posture": "flat"]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
var result: [String: Any] = [
|
|
72
|
+
"state": fold.state,
|
|
73
|
+
"isSeparating": fold.isSeparating,
|
|
74
|
+
"posture": posture(of: fold)
|
|
75
|
+
]
|
|
76
|
+
result["hingeOrientation"] = fold.hingeOrientation
|
|
77
|
+
result["hingeBounds"] = fold.hingeBounds.map(bounds(of:))
|
|
78
|
+
result["occludedBounds"] = fold.occludedBounds.map(bounds(of:))
|
|
79
|
+
result["activeDisplay"] = fold.activeDisplay
|
|
80
|
+
if !fold.cameraBounds.isEmpty {
|
|
81
|
+
result["cameraBounds"] = fold.cameraBounds.map(bounds(of:))
|
|
82
|
+
}
|
|
83
|
+
return result
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@objc public func getHingeAngle() -> [String: Any] {
|
|
87
|
+
return ["angle": provider.hingeAngle().map { $0 as Any } ?? NSNull()]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@objc public func barPlacement(in traits: UITraitCollection) -> [String: Any] {
|
|
91
|
+
return ["verticalBarEdge": verticalBarEdge(of: traits).map { $0 as Any } ?? NSNull()]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private func verticalBarEdge(of traits: UITraitCollection) -> String? {
|
|
95
|
+
#if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
|
|
96
|
+
if #available(iOS 27.1, *) {
|
|
97
|
+
switch traits.verticalBarEdge {
|
|
98
|
+
case .leading: return "leading"
|
|
99
|
+
case .trailing: return "trailing"
|
|
100
|
+
default: return nil
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
#endif
|
|
104
|
+
return nil
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
public func observeChanges(in view: UIView, onChange: @escaping () -> Void) {
|
|
108
|
+
provider.observe(view, onChange: onChange)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@objc public func sizeClass(
|
|
112
|
+
horizontal: UIUserInterfaceSizeClass,
|
|
113
|
+
vertical: UIUserInterfaceSizeClass,
|
|
114
|
+
width: CGFloat,
|
|
115
|
+
height: CGFloat
|
|
116
|
+
) -> [String: String] {
|
|
117
|
+
return [
|
|
118
|
+
"horizontal": name(of: horizontal),
|
|
119
|
+
"vertical": name(of: vertical),
|
|
120
|
+
"widthClass": widthClass(of: width),
|
|
121
|
+
"heightClass": heightClass(of: height)
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private func name(of sizeClass: UIUserInterfaceSizeClass) -> String {
|
|
126
|
+
return sizeClass == .regular ? "regular" : "compact"
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private func widthClass(of width: CGFloat) -> String {
|
|
130
|
+
if width >= 1600 { return "extraLarge" }
|
|
131
|
+
if width >= 1200 { return "large" }
|
|
132
|
+
if width >= 840 { return "expanded" }
|
|
133
|
+
if width >= 600 { return "medium" }
|
|
134
|
+
return "compact"
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private func heightClass(of height: CGFloat) -> String {
|
|
138
|
+
if height >= 900 { return "expanded" }
|
|
139
|
+
if height >= 480 { return "medium" }
|
|
140
|
+
return "compact"
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private func posture(of fold: NativeFold) -> String {
|
|
144
|
+
guard fold.state == "half-opened" else { return "flat" }
|
|
145
|
+
return fold.hingeOrientation == "horizontal" ? "tabletop" : "book"
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private func bounds(of rect: CGRect) -> [String: Int] {
|
|
149
|
+
return [
|
|
150
|
+
"x": Int(rect.origin.x.rounded()),
|
|
151
|
+
"y": Int(rect.origin.y.rounded()),
|
|
152
|
+
"width": Int(rect.size.width.rounded()),
|
|
153
|
+
"height": Int(rect.size.height.rounded())
|
|
154
|
+
]
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import UIKit
|
|
3
|
+
import Capacitor
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Please read the Capacitor iOS Plugin Development Guide
|
|
7
|
+
* here: https://capacitorjs.com/docs/plugins/ios
|
|
8
|
+
*/
|
|
9
|
+
@objc(FoldablePlugin)
|
|
10
|
+
public class FoldablePlugin: CAPPlugin, CAPBridgedPlugin {
|
|
11
|
+
public let identifier = "FoldablePlugin"
|
|
12
|
+
public let jsName = "Foldable"
|
|
13
|
+
public let pluginMethods: [CAPPluginMethod] = [
|
|
14
|
+
CAPPluginMethod(name: "isDeviceFoldable", returnType: CAPPluginReturnPromise),
|
|
15
|
+
CAPPluginMethod(name: "getFoldState", returnType: CAPPluginReturnPromise),
|
|
16
|
+
CAPPluginMethod(name: "getHingeAngle", returnType: CAPPluginReturnPromise),
|
|
17
|
+
CAPPluginMethod(name: "getSizeClass", returnType: CAPPluginReturnPromise),
|
|
18
|
+
CAPPluginMethod(name: "getDisplayModes", returnType: CAPPluginReturnPromise),
|
|
19
|
+
CAPPluginMethod(name: "getBarPlacement", returnType: CAPPluginReturnPromise),
|
|
20
|
+
CAPPluginMethod(name: "startRearDisplay", returnType: CAPPluginReturnPromise),
|
|
21
|
+
CAPPluginMethod(name: "stopRearDisplay", returnType: CAPPluginReturnPromise),
|
|
22
|
+
CAPPluginMethod(name: "startDualScreen", returnType: CAPPluginReturnPromise),
|
|
23
|
+
CAPPluginMethod(name: "stopDualScreen", returnType: CAPPluginReturnPromise)
|
|
24
|
+
]
|
|
25
|
+
private let implementation = Foldable()
|
|
26
|
+
private var lastSizeClass: [String: String]?
|
|
27
|
+
private var lastFoldState: [String: Any]?
|
|
28
|
+
private var lastBarPlacement: [String: Any]?
|
|
29
|
+
private var lastHingeAngle: Double?
|
|
30
|
+
private var orientationObserver: NSObjectProtocol?
|
|
31
|
+
|
|
32
|
+
@objc override public func load() {
|
|
33
|
+
DispatchQueue.main.async { [weak self] in
|
|
34
|
+
self?.observeChanges()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
deinit {
|
|
39
|
+
if let orientationObserver = orientationObserver {
|
|
40
|
+
NotificationCenter.default.removeObserver(orientationObserver)
|
|
41
|
+
UIDevice.current.endGeneratingDeviceOrientationNotifications()
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@objc func isDeviceFoldable(_ call: CAPPluginCall) {
|
|
46
|
+
DispatchQueue.main.async { [weak self] in
|
|
47
|
+
guard let self = self else { return }
|
|
48
|
+
call.resolve([
|
|
49
|
+
"foldable": self.implementation.isDeviceFoldable(),
|
|
50
|
+
"supportsTabletop": self.implementation.supportsTabletop()
|
|
51
|
+
])
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@objc func getFoldState(_ call: CAPPluginCall) {
|
|
56
|
+
DispatchQueue.main.async { [weak self] in
|
|
57
|
+
guard let self = self else { return }
|
|
58
|
+
call.resolve(self.currentFoldState())
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@objc func getHingeAngle(_ call: CAPPluginCall) {
|
|
63
|
+
DispatchQueue.main.async { [weak self] in
|
|
64
|
+
guard let self = self else { return }
|
|
65
|
+
call.resolve(self.implementation.getHingeAngle())
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@objc func getSizeClass(_ call: CAPPluginCall) {
|
|
70
|
+
DispatchQueue.main.async { [weak self] in
|
|
71
|
+
guard let self = self else { return }
|
|
72
|
+
call.resolve(self.currentSizeClass())
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@objc func getDisplayModes(_ call: CAPPluginCall) {
|
|
77
|
+
call.resolve(["rearDisplay": "unsupported", "dualScreen": "unsupported"])
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@objc func getBarPlacement(_ call: CAPPluginCall) {
|
|
81
|
+
DispatchQueue.main.async { [weak self] in
|
|
82
|
+
guard let self = self else { return }
|
|
83
|
+
call.resolve(self.currentBarPlacement())
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@objc func startRearDisplay(_ call: CAPPluginCall) {
|
|
88
|
+
call.unavailable("Rear display mode is only available on Android.")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@objc func stopRearDisplay(_ call: CAPPluginCall) {
|
|
92
|
+
call.resolve()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@objc func startDualScreen(_ call: CAPPluginCall) {
|
|
96
|
+
call.unavailable("Dual-screen mode is only available on Android.")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@objc func stopDualScreen(_ call: CAPPluginCall) {
|
|
100
|
+
call.resolve()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private func currentFoldState() -> [String: Any] {
|
|
104
|
+
return implementation.getFoldState(in: bridge?.webView)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private func currentBarPlacement() -> [String: Any] {
|
|
108
|
+
return implementation.barPlacement(in: bridge?.viewController?.traitCollection ?? UITraitCollection.current)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private func currentSizeClass() -> [String: String] {
|
|
112
|
+
let traits = bridge?.viewController?.traitCollection ?? UITraitCollection.current
|
|
113
|
+
let size = bridge?.viewController?.view.bounds.size ?? .zero
|
|
114
|
+
return implementation.sizeClass(
|
|
115
|
+
horizontal: traits.horizontalSizeClass,
|
|
116
|
+
vertical: traits.verticalSizeClass,
|
|
117
|
+
width: size.width,
|
|
118
|
+
height: size.height
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private func observeChanges() {
|
|
123
|
+
guard let view = bridge?.viewController?.view else { return }
|
|
124
|
+
|
|
125
|
+
lastFoldState = currentFoldState()
|
|
126
|
+
lastBarPlacement = currentBarPlacement()
|
|
127
|
+
implementation.observeChanges(in: view) { [weak self] in
|
|
128
|
+
self?.notifyFoldStateIfChanged()
|
|
129
|
+
self?.notifyHingeAngleIfChanged()
|
|
130
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
|
131
|
+
self?.notifyFoldStateIfChanged()
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
|
|
136
|
+
orientationObserver = NotificationCenter.default.addObserver(
|
|
137
|
+
forName: UIDevice.orientationDidChangeNotification,
|
|
138
|
+
object: nil,
|
|
139
|
+
queue: .main
|
|
140
|
+
) { [weak self] _ in
|
|
141
|
+
self?.notifyFoldStateIfChanged()
|
|
142
|
+
self?.notifySizeClassIfChanged()
|
|
143
|
+
self?.notifyBarPlacementIfChanged()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
guard #available(iOS 17.0, *) else { return }
|
|
147
|
+
|
|
148
|
+
lastSizeClass = currentSizeClass()
|
|
149
|
+
MainActor.assumeIsolated {
|
|
150
|
+
var traits: [UITrait] = [UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]
|
|
151
|
+
#if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
|
|
152
|
+
if #available(iOS 27.1, *) {
|
|
153
|
+
traits += UITraitCollection.systemTraitsAffectingVerticalBarEdge
|
|
154
|
+
}
|
|
155
|
+
#endif
|
|
156
|
+
_ = view.registerForTraitChanges(traits) { [weak self] (_: UIView, _: UITraitCollection) in
|
|
157
|
+
self?.notifySizeClassIfChanged()
|
|
158
|
+
self?.notifyFoldStateIfChanged()
|
|
159
|
+
self?.notifyBarPlacementIfChanged()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private func notifyHingeAngleIfChanged() {
|
|
165
|
+
guard let angle = implementation.getHingeAngle()["angle"] as? Double, angle != lastHingeAngle else { return }
|
|
166
|
+
|
|
167
|
+
lastHingeAngle = angle
|
|
168
|
+
notifyListeners("hingeAngleChange", data: ["angle": angle])
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private func notifyBarPlacementIfChanged() {
|
|
172
|
+
let placement = currentBarPlacement()
|
|
173
|
+
if let last = lastBarPlacement, (last as NSDictionary).isEqual(to: placement) { return }
|
|
174
|
+
|
|
175
|
+
lastBarPlacement = placement
|
|
176
|
+
notifyListeners("barPlacementChange", data: placement)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private func notifyFoldStateIfChanged() {
|
|
180
|
+
let foldState = currentFoldState()
|
|
181
|
+
if let last = lastFoldState, (last as NSDictionary).isEqual(to: foldState) { return }
|
|
182
|
+
|
|
183
|
+
lastFoldState = foldState
|
|
184
|
+
notifyListeners("foldStateChange", data: foldState)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private func notifySizeClassIfChanged() {
|
|
188
|
+
let sizeClass = currentSizeClass()
|
|
189
|
+
guard sizeClass != lastSizeClass else { return }
|
|
190
|
+
|
|
191
|
+
lastSizeClass = sizeClass
|
|
192
|
+
notifyListeners("sizeClassChange", data: sizeClass)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
struct ReservedRegion {
|
|
4
|
+
enum Kind {
|
|
5
|
+
case division
|
|
6
|
+
case occlusion
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
var kind: Kind
|
|
10
|
+
var frame: CGRect
|
|
11
|
+
var isActive: Bool
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
enum HingeStatus {
|
|
15
|
+
case closed
|
|
16
|
+
case partiallyOpen
|
|
17
|
+
case fullyOpen
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
protocol FoldSource {
|
|
21
|
+
var hasFold: Bool { get }
|
|
22
|
+
var hingeStatus: HingeStatus? { get }
|
|
23
|
+
var hingeAngle: Double? { get }
|
|
24
|
+
func reservedRegions(in view: UIView) -> [ReservedRegion]
|
|
25
|
+
func observe(_ view: UIView, onChange: @escaping () -> Void)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
struct ReservedRegionFoldProvider: FoldProvider {
|
|
29
|
+
let source: FoldSource
|
|
30
|
+
|
|
31
|
+
var isFoldable: Bool { source.hasFold }
|
|
32
|
+
var supportsTabletop: Bool { source.hasFold }
|
|
33
|
+
|
|
34
|
+
func fold(in view: UIView?) -> NativeFold? {
|
|
35
|
+
guard source.hasFold, let view = view else { return nil }
|
|
36
|
+
return nativeFoldOf(regions: source.reservedRegions(in: view), hingeStatus: source.hingeStatus)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func hingeAngle() -> Double? {
|
|
40
|
+
return source.hingeAngle
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func observe(_ view: UIView, onChange: @escaping () -> Void) {
|
|
44
|
+
source.observe(view, onChange: onChange)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func nativeFoldOf(regions: [ReservedRegion], hingeStatus: HingeStatus?) -> NativeFold {
|
|
49
|
+
let divisions = hingeStatus == .closed ? [] : regions.filter { $0.kind == .division }
|
|
50
|
+
let division = divisions.first { $0.isActive } ?? divisions.first
|
|
51
|
+
let isFolded: Bool
|
|
52
|
+
switch hingeStatus {
|
|
53
|
+
case .partiallyOpen?:
|
|
54
|
+
isFolded = division != nil
|
|
55
|
+
case .fullyOpen?:
|
|
56
|
+
isFolded = false
|
|
57
|
+
default:
|
|
58
|
+
isFolded = division?.isActive == true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
var fold = NativeFold(state: isFolded ? "half-opened" : "flat", isSeparating: isFolded)
|
|
62
|
+
if let division = division {
|
|
63
|
+
fold.hingeBounds = division.frame
|
|
64
|
+
fold.hingeOrientation = division.frame.height >= division.frame.width ? "vertical" : "horizontal"
|
|
65
|
+
}
|
|
66
|
+
fold.cameraBounds = regions.filter { $0.kind == .occlusion && $0.isActive }.map { $0.frame }
|
|
67
|
+
fold.activeDisplay = hingeStatus.map { $0 == .closed ? "outer" : "inner" }
|
|
68
|
+
return fold
|
|
69
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
#if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
|
|
4
|
+
@available(iOS 27.1, *)
|
|
5
|
+
@MainActor
|
|
6
|
+
final class UIKitFoldSource: @preconcurrency FoldSource {
|
|
7
|
+
private weak var view: UIView?
|
|
8
|
+
private var interaction: UIHingeInteraction?
|
|
9
|
+
private var hinge: (status: UIHinge.Status, angle: CGFloat)?
|
|
10
|
+
|
|
11
|
+
nonisolated init() {}
|
|
12
|
+
|
|
13
|
+
var hasFold: Bool {
|
|
14
|
+
if let status = hinge?.status, status != .unknown { return true }
|
|
15
|
+
guard let view = view else { return false }
|
|
16
|
+
return !view.reservedRegions(kind: .division, options: .includeInactive).isEmpty
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
var hingeStatus: HingeStatus? {
|
|
20
|
+
switch hinge?.status {
|
|
21
|
+
case .closed: return .closed
|
|
22
|
+
case .partiallyOpen: return .partiallyOpen
|
|
23
|
+
case .fullyOpen: return .fullyOpen
|
|
24
|
+
default: return nil
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
var hingeAngle: Double? {
|
|
29
|
+
guard let hinge = hinge, hinge.status != .unknown else { return nil }
|
|
30
|
+
return Double(hinge.angle) * 180 / .pi
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func reservedRegions(in view: UIView) -> [ReservedRegion] {
|
|
34
|
+
let divisions = view.reservedRegions(kind: .division, options: .includeInactive).map {
|
|
35
|
+
ReservedRegion(kind: .division, frame: $0.frame, isActive: $0.isActive)
|
|
36
|
+
}
|
|
37
|
+
let occlusions = view.reservedRegions(kind: .occlusion).map {
|
|
38
|
+
ReservedRegion(kind: .occlusion, frame: $0.frame, isActive: $0.isActive)
|
|
39
|
+
}
|
|
40
|
+
return divisions + occlusions
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func observe(_ view: UIView, onChange: @escaping () -> Void) {
|
|
44
|
+
self.view = view
|
|
45
|
+
let interaction = UIHingeInteraction { [weak self] _, update in
|
|
46
|
+
self?.hinge = update.hinge.map { ($0.status, $0.angle) }
|
|
47
|
+
onChange()
|
|
48
|
+
}
|
|
49
|
+
view.addInteraction(interaction)
|
|
50
|
+
self.interaction = interaction
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
#endif
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import XCTest
|
|
3
|
+
@testable import FoldablePlugin
|
|
4
|
+
|
|
5
|
+
private final class FakeFoldProvider: FoldProvider {
|
|
6
|
+
var isFoldable = true
|
|
7
|
+
var supportsTabletop = true
|
|
8
|
+
var reportedFold: NativeFold?
|
|
9
|
+
var angle: Double?
|
|
10
|
+
|
|
11
|
+
func fold(in view: UIView?) -> NativeFold? {
|
|
12
|
+
return reportedFold
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
func hingeAngle() -> Double? {
|
|
16
|
+
return angle
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func observe(_ view: UIView, onChange: @escaping () -> Void) {}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class FoldableTests: XCTestCase {
|
|
23
|
+
func testIsDeviceFoldableReportsFalseStub() {
|
|
24
|
+
let implementation = Foldable()
|
|
25
|
+
|
|
26
|
+
XCTAssertFalse(implementation.isDeviceFoldable())
|
|
27
|
+
XCTAssertFalse(implementation.supportsTabletop())
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func testGetFoldStateReportsFlatStub() {
|
|
31
|
+
let result = Foldable().getFoldState()
|
|
32
|
+
|
|
33
|
+
XCTAssertEqual(result["state"] as? String, "flat")
|
|
34
|
+
XCTAssertEqual(result["isSeparating"] as? Bool, false)
|
|
35
|
+
XCTAssertEqual(result["posture"] as? String, "flat")
|
|
36
|
+
XCTAssertNil(result["hingeOrientation"])
|
|
37
|
+
XCTAssertNil(result["occludedBounds"])
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func testSizeClassNamesUIKitSizeClassesAndWindowClasses() {
|
|
41
|
+
let result = Foldable().sizeClass(horizontal: .regular, vertical: .regular, width: 626, height: 890)
|
|
42
|
+
|
|
43
|
+
XCTAssertEqual(result["horizontal"], "regular")
|
|
44
|
+
XCTAssertEqual(result["vertical"], "regular")
|
|
45
|
+
XCTAssertEqual(result["widthClass"], "medium")
|
|
46
|
+
XCTAssertEqual(result["heightClass"], "medium")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func testBarPlacementIsHorizontalWithoutVerticalBars() {
|
|
50
|
+
XCTAssertTrue(Foldable().barPlacement(in: UITraitCollection())["verticalBarEdge"] is NSNull)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func testGetHingeAngleReportsNullStub() {
|
|
54
|
+
XCTAssertTrue(Foldable().getHingeAngle()["angle"] is NSNull)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func testGetFoldStateMapsProviderFold() {
|
|
58
|
+
let provider = FakeFoldProvider()
|
|
59
|
+
provider.reportedFold = NativeFold(
|
|
60
|
+
state: "half-opened",
|
|
61
|
+
isSeparating: true,
|
|
62
|
+
hingeOrientation: "vertical",
|
|
63
|
+
hingeBounds: CGRect(x: 400.4, y: 0, width: 20, height: 700),
|
|
64
|
+
activeDisplay: "inner",
|
|
65
|
+
cameraBounds: [CGRect(x: 10, y: 10, width: 40, height: 40)]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
let result = Foldable(provider: provider).getFoldState()
|
|
69
|
+
|
|
70
|
+
XCTAssertEqual(result["state"] as? String, "half-opened")
|
|
71
|
+
XCTAssertEqual(result["isSeparating"] as? Bool, true)
|
|
72
|
+
XCTAssertEqual(result["posture"] as? String, "book")
|
|
73
|
+
XCTAssertEqual(result["hingeOrientation"] as? String, "vertical")
|
|
74
|
+
XCTAssertEqual(result["hingeBounds"] as? [String: Int], ["x": 400, "y": 0, "width": 20, "height": 700])
|
|
75
|
+
XCTAssertNil(result["occludedBounds"])
|
|
76
|
+
XCTAssertEqual(result["activeDisplay"] as? String, "inner")
|
|
77
|
+
XCTAssertEqual(result["cameraBounds"] as? [[String: Int]], [["x": 10, "y": 10, "width": 40, "height": 40]])
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func testHorizontalHalfOpenedFoldIsTabletop() {
|
|
81
|
+
let provider = FakeFoldProvider()
|
|
82
|
+
provider.reportedFold = NativeFold(state: "half-opened", isSeparating: true, hingeOrientation: "horizontal")
|
|
83
|
+
|
|
84
|
+
XCTAssertEqual(Foldable(provider: provider).getFoldState()["posture"] as? String, "tabletop")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func testFoldStateOmitsCameraBoundsWhenThereAreNone() {
|
|
88
|
+
let provider = FakeFoldProvider()
|
|
89
|
+
provider.reportedFold = NativeFold(state: "flat", isSeparating: false)
|
|
90
|
+
|
|
91
|
+
XCTAssertNil(Foldable(provider: provider).getFoldState()["cameraBounds"])
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func testIsDeviceFoldableAndHingeAngleReadProvider() {
|
|
95
|
+
let provider = FakeFoldProvider()
|
|
96
|
+
provider.angle = 90
|
|
97
|
+
|
|
98
|
+
let implementation = Foldable(provider: provider)
|
|
99
|
+
|
|
100
|
+
XCTAssertTrue(implementation.isDeviceFoldable())
|
|
101
|
+
XCTAssertTrue(implementation.supportsTabletop())
|
|
102
|
+
XCTAssertEqual(implementation.getHingeAngle()["angle"] as? Double, 90)
|
|
103
|
+
}
|
|
104
|
+
}
|