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.
Related
I'm struggling with how to detect that a certain view is being displayed on the screen.
I have a notifications list in which new notification cells have blue backgrounds, and 2 seconds after you see the notification, the background changes to white, meaning you've seen that new notification, just like in Twitter's new notification screen.
First I thought about using the onAppear modifier to detect whether you've seen the new notification cell, but it gets triggered before the particular cell is being displayed on the screen.
So I thought up a workaround. When the notifications bottom tab is selected, i.e. when the user comes to the notifications view from another view, then 2 seconds later change the background of the new notification cell.
struct NotificationCell: View {
#Binding var tabSelection: TabBarItem
#Binding var notificationsDetail: NotificationsDetail
var body: some View {
HStack {
//Views inside
}
.background(notificationsDetail.notificationsIsAlreadyRead ? .white : .blue)
}
}
However, I don't know where to write the following code because init() or onAppear gets triggered only once at the beginning. I need to make sure I run the code when the tabSelection property changes.
if tabSelection == .notifications {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
notificationsDetail.notificationsIsAlreadyRead = true
}
}
I've also studied onChange modifier but since I'm not using Texts or TexFields I thought onChange might not fit as a solution here.
Maybe my workaround is not a good approach in the first place to achieving what I want to do.
Any advice would be highly appreciated.
Thanks in advance.
onChange is what would work here.
.onChange(of: tabSelection) { selection in
if selection == .notifications {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
notificationsDetail.notificationsIsAlreadyRead = true
}
}
}
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 trying to have a system-wide progress bar in my SwiftUI application, so I defined this view (be aware: I'm targeting iOS 13+):
import SwiftUI
struct LoadingView<Content>: View where Content: View {
#Binding var isShowing: Bool
var content: () -> Content
var body: some View {
GeometryReader { _ in
ZStack {
self.content()
if self.isShowing {
VStack {
ActivityIndicator()
Text("Loading...")
}
}
}
}
}
}
struct ActivityIndicator: UIViewRepresentable {
typealias UIView = UIActivityIndicatorView
fileprivate var configuration = { (_: UIView) in }
func makeUIView(context: UIViewRepresentableContext<Self>) -> UIView { UIView() }
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<Self>) {
uiView.startAnimating()
configuration(uiView)
}
}
and is used in ContenView.swift like this
import SwiftUI
struct ContentView: View {
#EnvironmentObject var myViewModel: MyViewModel
var body: some View {
let isLoading = Binding<Bool>(
get: { self.myViewModel.isLoading },
set: { _ in }
)
LoadingView(isShowing: isLoading) {
NavigationView {
Home()
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
where MyViewModel is a pretty standard ViewModel with a #Published var for isLoading, and Home() is the main entry point for the app.
Whenever some action that trigger isLoading in MyViewModel is done, the progress bar is shown (and hidden) correctly.
But, if I present a .sheet either by one of the view inside the NavigationView, or using the ContentView itself, no matter what it hides the progress bar.
Even if I use the builtin 14+ ProgressView the problem persists.
.Zindex() does not help either.
Any way to have that view always on top when showed, no matter what .sheet, .alert or any overlay view is available on SwiftUI is present on the screen?
Thanks in advance!
As already written in the comments, a modal view will be shown on top of any other view. A modal view is meant to establish a Computer-Human communication, or dialog (thus modal views frequently will be named "Dialog").
The observation, that a sheet (modal view) covers the loading indicator is expected behaviour.
But, IMO the issue described in the question and refined in the comments, can be solved nicely without breaking the behaviour of the modal views:
When you want to show data, that is not yet complete or even completely absent, you may show a "blank" screen, and in additions to this, let the view model generate a view state that says, that the view should show an "Input Sheet".
So initially, the user sees an input form over a blank screen.
Once the user made the input and submits the form (which will be handled in the View Model) the input sheet disappears (controlled by the View State generated by the View Model), and reveals the "blank" view underneath it.
So, the View Model could now present another sheet, or it realises that the input is complete.
Once the input is complete, the view model loads data and since this may take a while, it reflects this in the View State accordingly, for example using a "loading" state or flag. The view renders this accordingly, which is a loading indicator above the "blank" view.
When the view model receives data, it clears the loading state and sets the view state accordingly, passing through the data as well.
The view now renders the data view.
If the loading task failed, the view model composes a view state where the content is "absent" and with an error info.
Again the view renders this, possibly showing an alert with the message above a "blank" view, since there is still no data.
Ensure, the user can dismiss the error alert and the view model handles it by removing the "modal error" state, but the content is still "absent".
Now, the user is starring at a blank view. You may embed an error message here, or even add a "Retry" button. In any case, ensure the user can navigate away from that screen.
And so on. ;)
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
I am getting frequent updates of the tableview datasource and need to synchronize tableview updates with datasource updates properly. Can you suggest the right approach to this in Swift 3?
ListViewModel
/// Main list viewmodel refreshing and filtering API
private func refreshCards(withIcon icon: CardIcon) {
queue.async {
Log.debug("refresh cards Started")
switch icon {
case .all:
self.loadOpenCards()
self.loadCompletedCards()
default:
self.filterOpenCards(byIcon: icon)
self.filterCompletedCards(byIcon: icon)
}
// Ask list TVC to reload table
self.listTVC?.refreshForUpdates()
Log.debug("refresh cards Finished")
}
}
ListTableViewController
func refreshForUpdates() {
DispatchQueue.main.async {
self.updateApprovalListBackgroundGraphics()
self.tableView.reloadData()
Log.debug("refresh cards Reloaded")
}
}
In this code tableView.reloadData() does not wait for the viewmodel refresh because it is dispatched async on the main thread.
The general approach I take in cases like this is to ensure the view controller has a static copy of the data so nothing is being changed in the background.
You have a master instance of your data model. This data model processes asynchronous updates. What it needs to do is to notify its listeners (such as a view controller) that it has been updated. The listener should respond to this update by saving off a copy of the data model and then refreshing its views (such as reloading a table view).
If the master data model happens to post another update while the view controller is updating itself, it's not a problem since the view controller won't handle that next update until it finishes updating from the previous update and the view controller's copy of the data has not yet changed with the new update.