Skip to content

Commit 1627b93

Browse files
authored
Fix searchField introspection (#234)
1 parent f327069 commit 1627b93

File tree

13 files changed

+773
-75
lines changed

13 files changed

+773
-75
lines changed

Examples/Showcase/Showcase/ContentView.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,10 @@ struct NavigationShowcase: View {
186186
.introspect(.navigationView(style: .columns), on: .tvOS(.v13, .v14, .v15, .v16)) { navigationController in
187187
navigationController.navigationBar.backgroundColor = .cyan
188188
}
189-
.introspect(.searchField, on: .iOS(.v15, .v16), .tvOS(.v15, .v16)) { searchField in
190-
searchField.backgroundColor = .red
189+
.introspect(.searchField, on: .iOS(.v15, .v16), .tvOS(.v15, .v16)) { searchBar in
190+
searchBar.backgroundColor = .red
191191
#if os(iOS)
192-
searchField.searchTextField.backgroundColor = .purple
192+
searchBar.searchTextField.backgroundColor = .purple
193193
#endif
194194
}
195195
#endif

Sources/Introspect.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,13 @@ public protocol PlatformEntity: AnyObject {
5959
}
6060

6161
extension PlatformEntity {
62-
var ancestors: some Sequence<Base> {
62+
@_spi(Internals)
63+
public var ancestors: some Sequence<Base> {
6364
sequence(first: self~, next: { $0.ancestor~ }).dropFirst()
6465
}
6566

66-
var allDescendants: [Base] {
67+
@_spi(Internals)
68+
public var allDescendants: [Base] {
6769
self.descendants.reduce([self~]) { $0 + $1.allDescendants~ }
6870
}
6971

Sources/ViewTypes/SearchField.swift

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,28 @@ extension iOSViewVersion<SearchFieldType, UISearchBar> {
1414
public static let v13 = Self.unavailable()
1515
@available(*, unavailable, message: ".searchable isn't available on iOS 14")
1616
public static let v14 = Self.unavailable()
17-
public static let v15 = Self(for: .v15)
18-
public static let v16 = Self(for: .v16)
17+
public static let v15 = Self(for: .v15, selector: selector)
18+
public static let v16 = Self(for: .v16, selector: selector)
19+
20+
private static var selector: IntrospectionSelector<UISearchBar> {
21+
.from(UINavigationController.self) {
22+
$0.viewIfLoaded?.allDescendants.lazy.compactMap { $0 as? UISearchBar }.first
23+
}
24+
}
1925
}
2026

2127
extension tvOSViewVersion<SearchFieldType, UISearchBar> {
2228
@available(*, unavailable, message: ".searchable isn't available on tvOS 13")
2329
public static let v13 = Self.unavailable()
2430
@available(*, unavailable, message: ".searchable isn't available on tvOS 14")
2531
public static let v14 = Self.unavailable()
26-
public static let v15 = Self(for: .v15)
27-
public static let v16 = Self(for: .v16)
32+
public static let v15 = Self(for: .v15, selector: selector)
33+
public static let v16 = Self(for: .v16, selector: selector)
34+
35+
private static var selector: IntrospectionSelector<UISearchBar> {
36+
.from(UINavigationController.self) {
37+
$0.viewIfLoaded?.allDescendants.lazy.compactMap { $0 as? UISearchBar }.first
38+
}
39+
}
2840
}
2941
#endif
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import SwiftUI
2+
3+
@main
4+
final class AppDelegate: UIResponder, UIApplicationDelegate {
5+
6+
var window: UIWindow?
7+
8+
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
9+
window = UIWindow(frame: UIScreen.main.bounds)
10+
window?.rootViewController = UIHostingController(rootView: EmptyView())
11+
window?.makeKeyAndVisible()
12+
return true
13+
}
14+
}

Tests/Tests.xcodeproj/project.pbxproj

Lines changed: 566 additions & 2 deletions
Large diffs are not rendered by default.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<Scheme
3+
LastUpgradeVersion = "1420"
4+
version = "1.3">
5+
<BuildAction
6+
parallelizeBuildables = "YES"
7+
buildImplicitDependencies = "YES">
8+
</BuildAction>
9+
<TestAction
10+
buildConfiguration = "Debug"
11+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
12+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
13+
shouldUseLaunchSchemeArgsEnv = "YES">
14+
<Testables>
15+
<TestableReference
16+
skipped = "NO">
17+
<BuildableReference
18+
BuildableIdentifier = "primary"
19+
BlueprintIdentifier = "D50E2F592A2B9F6600BAFB03"
20+
BuildableName = "LegacyTests.xctest"
21+
BlueprintName = "LegacyTests"
22+
ReferencedContainer = "container:Tests.xcodeproj">
23+
</BuildableReference>
24+
</TestableReference>
25+
</Testables>
26+
</TestAction>
27+
<LaunchAction
28+
buildConfiguration = "Debug"
29+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
30+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
31+
launchStyle = "0"
32+
useCustomWorkingDirectory = "NO"
33+
ignoresPersistentStateOnLaunch = "NO"
34+
debugDocumentVersioning = "YES"
35+
debugServiceExtension = "internal"
36+
allowLocationSimulation = "YES">
37+
<BuildableProductRunnable
38+
runnableDebuggingMode = "0">
39+
<BuildableReference
40+
BuildableIdentifier = "primary"
41+
BlueprintIdentifier = "D50E2F4D2A2B9DEE00BAFB03"
42+
BuildableName = "LegacyTestsHostApp.app"
43+
BlueprintName = "LegacyTestsHostApp"
44+
ReferencedContainer = "container:Tests.xcodeproj">
45+
</BuildableReference>
46+
</BuildableProductRunnable>
47+
</LaunchAction>
48+
<ProfileAction
49+
buildConfiguration = "Release"
50+
shouldUseLaunchSchemeArgsEnv = "YES"
51+
savedToolIdentifier = ""
52+
useCustomWorkingDirectory = "NO"
53+
debugDocumentVersioning = "YES">
54+
<MacroExpansion>
55+
<BuildableReference
56+
BuildableIdentifier = "primary"
57+
BlueprintIdentifier = "D50E2F4D2A2B9DEE00BAFB03"
58+
BuildableName = "LegacyTestsHostApp.app"
59+
BlueprintName = "LegacyTestsHostApp"
60+
ReferencedContainer = "container:Tests.xcodeproj">
61+
</BuildableReference>
62+
</MacroExpansion>
63+
</ProfileAction>
64+
<AnalyzeAction
65+
buildConfiguration = "Debug">
66+
</AnalyzeAction>
67+
<ArchiveAction
68+
buildConfiguration = "Release"
69+
revealArchiveInOrganizer = "YES">
70+
</ArchiveAction>
71+
</Scheme>

Tests/Tests/TestUtils.swift

Lines changed: 9 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,46 +3,24 @@ import XCTest
33

44
#if canImport(UIKit)
55
enum TestUtils {
6-
enum Constants {
7-
static let timeout: TimeInterval = 3
8-
}
6+
private static let window = UIWindow(frame: UIScreen.main.bounds)
97

108
static func present(view: some View) {
11-
let hostingController = UIHostingController(rootView: view)
12-
13-
for window in UIApplication.shared.windows {
14-
if let presentedViewController = window.rootViewController?.presentedViewController {
15-
presentedViewController.dismiss(animated: false, completion: nil)
16-
}
17-
window.isHidden = true
18-
}
19-
20-
let window = UIWindow(frame: UIScreen.main.bounds)
21-
window.layer.speed = 10
22-
23-
hostingController.beginAppearanceTransition(true, animated: false)
24-
window.rootViewController = hostingController
9+
window.rootViewController = UIHostingController(rootView: view)
2510
window.makeKeyAndVisible()
2611
window.layoutIfNeeded()
27-
hostingController.endAppearanceTransition()
2812
}
2913
}
3014
#elseif canImport(AppKit)
3115
enum TestUtils {
32-
enum Constants {
33-
static let timeout: TimeInterval = 5
34-
}
16+
private static let window = NSWindow(
17+
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
18+
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
19+
backing: .buffered,
20+
defer: true
21+
)
3522

3623
static func present(view: some View) {
37-
let window = NSWindow(
38-
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
39-
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
40-
backing: .buffered,
41-
defer: true
42-
)
43-
44-
window.center()
45-
window.setFrameAutosaveName("Main Window")
4624
window.contentView = NSHostingView(rootView: view)
4725
window.makeKeyAndOrderFront(nil)
4826
window.layoutIfNeeded()
@@ -60,7 +38,7 @@ func XCTAssertViewIntrospection<V: View, PV: AnyObject>(
6038
let spies = Spies<PV>()
6139
let view = view(spies)
6240
TestUtils.present(view: view)
63-
XCTWaiter(delegate: spies).wait(for: spies.expectations.values.map(\.0), timeout: TestUtils.Constants.timeout)
41+
XCTWaiter(delegate: spies).wait(for: spies.expectations.values.map(\.0), timeout: 3)
6442
extraAssertions(spies.objects.sorted(by: { $0.key < $1.key }).map(\.value))
6543
}
6644

Tests/Tests/ViewTypes/NavigationSplitViewTests.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ final class NavigationSplitViewTests: XCTestCase {
5050
XCTAssertViewIntrospection(of: PlatformNavigationSplitView.self) { spies in
5151
let spy = spies[0]
5252

53-
NavigationSplitView {
53+
// NB: columnVisibility is explicitly set here for ancestor introspection to work, because initially on iPad the sidebar is hidden, so the introspection modifier isn't triggered until the user makes the sidebar appear. This is why ancestor introspection is discouraged for most situations and it's opt-in.
54+
NavigationSplitView(columnVisibility: .constant(.all)) {
5455
ZStack {
5556
Color.red
5657
Text("Sidebar")

Tests/Tests/ViewTypes/NavigationViewWithColumnsStyleTests.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ final class NavigationViewWithColumnsStyleTests: XCTestCase {
5050
}
5151
}
5252
.navigationViewStyle(DoubleColumnNavigationViewStyle())
53+
#if os(iOS)
54+
// NB: this is necessary for ancestor introspection to work, because initially on iPad the "Customized" text isn't shown as it's hidden in the sidebar. This is why ancestor introspection is discouraged for most situations and it's opt-in.
55+
.introspect(.navigationView(style: .columns), on: .iOS(.v13, .v14, .v15, .v16)) {
56+
$0.preferredDisplayMode = .oneOverSecondary
57+
}
58+
#endif
5359
}
5460
}
5561
}

Tests/Tests/ViewTypes/SearchFieldTests.swift

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ final class SearchFieldTests: XCTestCase {
99
typealias PlatformSearchField = UISearchBar
1010
#endif
1111

12-
func testSearchField() throws {
12+
func testSearchFieldInNavigationStack() throws {
1313
guard #available(iOS 15, tvOS 15, *) else {
1414
throw XCTSkip()
1515
}
@@ -27,5 +27,74 @@ final class SearchFieldTests: XCTestCase {
2727
#endif
2828
}
2929
}
30+
31+
func testSearchFieldInNavigationStackAsAncestor() throws {
32+
guard #available(iOS 15, tvOS 15, *) else {
33+
throw XCTSkip()
34+
}
35+
36+
XCTAssertViewIntrospection(of: PlatformSearchField.self) { spies in
37+
let spy = spies[0]
38+
39+
NavigationView {
40+
Text("Customized")
41+
.searchable(text: .constant(""))
42+
#if os(iOS) || os(tvOS)
43+
.introspect(.searchField, on: .iOS(.v15, .v16), .tvOS(.v15, .v16), scope: .ancestor, customize: spy)
44+
#endif
45+
}
46+
.navigationViewStyle(.stack)
47+
}
48+
}
49+
50+
func testSearchFieldInNavigationSplitView() throws {
51+
guard #available(iOS 15, tvOS 15, *) else {
52+
throw XCTSkip()
53+
}
54+
55+
XCTAssertViewIntrospection(of: PlatformSearchField.self) { spies in
56+
let spy = spies[0]
57+
58+
NavigationView {
59+
Text("Customized")
60+
.searchable(text: .constant(""))
61+
}
62+
.navigationViewStyle(DoubleColumnNavigationViewStyle())
63+
#if os(iOS) || os(tvOS)
64+
.introspect(.searchField, on: .iOS(.v15, .v16), .tvOS(.v15, .v16), customize: spy)
65+
#endif
66+
#if os(iOS)
67+
// NB: this is necessary for introspection to work, because on iPad the search field is in the sidebar, which is initially hidden.
68+
.introspect(.navigationView(style: .columns), on: .iOS(.v13, .v14, .v15, .v16)) {
69+
$0.preferredDisplayMode = .oneOverSecondary
70+
}
71+
#endif
72+
}
73+
}
74+
75+
func testSearchFieldInNavigationSplitViewAsAncestor() throws {
76+
guard #available(iOS 15, tvOS 15, *) else {
77+
throw XCTSkip()
78+
}
79+
80+
XCTAssertViewIntrospection(of: PlatformSearchField.self) { spies in
81+
let spy = spies[0]
82+
83+
NavigationView {
84+
Text("Customized")
85+
.searchable(text: .constant(""))
86+
#if os(iOS) || os(tvOS)
87+
.introspect(.searchField, on: .iOS(.v15, .v16), .tvOS(.v15, .v16), scope: .ancestor, customize: spy)
88+
#endif
89+
}
90+
.navigationViewStyle(DoubleColumnNavigationViewStyle())
91+
#if os(iOS)
92+
// NB: this is necessary for introspection to work, because on iPad the search field is in the sidebar, which is initially hidden.
93+
.introspect(.navigationView(style: .columns), on: .iOS(.v13, .v14, .v15, .v16)) {
94+
$0.preferredDisplayMode = .oneOverSecondary
95+
}
96+
#endif
97+
}
98+
}
3099
}
31100
#endif

0 commit comments

Comments
 (0)