diff --git a/Package.resolved b/Package.resolved index 4c428c0..5ca5a7a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -2,12 +2,12 @@ "object": { "pins": [ { - "package": "Introspect", + "package": "swiftui-introspect", "repositoryURL": "https://github.com/siteline/SwiftUI-Introspect.git", "state": { "branch": null, - "revision": "2e09be8af614401bc9f87d40093ec19ce56ccaf2", - "version": "0.1.3" + "revision": "7dc5b287f8040e4ad5038739850b758e78f77808", + "version": "1.1.4" } } ] diff --git a/Package.swift b/Package.swift index 73ffacb..1083824 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3 +// swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "Focuser", platforms: [ - .iOS(.v13), + .iOS(.v14), ], products: [ .library( @@ -14,14 +14,16 @@ let package = Package( targets: ["Focuser"]), ], dependencies: [ - .package(name: "Introspect", url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.3") + .package(url: "https://github.com/siteline/SwiftUI-Introspect", exact: "1.1.4") ], targets: [ .target( name: "Focuser", - dependencies: ["Introspect"]), + dependencies: [.product(name: "SwiftUIIntrospect", package: "SwiftUI-Introspect")] + ), .testTarget( name: "FocuserTests", - dependencies: ["Focuser"]), + dependencies: ["Focuser"] + ), ] ) diff --git a/Sources/Focuser/ComplianceProtocol.swift b/Sources/Focuser/ComplianceProtocol.swift index 08cac57..deaace0 100644 --- a/Sources/Focuser/ComplianceProtocol.swift +++ b/Sources/Focuser/ComplianceProtocol.swift @@ -11,3 +11,18 @@ public protocol FocusStateCompliant: Hashable { static var last: Self { get } var next: Self? { get } } + +public extension FocusStateCompliant where Self: CaseIterable, AllCases: BidirectionalCollection { + + static var last: Self { + return Self.allCases.last! //swiftlint:disable:this force_unwrapping + } + + var next: Self? { + let all = Self.allCases + let index = all.firstIndex(of: self)! //swiftlint:disable:this force_unwrapping + let next = all.index(after: index) + return next == all.endIndex ? nil : all[next] + } + +} diff --git a/Sources/Focuser/LifeCycleEventHandler.swift b/Sources/Focuser/LifeCycleEventHandler.swift new file mode 100644 index 0000000..4f97bb2 --- /dev/null +++ b/Sources/Focuser/LifeCycleEventHandler.swift @@ -0,0 +1,147 @@ +// +// LifeCycleEventHandler.swift +// +// +// Created by Tarek Sabry on 03/02/2023. +// + +import UIKit +import SwiftUI + +public typealias LifeCycleEventHandler = ((LifeCycleEvent) -> Void) + +public enum LifeCycleEvent { + case viewWillAppear + case viewDidAppear + case viewWillDisappear + case viewDidDisappear +} + +struct ViewControllerLifeCycleHandler: UIViewControllerRepresentable { + + private let onLifeCycleEvent: LifeCycleEventHandler + + init(onLifeCycleEvent: @escaping LifeCycleEventHandler) { + self.onLifeCycleEvent = onLifeCycleEvent + } + + func makeUIViewController(context: Context) -> UIViewController { + LifeCycleViewController(onLifeCycleEvent: onLifeCycleEvent) + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} + + private class LifeCycleViewController: UIViewController { + private let onLifeCycleEvent: ((LifeCycleEvent) -> Void) + + init(onLifeCycleEvent: @escaping LifeCycleEventHandler) { + self.onLifeCycleEvent = onLifeCycleEvent + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + onLifeCycleEvent(.viewWillAppear) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + onLifeCycleEvent(.viewDidAppear) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + onLifeCycleEvent(.viewWillDisappear) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + onLifeCycleEvent(.viewDidDisappear) + } + } +} + +struct ViewDidLoadModifier: ViewModifier { + + @State private var didLoad = false + private let action: (() -> Void)? + + init(perform action: (() -> Void)? = nil) { + self.action = action + } + + func body(content: Content) -> some View { + content.onAppear { + if didLoad == false { + didLoad = true + action?() + } + } + } + +} + + +public extension View { + + func onLifeCycleEvent(perform: @escaping LifeCycleEventHandler) -> some View { + background(ViewControllerLifeCycleHandler(onLifeCycleEvent: perform)) + } + + func onDidLoad(perform: @escaping (() -> Void)) -> some View { + modifier(ViewDidLoadModifier(perform: perform)) + } + + func onWillAppear(perform: @escaping (() -> Void)) -> some View { + onLifeCycleEvent { event in + switch event { + case .viewWillAppear: + perform() + + default: + break + } + } + } + + func onDidAppear(perform: @escaping (() -> Void)) -> some View { + onLifeCycleEvent { event in + switch event { + case .viewDidAppear: + perform() + + default: + break + } + } + } + + func onWillDisappear(perform: @escaping (() -> Void)) -> some View { + onLifeCycleEvent { event in + switch event { + case .viewWillDisappear: + perform() + + default: + break + } + } + } + + func onDidDisappear(perform: @escaping (() -> Void)) -> some View { + onLifeCycleEvent { event in + switch event { + case .viewDidDisappear: + perform() + + default: + break + } + } + } + +} diff --git a/Sources/Focuser/TextEditorIntrospect.swift b/Sources/Focuser/TextEditorIntrospect.swift index 3b9402f..ec1a358 100644 --- a/Sources/Focuser/TextEditorIntrospect.swift +++ b/Sources/Focuser/TextEditorIntrospect.swift @@ -6,21 +6,128 @@ // import SwiftUI +import SwiftUIIntrospect + +class TextViewObserver: NSObject, UITextViewDelegate, ObservableObject { + var onDidBeginEditing: () -> () = { } + weak var forwardToDelegate: UITextViewDelegate? + weak var ownerTextView: UITextView? + + @available(iOS 2.0, *) + func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { + forwardToDelegate?.textViewShouldBeginEditing?(textView) ?? true + } + + @available(iOS 2.0, *) + func textViewShouldEndEditing(_ textView: UITextView) -> Bool { + forwardToDelegate?.textViewShouldEndEditing?(textView) ?? true + } + + + @available(iOS 2.0, *) + func textViewDidBeginEditing(_ textView: UITextView) { + onDidBeginEditing() + forwardToDelegate?.textViewDidBeginEditing?(textView) + } + + @available(iOS 2.0, *) + func textViewDidEndEditing(_ textView: UITextView) { + forwardToDelegate?.textViewDidEndEditing?(textView) + } + + + @available(iOS 2.0, *) + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + forwardToDelegate?.textView?(textView, shouldChangeTextIn: range, replacementText: text) ?? true + } + + @available(iOS 2.0, *) + func textViewDidChange(_ textView: UITextView) { + forwardToDelegate?.textViewDidChange?(textView) + } + + + @available(iOS 2.0, *) + func textViewDidChangeSelection(_ textView: UITextView) { + forwardToDelegate?.textViewDidChangeSelection?(textView) + } + + @available(iOS 10.0, *) + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + forwardToDelegate?.textView?(textView, shouldInteractWith: URL, in: characterRange, interaction: interaction) ?? true + } + + @available(iOS 10.0, *) + func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + forwardToDelegate?.textView?(textView, shouldInteractWith: textAttachment, in: characterRange, interaction: interaction) ?? true + } + + + @available(iOS, introduced: 7.0, deprecated: 10.0) + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange) -> Bool { + forwardToDelegate?.textView?(textView, shouldInteractWith: URL, in: characterRange) ?? true + } + + @available(iOS, introduced: 7.0, deprecated: 10.0) + func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange) -> Bool { + forwardToDelegate?.textView?(textView, shouldInteractWith: textAttachment, in: characterRange) ?? true + } + + @available(iOS 16.0, *) + func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? { + forwardToDelegate?.textView?(textView, editMenuForTextIn: range, suggestedActions: suggestedActions) + } + + @available(iOS 16.0, *) + func textView(_ textView: UITextView, willPresentEditMenuWith animator: UIEditMenuInteractionAnimating) { + forwardToDelegate?.textView?(textView, willPresentEditMenuWith: animator) + } + + @available(iOS 16.0, *) + func textView(_ textView: UITextView, willDismissEditMenuWith animator: UIEditMenuInteractionAnimating) { + forwardToDelegate?.textView?(textView, willDismissEditMenuWith: animator) + } +} public struct FocusModifierTextEditor: ViewModifier { @Binding var focusedField: Value? var equals: Value - @State var observer = TextFieldObserver() + @StateObject var observer = TextViewObserver() public func body(content: Content) -> some View { content - .introspectTextView { tv in + .introspect(.textEditor, on: .iOS(.v14, .v15, .v16, .v17)) { textView in + if !(textView.delegate is TextViewObserver) { + observer.forwardToDelegate = textView.delegate + observer.ownerTextView = textView + textView.delegate = observer + } + + observer.onDidBeginEditing = { + DispatchQueue.main.async { + focusedField = equals + } + } + if focusedField == equals { - tv.becomeFirstResponder() + DispatchQueue.main.async { + if textView.isUserInteractionEnabled { + textView.becomeFirstResponder() + } else { + focusedField = focusedField?.next + } + } + } + } + .onChange(of: focusedField) { focusedField in + if focusedField == nil { + observer.ownerTextView?.resignFirstResponder() + } + } + .onWillDisappear { + if focusedField != nil { + focusedField = nil } } - .simultaneousGesture(TapGesture().onEnded { - focusedField = equals - }) } } diff --git a/Sources/Focuser/TextField+Extensions.swift b/Sources/Focuser/TextField+Extensions.swift index 763d957..3699226 100644 --- a/Sources/Focuser/TextField+Extensions.swift +++ b/Sources/Focuser/TextField+Extensions.swift @@ -14,7 +14,6 @@ public extension View { } } -@available(iOS 14.0, *) public extension View { func focusEditor(_ focusedField: Binding, equals: T) -> some View { modifier(FocusModifierTextEditor(focusedField: focusedField, equals: equals)) diff --git a/Sources/Focuser/TextFieldIntrospect.swift b/Sources/Focuser/TextFieldIntrospect.swift index daf78ec..da981a2 100644 --- a/Sources/Focuser/TextFieldIntrospect.swift +++ b/Sources/Focuser/TextFieldIntrospect.swift @@ -6,10 +6,12 @@ // import SwiftUI -import Introspect +import SwiftUIIntrospect -class TextFieldObserver: NSObject, UITextFieldDelegate { - var onReturnTap: () -> () = {} +class TextFieldObserver: NSObject, UITextFieldDelegate, ObservableObject { + var onReturnTap: () -> () = { } + var onDidBeginEditing: () -> () = { } + weak var ownerTextField: UITextField? weak var forwardToDelegate: UITextFieldDelegate? @available(iOS 2.0, *) @@ -19,6 +21,7 @@ class TextFieldObserver: NSObject, UITextFieldDelegate { @available(iOS 2.0, *) func textFieldDidBeginEditing(_ textField: UITextField) { + onDidBeginEditing() forwardToDelegate?.textFieldDidBeginEditing?(textField) } @@ -57,39 +60,78 @@ class TextFieldObserver: NSObject, UITextFieldDelegate { onReturnTap() return forwardToDelegate?.textFieldShouldReturn?(textField) ?? true } + + @available(iOS 16.0, *) + func textField(_ textField: UITextField, editMenuForCharactersIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? { + forwardToDelegate?.textField?(textField, editMenuForCharactersIn: range, suggestedActions: suggestedActions) + } + + @available(iOS 16.0, *) + func textField(_ textField: UITextField, willPresentEditMenuWith animator: UIEditMenuInteractionAnimating) { + forwardToDelegate?.textField?(textField, willPresentEditMenuWith: animator) + } + + @available(iOS 16.0, *) + func textField(_ textField: UITextField, willDismissEditMenuWith animator: UIEditMenuInteractionAnimating) { + forwardToDelegate?.textField?(textField, willDismissEditMenuWith: animator) + } } public struct FocusModifier: ViewModifier { @Binding var focusedField: Value? var equals: Value - @State var observer = TextFieldObserver() + @StateObject var observer = TextFieldObserver() public func body(content: Content) -> some View { content - .introspectTextField { tf in - if !(tf.delegate is TextFieldObserver) { - observer.forwardToDelegate = tf.delegate - tf.delegate = observer + .introspect(.textField, on: .iOS(.v14, .v15, .v16, .v17)) { textField in + if !(textField.delegate is TextFieldObserver) { + observer.forwardToDelegate = textField.delegate + observer.ownerTextField = textField + textField.delegate = observer + } + + observer.onDidBeginEditing = { + DispatchQueue.main.async { + focusedField = equals + } } - /// when user taps return we navigate to next responder observer.onReturnTap = { - focusedField = focusedField?.next ?? Value.last + DispatchQueue.main.async { + focusedField = focusedField?.next + + if focusedField == nil { + textField.resignFirstResponder() + } + } } - - /// to show kayboard with `next` or `return` + + if focusedField == equals { + DispatchQueue.main.async { + if textField.isEnabled { + textField.becomeFirstResponder() + } else { + focusedField = focusedField?.next + } + } + } + if equals.hashValue == Value.last.hashValue { - tf.returnKeyType = .done + textField.returnKeyType = .done } else { - tf.returnKeyType = .next + textField.returnKeyType = .next } - - if focusedField == equals { - tf.becomeFirstResponder() + } + .onChange(of: focusedField) { focusedField in + if focusedField == nil { + observer.ownerTextField?.resignFirstResponder() + } + } + .onWillDisappear { + if focusedField != nil { + focusedField = nil } } - .simultaneousGesture(TapGesture().onEnded { - focusedField = equals - }) } }