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
Related
I'm new to SwiftUI and trying to start using it in a complex, existing UIKit app. The app has a theming system, and I'm not sure how to get the SwiftUI view to respond to theme change events.
Our theme objects look like
class ThemeService {
static var textColor: UIColor { get }
}
struct ViewTheme: {
private(set) var textColor = { ThemeService.textColor }
}
where the value returned ThemeService.textColor changes when the user changes the app's theme. In the UIKit portions of the app, views observe a "themeChanged" Notification and re-read the value of the textColor property from their theme structs.
I'm not sure how to manage this for SwiftUI. Since ViewTheme isn't an object, I can't use #ObservableObject, but its textColor property also doesn't change when the theme changes; just the value returned by calling it changes.
Is there a way to somehow get SwiftUI to re-render the view hierarchy from an external event, rather than from a change in a value that the view sees? Or should I be approaching this differently?
Your answer works perfectly well, but it requires adoption of ObservableObject. Here is an alternative answer which uses your existing NotificationCenter notifications to update a SwiftUI view.
struct MyView: View {
#State private var textColor: UIColor
var body: some View {
Text("Hello")
.foregroundColor(Color(textColor))
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("themeChanged"))) { _ in
textColor = ThemeService.textColor
}
}
}
This requires a #State variable to hold the theme's current data, but it's correct because a SwiftUI view is really just a snapshot of what the view should currently display. It ideally should not reference data that is arbitrarily changed because it leads to data-sync problems like the question you asked. So in a SwiftUI view, it is problematic to write ThemeService.textColor directly within the body unless you are certain an update will always occur after it the theme gets changed.
I was able to get this working by cheating with the theming system a little and changing the ViewTheme to an object:
class ViewTheme: ObservableObject {
private(set) var textColor = { ThemeService.textColor }
init() {
NotificationCenter.default.addObserver(forName: Notification.Name("themeChanged"),
object: nil, queue: .main) { [weak self] _ in
self?.objectWillChange.send()
}
}
}
Now the view can mark it with #ObservedObject and will be re-generated when the "themeChanged" notification is fired.
This seems to be working great, but I'm not sure if there are non-obvious problems that I'm missing with this solution.
This Problem drives me nuts: I have a small SwiftUi iOS App which works with Core data. All works fine, I can add edit etc. But the app is tab bar based ad I want one tabs badge updated when a record is added to core data.
So I thought I listen to the viewontext.hasChanged bool:
struct MainView: View {
#Environment(\.managedObjectContext) private var viewContext
#State var nrOfItems = 0
let persistanceController : PersistenceController
var body: some View {
TabView{
ItemListView(persistance: PersistenceController.shared)
.tabItem{
Label("Lebensmittel",systemImage: "cart")
}
.badge(nrOfItems)
}
.onChange(of: viewContext.hasChanges, perform: {newValue in
nrOfItems = persistanceController.getRecordsCount()
})
}
}
But the code in onChange is never called ... and I can't find anything about it neither here nor somewhere else. Anyone any hints?
Thanks, Andreas
The hasChanges is not published property, so it does not trigger onChange, try instead to use notification directly, like
.onReceive(NotificationCenter.default.publisher(for: NSManagedObjectContext.didChangeObjectsNotification,
object: viewContext)) { _ in
nrOfItems = persistanceController.getRecordsCount()
}
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.
I'm writing a text messaging app similar to Apple's built-in messaging app.
The first view like Apple's shows the latest text received or sent from or to each recipient and like Messaging, tapping on that row takes you into the chat view of that recipient.
On each view I declare the following to refresh the data when a background APN notifications is received:
let pub = NotificationCenter.default.publisher(for: .AYSTextMessagesUpdate)
and then:
.onReceive(pub) { (output) in
self.msgsVM.fetchHeaderTexts()
}
to updated the data. I have the same code in the chat view to update the texts of that particular thread.
The problem is when the user taps on the chat view and then later a notification comes the UI of the parent view is invalidated with kicks the user out of the chat view and back to the main view.
I'm using a NavigationLink embedded in a list in the first view to navigate to the chat view. When the notification comes in the corresponding data refresh invalidates the parent view causing the UI to of the parent view (which isn't currently the top view) to invalidate and redraw the user is kicked back to the parent view.
I either need to decouple the two views so one's update doesn't invalidate the other or somehow prevent the data refresh on the parent view to be postponed until that view is being displayed.
Any help would be greatly appreciated.
I'm not sure if this is a very elegant fix but this seems to work:
In the first view I define these two state variables:
#State private var needsRefreshed = false
#State private var listDisplayed = true
Then after the list I added this:
.disabled(self.showNewMessage)
.onAppear() {
self.listDisplayed = true
if self.needsRefreshed {
self.msgsVM.fetchHeaderTexts()
self.needsRefreshed = false
}
}
.onDisappear() {
self.listDisplayed = false
}
then I change the .onReceive code as follows:
.onReceive(pub) { (output) in
if self.listDisplayed {
self.msgsVM.fetchHeaderTexts()
} else {
self.needsRefreshed = true
}
}
Seems to work.
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?