22// ResponsiveTextField.swift
33//
44
5+ import Combine
56import UIKit
67import 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
207244extension 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