Introduces backport for FocusState
This commit is contained in:
parent
5a294a710f
commit
c15b9fc224
|
@ -24,8 +24,16 @@ public extension Backport where Wrapped: View {
|
|||
///
|
||||
/// - Returns: A view that fires an action when the specified value changes.
|
||||
@ViewBuilder
|
||||
// func onChange<Value: Equatable>(of value: Value, perform action: @escaping (Value) -> Void) -> some View {
|
||||
// wrapped.modifier(ChangeModifier(value: value, action: action))
|
||||
// }
|
||||
// @ViewBuilder
|
||||
func onChange<Value: Equatable>(of value: Value, perform action: @escaping (Value) -> Void) -> some View {
|
||||
wrapped.modifier(ChangeModifier(value: value, action: action))
|
||||
if #available(iOS 14, tvOS 14, macOS 11, watchOS 7, *) {
|
||||
wrapped.onChange(of: value, perform: action)
|
||||
} else {
|
||||
wrapped.modifier(ChangeModifier(value: value, action: action))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
@available(iOS, deprecated: 15)
|
||||
public extension Backport where Wrapped == Any {
|
||||
/// A property wrapper type that can read and write a value that SwiftUI updates
|
||||
/// as the placement of focus within the scene changes.
|
||||
///
|
||||
/// Use this property wrapper in conjunction with ``View/backport.focused(_:equals:)``
|
||||
/// and ``View/backport.focused(_:)`` to
|
||||
/// describe views whose appearance and contents relate to the location of
|
||||
/// focus in the scene. When focus enters the modified view, the wrapped value
|
||||
/// of this property updates to match a given prototype value. Similarly, when
|
||||
/// focus leaves, the wrapped value of this property resets to `nil`
|
||||
/// or `false`. Setting the property's value programmatically has the reverse
|
||||
/// effect, causing focus to move to the view associated with the
|
||||
/// updated value.
|
||||
///
|
||||
/// In the following example of a simple login screen, when the user presses the
|
||||
/// Sign In button and one of the fields is still empty, focus moves to that
|
||||
/// field. Otherwise, the sign-in process proceeds.
|
||||
///
|
||||
/// struct LoginForm {
|
||||
/// enum Field: Hashable {
|
||||
/// case username
|
||||
/// case password
|
||||
/// }
|
||||
///
|
||||
/// @State private var username = ""
|
||||
/// @State private var password = ""
|
||||
/// @Backport.FocusState private var focusedField: Field?
|
||||
///
|
||||
/// var body: some View {
|
||||
/// Form {
|
||||
/// TextField("Username", text: $username)
|
||||
/// .backport.focused($focusedField, equals: .username)
|
||||
///
|
||||
/// SecureField("Password", text: $password)
|
||||
/// .backport.focused($focusedField, equals: .password)
|
||||
///
|
||||
/// Button("Sign In") {
|
||||
/// if username.isEmpty {
|
||||
/// focusedField = .username
|
||||
/// } else if password.isEmpty {
|
||||
/// focusedField = .password
|
||||
/// } else {
|
||||
/// handleLogin(username, password)
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// To allow for cases where focus is completely absent from a view tree, the
|
||||
/// wrapped value must be either an optional or a Boolean. Set the focus binding
|
||||
/// to `false` or `nil` as appropriate to remove focus from all bound fields.
|
||||
/// You can also use this to remove focus from a ``TextField`` and thereby
|
||||
/// dismiss the keyboard.
|
||||
///
|
||||
@propertyWrapper
|
||||
struct FocusState<Value>: DynamicProperty where Value: Hashable {
|
||||
@State private var value: Value
|
||||
|
||||
public var projectedValue: Binding<Value> {
|
||||
Binding(
|
||||
get: { wrappedValue },
|
||||
set: { wrappedValue = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
public var wrappedValue: Value {
|
||||
get { value }
|
||||
nonmutating set { value = newValue }
|
||||
}
|
||||
|
||||
public init() where Value == Bool {
|
||||
_value = .init(initialValue: false)
|
||||
}
|
||||
|
||||
public init<T>() where Value == T?, T: Hashable {
|
||||
_value = .init(initialValue: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
|
@ -0,0 +1,131 @@
|
|||
import SwiftUI
|
||||
import SwiftBackports
|
||||
|
||||
public extension Backport where Wrapped: View {
|
||||
func focused<Value>(_ binding: Binding<Value?>, equals value: Value) -> some View where Value: Hashable {
|
||||
wrapped.modifier(FocusModifier(focused: binding, value: value))
|
||||
}
|
||||
}
|
||||
|
||||
private struct FocusModifier<Value: Hashable>: ViewModifier {
|
||||
@Environment(\.backportSubmit) private var submit
|
||||
@Backport.StateObject private var coordinator = Coordinator()
|
||||
@Binding var focused: Value?
|
||||
var value: Value
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
// this ensures when the field goes out of view, it doesn't retain focus
|
||||
.onWillDisappear { focused = nil }
|
||||
.inspect { inspector in
|
||||
inspector.sibling(ofType: UITextField.self)
|
||||
} customize: { view in
|
||||
coordinator.observe(field: view)
|
||||
|
||||
coordinator.onBegin = {
|
||||
focused = value
|
||||
}
|
||||
|
||||
coordinator.onReturn = {
|
||||
submit()
|
||||
}
|
||||
|
||||
coordinator.onEnd = {
|
||||
guard focused == value else { return }
|
||||
focused = nil
|
||||
}
|
||||
|
||||
if focused == value, view.isUserInteractionEnabled, view.isEnabled {
|
||||
view.becomeFirstResponder()
|
||||
}
|
||||
}
|
||||
.backport.onChange(of: focused) { newValue in
|
||||
if newValue == nil {
|
||||
coordinator.field?.resignFirstResponder()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class Coordinator: NSObject, ObservableObject, UITextFieldDelegate {
|
||||
private(set) weak var field: UITextField?
|
||||
weak var _delegate: UITextFieldDelegate?
|
||||
|
||||
var onBegin: () -> Void = { }
|
||||
var onReturn: () -> Void = { }
|
||||
var onEnd: () -> Void = { }
|
||||
|
||||
override init() { }
|
||||
|
||||
func observe(field: UITextField) {
|
||||
self.field = field
|
||||
|
||||
if field.delegate !== self {
|
||||
_delegate = field.delegate
|
||||
field.delegate = self
|
||||
}
|
||||
}
|
||||
|
||||
func textFieldDidBeginEditing(_ textField: UITextField) {
|
||||
_delegate?.textFieldDidBeginEditing?(textField)
|
||||
onBegin()
|
||||
}
|
||||
|
||||
func textFieldDidEndEditing(_ textField: UITextField) {
|
||||
_delegate?.textFieldDidEndEditing?(textField)
|
||||
onEnd()
|
||||
}
|
||||
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
onReturn()
|
||||
// prevent auto-resign
|
||||
return false
|
||||
}
|
||||
|
||||
override func responds(to aSelector: Selector!) -> Bool {
|
||||
if super.responds(to: aSelector) { return true }
|
||||
if _delegate?.responds(to: aSelector) ?? false { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
override func forwardingTarget(for aSelector: Selector!) -> Any? {
|
||||
if super.responds(to: aSelector) { return self }
|
||||
return _delegate
|
||||
}
|
||||
}
|
||||
|
||||
private struct WillDisappearHandler: UIViewControllerRepresentable {
|
||||
|
||||
let onWillDisappear: () -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
ViewWillDisappearViewController(onWillDisappear: onWillDisappear)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||
|
||||
private class ViewWillDisappearViewController: UIViewController {
|
||||
let onWillDisappear: () -> Void
|
||||
|
||||
init(onWillDisappear: @escaping () -> Void) {
|
||||
self.onWillDisappear = onWillDisappear
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
onWillDisappear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func onWillDisappear(_ perform: @escaping () -> Void) -> some View {
|
||||
background(WillDisappearHandler(onWillDisappear: perform))
|
||||
}
|
||||
}
|
||||
|
|
@ -22,10 +22,10 @@ public extension Backport where Wrapped: View {
|
|||
.onSubmit(action)
|
||||
} else {
|
||||
wrapped
|
||||
.modifier(SubmitModifier())
|
||||
.environment(\.backportSubmit, .init(submit: action))
|
||||
}
|
||||
}
|
||||
.modifier(SubmitModifier())
|
||||
.environment(\.backportSubmit, .init(submit: action))
|
||||
}
|
||||
|
||||
/// A semantic label describing the label of submission within a view hierarchy.
|
||||
|
|
Loading…
Reference in New Issue