Given the following minimal reproducible example:
struct ParentView: View {
#State private var showSheet = false
var body: some View {
return Button("Show sheet") {
showSheet.toggle()
}.sheet(
isPresented: $showSheet,
onDismiss: {
print("Parent onDismiss")
},
content: {
NavigationView {
SheetView(parentShowSheet: $showSheet)
}
}
)
}
}
struct SheetView: View {
#Environment(\.dismiss) private var dismiss
#Environment(\.isPresented) private var isPresented
#Binding var parentShowSheet: Bool
var body: some View {
Button("Manual close") {
dismiss()
}
.onChange(of: isPresented) { isPresented in
print("Sheet isPresented", isPresented)
}
.onChange(of: parentShowSheet) { parentShowSheet in
print("Sheet parentShowSheet", parentShowSheet)
}
}
}
The SheetView's onChange methods are not triggering in the way I would expect. In this example there are two ways to close a sheet once it's opened :
Click the "Manual close" button which triggers dismiss(), or
Pull down the sheet to trigger an "interactive close" (the thing that is disabled when .interactiveDismissDisabled(true) is added to the view).
If you try these both out you'll see that for (1) you'll get two print statements "Sheet parentShowSheet" and "Parent onDismiss", while for (2) you'll just get one print statement "Parent onDimiss".
Three questions:
Why does "Sheet parentShowSheet" not print in case (2)?
Why does "Sheet isPresented" not print in either case? I'd think that dismissing a sheet is very literally changing it's Environment(\.isPresented) value.
How can I add a hook inside of the SheetView that triggers when it is dismissed with either method (1) or method (2) (e.g. something that operates like either of my onChange methods, but actually works in both cases)?
Related
Abstract
I'm creating an app that allows for content creation and display. The UX I yearn for requires the content creation view to use programmatic navigation. I aim at architecture with a main view model and an additional one for the content creation view. The problem is, the content creation view model does not work as I expected in this specific example.
Code structure
Please note that this is a minimal reproducible example.
Suppose there is a ContentView: View with a nested AddContentPresenterView: View. The nested view consists of two phases:
specifying object's name
summary screen
To allow for programmatic navigation with NavigationStack (new in iOS 16), each phase has an associated value.
Assume that AddContentPresenterView requires the view model. No workarounds with #State will do - I desire to learn how to handle ObservableObject in this case.
Code
ContentView
struct ContentView: View {
#EnvironmentObject var model: ContentViewViewModel
var body: some View {
VStack {
NavigationStack(path: $model.path) {
List(model.content) { element in
Text(element.name)
}
.navigationDestination(for: Content.self) { element in
ContentDetailView(content: element)
}
.navigationDestination(for: Page.self) { page in
AddContentPresenterView(page: page)
}
}
Button {
model.navigateToNextPartOfContentCreation()
} label: {
Label("Add content", systemImage: "plus")
}
}
}
}
ContentDetailView (irrelevant)
struct ContentDetailView: View {
let content: Content
var body: some View {
Text(content.name)
}
}
AddContentPresenterView
As navigationDestination associates a destination view with a presented data type for use within a navigation stack, I found no better way of adding a paged view to be navigated using the NavigationStack than this.
extension AddContentPresenterView {
var contentName: some View {
TextField("Name your content", text: $addContentViewModel.contentName)
.onSubmit {
model.navigateToNextPartOfContentCreation()
}
}
var contentSummary: some View {
VStack {
Text(addContentViewModel.contentName)
Button {
model.addContent(addContentViewModel.createContent())
model.navigateToRoot()
} label: {
Label("Add this content", systemImage: "checkmark.circle")
}
}
}
}
ContentViewViewModel
Controls the navigation and adding content.
class ContentViewViewModel: ObservableObject {
#Published var path = NavigationPath()
#Published var content: [Content] = []
func navigateToNextPartOfContentCreation() {
switch path.count {
case 0:
path.append(Page.contentName)
case 1:
path.append(Page.contentSummary)
default:
fatalError("Navigation error.")
}
}
func navigateToRoot() {
path.removeLast(path.count)
}
func addContent(_ content: Content) {
self.content.append(content)
}
}
AddContentViewModel
Manages content creation.
class AddContentViewModel: ObservableObject {
#Published var contentName = ""
func createContent() -> Content {
return Content(name: contentName)
}
}
Page
Enum containing creation screen pages.
enum Page: Hashable {
case contentName, contentSummary
}
What is wrong
Currently, for each page pushed onto the navigation stack, a new StateObject is created. That makes the creation of object impossible, since the addContentViewModel.contentName holds value only for the bound screen.
I thought that, since StateObject is tied to the view's lifecycle, it's tied to AddContentPresenterView and, therefore, I would be able to share it.
What I've tried
The error is resolved when addContentViewModel in AddContentPresenterView is an EnvironmentObject initialized in App itself. Then, however, it's tied to the App's lifecycle and subsequent content creations greet us with stale data - as it should be.
Wraping up
How to keep SwiftUI from creating additional StateObjects in this custom page view?
Should I resort to ObservedObject and try some wizardry? Should I just implement a reset method for my AddContentViewModel and reset the data on entering or quiting the screen?
Or maybe there is a better way of achieving what I've summarized in abstract?
If you declare #StateObject var addContentViewModel = AddContentViewModel() in your AddContentPresenterView it will always initialise new AddContentViewModel object when you add AddContentPresenterView in navigation stack. Now looking at your code and app flow I don't fill you need AddContentViewModel.
First, update your contentSummary of the Page enum with an associated value like this.
enum Page {
case contentName, contentSummary(String)
}
Now update your navigate to the next page method of your ContentViewModel like below.
func navigateToNextPage(_ page: Page) {
path.append(page)
}
Now for ContentView, I think you need to add VStack inside NavigationStack otherwise that bottom plus button will always be visible.
ContentView
struct ContentView: View {
#EnvironmentObject var model: ContentViewViewModel
var body: some View {
NavigationStack(path: $model.path) {
VStack {
List(model.content) { element in
Text(element.name)
}
.navigationDestination(for: Content.self) { element in
ContentDetailView(content: element)
}
.navigationDestination(for: Page.self) { page in
switch page {
case .contentName: AddContentView()
case .contentSummary(let name): ContentSummaryView(contentName: name)
}
}
Button {
model.navigateToNextPage(.contentName)
} label: {
Label("Add content", systemImage: "plus")
}
}
}
}
}
So now it will push destination view on basis of the type of the Page. So you can remove your AddContentPresenterView and add AddContentView and ContentSummaryView.
AddContentView
struct AddContentView: View {
#EnvironmentObject var model: ContentViewViewModel
#State private var contentName = ""
var body: some View {
TextField("Name your content", text: $contentName)
.onSubmit {
model.navigateToNextPage(.contentSummary(contentName))
}
}
}
ContentSummaryView
struct ContentSummaryView: View {
#EnvironmentObject var model: ContentViewViewModel
let contentName: String
var body: some View {
VStack {
Text(contentName)
Button {
model.addContent(Content(name: contentName))
model.navigateToRoot()
} label: {
Label("Add this content", systemImage: "checkmark.circle")
}
}
}
}
So as you can see I have used #State property in AddContentView to bind it with TextField and on submit I'm passing it as an associated value with contentSummary. So this will reduce the use of AddContentViewModel. So now there is no need to reset anything or you want face any issue of data loss when you push to ContentSummaryView.
I have a main tab bar that has three tabs, in the first tab I have a background task that may return an error, this error is presented by an alert view. now if I moved to any tab views in the app while the background task is running and an error occurred the alert will present on the current view instead of showing in the first tab view.
struct FirstTabView: View {
// viewModel will fire the background task after init
#StateObject var viewModel: FirstViewModel = .init()
var body: some View {
Text("Hello First")
.alert("error", isPresented: .init(get: {
return viewModel.errorMessage != nil
}, set: { _ in
viewModel.errorMessage = nil
})) {
Button("OK") {
}
}
}
}
how can I limit the error alert to be presented on the first tab only?
One solution could be move the alert to the main TabView, rather than having it shown in the child view. By doing that, you will be able to track what tab is selected and trigger the alert only when both conditions are true:
the first tab is selected
the view-model's property errorMessage is not nil
The trigger is a dedicated showAlert state property in your TabView view, that will change whenever the first tab appears on the screen.
In the example here below, you can change your view-model's property from the second view, but the alert will only be shown when you move to the first tab; I hope this is what you are looking for:
// The model must be an observable class
class MyModel: ObservableObject {
// The error message must be a published property
#Published var errorMessage: String? = nil
}
struct MyTabs: View {
// viewModel will fire the background task after init
let viewModel = MyModel() // Use your ViewModel as applicable
#State private var tabSelection = 0 // This property will track the selected tab
#State private var showAlert = false // This property is the trigger to the alert
var body: some View {
TabView(selection: $tabSelection) { // The selection: parameter tracks the selected tab through the .tag()
FirstTabView()
.environmentObject(viewModel) // Pass the same model to the Views in each tab
.tabItem { Text("First") }
.tag(0) // This is View #0 for the tabSelection property
.onAppear {
// Only when this View appears the showAlert will be set to true,
// only if there is an error in the model's property and the first tab is selected
if viewModel.errorMessage != nil && tabSelection == 0 {
showAlert = true
}
}
Second()
.environmentObject(viewModel) // Pass the same model to the Views in each tab
.tabItem { Text("Second") }
.tag(1) // This is View #1 for the tabSelection property
}
// Trigger the alert in the TabView, instead of in the child View
.alert("error", isPresented: $showAlert) {
Button {
viewModel.errorMessage = nil
} label: {
Text("OK")
}
} message: {
Text(viewModel.errorMessage ?? "not available")
}
}
}
struct FirstTabView: View {
#EnvironmentObject var viewModel: MyModel
var body: some View {
VStack {
Text("Hello First")
.padding()
Text("\(viewModel.errorMessage ?? "OK")")
.padding()
}
}
}
struct Second: View {
#EnvironmentObject var viewModel: MyModel
var body: some View {
VStack {
Text("Second")
.padding()
Text("\(viewModel.errorMessage ?? "OK")")
.padding()
Button {
viewModel.errorMessage = "error"
} label: {
Text("Show alert")
}
}
}
}
One way to handle it is by updating the badge icon on the first tab when the error occurs. Then the user can finish off what they are currently doing and then inspect the first tab when they notice it has updated, say with an exclamation mark badge. At that point, you could present the alert.
What should I observe to receive notifications in a View of focus changes on an app, or scene, displayed in an iPaOS Split View?
I'm trying to update some data, for the View, as described here, when the user gives focus back to the app.
Thanks.
Here is a solution that updates pasteDisabled whenever a UIPasteboard.changedNotification is received or a scenePhase is changed:
struct ContentView: View {
#Environment(\.scenePhase) private var scenePhase
#State private var pasteDisabled = false
var body: some View {
Text("Some Text")
.contextMenu {
Button(action: {}) {
Text("Paste")
Image(systemName: "doc.on.clipboard")
}
.disabled(pasteDisabled)
}
.onReceive(NotificationCenter.default.publisher(for: UIPasteboard.changedNotification)) { _ in
updatePasteDisabled()
}
.onChange(of: scenePhase) { _ in
updatePasteDisabled()
}
}
func updatePasteDisabled() {
pasteDisabled = !UIPasteboard.general.contains(pasteboardTypes: [aPAsteBoardType])
}
}
I want to programmatically be able to navigate to a link within a List of NavigationLinks when the view appears (building deep linking from push notification). I have a string -> Bool dictionary which is bound to a custom Binding<Bool> inside my view. When the view appears, I set the bool property, navigation happens, however, it immediately pops back. I followed the answer in SwiftUI NavigationLink immediately navigates back and made sure that each item in the List has a unique identifier, but the issue still persists.
Two questions:
Is my binding logic here correct?
How come the view pops back immediately?
import SwiftUI
class ContentViewModel: ObservableObject {
#Published var isLinkActive:[String: Bool] = [:]
}
struct ContentViewTwo: View {
#ObservedObject var contentViewModel = ContentViewModel()
#State var data = ["1", "2", "3"]
#State var shouldPushPage3: Bool = true
var page3: some View {
Text("Page 3")
.onAppear() {
print("Page 3 Appeared!")
}
}
func binding(chatId: String) -> Binding<Bool> {
return .init(get: { () -> Bool in
return self.contentViewModel.isLinkActive[chatId, default: false]
}) { (value) in
self.contentViewModel.isLinkActive[chatId] = value
}
}
var body: some View {
return
List(data, id: \.self) { data in
NavigationLink(destination: self.page3, isActive: self.binding(chatId: data)) {
Text("Page 3 Link with Data: \(data)")
}.onAppear() {
print("link appeared")
}
}.onAppear() {
print ("ContentViewTwo Appeared")
if (self.shouldPushPage3) {
self.shouldPushPage3 = false
self.contentViewModel.isLinkActive["3"] = true
print("Activating link to page 3")
}
}
}
}
struct ContentView: View {
var body: some View {
return NavigationView() {
VStack {
Text("Page 1")
NavigationLink(destination: ContentViewTwo()) {
Text("Page 2 Link")
}
}
}
}
}
The error is due to the lifecycle of the ViewModel, and is a limitation with SwiftUI NavigationLink itself at the moment, will have to wait to see if Apple updates the pending issues in the next release.
Update for SwiftUI 2.0:
Change:
#ObservedObject var contentViewModel = ContentViewModel()
to:
#StateObject var contentViewModel = ContentViewModel()
#StateObject means that changes in the state of the view model do not trigger a redraw of the whole body.
You also need to store the shouldPushPage3 variable outside the View as the view will get recreated every time you pop back to the root View.
enum DeepLinking {
static var shouldPushPage3 = true
}
And reference it as follows:
if (DeepLinking.shouldPushPage3) {
DeepLinking.shouldPushPage3 = false
self.contentViewModel.isLinkActive["3"] = true
print("Activating link to page 3")
}
The bug got fixed with the latest SwiftUI release. But to use this code at the moment, you will need to use the beta version of Xcode and iOS 14 - it will be live in a month or so with the next GM Xcode release.
I was coming up against this problem, with a standard (not using 'isActive') NavigationLink - for me the problem turned out to be the use of the view modifiers: .onAppear{code} and .onDisappear{code} in the destination view. I think it was causing a re-draw loop or something which caused the view to pop back to my list view (after approx 1 second).
I solved it by moving the modifiers onto a part of the destination view that's not affected by the code in those modifiers.
I am producing the situation on WatchOS with the following code
struct Modal : View {
#Binding var showingModal : Bool
init(showingModal : Binding<Bool>){
self._showingModal = showingModal
print("init modal")
}
var body: some View {
Button(action: {
self.showingModal.toggle()
}, label: {
Text("TTTT")
})
}
}
struct ContentView: View {
#State var showingModal = false
var body: some View {
Button(action: {
self.showingModal.toggle()
}, label: {
Text("AAAA")
}).sheet(isPresented: $showingModal, content: {Modal(showingModal: self.$showingModal)})
}
}
Every time I press the button in the master view to summon the modal with .sheet, Two instances of the modal view are created.
Could someone explain this phenomenon?
I tracked this down in my code to having the following line in my View:
#Environment(\.presentationMode) var presentation
I had been doing it due to https://stackoverflow.com/a/61311279/155186, but for some reason that problem seems to have disappeared for me so I guess I no longer need it.
I've filed Feedback FB7723767 with Apple about this.
It is probably a bug, as of Xcode 11.4.1 (11E503a).
Beware, that if for example initializing view models (or anything else for that matter) like so:
.sheet(isPresented: $isEditingModalPresented) {
LEditView(vm: LEditViewModel(), isPresented: self.$isEditingModalPresented)
}
the VM will be initialized twice.
Comment out/remove the init() method from Modal with everything else the same. You should be able to solve the issue of two instances of Modal being created, its because you are explicitly initializing the binding (showingModal) in the init() of Modal. Hope this makes sense.
private let uniqueId: String = "uniqueId"
Button(action: {
self.showingModal.toggle()
}, label: {
Text("AAAA")
})
.sheet(isPresented: $showingModal) {
Modal(showingModal: self.$showingModal)
.id("some-unique-id")
}
Ex:
.id(self.uniqueId)
Add unique id to your .sheet and not't worry :)
But, do not use UUID(), because sheet view will be represented on every touch event