So I'm retrieving data from FireStore. I'm retrieving the data successfully. When I tap my search button the first time the data is being downloaded and the new view is pushed. As a result, I get a blank view. But when I go back, hit search again, sure enough I can see my data being presented.
How can I make sure I first have the data I'm searching for THEN navigate to the new view? I've used #State variables etc. But nothing seems to be working. I am using the MVVM approach.
My ViewModel:
class SearchPostsViewModel: ObservableObject {
var post: [PostModel] = []
#State var searchCompleted: Bool = false
func searchPosts(completed: #escaping() -> Void, onError: #escaping(_ errorMessage: String) -> Void) {
isLoading = true
API.Post.searchHousesForSale(propertyStatus: propertyStatus, propertyType: propertyType, location: location, noOfBathrooms: noOfBathroomsValue, noOfBedrooms: noOfBedroomsValue, price: Int(price!)) { (post) in
self.post = post
print(self.post.count)
self.isLoading = false
self.searchCompleted.toggle()
}
}
}
The code that does work, but with the bug:
NavigationLink(destination: FilterSearchResults(searchViewModel: self.searchPostsViewModel)
.onAppear(perform: {
DispatchQueue.main.async {
self.createUserRequest()
}
})
)
{
Text("Search").modifier(UploadButtonModifier())
}
Try with the following modified view model
class SearchPostsViewModel: ObservableObject {
#Published var post: [PostModel] = [] // << make both published
#Published var searchCompleted: Bool = false
func searchPosts(completed: #escaping() -> Void, onError: #escaping(_ errorMessage: String) -> Void) {
isLoading = true
API.Post.searchHousesForSale(propertyStatus: propertyStatus, propertyType: propertyType, location: location, noOfBathrooms: noOfBathroomsValue, noOfBedrooms: noOfBedroomsValue, price: Int(price!)) { (post) in
DispatchQueue.main.async {
self.post = post // << update on main queue
print(self.post.count)
self.isLoading = false
self.searchCompleted.toggle()
}
}
}
}
You should look at the Apple documentation for #State and ObservableObject
https://developer.apple.com/documentation/combine/observableobject
https://developer.apple.com/documentation/swiftui/state
Your issue is with using an #State in a non-UI class/View.
It might help if you start with the Apple SwiftUI tutorials. So you understand the differences in with the wrappers and learn how it all connects.
https://developer.apple.com/tutorials/swiftui
Also, when you post questions make sure your code can be copied and pasted onto Xcode as-is so people can test it. You will get better feedback if other developers can see what is actually happening. As you progress it won't be as easy to see issues.
Related
The setup:
My app has a View ToolBar (all shortened):
struct ToolBar: View {
#State private var ownerShare: CKShare?
#State private var show_ownerModifyShare = false
// …
The toolbar has some buttons that are created by functions. One of them is
func sharingButton(imageName: String, enabled: Bool) -> SharingButton {
return SharingButton(systemImageName: imageName, enabled: enabled) {
Task {
do {
(_, participantShare, ownerShare) = try await dataSource.getSharingInfo()
// …
if ownerShare != nil { show_ownerModifyShare = true }
} catch (let error) {
//...
}
}
}
}
This is the body:
var body: some View {
HStack {
// …
sharingButton(imageName: "square.and.arrow.up", enabled: currentMode == .displayingItems)
.fullScreenCover(isPresented: $show_ownerModifyShare) {
// A CKShare record exists in the iCloud private database. Present a controller that allows to modify it.
CloudSharingView(container: CKContainer(identifier: kICloudContainerID), shareRecord: ownerShare!, dataSource: dataSource)
}
}
}
}
The problem:
When the sharingButton is tapped, ownerShare is set in Task {…}, since the iCloud database is shared as an owner.
Accordingly, show_ownerModifyShare = true is executed, and thus the body of struct ToolBar is newly rendered.
However, CloudSharingView(container: CKContainer(identifier: kICloudContainerID), shareRecord: ownerShare!, dataSource: dataSource) crashes, because ownerShare is nil, although it is only set true after ownerShare has been set != nil.
My question:
What could be the reason, and how to correct the code?
EDIT: (due to the comment of jnpdx):
I replaced .fullScreenCover(isPresented: by
.fullScreenCover(item: $ownerShare) { _ in
CloudSharingView(container: CKContainer(identifier: kICloudContainerID), shareRecord: ownerShare!, dataSource: dataSource)
}
but the code still crashes with Thread 1: Fatal error: Unexpectedly found nil while unwrapping an Optional value when ownerShare! is used.
You should use the item form of fullScreenCover, which allows you to send a dynamically-changed parameter to the inner closure:
.fullScreenCover(item: $ownerShare) { share in
CloudSharingView(container: CKContainer(identifier: kICloudContainerID), shareRecord: share, dataSource: dataSource)
}
This is a common issue with sheet and the related functions in SwiftUI, which calculate their closures when first rendered and not at presentation time. See related:
SwiftUI: Switch .sheet on enum, does not work
I’m building a macOS app based on data from CloudKit. I’m running into an issue where one of the Strings in my UI is not being updated as I would expect it to.
Model
I have an Event struct, which has an ID and a timestamp:
struct Event: Identifiable, Hashable {
let id: UUID
let timestamp: Date
init(id: UUID = UUID(),
timestamp: Date) {
self.id = id
self.timestamp = timestamp
}
}
Periodically, my app fetches updated Event data from CloudKit. This is handled in an ObservedObject called DataStore.
For example, the fetch happens when the DataStore is initialized, and when a push notification comes from the server to indicate there is new information.
The function updateLocalEvents() in DataStore is called to actually update the local in-memory #Published Array, which calls fetchEvents() to actually get the current set of last 10 events data from CloudKit.
class DataStore: ObservableObject {
#Published var recentEvents: [Event] = []
init() {
updateLocalEvents()
}
func updateLocalEvents() {
print("updateLocalEvents()")
fetchEvents()
.receive(on: RunLoop.main)
.assign(to: &$recentEvents)
}
private func fetchEvents() -> AnyPublisher<[Event],Never> {
let predicate = NSPredicate(value: true)
let query = CKQuery(recordType: "Event",
predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: "timestamp",
ascending: false)]
let operation = CKQueryOperation(query: query)
operation.resultsLimit = 10
operation.qualityOfService = .userInitiated
var events = [Event]()
operation.recordFetchedBlock = { record in
if let id = UUID(uuidString: record.recordID.recordName),
let timestamp = record.object(forKey: "timestamp") as? Date
{
events.append(Event(id: id,
timestamp: timestamp))
}
}
return Future<[Event],Never> { promise in
operation.completionBlock = {
promise(.success(events))
}
CKConstants.container
.privateCloudDatabase
.add(operation)
}
.eraseToAnyPublisher()
}
}
View
In my view, I show a String to indicate the time since the last event. For example, it may say 1 hour ago or 3 hours ago using a RelativeDateTimeFormatter.
This is stored in a timeAgo #State variable of type String?.
There is a Timer that attempts to update the timeAgo String? every minute, using an .onReceive modifier for the Timer, and another .onReceive modifier that uses the #Published Array of Events to update the timeAgo String?. Here is my view code:
struct EventsView: View {
#EnvironmentObject var store: DataStore
#State private var timer: Publishers.Autoconnect<Timer.TimerPublisher> = Timer
.publish(every: 60,
on: .main,
in: .common)
.autoconnect()
#State private var timeAgo: String?
var body: some View {
VStack {
if let mostRecentEvent = store.recentEvents.first {
Text(timeAgo ?? relativeTimeFormatter.localizedString(for: mostRecentEvent.timestamp, relativeTo: Date()))
.fontWeight(.bold)
.font(.system(.largeTitle,
design: .rounded))
.onReceive(timer) { _ in
timeAgo = relativeTimeFormatter
.localizedString(for: mostRecentEvent.timestamp, relativeTo: Date())
}
.onReceive(store.$recentEvents) { recentEvents in
print(".onReceive(store.$recentEvents)")
if let mostRecentEvent = recentEvents.first {
timeAgo = relativeTimeFormatter
.localizedString(for: mostRecentEvent.timestamp, relativeTo: Date())
}
}
} else {
Text("No Event Data")
}
}
.frame(minWidth: 250,
maxWidth: 250,
minHeight: 200,
maxHeight: 200)
}
}
private let relativeTimeFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.dateTimeStyle = .numeric
return formatter
}()
The problem is that, sometimes, there is a push notification indicating new data, and the updateLocalEvents() function is called, updating the recentEvents variable, which also triggers .onReceive(store.$recentEvents). I can see this is happening with my print statements. However, the timeAgo variable does not always get updated, and the view still shows the old string.
How should I change this to get my desired result of always keeping the timeAgo String? up-to-date based on the current value of #Published var recentEvents?
I’m also open to any other suggestions to simplify or improve any of this code I shared! Thanks!
It turns out I made a dumb mistake and posted this thinking it was a SwiftUI or Combine issue, when really there was a different problem.
From what I can tell now, SwiftUI and everything I had set up was actually working fine.
The real issue was in the data I was fetching. The array of Events based on the query results were not what I expected. I was receiving a push notification from CloudKit that indicated there were, and based on that I would kick off the query to CloudKit to fetch the new data. This data that was fetched was outdated, and did not include the new events (or still included deleted events).
I found that if I included a delay of just a couple seconds between the notification arriving and sending the query, the expected data would come back from CloudKit and everything would update accordingly.
I should have actually been using the CKFetchDatabaseChangesOperation and related features of the CloudKit API to fetch and process the actual changes that triggered the notification instead of kicking off a fresh query each time.
For the new delegate file in iOS 14 I need to include both the .environmentObject settings and the UserSettings: ObservableObject (which is a Realm Class).
But I first need to create the User data if there is none (first time user) otherwise it give me a null error and crashes.
Where would I put the code to initiate the user before calling it in the body loads?
#main
struct myapp_App: App {
let userSettings = UserSettings() // calling the data which will not exist if initial user
var body: some Scene {
WindowGroup {
ContentView().environmentObject(userSettings)
}
}
}
Thank you.
I put the code to create the user if first time inside the int() of the ObservableObject.
I hope can only hope this code is proper? But it works.
Good luck.
class UserSettings: ObservableObject {
#Published var name: String? = nil
#Published var email: String? = nil
init(){
if User.userExist() == false {
User.initiateUser()
}
let u = User.getUser()
name = u!.name
email = u!.email
}
}
Multiline text input is currently not natively supported in SwiftUI (hopefully this feature is added soon!) so I've been trying to use the combine framework to implement a UITextView from UIKit which does support multiline input, however i've been having mixed results.
This is the code i've created to make the Text view:
struct MultilineTextView: UIViewRepresentable {
#Binding var text: String
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.isScrollEnabled = true
view.isEditable = true
view.isUserInteractionEnabled = true
view.backgroundColor = UIColor.white
view.textColor = UIColor.black
view.font = UIFont.systemFont(ofSize: 17)
view.delegate = context.coordinator
return view
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text
}
func frame(numLines: CGFloat) -> some View {
let height = UIFont.systemFont(ofSize: 17).lineHeight * numLines
return self.frame(height: height)
}
func makeCoordinator() -> MultilineTextView.Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UITextViewDelegate {
var parent: MultilineTextView
init(_ parent: MultilineTextView) {
self.parent = parent
}
func textViewDidChange(_ textView: UITextView) {
parent.text = textView.text
}
}
}
I've then implemented it in a swiftUI view like:
MultilineTextView(text: title ? $currentItem.titleEnglish : $currentItem.pairArray[currentPair].english)//.frame(numLines: 4)
And bound it to a state variable:
#State var currentItem:Item
It sort of works. However, the state var currentItem:Item contains an array of strings which I'm then cycling through using buttons which update the string array based on what has been inputted into MultilineTextView. This is where i'm encountering a problem where the MultilineTextView seems to bind to only the first string item in the array, and then it won't change. When I use swiftUI's native TextField view this functionality works fine and I can cycle through the string array and update it by inputting text into the TextField.
I think I must be missing something in the MultilineTextView struct to allow this functionality. Any pointers are gratefully received.
Update: Added model structs
struct Item: Identifiable, Codable {
let id = UUID()
var completed = false
var pairArray:[TextPair]
}
struct TextPair: Identifiable, Codable {
let id = UUID()
var textOne:String
var textTwo:String
}
Edit:
So I've done some more digging and I've found what I think is the problem. When the textViewDidChange of the UITextView is triggered, it does send the updated text which I can see in the console. The strange thing is that the updateUIView function then also gets triggered and it updates the UITextView's text with what was in the binding var before the update was sent via textViewDidChange. The result is that the UITextview just refuses to change when you type into it. The strange thing is that it works for the first String in the array, but when the item is changed it won't work anymore.
It appears that SwiftUI creates two variants of UIViewRepresentable, for each binding, but does not switch them when state, ie title is switched... probably due to defect, worth submitting to Apple.
I've found worked workaround (tested with Xcode 11.2 / iOS 13.2), use instead explicitly different views as below
if title {
MultilineTextView(text: $currentItem.titleEnglish)
} else {
MultilineTextView(text: $currentItem.pairArray[currentPair].textOne)
}
So I figured out the problem in the end, the reason why it wasn't updating was because I was passing in a string which was located with TWO state variables. You can see that in the following line, currentItem is one state variable, but currentPair is another state variable that provides an index number to locate a string. The latter was not being updated because it wasn't also being passed into the multiline text view via a binding.
MultilineTextView(text: title ? $currentItem.titleEnglish : $currentItem.pairArray[currentPair].english)
I thought initially that passing in one would be fine and the parent view would handle the other one but this turns out not to be the case. I solved my problem by making two binding variables so I could locate the string that I wanted in a dynamic way. Sounds stupid now but I couldn't see it at the time.
I'm trying to dismiss a .sheet in SwiftUI, after calling an async process to confirm the user's MFA code. (I'm using the AWS Amplify Framework).
I have a binding variable set on the main view, and reference it in the view the sheet presents with #Binding var displayMFAView: Bool. I have an authentication helper that tracks the user state: #EnvironmentObject var userAuthHelper: UserAuthHelper.
The following code dismisses the sheet as expected:
func confirmMFACode(verificationCode: String) {
// Code to confifm MFA...
print("User confirmed MFA")
self.userAuthHelper.isSignedIn = true
self.displayMFAView = false
}
However, if I call the auth process via Amplify's confirmSignIn method,
func confirmVerificationMFA(verificationCode: String) {
AWSMobileClient.default().confirmSignIn(challengeResponse: verificationCode) { (signInResult, error) in
if let error = error as? AWSMobileClientError {
// ... error handling ...
} else if let signInResult = signInResult {
switch (signInResult.signInState) {
case .signedIn:
print("User confirmed MFA")
self.userAuthHelper.isSignedIn = true
self.displayMFAView = false
default:
print("\(signInResult.signInState.rawValue)")
}
}
}
}
the sheet does not get dismissed. I have tried wrapping the variable assignment in DispatchQueue.main.async {..., but that hasn't solved the issue either.
...
DispatchQueue.main.async {
self.userAuthHelper.isSignedIn = true
self.displayMFAView = false
}
...
In fact, this throws the following into my logs:
Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
Wrapping the switch (... in a DispatchQueue per https://stackoverflow.com/a/58288437/217101 gave me the same warning in my log.
Admittedly I don't have a firm grasp on SwiftUI or AWS Amplify. What am I not understanding?
From what I can tell the async call does something unexpected with the state variables, but not with an EnvironmentObject. So, nstead of #Binding var displayMFAView: Bool, I stored displayMFAView in an EnvironmentObject,
#EnvironmentObject var settings: UserSettings
#State var mfaCode: String = ""
and then can show or hide the .sheet(... by updating a boolean in that object:
Button(action: {
self.signIn() // Async call happens here
self.settings.displayMFAView.toggle()
}) {
Text("Sign In")
}.sheet(isPresented: self.$settings.displayMFAView) {
// Example code to capture text
TextField("Enter your MFA code", text: self.$mfaCode)
}
Button(action: {
self.verifyMFACode(verificationCode: self.mfaCode) // async call
}) {
Text("Confirm")
}
In func verifyMFACode(), I can make an async call to validate my user, then toggle the sheet to disappear on success:
func verifyMFACode(verificationCode: String) {
AWSMobileClient.default().confirmSignIn(challengeResponse: verificationCode) {
...
case .signedIn:
self.settings.displayMFAView.toggle()
...