craft-native 0.0.499 → 0.0.500
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/CraftNativeFlatList.kt.template +51 -3
- package/dist/android/templates/CraftNativeShell.kt.template +54 -0
- package/dist/android/templates/MainActivityNative.kt.template +25 -1
- package/dist/api/process.d.ts +23 -28
- package/dist/bridge/core.d.ts +30 -6
- package/dist/cli.js +39 -19
- package/dist/components/index.d.ts +1 -1
- package/dist/index.cjs +90 -99
- package/dist/index.js +90 -99
- package/dist/ios/templates/CraftApp.swift +17 -0
- package/dist/ios/templates/CraftHybrid.swift +16 -0
- package/dist/ios/templates/CraftNativeFiles.swift +152 -7
- package/dist/ios/templates/CraftNativeFlatList.swift +43 -6
- package/dist/ios/templates/CraftNativeScreen.swift +184 -66
- package/dist/ios/templates/CraftScheduledWorkouts.swift +11 -4
- package/dist/ios/templates/CraftWebAudio.swift +20 -1
- package/dist/package.d.ts +22 -0
- package/package.json +1 -1
|
@@ -150,6 +150,38 @@ func craftNativeNumber(_ value: Any?) -> NSNumber? {
|
|
|
150
150
|
return number
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/// A number from a prop as an `Int`, the way `toInt()` gives it on the
|
|
154
|
+
/// Android host: toward zero, the nearest end of the 32-bit range when it is
|
|
155
|
+
/// outside it, and 0 when it is not a number.
|
|
156
|
+
///
|
|
157
|
+
/// `Int(_:)` traps on a value it cannot hold, and a prop is whatever the
|
|
158
|
+
/// screen's script said: `maxLength={Number.MAX_VALUE}` ended the process
|
|
159
|
+
/// (#510). The 32-bit range is the other host's, and it leaves room for the
|
|
160
|
+
/// arithmetic done on the result, which `Int.max` would not.
|
|
161
|
+
func craftNativeInt(_ value: Double) -> Int {
|
|
162
|
+
guard !value.isNaN else { return 0 }
|
|
163
|
+
return Int(max(Double(Int32.min), min(Double(Int32.max), value.rounded(.towardZero))))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func craftNativeInt(_ value: CGFloat) -> Int { craftNativeInt(Double(value)) }
|
|
167
|
+
|
|
168
|
+
/// The most a number may be in layout, either side of zero: 2^25 points. It
|
|
169
|
+
/// is far past any screen, and small enough that what the layout makes of a
|
|
170
|
+
/// few such numbers multiplied together still fits a Float, which the flex
|
|
171
|
+
/// engine works in.
|
|
172
|
+
let craftNativeLayoutLimit: CGFloat = 33_554_432
|
|
173
|
+
|
|
174
|
+
/// A number for layout: inside that limit, and 0 when it is not a number.
|
|
175
|
+
///
|
|
176
|
+
/// A style is whatever the script said. `flex: Number.MAX_VALUE` is finite,
|
|
177
|
+
/// and became an infinite share of a row, an infinite frame, and then
|
|
178
|
+
/// UIKit's `NSLayoutConstraint constant is not finite! That's illegal`, an
|
|
179
|
+
/// exception nothing catches: the app ended. Lengths and flex factors are
|
|
180
|
+
/// held to this as they are read, and so is every frame the layout produces.
|
|
181
|
+
func craftNativeLayoutNumber(_ value: CGFloat) -> CGFloat {
|
|
182
|
+
value.isNaN ? 0 : max(-craftNativeLayoutLimit, min(craftNativeLayoutLimit, value))
|
|
183
|
+
}
|
|
184
|
+
|
|
153
185
|
/// A finite number from a prop, for the views outside the screen controller,
|
|
154
186
|
/// which has its own: the keyboard-avoiding view and SVG text read theirs
|
|
155
187
|
/// with it, and without it the template did not compile.
|
|
@@ -161,11 +193,19 @@ private func number(_ value: Any?) -> CGFloat? {
|
|
|
161
193
|
return value.isFinite ? value : nil
|
|
162
194
|
}
|
|
163
195
|
|
|
196
|
+
/// A finite number from a style, held to what layout can use (see
|
|
197
|
+
/// `craftNativeLayoutNumber`). A padding, radius, spacing or size of
|
|
198
|
+
/// `Number.MAX_VALUE` reached UIKit as an inset or a font it could not place,
|
|
199
|
+
/// and UIKit raised `CALayer position contains NaN`: the app ended.
|
|
200
|
+
private func styleNumber(_ value: Any?) -> CGFloat? {
|
|
201
|
+
number(value).map(craftNativeLayoutNumber)
|
|
202
|
+
}
|
|
203
|
+
|
|
164
204
|
private func craftNativeFontWeight(_ value: Any?) -> String? {
|
|
165
205
|
if let value = value as? String { return value }
|
|
166
206
|
guard let number = craftNativeNumber(value), number.doubleValue.isFinite else { return nil }
|
|
167
207
|
let integer = number.doubleValue.rounded()
|
|
168
|
-
return integer == number.doubleValue ? String(
|
|
208
|
+
return integer == number.doubleValue ? String(craftNativeInt(integer)) : nil
|
|
169
209
|
}
|
|
170
210
|
|
|
171
211
|
private func craftNativePickerValue(_ value: Any?) -> String? {
|
|
@@ -2389,16 +2429,16 @@ private enum CraftNativeLength: Equatable {
|
|
|
2389
2429
|
if let number = craftNativeNumber(value) {
|
|
2390
2430
|
let points = CGFloat(truncating: number)
|
|
2391
2431
|
guard points.isFinite else { return nil }
|
|
2392
|
-
self = .points(points)
|
|
2432
|
+
self = .points(craftNativeLayoutNumber(points))
|
|
2393
2433
|
return
|
|
2394
2434
|
}
|
|
2395
2435
|
guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
|
|
2396
2436
|
if text.hasSuffix("%"), let fraction = Double(text.dropLast()), fraction.isFinite {
|
|
2397
|
-
self = .percent(CGFloat(fraction) / 100)
|
|
2437
|
+
self = .percent(craftNativeLayoutNumber(CGFloat(fraction) / 100))
|
|
2398
2438
|
} else if text.hasSuffix("px"), let points = Double(text.dropLast(2)), points.isFinite {
|
|
2399
|
-
self = .points(CGFloat(points))
|
|
2439
|
+
self = .points(craftNativeLayoutNumber(CGFloat(points)))
|
|
2400
2440
|
} else if let points = Double(text), points.isFinite {
|
|
2401
|
-
self = .points(CGFloat(points))
|
|
2441
|
+
self = .points(craftNativeLayoutNumber(CGFloat(points)))
|
|
2402
2442
|
} else {
|
|
2403
2443
|
return nil
|
|
2404
2444
|
}
|
|
@@ -2449,7 +2489,7 @@ private struct CraftNativeLayoutStyle {
|
|
|
2449
2489
|
func number(_ value: Any?) -> CGFloat? {
|
|
2450
2490
|
guard let number = craftNativeNumber(value) else { return nil }
|
|
2451
2491
|
let value = CGFloat(truncating: number)
|
|
2452
|
-
return value.isFinite ? value : nil
|
|
2492
|
+
return value.isFinite ? craftNativeLayoutNumber(value) : nil
|
|
2453
2493
|
}
|
|
2454
2494
|
width = CraftNativeLength(raw["width"])
|
|
2455
2495
|
height = CraftNativeLength(raw["height"])
|
|
@@ -2466,7 +2506,7 @@ private struct CraftNativeLayoutStyle {
|
|
|
2466
2506
|
left = number(raw["left"])
|
|
2467
2507
|
let align = raw["alignSelf"] as? String
|
|
2468
2508
|
alignSelf = align == "auto" ? nil : align
|
|
2469
|
-
order = number(raw["order"]).map {
|
|
2509
|
+
order = number(raw["order"]).map { craftNativeInt($0) } ?? 0
|
|
2470
2510
|
// `flex: n` is the web's and React Native's shorthand: a positive
|
|
2471
2511
|
// number grows by n, shrinks, and starts from nothing, so `flex-1`
|
|
2472
2512
|
// siblings share a row equally whatever their content.
|
|
@@ -2832,7 +2872,7 @@ enum CraftNativeFlexLayout {
|
|
|
2832
2872
|
static func ceiled(_ size: CGSize, scale: CGFloat, atLeast floor: CGSize) -> CGSize {
|
|
2833
2873
|
let scale = max(1, scale)
|
|
2834
2874
|
// Less a hair, so a size already on the pixel grid stays put.
|
|
2835
|
-
func up(_ value: CGFloat) -> CGFloat { (value * scale - 0.001).rounded(.up) / scale }
|
|
2875
|
+
func up(_ value: CGFloat) -> CGFloat { (craftNativeLayoutNumber(value) * scale - 0.001).rounded(.up) / scale }
|
|
2836
2876
|
return CGSize(width: max(floor.width, up(size.width)), height: max(floor.height, up(size.height)))
|
|
2837
2877
|
}
|
|
2838
2878
|
|
|
@@ -2840,7 +2880,9 @@ enum CraftNativeFlexLayout {
|
|
|
2840
2880
|
/// neither overlap nor leave a hairline between them.
|
|
2841
2881
|
static func snapped(_ rect: CGRect, scale: CGFloat) -> CGRect {
|
|
2842
2882
|
let scale = max(1, scale)
|
|
2843
|
-
|
|
2883
|
+
// Whatever the arithmetic before this made of the styles it was
|
|
2884
|
+
// given, the frame UIKit is handed is one it accepts.
|
|
2885
|
+
func snap(_ value: CGFloat) -> CGFloat { (craftNativeLayoutNumber(value) * scale).rounded() / scale }
|
|
2844
2886
|
let minX = snap(rect.minX), minY = snap(rect.minY)
|
|
2845
2887
|
return CGRect(x: minX, y: minY, width: max(0, snap(rect.maxX) - minX), height: max(0, snap(rect.maxY) - minY))
|
|
2846
2888
|
}
|
|
@@ -3314,6 +3356,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
3314
3356
|
if moved {
|
|
3315
3357
|
craftScreenController?.scheduleLayoutEvents()
|
|
3316
3358
|
craftScreenController?.scheduleKeyboardInsets()
|
|
3359
|
+
craftScreenController?.scheduleFloatingClearance()
|
|
3317
3360
|
}
|
|
3318
3361
|
}
|
|
3319
3362
|
for (child, frame) in result.frames {
|
|
@@ -5001,7 +5044,7 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
5001
5044
|
/// these indices stay at the top of the visible area once scrolled to,
|
|
5002
5045
|
/// each pushed up by the next. Vertical scroll views only.
|
|
5003
5046
|
func setStickyHeaderIndices(_ value: Any?) {
|
|
5004
|
-
let next = Array(Set(((value as? [Any]) ?? []).compactMap { number($0).map {
|
|
5047
|
+
let next = Array(Set(((value as? [Any]) ?? []).compactMap { number($0).map { craftNativeInt($0) } }.filter { $0 >= 0 })).sorted()
|
|
5005
5048
|
guard next != stickyIndices else { return }
|
|
5006
5049
|
stickyIndices = next
|
|
5007
5050
|
setNeedsLayout()
|
|
@@ -5117,6 +5160,19 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
5117
5160
|
setContentNeedsLayout()
|
|
5118
5161
|
}
|
|
5119
5162
|
|
|
5163
|
+
/// What covers the end of this scroll view from below: the keyboard, and
|
|
5164
|
+
/// the compact tab actions floating above the tab bar (#523). The content
|
|
5165
|
+
/// is inset by the larger, so its last lines can be scrolled clear of
|
|
5166
|
+
/// whichever reaches further.
|
|
5167
|
+
var keyboardCover: CGFloat = 0 { didSet { applyCover() } }
|
|
5168
|
+
var floatingCover: CGFloat = 0 { didSet { applyCover() } }
|
|
5169
|
+
|
|
5170
|
+
private func applyCover() {
|
|
5171
|
+
let cover = max(keyboardCover, floatingCover)
|
|
5172
|
+
if abs(contentInset.bottom - cover) > 0.5 { contentInset.bottom = cover }
|
|
5173
|
+
if abs(verticalScrollIndicatorInsets.bottom - cover) > 0.5 { verticalScrollIndicatorInsets.bottom = cover }
|
|
5174
|
+
}
|
|
5175
|
+
|
|
5120
5176
|
private var refreshingProp = false
|
|
5121
5177
|
|
|
5122
5178
|
/// `onRefresh` and `refreshing`: the system's pull to refresh, controlled
|
|
@@ -5525,7 +5581,7 @@ final class CraftNativeSvgView: UIView {
|
|
|
5525
5581
|
var out = from
|
|
5526
5582
|
var i = 0
|
|
5527
5583
|
while i < from.count {
|
|
5528
|
-
let command =
|
|
5584
|
+
let command = craftNativeInt(from[i])
|
|
5529
5585
|
guard to[i] == from[i] else { return from }
|
|
5530
5586
|
let count: Int
|
|
5531
5587
|
switch command {
|
|
@@ -5710,7 +5766,7 @@ final class CraftNativeSvgView: UIView {
|
|
|
5710
5766
|
guard !text.isEmpty else { return }
|
|
5711
5767
|
let props = node["props"] as? [String: Any] ?? [:]
|
|
5712
5768
|
let style = node["style"] as? [String: Any] ?? [:]
|
|
5713
|
-
let size = number(props["fontSize"]) ??
|
|
5769
|
+
let size = number(props["fontSize"]) ?? styleNumber(style["fontSize"]) ?? 12
|
|
5714
5770
|
let weight: UIFont.Weight = {
|
|
5715
5771
|
switch paint.fontWeight?.lowercased() {
|
|
5716
5772
|
case "500": return .medium
|
|
@@ -5752,7 +5808,7 @@ final class CraftNativeSvgView: UIView {
|
|
|
5752
5808
|
var i = 0
|
|
5753
5809
|
func point(_ at: Int) -> CGPoint { CGPoint(x: n[at], y: n[at + 1]) }
|
|
5754
5810
|
while i < n.count {
|
|
5755
|
-
switch
|
|
5811
|
+
switch craftNativeInt(n[i]) {
|
|
5756
5812
|
case 0 where i + 2 < n.count: path.move(to: point(i + 1)); i += 3
|
|
5757
5813
|
case 1 where i + 2 < n.count: path.addLine(to: point(i + 1)); i += 3
|
|
5758
5814
|
case 2 where i + 6 < n.count: path.addCurve(to: point(i + 5), controlPoint1: point(i + 1), controlPoint2: point(i + 3)); i += 7
|
|
@@ -6857,6 +6913,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6857
6913
|
/// targeted update of this subtree inherits it without its ancestors.
|
|
6858
6914
|
var inheritedFontFamily: String?
|
|
6859
6915
|
var children: [RenderedNode] = []
|
|
6916
|
+
/// `autoFocus` was on when this input was last drawn. Focus is taken
|
|
6917
|
+
/// when it turns on, which mounting with it on is, and not on each
|
|
6918
|
+
/// render while it stays on (#511).
|
|
6919
|
+
var autoFocus = false
|
|
6860
6920
|
var widthConstraint: NSLayoutConstraint?
|
|
6861
6921
|
var heightConstraint: NSLayoutConstraint?
|
|
6862
6922
|
|
|
@@ -7015,6 +7075,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7015
7075
|
/// Where the keyboard is on the screen while it is up, in screen coordinates.
|
|
7016
7076
|
private var keyboardScreenFrame: CGRect?
|
|
7017
7077
|
private var keyboardInsetsScheduled = false
|
|
7078
|
+
private var floatingClearanceScheduled = false
|
|
7079
|
+
/// Whether any scroll view may still hold room for actions that have gone.
|
|
7080
|
+
private var floatingClearanceApplied = false
|
|
7018
7081
|
/// The hybrid shell (CraftHybrid.swift), when this screen is one of a web
|
|
7019
7082
|
/// app's native screens: told of the first frame, of a first frame that
|
|
7020
7083
|
/// could not be drawn, and of `craft.navigation.open(path)`.
|
|
@@ -7396,6 +7459,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7396
7459
|
emitLayoutEvents()
|
|
7397
7460
|
sendViewportIfChanged()
|
|
7398
7461
|
scheduleKeyboardInsets()
|
|
7462
|
+
scheduleFloatingClearance()
|
|
7399
7463
|
}
|
|
7400
7464
|
|
|
7401
7465
|
/// The width the bundle last heard, so a layout pass at the same size says nothing.
|
|
@@ -8816,6 +8880,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8816
8880
|
}
|
|
8817
8881
|
// The app script has no screen for the keyboard to cover.
|
|
8818
8882
|
if !isAppScript {
|
|
8883
|
+
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
8884
|
+
forName: CraftChrome.floatingActionsDidMove,
|
|
8885
|
+
object: nil,
|
|
8886
|
+
queue: .main
|
|
8887
|
+
) { [weak self] _ in
|
|
8888
|
+
self?.scheduleFloatingClearance()
|
|
8889
|
+
})
|
|
8819
8890
|
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
8820
8891
|
forName: UIResponder.keyboardWillChangeFrameNotification,
|
|
8821
8892
|
object: nil,
|
|
@@ -8960,7 +9031,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8960
9031
|
/// took account of) insets its content by that much, so everything in it
|
|
8961
9032
|
/// can be scrolled above the keyboard. Internal for XCTest.
|
|
8962
9033
|
func insetScrollViewsForKeyboard() {
|
|
8963
|
-
var scrolls: [
|
|
9034
|
+
var scrolls: [CraftNativeScrollView] = []
|
|
8964
9035
|
func collect(_ node: RenderedNode?) {
|
|
8965
9036
|
guard let node else { return }
|
|
8966
9037
|
if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical { scrolls.append(scroll) }
|
|
@@ -8977,8 +9048,51 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8977
9048
|
let automaticBottom = max(0, scroll.adjustedContentInset.bottom - scroll.contentInset.bottom)
|
|
8978
9049
|
overlap = max(0, overlap - automaticBottom)
|
|
8979
9050
|
}
|
|
8980
|
-
|
|
8981
|
-
|
|
9051
|
+
// Its own inset is what is being measured, not part of the answer.
|
|
9052
|
+
scroll.keyboardCover = overlap
|
|
9053
|
+
}
|
|
9054
|
+
}
|
|
9055
|
+
|
|
9056
|
+
/// The compact tab actions float over the trailing end of whatever
|
|
9057
|
+
/// scrolls behind them. Each vertical scroll view and list they reach
|
|
9058
|
+
/// into gets that much room at its end, so its last row can be brought
|
|
9059
|
+
/// clear of them; one that ends above them gets none. How wide a scroll
|
|
9060
|
+
/// view is does not come into it: a page of a pager that is off to one
|
|
9061
|
+
/// side now is under the actions once it is swiped to. The safe area and
|
|
9062
|
+
/// the viewport are left alone: content still scrolls behind the
|
|
9063
|
+
/// actions. Internal for XCTest.
|
|
9064
|
+
func insetScrollViewsForFloatingActions() {
|
|
9065
|
+
let chrome = CraftChrome.shared
|
|
9066
|
+
let actions = chrome.floatingActions.flatMap { frame in
|
|
9067
|
+
chrome.floatingActionsHost.flatMap { view.isDescendant(of: $0) ? frame : nil }
|
|
9068
|
+
}
|
|
9069
|
+
func cover(_ scroll: UIScrollView) -> CGFloat {
|
|
9070
|
+
guard let actions, let window = scroll.window else { return 0 }
|
|
9071
|
+
let frame = scroll.convert(scroll.bounds, to: window)
|
|
9072
|
+
// An edge-to-edge scroller already clears the tab bar through
|
|
9073
|
+
// UIKit's safe area. Add only what the actions reach above it.
|
|
9074
|
+
let automaticBottom = max(0, scroll.adjustedContentInset.bottom - scroll.contentInset.bottom)
|
|
9075
|
+
return max(0, frame.maxY - actions.minY - automaticBottom)
|
|
9076
|
+
}
|
|
9077
|
+
func visit(_ node: RenderedNode?) {
|
|
9078
|
+
guard let node else { return }
|
|
9079
|
+
if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical { scroll.floatingCover = cover(scroll) }
|
|
9080
|
+
else if let list = node.view as? CraftNativeFlatList { list.floatingCover = cover(list) }
|
|
9081
|
+
node.children.forEach(visit)
|
|
9082
|
+
}
|
|
9083
|
+
visit(renderedRoot)
|
|
9084
|
+
floatingClearanceApplied = actions != nil
|
|
9085
|
+
}
|
|
9086
|
+
|
|
9087
|
+
/// A layout moved a view, or the actions moved: measured again once the
|
|
9088
|
+
/// pass is over, where each scroll view now ends.
|
|
9089
|
+
fileprivate func scheduleFloatingClearance() {
|
|
9090
|
+
guard CraftChrome.shared.floatingActions != nil || floatingClearanceApplied, !floatingClearanceScheduled else { return }
|
|
9091
|
+
floatingClearanceScheduled = true
|
|
9092
|
+
DispatchQueue.main.async { [weak self] in
|
|
9093
|
+
guard let self else { return }
|
|
9094
|
+
self.floatingClearanceScheduled = false
|
|
9095
|
+
self.insetScrollViewsForFloatingActions()
|
|
8982
9096
|
}
|
|
8983
9097
|
}
|
|
8984
9098
|
|
|
@@ -9766,12 +9880,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9766
9880
|
case "Text":
|
|
9767
9881
|
let text = children.compactMap { $0 as? String }.joined()
|
|
9768
9882
|
if let selectable = result as? CraftNativeSelectableTextView {
|
|
9769
|
-
selectable.textContainer.maximumNumberOfLines = max(0,
|
|
9883
|
+
selectable.textContainer.maximumNumberOfLines = max(0, craftNativeInt(number(props["numberOfLines"]) ?? 0))
|
|
9770
9884
|
selectable.textContainer.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
|
|
9771
9885
|
configureSelectableText(selectable, text: text, style: style)
|
|
9772
9886
|
} else {
|
|
9773
9887
|
let label = result as! UILabel
|
|
9774
|
-
label.numberOfLines = max(0,
|
|
9888
|
+
label.numberOfLines = max(0, craftNativeInt(number(props["numberOfLines"]) ?? 0))
|
|
9775
9889
|
label.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
|
|
9776
9890
|
(label as? CraftNativeLabel)?.insets = paddingInsets(style)
|
|
9777
9891
|
configureText(label, text: text, style: style)
|
|
@@ -9830,6 +9944,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9830
9944
|
? nil
|
|
9831
9945
|
: inputDrafts[current.identity] ?? props["defaultValue"] as? String
|
|
9832
9946
|
)
|
|
9947
|
+
applyAutoFocus(props["autoFocus"], to: textView, node: current)
|
|
9833
9948
|
break
|
|
9834
9949
|
}
|
|
9835
9950
|
let field = result as! UITextField
|
|
@@ -9848,7 +9963,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9848
9963
|
field.tintColor = color(props["selectionColor"])
|
|
9849
9964
|
let fieldId = ObjectIdentifier(field)
|
|
9850
9965
|
inputIdentities[fieldId] = current.identity
|
|
9851
|
-
let maxLength =
|
|
9966
|
+
let maxLength = craftNativeInt(number(props["maxLength"]) ?? 0)
|
|
9852
9967
|
if maxLength > 0 {
|
|
9853
9968
|
textMaxLengths[fieldId] = maxLength
|
|
9854
9969
|
} else {
|
|
@@ -9890,12 +10005,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9890
10005
|
updateAuxiliaryHandler(events["onBlur"], in: &blurHandlers, for: field)
|
|
9891
10006
|
updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: field)
|
|
9892
10007
|
updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: field)
|
|
9893
|
-
|
|
9894
|
-
DispatchQueue.main.async { [weak self] in
|
|
9895
|
-
guard self?.isLeavingScreen == false else { return }
|
|
9896
|
-
_ = field.becomeFirstResponder()
|
|
9897
|
-
}
|
|
9898
|
-
}
|
|
10008
|
+
applyAutoFocus(props["autoFocus"], to: field, node: current)
|
|
9899
10009
|
case "SegmentedControl":
|
|
9900
10010
|
let control = result as! CraftNativeSegmentedControl
|
|
9901
10011
|
let items = props["items"] as? [[String: Any]] ?? []
|
|
@@ -9964,7 +10074,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9964
10074
|
if picker.preferredDatePickerStyle != display { picker.preferredDatePickerStyle = display }
|
|
9965
10075
|
}
|
|
9966
10076
|
// UIKit takes an interval that divides an hour, and ignores others.
|
|
9967
|
-
let interval =
|
|
10077
|
+
let interval = craftNativeInt(number(props["minuteInterval"]) ?? 1)
|
|
9968
10078
|
picker.minuteInterval = (1...30).contains(interval) && 60 % interval == 0 ? interval : 1
|
|
9969
10079
|
picker.minimumDate = Self.pickerDate(props["minimumDate"])
|
|
9970
10080
|
picker.maximumDate = Self.pickerDate(props["maximumDate"])
|
|
@@ -10010,7 +10120,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10010
10120
|
modalFocusRestore[modalID] = focusBeforeContent
|
|
10011
10121
|
}
|
|
10012
10122
|
if visible, wasVisible != true { positionModals() }
|
|
10013
|
-
modal.setVisible(visible, opacity:
|
|
10123
|
+
modal.setVisible(visible, opacity: styleNumber(style["opacity"]) ?? 1, animationType: props["animationType"] as? String)
|
|
10014
10124
|
if !visible, wasVisible == true {
|
|
10015
10125
|
let restore = modalFocusRestore.removeValue(forKey: modalID)
|
|
10016
10126
|
DispatchQueue.main.async { [weak self, weak restore] in
|
|
@@ -10037,7 +10147,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10037
10147
|
let disabled = props["disabled"] as? Bool == true
|
|
10038
10148
|
pressable.pressActiveOpacity = disabled
|
|
10039
10149
|
? nil : number(props["activeOpacity"]) ?? 0.5
|
|
10040
|
-
pressable.pressBaseOpacity =
|
|
10150
|
+
pressable.pressBaseOpacity = styleNumber(style["opacity"]) ?? 1
|
|
10041
10151
|
pressable.pressUnderlayColor = type == "TouchableHighlight" && !disabled ? color(props["underlayColor"]) : nil
|
|
10042
10152
|
case "Switch":
|
|
10043
10153
|
if let toggle = result as? CraftNativeSolidSwitch {
|
|
@@ -10647,7 +10757,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10647
10757
|
list.apply(
|
|
10648
10758
|
nodes: children,
|
|
10649
10759
|
horizontal: props["horizontal"] as? Bool == true,
|
|
10650
|
-
columns:
|
|
10760
|
+
columns: craftNativeInt(number(props["numColumns"]) ?? 1),
|
|
10651
10761
|
inverted: props["inverted"] as? Bool == true,
|
|
10652
10762
|
endReachedThreshold: threshold,
|
|
10653
10763
|
renderItem: { [weak self] node, identity, reusable in
|
|
@@ -11180,13 +11290,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11180
11290
|
}
|
|
11181
11291
|
// An input's own scrolling follows its height (CraftNativeTextView).
|
|
11182
11292
|
if !(textView is CraftNativeTextView) { textView.isScrollEnabled = false }
|
|
11183
|
-
let numberOfLines =
|
|
11293
|
+
let numberOfLines = craftNativeInt(number(props["numberOfLines"]) ?? 0)
|
|
11184
11294
|
if numberOfLines > 0 {
|
|
11185
11295
|
textView.textContainer.maximumNumberOfLines = numberOfLines
|
|
11186
11296
|
} else {
|
|
11187
11297
|
textView.textContainer.maximumNumberOfLines = 0
|
|
11188
11298
|
}
|
|
11189
|
-
let maxLength =
|
|
11299
|
+
let maxLength = craftNativeInt(number(props["maxLength"]) ?? 0)
|
|
11190
11300
|
if maxLength > 0 {
|
|
11191
11301
|
textMaxLengths[id] = maxLength
|
|
11192
11302
|
} else {
|
|
@@ -11216,11 +11326,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11216
11326
|
updateAuxiliaryHandler(events["onBlur"], in: &blurHandlers, for: textView)
|
|
11217
11327
|
updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: textView)
|
|
11218
11328
|
updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: textView)
|
|
11219
|
-
|
|
11220
|
-
|
|
11221
|
-
|
|
11222
|
-
|
|
11223
|
-
|
|
11329
|
+
}
|
|
11330
|
+
|
|
11331
|
+
/// `autoFocus`: focus when the input mounts with it, or when it is turned
|
|
11332
|
+
/// on later. Applied on every render it took the keyboard back each time
|
|
11333
|
+
/// anything else on the screen changed (#511).
|
|
11334
|
+
private func applyAutoFocus(_ value: Any?, to input: UIView, node: RenderedNode) {
|
|
11335
|
+
let wanted = value as? Bool == true
|
|
11336
|
+
let turnedOn = wanted && !node.autoFocus
|
|
11337
|
+
node.autoFocus = wanted
|
|
11338
|
+
guard turnedOn, !input.isFirstResponder else { return }
|
|
11339
|
+
DispatchQueue.main.async { [weak self] in
|
|
11340
|
+
guard self?.isLeavingScreen == false else { return }
|
|
11341
|
+
_ = input.becomeFirstResponder()
|
|
11224
11342
|
}
|
|
11225
11343
|
}
|
|
11226
11344
|
|
|
@@ -11230,8 +11348,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11230
11348
|
/// leaving a hole.
|
|
11231
11349
|
private func configureIcon(_ icon: UIImageView, props: [String: Any], style: [String: Any]) {
|
|
11232
11350
|
let name = (props["symbol"] as? String) ?? (props["name"] as? String) ?? ""
|
|
11233
|
-
let box = [
|
|
11234
|
-
let fontSize =
|
|
11351
|
+
let box = [styleNumber(style["width"]), styleNumber(style["height"])].compactMap { $0 }.min()
|
|
11352
|
+
let fontSize = styleNumber(style["fontSize"])
|
|
11235
11353
|
// Without a size of its own an icon is a 20 pt square (contract A2).
|
|
11236
11354
|
(icon as? CraftNativeIconView)?.defaultBox = box == nil && fontSize == nil ? 20 : nil
|
|
11237
11355
|
let pointSize = fontSize ?? (box ?? 20) * 0.84
|
|
@@ -11272,10 +11390,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11272
11390
|
stack.alignItems = style["alignItems"] as? String ?? "stretch"
|
|
11273
11391
|
stack.alignContent = style["alignContent"] as? String ?? "flex-start"
|
|
11274
11392
|
stack.justifyContent = style["justifyContent"] as? String ?? "flex-start"
|
|
11275
|
-
let gap = max(0,
|
|
11393
|
+
let gap = max(0, styleNumber(style["gap"]) ?? 0)
|
|
11276
11394
|
stack.gap = gap
|
|
11277
|
-
stack.rowGap = max(0,
|
|
11278
|
-
stack.columnGap = max(0,
|
|
11395
|
+
stack.rowGap = max(0, styleNumber(style["rowGap"]) ?? gap)
|
|
11396
|
+
stack.columnGap = max(0, styleNumber(style["columnGap"]) ?? gap)
|
|
11279
11397
|
stack.grid = style["display"] as? String == "grid"
|
|
11280
11398
|
stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
|
|
11281
11399
|
stack.gridRows = craftNativeGridRowCount(style["gridTemplateRows"])
|
|
@@ -11287,8 +11405,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11287
11405
|
private func addJustificationSpacers(to stack: CraftNativeFlowView, value: String?) { }
|
|
11288
11406
|
|
|
11289
11407
|
private func applyViewStyle(_ style: [String: Any], to view: UIView, node: RenderedNode) {
|
|
11290
|
-
let width = style["minWidth"] == nil && style["maxWidth"] == nil ?
|
|
11291
|
-
let height = style["minHeight"] == nil && style["maxHeight"] == nil ?
|
|
11408
|
+
let width = style["minWidth"] == nil && style["maxWidth"] == nil ? styleNumber(style["width"]) : nil
|
|
11409
|
+
let height = style["minHeight"] == nil && style["maxHeight"] == nil ? styleNumber(style["height"]) : nil
|
|
11292
11410
|
updateDimension(width, constraint: &node.widthConstraint, anchor: view.widthAnchor)
|
|
11293
11411
|
updateDimension(height, constraint: &node.heightConstraint, anchor: view.heightAnchor)
|
|
11294
11412
|
view.backgroundColor = color(style["backgroundColor"]) ?? .clear
|
|
@@ -11307,31 +11425,31 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11307
11425
|
direction: style["gradientDirection"] as? String,
|
|
11308
11426
|
colors: [color(style["gradientFrom"]), color(style["gradientVia"]), color(style["gradientTo"])]
|
|
11309
11427
|
)
|
|
11310
|
-
view.alpha =
|
|
11428
|
+
view.alpha = styleNumber(style["opacity"]) ?? 1
|
|
11311
11429
|
view.isHidden = style["display"] as? String == "none"
|
|
11312
|
-
let borderRadius =
|
|
11430
|
+
let borderRadius = styleNumber(style["borderRadius"]) ?? 0
|
|
11313
11431
|
let cornerRadiusKeys = ["borderTopLeftRadius", "borderTopRightRadius", "borderBottomRightRadius", "borderBottomLeftRadius"]
|
|
11314
11432
|
let hasCornerRadii = cornerRadiusKeys.contains { style[$0] != nil }
|
|
11315
11433
|
view.craftRequestedCornerRadius = borderRadius
|
|
11316
11434
|
view.craftRequestedCornerRadii = hasCornerRadii
|
|
11317
|
-
? cornerRadiusKeys.map {
|
|
11435
|
+
? cornerRadiusKeys.map { styleNumber(style[$0]) ?? borderRadius }
|
|
11318
11436
|
: nil
|
|
11319
|
-
let borderWidth =
|
|
11437
|
+
let borderWidth = styleNumber(style["borderWidth"]) ?? 0
|
|
11320
11438
|
let borderColor = color(style["borderColor"]) ?? .clear
|
|
11321
11439
|
let sideWidthKeys = ["borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth"]
|
|
11322
11440
|
let sideColorKeys = ["borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor"]
|
|
11323
11441
|
view.craftApplySideBorders(
|
|
11324
11442
|
(sideWidthKeys + sideColorKeys).contains { style[$0] != nil },
|
|
11325
11443
|
style: style["borderStyle"] as? String,
|
|
11326
|
-
widths: sideWidthKeys.map {
|
|
11444
|
+
widths: sideWidthKeys.map { styleNumber(style[$0]) ?? borderWidth },
|
|
11327
11445
|
colors: sideColorKeys.map { color(style[$0]) ?? borderColor }
|
|
11328
11446
|
)
|
|
11329
|
-
view.layer.zPosition =
|
|
11447
|
+
view.layer.zPosition = styleNumber(style["zIndex"]) ?? 0
|
|
11330
11448
|
applyTransform(style["transform"], to: view)
|
|
11331
|
-
let elevation = max(0,
|
|
11449
|
+
let elevation = max(0, styleNumber(style["elevation"]) ?? 0)
|
|
11332
11450
|
let shadowColor = color(style["shadowColor"]) ?? .black
|
|
11333
|
-
let shadowOpacity = min(1, max(0,
|
|
11334
|
-
let shadowRadius = max(0,
|
|
11451
|
+
let shadowOpacity = min(1, max(0, styleNumber(style["shadowOpacity"]) ?? (elevation > 0 ? min(0.28, 0.12 + elevation * 0.02) : 0)))
|
|
11452
|
+
let shadowRadius = max(0, styleNumber(style["shadowRadius"]) ?? elevation * 0.5)
|
|
11335
11453
|
let shadowOffset = style["shadowOffset"] as? [String: Any]
|
|
11336
11454
|
view.layer.shadowColor = shadowColor.cgColor
|
|
11337
11455
|
view.layer.shadowOpacity = Float(shadowOpacity)
|
|
@@ -11364,7 +11482,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11364
11482
|
|
|
11365
11483
|
private func paddingInsets(_ style: [String: Any]) -> UIEdgeInsets {
|
|
11366
11484
|
func nonNegative(_ value: Any?) -> CGFloat? {
|
|
11367
|
-
guard let value =
|
|
11485
|
+
guard let value = styleNumber(value), value >= 0 else { return nil }
|
|
11368
11486
|
return value
|
|
11369
11487
|
}
|
|
11370
11488
|
let padding = nonNegative(style["padding"]) ?? 0
|
|
@@ -11387,11 +11505,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11387
11505
|
var threeDimensional = CATransform3DIdentity
|
|
11388
11506
|
var hasThreeDimensionalRotation = false
|
|
11389
11507
|
for transform in transforms {
|
|
11390
|
-
if let value =
|
|
11391
|
-
if let value =
|
|
11392
|
-
if let value =
|
|
11393
|
-
if let value =
|
|
11394
|
-
if let value =
|
|
11508
|
+
if let value = styleNumber(transform["translateX"]) { affine = affine.translatedBy(x: value, y: 0) }
|
|
11509
|
+
if let value = styleNumber(transform["translateY"]) { affine = affine.translatedBy(x: 0, y: value) }
|
|
11510
|
+
if let value = styleNumber(transform["scale"]) { affine = affine.scaledBy(x: value, y: value) }
|
|
11511
|
+
if let value = styleNumber(transform["scaleX"]) { affine = affine.scaledBy(x: value, y: 1) }
|
|
11512
|
+
if let value = styleNumber(transform["scaleY"]) { affine = affine.scaledBy(x: 1, y: value) }
|
|
11395
11513
|
if let value = transformAngle(transform["rotate"] ?? transform["rotateZ"]) { affine = affine.rotated(by: value) }
|
|
11396
11514
|
if let value = transformAngle(transform["skewX"]) { affine = affine.concatenating(CGAffineTransform(a: 1, b: 0, c: tan(value), d: 1, tx: 0, ty: 0)) }
|
|
11397
11515
|
if let value = transformAngle(transform["skewY"]) { affine = affine.concatenating(CGAffineTransform(a: 1, b: tan(value), c: 0, d: 1, tx: 0, ty: 0)) }
|
|
@@ -11429,10 +11547,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11429
11547
|
label.font = textFont(style, default: .systemFont(ofSize: 16))
|
|
11430
11548
|
label.textAlignment = textAlignment(style["textAlign"])
|
|
11431
11549
|
var attributes: [NSAttributedString.Key: Any] = [:]
|
|
11432
|
-
if let spacing =
|
|
11550
|
+
if let spacing = styleNumber(style["letterSpacing"]) { attributes[.kern] = spacing }
|
|
11433
11551
|
addTextDecorationAttributes(style, to: &attributes)
|
|
11434
11552
|
if let shadow = textShadow(style) { attributes[.shadow] = shadow }
|
|
11435
|
-
if let lineHeight =
|
|
11553
|
+
if let lineHeight = styleNumber(style["lineHeight"]) {
|
|
11436
11554
|
let paragraph = NSMutableParagraphStyle()
|
|
11437
11555
|
paragraph.minimumLineHeight = lineHeight
|
|
11438
11556
|
paragraph.maximumLineHeight = lineHeight
|
|
@@ -11486,8 +11604,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11486
11604
|
.foregroundColor: color,
|
|
11487
11605
|
.font: font,
|
|
11488
11606
|
]
|
|
11489
|
-
if let spacing =
|
|
11490
|
-
if let lineHeight =
|
|
11607
|
+
if let spacing = styleNumber(style["letterSpacing"]) { attributes[.kern] = spacing }
|
|
11608
|
+
if let lineHeight = styleNumber(style["lineHeight"]) {
|
|
11491
11609
|
let paragraph = NSMutableParagraphStyle()
|
|
11492
11610
|
paragraph.minimumLineHeight = lineHeight
|
|
11493
11611
|
paragraph.maximumLineHeight = lineHeight
|
|
@@ -11509,14 +11627,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11509
11627
|
.foregroundColor: color(style["color"]) ?? .label,
|
|
11510
11628
|
.font: font,
|
|
11511
11629
|
]
|
|
11512
|
-
if let spacing =
|
|
11630
|
+
if let spacing = styleNumber(style["letterSpacing"]) { attributes[.kern] = spacing }
|
|
11513
11631
|
// UIKit otherwise resets the field's alignment to natural when these attributes are applied.
|
|
11514
11632
|
let paragraph = NSMutableParagraphStyle()
|
|
11515
11633
|
paragraph.alignment = alignment
|
|
11516
11634
|
// The default paragraph wraps even inside a UITextField. A long URL
|
|
11517
11635
|
// then paints a second line outside the field instead of scrolling.
|
|
11518
11636
|
if singleLine { paragraph.lineBreakMode = .byClipping }
|
|
11519
|
-
if let lineHeight =
|
|
11637
|
+
if let lineHeight = styleNumber(style["lineHeight"]) {
|
|
11520
11638
|
paragraph.minimumLineHeight = lineHeight
|
|
11521
11639
|
paragraph.maximumLineHeight = lineHeight
|
|
11522
11640
|
}
|
|
@@ -11535,7 +11653,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11535
11653
|
width: number(offset?["width"]) ?? 0,
|
|
11536
11654
|
height: number(offset?["height"]) ?? 0
|
|
11537
11655
|
)
|
|
11538
|
-
shadow.shadowBlurRadius = max(0,
|
|
11656
|
+
shadow.shadowBlurRadius = max(0, styleNumber(style["textShadowRadius"]) ?? 0)
|
|
11539
11657
|
return shadow
|
|
11540
11658
|
}
|
|
11541
11659
|
|
|
@@ -11574,7 +11692,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11574
11692
|
}
|
|
11575
11693
|
|
|
11576
11694
|
private func textFont(_ style: [String: Any], default fallback: UIFont) -> UIFont {
|
|
11577
|
-
let size =
|
|
11695
|
+
let size = styleNumber(style["fontSize"]) ?? fallback.pointSize
|
|
11578
11696
|
let weight: UIFont.Weight
|
|
11579
11697
|
switch craftNativeFontWeight(style["fontWeight"]) {
|
|
11580
11698
|
case "100": weight = .ultraLight
|
|
@@ -317,6 +317,13 @@ enum CraftWorkoutPlanBuilder {
|
|
|
317
317
|
return double.isFinite ? double : nil
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
/// A number as a whole one, toward zero, or nil when no `Int` holds it.
|
|
321
|
+
/// `Int(_:)` traps there, and these come from the caller: an `hour` of
|
|
322
|
+
/// `1e30` ended the process where it should have been refused (#510).
|
|
323
|
+
private static func whole(_ value: Any?) -> Int? {
|
|
324
|
+
number(value).flatMap { Int(exactly: $0.rounded(.towardZero)) }
|
|
325
|
+
}
|
|
326
|
+
|
|
320
327
|
/// Two schedule dates as WorkoutKit compares them: the day and the time of day.
|
|
321
328
|
static func sameMoment(_ a: DateComponents, _ b: DateComponents) -> Bool {
|
|
322
329
|
a.year == b.year && a.month == b.month && a.day == b.day && (a.hour ?? 0) == (b.hour ?? 0) && (a.minute ?? 0) == (b.minute ?? 0)
|
|
@@ -328,8 +335,8 @@ enum CraftWorkoutPlanBuilder {
|
|
|
328
335
|
let parts = text.split(separator: "-", omittingEmptySubsequences: false)
|
|
329
336
|
guard parts.count == 3, parts[0].count == 4, parts[1].count == 2, parts[2].count == 2,
|
|
330
337
|
let year = Int(parts[0]), let month = Int(parts[1]), let day = Int(parts[2]) else { return nil }
|
|
331
|
-
let hour = body["hour"] == nil || body["hour"] is NSNull ? 7 :
|
|
332
|
-
let minute = body["minute"] == nil || body["minute"] is NSNull ? 0 :
|
|
338
|
+
let hour = body["hour"] == nil || body["hour"] is NSNull ? 7 : whole(body["hour"])
|
|
339
|
+
let minute = body["minute"] == nil || body["minute"] is NSNull ? 0 : whole(body["minute"])
|
|
333
340
|
guard let hour, let minute, (0...23).contains(hour), (0...59).contains(minute) else { return nil }
|
|
334
341
|
var components = DateComponents()
|
|
335
342
|
components.year = year
|
|
@@ -379,7 +386,7 @@ enum CraftWorkoutPlanBuilder {
|
|
|
379
386
|
}
|
|
380
387
|
func zone() -> Int? {
|
|
381
388
|
guard let value = number(body["zone"]), value >= 1, value.rounded() == value else { return nil }
|
|
382
|
-
return Int(value)
|
|
389
|
+
return Int(exactly: value)
|
|
383
390
|
}
|
|
384
391
|
switch type {
|
|
385
392
|
case "heartRate":
|
|
@@ -485,7 +492,7 @@ enum CraftWorkoutPlanBuilder {
|
|
|
485
492
|
var blocks: [IntervalBlock] = []
|
|
486
493
|
for (blockIndex, value) in (workout["blocks"] as? [Any] ?? []).enumerated() {
|
|
487
494
|
let block = value as? [String: Any] ?? [:]
|
|
488
|
-
let iterations =
|
|
495
|
+
let iterations = whole(block["iterations"] ?? 1) ?? 0
|
|
489
496
|
guard iterations >= 1 else { return refuse("block \(blockIndex + 1) needs iterations of 1 or more") }
|
|
490
497
|
var steps: [IntervalStep] = []
|
|
491
498
|
for (stepIndex, entry) in (block["steps"] as? [Any] ?? []).enumerated() {
|
|
@@ -503,6 +503,25 @@ final class CraftAudioBufferSource: CraftAudioSource {
|
|
|
503
503
|
private var played = 0.0
|
|
504
504
|
private var began = false
|
|
505
505
|
|
|
506
|
+
/// An arbitrary limit on the rate, and WebKit's: `MaxRate` in its
|
|
507
|
+
/// AudioBufferSourceNode.
|
|
508
|
+
static let maximumRate = 1024.0
|
|
509
|
+
|
|
510
|
+
/// The rate a buffer plays at: its own sample rate over the context's,
|
|
511
|
+
/// times `playbackRate`, times two to the power of `detune` in octaves.
|
|
512
|
+
///
|
|
513
|
+
/// Neither param is bounded for a buffer source, so the product can be
|
|
514
|
+
/// infinite (a detune of 2,000,000 cents) or not a number (that, with a
|
|
515
|
+
/// playbackRate of 0). A looping source wrapped an infinite position into
|
|
516
|
+
/// one that is not a number, and `Int(position)` traps on it: the render
|
|
517
|
+
/// thread ended the process. WebKit clamps the rate to its limit either
|
|
518
|
+
/// side of zero and plays a rate that is not a number at 1; so does this.
|
|
519
|
+
static func rate(ratio: Double, playbackRate: Double, detune: Double) -> Double {
|
|
520
|
+
let rate = ratio * playbackRate * pow(2, detune / 1200)
|
|
521
|
+
guard !rate.isNaN else { return 1 }
|
|
522
|
+
return max(-maximumRate, min(maximumRate, rate))
|
|
523
|
+
}
|
|
524
|
+
|
|
506
525
|
override init(id: Int) {
|
|
507
526
|
super.init(id: id)
|
|
508
527
|
params = [playbackRate, detune]
|
|
@@ -530,7 +549,7 @@ final class CraftAudioBufferSource: CraftAudioSource {
|
|
|
530
549
|
position = loopFrom + (position - loopFrom).truncatingRemainder(dividingBy: span)
|
|
531
550
|
}
|
|
532
551
|
}
|
|
533
|
-
let rate = buffer.sampleRate / render.sampleRate
|
|
552
|
+
let rate = Self.rate(ratio: buffer.sampleRate / render.sampleRate, playbackRate: Double(playbackRate.values[0]), detune: Double(detune.values[0]))
|
|
534
553
|
let first = buffer.channels[0]
|
|
535
554
|
let second = buffer.channels.count > 1 ? buffer.channels[1] : first
|
|
536
555
|
let frames = buffer.length
|