@craft-native/ios 0.0.483 → 0.0.486

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.483",
3
+ "version": "0.0.486",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -5192,7 +5192,164 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
5192
5192
  /// 3 quadratic, 4 close, in viewBox units - and scaled to the view. Paint is
5193
5193
  /// inherited down the tree as SVG does; `dark:` classes arrive already
5194
5194
  /// resolved for the appearance, so a redraw is all a theme change needs.
5195
+ /// Native inspection of an SVG plot. Coordinates are fractions of the plot's
5196
+ /// bounds; labels and values are already localized by the application. No JS
5197
+ /// callback or bridge round trip is needed while a finger moves.
5198
+ final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
5199
+ struct Reading {
5200
+ let key: String
5201
+ let x: CGFloat
5202
+ let y: CGFloat?
5203
+ let color: UIColor
5204
+ let label: String
5205
+ let rows: [(String, String, UIColor?)]
5206
+ var announcement: String { ([label] + rows.map { $0.0 + ": " + $0.1 }).joined(separator: ". ") }
5207
+ }
5208
+ private weak var view: UIView?
5209
+ private var configuration: NSDictionary = [:]
5210
+ private(set) var readings: [Reading] = []
5211
+ private(set) var selectedIndex = -1
5212
+ private var title = ""
5213
+ private let feedback = UISelectionFeedbackGenerator()
5214
+ private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:)))
5215
+ private lazy var pan = UIPanGestureRecognizer(target: self, action: #selector(panned(_:)))
5216
+ private lazy var hold = UILongPressGestureRecognizer(target: self, action: #selector(held(_:)))
5217
+
5218
+ init(view: UIView) {
5219
+ self.view = view
5220
+ super.init()
5221
+ pan.delegate = self
5222
+ hold.minimumPressDuration = 0.25
5223
+ [tap, pan, hold].forEach { view.addGestureRecognizer($0); $0.isEnabled = false }
5224
+ }
5225
+
5226
+ func configure(_ raw: Any?) {
5227
+ let next = raw as? [String: Any] ?? [:]
5228
+ guard !configuration.isEqual(to: next) else { return }
5229
+ configuration = next as NSDictionary
5230
+ title = next["title"] as? String ?? ""
5231
+ readings = (next["points"] as? [[String: Any]] ?? []).compactMap { point in
5232
+ guard let x = craftNativeNumber(point["x"])?.doubleValue, x.isFinite, x >= 0, x <= 1,
5233
+ let label = point["label"] as? String else { return nil }
5234
+ let rawY = craftNativeNumber(point["y"])?.doubleValue
5235
+ let y = rawY.flatMap { $0.isFinite && $0 >= 0 && $0 <= 1 ? CGFloat($0) : nil }
5236
+ let rows = (point["rows"] as? [[String: Any]] ?? []).compactMap { row -> (String, String, UIColor?)? in
5237
+ guard let label = row["label"] as? String, let value = row["value"] as? String else { return nil }
5238
+ return (label, value, (row["color"] as? String).flatMap { UIColor(css: $0) })
5239
+ }
5240
+ return Reading(key: point["key"] as? String ?? label, x: CGFloat(x), y: y,
5241
+ color: (point["color"] as? String).flatMap { UIColor(css: $0) } ?? .systemBlue, label: label, rows: rows)
5242
+ }
5243
+ selectedIndex = -1
5244
+ view?.isUserInteractionEnabled = !readings.isEmpty
5245
+ if readings.isEmpty { view?.accessibilityTraits.remove(.adjustable); view?.accessibilityValue = nil }
5246
+ [tap, pan, hold].forEach { $0.isEnabled = !readings.isEmpty }
5247
+ configureAccessibility()
5248
+ view?.setNeedsDisplay()
5249
+ }
5250
+
5251
+ func configureAccessibility() {
5252
+ guard let view, !readings.isEmpty else { return }
5253
+ view.isAccessibilityElement = true
5254
+ view.accessibilityTraits.insert(.adjustable)
5255
+ view.accessibilityValue = readings[selectedIndex >= 0 ? selectedIndex : readings.count - 1].announcement
5256
+ }
5257
+
5258
+ func select(_ index: Int, haptic: Bool = true) {
5259
+ guard !readings.isEmpty else { return }
5260
+ let next = max(0, min(readings.count - 1, index))
5261
+ guard next != selectedIndex else { return }
5262
+ selectedIndex = next
5263
+ if haptic { feedback.selectionChanged() }
5264
+ configureAccessibility()
5265
+ view?.setNeedsDisplay()
5266
+ }
5267
+
5268
+ func adjust(_ direction: Int) {
5269
+ select(selectedIndex < 0 ? (direction < 0 ? readings.count - 1 : 0) : selectedIndex + direction)
5270
+ }
5271
+
5272
+ @discardableResult func dismiss() -> Bool {
5273
+ guard selectedIndex >= 0 else { return false }
5274
+ selectedIndex = -1
5275
+ configureAccessibility()
5276
+ view?.setNeedsDisplay()
5277
+ return true
5278
+ }
5279
+
5280
+ private func select(at point: CGPoint) {
5281
+ guard let view, view.bounds.width > 0 else { return }
5282
+ let fraction = max(0, min(1, point.x / view.bounds.width))
5283
+ if let nearest = readings.indices.min(by: { abs(readings[$0].x - fraction) < abs(readings[$1].x - fraction) }) { select(nearest) }
5284
+ }
5285
+ @objc private func tapped(_ gesture: UITapGestureRecognizer) { select(at: gesture.location(in: view)) }
5286
+ @objc private func panned(_ gesture: UIPanGestureRecognizer) {
5287
+ if gesture.state == .began || gesture.state == .changed { select(at: gesture.location(in: view)) }
5288
+ if gesture.state == .cancelled { dismiss() }
5289
+ }
5290
+ @objc private func held(_ gesture: UILongPressGestureRecognizer) {
5291
+ if gesture.state == .began || gesture.state == .changed { select(at: gesture.location(in: view)) }
5292
+ if gesture.state == .cancelled { dismiss() }
5293
+ }
5294
+ func gestureRecognizerShouldBegin(_ gesture: UIGestureRecognizer) -> Bool {
5295
+ guard gesture === pan else { return true }
5296
+ let velocity = pan.velocity(in: view)
5297
+ return abs(velocity.x) > abs(velocity.y)
5298
+ }
5299
+ func gestureRecognizer(_ gesture: UIGestureRecognizer, shouldBeRequiredToFailBy other: UIGestureRecognizer) -> Bool {
5300
+ gesture === pan && other.view is UIScrollView
5301
+ }
5302
+
5303
+ func draw(in context: CGContext, bounds: CGRect) {
5304
+ guard readings.indices.contains(selectedIndex), bounds.width > 24, bounds.height > 24 else { return }
5305
+ let reading = readings[selectedIndex], x = bounds.width * reading.x
5306
+ context.saveGState()
5307
+ defer { context.restoreGState() }
5308
+ context.setStrokeColor(UIColor.secondaryLabel.withAlphaComponent(0.65).cgColor)
5309
+ context.setLineWidth(1)
5310
+ context.setLineDash(phase: 0, lengths: [2, 3])
5311
+ context.move(to: CGPoint(x: x, y: 0)); context.addLine(to: CGPoint(x: x, y: bounds.height)); context.strokePath()
5312
+ context.setLineDash(phase: 0, lengths: [])
5313
+ if let y = reading.y {
5314
+ let dot = CGRect(x: x - 5, y: bounds.height * y - 5, width: 10, height: 10)
5315
+ context.setFillColor(reading.color.cgColor); context.fillEllipse(in: dot)
5316
+ context.setStrokeColor(UIColor.systemBackground.cgColor); context.setLineWidth(2); context.strokeEllipse(in: dot)
5317
+ }
5318
+ let font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 12), maximumPointSize: 20)
5319
+ let bold = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 12, weight: .semibold), maximumPointSize: 20)
5320
+ let rowHeight = ceil(font.lineHeight) + 7
5321
+ let width = min(264, bounds.width - 16)
5322
+ let height = min(bounds.height - 16, 24 + rowHeight * CGFloat(reading.rows.count + (title.isEmpty ? 1 : 2)))
5323
+ let left = max(8, min(bounds.width - width - 8, x > bounds.midX ? x - width - 12 : x + 12))
5324
+ let panel = CGRect(x: left, y: 8, width: width, height: height)
5325
+ context.setShadow(offset: CGSize(width: 0, height: 4), blur: 12, color: UIColor.black.withAlphaComponent(0.16).cgColor)
5326
+ UIColor.secondarySystemGroupedBackground.setFill(); UIBezierPath(roundedRect: panel, cornerRadius: 12).fill()
5327
+ context.setShadow(offset: .zero, blur: 0, color: nil)
5328
+ UIColor.separator.withAlphaComponent(0.5).setStroke(); let border = UIBezierPath(roundedRect: panel.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 12); border.lineWidth = 1; border.stroke()
5329
+ context.clip(to: panel.insetBy(dx: 10, dy: 8))
5330
+ let paragraph = NSMutableParagraphStyle(); paragraph.lineBreakMode = .byTruncatingTail
5331
+ func text(_ value: String, rect: CGRect, strong: Bool = false, right: Bool = false, secondary: Bool = false) {
5332
+ paragraph.alignment = right ? .right : .left
5333
+ (value as NSString).draw(in: rect, withAttributes: [.font: strong ? bold : font, .foregroundColor: secondary ? UIColor.label.withAlphaComponent(0.72) : UIColor.label, .paragraphStyle: paragraph])
5334
+ }
5335
+ var top = panel.minY + 12
5336
+ if !title.isEmpty { text(title, rect: CGRect(x: left + 12, y: top, width: width - 24, height: rowHeight), strong: true); top += rowHeight }
5337
+ text(reading.label, rect: CGRect(x: left + 12, y: top, width: width - 24, height: rowHeight), secondary: true); top += rowHeight
5338
+ for row in reading.rows {
5339
+ var labelLeft = left + 12
5340
+ if let color = row.2 { color.setFill(); UIBezierPath(roundedRect: CGRect(x: labelLeft, y: top + 4, width: 8, height: 8), cornerRadius: 1).fill(); labelLeft += 14 }
5341
+ text(row.0, rect: CGRect(x: labelLeft, y: top, width: width * 0.47 - (labelLeft - left), height: rowHeight), secondary: true)
5342
+ text(row.1, rect: CGRect(x: left + width * 0.48, y: top, width: width * 0.52 - 12, height: rowHeight), strong: true, right: true)
5343
+ top += rowHeight
5344
+ }
5345
+ }
5346
+ }
5347
+
5195
5348
  final class CraftNativeSvgView: UIView {
5349
+ lazy var inspection = CraftChartInspection(view: self)
5350
+ override func accessibilityIncrement() { inspection.adjust(1) }
5351
+ override func accessibilityDecrement() { inspection.adjust(-1) }
5352
+ override func accessibilityPerformEscape() -> Bool { inspection.dismiss() }
5196
5353
  override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5197
5354
  craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5198
5355
  }
@@ -5200,6 +5357,7 @@ final class CraftNativeSvgView: UIView {
5200
5357
  var drawing: [String: Any] = [:] {
5201
5358
  didSet {
5202
5359
  animates = Self.hasAnimation(drawing["children"] as? [Any] ?? [])
5360
+ inspection.configure((drawing["props"] as? [String: Any])?["inspection"])
5203
5361
  setNeedsDisplay()
5204
5362
  updateFrames()
5205
5363
  }
@@ -5426,14 +5584,17 @@ final class CraftNativeSvgView: UIView {
5426
5584
 
5427
5585
  override func draw(_ rect: CGRect) {
5428
5586
  guard let context = UIGraphicsGetCurrentContext() else { return }
5587
+ context.saveGState()
5429
5588
  viewTransform = .identity
5430
5589
  if let box = viewBox {
5431
5590
  let props = drawing["props"] as? [String: Any] ?? [:]
5432
- guard let transform = CraftNativeSvgView.transform(box: box, bounds: bounds.size, preserveAspectRatio: props["preserveAspectRatio"] as? String) else { return }
5591
+ guard let transform = CraftNativeSvgView.transform(box: box, bounds: bounds.size, preserveAspectRatio: props["preserveAspectRatio"] as? String) else { context.restoreGState(); return }
5433
5592
  viewTransform = transform
5434
5593
  context.concatenate(transform)
5435
5594
  }
5436
5595
  draw(children: drawing["children"] as? [Any] ?? [], paint: Paint().merged(drawing), in: context)
5596
+ context.restoreGState()
5597
+ inspection.draw(in: context, bounds: bounds)
5437
5598
  }
5438
5599
 
5439
5600
  private func draw(children: [Any], paint: Paint, in context: CGContext) {
@@ -9832,6 +9993,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9832
9993
  }
9833
9994
  result.craftPointerEvents = CraftNativePointerEvents.resolve(props: props, style: style)
9834
9995
  applyAccessibility(props, type: type, to: result)
9996
+ (result as? CraftNativeSvgView)?.inspection.configureAccessibility()
9835
9997
  // After applyAccessibility: it settles whether the view is an element
9836
9998
  // at all, and an action on a non-element is unreachable.
9837
9999
  updateAccessibilityActions(props["accessibilityActions"], handler: menuDisabled ? nil : events["onAccessibilityAction"], for: result)
@@ -10479,7 +10641,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10479
10641
  /// not be panned, zoomed, or have a pin tapped; a picker could not be spun.
10480
10642
  private func ownsTouches(_ view: UIView) -> Bool {
10481
10643
  view is UIControl || view is UIScrollView || view is CraftNativeModalView || view is CraftNativeVideoView
10482
- || view is MKMapView || view is UIPickerView
10644
+ || view is MKMapView || view is UIPickerView || ((view as? CraftNativeSvgView)?.inspection.readings.isEmpty == false)
10483
10645
  }
10484
10646
 
10485
10647
  private func updatePressHandler(_ handler: String?, for view: UIView) {
@@ -796,9 +796,16 @@ final class DriverTests: XCTestCase {
796
796
  // A system prompt over the app ("Save Password?" → Not Now), wherever iOS shows it.
797
797
  let label = arg.isEmpty ? "Not Now" : arg
798
798
  let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
799
- // The app's own alert or sheet first (its button can share a
800
- // label with the control behind it), then the app, then iOS.
801
- let candidates = { self.presentedScopes().map { $0.buttons[label] } + [springboard.buttons[label]] }
799
+ // Optional prompt dismissal must never activate a same-named
800
+ // control on the screen behind it. Resolve only presented dialogs
801
+ // and system UI, binding each match by index before tapping: a
802
+ // password sheet can arrive between query construction and use.
803
+ let candidates = {
804
+ let dialogs = self.presentedScopes().filter { $0.elementType == .alert || $0.elementType == .sheet }
805
+ return (dialogs + [springboard]).flatMap { scope in
806
+ scope.buttons.matching(NSPredicate(format: "label == %@", label)).allElementsBoundByIndex
807
+ }
808
+ }
802
809
  let end = Date().addingTimeInterval(6)
803
810
  // A tap is not a dismissal. iOS 26 draws "Save Password?" as a
804
811
  // sheet that is in the tree, and hittable, while it still slides