Unexpected SwiftUI #State behavior - swiftui

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

Related

How do I track down the dependency that causes a refresh?

I have a fairly complex document type to work with. It is basically a bundle containing a set of independent documents of the same type, with various pieces of metadata about the documents. The data structure that represents the bundle is an array of structs, similar to this (there are several more fields, but these are representative):
struct DocumentData: Equatable, Identifiable, Hashable {
let id = UUID()
var docData: DocumentDataClass
var docName: String
var docFileWrapper: FileWrapper?
func hash(into hasher: inout Hasher) {
id.hash(into: &hasher)
}
static func ==(lhs: KeyboardLayoutData, rhs: KeyboardLayoutData) -> Bool {
return lhs.id == rhs.id
}
}
The window for the bundle is a master-detail, with a list on the left and, when one is selected, there is an edit pane for the document on the right. The FileWrapper is used to keep track of which files need to be written for saving, so it gets initialised on reading the relevant file, and reset when an undoable change is made. That is largely the only way that the DocumentData structure gets changed (ignoring explicit things like changing the name).
I've reached a point where a lot of things are working, but I'm stuck on one. There's a view inside the edit pane, several levels deep, and when I double-click it, I want a sheet to appear. It does so, but then disappears by itself.
Searching for ways to work this out, I discovered by using print(Self._printChanges()) at various points that the edit pane was being refreshed after showing the sheet, which meant that the parent disappeared. What I found was that the dependency that changed was the DocumentData instance. But, I then added a print of the DocumentData instance before the _printChanges call, and it is identical. I have also put in didSet for each field of DocumentData to print when they get set, and nothing gets printed, so I'm not sure where the change is happening.
So the question comes down to how I can work out what is actually driving the refresh, since what is claimed to be different is identical in every field.
There are some other weird things happening, such as dragging and dropping text into the view causing the whole top-level document array of DocumentData items to change before the drop gets processed and the data structures get updated, so there are things I am not understanding as clearly as I might like. Any guidance is much appreciated.
ADDED:
The view that triggers the sheet is fairly straightforward, especially compared to its enclosing view, which is where most of the interface code is. This is a slightly simplified version of it:
struct MyView: View, DropDelegate {
#EnvironmentObject var keyboardStatus: KeyboardStatus
#Environment(\.displayFont) var displayFont
#Environment(\.undoManager) var undoManager
var keyCode: Int
#State var modifiers: NSEvent.ModifierFlags = []
#State private var dragHighlight = false
#State private var activeSheet: ActiveSheet?
#State private var editPopoverIsPresented = false
// State variables for double click and drop handling
...
static let dropTypes = [UTType.utf8PlainText]
var body: some View {
ZStack {
BackgroundView(...)
Text(...)
}
.onAppear {
modifiers = keyboardStatus.currentModifiers
}
.focusable(false)
.allowsHitTesting(true)
.contentShape(geometry.contentPath)
.onHover { entered in
// updates an inspector view
}
.onTapGesture(count: 2) {
interactionType = .doubleClick
activeSheet = .doubleClick
}
.onTapGesture(count: 1) {
handleItemClick()
}
.sheet(item: $activeSheet, onDismiss: handleSheetReturn) { item in
switch item {
case .doubleClick:
DoubleClickItem(...) ) {
activeSheet = nil
}
case .drop:
DropItem(...) {
activeSheet = nil
}
}
}
.popover(isPresented: $editPopoverIsPresented) {
EditPopup(...)
}
.onDrop(of: KeyCap.dropTypes, delegate: self)
.contextMenu {
ItemContextMenu(...)
}
}
func handleItemClick() {
NotificationCenter.default.post(name: .itemClick, object: nil, userInfo: [...])
}
func handleEvent(event: KeyEvent) {
if event.eventKind == .dropText {
interactionType = .drop
activeSheet = .drop
}
else if event.eventKind == .replaceText {
...
handleItemDoubleClick()
}
}
func handleSheetReturn() {
switch interactionType {
case .doubleClick:
handleItemDoubleClick()
case .drop:
handleItemDrop()
case .none:
break
}
}
func handleItemDoubleClick() {
switch itemAction {
case .state1:
...
case .state2:
...
case .none:
// User cancelled
break
}
interactionType = nil
}
func handleItemDrop() {
switch itemDropAction {
case .action1:
...
case .action2:
...
case .none:
// User cancelled
break
}
interactionType = nil
}
// Drop delegate
func dropEntered(info: DropInfo) {
dragHighlight = true
}
func dropExited(info: DropInfo) {
dragHighlight = false
}
func performDrop(info: DropInfo) -> Bool {
if let item = info.itemProviders(for: MyView.dropTypes).first {
item.loadItem(forTypeIdentifier: UTType.utf8PlainText.identifier, options: nil) { (textData, error) in
if let textData = String(data: textData as! Data, encoding: .utf8) {
let event = ...
handleEvent(event: event)
}
}
return true
}
return false
}
}
Further edit:
I ended up rewiring the code so that the sheet belongs to the higher level view, which makes everything work without solving the question. I still don't understand why I get a notification that a dependency has changed when it is identical to what it was before, and none of the struct's didSet blocks are called.
Try removing the class from the DocumentData. The use of objects in SwiftUI can cause these kind of bugs since it’s all designed for value types.
Try using ReferenceFileDocument to work with your model object instead of FileDocument which is designed for a model of value types.
Try using sheet(item:onDismiss:content:) for editing. I've seen people have the problem you describe when they try to hack the boolean sheet to work with editing an item.

Initialize optional #AppStorage property with non-nil value

I need an optional #AppStorage String property (for a NavigationLink selection, which required optional), so I declared
#AppStorage("navItemSelected") var navItemSelected: String?
I need it to start with a default value that's non-nil, so I tried:
#AppStorage("navItemSelected") var navItemSelected: String? = "default"
but that doesn't compile.
I also tried:
init() {
if navItemSelected == nil { navItemSelected = "default" }
}
But this just overwrites the actual persisted value whenever the app starts.
Is there a way to start it with a default non-nil value and then have it persisted as normal?
Here is a simple demo of possible approach based on inline Binding (follow-up of my comment above).
Tested with Xcode 13 / iOS 15
struct DemoAppStoreNavigation: View {
static let defaultNav = "default"
#AppStorage("navItemSelected") var navItemSelected = Self.defaultNav
var body: some View {
NavigationView {
Button("Go Next") {
navItemSelected = "next"
}.background(
NavigationLink(isActive: Binding(
get: { navItemSelected != Self.defaultNav },
set: { _ in }
), destination: {
Button("Return") {
navItemSelected = Self.defaultNav
}
.onDisappear {
navItemSelected = Self.defaultNav // << for the case of `<Back`
}
}) { EmptyView() }
)
}
}
}
#AppStorage is a wrapper for UserDefaults, so you can simply register a default the old-fashioned way:
UserDefaults.standard.register(defaults: ["navItemSelected" : "default"])
You will need to call register(defaults:) before your view loads, so I’d recommend calling it in your App’s init or in application(_:didFinishLaunchingWithOptions:).

How to use published optional properties correctly for SwiftUI

To provide some context, Im writing an order tracking section of our app, which reloads the order status from the server every so-often. The UI on-screen is developed in SwiftUI. I require an optional image on screen that changes as the order progresses through the statuses.
When I try the following everything works...
My viewModel is an ObservableObject:
internal class MyAccountOrderViewModel: ObservableObject {
This has a published property:
#Published internal var graphicURL: URL = Bundle.main.url(forResource: "tracking_STAGEONE", withExtension: "gif")!
In SwiftUI use the property as follows:
GIFViewer(imageURL: $viewModel.graphicURL)
My issue is that the graphicURL property has a potentially incorrect placeholder value, and my requirements were that it was optional. Changing the published property to: #Published internal var graphicURL: URL? causes an issue for my GIFViewer which rightly does not accept an optional URL:
Cannot convert value of type 'Binding<URL?>' to expected argument type 'Binding<URL>'
Attempting the obvious unwrapping of graphicURL produces this error:
Cannot force unwrap value of non-optional type 'Binding<URL?>'
What is the right way to make this work? I don't want to have to put a value in the property, and check if the property equals placeholder value (Ie treat that as if it was nil), or assume the property is always non-nil and unsafely force unwrap it somehow.
Below is an extension of Binding you can use to convert a type like Binding<Int?> to Binding<Int>?. In your case, it would be URL instead of Int, but this extension is generic so will work with any Binding:
extension Binding {
func optionalBinding<T>() -> Binding<T>? where T? == Value {
if let wrappedValue = wrappedValue {
return Binding<T>(
get: { wrappedValue },
set: { self.wrappedValue = $0 }
)
} else {
return nil
}
}
}
With example view:
struct ContentView: View {
#StateObject private var model = MyModel()
var body: some View {
VStack(spacing: 30) {
Button("Toggle if nil") {
if model.counter == nil {
model.counter = 0
} else {
model.counter = nil
}
}
if let binding = $model.counter.optionalBinding() {
Stepper(String(binding.wrappedValue), value: binding)
} else {
Text("Counter is nil")
}
}
}
}
class MyModel: ObservableObject {
#Published var counter: Int?
}
Result:

SwiftUI - Navigate to view after retrieving data

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.

Dismiss a .sheet in SwiftUI after an async process has completed?

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()
...