.sheet in SwiftUI strange behaviour when passing a variable - ios

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 }
}

Related

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 }
}

How can I dynamically build a View for SwiftUI and present it?

I've included stubbed code samples. I'm not sure how to get this presentation to work. My expectation is that when the sheet presentation closure is evaluated, aDependency should be non-nil. However, what is happening is that aDependency is being treated as nil, and TheNextView never gets put on screen.
How can I model this such that TheNextView is shown? What am I missing here?
struct ADependency {}
struct AModel {
func buildDependencyForNextExperience() -> ADependency? {
return ADependency()
}
}
struct ATestView_PresentationOccursButNextViewNotShown: View {
#State private var aDependency: ADependency?
#State private var isPresenting = false
#State private var wantsPresent = false {
didSet {
aDependency = model.buildDependencyForNextExperience()
isPresenting = true
}
}
private let model = AModel()
var body: some View {
Text("Tap to present")
.onTapGesture {
wantsPresent = true
}
.sheet(isPresented: $isPresenting, content: {
if let dependency = aDependency {
// Never executed
TheNextView(aDependency: dependency)
}
})
}
}
struct TheNextView: View {
let aDependency: ADependency
init(aDependency: ADependency) {
self.aDependency = aDependency
}
var body: some View {
Text("Next Screen")
}
}
This is a common problem in iOS 14. The sheet(isPresented:) gets evaluated on first render and then does not correctly update.
To get around this, you can use sheet(item:). The only catch is your item has to conform to Identifiable.
The following version of your code works:
struct ADependency : Identifiable {
var id = UUID()
}
struct AModel {
func buildDependencyForNextExperience() -> ADependency? {
return ADependency()
}
}
struct ContentView: View {
#State private var aDependency: ADependency?
private let model = AModel()
var body: some View {
Text("Tap to present")
.onTapGesture {
aDependency = model.buildDependencyForNextExperience()
}
.sheet(item: $aDependency, content: { (item) in
TheNextView(aDependency: item)
})
}
}

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

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
}
}

Deep link onChange not triggered in SwiftUI sheet

I have an issue with deep links in my SwiftUI app.
In my app class I have declared deepLink as an environment variable for every View under ContentView() in the hierarchy:
...
#main
struct TestApp: App {
var userSettings: UserSettings
var dataFetcher: DataFetcher
var dataUpdater: DataUpdater
#State var deepLink = ""
init() {
userSettings = UserSettings()
dataFetcher = DataFetcher(userSettings: userSettings)
dataUpdater = DataUpdater(userSettings: userSettings)
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(userSettings)
.environmentObject(dataFetcher)
.environmentObject(dataUpdater)
.onOpenURL { url in
deepLink = url.absoluteString
}
.environment(\.deepLink, deepLink)
}
}
}
In my ContentView() I've declared deepLink as an environment variable
struct ContentView: View {
...
#State var isTestSheetViewPresented = false
#Environment(\.deepLink) var deepLink: String
...
var body: some View {
Button(action: {
self.isTestSheetViewPresented = true
}, label: {
HStack {
Spacer()
Image(systemName: "plus")
Text("Add")
Spacer()
}
})
.sheet(isPresented: $isTestSheetViewPresented, content: {
TestSheetView(isPresented: self.$isTestSheetViewPresented)
})
.onChange(of: self.deepLink) { _ in
self.isTestSheetViewPresented = true
}
}
}
And the TestSheetView is like this
struct TestSheetView: View {
#Environment(\.deepLink) var deepLink: String
#State var url: String = ""
var body: some View {
Text(url)
.onChange(of: deepLink) { _ in
if deepLink != "" {
self.url = deepLink
}
}
}
}
The problem is that when I click on a link, and my app opens, the TestSheetView is correctly presented but the onChange is not triggered unless I scroll a little bit down the sheet.
Instead if I put the same code of the TestSheetView in the ContentView then the text is correctly shown
Seems like a timing issue. While TestSheetView is being initialized, the body is created after deepLink changed, so it won't be able to detect it.
The solution is to use onAppear in TestSheetView and read from there, like so:
struct TestSheetView: View {
#Environment(\.deepLink) var deepLink: String
#State var url: String = ""
var body: some View {
Text(url)
.onAppear {
if deepLink != "" {
self.url = deepLink
}
}
}
}
It's unergonomic to handle both the case where the view is yet to appear, and the case where a link is being navigated within the view. The following view modifier handles both cases. It assumes an .onOpenURL() handler in the top level navigating view that sets both the current tab selection, along with the currentDeepLink environment value.
struct DeepLinkViewModifier: ViewModifier {
#Environment(\.currentDeepLink) private var currentDeepLink
let action: ((URL) -> Bool)
func body(content: Content) -> some View {
content
.onAppear {
if let url = currentDeepLink.wrappedValue,
action(url) {
currentDeepLink.wrappedValue = nil
}
}
.onOpenURL { url in
_ = action(url)
}
}
}
extension View {
func onDeepLink(perform action: #escaping ((URL) -> Bool)) -> some View {
return self.modifier(DeepLinkViewModifier(action: action))
}
}
Use:
struct SomeView: View {
#State urlString: String = ""
var body: some View {
Text(urlString).onDeepLink { url
self.string = url.absoluteString
return true // return false if another handler should consume
}
}
}
struct ContentView: View {
#State private var tabSelection: TabSelection = .something
#State private var currentDeepLink: URL? = nil
var body: some View {
TabView(selection: self.$tabSelection) {
...
}
.onOpenURL { url in
self.tabSelection = ... // determine selection from URL
self.currentDeepLink = url
}
.environment(\.tabSelection, self.$tabSelection)
.environment(\.currentDeepLink, self.$currentDeepLink)
}
}

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) {}

Resources