@craft-native/ios 0.0.467 → 0.0.475
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 +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +50 -36
- package/templates/CraftFramePacingLog.swift +18 -0
- package/templates/CraftHybrid.swift +48 -6
- package/templates/CraftNativeScreen.swift +15 -0
- package/templates/CraftNativeUI.swift +13 -5
- package/templates/testing/DriverTests.swift.template +27 -3
package/dist/cli.js
CHANGED
|
@@ -1401,6 +1401,7 @@ function writeShell(output, config) {
|
|
|
1401
1401
|
writeSource(join4(output, "Sources", "CraftVoiceRecording.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftVoiceRecording.swift"), "utf8"));
|
|
1402
1402
|
writeSource(join4(output, "Sources", "CraftWebAudio.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftWebAudio.swift"), "utf8"));
|
|
1403
1403
|
writeSource(join4(output, "Sources", "CraftHybrid.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftHybrid.swift"), "utf8"));
|
|
1404
|
+
writeSource(join4(output, "Sources", "CraftFramePacingLog.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftFramePacingLog.swift"), "utf8"));
|
|
1404
1405
|
writeSource(join4(output, "Sources", "CraftCrashReporter.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
|
|
1405
1406
|
writeSource(join4(output, "Sources", "CraftLiveActivities.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftLiveActivities.swift"), "utf8"));
|
|
1406
1407
|
writeSource(join4(output, "Shared", "CraftActivityAttributes.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftActivityAttributes.swift"), "utf8"));
|
package/dist/index.js
CHANGED
|
@@ -1615,6 +1615,7 @@ function writeShell(output, config) {
|
|
|
1615
1615
|
writeSource(join4(output, "Sources", "CraftVoiceRecording.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftVoiceRecording.swift"), "utf8"));
|
|
1616
1616
|
writeSource(join4(output, "Sources", "CraftWebAudio.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftWebAudio.swift"), "utf8"));
|
|
1617
1617
|
writeSource(join4(output, "Sources", "CraftHybrid.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftHybrid.swift"), "utf8"));
|
|
1618
|
+
writeSource(join4(output, "Sources", "CraftFramePacingLog.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftFramePacingLog.swift"), "utf8"));
|
|
1618
1619
|
writeSource(join4(output, "Sources", "CraftCrashReporter.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
|
|
1619
1620
|
writeSource(join4(output, "Sources", "CraftLiveActivities.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftLiveActivities.swift"), "utf8"));
|
|
1620
1621
|
writeSource(join4(output, "Shared", "CraftActivityAttributes.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftActivityAttributes.swift"), "utf8"));
|
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -819,6 +819,7 @@ final class CraftChrome: ObservableObject {
|
|
|
819
819
|
var image: String? = nil
|
|
820
820
|
/// A compact floating icon, instead of the system full-width accessory shelf.
|
|
821
821
|
var iconOnly = false
|
|
822
|
+
var companions: [Tab] = []
|
|
822
823
|
}
|
|
823
824
|
|
|
824
825
|
@Published var tabs: [Tab] = []
|
|
@@ -828,8 +829,12 @@ final class CraftChrome: ObservableObject {
|
|
|
828
829
|
/// The visible destination may be the accessory, independently of its owning stack.
|
|
829
830
|
@Published var visiblePath: String?
|
|
830
831
|
var accessorySelected: Bool {
|
|
831
|
-
guard let
|
|
832
|
-
return
|
|
832
|
+
guard let accessory = tabAccessory else { return false }
|
|
833
|
+
return ([accessory] + accessory.companions).contains { accessorySelected($0) }
|
|
834
|
+
}
|
|
835
|
+
func accessorySelected(_ accessory: Tab) -> Bool {
|
|
836
|
+
guard let path = visiblePath else { return false }
|
|
837
|
+
return path == accessory.id || path.hasPrefix(accessory.id + "/")
|
|
833
838
|
}
|
|
834
839
|
var highlightedTab: String? { accessorySelected ? nil : selected }
|
|
835
840
|
let tabSwipeProgress = CraftTabSwipeProgress()
|
|
@@ -954,7 +959,14 @@ final class CraftChrome: ObservableObject {
|
|
|
954
959
|
next = Tab(id: path, title: title, symbol: item["symbol"] as? String ?? "message",
|
|
955
960
|
badge: (item["badge"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) },
|
|
956
961
|
accessibilityLabel: item["accessibilityLabel"] as? String,
|
|
957
|
-
iconOnly: item["iconOnly"] as? Bool ?? false
|
|
962
|
+
iconOnly: item["iconOnly"] as? Bool ?? false,
|
|
963
|
+
companions: ((item["companions"] as? [[String: Any]]) ?? []).prefix(2).compactMap { action in
|
|
964
|
+
guard let companionPath = action["path"] as? String,
|
|
965
|
+
companionPath.hasPrefix("/"), !companionPath.hasPrefix("//"), companionPath != path,
|
|
966
|
+
let companionTitle = action["title"] as? String else { return nil }
|
|
967
|
+
return Tab(id: companionPath, title: companionTitle, symbol: action["symbol"] as? String ?? "circle",
|
|
968
|
+
accessibilityLabel: action["accessibilityLabel"] as? String, iconOnly: true)
|
|
969
|
+
})
|
|
958
970
|
} else {
|
|
959
971
|
next = nil
|
|
960
972
|
}
|
|
@@ -1265,43 +1277,45 @@ private struct CraftTabBarContent: View {
|
|
|
1265
1277
|
if !chrome.tabs.isEmpty {
|
|
1266
1278
|
HStack(spacing: 8) {
|
|
1267
1279
|
bar
|
|
1268
|
-
if let
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1280
|
+
if let primary = chrome.tabAccessory {
|
|
1281
|
+
ForEach(primary.companions + [primary]) { accessory in
|
|
1282
|
+
Button { CraftHybrid.shared.open(accessory.id) } label: {
|
|
1283
|
+
surface {
|
|
1284
|
+
Image(systemName: accessory.symbol)
|
|
1285
|
+
.font(.system(size: iconSize))
|
|
1286
|
+
.foregroundStyle(chrome.accessorySelected(accessory) ? selectedColor : Color.primary)
|
|
1287
|
+
.frame(width: 62, height: 62)
|
|
1288
|
+
.background {
|
|
1289
|
+
if chrome.accessorySelected(accessory) {
|
|
1290
|
+
Circle().fill(selectedColor.opacity(0.14)).padding(4)
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
.contentShape(Rectangle())
|
|
1295
|
+
.overlay(alignment: .topTrailing) {
|
|
1296
|
+
if let badge = accessory.badge {
|
|
1297
|
+
Text(badge)
|
|
1298
|
+
.font(.system(size: badgeSize, weight: .bold))
|
|
1299
|
+
.foregroundStyle(.white)
|
|
1300
|
+
.padding(.horizontal, 4)
|
|
1301
|
+
.frame(minWidth: 18, minHeight: 18)
|
|
1302
|
+
.background(Capsule().fill(Color.red))
|
|
1278
1303
|
}
|
|
1279
1304
|
}
|
|
1280
1305
|
}
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
.buttonStyle(.plain)
|
|
1294
|
-
.accessibilityShowsLargeContentViewer {
|
|
1295
|
-
Image(systemName: accessory.symbol)
|
|
1296
|
-
Text(accessory.accessibilityLabel ?? accessory.title)
|
|
1306
|
+
.buttonStyle(.plain)
|
|
1307
|
+
.accessibilityShowsLargeContentViewer {
|
|
1308
|
+
Image(systemName: accessory.symbol)
|
|
1309
|
+
Text(accessory.accessibilityLabel ?? accessory.title)
|
|
1310
|
+
}
|
|
1311
|
+
// Keep accessibility intent for the viewer; displayed
|
|
1312
|
+
// sizes come from the bounded metrics owned above.
|
|
1313
|
+
.environment(\.dynamicTypeSize, viewerTextSize)
|
|
1314
|
+
.accessibilityLabel(accessory.accessibilityLabel ?? accessory.title)
|
|
1315
|
+
.accessibilityValue(accessory.badge ?? "")
|
|
1316
|
+
.accessibilityIdentifier("craft-tab-accessory")
|
|
1317
|
+
.accessibilityAddTraits(chrome.accessorySelected(accessory) ? .isSelected : [])
|
|
1297
1318
|
}
|
|
1298
|
-
// Keep accessibility intent for the viewer; displayed
|
|
1299
|
-
// sizes come from the bounded metrics owned above.
|
|
1300
|
-
.environment(\.dynamicTypeSize, viewerTextSize)
|
|
1301
|
-
.accessibilityLabel(accessory.accessibilityLabel ?? accessory.title)
|
|
1302
|
-
.accessibilityValue(accessory.badge ?? "")
|
|
1303
|
-
.accessibilityIdentifier("craft-tab-accessory")
|
|
1304
|
-
.accessibilityAddTraits(chrome.accessorySelected ? .isSelected : [])
|
|
1305
1319
|
}
|
|
1306
1320
|
}
|
|
1307
1321
|
.padding(.horizontal, 16)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The opt-in frame trace shares one append-only file for this app process.
|
|
4
|
+
enum CraftFramePacingLog {
|
|
5
|
+
static func open(in directory: URL) throws -> FileHandle {
|
|
6
|
+
let manager = FileManager.default
|
|
7
|
+
try manager.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
8
|
+
let url = directory.appendingPathComponent("craft-frame-pacing.log")
|
|
9
|
+
if !manager.fileExists(atPath: url.path),
|
|
10
|
+
!manager.createFile(atPath: url.path, contents: nil) {
|
|
11
|
+
throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteUnknownError,
|
|
12
|
+
userInfo: [NSFilePathErrorKey: url.path])
|
|
13
|
+
}
|
|
14
|
+
let handle = try FileHandle(forWritingTo: url)
|
|
15
|
+
_ = try handle.seekToEnd()
|
|
16
|
+
return handle
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -1192,6 +1192,9 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
1192
1192
|
private var synchronizingTabs = false
|
|
1193
1193
|
private var accessoryModel: CraftChrome.Tab?
|
|
1194
1194
|
private let accessoryButton = UIButton(type: .system)
|
|
1195
|
+
private var companionButtons: [UIButton] = []
|
|
1196
|
+
private var companionModels: [CraftChrome.Tab] = []
|
|
1197
|
+
private var companionGlasses: [UIVisualEffectView] = []
|
|
1195
1198
|
private let accessoryBadge = UILabel()
|
|
1196
1199
|
private let accessoryGlass = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
|
|
1197
1200
|
private static let tabPictures = NSCache<NSURL, UIImage>()
|
|
@@ -1410,6 +1413,40 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
1410
1413
|
private func updateSystemAccessory(hidden: Bool) {
|
|
1411
1414
|
let chrome = CraftChrome.shared
|
|
1412
1415
|
let model = hidden ? nil : chrome.tabAccessory
|
|
1416
|
+
// Keep controls when only the selected destination or unread badge changes.
|
|
1417
|
+
let companions = model?.iconOnly == true ? model?.companions ?? [] : []
|
|
1418
|
+
if companionModels != companions {
|
|
1419
|
+
companionModels = companions
|
|
1420
|
+
for button in companionButtons { button.removeFromSuperview() }
|
|
1421
|
+
for glass in companionGlasses { glass.removeFromSuperview() }
|
|
1422
|
+
companionButtons.removeAll()
|
|
1423
|
+
companionGlasses.removeAll()
|
|
1424
|
+
for action in companions {
|
|
1425
|
+
let button = UIButton(type: .system)
|
|
1426
|
+
var configuration = UIButton.Configuration.plain()
|
|
1427
|
+
configuration.image = UIImage(systemName: action.symbol)
|
|
1428
|
+
button.configuration = configuration
|
|
1429
|
+
button.accessibilityIdentifier = "craft-tab-companion:" + action.id
|
|
1430
|
+
button.accessibilityLabel = action.accessibilityLabel ?? action.title
|
|
1431
|
+
button.accessibilityValue = action.badge
|
|
1432
|
+
button.accessibilityTraits = chrome.accessorySelected(action) ? [.button, .selected] : [.button]
|
|
1433
|
+
button.addAction(UIAction { [weak self] _ in self?.hybrid.open(action.id) }, for: .touchUpInside)
|
|
1434
|
+
let glass = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
|
|
1435
|
+
#if compiler(>=6.2)
|
|
1436
|
+
if #available(iOS 26.0, *) { glass.effect = UIGlassEffect() }
|
|
1437
|
+
#endif
|
|
1438
|
+
glass.layer.cornerRadius = 24
|
|
1439
|
+
glass.clipsToBounds = true
|
|
1440
|
+
glass.isUserInteractionEnabled = false
|
|
1441
|
+
view.addSubview(glass)
|
|
1442
|
+
view.addSubview(button)
|
|
1443
|
+
companionGlasses.append(glass)
|
|
1444
|
+
companionButtons.append(button)
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
for (index, action) in companionModels.enumerated() {
|
|
1448
|
+
companionButtons[index].accessibilityTraits = chrome.accessorySelected(action) ? [.button, .selected] : [.button]
|
|
1449
|
+
}
|
|
1413
1450
|
if let model {
|
|
1414
1451
|
var configuration = UIButton.Configuration.plain()
|
|
1415
1452
|
configuration.image = UIImage(systemName: model.symbol)
|
|
@@ -1419,7 +1456,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
1419
1456
|
accessoryButton.configuration = configuration
|
|
1420
1457
|
accessoryButton.accessibilityLabel = model.accessibilityLabel ?? model.displayTitle
|
|
1421
1458
|
accessoryButton.accessibilityValue = model.badge
|
|
1422
|
-
accessoryButton.accessibilityTraits = chrome.accessorySelected ? [.button, .selected] : [.button]
|
|
1459
|
+
accessoryButton.accessibilityTraits = chrome.accessorySelected(model) ? [.button, .selected] : [.button]
|
|
1423
1460
|
}
|
|
1424
1461
|
#if compiler(>=6.2)
|
|
1425
1462
|
if #available(iOS 26.0, *), model?.iconOnly != true {
|
|
@@ -1493,6 +1530,11 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
1493
1530
|
let x = iconOnly ? view.bounds.maxX - view.safeAreaInsets.right - width - 20 : 16
|
|
1494
1531
|
accessoryButton.frame = CGRect(x: x, y: tabBar.frame.minY - height - (iconOnly ? 8 : 0), width: width, height: height)
|
|
1495
1532
|
accessoryGlass.frame = accessoryButton.frame
|
|
1533
|
+
for (index, button) in companionButtons.enumerated() {
|
|
1534
|
+
// Array order reads left to right, ending at the primary action.
|
|
1535
|
+
button.frame = accessoryButton.frame.offsetBy(dx: -CGFloat(companionButtons.count - index) * (width + 12), dy: 0)
|
|
1536
|
+
companionGlasses[index].frame = button.frame
|
|
1537
|
+
}
|
|
1496
1538
|
let badgeWidth = max(18, accessoryBadge.intrinsicContentSize.width + 8)
|
|
1497
1539
|
accessoryBadge.frame = CGRect(x: accessoryButton.frame.maxX - badgeWidth + 4, y: accessoryButton.frame.minY - 3, width: badgeWidth, height: 18)
|
|
1498
1540
|
// A compact action floats over the page, not across its whole width.
|
|
@@ -2703,11 +2745,11 @@ final class CraftFramePacing: NSObject {
|
|
|
2703
2745
|
private let output = DispatchQueue(label: "craft.frame-pacing", qos: .utility)
|
|
2704
2746
|
private lazy var file: FileHandle? = {
|
|
2705
2747
|
guard let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return nil }
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2748
|
+
do { return try CraftFramePacingLog.open(in: caches) }
|
|
2749
|
+
catch {
|
|
2750
|
+
NSLog("[craft frames] trace file unavailable: %@", String(describing: error))
|
|
2751
|
+
return nil
|
|
2752
|
+
}
|
|
2711
2753
|
}()
|
|
2712
2754
|
|
|
2713
2755
|
private func write(_ line: String) {
|
|
@@ -10779,6 +10779,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10779
10779
|
// beneath the screen's floating chrome.
|
|
10780
10780
|
let overflow = style["overflow"] as? String
|
|
10781
10781
|
view.clipsToBounds = overflow == "hidden"
|
|
10782
|
+
// A single-line editor owns a text viewport. Its private label
|
|
10783
|
+
// can be wider than the control while editing or showing a URL;
|
|
10784
|
+
// inherited visible overflow must not open that viewport.
|
|
10785
|
+
|| view is UITextField
|
|
10782
10786
|
|| (view is UIScrollView && overflow != "visible")
|
|
10783
10787
|
|| (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
|
|
10784
10788
|
|| ((view is UIImageView || view is CraftNativePositionedImageView) && (borderRadius > 0 || hasCornerRadii))
|
|
@@ -11440,6 +11444,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11440
11444
|
// UISwitch also owns its native identity and enabled/on state.
|
|
11441
11445
|
// Assigning .button changes its legacy AX type into a button while
|
|
11442
11446
|
// modern XCTest still identifies its UIKit class as a switch.
|
|
11447
|
+
// The solid variant publishes "1"/"0" as a string from
|
|
11448
|
+
// refreshAccessibility(). A UISwitch left to UIKit does not: the value
|
|
11449
|
+
// it hands XCTest arrives as a number, so every `value as? String`
|
|
11450
|
+
// read finds nothing. The driver's flip check is one of those reads,
|
|
11451
|
+
// which is why a switch that did flip was reported as never flipping,
|
|
11452
|
+
// and only sometimes - it depends which representation the accessibility
|
|
11453
|
+
// snapshot was built from. Publishing the string makes both variants
|
|
11454
|
+
// answer the same way. An authored accessibilityValue still wins.
|
|
11455
|
+
if let toggle = view as? UISwitch, !authoredAccessibilityValues.contains(id) {
|
|
11456
|
+
view.accessibilityValue = toggle.isOn ? "1" : "0"
|
|
11457
|
+
}
|
|
11443
11458
|
if view is UITextView || view is UISwitch { return }
|
|
11444
11459
|
if let toggle = view as? CraftNativeSolidSwitch { toggle.refreshAccessibility(); return }
|
|
11445
11460
|
view.accessibilityTraits = traits
|
|
@@ -30,14 +30,22 @@ enum CraftPresenter {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/// Present over whatever is on screen. A controller that shows as a
|
|
33
|
-
/// popover
|
|
34
|
-
/// `view`, or sits in the middle of it with no
|
|
35
|
-
/// exception for a popover with neither. False
|
|
36
|
-
/// so the caller answers rather than waiting.
|
|
33
|
+
/// popover (an action sheet, a share sheet, on a regular-width screen)
|
|
34
|
+
/// points at `anchor` in `view`, or sits in the middle of it with no
|
|
35
|
+
/// arrow; iPadOS raises an exception for a popover with neither. False
|
|
36
|
+
/// when nothing can present, so the caller answers rather than waiting.
|
|
37
|
+
///
|
|
38
|
+
/// Only where the presentation really is a popover. A `.actionSheet`
|
|
39
|
+
/// always carries a `popoverPresentationController`, phone included, and
|
|
40
|
+
/// from iOS 26 configuring it is enough to make the phone present the
|
|
41
|
+
/// sheet as a centred popover -- which silently drops its cancel action,
|
|
42
|
+
/// as UIKit never draws one in a popover. A compact screen is left alone
|
|
43
|
+
/// and gets the standard sheet, with its Cancel.
|
|
37
44
|
@discardableResult
|
|
38
45
|
static func present(_ controller: UIViewController, from view: UIView?, anchor: CGRect? = nil) -> Bool {
|
|
39
46
|
guard let top = topViewController() else { return false }
|
|
40
|
-
|
|
47
|
+
let asPopover = (view ?? top.view)?.traitCollection.horizontalSizeClass == .regular
|
|
48
|
+
if asPopover, let popover = controller.popoverPresentationController {
|
|
41
49
|
let source = view ?? top.view!
|
|
42
50
|
popover.sourceView = source
|
|
43
51
|
if let anchor {
|
|
@@ -182,6 +182,30 @@ final class DriverTests: XCTestCase {
|
|
|
182
182
|
return nil
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/// Safari's web view extends behind its browser bars. Those regions accept
|
|
186
|
+
/// touches for the bars, not for page controls reported inside the web view.
|
|
187
|
+
func browserContentFrame(_ frame: CGRect, topBar: CGRect?, bottomBar: CGRect?) -> CGRect {
|
|
188
|
+
var top = frame.minY
|
|
189
|
+
var bottom = frame.maxY
|
|
190
|
+
if let bar = topBar, bar.contains(CGPoint(x: frame.midX, y: bar.midY)), bar.intersects(frame) {
|
|
191
|
+
top = max(top, bar.maxY)
|
|
192
|
+
}
|
|
193
|
+
if let bar = bottomBar, bar.contains(CGPoint(x: frame.midX, y: bar.midY)), bar.intersects(frame) {
|
|
194
|
+
bottom = min(bottom, bar.minY)
|
|
195
|
+
}
|
|
196
|
+
return CGRect(x: frame.minX, y: top, width: frame.width, height: max(0, bottom - top))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
func scrollViewport() -> CGRect {
|
|
200
|
+
let webView = app.webViews.firstMatch
|
|
201
|
+
let topBar = app.otherElements["TopBrowserBar"]
|
|
202
|
+
let bottomBar = app.toolbars["BottomBrowserToolbar"]
|
|
203
|
+
guard webView.exists, topBar.exists || bottomBar.exists else { return app.frame }
|
|
204
|
+
return browserContentFrame(webView.frame.intersection(app.frame),
|
|
205
|
+
topBar: topBar.exists ? topBar.frame : nil,
|
|
206
|
+
bottomBar: bottomBar.exists ? bottomBar.frame : nil)
|
|
207
|
+
}
|
|
208
|
+
|
|
185
209
|
/// A short target fits entirely; an oversized target fills the viewport.
|
|
186
210
|
func visibleScrollFrame(_ target: CGRect, in viewport: CGRect) -> Bool {
|
|
187
211
|
let visible = target.intersection(viewport)
|
|
@@ -192,7 +216,7 @@ final class DriverTests: XCTestCase {
|
|
|
192
216
|
|
|
193
217
|
func scrollTargetVisible(_ target: XCUIElement?) -> Bool {
|
|
194
218
|
guard let target, target.exists, target.isHittable,
|
|
195
|
-
visibleScrollFrame(target.frame, in:
|
|
219
|
+
visibleScrollFrame(target.frame, in: scrollViewport()) else { return false }
|
|
196
220
|
// Hittable alone accepts controls clipped by the scroll view's edge.
|
|
197
221
|
// Only an ancestor scroll view clips this target, not its siblings.
|
|
198
222
|
// One that does not overlap the target is not its ancestor, whatever
|
|
@@ -220,7 +244,7 @@ final class DriverTests: XCTestCase {
|
|
|
220
244
|
/// below the screen while drawing it over the lower half - and it stops
|
|
221
245
|
/// short of a keyboard whose frame is known.
|
|
222
246
|
func scrollPage(towardTop: Bool) {
|
|
223
|
-
let frame =
|
|
247
|
+
let frame = scrollViewport()
|
|
224
248
|
var lower = frame.minY + frame.height * 0.55
|
|
225
249
|
let keyboard = app.keyboards.firstMatch
|
|
226
250
|
if keyboard.exists, !keyboard.frame.isEmpty, keyboard.frame.minY < frame.maxY {
|
|
@@ -719,7 +743,7 @@ final class DriverTests: XCTestCase {
|
|
|
719
743
|
var found = element(containing: arg)
|
|
720
744
|
var drags = 0
|
|
721
745
|
while !scrollTargetVisible(found) && drags < 12 {
|
|
722
|
-
let towardTop = found.map { !$0.frame.isEmpty && $0.frame.midY <
|
|
746
|
+
let towardTop = found.map { !$0.frame.isEmpty && $0.frame.midY < scrollViewport().midY } ?? false
|
|
723
747
|
scrollPage(towardTop: towardTop)
|
|
724
748
|
drags += 1
|
|
725
749
|
found = element(containing: arg)
|