Skip to content

Commit 1310f67

Browse files
committed
Added FirstResponderStateChangeHandler.receive(on:).
This is useful to prevent nested view updates when the change handler is triggered by a programatic first responder state change and it also needs to make a change that will trigger a view update.
1 parent 771a82e commit 1310f67

File tree

1 file changed

+45
-2
lines changed

1 file changed

+45
-2
lines changed

Sources/ResponsiveTextField/ResponsiveTextField.swift

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// ResponsiveTextField.swift
33
//
44

5+
import Combine
56
import UIKit
67
import SwiftUI
78

@@ -191,8 +192,9 @@ public struct FirstResponderStateChangeHandler {
191192
}
192193

193194
/// Returns a new state change handler that wraps the underlying state change handler
194-
/// in a `withAnimation` closure - this is useful if you want state changes triggered by
195-
/// a first responder state change to be explicitly animated.
195+
/// in a `withAnimation` closure - this is useful if your state change handler is performing
196+
/// some state change and you want that change to be animated.
197+
///
196198
public func animation() -> Self {
197199
.init(
198200
handleStateChange: { isFirstResponder in
@@ -202,12 +204,53 @@ public struct FirstResponderStateChangeHandler {
202204
canResignFirstResponder: canResignFirstResponder
203205
)
204206
}
207+
208+
/// Returns a new state change handler that scheduldes the callback to the original state change
209+
/// handler on the given scheduler.
210+
///
211+
/// - Parameters:
212+
/// - scheduler: The scheduler to schedule the callback on when the first responder state changes.
213+
///
214+
/// This modifier is useful when your first responder state change handler needs to perform some state
215+
/// mutation that will trigger a new view update and you are programatically triggering the first responder state
216+
/// change.
217+
///
218+
/// When a text field becomes first responder naturally, e.g. because the user tapped on the text field, it is
219+
/// safe to perform state changes that perform a view update inside this callback. However, programatic first
220+
/// responder state changes (where you change the demand state connected to the `firstResponderDemand`
221+
/// binding passed into `ResponsiveTextField`) happen as part of a view update - i.e. the demand change
222+
/// will trigger a view update and the `becomeFirstResponder()` call will happn in the `updateUIView`
223+
/// as part of that view change event.
224+
///
225+
/// This means that the change handler callback will be called as part of the view update and if that change handler
226+
/// does something to trigger a view update itself, you will receive a runtime warning about the nested view updates.
227+
///
228+
/// To break this loop, `ResponsiveTextField` could ensure that it always wraps its calls to the change handler
229+
/// on the next runloop tick, or in an async `DispatchQueue` call however this would be a pretty brute-force approach
230+
/// and would result in an unnecessary queue hop on every callback, even if it wasn't needed.
231+
///
232+
/// Instead, if you are programatically triggering a first responder change and the text field and also triggering a
233+
/// view update in your state change handler, you can explicitly force that callback to happen after the view update
234+
/// cycle completes using this method. You can pass in any suitable scheduler, such as `RunLoop.main` or
235+
/// `DispatchQueue.main`.
236+
///
237+
public func receive<S: Scheduler>(on scheduler: S, options: S.SchedulerOptions? = nil) -> Self {
238+
return .init { isFirstResponder in
239+
scheduler.schedule { self.handleStateChange(isFirstResponder) }
240+
}
241+
}
205242
}
206243

207244
extension FirstResponderStateChangeHandler {
208245
/// Returns a change handler that updates a `Bool` binding with the `isFirstResponder`
209246
/// value whenever it changes.
210247
///
248+
/// - Note: if you want this to trigger an animated change, instead of using the `.animation()`
249+
/// modifier on `FirstResponderStateChangeHandler`, you can simply pass in an animated
250+
/// binding instead:
251+
///
252+
/// onFirstResponderStateChanged: .updates($state.animation())
253+
///
211254
/// - Parameters:
212255
/// - binding: A binding to some Boolean state property that should be updated.
213256
///

0 commit comments

Comments
 (0)