SwiftUI / iOS 13 - NotificationCenter with Multiple Scenes - ipad

With the introduction of iOS 13, a single app can have multiple scenes. In particular, on the iPad, you can run 2 scenes of an app side by side.
This creates a problem in my app with NotificationCenter. A NotificationCenter event is shared by across all scenes. While this could be useful syncing data across scenes, some events that are intended to be responded within the original scene are handled by all 2 scenes.
For example, in this custom UIHostingController class, I have a function to handle UIKeyCommands and post a Notification to the NotificationCenter.
class HostingController: UIHostingController<ContentView> {
override var keyCommands: [UIKeyCommand]? {
var result: [UIKeyCommand] = [
UIKeyCommand(
title: "New File",
action: #selector(handleNotification),
input: "N",
modifierFlags: [.command],
discoverabilityTitle: "New File"
)
]
}
#objc func handleNotification(sender: UIKeyCommand){
NotificationCenter.default.post(name: Notification.Name.handleKeyboardShortcut, object: sender.title)
}
}
This event is then handled by an UI element in ContentView.
var body: some View {
ZStack(){
...
}.onReceive(NotificationCenter.default.publisher(for: Notification.Name.handleKeyboardShortcut)) { notification in
if let shortcutKey = notification.object as? String {
switch shortcutKey {
case "New File":
self.openNewFile()
case "Show Panel":
self.openConsolePanel()
...
default:
break
}
}
}
Then the problem arises, when pressing Command + N while running 2 scenes of the app side-by-side, both scenes' openNewFile function is fired. Both instances pop up an file selection sheet. This is obviously an undesirable behavior.
I have a few ideas regarding the issue.
Check if the scene is last interacted, and only call the function openNewFile if so.
Handle UIKeyCommands outside HostingController, since it's shared by all scenes. In particular, I want to use addKeyCommand(_:) equivalent methods inside ContentView so that the UIKeyCommands can be dynamically changed.
I don't quite know how implement these though. What could be the best way to handle the problem?

Related

SwiftUI - testing - simulate tap gesture?

Is it somehow possible to simulate a tap when testing (ex. snapshot tests) a tap or any other gesture in SwiftUI?
For UIKit we can do something like:
button.sendActions(for: .touchUpInside)
Is there any SwiftUI equivalent?
While it's not directly possible to "Simulate" in the fashion you're attempting to simulate, it is perfectly possible to simulate the actions behind the buttons. This is assuming that you're using an MVVM architecture. The reason for this is that if you "Simulate" via the backing methods that support the buttons, via the view model, then you will still get the same result. In addition to this, SwiftUI will update and recalculate the views upon any state change, meaning it doesn't matter if the button changes a state or if a method changes the state. You can then extend that functionality to the init() function of the view struct, and viola, you'll be simulating actions.
View Model Example
class VMExample: ObservableObject {
#Published var shouldNavigate = false
func simulateNavigate() {
shouldNavigate.toggle
}
}
View Example
struct MyView: View {
#ObservedObject var vm = VMExample()
var body: some View {
NavigationLink(
"Navigate",
destination: Text("New View"),
isActive: $vm.shouldNavigate)
.onAppear {
//If Debug
vm.simulateNavigate()
}
}
}
Simulating multiple actions
To do it with multiple actions, you could potentially create some function func beginSimulation() that begins running through all the actions you want to test. You might change some text, navigate to a view, etc...
TL;DR
Simulate the actions behind the buttons, not the buttons interactions themselves. The result will be the same due to View Binding.

SwiftUi 3.0 - #EnvironmentObject causing NavigationLink to pop

I've encountered a very odd bug in one of my apps on iOS 15 / SwiftUi 3.0 - can't find any info on it.
I have a series of 3 screens that each link to one another with a tag/selection NavigationLink as below:
NavigationLink(destination: CityListView(city: city, selection: $citySelection, orgId: self.orgId), tag: city.id, selection: $citySelection) {
CityRow(city: city)
}
The $citySelection is a binding on each subsequent view to allow the app to programmatically pop the views back to the first when needed.
However, since iOS 15 there is a very odd behaviour when the app is brought foward from the background. Essentially all the views pop back to the first view. Even if I remove all the above bindings it still happens and it seems to be related to one of my #EnvironmentObject.
Each of these views has access to #EnvironmentObject NotificationHandler - notifcation handler is called from many places in the app to let the users know something is happening - background processing / api calls etc. Its very simple - code below:
class NotificationHandler: ObservableObject {
// Boot Notification Center
let nc = NotificationCenter.default
#Published var networkActive: Bool = false
init() {
print("NOTIFICATION HANDLER BOOTED")
nc.addObserver(self, selector: #selector(networkStart), name: Notification.Name("networkingStart"), object: nil)
nc.addObserver(self, selector: #selector(networkStop), name: Notification.Name("networkingStop"), object: nil)
}
#objc private func networkStart() {
self.networkActive = true
}
#objc private func networkStop() {
self.networkActive = false
}
}
Each of my three screens accesses the networkActive variable to decide if it needs to show a progress bar:
if self.notificationHandler.networkActive {
ProgressBar()
Spacer()
}
The problem is, when the app comes back from the background, if the notificationHandler is used at all, the app pops all the screens back to the first one.
If I remove the #EnvironmentObject form the first NavigationLink view this behaviour stops but I obviously can't use the progress bar without it. Accessing the #EnvironmentObject on any of the other views does not cause this behaviour, only the first.
Additionally, this behaviour doesn't happen on iOS 14.5
Any thoughts would be greatly appretiated.
Thanks

Dismiss keyboard in an iOS Notification Content Extension

I'm presenting custom action buttons in my iOS 11 notification window via a Notification Content Extension target. One of them is a 'comment' button. If I press it the keyboard shows up properly, but I am not able to figure out how to have the keyboard go away and get back to the other buttons on the notification. There's not really anything I can see to call resignFirstResponder on. Am I just missing something really obvious?
There is more than one way to do this.
Without A Content Extension
The first does not even require a notification content extension! The UNTextInputNotificationAction does all of the work for you. When initializing the action you specify parameters for the text field that will be presented when the action is triggered. That action is attached to your notification category during registration (i.e. inside willFinishLaunchingWithOptions):
userNotificationCenter.getNotificationCategories { (categories) in
var categories: Set<UNNotificationCategory> = categories
let inputAction: UNTextInputNotificationAction = UNTextInputNotificationAction(identifier: "org.quellish.textInput", title: "Comment", options: [], textInputButtonTitle: "Done", textInputPlaceholder: "This is awesome!")
let category: UNNotificationCategory = UNNotificationCategory(identifier: notificationCategory, actions: [inputAction], intentIdentifiers: [], hiddenPreviewsBodyPlaceholder: "Placeholder", options: [])
categories.insert(category)
userNotificationCenter.setNotificationCategories(categories)
}
This will produce an experience like this:
Note that by default, the "Done" button dismisses the keyboard and notification.
With more than one action you get this:
There is no going back to the action buttons that were presented with the notification - notifications can't do that. To see those actions choices again would require showing another notification.
With a Content Extension
First, the above section works with a content extension as well. When the user finishes entering text and hits the "textInputButton" the didReceive(_:completionHandler:) method of the content extension is called. This is an opportunity to use the input or dismiss the extension. The WWDC 2016 session Advanced Notifications describes this same use case and details ways it can be customized further.
This may not meet your needs. You may want to have a customized text entry user interface, etc. In that case it is up to your extension to handle showing and hiding the keyboard. The responder that handles text input - a UITextField, for example - should become first responder when the notification is received. Doing so will show the keyboard. Resigning first responder will hide it. This can be done inside a UITextField delegate method.
For example, this:
override var canBecomeFirstResponder: Bool {
get {
return true
}
}
func didReceive(_ notification: UNNotification) {
self.label?.text = notification.request.content.body
self.textField?.delegate = self
self.becomeFirstResponder()
self.textField?.becomeFirstResponder()
return
}
// UITextFieldDelegate
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
self.textField?.resignFirstResponder()
self.resignFirstResponder()
return true
}
Produces a result like this:
Keep in mind that on iOS 10 and 11 any taps on the notification itself - like on your text field - may result in it being dismissed! For this and many other reasons going this route is probably not desirable.

how to call a method in a view controller from Appdelegate in Swift?

this Main Menu VC will be opened when the app launched for the first time or after the user back to the app (the app become active after enter the background state).
every time this main menu VC is opened, ideally I need to update the time that the date time data comes from the server. in this main menu vc class I call getDateTimeFromServer() after that I updateUI().
but to update the data after the app enter the background and back to the foreground, the getDateTimeFromServer() and updateUI() shall be activated from Appdelegate using function.
func applicationWillEnterForeground(application: UIApplication) {
}
so how do I activate a method that are exist in Main Menu VC from AppDelegate
You don’t need to call the view controller method in app delegate. Observe foreground event in your controller and call your method from there itself.
Observe for the UIApplicationWillEnterForeground notification in your viewController viewDidLoad:
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.yourMethod), name: NSNotification.Name.UIApplicationWillEnterForeground, object: nil)
Implement this to receive callback when user enters foreground
#objc func yourMethod() {
// Call getDateTimeFromServer()
}
These types of messaging are in most cases done with static context. As it was already mentioned you could alternatively use notification center within the within the view controller to be notified of your application entering foreground. I discourage you creating custom notifications for this though (but is a possible solution as well).
Anyway for your specific case I suggest you have a model that contains your data. Then create a shared instance of it.
class MyDataModel {
static var shared: MyDataModel = {
let model = MyDataModel()
model.reloadData()
return model
}()
var myObjects: [MyObject]?
func reloadData() {
// load data asynchronously
}
}
Now when your view controller needs to reload it simply uses MyDataModel.shared.myObjects as data source.
In app delegate all you do is reload it when app comes back to foreground using MyDataModel.shared.reloadData().
So now a delegate is still missing so we add
protocol MyDataModelDelegate: class {
func myDataModel(_ sender: MyDataModel, updatedObjects objects: [MyObject]?)
}
class MyDataModel {
weak var delegate: MyDataModelDelegate?
static var shared: MyDataModel = {
Now when your view controller appears it needs to assign itself as a delegate MyDataModel.shared.delegate = self. And implement the protocol in which a reload on the view must be made.
A callout to the delegate can simply be done in a model setter:
}()
var myObjects: [MyObject]? {
didSet {
delegate.myDataModel(self, updatedObjects: myObjects)
}
}
func reloadData() {
You can do something like that, using a technique called Key-Value Observation:
class CommonObservableData: NSObject {
// Use #objc and dynamic to ensure enabling Key-Value Observation
#objc dynamic var dateTime: Date?
static let shared = CommonObservableData()
func updateFromWeb() {
// callWebThen is a function you will define that calls your Web API, then
// calls a completion handler you define, passing new value to your handler
callWeb(then: { self.dateTime = $0 })
}
}
Then you observe on it using Swift 4 's new NSKeyValueObservation.
class SomeViewController: UIViewController {
var kvo: NSKeyValueObservation?
func viewDidLoad() {
...
kvo = CommonObservableData.shared.observe(
\CommonObservableData.dateTime, { model, change in
self.label.text = "\(model.dateTime)"
})
}
}
Key-Value Observation is originally an Objective-C technique that is "somewhat revived" by Swift 4, this technique allows you to observe changes on a property (called a Key in Objective-C) of any object.
So, in the previous code snippets, we made a class, and made it a singleton, this singleton has an observable property called dateTime, where we could observe on change of this property, and make any change in this property automatically calls a method where we could update the UI.
Read about KVO here:
Key-Value Observation Apple Programming Guide
Key-Value Observation using Swift 4
Also, if you like Rx and RFP (Reactive Functional Programming), you can use RxSwift and do the observation in a cleaner way using it.
In swift 4 and 5, the notification name is changed the below code working for both.
notifyCenter.addObserver(self, selector: #selector(new), name:UIApplication.willEnterForegroundNotification, object: nil)
#objc func new(){}

How to subscribe to delegate events globally?

I have a custom delegate that triggers certain events. For context, it's a bluetooth device that fires events arbitrarily. I'd like my view controllers to optionally subscribe to these events that get triggered by the device delegate.
It doesn't make sense that each view controller conforms to the custom delegate because that means the device variable would be local and would only fire in that view controller. Other view controllers wouldn't be aware of the change. Another concrete example would be CLLocationManagerDelegate - for example what if I wanted all view controllers to listen to the GPS coordinate changes?
Instead, I was thinking more of a global delegate that all view controllers can subscribe to. So if one view controller triggers a request, the device would call the delegate function for all subscribed view controllers that are listening.
How can I achieve this architectural design? Are delegates not the right approach? I thought maybe NotificationCenter can help here, but seems too loosely typed, perhaps throwing protocols would help makes things more manageable/elegant? Any help would be greatly appreciated!
You could have an array of subscribers that would get notified.
class CustomNotifier {
private var targets : [AnyObject] = [AnyObject]()
private var actions : [Selector] = [Selector]()
func addGlobalEventTarget(target: AnyObject, action: Selector) {
targets.append(target)
actions.append(action)
}
private func notifyEveryone () {
for index in 0 ..< targets.count {
if targets[index].respondsToSelector(actions[index]) {
targets[index].performSelector(actions[index])
}
}
}
}
Naturally, you'd have to plan further to maintain the lifecycle of targets and actions, and provide a way to unsubscribe etc.
Note: Also ideal would be for the array of targets and actions to be an of weak objects. This SO question, for instance, deals with the subject.
• NotificationCenter is first solution that comes in mind. Yes, it is loosely typed. But you can improve it. For example like this:
extension NSNotificationCenter {
private let myCustomNotification = "MyCustomNotification"
func postMyCustomNotification() {
postNotification(myCustomNotification)
}
func addMyCustomNotificationObserverUsingBlock(block: () -> ()) -> NSObjectProtocol {
return addObserverForName(myCustomNotification, object: nil, queue: nil) { _ in
block()
}
}
}
• Second solution would be to create some shared object, which will store all delegates or blocks/closures and will trigger them when needed. Such object basically will be the same as using NotificationCenter, but gives you more control.

Resources