@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
|
@@ -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
|
|
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
|
-
|
|
5574
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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)
|