@craft-native/ios 0.0.241 → 0.0.242
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 +22 -7
- package/dist/index.d.ts +12 -1
- package/dist/index.js +24 -7
- package/package.json +1 -1
- package/templates/CraftApp.swift +74 -23
- package/templates/CraftHybrid.swift +193 -29
- package/templates/CraftNativeActions.swift +26 -27
- package/templates/CraftNativeFiles.swift +205 -1
- package/templates/CraftNativeScreen.swift +159 -20
|
@@ -1755,6 +1755,25 @@ private var craftNativeCornerRadiiKey: UInt8 = 0
|
|
|
1755
1755
|
private var craftNativeBorderLayerKey: UInt8 = 0
|
|
1756
1756
|
private var craftNativeSideBorderLayersKey: UInt8 = 0
|
|
1757
1757
|
|
|
1758
|
+
private let craftAllCorners: CACornerMask = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMaxXMaxYCorner, .layerMinXMaxYCorner]
|
|
1759
|
+
|
|
1760
|
+
/// One radius for every rounded corner of `radii` (top-left, top-right,
|
|
1761
|
+
/// bottom-right, bottom-left), and which corners those are; nil when the
|
|
1762
|
+
/// rounded corners differ.
|
|
1763
|
+
func craftSharedCornerRadius(_ radii: [CGFloat]) -> (radius: CGFloat, corners: CACornerMask)? {
|
|
1764
|
+
let order: [CACornerMask] = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMaxXMaxYCorner, .layerMinXMaxYCorner]
|
|
1765
|
+
var radius: CGFloat?
|
|
1766
|
+
var corners: CACornerMask = []
|
|
1767
|
+
for (index, corner) in order.enumerated() {
|
|
1768
|
+
let value = max(0, radii.count > index ? radii[index] : 0)
|
|
1769
|
+
guard value > 0 else { continue }
|
|
1770
|
+
if let radius, abs(radius - value) > 0.01 { return nil }
|
|
1771
|
+
radius = value
|
|
1772
|
+
corners.insert(corner)
|
|
1773
|
+
}
|
|
1774
|
+
return radius.map { ($0, corners) } ?? (0, craftAllCorners)
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1758
1777
|
private func craftRoundedPath(_ rect: CGRect, _ rawRadii: [CGFloat]) -> CGPath {
|
|
1759
1778
|
let limit = min(rect.width, rect.height) / 2
|
|
1760
1779
|
let radii = (0..<4).map { min(max(0, rawRadii.count > $0 ? rawRadii[$0] : 0), limit) }
|
|
@@ -1805,25 +1824,62 @@ extension UIView {
|
|
|
1805
1824
|
let requested = craftRequestedCornerRadius ?? 0
|
|
1806
1825
|
let limit = min(bounds.width, bounds.height) / 2
|
|
1807
1826
|
let radius = limit > 0 ? min(requested, limit) : min(requested, 9_998)
|
|
1808
|
-
if let radii = craftRequestedCornerRadii {
|
|
1827
|
+
if let radii = craftRequestedCornerRadii, let shared = craftSharedCornerRadius(radii) {
|
|
1828
|
+
// The rounded corners share one radius (`rounded-t-md`): the
|
|
1829
|
+
// layer rounds just those corners itself. A mask layer is an
|
|
1830
|
+
// offscreen render pass for every frame the view is on screen,
|
|
1831
|
+
// which Apple names first among render-phase hitches. Clipped
|
|
1832
|
+
// like the mask was.
|
|
1833
|
+
let clamped = limit > 0 ? min(shared.radius, limit) : min(shared.radius, 9_998)
|
|
1834
|
+
layer.mask = nil
|
|
1835
|
+
if layer.cornerRadius != clamped { layer.cornerRadius = clamped }
|
|
1836
|
+
if layer.maskedCorners != shared.corners { layer.maskedCorners = shared.corners }
|
|
1837
|
+
if !layer.masksToBounds { layer.masksToBounds = true }
|
|
1838
|
+
} else if let radii = craftRequestedCornerRadii {
|
|
1839
|
+
// Corners of different radii: only a path can draw them.
|
|
1809
1840
|
let mask = (layer.mask as? CAShapeLayer) ?? CAShapeLayer()
|
|
1810
|
-
mask.frame = bounds
|
|
1841
|
+
if mask.frame != bounds { mask.frame = bounds }
|
|
1811
1842
|
mask.path = craftRoundedPath(bounds, radii)
|
|
1812
1843
|
layer.mask = mask
|
|
1813
1844
|
layer.cornerRadius = 0
|
|
1814
1845
|
} else {
|
|
1815
|
-
layer.mask = nil
|
|
1846
|
+
if layer.mask != nil { layer.mask = nil }
|
|
1847
|
+
if layer.maskedCorners != craftAllCorners { layer.maskedCorners = craftAllCorners }
|
|
1816
1848
|
if layer.cornerRadius != radius { layer.cornerRadius = radius }
|
|
1817
1849
|
}
|
|
1818
|
-
|
|
1819
|
-
|
|
1850
|
+
// A box shadow, as CSS draws it: the shape of the rounded box. Given
|
|
1851
|
+
// no path, Core Animation renders the layer offscreen every frame to
|
|
1852
|
+
// find the shadow's shape from its pixels.
|
|
1853
|
+
if layer.shadowOpacity > 0, bounds.width > 0, bounds.height > 0 {
|
|
1854
|
+
let cornerRadius = layer.mask == nil ? layer.cornerRadius : 0
|
|
1855
|
+
let shape: CGPath = layer.mask == nil
|
|
1856
|
+
? (layer.maskedCorners == craftAllCorners || cornerRadius == 0
|
|
1857
|
+
? CGPath(roundedRect: bounds, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil)
|
|
1858
|
+
: craftRoundedPath(bounds, [CACornerMask.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMaxXMaxYCorner, .layerMinXMaxYCorner].map { layer.maskedCorners.contains($0) ? cornerRadius : 0 }))
|
|
1859
|
+
: craftRoundedPath(bounds, craftRequestedCornerRadii ?? [])
|
|
1860
|
+
if layer.shadowPath != shape { layer.shadowPath = shape }
|
|
1861
|
+
} else if layer.shadowPath != nil {
|
|
1862
|
+
layer.shadowPath = nil
|
|
1863
|
+
}
|
|
1864
|
+
let border = objc_getAssociatedObject(self, &craftNativeBorderLayerKey) as? CAShapeLayer
|
|
1865
|
+
let sides = (objc_getAssociatedObject(self, &craftNativeSideBorderLayersKey) as? [CALayer]).flatMap { $0.count == 4 ? $0 : nil }
|
|
1866
|
+
guard border != nil || sides != nil else { return }
|
|
1867
|
+
// Border layers are the view's own sublayers, so a new frame or path
|
|
1868
|
+
// would animate implicitly; and a path set again, even an equal one,
|
|
1869
|
+
// draws the shape again. Layout runs for every child on every pass.
|
|
1870
|
+
CATransaction.begin()
|
|
1871
|
+
CATransaction.setDisableActions(true)
|
|
1872
|
+
defer { CATransaction.commit() }
|
|
1873
|
+
if let border {
|
|
1874
|
+
if border.frame != bounds { border.frame = bounds }
|
|
1820
1875
|
let inset = border.lineWidth / 2
|
|
1821
|
-
|
|
1876
|
+
let path = UIBezierPath(
|
|
1822
1877
|
roundedRect: bounds.insetBy(dx: inset, dy: inset),
|
|
1823
1878
|
cornerRadius: max(0, radius - inset)
|
|
1824
1879
|
).cgPath
|
|
1880
|
+
if border.path != path { border.path = path }
|
|
1825
1881
|
}
|
|
1826
|
-
if let sides
|
|
1882
|
+
if let sides {
|
|
1827
1883
|
let top = sides[0].frame.height
|
|
1828
1884
|
let right = sides[1].frame.width
|
|
1829
1885
|
let bottom = sides[2].frame.height
|
|
@@ -1835,9 +1891,10 @@ extension UIView {
|
|
|
1835
1891
|
CGRect(x: 0, y: 0, width: left, height: bounds.height),
|
|
1836
1892
|
]
|
|
1837
1893
|
for (index, side) in sides.enumerated() {
|
|
1838
|
-
side.frame = frames[index]
|
|
1894
|
+
if side.frame != frames[index] { side.frame = frames[index] }
|
|
1839
1895
|
if let shape = side as? CAShapeLayer {
|
|
1840
|
-
|
|
1896
|
+
let path = craftSideBorderPath(shape.bounds, index: index)
|
|
1897
|
+
if shape.path != path { shape.path = path }
|
|
1841
1898
|
}
|
|
1842
1899
|
}
|
|
1843
1900
|
}
|
|
@@ -4292,6 +4349,52 @@ private final class CraftNativeTextView: UITextView {
|
|
|
4292
4349
|
}
|
|
4293
4350
|
}
|
|
4294
4351
|
|
|
4352
|
+
/// The native screens' JavaScript. A screen is a JavaScriptCore context of
|
|
4353
|
+
/// its own and evaluates the whole file it is given, so each route has a file
|
|
4354
|
+
/// of its own when stx wrote one (`dist/native-screens/<route>.js`, stx's
|
|
4355
|
+
/// `routesDir`): a screen then parses its own code rather than every
|
|
4356
|
+
/// screen's. The one-file `dist/native-screen.js` remains the fallback.
|
|
4357
|
+
enum CraftNativeBundle {
|
|
4358
|
+
/// Every screen's context lives in this one virtual machine. They all run
|
|
4359
|
+
/// on the main thread, the machine's only one, and sharing it shares its
|
|
4360
|
+
/// code cache: a screen opened again (a second workout, a tab rebuilt)
|
|
4361
|
+
/// is not parsed again. Each screen keeps a global object of its own.
|
|
4362
|
+
static let virtualMachine = JSVirtualMachine()!
|
|
4363
|
+
|
|
4364
|
+
private static let sources: NSCache<NSString, NSString> = {
|
|
4365
|
+
let cache = NSCache<NSString, NSString>()
|
|
4366
|
+
// Room for the one-file bundle, or a dozen route files.
|
|
4367
|
+
cache.totalCostLimit = 24 << 20
|
|
4368
|
+
return cache
|
|
4369
|
+
}()
|
|
4370
|
+
|
|
4371
|
+
/// The file a route runs from: its own, else the one-file bundle.
|
|
4372
|
+
static func url(for route: String?) -> URL? {
|
|
4373
|
+
if let route, !route.isEmpty, !route.contains("/"), !route.hasPrefix("."),
|
|
4374
|
+
let own = Bundle.main.url(forResource: route, withExtension: "js", subdirectory: "dist/native-screens") {
|
|
4375
|
+
return own
|
|
4376
|
+
}
|
|
4377
|
+
return Bundle.main.url(forResource: "native-screen", withExtension: "js", subdirectory: "dist")
|
|
4378
|
+
?? Bundle.main.url(forResource: "native-screen", withExtension: "js")
|
|
4379
|
+
}
|
|
4380
|
+
|
|
4381
|
+
/// Whether the app has native screens to run at all.
|
|
4382
|
+
static var isAvailable: Bool {
|
|
4383
|
+
url(for: nil) != nil || Bundle.main.url(forResource: "native-screens", withExtension: nil, subdirectory: "dist") != nil
|
|
4384
|
+
}
|
|
4385
|
+
|
|
4386
|
+
/// The route's source, read from disk once and kept while memory allows:
|
|
4387
|
+
/// a push read and decoded the file on the main thread every time.
|
|
4388
|
+
static func source(for route: String?) -> (source: String, url: URL)? {
|
|
4389
|
+
guard let url = url(for: route) else { return nil }
|
|
4390
|
+
let key = url.path as NSString
|
|
4391
|
+
if let cached = sources.object(forKey: key) { return (cached as String, url) }
|
|
4392
|
+
guard let text = try? String(contentsOf: url, encoding: .utf8) else { return nil }
|
|
4393
|
+
sources.setObject(text as NSString, forKey: key, cost: text.utf8.count)
|
|
4394
|
+
return (text, url)
|
|
4395
|
+
}
|
|
4396
|
+
}
|
|
4397
|
+
|
|
4295
4398
|
final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate {
|
|
4296
4399
|
private final class RenderedNode {
|
|
4297
4400
|
let identity: String
|
|
@@ -4313,7 +4416,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4313
4416
|
private let config: CraftConfig
|
|
4314
4417
|
private let routeName: String?
|
|
4315
4418
|
private(set) var routeParams: [String: Any]
|
|
4316
|
-
private let jsContext = JSContext()!
|
|
4419
|
+
private let jsContext = JSContext(virtualMachine: CraftNativeBundle.virtualMachine)!
|
|
4317
4420
|
private let rootStack = CraftNativeFlowView()
|
|
4318
4421
|
private var handlers: [ObjectIdentifier: String] = [:]
|
|
4319
4422
|
private var pressInHandlers: [ObjectIdentifier: String] = [:]
|
|
@@ -4461,8 +4564,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4461
4564
|
rootLeadingToSafeArea!,
|
|
4462
4565
|
rootTrailingToSafeArea!
|
|
4463
4566
|
])
|
|
4464
|
-
|
|
4465
|
-
|
|
4567
|
+
let pacing = CraftFramePacing.shared
|
|
4568
|
+
pacing.measure("mount \(routeName ?? "screen") setup") { setupJavaScript() }
|
|
4569
|
+
pacing.measure("mount \(routeName ?? "screen") bundle") { loadBundle() }
|
|
4466
4570
|
observeNativeEvents()
|
|
4467
4571
|
if isAppScript {
|
|
4468
4572
|
// Its bundle has run, so its listeners are in place: the app's
|
|
@@ -4501,6 +4605,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4501
4605
|
|
|
4502
4606
|
override func viewWillDisappear(_ animated: Bool) {
|
|
4503
4607
|
super.viewWillDisappear(animated)
|
|
4608
|
+
screenVisibilityChanged(false)
|
|
4504
4609
|
stopCelebrations(in: view)
|
|
4505
4610
|
// The accessory belongs to the visible native screen. Clear the
|
|
4506
4611
|
// shared shell state before a hybrid/web route appears, otherwise a
|
|
@@ -4563,6 +4668,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4563
4668
|
if visible { restoreShellChrome() }
|
|
4564
4669
|
guard visible != reportedVisible else { return }
|
|
4565
4670
|
reportedVisible = visible
|
|
4671
|
+
if visible { DeepLinkManager.shared.replayNativeLinks() }
|
|
4566
4672
|
if visible, !hasAppeared {
|
|
4567
4673
|
hasAppeared = true
|
|
4568
4674
|
return
|
|
@@ -5133,13 +5239,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5133
5239
|
}
|
|
5134
5240
|
|
|
5135
5241
|
private func loadBundle() {
|
|
5136
|
-
guard let
|
|
5137
|
-
?? Bundle.main.url(forResource: "native-screen", withExtension: "js"),
|
|
5138
|
-
let script = try? String(contentsOf: url, encoding: .utf8) else {
|
|
5242
|
+
guard let bundle = CraftNativeBundle.source(for: routeName) else {
|
|
5139
5243
|
showError("Missing dist/native-screen.js. Compile a .stx screen with `stx native compile` first.")
|
|
5140
5244
|
return
|
|
5141
5245
|
}
|
|
5142
|
-
jsContext.evaluateScript(
|
|
5246
|
+
jsContext.evaluateScript(bundle.source, withSourceURL: bundle.url)
|
|
5143
5247
|
// A bundle that names no route leaves `__stxNativeRoute` undefined;
|
|
5144
5248
|
// its title is the app's, not the word "undefined". A title the
|
|
5145
5249
|
// script set with `navigation.setOptions` wins over both.
|
|
@@ -5485,7 +5589,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5485
5589
|
private func loadBarImage(_ url: URL, _ done: @escaping (UIImage?) -> Void) {
|
|
5486
5590
|
if let cached = Self.barImages.object(forKey: url as NSURL) { done(cached); return }
|
|
5487
5591
|
URLSession.shared.dataTask(with: url) { data, _, _ in
|
|
5488
|
-
let image = data.flatMap(UIImage.init(data:))
|
|
5592
|
+
let image = data.flatMap(UIImage.init(data:)).map { $0.preparingForDisplay() ?? $0 }
|
|
5489
5593
|
if let image { Self.barImages.setObject(image, forKey: url as NSURL) }
|
|
5490
5594
|
DispatchQueue.main.async { done(image) }
|
|
5491
5595
|
}.resume()
|
|
@@ -5619,8 +5723,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5619
5723
|
sendAppState(CraftNativeActions.currentAppState())
|
|
5620
5724
|
// Links are the screens' to show; the app script hears none.
|
|
5621
5725
|
guard !isAppScript else { return }
|
|
5622
|
-
deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self]
|
|
5623
|
-
guard let self = self
|
|
5726
|
+
deepLinkListener = DeepLinkManager.shared.addNativeListener(canReceive: { [weak self] in
|
|
5727
|
+
guard let self = self else { return false }
|
|
5728
|
+
return self.reportedVisible && !self.runtimeDisposed && self.navigationController?.topViewController === self
|
|
5729
|
+
}) { [weak self] url, initial in
|
|
5730
|
+
guard let self = self else { return }
|
|
5624
5731
|
var payload = CraftNativeActions.deepLinkData(url)
|
|
5625
5732
|
payload["initial"] = initial
|
|
5626
5733
|
self.send(type: "DEEP_LINK", payload: payload)
|
|
@@ -7425,6 +7532,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7425
7532
|
|| (view is UIScrollView && overflow != "visible")
|
|
7426
7533
|
|| (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
|
|
7427
7534
|
|| (view is UIImageView && (borderRadius > 0 || hasCornerRadii))
|
|
7535
|
+
// Per-corner rounding clips its content, as its mask did.
|
|
7536
|
+
|| hasCornerRadii
|
|
7428
7537
|
view.layer.masksToBounds = view.clipsToBounds
|
|
7429
7538
|
view.craftLayoutStyle = CraftNativeLayoutStyle(style)
|
|
7430
7539
|
(view as? CraftNativeFlowView)?.invalidateMeasurements()
|
|
@@ -7687,6 +7796,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7687
7796
|
}
|
|
7688
7797
|
}
|
|
7689
7798
|
|
|
7799
|
+
/// Downloaded images, decoded, for every screen: a screen opened again or
|
|
7800
|
+
/// a row scrolled back draws its picture in its first frame rather than
|
|
7801
|
+
/// fetching and decoding it again. Bounded by decoded bytes.
|
|
7802
|
+
private static let decodedImages: NSCache<NSString, UIImage> = {
|
|
7803
|
+
let cache = NSCache<NSString, UIImage>()
|
|
7804
|
+
cache.totalCostLimit = 48 << 20
|
|
7805
|
+
return cache
|
|
7806
|
+
}()
|
|
7807
|
+
|
|
7690
7808
|
/// An image the screen downloads: https anywhere, and http only from this
|
|
7691
7809
|
/// machine (localhost, 127.0.0.1, ::1, *.localhost), the development
|
|
7692
7810
|
/// server the app's ATS exception already allows. Internal for XCTest.
|
|
@@ -7738,14 +7856,30 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7738
7856
|
return
|
|
7739
7857
|
}
|
|
7740
7858
|
if let url = URL(string: uri), Self.loadsImage(from: url) {
|
|
7859
|
+
// Shown before: on screen in this same frame, already decoded.
|
|
7860
|
+
if let decoded = Self.decodedImages.object(forKey: requestKey as NSString) {
|
|
7861
|
+
view.image = imageForDisplay(decoded, view: view)
|
|
7862
|
+
imageDidChange(view)
|
|
7863
|
+
emitImageEvent(view, handler: imageLoadHandlers[id])
|
|
7864
|
+
emitImageEvent(view, handler: imageLoadEndHandlers[id])
|
|
7865
|
+
return
|
|
7866
|
+
}
|
|
7741
7867
|
var request = URLRequest(url: url)
|
|
7742
7868
|
for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) }
|
|
7743
7869
|
let task = URLSession.shared.dataTask(with: request) { [weak self, weak view] data, _, _ in
|
|
7870
|
+
// Decoded here, off the main thread. A UIImage made from data
|
|
7871
|
+
// is only decoded when it first draws, on the main thread, in
|
|
7872
|
+
// the frame it scrolls into view: Apple's "prepare" hitch.
|
|
7873
|
+
let decoded = data.flatMap(UIImage.init(data:)).map { $0.preparingForDisplay() ?? $0 }
|
|
7874
|
+
if let decoded {
|
|
7875
|
+
let pixels = decoded.size.width * decoded.scale * decoded.size.height * decoded.scale
|
|
7876
|
+
Self.decodedImages.setObject(decoded, forKey: requestKey as NSString, cost: Int(pixels * 4))
|
|
7877
|
+
}
|
|
7744
7878
|
guard let self, let view else { return }
|
|
7745
7879
|
DispatchQueue.main.async {
|
|
7746
7880
|
guard self.imageSources[ObjectIdentifier(view)] == requestKey else { return }
|
|
7747
7881
|
self.imageTasks.removeValue(forKey: ObjectIdentifier(view))
|
|
7748
|
-
guard let
|
|
7882
|
+
guard let image = decoded else {
|
|
7749
7883
|
self.imageFailure(view, uri: uri, message: "Image download failed")
|
|
7750
7884
|
return
|
|
7751
7885
|
}
|
|
@@ -8259,12 +8393,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8259
8393
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
|
|
8260
8394
|
}
|
|
8261
8395
|
|
|
8396
|
+
private var pacingName: String { "scroll \(routeName ?? "screen")" }
|
|
8397
|
+
|
|
8262
8398
|
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
|
8399
|
+
CraftFramePacing.shared.begin(pacingName)
|
|
8263
8400
|
guard let handler = scrollBeginHandlers[ObjectIdentifier(scrollView)] else { return }
|
|
8264
8401
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
|
|
8265
8402
|
}
|
|
8266
8403
|
|
|
8267
8404
|
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
|
8405
|
+
if !decelerate { CraftFramePacing.shared.end(pacingName) }
|
|
8268
8406
|
guard let handler = scrollEndHandlers[ObjectIdentifier(scrollView)] else { return }
|
|
8269
8407
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
|
|
8270
8408
|
}
|
|
@@ -8275,6 +8413,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8275
8413
|
}
|
|
8276
8414
|
|
|
8277
8415
|
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
|
8416
|
+
CraftFramePacing.shared.end(pacingName)
|
|
8278
8417
|
guard let handler = scrollMomentumEndHandlers[ObjectIdentifier(scrollView)] else { return }
|
|
8279
8418
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
|
|
8280
8419
|
}
|