How can I present successive views in a SwiftUI-based iOS app? - ios

In attempting to learn SwiftUI, I am working on an iOS app that displays a list view of "observation sessions" and allows users to create new sessions from a "New" button. It requires an intermediate step of selecting a configuration that the new session will be based on.
I am able to show reasonable session list and configuration list screens, but my attempts to handle the selected configuration are failing.
The closure sent to the configurations list screen is called successfully as evidenced by a print statement that correctly displays the configuration name. But the remainder of the handler that is supposed to present a third view type fails to work (i.e. it doesn't present the view). In addition, I am getting a warning where I attempt to present the new view that "Result of call to 'sheet(isPresented:onDismiss:content:)' is unused". I'm hoping somebody can explain to me what I'm doing wrong. This is in Xcode 12.3, targeting iOS 14 in the simulator. Here is the SessionListView code where the problem is exhibited:
import SwiftUI
struct SessionsListView: View {
#ObservedObject var dataManager: DataManager
#State private var isPresented = false
#State private var isObserving = false
var body: some View {
VStack {
List {
ForEach(dataManager.allSavedSessions) {session in
NavigationLink(
// Navigate to a detail view
destination: SessionDetailView(session: session),
label: {
Text("\(session.name)")
})
}
}
Spacer()
Button("New Session") {
isPresented = true
}
.padding()
.font(.headline)
.sheet(isPresented: $isPresented) {
// Present a configuration list view where user must select configuration to use for new session
// Requires a closure that's called upon selection in the configuration list view, to handle the selection
NavigationView {
ConfigurationsListView(dataManager: dataManager, selectionHandler: { config in
isPresented = false
isObserving = true
handleConfigSelection(config)
})
.navigationTitle("Configurations")
.navigationBarItems(trailing: Button("Cancel") {
isPresented = false
})
}
}
}
}
private func handleConfigSelection(_ config: SessionConfiguration) {
// Use the selected configuration to start an observations session
print("Selected \(config.name). Will attempt to show sheet from \(self)")
isPresented = false
isObserving = true
self.sheet(isPresented: $isObserving) { // displaying warning: "Result of call to 'sheet(isPresented:onDismiss:content:)' is unused"
NavigationView {
ObservationsView(configuration: config)
.navigationBarItems(trailing: Button(action: {}) {
Text("Done")
})
}
}
}
}
Here's the code I'm using in this simplified demo for the model types.
ObservationSession:
struct ObservationSession: Identifiable {
let id: UUID = UUID()
let name: String
}
SessionConfiguration:
import Foundation
struct ObservationSession: Identifiable {
let id: UUID = UUID()
let name: String
}
DataManager:
import Foundation
class DataManager: ObservableObject {
var allSavedSessions: [ObservationSession] {
return [ObservationSession(name: "Field mouse droppings"), ObservationSession(name: "Squirrels running up trees"), ObservationSession(name: "Squirrel behavior in urban landscapes")]
}
var allSavedConfigurations: [SessionConfiguration] {
return [SessionConfiguration(name: "Squirrel Behavior"), SessionConfiguration(name: "Squirrel/Tree Interaction"), SessionConfiguration(name: "Mouse Behavior")]
}
}

After a night's sleep I figured out an approach that seems to work.
I added a "currentConfiguration" property to my DataManager class of type SessionConfiguration, and set that property in the ConfigurationsListView when a user selects a configuration from the list. Then the SessionsListView can either present the ConfigurationsListView or an ObservationsView depending on a variable that tracks the flow:
import SwiftUI
enum SessionListPresentationFlow {
case configuration
case observation
}
struct SessionsListView: View {
#ObservedObject var dataManager: DataManager
#State private var isPresented = false
#State var flow: SessionListPresentationFlow = .configuration
var body: some View {
VStack {
List {
ForEach(dataManager.allSavedSessions) {session in
NavigationLink(
// Navigate to a detail view
destination: SessionDetailView(session: session),
label: {
Text("\(session.name)")
})
}
}
Spacer()
Button("New Session") {
isPresented = true
}
.padding()
.font(.headline)
.sheet(isPresented: $isPresented, onDismiss: {
if flow == .observation {
flow = .configuration
} else {
flow = .configuration
}
dataManager.currentConfiguration = nil
isPresented = false
}) {
// Present a view for the appropriate flow
viewForCurrentFlow()
}
}
}
#ViewBuilder private func viewForCurrentFlow() -> some View {
if flow == .configuration {
NavigationView {
ConfigurationsListView(dataManager: dataManager, selectionHandler: { config in
isPresented = false
handleConfigSelection(config)
})
.navigationTitle("Configurations")
.navigationBarItems(trailing: Button("Cancel") {
isPresented = false
flow = .observation
})
}
} else if flow == .observation, let config = dataManager.currentConfiguration {
NavigationView {
ObservationsView(configuration: config)
.navigationBarItems(leading: Button(action: {isPresented = false}) {
Text("Done")
})
}
} else {
EmptyView()
}
}
private func handleConfigSelection(_ config: SessionConfiguration) {
flow = .observation
isPresented = true
}
}

Related

Why does this SwiftUI List require an extra objectWillChange.send?

Here is a simple list view of "Topic" struct items. The goal is to present an editor view when a row of the list is tapped. In this code, tapping a row is expected to cause the selected topic to be stored as "tappedTopic" in an #State var and sets a Boolean #State var that causes the EditorV to be presented.
When the code as shown is run and a line is tapped, its topic name prints properly in the Print statement in the Button action, but then the app crashes because self.tappedTopic! finds tappedTopic to be nil in the EditTopicV(...) line.
If the line "tlVM.objectWillChange.send()" is uncommented, the code runs fine. Why is this needed?
And a second puzzle: in the case where the code runs fine, with the objectWillChange.send() uncommented, a print statement in the EditTopicV init() shows that it runs twice. Why?
Any help would be greatly appreciated. I am using Xcode 13.2.1 and my deployment target is set to iOS 15.1.
Topic.swift:
struct Topic: Identifiable {
var name: String = "Default"
var iconName: String = "circle"
var id: String { name }
}
TopicListV.swift:
struct TopicListV: View {
#ObservedObject var tlVM: TopicListVM
#State var tappedTopic: Topic? = nil
#State var doEditTappedTopic = false
var body: some View {
VStack(alignment: .leading) {
List {
ForEach(tlVM.topics) { topic in
Button(action: {
tappedTopic = topic
// why is the following line needed?
tlVM.objectWillChange.send()
doEditTappedTopic = true
print("Tapped topic = \(tappedTopic!.name)")
}) {
Label(topic.name, systemImage: topic.iconName)
.padding(10)
}
}
}
Spacer()
}
.sheet(isPresented: $doEditTappedTopic) {
EditTopicV(tlVM: tlVM, originalTopic: self.tappedTopic!)
}
}
}
EditTopicV.swift (Editor View):
struct EditTopicV: View {
#ObservedObject var tlVM: TopicListVM
#Environment(\.presentationMode) var presentationMode
let originalTopic: Topic
#State private var editTopic: Topic
#State private var ic = "circle"
let iconList = ["circle", "leaf", "photo"]
init(tlVM: TopicListVM, originalTopic: Topic) {
print("DBG: EditTopicV: originalTopic = \(originalTopic)")
self.tlVM = tlVM
self.originalTopic = originalTopic
self._editTopic = .init(initialValue: originalTopic)
}
var body: some View {
VStack(alignment: .leading) {
HStack {
Button("Cancel") {
presentationMode.wrappedValue.dismiss()
}
Spacer()
Button("Save") {
editTopic.iconName = editTopic.iconName.lowercased()
tlVM.change(topic: originalTopic, to: editTopic)
presentationMode.wrappedValue.dismiss()
}
}
HStack {
Text("Name:")
TextField("name", text: $editTopic.name)
Spacer()
}
Picker("Color Theme", selection: $editTopic.iconName) {
ForEach(iconList, id: \.self) { icon in
Text(icon).tag(icon)
}
}
.pickerStyle(.segmented)
Spacer()
}
.padding()
}
}
TopicListVM.swift (Observable Object View Model):
class TopicListVM: ObservableObject {
#Published var topics = [Topic]()
func append(topic: Topic) {
topics.append(topic)
}
func change(topic: Topic, to newTopic: Topic) {
if let index = topics.firstIndex(where: { $0.name == topic.name }) {
topics[index] = newTopic
}
}
static func ex1() -> TopicListVM {
let tvm = TopicListVM()
tvm.append(topic: Topic(name: "leaves", iconName: "leaf"))
tvm.append(topic: Topic(name: "photos", iconName: "photo"))
tvm.append(topic: Topic(name: "shapes", iconName: "circle"))
return tvm
}
}
Here's what the list looks like:
Using sheet(isPresented:) has the tendency to cause issues like this because SwiftUI calculates the destination view in a sequence that doesn't always seem to make sense. In your case, using objectWillSend on the view model, even though it shouldn't have any effect, seems to delay the calculation of your force-unwrapped variable and avoids the crash.
To solve this, use the sheet(item:) form:
.sheet(item: $tappedTopic) { item in
EditTopicV(tlVM: tlVM, originalTopic: item)
}
Then, your item gets passed in the closure safely and there's no reason for a force unwrap.
You can also capture tappedTopic for a similar result, but you still have to force unwrap it, which is generally something we want to avoid:
.sheet(isPresented: $doEditTappedTopic) { [tappedTopic] in
EditTopicV(tlVM: tlVM, originalTopic: tappedTopic!)
}

What is wrong with this simple SwiftUI example? [duplicate]

I am using .sheet view in SwiftUI and I am observing a strange behavior in the execution of the code.
I am having a view SignInView2:
struct SignInView2: View {
#Environment(\.presentationMode) var presentationMode
#State var invitationUrl = URL(string: "www")
#State private var showingSheet = false
var body: some View {
VStack {
Text("Share Screen")
Button(action: {
print("link: \(invitationUrl)") // Here I see the new value assigned from createLink()
self.showingSheet = true
}) {
Text("Share")
}
.sheet(isPresented: $showingSheet) {
let invitationLink = invitationUrl?.absoluteString // Paasing the old value (www)
ActivityView(activityItems: [NSURL(string: invitationLink!)] as [Any], applicationActivities: nil)
}
}
.onAppear() {
createLink()
}
}
}
which calls create a link method when it appears:
extension SignInView2 {
func createLink() {
guard let uid = Auth.auth().currentUser?.uid else {
print("tuk0")
return }
let link = URL(string: "https://www.example.com/?invitedby=\(uid)")
print("tuk1:\(String(describing: link))")
let referralLink = DynamicLinkComponents(link: link!, domainURIPrefix: "https://makeitso.page.link")
print("tuk2:\(String(describing: referralLink))")
referralLink?.iOSParameters = DynamicLinkIOSParameters(bundleID: "com.IVANDOS.ToDoFirebase")
referralLink?.iOSParameters?.minimumAppVersion = "1.0"
referralLink?.iOSParameters?.appStoreID = "13129650"
referralLink?.shorten { (shortURL, warnings, error) in
if let error = error {
print(error.localizedDescription)
return
}
print("tuk4: \(shortURL)")
self.invitationUrl = shortURL!
}
}
}
That method assigns a value to the invitationUrl variable, which is passed to the sheet. Unfortunatelly, when the sheet appears, I don't see the newly assigned variable but I see only "www". Can you explain me how to pass the new value generated from createLink()?
This is known behaviour of sheet since SwiftUI 2.0. Content is created in time of sheet created not in time of showing. So the solution can be either to use .sheet(item:... modifier or passing binding in sheet content view (which is kind of reference to state storage and don't need to be updated).
Here is a demo of possible approach. Prepared with Xcode 12.4.
struct SignInView2: View {
#Environment(\.presentationMode) var presentationMode
#State private var invitationUrl: URL? // by default is absent
var body: some View {
VStack {
Text("Share Screen")
Button(action: {
print("link: \(invitationUrl)")
self.invitationUrl = createLink() // assignment activates sheet
}) {
Text("Share")
}
.sheet(item: $invitationUrl) {
ActivityView(activityItems: [$0] as [Any], applicationActivities: nil)
}
}
}
}
// Needed to be used as sheet item
extension URL: Identifiable {
public var id: String { self.absoluteString }
}

.sheet in SwiftUI strange behaviour when passing a variable

I am using .sheet view in SwiftUI and I am observing a strange behavior in the execution of the code.
I am having a view SignInView2:
struct SignInView2: View {
#Environment(\.presentationMode) var presentationMode
#State var invitationUrl = URL(string: "www")
#State private var showingSheet = false
var body: some View {
VStack {
Text("Share Screen")
Button(action: {
print("link: \(invitationUrl)") // Here I see the new value assigned from createLink()
self.showingSheet = true
}) {
Text("Share")
}
.sheet(isPresented: $showingSheet) {
let invitationLink = invitationUrl?.absoluteString // Paasing the old value (www)
ActivityView(activityItems: [NSURL(string: invitationLink!)] as [Any], applicationActivities: nil)
}
}
.onAppear() {
createLink()
}
}
}
which calls create a link method when it appears:
extension SignInView2 {
func createLink() {
guard let uid = Auth.auth().currentUser?.uid else {
print("tuk0")
return }
let link = URL(string: "https://www.example.com/?invitedby=\(uid)")
print("tuk1:\(String(describing: link))")
let referralLink = DynamicLinkComponents(link: link!, domainURIPrefix: "https://makeitso.page.link")
print("tuk2:\(String(describing: referralLink))")
referralLink?.iOSParameters = DynamicLinkIOSParameters(bundleID: "com.IVANDOS.ToDoFirebase")
referralLink?.iOSParameters?.minimumAppVersion = "1.0"
referralLink?.iOSParameters?.appStoreID = "13129650"
referralLink?.shorten { (shortURL, warnings, error) in
if let error = error {
print(error.localizedDescription)
return
}
print("tuk4: \(shortURL)")
self.invitationUrl = shortURL!
}
}
}
That method assigns a value to the invitationUrl variable, which is passed to the sheet. Unfortunatelly, when the sheet appears, I don't see the newly assigned variable but I see only "www". Can you explain me how to pass the new value generated from createLink()?
This is known behaviour of sheet since SwiftUI 2.0. Content is created in time of sheet created not in time of showing. So the solution can be either to use .sheet(item:... modifier or passing binding in sheet content view (which is kind of reference to state storage and don't need to be updated).
Here is a demo of possible approach. Prepared with Xcode 12.4.
struct SignInView2: View {
#Environment(\.presentationMode) var presentationMode
#State private var invitationUrl: URL? // by default is absent
var body: some View {
VStack {
Text("Share Screen")
Button(action: {
print("link: \(invitationUrl)")
self.invitationUrl = createLink() // assignment activates sheet
}) {
Text("Share")
}
.sheet(item: $invitationUrl) {
ActivityView(activityItems: [$0] as [Any], applicationActivities: nil)
}
}
}
}
// Needed to be used as sheet item
extension URL: Identifiable {
public var id: String { self.absoluteString }
}

SwiftUI sheet not dismissing when isPresented value changes from a closure

I have a sheet view that is presented when a user clicks a button as shown in the parent view below:
struct ViewWithSheet: View {
#State var showingSheetView: Bool = false
#EnvironmetObject var store: DataStore()
var body: some View {
NavigationView() {
ZStack {
Button(action: { self.showingSheetView = true }) {
Text("Show sheet view")
}
}
.navigationBarHidden(true)
.navigationBarTitle("")
.sheet(isPresented: $showingSheetView) {
SheetView(showingSheetView: self.$showingSheetView).environmentObject(self.dataStore)
}
}
}
}
In the sheet view, when a user clicks another button, an action is performed by the store that has a completion handler. The completion handler returns an object value, and if that value exists, should dismiss the SheetView.
struct SheetView: View {
#Binding var showingSheetView: Bool
#EnvironmentObject var store: DataStore()
//#Environment(\.presentationMode) private var presentationMode
func create() {
store.createObject() { object, error in
if let _ = object {
self.showingSheetView = false
// self.presentationMode.wrappedValue.dismiss()
}
}
}
var body: some View {
VStack {
VStack {
HStack {
Button(action: { self.showingSheetView = false }) {
Text("Cancel")
}
Spacer()
Spacer()
Button(action: { self.create() }) {
Text("Add")
}
}
.padding()
}
}
}
}
However, in the create() function, once the store returns values and showingSheetView is set to false, the sheet view doesn't dismiss as expected. I've tried using presentationMode to dismiss the sheet as well, but this also doesn't appear to work.
I found my issue, the sheet wasn't dismissing due to a conditional in my overall App wrapping View, I had an if statement that would show a loading view on app startup, however, in my DataStore I was setting it's fetching variable on every function call it performs. When that value changed, the view stack behind my sheet view would re-render the LoadingView and then my TabView once the fetching variable changed again. This was making the sheet view un-dismissable. Here's an example of what my AppView looked like:
struct AppView: View {
#State private var fetchMessage: String = ""
#EnvironmentObject var store: DataStore()
func initializeApp() {
self.fetchMessage = "Getting App Data"
store.getData() { object, error in
if let error = error {
self.fetchMessage = error.localizedDescription
}
self.fetchMessage = ""
}
}
var body: some View {
Group {
ZStack {
//this is where my issue was occurring
if(!store.fetching) {
TabView {
Tab1().tabItem {
Image(systemName: "tab-1")
Text("Tab1")
}
Tab2().tabItem {
Image(systemName: "tab-2")
Text("Tab2")
}
//Tab 3 contained my ViewWithSheet() and SheetView()
Tab3().tabItem {
Image(systemName: "tab-3")
Text("Tab3")
}
}
} else {
LoadingView(loadingMessage: $fetchMessage)
}
}
}.onAppear(perform: initializeApp)
}
}
To solve my issue, I added another variable to my DataStore called initializing, which I use to render the loading screen or the actual application views on first .onAppear event in my app. Below is an example of what my updated AppView looks like:
struct AppView: View {
#State private var fetchMessage: String = ""
#EnvironmentObject var store: DataStore()
func initializeApp() {
self.fetchMessage = "Getting App Data"
store.getData() { object, error in
if let error = error {
self.fetchMessage = error.localizedDescription
}
self.fetchMessage = ""
//set the value to false once I'm done getting my app's initial data.
self.store.initializing = false
}
}
var body: some View {
Group {
ZStack {
//now using initializing instead
if(!store.initializing) {
TabView {
Tab1().tabItem {
Image(systemName: "tab-1")
Text("Tab1")
}
Tab2().tabItem {
Image(systemName: "tab-2")
Text("Tab2")
}
//Tab 3 contained my ViewWithSheet() and SheetView()
Tab3().tabItem {
Image(systemName: "tab-3")
Text("Tab3")
}
}
} else {
LoadingView(loadingMessage: $fetchMessage)
}
}
}.onAppear(perform: initializeApp)
}
}
Try to do this on main queue explicitly
func create() {
store.createObject() { object, error in
if let _ = object {
DispatchQueue.main.async {
self.showingSheetView = false
}
}
// think also about feedback on else case as well !!
}
}
Want to see something hacky that worked for me? Disclaimer: Might not work for you and I don't necessarily recommend it. But maybe it'll help someone in a pinch.
If you add a NavigationLink AND keep your fullScreenCover, then the fullscreen cover will be able to dismiss itself like you expect.
Why does this happen when you add the NavigationLink to your View? I don't know. My guess is it creates an extra reference somewhere.
Add this to your body, and keep your sheet as it is:
NavigationLink(destination: YOURVIEW().environmentObjects(), isActive: $showingSheetView) {}

SwiftUI: Using #Binding to dismiss a modal view not working

I'm passing a #State var down a few views, using #Binding on the child views and when I ultimately set the variable to back to false, sometimes my view doesn't dismiss.
It seems like I can run articleDisplayed.toggle() but if I run an additional function above or below, it won't work.
Any idea what's going on here?
Here's my code:
struct HomeView: View {
#EnvironmentObject var state: AppState
#State var articleDisplayed = false
// MARK: - Body
var body: some View {
NavigationView {
ZStack {
List {
ForEach(state.cards, id: \.id) { card in
Button(action: {
self.articleDisplayed = true // I set it to true here
self.state.activeCard = card
}) {
HomeCell(
card: card,
publicationColor: self.state.publication.brandColor
)
}.sheet(isPresented: self.$articleDisplayed) {
SafariQuickTopicView(articleDisplayed: self.$articleDisplayed)
.environmentObject(self.state)
.environment(\.colorScheme, .light)
}
}
}
}
}
}
}
Then in my SafariQuickTopicView:
struct SafariQuickTopicView: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#EnvironmentObject var state: AppState
#Binding var articleDisplayed: Bool
var body: some View {
NavigationView {
ZStack(alignment: .bottom) {
// doesn't matter what's in here
}
.navigationBarItems(trailing: passButton)
}
}
private var passButton: some View {
Button(action: self.state.pass {
DispatchQueue.main.async {
// self.state.removeActiveCardFromState()
self.articleDisplayed.toggle() // this will work but adding a second function in here prevents it from working, above or below the toggle.
}
}
}) {
Text("Pass")
}
}
Finally, in my AppState:
func pass(completion: () -> Void) { // need completion?
guard let activeCard = activeCard else { return }
if let index = cards.firstIndex(where: { $0.id == activeCard.id }) {
activeCard.add(comment: "pass")
rejectCurrentCard() // Does an async operation with an external API but we don't care about the result
addRemovedActiveCardToUserDefaults()
completion()
}
}
Move .sheet out of List, it must be one per view hierarchy, so like
List {
ForEach(state.cards, id: \.id) { card in
Button(action: {
self.articleDisplayed = true // I set it to true here
self.state.activeCard = card
}) {
HomeCell(
card: card,
publicationColor: self.state.publication.brandColor
)
}
}
}
.sheet(isPresented: self.$articleDisplayed) {
SafariQuickTopicView(articleDisplayed: self.$articleDisplayed)
.environmentObject(self.state)
.environment(\.colorScheme, .light)
}

Resources