Deep link onChange not triggered in SwiftUI sheet - ios

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

Related

SwiftUI view parameter does not update as expected

I am curious why this .fullScreenCover display of a view does not update properly with a passed-in parameter unless the parameter is using the #Binding property wrapper. Is this a bug or intended behavior? Is this the fact that the view shown by the fullScreenCover is not lazily generated?
import SwiftUI
struct ContentView: View {
#State private var showFullScreen = false
#State private var message = "Initial Message"
var body: some View {
VStack {
Button {
self.message = "new message"
showFullScreen = true
} label: {
Text("Show Full Screen")
}
}.fullScreenCover(isPresented: $showFullScreen) {
TestView(text: message)
}
}
}
struct TestView: View {
var text: String
var body: some View {
Text(text)
}
}
There is a different fullScreenCover for passing in dynamic data, e.g.
import SwiftUI
struct CoverData: Identifiable {
var id: String {
return message
}
let message: String
}
struct FullScreenCoverTestView: View {
#State private var coverData: CoverData?
var body: some View {
VStack {
Button {
coverData = CoverData(message: "new message")
} label: {
Text("Show Full Screen")
}
}
.fullScreenCover(item: $coverData, onDismiss: didDismiss) { item in
TestView(text: item.message)
.onTapGesture {
coverData = nil
}
}
}
func didDismiss() {
// Handle the dismissing action.
}
}
struct TestView: View {
let text: String
var body: some View {
Text(text)
}
}
More info and an example in the docs:
https://developer.apple.com/documentation/SwiftUI/AnyView/fullScreenCover(item:onDismiss:content:)

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

.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: 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)
}

SwiftUI NavigationLink loads destination view immediately, without clicking

With following code:
struct HomeView: View {
var body: some View {
NavigationView {
List(dataTypes) { dataType in
NavigationLink(destination: AnotherView()) {
HomeViewRow(dataType: dataType)
}
}
}
}
}
What's weird, when HomeView appears, NavigationLink immediately loads the AnotherView. As a result, all AnotherView dependencies are loaded as well, even though it's not visible on the screen yet. The user has to click on the row to make it appear.
My AnotherView contains a DataSource, where various things happen. The issue is that whole DataSource is loaded at this point, including some timers etc.
Am I doing something wrong..? How to handle it in such way, that AnotherView gets loaded once the user presses on that HomeViewRow?
The best way I have found to combat this issue is by using a Lazy View.
struct NavigationLazyView<Content: View>: View {
let build: () -> Content
init(_ build: #autoclosure #escaping () -> Content) {
self.build = build
}
var body: Content {
build()
}
}
Then the NavigationLink would look like this. You would place the View you want to be displayed inside ()
NavigationLink(destination: NavigationLazyView(DetailView(data: DataModel))) { Text("Item") }
EDIT: See #MwcsMac's answer for a cleaner solution which wraps View creation inside a closure and only initializes it once the view is rendered.
It takes a custom ForEach to do what you are asking for since the function builder does have to evaluate the expression
NavigationLink(destination: AnotherView()) {
HomeViewRow(dataType: dataType)
}
for each visible row to be able to show HomeViewRow(dataType:), in which case AnotherView() must be initialized too.
So to avoid this a custom ForEach is necessary.
import SwiftUI
struct LoadLaterView: View {
var body: some View {
HomeView()
}
}
struct DataType: Identifiable {
let id = UUID()
var i: Int
}
struct ForEachLazyNavigationLink<Data: RandomAccessCollection, Content: View, Destination: View>: View where Data.Element: Identifiable {
var data: Data
var destination: (Data.Element) -> (Destination)
var content: (Data.Element) -> (Content)
#State var selected: Data.Element? = nil
#State var active: Bool = false
var body: some View {
VStack{
NavigationLink(destination: {
VStack{
if self.selected != nil {
self.destination(self.selected!)
} else {
EmptyView()
}
}
}(), isActive: $active){
Text("Hidden navigation link")
.background(Color.orange)
.hidden()
}
List{
ForEach(data) { (element: Data.Element) in
Button(action: {
self.selected = element
self.active = true
}) { self.content(element) }
}
}
}
}
}
struct HomeView: View {
#State var dataTypes: [DataType] = {
return (0...99).map{
return DataType(i: $0)
}
}()
var body: some View {
NavigationView{
ForEachLazyNavigationLink(data: dataTypes, destination: {
return AnotherView(i: $0.i)
}, content: {
return HomeViewRow(dataType: $0)
})
}
}
}
struct HomeViewRow: View {
var dataType: DataType
var body: some View {
Text("Home View \(dataType.i)")
}
}
struct AnotherView: View {
init(i: Int) {
print("Init AnotherView \(i.description)")
self.i = i
}
var i: Int
var body: some View {
print("Loading AnotherView \(i.description)")
return Text("hello \(i.description)").onAppear {
print("onAppear AnotherView \(self.i.description)")
}
}
}
I had the same issue where I might have had a list of 50 items, that then loaded 50 views for the detail view that called an API (which resulted in 50 additional images being downloaded).
The answer for me was to use .onAppear to trigger all logic that needs to be executed when the view appears on screen (like setting off your timers).
struct AnotherView: View {
var body: some View {
VStack{
Text("Hello World!")
}.onAppear {
print("I only printed when the view appeared")
// trigger whatever you need to here instead of on init
}
}
}
For iOS 14 SwiftUI.
Non-elegant solution for lazy navigation destination loading, using view modifier, based on this post.
extension View {
func navigate<Value, Destination: View>(
item: Binding<Value?>,
#ViewBuilder content: #escaping (Value) -> Destination
) -> some View {
return self.modifier(Navigator(item: item, content: content))
}
}
private struct Navigator<Value, Destination: View>: ViewModifier {
let item: Binding<Value?>
let content: (Value) -> Destination
public func body(content: Content) -> some View {
content
.background(
NavigationLink(
destination: { () -> AnyView in
if let value = self.item.wrappedValue {
return AnyView(self.content(value))
} else {
return AnyView(EmptyView())
}
}(),
isActive: Binding<Bool>(
get: { self.item.wrappedValue != nil },
set: { newValue in
if newValue == false {
self.item.wrappedValue = nil
}
}
),
label: EmptyView.init
)
)
}
}
Call it like this:
struct ExampleView: View {
#State
private var date: Date? = nil
var body: some View {
VStack {
Text("Source view")
Button("Send", action: {
self.date = Date()
})
}
.navigate(
item: self.$date,
content: {
VStack {
Text("Destination view")
Text($0.debugDescription)
}
}
)
}
}
I was recently struggling with this issue (for a navigation row component for forms), and this did the trick for me:
#State private var shouldShowDestination = false
NavigationLink(destination: DestinationView(), isActive: $shouldShowDestination) {
Button("More info") {
self.shouldShowDestination = true
}
}
Simply wrap a Button with the NavigationLink, which activation is to be controlled with the button.
Now, if you're to have multiple button+links within the same view, and not an activation State property for each, you should rely on this initializer
/// Creates an instance that presents `destination` when `selection` is set
/// to `tag`.
public init<V>(destination: Destination, tag: V, selection: Binding<V?>, #ViewBuilder label: () -> Label) where V : Hashable
https://developer.apple.com/documentation/swiftui/navigationlink/3364637-init
Along the lines of this example:
struct ContentView: View {
#State private var selection: String? = nil
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: Text("Second View"), tag: "Second", selection: $selection) {
Button("Tap to show second") {
self.selection = "Second"
}
}
NavigationLink(destination: Text("Third View"), tag: "Third", selection: $selection) {
Button("Tap to show third") {
self.selection = "Third"
}
}
}
.navigationBarTitle("Navigation")
}
}
}
More info (and the slightly modified example above) taken from https://www.hackingwithswift.com/articles/216/complete-guide-to-navigationview-in-swiftui (under "Programmatic navigation").
Alternatively, create a custom view component (with embedded NavigationLink), such as this one
struct FormNavigationRow<Destination: View>: View {
let title: String
let destination: Destination
var body: some View {
NavigationLink(destination: destination, isActive: $shouldShowDestination) {
Button(title) {
self.shouldShowDestination = true
}
}
}
// MARK: Private
#State private var shouldShowDestination = false
}
and use it repeatedly as part of a Form (or List):
Form {
FormNavigationRow(title: "One", destination: Text("1"))
FormNavigationRow(title: "Two", destination: Text("2"))
FormNavigationRow(title: "Three", destination: Text("3"))
}
In the destination view you should listen to the event onAppear and put there all code that needs to be executed only when the new screen appears. Like this:
struct DestinationView: View {
var body: some View {
Text("Hello world!")
.onAppear {
// Do something important here, like fetching data from REST API
// This code will only be executed when the view appears
}
}
}

Resources