craft-native 0.0.145 → 0.0.147
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/android/templates/MainActivityNative.kt.template +33 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.js +1 -0
- package/dist/ios/templates/CraftApp.swift +56 -0
- package/dist/ios/templates/CraftHybrid.swift +26 -5
- package/dist/ios/templates/CraftMusic.swift +16 -5
- package/dist/ios/templates/CraftNativeActions.swift +137 -1
- package/dist/ios/templates/CraftNativeScreen.swift +506 -39
- package/dist/ios/templates/CraftNativeServices.swift +567 -0
- package/package.json +1 -1
|
@@ -226,7 +226,15 @@ private final class CraftNativeVideoView: UIView {
|
|
|
226
226
|
private var player: AVPlayer?
|
|
227
227
|
private var sourceURI = ""
|
|
228
228
|
private var endObserver: NSObjectProtocol?
|
|
229
|
+
private var statusObservation: NSKeyValueObservation?
|
|
230
|
+
private var playingObservation: NSKeyValueObservation?
|
|
231
|
+
private var timeObserver: Any?
|
|
229
232
|
private var loops = false
|
|
233
|
+
private var wasPlaying: Bool?
|
|
234
|
+
/// The screen's handlers by event name (`onEnd`, `onPlay`, `onPause`,
|
|
235
|
+
/// `onProgress`, `onLoad`, `onError`), and how an event reaches them.
|
|
236
|
+
var handlers: [String: String] = [:]
|
|
237
|
+
var emit: ((_ handler: String, _ nativeEvent: [String: Any]) -> Void)?
|
|
230
238
|
|
|
231
239
|
init(host: UIViewController) {
|
|
232
240
|
super.init(frame: .zero)
|
|
@@ -247,13 +255,35 @@ private final class CraftNativeVideoView: UIView {
|
|
|
247
255
|
playerController.view.frame = bounds
|
|
248
256
|
}
|
|
249
257
|
|
|
258
|
+
private func fire(_ event: String, _ nativeEvent: [String: Any] = [:]) {
|
|
259
|
+
guard let handler = handlers[event], !handler.isEmpty else { return }
|
|
260
|
+
emit?(handler, nativeEvent)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private func seconds(_ time: CMTime?) -> Double {
|
|
264
|
+
guard let time, time.isNumeric else { return 0 }
|
|
265
|
+
let value = CMTimeGetSeconds(time)
|
|
266
|
+
return value.isFinite ? value : 0
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private func detachPlayer() {
|
|
270
|
+
if let observer = endObserver { NotificationCenter.default.removeObserver(observer) }
|
|
271
|
+
endObserver = nil
|
|
272
|
+
statusObservation?.invalidate()
|
|
273
|
+
statusObservation = nil
|
|
274
|
+
playingObservation?.invalidate()
|
|
275
|
+
playingObservation = nil
|
|
276
|
+
if let timeObserver, let player { player.removeTimeObserver(timeObserver) }
|
|
277
|
+
timeObserver = nil
|
|
278
|
+
wasPlaying = nil
|
|
279
|
+
player?.pause()
|
|
280
|
+
player = nil
|
|
281
|
+
}
|
|
282
|
+
|
|
250
283
|
func configure(source: Any?, autoplay: Bool, paused: Bool, loop: Bool, muted: Bool, controls: Bool, resizeMode: String?) {
|
|
251
284
|
let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String ?? ""
|
|
252
285
|
if uri != sourceURI {
|
|
253
|
-
|
|
254
|
-
endObserver = nil
|
|
255
|
-
player?.pause()
|
|
256
|
-
player = nil
|
|
286
|
+
detachPlayer()
|
|
257
287
|
sourceURI = uri
|
|
258
288
|
loops = false
|
|
259
289
|
guard let url = URL(string: uri), !uri.isEmpty, url.scheme != nil else {
|
|
@@ -263,6 +293,7 @@ private final class CraftNativeVideoView: UIView {
|
|
|
263
293
|
let next = AVPlayer(url: url)
|
|
264
294
|
player = next
|
|
265
295
|
playerController.player = next
|
|
296
|
+
observe(next)
|
|
266
297
|
}
|
|
267
298
|
guard let player else { return }
|
|
268
299
|
player.isMuted = muted
|
|
@@ -270,28 +301,53 @@ private final class CraftNativeVideoView: UIView {
|
|
|
270
301
|
playerController.videoGravity = resizeMode == "cover" ? .resizeAspectFill
|
|
271
302
|
: resizeMode == "stretch" ? .resize
|
|
272
303
|
: .resizeAspect
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
player?.
|
|
304
|
+
loops = loop
|
|
305
|
+
if paused || !autoplay { player.pause() } else { player.play() }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/// Load, play and pause, time, the end and failure, as the web's video
|
|
309
|
+
/// element reports them, so `@ended` and `@timeupdate` work as on the page.
|
|
310
|
+
private func observe(_ player: AVPlayer) {
|
|
311
|
+
if let item = player.currentItem {
|
|
312
|
+
statusObservation = item.observe(\.status, options: [.new]) { [weak self] item, _ in
|
|
313
|
+
DispatchQueue.main.async {
|
|
314
|
+
guard let self, self.player?.currentItem === item else { return }
|
|
315
|
+
switch item.status {
|
|
316
|
+
case .readyToPlay: self.fire("onLoad", ["duration": self.seconds(item.duration)])
|
|
317
|
+
case .failed: self.fire("onError", ["message": item.error?.localizedDescription ?? "The video could not be played"])
|
|
318
|
+
default: break
|
|
319
|
+
}
|
|
284
320
|
}
|
|
285
321
|
}
|
|
322
|
+
endObserver = NotificationCenter.default.addObserver(
|
|
323
|
+
forName: .AVPlayerItemDidPlayToEndTime,
|
|
324
|
+
object: item,
|
|
325
|
+
queue: .main
|
|
326
|
+
) { [weak self, weak player] _ in
|
|
327
|
+
guard let self else { return }
|
|
328
|
+
self.fire("onEnd")
|
|
329
|
+
if self.loops { player?.seek(to: .zero) { _ in player?.play() } }
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
playingObservation = player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
|
|
333
|
+
DispatchQueue.main.async {
|
|
334
|
+
guard let self, self.player === player, player.timeControlStatus != .waitingToPlayAtSpecifiedRate else { return }
|
|
335
|
+
let playing = player.timeControlStatus == .playing
|
|
336
|
+
guard playing != self.wasPlaying else { return }
|
|
337
|
+
// Paused before it ever played is where it starts, not news.
|
|
338
|
+
if self.wasPlaying == nil && !playing { self.wasPlaying = false; return }
|
|
339
|
+
self.wasPlaying = playing
|
|
340
|
+
self.fire(playing ? "onPlay" : "onPause")
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main) { [weak self, weak player] time in
|
|
344
|
+
guard let self, let player, player.timeControlStatus == .playing else { return }
|
|
345
|
+
self.fire("onProgress", ["currentTime": self.seconds(time), "duration": self.seconds(player.currentItem?.duration)])
|
|
286
346
|
}
|
|
287
|
-
if paused || !autoplay { player.pause() } else { player.play() }
|
|
288
347
|
}
|
|
289
348
|
|
|
290
349
|
func stop() {
|
|
291
|
-
|
|
292
|
-
if let observer = endObserver { NotificationCenter.default.removeObserver(observer) }
|
|
293
|
-
endObserver = nil
|
|
294
|
-
player = nil
|
|
350
|
+
detachPlayer()
|
|
295
351
|
playerController.player = nil
|
|
296
352
|
sourceURI = ""
|
|
297
353
|
if playerController.parent != nil {
|
|
@@ -2035,6 +2091,35 @@ private final class CraftNativeLabel: UILabel {
|
|
|
2035
2091
|
}
|
|
2036
2092
|
}
|
|
2037
2093
|
|
|
2094
|
+
/// A one-line TextInput. Its style's padding is where the text and the
|
|
2095
|
+
/// placeholder start, as in a web input; UITextField has no padding of its own.
|
|
2096
|
+
final class CraftNativeTextField: UITextField {
|
|
2097
|
+
var padding = UIEdgeInsets.zero {
|
|
2098
|
+
didSet {
|
|
2099
|
+
guard padding != oldValue else { return }
|
|
2100
|
+
invalidateIntrinsicContentSize()
|
|
2101
|
+
setNeedsLayout()
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
override func textRect(forBounds bounds: CGRect) -> CGRect {
|
|
2106
|
+
super.textRect(forBounds: bounds.inset(by: padding))
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
override func editingRect(forBounds bounds: CGRect) -> CGRect {
|
|
2110
|
+
super.editingRect(forBounds: bounds.inset(by: padding))
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
override func placeholderRect(forBounds bounds: CGRect) -> CGRect {
|
|
2114
|
+
super.placeholderRect(forBounds: bounds.inset(by: padding))
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
override var intrinsicContentSize: CGSize {
|
|
2118
|
+
let size = super.intrinsicContentSize
|
|
2119
|
+
return CGSize(width: size.width + padding.left + padding.right, height: size.height + padding.top + padding.bottom)
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2038
2123
|
private final class CraftNativeFlexSpacer: UIView {}
|
|
2039
2124
|
|
|
2040
2125
|
/// An SF Symbol leaf. Its natural size is the symbol's, or a 20 pt square
|
|
@@ -2121,7 +2206,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2121
2206
|
|
|
2122
2207
|
private let config: CraftConfig
|
|
2123
2208
|
private let routeName: String?
|
|
2124
|
-
private
|
|
2209
|
+
private(set) var routeParams: [String: Any]
|
|
2125
2210
|
private let jsContext = JSContext()!
|
|
2126
2211
|
private let rootStack = CraftNativeFlowView()
|
|
2127
2212
|
private var handlers: [ObjectIdentifier: String] = [:]
|
|
@@ -2165,6 +2250,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2165
2250
|
private var svgOwners: [String: String] = [:]
|
|
2166
2251
|
private let mutationDocument = CraftNativeMutationDocument()
|
|
2167
2252
|
private let audio = CraftNativeAudio()
|
|
2253
|
+
/// `craft.audio`'s sounds and the streaming responses this screen reads.
|
|
2254
|
+
private let sounds = CraftNativeSounds()
|
|
2255
|
+
private let streams = CraftNativeEventStreams()
|
|
2256
|
+
/// A request a screen-owned module answers, by request token, and how to
|
|
2257
|
+
/// call it off when it times out or is cancelled.
|
|
2258
|
+
private var screenRequestCancellers: [String: () -> Void] = [:]
|
|
2259
|
+
/// Shown at least once: a later appearance is the screen coming back.
|
|
2260
|
+
private var hasAppeared = false
|
|
2261
|
+
/// Whether the script was last told the screen is on screen.
|
|
2262
|
+
private var reportedVisible = false
|
|
2168
2263
|
private let capabilityScope = UUID().uuidString
|
|
2169
2264
|
private var pendingCapabilityRequests = Set<String>()
|
|
2170
2265
|
private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
|
|
@@ -2172,6 +2267,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2172
2267
|
private var lifecycleObservers: [NSObjectProtocol] = []
|
|
2173
2268
|
private var deepLinkListener: UUID?
|
|
2174
2269
|
private var keyboardSafeAreaInset: CGFloat = 0
|
|
2270
|
+
/// Where the keyboard is on the screen while it is up, in screen coordinates.
|
|
2271
|
+
private var keyboardScreenFrame: CGRect?
|
|
2175
2272
|
/// The hybrid shell (CraftHybrid.swift), when this screen is one of a web
|
|
2176
2273
|
/// app's native screens: told of the first frame, of a first frame that
|
|
2177
2274
|
/// could not be drawn, and of `craft.navigation.open(path)`.
|
|
@@ -2241,11 +2338,45 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2241
2338
|
refreshTraitDefaults()
|
|
2242
2339
|
}
|
|
2243
2340
|
|
|
2341
|
+
override func viewDidAppear(_ animated: Bool) {
|
|
2342
|
+
super.viewDidAppear(animated)
|
|
2343
|
+
screenVisibilityChanged(true)
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2244
2346
|
override func viewDidDisappear(_ animated: Bool) {
|
|
2245
2347
|
super.viewDidDisappear(animated)
|
|
2246
2348
|
if isMovingFromParent || navigationController?.viewControllers.contains(where: { $0 === self }) == false {
|
|
2247
2349
|
disposeRuntime()
|
|
2350
|
+
} else {
|
|
2351
|
+
screenVisibilityChanged(false)
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
/// The screen came on screen or went off it: a screen pushed over it was
|
|
2356
|
+
/// popped, or the hybrid shell switched to or away from its tab (which
|
|
2357
|
+
/// hides a stack rather than moving it, so UIKit says nothing). The first
|
|
2358
|
+
/// appearance is the screen's mount, not news; every later one is
|
|
2359
|
+
/// `SCREEN_FOCUS { focused: true }`, and going away is `focused: false`.
|
|
2360
|
+
func screenVisibilityChanged(_ visible: Bool) {
|
|
2361
|
+
guard !runtimeDisposed, visible != reportedVisible else { return }
|
|
2362
|
+
reportedVisible = visible
|
|
2363
|
+
if visible, !hasAppeared {
|
|
2364
|
+
hasAppeared = true
|
|
2365
|
+
return
|
|
2248
2366
|
}
|
|
2367
|
+
send(type: "SCREEN_FOCUS", payload: ["focused": visible])
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
/// New params for the screen already showing a path: opened again with
|
|
2371
|
+
/// another query (`/m/messages?person=8`), or a tab's root opened with
|
|
2372
|
+
/// one (`/m/store?coach=5`). The script's `craft.route.params` changes
|
|
2373
|
+
/// and it hears `ROUTE_PARAMS`; before the script has run, it simply
|
|
2374
|
+
/// starts with them.
|
|
2375
|
+
func receiveRouteParams(_ params: [String: Any]) {
|
|
2376
|
+
routeParams = params
|
|
2377
|
+
guard isViewLoaded, !runtimeDisposed else { return }
|
|
2378
|
+
jsContext.setObject(params as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
|
|
2379
|
+
send(type: "ROUTE_PARAMS", payload: ["params": params])
|
|
2249
2380
|
}
|
|
2250
2381
|
|
|
2251
2382
|
private func disposeRuntime() {
|
|
@@ -2253,6 +2384,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2253
2384
|
runtimeDisposed = true
|
|
2254
2385
|
jsContext.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: [])
|
|
2255
2386
|
audio.close()
|
|
2387
|
+
sounds.close()
|
|
2388
|
+
streams.close()
|
|
2389
|
+
screenRequestCancellers.values.forEach { $0() }
|
|
2390
|
+
screenRequestCancellers.removeAll()
|
|
2256
2391
|
}
|
|
2257
2392
|
|
|
2258
2393
|
private func refreshTraitDefaults() {
|
|
@@ -2950,6 +3085,30 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2950
3085
|
guard let name = notification.userInfo?["name"] as? String else { return }
|
|
2951
3086
|
self?.send(type: "SNAPSHOT_CHANGED", payload: ["name": name])
|
|
2952
3087
|
})
|
|
3088
|
+
if config.enablePushNotifications {
|
|
3089
|
+
// A new device token (iOS hands one out after a reinstall or a
|
|
3090
|
+
// restore): every screen's `craft.push.onToken` hears it.
|
|
3091
|
+
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
3092
|
+
forName: .craftPushToken,
|
|
3093
|
+
object: nil,
|
|
3094
|
+
queue: .main
|
|
3095
|
+
) { [weak self] notification in
|
|
3096
|
+
guard let token = notification.object as? String else { return }
|
|
3097
|
+
self?.send(type: "PUSH_TOKEN", payload: ["token": token])
|
|
3098
|
+
})
|
|
3099
|
+
}
|
|
3100
|
+
// The coordinator's location events (a watch, a recording's fixes):
|
|
3101
|
+
// to this screen's script as the page's own CustomEvents.
|
|
3102
|
+
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
3103
|
+
forName: CraftWebServices.event,
|
|
3104
|
+
object: nil,
|
|
3105
|
+
queue: .main
|
|
3106
|
+
) { [weak self] notification in
|
|
3107
|
+
guard let name = notification.userInfo?["event"] as? String else { return }
|
|
3108
|
+
self?.send(type: "CRAFT_EVENT", payload: ["event": name, "detail": notification.userInfo?["detail"] ?? [:]])
|
|
3109
|
+
})
|
|
3110
|
+
sounds.emit = { [weak self] payload in self?.send(type: "SOUND_EVENT", payload: payload) }
|
|
3111
|
+
streams.emit = { [weak self] payload in self?.send(type: "NETWORK_STREAM", payload: payload) }
|
|
2953
3112
|
sendAppState(CraftNativeActions.currentAppState())
|
|
2954
3113
|
deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self] url, initial in
|
|
2955
3114
|
guard let self = self, self.navigationController?.topViewController === self else { return }
|
|
@@ -2961,24 +3120,74 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2961
3120
|
|
|
2962
3121
|
private func applyKeyboardSafeArea(_ notification: Notification, hidden: Bool = false) {
|
|
2963
3122
|
let inset: CGFloat
|
|
3123
|
+
let keyboardFrame: CGRect?
|
|
2964
3124
|
if hidden {
|
|
2965
3125
|
inset = 0
|
|
3126
|
+
keyboardFrame = nil
|
|
2966
3127
|
} else if let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
|
|
2967
|
-
|
|
2968
|
-
|
|
3128
|
+
keyboardFrame = frame.cgRectValue
|
|
3129
|
+
let local = view.convert(frame.cgRectValue, from: nil)
|
|
3130
|
+
// The safe area without the keyboard's own share, which an earlier
|
|
3131
|
+
// change of the keyboard's height added to it.
|
|
3132
|
+
inset = max(0, view.bounds.intersection(local).height - (view.safeAreaInsets.bottom - additionalSafeAreaInsets.bottom))
|
|
2969
3133
|
} else {
|
|
2970
3134
|
return
|
|
2971
3135
|
}
|
|
2972
|
-
guard abs(inset - keyboardSafeAreaInset) > 0.5 else { return }
|
|
2973
|
-
keyboardSafeAreaInset = inset
|
|
2974
3136
|
let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
|
|
2975
3137
|
let curve = (notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue ?? 7
|
|
3138
|
+
let changed = abs(inset - keyboardSafeAreaInset) > 0.5
|
|
3139
|
+
keyboardSafeAreaInset = inset
|
|
3140
|
+
keyboardScreenFrame = keyboardFrame
|
|
2976
3141
|
UIView.animate(withDuration: duration, delay: 0, options: UIView.AnimationOptions(rawValue: UInt(curve << 16))) {
|
|
2977
|
-
|
|
2978
|
-
|
|
3142
|
+
if changed {
|
|
3143
|
+
self.additionalSafeAreaInsets.bottom = inset
|
|
3144
|
+
self.view.layoutIfNeeded()
|
|
3145
|
+
}
|
|
3146
|
+
self.insetScrollViewsForKeyboard()
|
|
3147
|
+
self.scrollFocusedInputIntoView(animated: false)
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
/// A scroll view the keyboard still covers part of (one that does not end
|
|
3152
|
+
/// at the screen's bottom edge, or a keyboard taller than the safe area
|
|
3153
|
+
/// took account of) insets its content by that much, so everything in it
|
|
3154
|
+
/// can be scrolled above the keyboard. Internal for XCTest.
|
|
3155
|
+
func insetScrollViewsForKeyboard() {
|
|
3156
|
+
var scrolls: [UIScrollView] = []
|
|
3157
|
+
func collect(_ node: RenderedNode?) {
|
|
3158
|
+
guard let node else { return }
|
|
3159
|
+
if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical { scrolls.append(scroll) }
|
|
3160
|
+
node.children.forEach(collect)
|
|
3161
|
+
}
|
|
3162
|
+
collect(renderedRoot)
|
|
3163
|
+
for scroll in scrolls {
|
|
3164
|
+
var overlap: CGFloat = 0
|
|
3165
|
+
if let keyboard = keyboardScreenFrame, let window = scroll.window {
|
|
3166
|
+
let frame = scroll.convert(scroll.bounds, to: window)
|
|
3167
|
+
overlap = max(0, frame.maxY - window.convert(keyboard, from: nil).minY)
|
|
3168
|
+
}
|
|
3169
|
+
if abs(scroll.contentInset.bottom - overlap) > 0.5 { scroll.contentInset.bottom = overlap }
|
|
3170
|
+
if abs(scroll.verticalScrollIndicatorInsets.bottom - overlap) > 0.5 { scroll.verticalScrollIndicatorInsets.bottom = overlap }
|
|
2979
3171
|
}
|
|
2980
3172
|
}
|
|
2981
3173
|
|
|
3174
|
+
/// The focused field, scrolled into the part of its scroll view the
|
|
3175
|
+
/// keyboard leaves, with a little room around it.
|
|
3176
|
+
func scrollFocusedInputIntoView(animated: Bool) {
|
|
3177
|
+
guard let input = firstResponder(in: rootStack) else { return }
|
|
3178
|
+
scrollIntoView(input, animated: animated)
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
/// `input`, scrolled into the visible part of the scroll view it is in.
|
|
3182
|
+
/// Internal for XCTest.
|
|
3183
|
+
func scrollIntoView(_ input: UIView, animated: Bool) {
|
|
3184
|
+
var ancestor = input.superview
|
|
3185
|
+
while let current = ancestor, !(current is CraftNativeScrollView) { ancestor = current.superview }
|
|
3186
|
+
guard let scroll = ancestor as? CraftNativeScrollView, scroll.isVertical else { return }
|
|
3187
|
+
let rect = input.convert(input.bounds, to: scroll).insetBy(dx: 0, dy: -16)
|
|
3188
|
+
scroll.scrollRectToVisible(rect, animated: animated)
|
|
3189
|
+
}
|
|
3190
|
+
|
|
2982
3191
|
private func sendAppState(_ state: String) {
|
|
2983
3192
|
guard navigationController?.topViewController === self else { return }
|
|
2984
3193
|
send(type: "APP_STATE", payload: ["state": state])
|
|
@@ -3073,6 +3282,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3073
3282
|
let deadline = DispatchWorkItem { [weak self] in
|
|
3074
3283
|
guard let self = self, self.pendingCapabilityRequests.remove(requestToken) != nil else { return }
|
|
3075
3284
|
self.pendingCapabilityDeadlines.removeValue(forKey: requestToken)
|
|
3285
|
+
self.screenRequestCancellers.removeValue(forKey: requestToken)?()
|
|
3076
3286
|
CraftNativeActions.cancel(requestToken: requestToken)
|
|
3077
3287
|
self.send(type: "API_ERROR", payload: [
|
|
3078
3288
|
"version": craftNativeCapabilityProtocolVersion,
|
|
@@ -3086,16 +3296,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3086
3296
|
deadline: .now() + .milliseconds(craftNativeCapabilityTimeoutMilliseconds),
|
|
3087
3297
|
execute: deadline
|
|
3088
3298
|
)
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
module: payload["module"] as? String ?? "",
|
|
3093
|
-
method: payload["method"] as? String ?? "",
|
|
3094
|
-
args: args,
|
|
3095
|
-
config: config
|
|
3096
|
-
) { [weak self] answer in
|
|
3299
|
+
let module = payload["module"] as? String ?? ""
|
|
3300
|
+
let method = payload["method"] as? String ?? ""
|
|
3301
|
+
let reply: (Result<Any, CraftNativeActionError>) -> Void = { [weak self] answer in
|
|
3097
3302
|
guard let self = self, self.pendingCapabilityRequests.remove(requestToken) != nil else { return }
|
|
3098
3303
|
self.pendingCapabilityDeadlines.removeValue(forKey: requestToken)?.cancel()
|
|
3304
|
+
self.screenRequestCancellers.removeValue(forKey: requestToken)
|
|
3099
3305
|
switch answer {
|
|
3100
3306
|
case .success(let data):
|
|
3101
3307
|
self.send(type: "API_RESPONSE", payload: [
|
|
@@ -3112,11 +3318,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3112
3318
|
], correlationId: id)
|
|
3113
3319
|
}
|
|
3114
3320
|
}
|
|
3321
|
+
if performOnScreen(module: module, method: method, args: args, requestToken: requestToken, reply: reply) { return }
|
|
3322
|
+
CraftNativeActions.perform(
|
|
3323
|
+
requestToken: requestToken,
|
|
3324
|
+
version: payload["version"] as? Int ?? craftNativeCapabilityProtocolVersion,
|
|
3325
|
+
module: module,
|
|
3326
|
+
method: method,
|
|
3327
|
+
args: args,
|
|
3328
|
+
config: config,
|
|
3329
|
+
completion: reply
|
|
3330
|
+
)
|
|
3115
3331
|
case "API_CANCEL":
|
|
3116
3332
|
guard let id = payload["requestId"] as? String else { return }
|
|
3117
3333
|
let requestToken = "\(capabilityScope)/\(id)"
|
|
3118
3334
|
pendingCapabilityRequests.remove(requestToken)
|
|
3119
3335
|
pendingCapabilityDeadlines.removeValue(forKey: requestToken)?.cancel()
|
|
3336
|
+
screenRequestCancellers.removeValue(forKey: requestToken)?()
|
|
3120
3337
|
CraftNativeActions.cancel(requestToken: requestToken)
|
|
3121
3338
|
case "AUDIO":
|
|
3122
3339
|
audio.handle(payload)
|
|
@@ -3125,6 +3342,59 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3125
3342
|
}
|
|
3126
3343
|
}
|
|
3127
3344
|
|
|
3345
|
+
/// The modules whose work belongs to this screen and ends with it:
|
|
3346
|
+
/// `craft.audio`'s sounds and streaming `fetch`. False for any other
|
|
3347
|
+
/// module, which CraftNativeActions serves.
|
|
3348
|
+
private func performOnScreen(
|
|
3349
|
+
module: String,
|
|
3350
|
+
method: String,
|
|
3351
|
+
args: [Any],
|
|
3352
|
+
requestToken: String,
|
|
3353
|
+
reply: @escaping (Result<Any, CraftNativeActionError>) -> Void
|
|
3354
|
+
) -> Bool {
|
|
3355
|
+
func number(_ index: Int, _ key: String? = nil) -> Double {
|
|
3356
|
+
guard args.count > index else { return 0 }
|
|
3357
|
+
if let key { return ((args[index] as? [String: Any])?[key] as? NSNumber)?.doubleValue ?? 0 }
|
|
3358
|
+
return (args[index] as? NSNumber)?.doubleValue ?? 0
|
|
3359
|
+
}
|
|
3360
|
+
let id = args.first as? String ?? ""
|
|
3361
|
+
switch (module, method) {
|
|
3362
|
+
case ("Network", "stream"):
|
|
3363
|
+
let options = args.first as? [String: Any] ?? [:]
|
|
3364
|
+
let streamId = options["id"] as? String ?? ""
|
|
3365
|
+
screenRequestCancellers[requestToken] = { [weak self] in self?.streams.cancel(id: streamId) }
|
|
3366
|
+
streams.open(id: streamId, options: options, answer: reply)
|
|
3367
|
+
case ("Network", "cancelStream"):
|
|
3368
|
+
streams.cancel(id: id)
|
|
3369
|
+
reply(.success(true))
|
|
3370
|
+
case ("Audio", "play"):
|
|
3371
|
+
guard args.count > 1, let address = args[1] as? String, let url = URL(string: address), url.scheme != nil, !id.isEmpty else {
|
|
3372
|
+
reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "audio.play needs a URL")))
|
|
3373
|
+
return true
|
|
3374
|
+
}
|
|
3375
|
+
screenRequestCancellers[requestToken] = { [weak self] in self?.sounds.stop(id: id, fadeOut: 0) }
|
|
3376
|
+
sounds.play(id: id, url: url, options: args.count > 2 ? args[2] as? [String: Any] ?? [:] : [:], answer: reply)
|
|
3377
|
+
case ("Audio", "pause"):
|
|
3378
|
+
sounds.pause(id: id)
|
|
3379
|
+
reply(.success(true))
|
|
3380
|
+
case ("Audio", "resume"):
|
|
3381
|
+
sounds.resume(id: id)
|
|
3382
|
+
reply(.success(true))
|
|
3383
|
+
case ("Audio", "setVolume"):
|
|
3384
|
+
sounds.setVolume(id: id, volume: number(1), fade: number(2, "fade") / 1000)
|
|
3385
|
+
reply(.success(true))
|
|
3386
|
+
case ("Audio", "stop"):
|
|
3387
|
+
sounds.stop(id: id, fadeOut: number(1, "fadeOut") / 1000)
|
|
3388
|
+
reply(.success(true))
|
|
3389
|
+
case ("Audio", "stopAll"):
|
|
3390
|
+
sounds.stopAll(fadeOut: number(0, "fadeOut") / 1000)
|
|
3391
|
+
reply(.success(true))
|
|
3392
|
+
default:
|
|
3393
|
+
return false
|
|
3394
|
+
}
|
|
3395
|
+
return true
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3128
3398
|
/// Runs script in the screen's JavaScript context. Internal for
|
|
3129
3399
|
/// simulator-hosted XCTest of the host APIs.
|
|
3130
3400
|
@discardableResult
|
|
@@ -3306,6 +3576,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3306
3576
|
textMaxLengths.removeValue(forKey: fieldId)
|
|
3307
3577
|
}
|
|
3308
3578
|
field.delegate = self
|
|
3579
|
+
(field as? CraftNativeTextField)?.padding = paddingInsets(style)
|
|
3309
3580
|
field.textColor = color(style["color"]) ?? .label
|
|
3310
3581
|
field.font = textFont(style, default: .systemFont(ofSize: 16))
|
|
3311
3582
|
field.textAlignment = textAlignment(style["textAlign"])
|
|
@@ -3323,6 +3594,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3323
3594
|
if field.autocapitalizationType != desiredCapitalization { field.autocapitalizationType = desiredCapitalization }
|
|
3324
3595
|
let desiredEnabled = props["editable"] as? Bool != false
|
|
3325
3596
|
if field.isEnabled != desiredEnabled { field.isEnabled = desiredEnabled }
|
|
3597
|
+
applyAutofill(to: field, props: props)
|
|
3326
3598
|
if let value = props["value"] as? String {
|
|
3327
3599
|
updateField(field, value: value)
|
|
3328
3600
|
} else if previous?.view !== field {
|
|
@@ -3356,6 +3628,25 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3356
3628
|
}
|
|
3357
3629
|
picker.isUserInteractionEnabled = props["disabled"] as? Bool != true
|
|
3358
3630
|
updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: picker)
|
|
3631
|
+
case "DatePicker":
|
|
3632
|
+
let picker = result as! UIDatePicker
|
|
3633
|
+
let mode = Self.datePickerMode(props["mode"])
|
|
3634
|
+
if picker.datePickerMode != mode { picker.datePickerMode = mode }
|
|
3635
|
+
let display: UIDatePickerStyle = props["display"] as? String == "inline" ? .inline
|
|
3636
|
+
: props["display"] as? String == "wheels" || props["display"] as? String == "spinner" ? .wheels
|
|
3637
|
+
: .compact
|
|
3638
|
+
if picker.preferredDatePickerStyle != display { picker.preferredDatePickerStyle = display }
|
|
3639
|
+
// UIKit takes an interval that divides an hour, and ignores others.
|
|
3640
|
+
let interval = (props["minuteInterval"] as? NSNumber)?.intValue ?? 1
|
|
3641
|
+
picker.minuteInterval = (1...30).contains(interval) && 60 % interval == 0 ? interval : 1
|
|
3642
|
+
picker.minimumDate = Self.pickerDate(props["minimumDate"])
|
|
3643
|
+
picker.maximumDate = Self.pickerDate(props["maximumDate"])
|
|
3644
|
+
if let date = Self.pickerDate(props["value"]), abs(picker.date.timeIntervalSince(date)) >= 1 {
|
|
3645
|
+
picker.setDate(date, animated: false)
|
|
3646
|
+
}
|
|
3647
|
+
picker.tintColor = color(props["color"]) ?? color(style["color"])
|
|
3648
|
+
picker.isEnabled = props["disabled"] as? Bool != true
|
|
3649
|
+
updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: picker)
|
|
3359
3650
|
case "Modal":
|
|
3360
3651
|
let modal = result as! CraftNativeModalView
|
|
3361
3652
|
modal.transparent = props["transparent"] as? Bool == true
|
|
@@ -3474,8 +3765,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3474
3765
|
case "Video":
|
|
3475
3766
|
let video = result as! CraftNativeVideoView
|
|
3476
3767
|
let autoplay = (props["autoplay"] as? Bool) == true || (props["autoPlay"] as? Bool) == true
|
|
3768
|
+
video.handlers = events
|
|
3769
|
+
video.emit = { [weak self] handler, nativeEvent in
|
|
3770
|
+
self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": nativeEvent])
|
|
3771
|
+
}
|
|
3477
3772
|
video.configure(
|
|
3478
|
-
source: props["source"],
|
|
3773
|
+
source: props["source"] ?? props["src"],
|
|
3479
3774
|
autoplay: autoplay,
|
|
3480
3775
|
paused: (props["paused"] as? Bool) == true,
|
|
3481
3776
|
loop: (props["loop"] as? Bool) == true,
|
|
@@ -3533,7 +3828,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3533
3828
|
reconcileChildren(children, in: stack, parent: current, path: path, style: style)
|
|
3534
3829
|
}
|
|
3535
3830
|
updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
|
|
3536
|
-
if type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
|
|
3831
|
+
if type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
|
|
3537
3832
|
let disabled = props["disabled"] as? Bool == true
|
|
3538
3833
|
updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
|
|
3539
3834
|
updatePressFeedback(
|
|
@@ -3563,7 +3858,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3563
3858
|
textView.delegate = self
|
|
3564
3859
|
return textView
|
|
3565
3860
|
}
|
|
3566
|
-
let field =
|
|
3861
|
+
let field = CraftNativeTextField()
|
|
3567
3862
|
field.addTarget(self, action: #selector(textChanged(_:)), for: .editingChanged)
|
|
3568
3863
|
field.addTarget(self, action: #selector(textFocused(_:)), for: .editingDidBegin)
|
|
3569
3864
|
field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
|
|
@@ -3574,6 +3869,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3574
3869
|
picker.dataSource = self
|
|
3575
3870
|
picker.delegate = self
|
|
3576
3871
|
return picker
|
|
3872
|
+
case "DatePicker":
|
|
3873
|
+
let picker = UIDatePicker()
|
|
3874
|
+
picker.preferredDatePickerStyle = .compact
|
|
3875
|
+
picker.addTarget(self, action: #selector(datePicked(_:)), for: .valueChanged)
|
|
3876
|
+
return picker
|
|
3577
3877
|
case "Modal":
|
|
3578
3878
|
return CraftNativeModalView()
|
|
3579
3879
|
case "Switch":
|
|
@@ -4005,6 +4305,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4005
4305
|
textView.autocapitalizationType = capitalizationType(props["autoCapitalize"])
|
|
4006
4306
|
textView.isSecureTextEntry = props["secureTextEntry"] as? Bool == true
|
|
4007
4307
|
textView.isEditable = props["editable"] as? Bool != false
|
|
4308
|
+
applyAutofill(to: textView, props: props)
|
|
4309
|
+
// The style's padding is where the text starts, as in a web textarea.
|
|
4310
|
+
let padding = paddingInsets(style)
|
|
4311
|
+
if padding != .zero {
|
|
4312
|
+
if textView.textContainerInset != padding { textView.textContainerInset = padding }
|
|
4313
|
+
textView.textContainer.lineFragmentPadding = 0
|
|
4314
|
+
}
|
|
4008
4315
|
textView.isScrollEnabled = false
|
|
4009
4316
|
if let numberOfLines = (props["numberOfLines"] as? NSNumber)?.intValue, numberOfLines > 0 {
|
|
4010
4317
|
textView.textContainer.maximumNumberOfLines = numberOfLines
|
|
@@ -4318,6 +4625,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4318
4625
|
}
|
|
4319
4626
|
}
|
|
4320
4627
|
|
|
4628
|
+
/// An image the screen downloads: https anywhere, and http only from this
|
|
4629
|
+
/// machine (localhost, 127.0.0.1, ::1, *.localhost), the development
|
|
4630
|
+
/// server the app's ATS exception already allows. Internal for XCTest.
|
|
4631
|
+
static func loadsImage(from url: URL) -> Bool {
|
|
4632
|
+
let scheme = url.scheme?.lowercased()
|
|
4633
|
+
if scheme == "https" { return true }
|
|
4634
|
+
guard scheme == "http", let host = url.host?.lowercased() else { return false }
|
|
4635
|
+
let bare = host.hasPrefix("[") && host.hasSuffix("]") ? String(host.dropFirst().dropLast()) : host
|
|
4636
|
+
return ["localhost", "127.0.0.1", "::1"].contains(bare) || bare.hasSuffix(".localhost")
|
|
4637
|
+
}
|
|
4638
|
+
|
|
4321
4639
|
private func updateImage(_ view: UIImageView, source: Any?) {
|
|
4322
4640
|
let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String
|
|
4323
4641
|
let id = ObjectIdentifier(view)
|
|
@@ -4348,7 +4666,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4348
4666
|
emitImageEvent(view, handler: imageLoadEndHandlers[id])
|
|
4349
4667
|
return
|
|
4350
4668
|
}
|
|
4351
|
-
if let url = URL(string: uri),
|
|
4669
|
+
if let url = URL(string: uri), Self.loadsImage(from: url) {
|
|
4352
4670
|
let task = URLSession.shared.dataTask(with: url) { [weak self, weak view] data, _, _ in
|
|
4353
4671
|
guard let self, let view else { return }
|
|
4354
4672
|
DispatchQueue.main.async {
|
|
@@ -4471,6 +4789,31 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4471
4789
|
}
|
|
4472
4790
|
}
|
|
4473
4791
|
|
|
4792
|
+
/// Return on a `returnKeyType="next"` field the screen does not handle
|
|
4793
|
+
/// itself (no `@submit`) goes to the next field down the screen, keeping
|
|
4794
|
+
/// the keyboard up. Every other Return ends editing and fires `@submit`.
|
|
4795
|
+
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
|
4796
|
+
guard textField.returnKeyType == .next, submitHandlers[ObjectIdentifier(textField)] == nil,
|
|
4797
|
+
let next = inputAfter(textField) else { return true }
|
|
4798
|
+
next.becomeFirstResponder()
|
|
4799
|
+
return false
|
|
4800
|
+
}
|
|
4801
|
+
|
|
4802
|
+
/// The editable field after `input` in the screen's document order.
|
|
4803
|
+
/// Internal for XCTest.
|
|
4804
|
+
func inputAfter(_ input: UIView) -> UIView? {
|
|
4805
|
+
var inputs: [UIView] = []
|
|
4806
|
+
func collect(_ node: RenderedNode?) {
|
|
4807
|
+
guard let node else { return }
|
|
4808
|
+
if let field = node.view as? UITextField, field.isEnabled, !field.isHidden { inputs.append(field) }
|
|
4809
|
+
if let textView = node.view as? UITextView, textView.isEditable, !textView.isHidden { inputs.append(textView) }
|
|
4810
|
+
node.children.forEach(collect)
|
|
4811
|
+
}
|
|
4812
|
+
collect(renderedRoot)
|
|
4813
|
+
guard let index = inputs.firstIndex(where: { $0 === input }), index + 1 < inputs.count else { return nil }
|
|
4814
|
+
return inputs[index + 1]
|
|
4815
|
+
}
|
|
4816
|
+
|
|
4474
4817
|
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
|
4475
4818
|
guard let maxLength = textMaxLengths[ObjectIdentifier(textField)],
|
|
4476
4819
|
let current = textField.text,
|
|
@@ -4499,6 +4842,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4499
4842
|
|
|
4500
4843
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
|
4501
4844
|
lastFocusedInput = textView
|
|
4845
|
+
if keyboardScreenFrame != nil { DispatchQueue.main.async { self.scrollFocusedInputIntoView(animated: true) } }
|
|
4502
4846
|
guard let handler = focusHandlers[ObjectIdentifier(textView)] else { return }
|
|
4503
4847
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
|
|
4504
4848
|
}
|
|
@@ -4539,6 +4883,66 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4539
4883
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": value]])
|
|
4540
4884
|
}
|
|
4541
4885
|
|
|
4886
|
+
/// `@change` on a DatePicker: the value as `<input type="date|time|
|
|
4887
|
+
/// datetime-local">` writes it, in local time, with the moment as ISO and
|
|
4888
|
+
/// milliseconds for code that wants those.
|
|
4889
|
+
@objc private func datePicked(_ sender: UIDatePicker) {
|
|
4890
|
+
guard let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
4891
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": Self.datePickerEvent(sender.date, mode: sender.datePickerMode)])
|
|
4892
|
+
}
|
|
4893
|
+
|
|
4894
|
+
static func datePickerEvent(_ date: Date, mode: UIDatePicker.Mode) -> [String: Any] {
|
|
4895
|
+
let local = DateFormatter()
|
|
4896
|
+
local.locale = Locale(identifier: "en_US_POSIX")
|
|
4897
|
+
local.calendar = Calendar(identifier: .gregorian)
|
|
4898
|
+
local.timeZone = .current
|
|
4899
|
+
local.dateFormat = mode == .date ? "yyyy-MM-dd" : mode == .time ? "HH:mm" : "yyyy-MM-dd'T'HH:mm"
|
|
4900
|
+
let iso = ISO8601DateFormatter()
|
|
4901
|
+
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
4902
|
+
return [
|
|
4903
|
+
"value": local.string(from: date),
|
|
4904
|
+
"iso": iso.string(from: date),
|
|
4905
|
+
"timestamp": (date.timeIntervalSince1970 * 1000).rounded(),
|
|
4906
|
+
]
|
|
4907
|
+
}
|
|
4908
|
+
|
|
4909
|
+
static func datePickerMode(_ value: Any?) -> UIDatePicker.Mode {
|
|
4910
|
+
switch value as? String {
|
|
4911
|
+
case "time": return .time
|
|
4912
|
+
case "datetime", "dateAndTime", "datetime-local": return .dateAndTime
|
|
4913
|
+
case "countdown": return .countDownTimer
|
|
4914
|
+
default: return .date
|
|
4915
|
+
}
|
|
4916
|
+
}
|
|
4917
|
+
|
|
4918
|
+
/// A DatePicker's value: `2026-10-12` (that day), `07:30` (today at that
|
|
4919
|
+
/// time), `2026-10-12T07:30` (local), a full ISO 8601 string, or
|
|
4920
|
+
/// milliseconds since 1970. Nil for anything else.
|
|
4921
|
+
static func pickerDate(_ value: Any?) -> Date? {
|
|
4922
|
+
if let number = value as? NSNumber, !(value is Bool) { return Date(timeIntervalSince1970: number.doubleValue / 1000) }
|
|
4923
|
+
guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
|
|
4924
|
+
let local = DateFormatter()
|
|
4925
|
+
local.locale = Locale(identifier: "en_US_POSIX")
|
|
4926
|
+
local.calendar = Calendar(identifier: .gregorian)
|
|
4927
|
+
local.timeZone = .current
|
|
4928
|
+
for format in ["HH:mm", "HH:mm:ss"] {
|
|
4929
|
+
local.dateFormat = format
|
|
4930
|
+
if let time = local.date(from: text) {
|
|
4931
|
+
let parts = Calendar.current.dateComponents([.hour, .minute, .second], from: time)
|
|
4932
|
+
return Calendar.current.date(bySettingHour: parts.hour ?? 0, minute: parts.minute ?? 0, second: parts.second ?? 0, of: Date())
|
|
4933
|
+
}
|
|
4934
|
+
}
|
|
4935
|
+
for format in ["yyyy-MM-dd", "yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.SSS"] {
|
|
4936
|
+
local.dateFormat = format
|
|
4937
|
+
if let date = local.date(from: text) { return date }
|
|
4938
|
+
}
|
|
4939
|
+
let iso = ISO8601DateFormatter()
|
|
4940
|
+
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
4941
|
+
if let date = iso.date(from: text) { return date }
|
|
4942
|
+
iso.formatOptions = [.withInternetDateTime]
|
|
4943
|
+
return iso.date(from: text)
|
|
4944
|
+
}
|
|
4945
|
+
|
|
4542
4946
|
@objc private func sliderFinished(_ sender: UISlider) {
|
|
4543
4947
|
guard let handler = sliderCompleteHandlers[ObjectIdentifier(sender)] else { return }
|
|
4544
4948
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": snappedSliderValue(sender.value, for: sender)]])
|
|
@@ -4567,6 +4971,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4567
4971
|
|
|
4568
4972
|
@objc private func textFocused(_ sender: UITextField) {
|
|
4569
4973
|
lastFocusedInput = sender
|
|
4974
|
+
// Moving between fields does not move the keyboard, so nothing else
|
|
4975
|
+
// brings the next one up from under it.
|
|
4976
|
+
if keyboardScreenFrame != nil { DispatchQueue.main.async { self.scrollFocusedInputIntoView(animated: true) } }
|
|
4570
4977
|
guard let handler = focusHandlers[ObjectIdentifier(sender)] else { return }
|
|
4571
4978
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
|
|
4572
4979
|
}
|
|
@@ -4677,10 +5084,66 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4677
5084
|
case "url": return .URL
|
|
4678
5085
|
case "web-search": return .webSearch
|
|
4679
5086
|
case "visible-password": return .asciiCapable
|
|
5087
|
+
case "ascii-capable": return .asciiCapable
|
|
5088
|
+
case "numbers-and-punctuation": return .numbersAndPunctuation
|
|
5089
|
+
case "name-phone-pad": return .namePhonePad
|
|
5090
|
+
case "twitter": return .twitter
|
|
4680
5091
|
default: return .default
|
|
4681
5092
|
}
|
|
4682
5093
|
}
|
|
4683
5094
|
|
|
5095
|
+
/// What the field holds, for the system's autofill: `textContentType` in
|
|
5096
|
+
/// UITextContentType's names (`username`, `password`, `newPassword`,
|
|
5097
|
+
/// `oneTimeCode`...), or `autoComplete` in HTML's (`current-password`,
|
|
5098
|
+
/// `one-time-code`, `email`). A password field without it gets no
|
|
5099
|
+
/// Passwords suggestion above the keyboard.
|
|
5100
|
+
static func textContentType(_ props: [String: Any]) -> UITextContentType? {
|
|
5101
|
+
let raw = (props["textContentType"] as? String) ?? (props["autoComplete"] as? String) ?? (props["autocomplete"] as? String)
|
|
5102
|
+
guard let raw, !raw.isEmpty else { return nil }
|
|
5103
|
+
let key = raw.replacingOccurrences(of: "-", with: "").replacingOccurrences(of: "_", with: "").lowercased()
|
|
5104
|
+
switch key {
|
|
5105
|
+
case "username": return .username
|
|
5106
|
+
case "nickname": return .nickname
|
|
5107
|
+
case "password", "currentpassword": return .password
|
|
5108
|
+
case "newpassword": return .newPassword
|
|
5109
|
+
case "onetimecode": return .oneTimeCode
|
|
5110
|
+
case "emailaddress", "email": return .emailAddress
|
|
5111
|
+
case "telephonenumber", "tel", "phone": return .telephoneNumber
|
|
5112
|
+
case "name": return .name
|
|
5113
|
+
case "givenname", "firstname": return .givenName
|
|
5114
|
+
case "middlename", "additionalname": return .middleName
|
|
5115
|
+
case "familyname", "lastname": return .familyName
|
|
5116
|
+
case "nameprefix", "honorificprefix": return .namePrefix
|
|
5117
|
+
case "namesuffix", "honorificsuffix": return .nameSuffix
|
|
5118
|
+
case "organizationname", "organization": return .organizationName
|
|
5119
|
+
case "jobtitle", "organizationtitle": return .jobTitle
|
|
5120
|
+
case "url": return .URL
|
|
5121
|
+
case "fullstreetaddress", "streetaddress": return .fullStreetAddress
|
|
5122
|
+
case "streetaddressline1", "addressline1": return .streetAddressLine1
|
|
5123
|
+
case "streetaddressline2", "addressline2": return .streetAddressLine2
|
|
5124
|
+
case "addresscity", "addresslevel2": return .addressCity
|
|
5125
|
+
case "addressstate", "addresslevel1": return .addressState
|
|
5126
|
+
case "addresscityandstate": return .addressCityAndState
|
|
5127
|
+
case "postalcode": return .postalCode
|
|
5128
|
+
case "countryname", "country": return .countryName
|
|
5129
|
+
case "location": return .location
|
|
5130
|
+
case "creditcardnumber", "ccnumber": return .creditCardNumber
|
|
5131
|
+
default: return nil
|
|
5132
|
+
}
|
|
5133
|
+
}
|
|
5134
|
+
|
|
5135
|
+
private func applyAutofill(to input: UITextInputTraits & UIView, props: [String: Any]) {
|
|
5136
|
+
let type = Self.textContentType(props)
|
|
5137
|
+
let rules = (props["passwordRules"] as? String).flatMap { $0.isEmpty ? nil : UITextInputPasswordRules(descriptor: $0) }
|
|
5138
|
+
if let field = input as? UITextField {
|
|
5139
|
+
if field.textContentType != type { field.textContentType = type }
|
|
5140
|
+
if field.passwordRules?.passwordRulesDescriptor != rules?.passwordRulesDescriptor { field.passwordRules = rules }
|
|
5141
|
+
} else if let textView = input as? UITextView {
|
|
5142
|
+
if textView.textContentType != type { textView.textContentType = type }
|
|
5143
|
+
if textView.passwordRules?.passwordRulesDescriptor != rules?.passwordRulesDescriptor { textView.passwordRules = rules }
|
|
5144
|
+
}
|
|
5145
|
+
}
|
|
5146
|
+
|
|
4684
5147
|
private func returnKeyType(_ value: Any?) -> UIReturnKeyType {
|
|
4685
5148
|
switch value as? String {
|
|
4686
5149
|
case "done": return .done
|
|
@@ -4688,6 +5151,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4688
5151
|
case "next": return .next
|
|
4689
5152
|
case "search": return .search
|
|
4690
5153
|
case "send": return .send
|
|
5154
|
+
case "join": return .join
|
|
5155
|
+
case "route": return .route
|
|
5156
|
+
case "continue": return .continue
|
|
5157
|
+
case "emergency-call": return .emergencyCall
|
|
4691
5158
|
default: return .default
|
|
4692
5159
|
}
|
|
4693
5160
|
}
|