@craft-native/ios 0.0.443 → 0.0.446

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.443",
3
+ "version": "0.0.446",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -5524,6 +5524,22 @@ final class CraftNativeSolidSwitch: UIControl {
5524
5524
  private let naturalSize = UISwitch().intrinsicContentSize
5525
5525
  private var touchOrigin: CGPoint?
5526
5526
  private var dragPosition: CGFloat?
5527
+ /// Where the thumb is *drawn*, which is where it rests except while a
5528
+ /// change travels there. `thumbRect` keeps reporting the resting position,
5529
+ /// so hit-testing, the drag maths and tests all read one instant,
5530
+ /// authoritative value and only the drawing lags behind it.
5531
+ private var drawnPosition: CGFloat = 0
5532
+ private var travel: CADisplayLink?
5533
+ private var travelFrom: CGFloat = 0
5534
+ private var travelStart: CFTimeInterval = 0
5535
+ /// Drawing can settle independently of immediate value changes and taps.
5536
+ private static let travelDuration: CFTimeInterval = 0.38
5537
+ /// Thumb stretch while held, from 0 (released) to 1 (pressed).
5538
+ private var pressAmount: CGFloat = 0
5539
+ private var pressTarget: CGFloat = 0
5540
+ private var pressFrom: CGFloat = 0
5541
+ private var pressStart: CFTimeInterval = 0
5542
+ private static let pressDuration: CFTimeInterval = 0.14
5527
5543
  override var intrinsicContentSize: CGSize { naturalSize }
5528
5544
  override var isEnabled: Bool {
5529
5545
  didSet {
@@ -5533,6 +5549,14 @@ final class CraftNativeSolidSwitch: UIControl {
5533
5549
  }
5534
5550
  }
5535
5551
  override var canBecomeFirstResponder: Bool { isEnabled }
5552
+ override var isHidden: Bool {
5553
+ didSet { if isHidden { settleMotion(cancelTracking: true) } }
5554
+ }
5555
+ override var semanticContentAttribute: UISemanticContentAttribute {
5556
+ didSet {
5557
+ if oldValue != semanticContentAttribute { settleMotion(cancelTracking: true) }
5558
+ }
5559
+ }
5536
5560
 
5537
5561
  override init(frame: CGRect) {
5538
5562
  super.init(frame: frame)
@@ -5544,11 +5568,111 @@ final class CraftNativeSolidSwitch: UIControl {
5544
5568
  required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
5545
5569
 
5546
5570
  func setOn(_ value: Bool) {
5571
+ let previous = visualPosition
5572
+ let changed = isOn != value
5547
5573
  isOn = value
5548
5574
  refreshAccessibility()
5575
+ guard changed else { setNeedsDisplay(); return }
5576
+ drawnPosition = previous
5577
+ moveThumb()
5578
+ }
5579
+
5580
+ private var canAnimate: Bool {
5581
+ guard isEnabled, !UIAccessibility.isReduceMotionEnabled, let window,
5582
+ convert(bounds, to: window).intersects(window.bounds) else { return false }
5583
+ var ancestor: UIView? = self
5584
+ while let view = ancestor {
5585
+ if view.isHidden || view.alpha <= 0 { return false }
5586
+ ancestor = view.superview
5587
+ }
5588
+ return true
5589
+ }
5590
+
5591
+ private func settleMotion(cancelTracking: Bool = false) {
5592
+ travel?.invalidate()
5593
+ travel = nil
5594
+ drawnPosition = restingPosition
5595
+ pressAmount = 0
5596
+ pressTarget = 0
5597
+ if cancelTracking {
5598
+ touchOrigin = nil
5599
+ dragPosition = nil
5600
+ }
5601
+ setNeedsDisplay()
5602
+ }
5603
+
5604
+ /// Only drawing travels; value, accessibility and hit geometry change at once.
5605
+ private func moveThumb() {
5606
+ travel?.invalidate()
5607
+ travel = nil
5608
+ let target = restingPosition
5609
+ travelFrom = dragPosition ?? drawnPosition
5610
+ guard canAnimate else { settleMotion(); return }
5611
+ travelStart = CACurrentMediaTime()
5612
+ guard travelFrom != target || pressAmount != pressTarget else {
5613
+ drawnPosition = target
5614
+ setNeedsDisplay()
5615
+ return
5616
+ }
5617
+ startLink()
5618
+ }
5619
+
5620
+ /// Stretch the thumb toward where it is going, or let it relax back.
5621
+ private func press(_ amount: CGFloat) {
5622
+ guard pressTarget != amount else { return }
5623
+ pressFrom = pressAmount
5624
+ pressTarget = amount
5625
+ pressStart = CACurrentMediaTime()
5626
+ guard canAnimate else { settleMotion(); return }
5627
+ if travel == nil {
5628
+ drawnPosition = restingPosition
5629
+ travelFrom = restingPosition
5630
+ travelStart = CACurrentMediaTime()
5631
+ }
5632
+ startLink()
5633
+ }
5634
+
5635
+ private func startLink() {
5636
+ guard travel == nil else { return }
5637
+ let link = CADisplayLink(target: self, selector: #selector(stepThumb))
5638
+ link.add(to: .main, forMode: .common)
5639
+ travel = link
5640
+ }
5641
+
5642
+ @objc private func stepThumb() {
5643
+ guard canAnimate else { settleMotion(); return }
5644
+ let now = CACurrentMediaTime()
5645
+ let target = restingPosition
5646
+ let position = min(1, max(0, (now - travelStart) / Self.travelDuration))
5647
+ drawnPosition = travelFrom + (target - travelFrom) * CGFloat(Self.spring(position))
5648
+ let pressed = min(1, max(0, (now - pressStart) / Self.pressDuration))
5649
+ pressAmount = pressFrom + (pressTarget - pressFrom) * CGFloat(pressed * pressed * (3 - 2 * pressed))
5549
5650
  setNeedsDisplay()
5651
+ guard position >= 1, pressed >= 1 else { return }
5652
+ drawnPosition = target
5653
+ pressAmount = pressTarget
5654
+ travel?.invalidate()
5655
+ travel = nil
5550
5656
  }
5551
5657
 
5658
+ /// A small spring overshoot, applied only to opaque drawing geometry.
5659
+ private static func spring(_ fraction: Double) -> Double {
5660
+ guard fraction < 1 else { return 1 }
5661
+ let damping = 0.68, frequency = 15.0
5662
+ let damped = frequency * (1 - damping * damping).squareRoot()
5663
+ let decay = exp(-damping * frequency * fraction)
5664
+ return 1 - decay * (cos(damped * fraction) + (damping * frequency / damped) * sin(damped * fraction))
5665
+ }
5666
+
5667
+ /// A display link retains this control, so a switch that leaves the screen
5668
+ /// mid-travel would hold itself alive and keep drawing frames nobody sees.
5669
+ override func willMove(toWindow newWindow: UIWindow?) {
5670
+ super.willMove(toWindow: newWindow)
5671
+ guard newWindow == nil else { return }
5672
+ settleMotion(cancelTracking: true)
5673
+ }
5674
+ deinit { travel?.invalidate() }
5675
+
5552
5676
  func refreshAccessibility() {
5553
5677
  accessibilityTraits = .button
5554
5678
  if #available(iOS 17.0, *) { accessibilityTraits.insert(.toggleButton) }
@@ -5561,21 +5685,99 @@ final class CraftNativeSolidSwitch: UIControl {
5561
5685
  let size = CGSize(width: min(bounds.width, naturalSize.width), height: min(bounds.height, naturalSize.height))
5562
5686
  return CGRect(x: bounds.midX - size.width / 2, y: bounds.midY - size.height / 2, width: size.width, height: size.height)
5563
5687
  }
5564
- var thumbRect: CGRect {
5688
+ private var restingPosition: CGFloat {
5689
+ (isOn == (effectiveUserInterfaceLayoutDirection != .rightToLeft)) ? 1 : 0
5690
+ }
5691
+ private var visualPosition: CGFloat {
5692
+ dragPosition ?? (travel == nil ? restingPosition : drawnPosition)
5693
+ }
5694
+ /// Where the thumb belongs now. A finger on it wins, then the state.
5695
+ var thumbRect: CGRect { thumbRect(at: dragPosition ?? restingPosition) }
5696
+ /// Where this frame draws it: the same, except part-way through a travel,
5697
+ /// and stretched toward its destination while a finger holds it down.
5698
+ private var drawnThumbRect: CGRect {
5699
+ let position = visualPosition
5700
+ let rect = thumbRect(at: position)
5701
+ guard pressAmount > 0 else { return rect }
5702
+ let track = trackRect.insetBy(dx: 2, dy: 2)
5703
+ let stretch = rect.width * 0.18 * pressAmount
5704
+ // It grows toward the end it is heading for, so the edge nearest the
5705
+ // finger stays put and the far edge reaches.
5706
+ let x = position < 0.5 ? rect.minX : rect.minX - stretch
5707
+ return CGRect(x: min(max(x, track.minX), track.maxX - rect.width - stretch),
5708
+ y: rect.minY, width: rect.width + stretch, height: rect.height)
5709
+ }
5710
+ private func thumbRect(at position: CGFloat) -> CGRect {
5565
5711
  let track = trackRect.insetBy(dx: 2, dy: 2)
5566
- let onRight = effectiveUserInterfaceLayoutDirection != .rightToLeft
5567
- let position = dragPosition ?? ((isOn == onRight) ? 1 : 0)
5568
5712
  return CGRect(x: track.minX + (track.width - track.height) * position, y: track.minY, width: track.height, height: track.height)
5569
5713
  }
5570
5714
  override func draw(_ rect: CGRect) {
5571
5715
  let track = trackRect
5572
- guard track.width > 4, track.height > 4 else { return }
5573
- (isEnabled ? (isOn ? onColor ?? .systemGreen : offColor ?? .systemGray4) : UIColor.systemGray4).setFill()
5574
- UIBezierPath(roundedRect: track, cornerRadius: track.height / 2).fill()
5716
+ guard track.width > 4, track.height > 4, let context = UIGraphicsGetCurrentContext() else { return }
5717
+ let position = visualPosition
5718
+ // The track's colour arrives *with* the thumb. Filling it from `isOn`
5719
+ // turns the whole track the instant the value changes, so the colour
5720
+ // overtakes the thumb and the two read as two separate things moving.
5721
+ let dim = isEnabled ? (offColor ?? .systemGray4) : UIColor.systemGray4
5722
+ let lit = isEnabled ? (onColor ?? .systemGreen) : UIColor.systemGray4
5723
+ let onness = effectiveUserInterfaceLayoutDirection != .rightToLeft ? position : 1 - position
5724
+ Self.blend(dim, lit, onness).setFill()
5725
+ let trackPath = UIBezierPath(roundedRect: track, cornerRadius: track.height / 2)
5726
+ trackPath.fill()
5727
+ // The well the thumb sits in: shaded under its own top rim.
5728
+ context.saveGState()
5729
+ trackPath.addClip()
5730
+ if let shade = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
5731
+ colors: [UIColor.black.withAlphaComponent(0.10).cgColor, UIColor.clear.cgColor] as CFArray,
5732
+ locations: [0, 1]) {
5733
+ context.drawLinearGradient(shade, start: CGPoint(x: track.midX, y: track.minY),
5734
+ end: CGPoint(x: track.midX, y: track.minY + track.height * 0.55), options: [])
5735
+ }
5736
+ context.restoreGState()
5737
+
5738
+ let thumb = drawnThumbRect
5739
+ let thumbPath = UIBezierPath(roundedRect: thumb, cornerRadius: thumb.height / 2)
5740
+ // Draw the shadow in the same frame, without layer animations or fades.
5741
+ context.saveGState()
5742
+ context.setShadow(offset: CGSize(width: 0, height: 1.5), blur: 3,
5743
+ color: UIColor.black.withAlphaComponent(isEnabled ? 0.28 : 0.16).cgColor)
5575
5744
  // The solid variant deliberately ignores thumb alpha. No transient
5576
5745
  // glass, scaling, fade, implicit layer animation or delayed redraw.
5577
5746
  (thumbColor ?? .white).withAlphaComponent(1).setFill()
5578
- UIBezierPath(ovalIn: thumbRect).fill()
5747
+ thumbPath.fill()
5748
+ context.restoreGState()
5749
+ // Shading stays above the opaque fill and away from its centre.
5750
+ context.saveGState()
5751
+ thumbPath.addClip()
5752
+ if let gloss = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
5753
+ colors: [UIColor.white.withAlphaComponent(0.55).cgColor, UIColor.clear.cgColor] as CFArray,
5754
+ locations: [0, 1]) {
5755
+ context.drawLinearGradient(gloss, start: CGPoint(x: thumb.midX, y: thumb.minY),
5756
+ end: CGPoint(x: thumb.midX, y: thumb.minY + thumb.height * 0.40), options: [])
5757
+ }
5758
+ if let sheen = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
5759
+ colors: [UIColor.clear.cgColor, UIColor.black.withAlphaComponent(0.07).cgColor] as CFArray,
5760
+ locations: [0, 1]) {
5761
+ context.drawLinearGradient(sheen, start: CGPoint(x: thumb.midX, y: thumb.maxY - thumb.height * 0.20),
5762
+ end: CGPoint(x: thumb.midX, y: thumb.maxY), options: [])
5763
+ }
5764
+ context.restoreGState()
5765
+ // A hairline rim, so it still separates from a pale track.
5766
+ UIColor.black.withAlphaComponent(0.06).setStroke()
5767
+ thumbPath.lineWidth = 0.5
5768
+ thumbPath.stroke()
5769
+ }
5770
+
5771
+ /// Mix two colours, for a track that changes with the thumb rather than
5772
+ /// ahead of it.
5773
+ private static func blend(_ from: UIColor, _ to: UIColor, _ amount: CGFloat) -> UIColor {
5774
+ let mix = min(1, max(0, amount))
5775
+ var fr: CGFloat = 0, fg: CGFloat = 0, fb: CGFloat = 0, fa: CGFloat = 0
5776
+ var tr: CGFloat = 0, tg: CGFloat = 0, tb: CGFloat = 0, ta: CGFloat = 0
5777
+ guard from.getRed(&fr, green: &fg, blue: &fb, alpha: &fa),
5778
+ to.getRed(&tr, green: &tg, blue: &tb, alpha: &ta) else { return mix < 0.5 ? from : to }
5779
+ return UIColor(red: fr + (tr - fr) * mix, green: fg + (tg - fg) * mix,
5780
+ blue: fb + (tb - fb) * mix, alpha: fa + (ta - fa) * mix)
5579
5781
  }
5580
5782
  override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
5581
5783
  super.traitCollectionDidChange(previousTraitCollection)
@@ -5585,6 +5787,7 @@ final class CraftNativeSolidSwitch: UIControl {
5585
5787
  guard isEnabled else { return false }
5586
5788
  touchOrigin = touch.location(in: self)
5587
5789
  dragPosition = nil
5790
+ press(1)
5588
5791
  return true
5589
5792
  }
5590
5793
  func updateDrag(to point: CGPoint) {
@@ -5599,7 +5802,15 @@ final class CraftNativeSolidSwitch: UIControl {
5599
5802
  return true
5600
5803
  }
5601
5804
  override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
5602
- defer { touchOrigin = nil; dragPosition = nil; setNeedsDisplay() }
5805
+ // The thumb carries on from wherever the finger left it, rather than
5806
+ // jumping to the end of the track the moment it lifts.
5807
+ defer {
5808
+ touchOrigin = nil
5809
+ if let released = dragPosition { drawnPosition = released }
5810
+ dragPosition = nil
5811
+ press(0)
5812
+ moveThumb()
5813
+ }
5603
5814
  guard isEnabled, let touch, touchOrigin != nil,
5604
5815
  bounds.insetBy(dx: -10, dy: -10).contains(touch.location(in: self)) else { return }
5605
5816
  let next = dragPosition.map { ($0 >= 0.5) == (effectiveUserInterfaceLayoutDirection != .rightToLeft) } ?? !isOn
@@ -5607,8 +5818,10 @@ final class CraftNativeSolidSwitch: UIControl {
5607
5818
  }
5608
5819
  override func cancelTracking(with event: UIEvent?) {
5609
5820
  touchOrigin = nil
5821
+ if let released = dragPosition { drawnPosition = released }
5610
5822
  dragPosition = nil
5611
- setNeedsDisplay()
5823
+ press(0)
5824
+ moveThumb()
5612
5825
  }
5613
5826
  override func accessibilityActivate() -> Bool {
5614
5827
  guard isEnabled else { return false }
@@ -193,10 +193,15 @@ final class DriverTests: XCTestCase {
193
193
  visibleScrollFrame(target.frame, in: app.frame) else { return false }
194
194
  // Hittable alone accepts controls clipped by the scroll view's edge.
195
195
  // Only an ancestor scroll view clips this target, not its siblings.
196
+ // One that does not overlap the target is not its ancestor, whatever
197
+ // the descendants query says: with a keyboard up, the query matched
198
+ // a form's Send button inside the keyboard's suggestion strip, and
199
+ // Send, in full view above the keys, could never be scrolled to.
196
200
  let identity = NSPredicate(format: "identifier == %@ AND label == %@", target.identifier, target.label)
197
- for scroll in app.scrollViews.allElementsBoundByIndex {
201
+ let frame = target.frame
202
+ for scroll in app.scrollViews.allElementsBoundByIndex where scroll.frame.intersects(frame) {
198
203
  if scroll.descendants(matching: .any).matching(identity).firstMatch.exists,
199
- !visibleScrollFrame(target.frame, in: scroll.frame.intersection(app.frame)) { return false }
204
+ !visibleScrollFrame(frame, in: scroll.frame.intersection(app.frame)) { return false }
200
205
  }
201
206
  return true
202
207
  }
@@ -287,6 +292,12 @@ final class DriverTests: XCTestCase {
287
292
 
288
293
  /// A switch reports "0" or "1". Any other value is not a switch state to
289
294
  /// wait on, so the tap stands as it would for any other control.
295
+ /// The switch answers `/exec` with 200 whatever the hook did, its first
296
+ /// word the hook's exit code: only `exit 0` is a hook that worked.
297
+ func hookSucceeded(_ answer: String) -> Bool {
298
+ answer.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("exit 0")
299
+ }
300
+
290
301
  func switchFlipped(from before: String?, to after: String?) -> Bool {
291
302
  let states: Set<String> = ["0", "1"]
292
303
  guard let before, let after, states.contains(before), states.contains(after) else { return true }
@@ -390,7 +401,12 @@ final class DriverTests: XCTestCase {
390
401
  check(false, "exec \(arg): hook failed or returned no successful response")
391
402
  return
392
403
  }
393
- note("exec \(arg): \(out.trimmingCharacters(in: .whitespacesAndNewlines))")
404
+ let answer = out.trimmingCharacters(in: .whitespacesAndNewlines)
405
+ note("exec \(arg): \(answer)")
406
+ // A failed hook (a fresh install that never reinstalled) stops the
407
+ // journey here with its reason, not three steps later at a screen
408
+ // that was never reset.
409
+ check(hookSucceeded(answer), "exec \(arg): hook failed: \(answer)")
394
410
  case "openurl":
395
411
  if let url = URL(string: arg) {
396
412
  XCUIDevice.shared.system.open(url)