Introduces backport for FocusState

This commit is contained in:
Shaps Benkau 2023-05-08 14:32:50 +01:00
parent 5a294a710f
commit c15b9fc224
4 changed files with 227 additions and 3 deletions

View File

@ -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))
}
}
}

View File

@ -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

View File

@ -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))
}
}

View File

@ -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.