craft-native 0.0.135 → 0.0.138
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/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.js +6 -0
- package/dist/ios/templates/CraftApp.swift +35 -6
- package/dist/ios/templates/CraftHybrid.swift +28 -2
- package/dist/ios/templates/CraftNativeScreen.swift +275 -7
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3303
3303
|
`);
|
|
3304
3304
|
}
|
|
3305
3305
|
// package.json
|
|
3306
|
-
var version = "0.0.
|
|
3306
|
+
var version = "0.0.138";
|
|
3307
3307
|
|
|
3308
3308
|
// bin/cli.ts
|
|
3309
3309
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
package/dist/ios/src/index.js
CHANGED
|
@@ -349,6 +349,12 @@ ${plistArray(config.appGroups)}
|
|
|
349
349
|
entries.push(` <key>aps-environment</key>
|
|
350
350
|
<string>$(CRAFT_APNS_ENVIRONMENT)</string>`);
|
|
351
351
|
}
|
|
352
|
+
if (config.enableSocialAuth) {
|
|
353
|
+
entries.push(` <key>com.apple.developer.applesignin</key>
|
|
354
|
+
<array>
|
|
355
|
+
<string>Default</string>
|
|
356
|
+
</array>`);
|
|
357
|
+
}
|
|
352
358
|
if (config.enableSecureStorage) {
|
|
353
359
|
entries.push(` <key>keychain-access-groups</key>
|
|
354
360
|
<array>
|
|
@@ -535,8 +535,25 @@ final class CraftChrome: ObservableObject {
|
|
|
535
535
|
private var occupied: CGFloat = 0
|
|
536
536
|
private var pendingHide: DispatchWorkItem?
|
|
537
537
|
private var splashDeadline: DispatchWorkItem?
|
|
538
|
+
/// The keyboard is up and covers the tab bar, so the page need not leave
|
|
539
|
+
/// room for it: the web view ends at the keyboard (CraftHybrid's webHost).
|
|
540
|
+
private var keyboardUp = false
|
|
538
541
|
|
|
539
|
-
private init() {
|
|
542
|
+
private init() {
|
|
543
|
+
let center = NotificationCenter.default
|
|
544
|
+
center.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { [weak self] _ in
|
|
545
|
+
self?.keyboardChanged(up: true)
|
|
546
|
+
}
|
|
547
|
+
center.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { [weak self] _ in
|
|
548
|
+
self?.keyboardChanged(up: false)
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private func keyboardChanged(up: Bool) {
|
|
553
|
+
guard up != keyboardUp else { return }
|
|
554
|
+
keyboardUp = up
|
|
555
|
+
publishLayout()
|
|
556
|
+
}
|
|
540
557
|
|
|
541
558
|
/// The tab bar from `tabs` in craft.config.json, there before the page
|
|
542
559
|
/// loads; the page's own `tabBar` message replaces it later. `splash`
|
|
@@ -685,7 +702,7 @@ final class CraftChrome: ObservableObject {
|
|
|
685
702
|
/// The bar's height as `--craft-tab-bar-height`, so the page leaves room
|
|
686
703
|
/// for it, and as an event for anything that measures.
|
|
687
704
|
func publishLayout() {
|
|
688
|
-
let height = tabBarVisible ? Int(occupied) : 0
|
|
705
|
+
let height = tabBarVisible && !keyboardUp ? Int(occupied) : 0
|
|
689
706
|
CraftHybrid.shared.tabBarCovers(CGFloat(height))
|
|
690
707
|
let script = "document.documentElement.style.setProperty('--craft-tab-bar-height','\(height)px');window.dispatchEvent(new CustomEvent('craftTabBarLayout',{detail:{height:\(height)}}));"
|
|
691
708
|
DispatchQueue.main.async { [weak self] in self?.webView?.evaluateJavaScript(script, completionHandler: nil) }
|
|
@@ -2208,7 +2225,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2208
2225
|
// MARK: - Social Auth
|
|
2209
2226
|
case "signInWithApple":
|
|
2210
2227
|
if config.enableSocialAuth {
|
|
2211
|
-
signInWithApple(callbackId: callbackId)
|
|
2228
|
+
signInWithApple(nonce: body["nonce"] as? String, callbackId: callbackId)
|
|
2212
2229
|
} else {
|
|
2213
2230
|
rejectCallback(callbackId, error: "Social sign-in is disabled", code: "CAPABILITY_DISABLED")
|
|
2214
2231
|
}
|
|
@@ -3567,8 +3584,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3567
3584
|
restorePurchases: function() {
|
|
3568
3585
|
return this._invoke('restorePurchases');
|
|
3569
3586
|
},
|
|
3570
|
-
|
|
3571
|
-
|
|
3587
|
+
// `nonce` is the SHA-256 of a one-time value the page keeps;
|
|
3588
|
+
// Apple writes it into the identity token, so the server can
|
|
3589
|
+
// tie the sign-in to that page. The answer carries Apple's
|
|
3590
|
+
// one-use authorizationCode for the server to redeem.
|
|
3591
|
+
signInWithApple: function(options) {
|
|
3592
|
+
var nonce = options && typeof options.nonce === 'string' ? options.nonce : undefined;
|
|
3593
|
+
return this._invoke('signInWithApple', nonce ? {nonce: nonce} : {});
|
|
3572
3594
|
},
|
|
3573
3595
|
signInWithGoogle: function() {
|
|
3574
3596
|
return Promise.reject(new Error('Google Sign-In is unavailable on iOS'));
|
|
@@ -6588,12 +6610,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6588
6610
|
}
|
|
6589
6611
|
|
|
6590
6612
|
// MARK: - Social Auth (Apple Sign In)
|
|
6591
|
-
private func signInWithApple(callbackId: String?) {
|
|
6613
|
+
private func signInWithApple(nonce: String?, callbackId: String?) {
|
|
6592
6614
|
DispatchQueue.main.async {
|
|
6593
6615
|
self.pendingCallbackId = callbackId
|
|
6594
6616
|
let provider = ASAuthorizationAppleIDProvider()
|
|
6595
6617
|
let request = provider.createRequest()
|
|
6596
6618
|
request.requestedScopes = [.fullName, .email]
|
|
6619
|
+
if let nonce, !nonce.isEmpty { request.nonce = nonce }
|
|
6597
6620
|
|
|
6598
6621
|
let controller = ASAuthorizationController(authorizationRequests: [request])
|
|
6599
6622
|
controller.delegate = self
|
|
@@ -8216,6 +8239,12 @@ extension CraftWebView.Coordinator: ASAuthorizationControllerDelegate, ASAuthori
|
|
|
8216
8239
|
let tokenString = String(data: identityToken, encoding: .utf8) {
|
|
8217
8240
|
result["identityToken"] = tokenString
|
|
8218
8241
|
}
|
|
8242
|
+
// One use, five minutes: the server redeems it at Apple's token
|
|
8243
|
+
// endpoint and so reads the identity straight from Apple.
|
|
8244
|
+
if let authorizationCode = appleIDCredential.authorizationCode,
|
|
8245
|
+
let codeString = String(data: authorizationCode, encoding: .utf8) {
|
|
8246
|
+
result["authorizationCode"] = codeString
|
|
8247
|
+
}
|
|
8219
8248
|
|
|
8220
8249
|
resolveCallback(pendingCallbackId, result: result)
|
|
8221
8250
|
}
|
|
@@ -539,6 +539,14 @@ final class CraftHybrid: NSObject {
|
|
|
539
539
|
}, function () { arriving = null; finish(); });
|
|
540
540
|
});
|
|
541
541
|
},
|
|
542
|
+
// Put the page at `path` in place of where it is, without a step in
|
|
543
|
+
// its history: the shell is back at a native screen and the page
|
|
544
|
+
// behind it had no history left to go back through (it reloaded).
|
|
545
|
+
reset: function (path) {
|
|
546
|
+
if (!(navigate && current && current.__craftHybrid)) { location.replace(path); return Promise.resolve(false); }
|
|
547
|
+
hostNav = path;
|
|
548
|
+
return Promise.resolve(navigate(path, { instant: true, replace: true }));
|
|
549
|
+
},
|
|
542
550
|
setBase: function (value) { base = typeof value === 'number' ? value : null; },
|
|
543
551
|
// Back to `target` in the page's stack, after the shell popped the
|
|
544
552
|
// entries above it.
|
|
@@ -667,6 +675,11 @@ final class CraftHybrid: NSObject {
|
|
|
667
675
|
webView?.evaluateJavaScript("window.__craftHybrid && window.__craftHybrid.setBase(\(value))", completionHandler: nil)
|
|
668
676
|
}
|
|
669
677
|
|
|
678
|
+
func pageReset(to path: String) {
|
|
679
|
+
guard let data = try? JSONSerialization.data(withJSONObject: [path]), let json = String(data: data, encoding: .utf8) else { return }
|
|
680
|
+
webView?.evaluateJavaScript("window.__craftHybrid && window.__craftHybrid.reset && window.__craftHybrid.reset(\(json)[0])", completionHandler: nil)
|
|
681
|
+
}
|
|
682
|
+
|
|
670
683
|
func pageBack(to depth: Int) {
|
|
671
684
|
webView?.evaluateJavaScript("window.__craftHybrid && window.__craftHybrid.backTo(\(max(0, depth)))", completionHandler: nil)
|
|
672
685
|
}
|
|
@@ -752,7 +765,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
752
765
|
|
|
753
766
|
init(config: CraftConfig) {
|
|
754
767
|
self.config = config
|
|
755
|
-
|
|
768
|
+
// Edge to edge, but not under the keyboard: the web view ends at its
|
|
769
|
+
// top. Under it, WebKit scrolled the page's layout viewport to show a
|
|
770
|
+
// focused field and carried every sticky header off the screen.
|
|
771
|
+
webHost = UIHostingController(rootView: AnyView(CraftWebView(config: config).ignoresSafeArea(.container)))
|
|
756
772
|
super.init(nibName: nil, bundle: nil)
|
|
757
773
|
hybrid.controller = self
|
|
758
774
|
}
|
|
@@ -784,7 +800,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
784
800
|
guard !webAttached else { return }
|
|
785
801
|
webAttached = true
|
|
786
802
|
webHost.view.backgroundColor = config.resolvedBackgroundColor
|
|
787
|
-
if #available(iOS 16.4, *) { webHost.safeAreaRegions = [] }
|
|
803
|
+
if #available(iOS 16.4, *) { webHost.safeAreaRegions = [.keyboard] }
|
|
788
804
|
if let slot = visibleStack?.topViewController as? CraftHybridWebSlot, slot.isViewLoaded {
|
|
789
805
|
moveWeb(to: slot, in: slot.view)
|
|
790
806
|
} else {
|
|
@@ -1109,6 +1125,16 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1109
1125
|
if let depth = removed.compactMap({ ($0 as? CraftHybridWebSlot)?.depth }).min() {
|
|
1110
1126
|
hybrid.pageBack(to: depth - 1)
|
|
1111
1127
|
}
|
|
1128
|
+
// Back at a tab's native root while the page, with no history left
|
|
1129
|
+
// to go back through (it reloaded on the screen just popped), still
|
|
1130
|
+
// shows that screen: it moves to the root's path. Left there, the
|
|
1131
|
+
// next screen opened from the root was pushed on top of it and named
|
|
1132
|
+
// it in its Back button ("Me" instead of "Today").
|
|
1133
|
+
if !removed.isEmpty, !(viewController is CraftHybridWebSlot), navigationController.viewControllers.first === viewController,
|
|
1134
|
+
let rootPath = nativePaths[ObjectIdentifier(viewController)], !rootPath.contains(":"), hybrid.pageDepth == 0,
|
|
1135
|
+
let pagePath = hybrid.pagePath, CraftHybridRoutes.normalize(pagePath) != CraftHybridRoutes.normalize(rootPath) {
|
|
1136
|
+
hybrid.pageReset(to: rootPath)
|
|
1137
|
+
}
|
|
1112
1138
|
guard navigationController === visibleStack else { return }
|
|
1113
1139
|
if let slot = viewController as? CraftHybridWebSlot { adoptWeb(into: slot) } else { parkWeb() }
|
|
1114
1140
|
}
|
|
@@ -1127,6 +1127,249 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
1127
1127
|
|
|
1128
1128
|
/// Text with padding of its own: the padding is inside the label, around
|
|
1129
1129
|
/// the text, and counts in its measured size.
|
|
1130
|
+
/// A vector drawing (stx `svg.ts`): an `Svg` node and the shapes below it,
|
|
1131
|
+
/// replayed from each shape's `geometry` commands - 0 move, 1 line, 2 cubic,
|
|
1132
|
+
/// 3 quadratic, 4 close, in viewBox units - and scaled to the view. Paint is
|
|
1133
|
+
/// inherited down the tree as SVG does; `dark:` classes arrive already
|
|
1134
|
+
/// resolved for the appearance, so a redraw is all a theme change needs.
|
|
1135
|
+
private final class CraftNativeSvgView: UIView {
|
|
1136
|
+
var drawing: [String: Any] = [:] {
|
|
1137
|
+
didSet { setNeedsDisplay() }
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
override init(frame: CGRect) {
|
|
1141
|
+
super.init(frame: frame)
|
|
1142
|
+
isOpaque = false
|
|
1143
|
+
backgroundColor = .clear
|
|
1144
|
+
contentMode = .redraw
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
1148
|
+
|
|
1149
|
+
var viewBox: CGRect? {
|
|
1150
|
+
let props = drawing["props"] as? [String: Any] ?? [:]
|
|
1151
|
+
guard let box = props["viewBox"] as? [Any], box.count == 4 else { return nil }
|
|
1152
|
+
let n = box.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } }
|
|
1153
|
+
guard n.count == 4, n[2] > 0, n[3] > 0 else { return nil }
|
|
1154
|
+
return CGRect(x: n[0], y: n[1], width: n[2], height: n[3])
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
override var intrinsicContentSize: CGSize { CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric) }
|
|
1158
|
+
|
|
1159
|
+
/// Height from the viewBox's proportions, when no style gave one.
|
|
1160
|
+
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
|
1161
|
+
guard let box = viewBox, size.width > 0, size.width < CraftNativeFlexLayout.unbounded else { return CGSize(width: 0, height: 0) }
|
|
1162
|
+
return CGSize(width: size.width, height: size.width * box.height / box.width)
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
private struct Paint {
|
|
1166
|
+
var fill: String? = "#000000"
|
|
1167
|
+
var stroke: String?
|
|
1168
|
+
var strokeWidth: CGFloat = 1
|
|
1169
|
+
var color: String? = "#000000"
|
|
1170
|
+
var opacity: CGFloat = 1
|
|
1171
|
+
var linecap: String?
|
|
1172
|
+
var linejoin: String?
|
|
1173
|
+
var dashes: [CGFloat]?
|
|
1174
|
+
|
|
1175
|
+
func merged(_ node: [String: Any]) -> Paint {
|
|
1176
|
+
var next = self
|
|
1177
|
+
let props = node["props"] as? [String: Any] ?? [:]
|
|
1178
|
+
let style = node["style"] as? [String: Any] ?? [:]
|
|
1179
|
+
if let color = style["color"] as? String { next.color = color }
|
|
1180
|
+
if let fill = style["fill"] as? String ?? props["fill"] as? String { next.fill = fill }
|
|
1181
|
+
if let stroke = style["stroke"] as? String ?? props["stroke"] as? String { next.stroke = stroke }
|
|
1182
|
+
if let width = (style["strokeWidth"] as? NSNumber ?? props["strokeWidth"] as? NSNumber) { next.strokeWidth = CGFloat(truncating: width) }
|
|
1183
|
+
if let opacity = props["opacity"] as? NSNumber ?? style["opacity"] as? NSNumber { next.opacity *= CGFloat(truncating: opacity) }
|
|
1184
|
+
if let cap = props["strokeLinecap"] as? String { next.linecap = cap }
|
|
1185
|
+
if let join = props["strokeLinejoin"] as? String { next.linejoin = join }
|
|
1186
|
+
if let dashes = props["strokeDasharray"] as? [Any] { next.dashes = dashes.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } } }
|
|
1187
|
+
return next
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
func resolve(_ value: String?) -> UIColor? {
|
|
1191
|
+
guard let value, value != "none", value != "transparent" else { return nil }
|
|
1192
|
+
let text = value == "currentColor" ? (color ?? "#000000") : value
|
|
1193
|
+
return UIColor(css: text) ?? UIColor(hex: text)
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
override func draw(_ rect: CGRect) {
|
|
1198
|
+
guard let context = UIGraphicsGetCurrentContext() else { return }
|
|
1199
|
+
if let box = viewBox {
|
|
1200
|
+
let scale = min(bounds.width / box.width, bounds.height / box.height)
|
|
1201
|
+
guard scale.isFinite, scale > 0 else { return }
|
|
1202
|
+
context.translateBy(x: (bounds.width - box.width * scale) / 2, y: (bounds.height - box.height * scale) / 2)
|
|
1203
|
+
context.scaleBy(x: scale, y: scale)
|
|
1204
|
+
context.translateBy(x: -box.minX, y: -box.minY)
|
|
1205
|
+
}
|
|
1206
|
+
draw(children: drawing["children"] as? [Any] ?? [], paint: Paint().merged(drawing), in: context)
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
private func draw(children: [Any], paint: Paint, in context: CGContext) {
|
|
1210
|
+
for case let shape as [String: Any] in children {
|
|
1211
|
+
let style = shape["style"] as? [String: Any] ?? [:]
|
|
1212
|
+
if style["display"] as? String == "none" { continue }
|
|
1213
|
+
let own = paint.merged(shape)
|
|
1214
|
+
switch shape["type"] as? String {
|
|
1215
|
+
case "G":
|
|
1216
|
+
draw(children: shape["children"] as? [Any] ?? [], paint: own, in: context)
|
|
1217
|
+
case "SvgText":
|
|
1218
|
+
drawText(shape, paint: own)
|
|
1219
|
+
default:
|
|
1220
|
+
guard let path = CraftNativeSvgView.path((shape["props"] as? [String: Any])?["geometry"]) else { continue }
|
|
1221
|
+
context.saveGState()
|
|
1222
|
+
context.setAlpha(own.opacity)
|
|
1223
|
+
if let fill = own.resolve(own.fill) {
|
|
1224
|
+
fill.setFill()
|
|
1225
|
+
path.fill()
|
|
1226
|
+
}
|
|
1227
|
+
if let stroke = own.resolve(own.stroke), own.strokeWidth > 0 {
|
|
1228
|
+
stroke.setStroke()
|
|
1229
|
+
path.lineWidth = own.strokeWidth
|
|
1230
|
+
path.lineCapStyle = own.linecap == "round" ? .round : own.linecap == "square" ? .square : .butt
|
|
1231
|
+
path.lineJoinStyle = own.linejoin == "round" ? .round : own.linejoin == "bevel" ? .bevel : .miter
|
|
1232
|
+
if let dashes = own.dashes, !dashes.isEmpty { path.setLineDash(dashes, count: dashes.count, phase: 0) }
|
|
1233
|
+
path.stroke()
|
|
1234
|
+
}
|
|
1235
|
+
context.restoreGState()
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
private func drawText(_ node: [String: Any], paint: Paint) {
|
|
1241
|
+
let text = (node["children"] as? [Any] ?? []).compactMap { $0 as? String }.joined()
|
|
1242
|
+
guard !text.isEmpty else { return }
|
|
1243
|
+
let props = node["props"] as? [String: Any] ?? [:]
|
|
1244
|
+
let style = node["style"] as? [String: Any] ?? [:]
|
|
1245
|
+
let size = CGFloat(truncating: props["fontSize"] as? NSNumber ?? style["fontSize"] as? NSNumber ?? 12)
|
|
1246
|
+
let weight: UIFont.Weight = (props["fontWeight"] as? String ?? style["fontWeight"] as? String).map { ["500": .medium, "600": .semibold, "700": .bold, "bold": .bold][$0] ?? .regular } ?? .regular
|
|
1247
|
+
let string = NSAttributedString(string: text, attributes: [
|
|
1248
|
+
.font: UIFont.systemFont(ofSize: size, weight: weight),
|
|
1249
|
+
.foregroundColor: paint.resolve(paint.fill) ?? UIColor.label,
|
|
1250
|
+
])
|
|
1251
|
+
var x = CGFloat(truncating: props["x"] as? NSNumber ?? 0)
|
|
1252
|
+
let y = CGFloat(truncating: props["y"] as? NSNumber ?? 0)
|
|
1253
|
+
switch props["textAnchor"] as? String {
|
|
1254
|
+
case "middle": x -= string.size().width / 2
|
|
1255
|
+
case "end": x -= string.size().width
|
|
1256
|
+
default: break
|
|
1257
|
+
}
|
|
1258
|
+
// y is the baseline, as in SVG.
|
|
1259
|
+
string.draw(at: CGPoint(x: x, y: y - UIFont.systemFont(ofSize: size).ascender))
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
static func path(_ value: Any?) -> UIBezierPath? {
|
|
1263
|
+
guard let list = value as? [Any] else { return nil }
|
|
1264
|
+
let n = list.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } }
|
|
1265
|
+
guard !n.isEmpty, n.count == list.count else { return nil }
|
|
1266
|
+
let path = UIBezierPath()
|
|
1267
|
+
var i = 0
|
|
1268
|
+
func point(_ at: Int) -> CGPoint { CGPoint(x: n[at], y: n[at + 1]) }
|
|
1269
|
+
while i < n.count {
|
|
1270
|
+
switch Int(n[i]) {
|
|
1271
|
+
case 0 where i + 2 < n.count: path.move(to: point(i + 1)); i += 3
|
|
1272
|
+
case 1 where i + 2 < n.count: path.addLine(to: point(i + 1)); i += 3
|
|
1273
|
+
case 2 where i + 6 < n.count: path.addCurve(to: point(i + 5), controlPoint1: point(i + 1), controlPoint2: point(i + 3)); i += 7
|
|
1274
|
+
case 3 where i + 4 < n.count: path.addQuadCurve(to: point(i + 3), controlPoint: point(i + 1)); i += 5
|
|
1275
|
+
case 4: path.close(); i += 1
|
|
1276
|
+
default: return path
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
return path
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/// A view's linear gradient (stx `bg-gradient-to-br from-* via-* to-*`), kept
|
|
1284
|
+
/// as its first sublayer - under its subviews - and following its bounds and
|
|
1285
|
+
/// corners as they change.
|
|
1286
|
+
private final class CraftNativeGradientLayer: CAGradientLayer {
|
|
1287
|
+
private var observations: [NSKeyValueObservation] = []
|
|
1288
|
+
|
|
1289
|
+
static func apply(to view: UIView, direction: String?, colors: [UIColor?]) {
|
|
1290
|
+
let existing = view.layer.sublayers?.first as? CraftNativeGradientLayer
|
|
1291
|
+
guard let direction, let from = colors[0], let to = colors[2] else {
|
|
1292
|
+
existing?.removeFromSuperlayer()
|
|
1293
|
+
return
|
|
1294
|
+
}
|
|
1295
|
+
let layer = existing ?? CraftNativeGradientLayer()
|
|
1296
|
+
layer.colors = [from, colors[1], to].compactMap { $0?.cgColor }
|
|
1297
|
+
let points: (CGPoint, CGPoint)
|
|
1298
|
+
switch direction {
|
|
1299
|
+
case "to-t": points = (CGPoint(x: 0.5, y: 1), CGPoint(x: 0.5, y: 0))
|
|
1300
|
+
case "to-tr": points = (CGPoint(x: 0, y: 1), CGPoint(x: 1, y: 0))
|
|
1301
|
+
case "to-r": points = (CGPoint(x: 0, y: 0.5), CGPoint(x: 1, y: 0.5))
|
|
1302
|
+
case "to-br": points = (CGPoint(x: 0, y: 0), CGPoint(x: 1, y: 1))
|
|
1303
|
+
case "to-bl": points = (CGPoint(x: 1, y: 0), CGPoint(x: 0, y: 1))
|
|
1304
|
+
case "to-l": points = (CGPoint(x: 1, y: 0.5), CGPoint(x: 0, y: 0.5))
|
|
1305
|
+
case "to-tl": points = (CGPoint(x: 1, y: 1), CGPoint(x: 0, y: 0))
|
|
1306
|
+
default: points = (CGPoint(x: 0.5, y: 0), CGPoint(x: 0.5, y: 1))
|
|
1307
|
+
}
|
|
1308
|
+
layer.startPoint = points.0
|
|
1309
|
+
layer.endPoint = points.1
|
|
1310
|
+
layer.follow(view)
|
|
1311
|
+
guard existing == nil else { return }
|
|
1312
|
+
view.layer.insertSublayer(layer, at: 0)
|
|
1313
|
+
layer.observations = [
|
|
1314
|
+
view.observe(\.bounds) { [weak layer] view, _ in layer?.follow(view) },
|
|
1315
|
+
view.layer.observe(\.cornerRadius) { [weak layer, weak view] _, _ in if let view { layer?.follow(view) } },
|
|
1316
|
+
]
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
private func follow(_ view: UIView) {
|
|
1320
|
+
CATransaction.begin()
|
|
1321
|
+
CATransaction.setDisableActions(true)
|
|
1322
|
+
frame = view.bounds
|
|
1323
|
+
cornerRadius = view.layer.cornerRadius
|
|
1324
|
+
maskedCorners = view.layer.maskedCorners
|
|
1325
|
+
masksToBounds = cornerRadius > 0
|
|
1326
|
+
CATransaction.commit()
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/// A button that keeps the padding its style asks for (`px-4 py-2`). A bare
|
|
1331
|
+
/// UIButton sizes to its title and ignores it, so a styled button drew as a
|
|
1332
|
+
/// pill with its label touching the edges. The padding is added to its size
|
|
1333
|
+
/// and the label laid out inside it, at the alignment the style asked for.
|
|
1334
|
+
private final class CraftNativeButton: UIButton {
|
|
1335
|
+
var padding: UIEdgeInsets = .zero {
|
|
1336
|
+
didSet {
|
|
1337
|
+
guard padding != oldValue else { return }
|
|
1338
|
+
invalidateIntrinsicContentSize()
|
|
1339
|
+
setNeedsLayout()
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
override var intrinsicContentSize: CGSize {
|
|
1344
|
+
let size = super.intrinsicContentSize
|
|
1345
|
+
return CGSize(width: size.width + padding.left + padding.right, height: size.height + padding.top + padding.bottom)
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
|
1349
|
+
let inner = super.sizeThatFits(CGSize(
|
|
1350
|
+
width: max(0, size.width - padding.left - padding.right),
|
|
1351
|
+
height: max(0, size.height - padding.top - padding.bottom)
|
|
1352
|
+
))
|
|
1353
|
+
return CGSize(width: inner.width + padding.left + padding.right, height: inner.height + padding.top + padding.bottom)
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
override func layoutSubviews() {
|
|
1357
|
+
super.layoutSubviews()
|
|
1358
|
+
guard padding != .zero, let label = titleLabel else { return }
|
|
1359
|
+
let area = bounds.inset(by: padding)
|
|
1360
|
+
let fitted = label.sizeThatFits(area.size)
|
|
1361
|
+
let width = min(ceil(fitted.width), area.width)
|
|
1362
|
+
let height = min(ceil(fitted.height), area.height)
|
|
1363
|
+
let x: CGFloat
|
|
1364
|
+
switch contentHorizontalAlignment {
|
|
1365
|
+
case .left, .leading: x = area.minX
|
|
1366
|
+
case .right, .trailing: x = area.maxX - width
|
|
1367
|
+
default: x = area.midX - width / 2
|
|
1368
|
+
}
|
|
1369
|
+
label.frame = CGRect(x: x, y: area.midY - height / 2, width: width, height: height)
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1130
1373
|
private final class CraftNativeLabel: UILabel {
|
|
1131
1374
|
var insets = UIEdgeInsets.zero {
|
|
1132
1375
|
didSet {
|
|
@@ -1283,6 +1526,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1283
1526
|
private weak var lastFocusedInput: UIView?
|
|
1284
1527
|
private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
|
|
1285
1528
|
private var flatListOwners: [String: String] = [:]
|
|
1529
|
+
/// A drawing's shapes are not views: a change to one redraws its `Svg`.
|
|
1530
|
+
private var svgOwners: [String: String] = [:]
|
|
1286
1531
|
private let mutationDocument = CraftNativeMutationDocument()
|
|
1287
1532
|
private let capabilityScope = UUID().uuidString
|
|
1288
1533
|
private var pendingCapabilityRequests = Set<String>()
|
|
@@ -1778,13 +2023,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1778
2023
|
button.addAction(action, for: .touchUpInside)
|
|
1779
2024
|
button.translatesAutoresizingMaskIntoConstraints = false
|
|
1780
2025
|
NSLayoutConstraint.activate([button.widthAnchor.constraint(equalToConstant: 44), button.heightAnchor.constraint(equalToConstant: 44)])
|
|
2026
|
+
// The system's own fills and blue, as Apple's apps draw a bar's round
|
|
2027
|
+
// buttons: they follow light, dark and Increase Contrast by themselves.
|
|
1781
2028
|
let accent = spec["variant"] as? String == "accent"
|
|
1782
|
-
button.backgroundColor = UIColor
|
|
1783
|
-
if traits.userInterfaceStyle == .dark { return UIColor(red: 0.15, green: 0.39, blue: 0.92, alpha: accent ? 0.24 : 0.12) }
|
|
1784
|
-
return UIColor(red: 0.15, green: 0.39, blue: 0.92, alpha: 0.08)
|
|
1785
|
-
}
|
|
2029
|
+
button.backgroundColor = accent ? UIColor.systemBlue.withAlphaComponent(0.15) : .tertiarySystemFill
|
|
1786
2030
|
button.layer.cornerRadius = 22
|
|
1787
|
-
button.tintColor =
|
|
2031
|
+
button.tintColor = .systemBlue
|
|
1788
2032
|
button.setTitleColor(button.tintColor, for: .normal)
|
|
1789
2033
|
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
1790
2034
|
if let title = spec["title"] as? String, !title.isEmpty { button.setTitle(title, for: .normal) }
|
|
@@ -2118,7 +2362,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2118
2362
|
let focused = firstResponder(in: rootStack) ?? lastFocusedInput
|
|
2119
2363
|
var applied = Set<String>()
|
|
2120
2364
|
for id in ids {
|
|
2121
|
-
let target = flatListOwners[id] ?? id
|
|
2365
|
+
let target = flatListOwners[id] ?? svgOwners[id] ?? id
|
|
2122
2366
|
guard applied.insert(target).inserted else { continue }
|
|
2123
2367
|
guard let document = mutationDocument.node(target), let previous = renderedNode(target) else { return false }
|
|
2124
2368
|
let next = reconcile(document, identity: previous.identity, path: target, previous: previous)
|
|
@@ -2228,6 +2472,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2228
2472
|
attributes: buttonTitleAttributes(style, color: titleColor, font: font, forceUnderline: type == "Link")
|
|
2229
2473
|
), for: .normal)
|
|
2230
2474
|
button.contentHorizontalAlignment = buttonAlignment(style["textAlign"])
|
|
2475
|
+
(button as? CraftNativeButton)?.padding = paddingInsets(style)
|
|
2231
2476
|
button.isEnabled = props["disabled"] as? Bool != true
|
|
2232
2477
|
updateHandler(nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: button)
|
|
2233
2478
|
case "TextInput":
|
|
@@ -2388,6 +2633,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2388
2633
|
} else {
|
|
2389
2634
|
indicator.startAnimating()
|
|
2390
2635
|
}
|
|
2636
|
+
case "Svg":
|
|
2637
|
+
let drawing = result as! CraftNativeSvgView
|
|
2638
|
+
let owner = current.protocolId ?? path
|
|
2639
|
+
svgOwners = svgOwners.filter { $0.value != owner }
|
|
2640
|
+
for child in children { registerSvgOwnership(child, owner: owner) }
|
|
2641
|
+
let previousBox = drawing.viewBox
|
|
2642
|
+
drawing.drawing = node
|
|
2643
|
+
if drawing.viewBox != previousBox { CraftNativeFlexLayout.invalidateAncestors(of: drawing) }
|
|
2644
|
+
current.children = []
|
|
2391
2645
|
case "Icon":
|
|
2392
2646
|
configureIcon(result as! UIImageView, props: props, style: style)
|
|
2393
2647
|
case "Image":
|
|
@@ -2480,7 +2734,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2480
2734
|
label.numberOfLines = 0
|
|
2481
2735
|
return label
|
|
2482
2736
|
case "Button", "Link":
|
|
2483
|
-
let button =
|
|
2737
|
+
let button = CraftNativeButton(type: .system)
|
|
2484
2738
|
button.addTarget(self, action: #selector(buttonPressed(_:)), for: .touchUpInside)
|
|
2485
2739
|
return button
|
|
2486
2740
|
case "TextInput":
|
|
@@ -2524,6 +2778,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2524
2778
|
return CraftNativeScrollView()
|
|
2525
2779
|
case "FlatList":
|
|
2526
2780
|
return CraftNativeFlatList()
|
|
2781
|
+
case "Svg":
|
|
2782
|
+
return CraftNativeSvgView()
|
|
2527
2783
|
case "View", "SafeAreaView":
|
|
2528
2784
|
return CraftNativeFlowView()
|
|
2529
2785
|
case "TouchableOpacity", "TouchableHighlight", "Pressable":
|
|
@@ -2689,6 +2945,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2689
2945
|
}
|
|
2690
2946
|
}
|
|
2691
2947
|
|
|
2948
|
+
private func registerSvgOwnership(_ node: Any, owner: String) {
|
|
2949
|
+
guard let node = node as? [String: Any] else { return }
|
|
2950
|
+
if let id = node["id"] as? String, !id.isEmpty { svgOwners[id] = owner }
|
|
2951
|
+
for child in node["children"] as? [Any] ?? [] { registerSvgOwnership(child, owner: owner) }
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2692
2954
|
private func detach(_ node: RenderedNode, from stack: CraftNativeFlowView) {
|
|
2693
2955
|
forgetHandlers(node)
|
|
2694
2956
|
if node.type == "Modal" {
|
|
@@ -2709,6 +2971,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2709
2971
|
flatListRows.removeValue(forKey: listKey)
|
|
2710
2972
|
if let owner = node.protocolId { flatListOwners = flatListOwners.filter { $0.value != owner } }
|
|
2711
2973
|
}
|
|
2974
|
+
if node.view is CraftNativeSvgView, let owner = node.protocolId { svgOwners = svgOwners.filter { $0.value != owner } }
|
|
2712
2975
|
let id = ObjectIdentifier(node.view)
|
|
2713
2976
|
if let modal = node.view as? CraftNativeModalView { modal.resetForTeardown() }
|
|
2714
2977
|
handlers.removeValue(forKey: id)
|
|
@@ -3019,6 +3282,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3019
3282
|
updateDimension(width, constraint: &node.widthConstraint, anchor: view.widthAnchor)
|
|
3020
3283
|
updateDimension(height, constraint: &node.heightConstraint, anchor: view.heightAnchor)
|
|
3021
3284
|
view.backgroundColor = color(style["backgroundColor"]) ?? .clear
|
|
3285
|
+
CraftNativeGradientLayer.apply(
|
|
3286
|
+
to: view,
|
|
3287
|
+
direction: style["gradientDirection"] as? String,
|
|
3288
|
+
colors: [color(style["gradientFrom"]), color(style["gradientVia"]), color(style["gradientTo"])]
|
|
3289
|
+
)
|
|
3022
3290
|
view.alpha = number(style["opacity"]) ?? 1
|
|
3023
3291
|
view.isHidden = style["display"] as? String == "none"
|
|
3024
3292
|
view.craftRequestedCornerRadius = number(style["borderRadius"])
|