├── .github
├── FUNDING.yml
└── workflows
│ └── cocoapods.yml
├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── contents.xcworkspacedata
├── ChainResponder.gif
├── LICENSE
├── Package.resolved
├── Package.swift
├── README.md
├── ResponderChain.podspec
├── Sources
└── ResponderChain
│ └── ResponderChain.swift
└── Tests
├── LinuxMain.swift
└── ResponderChainTests
├── ResponderChainTests.swift
└── XCTestManifests.swift
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [Amzd] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
13 |
--------------------------------------------------------------------------------
/.github/workflows/cocoapods.yml:
--------------------------------------------------------------------------------
1 |
2 | name: Cocoapods release
3 | on:
4 | workflow_dispatch:
5 | release:
6 |
7 | jobs:
8 | build:
9 | runs-on: macOS-latest
10 |
11 | steps:
12 | - uses: actions/checkout@v1
13 | - name: Publish to CocoaPod register
14 | env:
15 | COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }}
16 | run: |
17 | pod trunk push ResponderChain.podspec --allow-warnings
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/ChainResponder.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Amzd/ResponderChain/f757143bb000e8ffb81deac4b2fc59cefd12337f/ChainResponder.gif
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Casper "Amzd" Zandbergen
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "Introspect",
6 | "repositoryURL": "https://github.com/siteline/SwiftUI-Introspect.git",
7 | "state": {
8 | "branch": null,
9 | "revision": "36ecf80429d00a4cd1e81fbfe4655b1d99ebd651",
10 | "version": "0.1.2"
11 | }
12 | },
13 | {
14 | "package": "SwizzleSwift",
15 | "repositoryURL": "https://github.com/Amzd/SwizzleSwift",
16 | "state": {
17 | "branch": null,
18 | "revision": "e2d31c646182bf94a496b173c6ee5ad191230e9a",
19 | "version": "1.0.0"
20 | }
21 | },
22 | {
23 | "package": "ViewInspector",
24 | "repositoryURL": "https://github.com/nalexn/ViewInspector",
25 | "state": {
26 | "branch": null,
27 | "revision": "ef7874a423f3610a4ce4e396d9a61494f5e96b56",
28 | "version": "0.6.0"
29 | }
30 | }
31 | ]
32 | },
33 | "version": 1
34 | }
35 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "ResponderChain",
8 | platforms: [
9 | .macOS(.v10_13),
10 | .iOS(.v11),
11 | .tvOS(.v11)
12 | ],
13 | products: [
14 | .library(name: "ResponderChain", targets: ["ResponderChain"]),
15 | ],
16 | dependencies: [
17 | .package(name: "Introspect", url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.2"),
18 | .package(url: "https://github.com/Amzd/SwizzleSwift", from: "1.0.0"),
19 | .package(url: "https://github.com/nalexn/ViewInspector", from: "0.6.0"),
20 | ],
21 | targets: [
22 | .target(name: "ResponderChain", dependencies: ["Introspect", "SwizzleSwift"]),
23 | .testTarget(name: "ResponderChainTests", dependencies: ["ResponderChain", "ViewInspector"]),
24 | ]
25 | )
26 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | As of June 7 2021 this functionality is in the SwiftUI 3 beta. https://developer.apple.com/documentation/SwiftUI/FocusState
2 |
3 | The Apple implementation is a bit different from ResponderChain but switching over looks to be quite easy.
4 |
5 | Also the Apple implementation only supports iOS 15 so I think this repo is still useful for backwards compatibility.
6 |
7 | # ⛓️ ResponderChain
8 |
9 | Cross-platform first responder handling without subclassing views or making custom ViewRepresentables in SwiftUI
10 |
11 | ## Features
12 |
13 | - **💡 Easy to use:** Get, set and resign first responder simply through an EnvironmentObject.
14 | - **⏰ Time Saving:** If an underlying view can become first responder all you have to do is tag it; and it works!
15 | - **👀 Insightful:** Gives insight in which views can become first responder.
16 |
17 | ## Overview
18 |
19 | Attach the ResponderChain as environmentObject.
20 |
21 | ```swift
22 | // In the SceneDelegate or ApplicationDelegate where you have access to the window:
23 | let rootView = Example().environmentObject(ResponderChain(forWindow: window))
24 |
25 | // SwiftUI only:
26 | Example().withResponderChainForCurrentWindow()
27 | ```
28 |
29 | Tag views that can become first responder.
30 |
31 | ```swift
32 | TextField(...).responderTag("MyTextField")
33 | ```
34 |
35 | Check tagged views that are currently available to become first responder.
36 |
37 | ```swift
38 | chain.availableResponders.contains("MyList")
39 | ```
40 |
41 | Make tagged views become first responder.
42 |
43 | ```swift
44 | chain.firstResponder = "MyTextField"
45 | if chain.firstResponder == nil {
46 | print("Failed")
47 | }
48 | ```
49 | > This is completely safe, if "MyTextField" was either not available to become first responder or it wasn't tagged properly; `chain.firstResponder` will become `nil`
50 |
51 |
52 |
53 | Resign first responder.
54 |
55 | ```swift
56 | chain.firstResponder = nil
57 | ```
58 | > **Note:** This only works if the current firstResponder was tagged.
59 |
60 | ## Example
61 |
62 | Attach the ResponderChain as environmentObject.
63 |
64 | ```swift
65 | ...
66 | // In the SceneDelegate or ApplicationDelegate where you have access to the window:
67 | let rootView = ResponderChainExample().environmentObject(ResponderChain(forWindow: window))
68 |
69 | // SwiftUI only:
70 | ResponderChainExample().withResponderChainForCurrentWindow()
71 | ...
72 | ```
73 |
74 | **ResponderChainExample.swift**
75 | ```swift
76 | struct ResponderChainExample: View {
77 | @EnvironmentObject var chain: ResponderChain
78 |
79 | var body: some View {
80 | VStack(spacing: 20) {
81 | // Show which view is first responder
82 | Text("Selected field: \(chain.firstResponder?.description ?? "Nothing selected")")
83 |
84 | // Some views that can become first responder
85 | TextField("0", text: .constant(""), onCommit: { chain.firstResponder = "1" }).responderTag("0")
86 | TextField("1", text: .constant(""), onCommit: { chain.firstResponder = "2" }).responderTag("1")
87 | TextField("2", text: .constant(""), onCommit: { chain.firstResponder = "3" }).responderTag("2")
88 | TextField("3", text: .constant(""), onCommit: { chain.firstResponder = nil }).responderTag("3")
89 |
90 | // Buttons to change first responder
91 | HStack {
92 | Button("Select 0", action: { chain.firstResponder = "0" })
93 | Button("Select 1", action: { chain.firstResponder = "1" })
94 | Button("Select 2", action: { chain.firstResponder = "2" })
95 | Button("Select 3", action: { chain.firstResponder = "3" })
96 | }
97 | }
98 | .padding()
99 | .onAppear {
100 | // Set first responder on appear
101 | DispatchQueue.main.async {
102 | chain.firstResponder = "0"
103 | }
104 | }
105 | }
106 | }
107 | ```
108 |
109 |
110 |
--------------------------------------------------------------------------------
/ResponderChain.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |s|
2 | s.name = 'ResponderChain'
3 | s.version = '1.1.2'
4 | s.summary = 'Cross-platform first responder handling without subclassing views or making custom ViewRepresentables in SwiftUI'
5 | s.license = { type: 'MIT' }
6 | s.homepage = 'https://github.com/Amzd/ResponderChain'
7 | s.author = { 'Casper Zandbergen' => 'info@casperzandbergen.nl' }
8 | s.source = { :git => 'https://github.com/Amzd/ResponderChain.git', :tag => s.version.to_s }
9 | s.dependency 'Introspect', '0.1.2'
10 | s.dependency 'SwizzleSwift'
11 | s.source_files = 'Sources/**/*.swift'
12 |
13 | s.swift_version = '5.1'
14 | s.ios.deployment_target = '11.0'
15 | # s.tvos.deployment_target = '11.0' # SwizzleSwift doesn't support tvos on cocoapods
16 | # s.osx.deployment_target = '10.13' # SwizzleSwift doesn't support osx on cocoapods
17 | end
18 |
--------------------------------------------------------------------------------
/Sources/ResponderChain/ResponderChain.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ResponderChain.swift
3 | //
4 | // Created by Casper Zandbergen on 30/11/2020.
5 | // https://twitter.com/amzdme
6 | //
7 |
8 | import Foundation
9 | import Combine
10 | import SwiftUI
11 | import Introspect
12 |
13 | // MARK: Platform specifics
14 |
15 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
16 | typealias ResponderPublisher = AnyPublisher
17 |
18 | #if os(macOS)
19 | import Cocoa
20 | public typealias PlatformWindow = NSWindow
21 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
22 | public typealias PlatformIntrospectionView = AppKitIntrospectionView
23 | typealias PlatformResponder = NSResponder
24 |
25 | extension NSWindow {
26 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
27 | var firstResponderPublisher: ResponderPublisher {
28 | publisher(for: \.firstResponder).eraseToAnyPublisher()
29 | }
30 | }
31 |
32 | extension NSView {
33 | /// There is no swizzling needed on macOS
34 | static var responderSwizzling: Void = ()
35 |
36 | var canBecomeFirstResponder: Bool {
37 | return canBecomeKeyView
38 | }
39 | }
40 |
41 | #elseif os(iOS) || os(tvOS) || os(watchOS)
42 | import UIKit
43 | import SwizzleSwift
44 | public typealias PlatformWindow = UIWindow
45 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
46 | public typealias PlatformIntrospectionView = UIKitIntrospectionView
47 | typealias PlatformResponder = UIResponder
48 |
49 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
50 | extension UIView {
51 | static var responderSwizzling: Void = {
52 | Swizzle(UIView.self) {
53 | #selector(becomeFirstResponder) <-> #selector(becomeFirstResponder_ResponderChain)
54 | #selector(resignFirstResponder) <-> #selector(resignFirstResponder_ResponderChain)
55 | }
56 | }()
57 |
58 | var firstResponderPublisher: ResponderPublisher {
59 | Self._firstResponderPublisher.eraseToAnyPublisher()
60 | }
61 |
62 | // I assume that resignFirstResponder is always called before an object is meant to be released.
63 | // If that is not the case then having a CurrentValueSubject instead of PassthroughSubject will
64 | // cause the firstResponder to be retained until a new firstResponder is set.
65 | private static let _firstResponderPublisher = CurrentValueSubject(nil)
66 |
67 | @objc open func becomeFirstResponder_ResponderChain() -> Bool {
68 | let result = becomeFirstResponder_ResponderChain()
69 | Self._firstResponderPublisher.send(self)
70 | return result
71 | }
72 |
73 | @objc open func resignFirstResponder_ResponderChain() -> Bool {
74 | Self._firstResponderPublisher.send(nil)
75 | guard Self.instancesRespond(to: #selector(UIView.resignFirstResponder_ResponderChain)) else {
76 | // UIAlertController somehow calls this but I can't figure out a
77 | // way to call an original resignFirstResponder. I haven't found
78 | // anything broken with just returning false here.
79 | return false
80 | }
81 | return resignFirstResponder_ResponderChain()
82 | }
83 | }
84 | #endif
85 |
86 | // MARK: View Extension
87 |
88 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
89 | extension View {
90 | /// Tag the closest sibling view that can become first responder
91 | public func responderTag(_ tag: Tag) -> some View {
92 | inject(FindResponderSibling(tag: tag))
93 | }
94 |
95 | /// This attaches the ResponderChain for the current window as environmentObject
96 | ///
97 | /// Will not show anything for the first frame as it introspects the closest view to get the window
98 | ///
99 | /// Use `.environmentObject(ResponderChain(forWindow: window))` if possible.
100 | public func withResponderChainForCurrentWindow() -> some View {
101 | self.modifier(ResponderChainWindowFinder())
102 | }
103 | }
104 |
105 | // MARK: ResponderChain
106 |
107 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
108 | public class ResponderChain: ObservableObject {
109 | @Published public var firstResponder: AnyHashable? {
110 | didSet {
111 | if shouldUpdateUI { updateUIForNewFirstResponder(oldValue: oldValue) }
112 | }
113 | }
114 |
115 | public var availableResponders: [AnyHashable] {
116 | taggedResponders.filter { $0.value.canBecomeFirstResponder } .map(\.key)
117 | }
118 |
119 | private var cancellables = Set()
120 | private var window: PlatformWindow
121 | private var shouldUpdateUI: Bool = true
122 | @Published internal var taggedResponders: [AnyHashable: PlatformView] = [:]
123 |
124 | public init(forWindow window: PlatformWindow) {
125 | self.window = window
126 | _ = PlatformView.responderSwizzling
127 | window.firstResponderPublisher.sink(receiveValue: { [self] responder in
128 | let tag = responderTag(for: responder)
129 | setFirstResponderWithoutUpdatingUI(tag)
130 | }).store(in: &cancellables)
131 | }
132 |
133 | internal func responderTag(for responder: PlatformResponder?) -> AnyHashable? {
134 | guard let view = responder as? PlatformView else {
135 | return nil
136 | }
137 |
138 | let possibleResponders = taggedResponders.filter {
139 | $0.value == view || view.isDescendant(of: $0.value)
140 | }
141 |
142 | let respondersByDistance: [AnyHashable: Int] = possibleResponders.mapValues {
143 | var distance = 0
144 | var responder: PlatformView? = $0
145 | while let step = responder, view.isDescendant(of: step) {
146 | responder = step.subviews.first(where: view.isDescendant(of:))
147 | distance += 1
148 | }
149 | return distance
150 | }
151 |
152 | return respondersByDistance.min(by: { $0.value < $1.value })?.key
153 | }
154 |
155 | internal func setFirstResponderWithoutUpdatingUI(_ newFirstResponder: AnyHashable?) {
156 | shouldUpdateUI = false
157 | firstResponder = newFirstResponder
158 | shouldUpdateUI = true
159 | }
160 |
161 | internal func updateUIForNewFirstResponder(oldValue: AnyHashable?) {
162 | assert(Thread.isMainThread && shouldUpdateUI)
163 | if let tag = firstResponder, tag != oldValue {
164 | if let responder = taggedResponders[tag] {
165 | print("Making first responder:", tag, responder)
166 | #if os(macOS)
167 | let succeeded = window.makeFirstResponder(responder)
168 | #elseif os(iOS) || os(tvOS)
169 | let succeeded = responder.becomeFirstResponder()
170 | #endif
171 | if !succeeded {
172 | firstResponder = nil
173 | print("Failed to make \(tag) first responder")
174 | }
175 | } else {
176 | print("Can't find responder for tag \(tag), make sure to set a tag using `.responderTag(_:)`")
177 | firstResponder = nil
178 | }
179 | } else if firstResponder == nil, let previousResponder = oldValue.flatMap({ taggedResponders[$0] }) {
180 | print("Resigning first responder", oldValue ?? "")
181 | #if os(macOS)
182 | window.endEditing(for: previousResponder)
183 | #elseif os(iOS) || os(tvOS)
184 | previousResponder.endEditing(true)
185 | #endif
186 | }
187 | }
188 | }
189 |
190 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
191 | private struct ResponderChainWindowFinder: ViewModifier {
192 | @State private var window: PlatformWindow? = nil
193 |
194 | func body(content: Content) -> some View {
195 | Group {
196 | if let window = window {
197 | content.environmentObject(ResponderChain(forWindow: window))
198 | } else {
199 | EmptyView()
200 | }
201 | }.introspect(selector: { $0.self }) {
202 | if self.window != $0.window {
203 | self.window = $0.window
204 | }
205 | }
206 | }
207 | }
208 |
209 | // MARK: - Tag
210 |
211 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
212 | private struct FindResponderSibling: View {
213 | @EnvironmentObject var responderChain: ResponderChain
214 |
215 | var tag: Tag
216 |
217 | var body: some View {
218 | PlatformIntrospectionView(
219 | selector: { introspectionView in
220 | guard
221 | let viewHost = Introspect.findViewHost(from: introspectionView),
222 | let superview = viewHost.superview,
223 | let entryIndex = superview.subviews.firstIndex(of: viewHost),
224 | entryIndex > 0
225 | else {
226 | return nil
227 | }
228 |
229 | func findResponder(in root: PlatformView) -> PlatformView? {
230 | for subview in root.subviews {
231 | if subview.canBecomeFirstResponder {
232 | return subview
233 | } else if let responder = findResponder(in: subview) {
234 | return responder
235 | }
236 | }
237 | return nil
238 | }
239 |
240 | for subview in superview.subviews[0..(lhs: AnyHashable?, rhs: H) -> Bool {
264 | if let lhs = lhs {
265 | return lhs == AnyHashable(rhs)
266 | }
267 | return false
268 | }
269 |
--------------------------------------------------------------------------------
/Tests/LinuxMain.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | import ResponderChainTests
4 |
5 | var tests = [XCTestCaseEntry]()
6 | tests += ResponderChainTests.allTests()
7 | XCTMain(tests)
8 |
--------------------------------------------------------------------------------
/Tests/ResponderChainTests/ResponderChainTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import ViewInspector
3 | import SwiftUI
4 | import Combine
5 | @testable import ResponderChain
6 |
7 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
8 | struct ResponderChainExample: View, Inspectable {
9 | @ObservedObject var chain: ResponderChain
10 |
11 | var body: some View {
12 | VStack(spacing: 20) {
13 | // Show which view is first responder
14 | Text("Selected field: \(chain.firstResponder?.description ?? "Nothing selected")")
15 |
16 | // Some views that can become first responder
17 | TextField("0", text: .constant(""), onCommit: { chain.firstResponder = "1" }).responderTag("0")
18 | TextField("1", text: .constant(""), onCommit: { chain.firstResponder = "2" }).responderTag("1")
19 | TextField("2", text: .constant(""), onCommit: { chain.firstResponder = "3" }).responderTag("2")
20 | TextField("3", text: .constant(""), onCommit: { chain.firstResponder = nil }).responderTag("3")
21 |
22 | // Buttons to change first responder
23 | HStack {
24 | Button("Select 0", action: { chain.firstResponder = "0" })
25 | Button("Select 1", action: { chain.firstResponder = "1" })
26 | Button("Select 2", action: { chain.firstResponder = "2" })
27 | Button("Select 3", action: { chain.firstResponder = "3" })
28 | Button("Select Nothing", action: { chain.firstResponder = nil })
29 | }
30 | }
31 | .environmentObject(chain)
32 | .padding()
33 | .onAppear {
34 | // Set first responder on appear
35 | DispatchQueue.main.async {
36 | chain.firstResponder = "0"
37 | }
38 | }
39 | }
40 | }
41 |
42 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
43 | final class ResponderChainTests: XCTestCase {
44 | static var chain: ResponderChain!
45 | static var window: PlatformWindow!
46 |
47 | override func setUp() {
48 | super.setUp()
49 | if Self.chain == nil {
50 | let didSetResponderChain = XCTestExpectation(description: "Did set ResponderChain")
51 | let windowGrabber = EmptyView().introspect(selector: { $0.self }) {
52 | if let window = $0.window {
53 | Self.chain = ResponderChain(forWindow: window)
54 | Self.window = window
55 | didSetResponderChain.fulfill()
56 | }
57 | }
58 | ViewHosting.host(view: windowGrabber)
59 | wait(for: [didSetResponderChain], timeout: 0.1)
60 | }
61 |
62 | // Every test gets a new view
63 | testView = ResponderChainExample(chain: Self.chain)
64 | ViewHosting.host(view: testView, viewId: "ResponderChain")
65 | }
66 | override func tearDown() {
67 | super.tearDown()
68 | ViewHosting.expel(viewId: "ResponderChain")
69 | }
70 |
71 | var cancellables: Set = []
72 | var testView: ResponderChainExample!
73 |
74 | func didSetFirstResponder(to tag: AnyHashable?) -> XCTestExpectation {
75 | let didSetFirstResponder = XCTestExpectation(description: "Did set ResponderChain.firstResponder and did not fail")
76 | didSetFirstResponder.expectedFulfillmentCount = 2
77 |
78 | Self.chain.$firstResponder.first(where: { $0 == tag }).sink { newFirstResponder in
79 | DispatchQueue.main.async {
80 | // If setting the firstResponder failed, chain.firstResponder will be nil here
81 | if Self.chain.firstResponder == newFirstResponder {
82 | didSetFirstResponder.fulfill()
83 | }
84 | }
85 | }.store(in: &self.cancellables)
86 |
87 | Self.window.firstResponderPublisher.first(where: { Self.chain.responderTag(for: $0) == tag }).sink { view in
88 | DispatchQueue.main.async {
89 | // If setting the firstResponder failed, chain.firstResponder will be nil here
90 | if Self.chain.firstResponder == Self.chain.responderTag(for: view) {
91 | didSetFirstResponder.fulfill()
92 | }
93 | }
94 | }.store(in: &self.cancellables)
95 |
96 | return didSetFirstResponder
97 | }
98 |
99 | func testAll() throws {
100 | wait(for: [didSetFirstResponder(to: "0")], timeout: 0.5)
101 | XCTAssert(try testView.inspect().find(ViewType.Text.self).string() == "Selected field: 0")
102 |
103 | try testView.inspect().findAll(ViewType.TextField.self)[0].callOnCommit()
104 | wait(for: [didSetFirstResponder(to: "1")], timeout: 0.5)
105 | XCTAssert(try testView.inspect().find(ViewType.Text.self).string() == "Selected field: 1")
106 |
107 | try testView.inspect().findAll(ViewType.Button.self)[2].tap()
108 | wait(for: [didSetFirstResponder(to: "2")], timeout: 0.5)
109 | XCTAssert(try testView.inspect().find(ViewType.Text.self).string() == "Selected field: 2")
110 |
111 | try testView.inspect().findAll(ViewType.Button.self)[4].tap()
112 | wait(for: [didSetFirstResponder(to: nil)], timeout: 0.5)
113 | XCTAssert(try testView.inspect().find(ViewType.Text.self).string() == "Selected field: Nothing selected")
114 |
115 | Self.chain.firstResponder = "1"
116 | XCTAssert(Self.chain.firstResponder == "1")
117 | Self.chain.firstResponder = "Something that isn't tagged"
118 | XCTAssert(Self.chain.firstResponder == nil)
119 |
120 | XCTAssert(Set(Self.chain.availableResponders) == ["0", "1", "2", "3"])
121 | }
122 |
123 | static var allTests = [
124 | ("testAll", testAll),
125 | ]
126 | }
127 |
--------------------------------------------------------------------------------
/Tests/ResponderChainTests/XCTestManifests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | #if !canImport(ObjectiveC)
4 | public func allTests() -> [XCTestCaseEntry] {
5 | return [
6 | testCase(ResponderChainTests.allTests),
7 | ]
8 | }
9 | #endif
10 |
--------------------------------------------------------------------------------