├── .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 | --------------------------------------------------------------------------------