SwiftUI Navigation Link embedded in a Button with an action - swift

So I've made a Button view in my body and I have set it's action to a Google Sign In action, but I want it to also transition to a new view when the sign in flow is completed. The problem is that I have set the label of the button to a Navigation Link and when I click it, it directly transitions to a next view. How can I delay the transition? For context, VoucherView is the next view I want to transition to.
Button {
guard let clientID = FirebaseApp.app()?.options.clientID else { return }
// Create Google Sign In configuration object.
let config = GIDConfiguration(clientID: clientID, serverClientID: GlobalConstants.BACKEND_SERVER_REQUEST_ID_TOKEN)
guard let presenter = CommonHelper.GetRootViewController() else {return}
// Start the sign in flow!
GIDSignIn.sharedInstance.signIn(with: config, presenting: presenter) {user, error in
if let error = error {
print (error.localizedDescription)
return
}
guard
let authentication = user?.authentication,
let idToken = authentication.idToken
else {
print ("Something went wrong!")
return
}
print (authentication)
print (idToken)
let credential = GoogleAuthProvider.credential(withIDToken: idToken,
accessToken: authentication.accessToken)
print (credential)
UserManager.shared.firebaseAuthentication(credential: credential)
signed = true
}
} label: {
NavigationLink {
VoucherView()
} label: {
Text("Sign In")
}
}
Edit: After I tried using isValid as a #State variable, after every cycle of SignIn and SignOut the screen goes down.
First SignIn
FirstSignOut
SecondSignIn
SecondSignOut

Instead of using NavigationLink inside your Button, you can use a NavigationLink with an EmptyView for a label and then activate it programmatically with the isActive parameter.
See the following example -- the Google Sign In process is replaced by just a simple async closure for brevity.
struct ContentView: View {
#State private var navigateToNextView = false
var body: some View {
NavigationView {
VStack {
Button {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
navigateToNextView = true
}
} label: {
Text("Sign in")
}
NavigationLink(destination: View2(), isActive: $navigateToNextView) { EmptyView()
}
}
}.navigationTitle("Test")
}
}
struct View2 : View {
var body: some View {
Text("Next view")
}
}
A couple more notes:
Note that there has to be a NavigationView (make sure it's just one) in the hierarchy for this to work
In general, async work is better done in an ObservableObject than in a View. You can consider moving your login function to an ObservableObject and making the navigateToNextView a #Published property on that object.

Related

How to change State variables one after another in SwiftUI?

I have a Menu with a few buttons. Each button, represents an URL.
On selecting one of the buttons I want to present a webView loading the said URL using .fullScreenCover(isPresented:)
#State private var showWebPage = false
#State private var urlToLoad = ""
...
View()
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button("FAQ", action: {
presentWebView(for: "https://example.com/faqsLink")
})
Button("Privacy Policy", action: {
presentWebView(for: "https://example.com/privacyLink")
})
Button("Terms and Conditions", action: {
presentWebView(for: "https://example.com/termsLink")
})
}
}
}
.fullScreenCover(isPresented: $showWebPage) {
WebView(url: URL(string: urlToLoad)!)
}
private func presentWebView(for url: String) {
urlToLoad = url
showWebPage.toggle()
}
Everytime I try this, urlToLoad is still empty when I toggle showWebPage
I feel it has to do with how #State works but can't figure it out, I'm still new to SwiftUI.
with the hint from Delano I managed to come up with this:
.fullScreenCover(isPresented: $showWebPage) {
WebView(url: URL(string: urlToLoad)!)
.withCloseButton(and: "FAQ")
}
.onChange(of: urlToLoad) { value in
log.info (value)
if !value.isEmpty {
showWebPage.toggle()
}
}
and in the button action I just update the value of urlToLoad
Hope this helps out somebody, I spent more time on this than I ever thought a Menu needed.

Best way to make Paywall for document-based SwiftUI apps

My app displays a Paywall right away. Everything should be working fine as of my tests, but every time I upload the app update to App Store connect, they tell me that there is a problem: Users are not able to get access to premium features after they purchase the in-app subscription (i.e. the Paywall is not gone away after a successful purchase).
My approach to grant access to users, is as following:
1- Check user access in the main app view:
If the user has no access, a Paywall modal sheet should be shown to prevent users from using the app, and provide subscription options. Otherwise, if the user already has purchased subscription, he will be granted access and app won't show him the Paywall modal sheet.
Assuming that I'm using RevenueCat SDK instead of StoreKit to process in-app purchases, the checking process will be inside .onAppear of ContentView:
#main
struct MyApp: App {
var body: some Scene {
DocumentView()
}
}
struct DocumentView: Scene {
#State var showModal = false
var body: some Scene {
DocumentGroup(newDocument: Document()) { file in
ContentView(document: file.$document, showModal: $showModal)
.onAppear() {
Purchases.shared.getCustomerInfo { customerInfo, error in
// Check the info parameter for active entitlements
if customerInfo?.entitlements[Constant.entitlementId]?.isActive == true {
// Grant access to user
self.showModal = false
UserDefaults.standard.setValue(true, forKey: Constant.userDefaultsAccess)
} else {
// Revoke access from user
self.showModal = true
UserDefaults.standard.setValue(false, forKey: Constant.userDefaultsAccess)
}
}
}
}
}
}
2- Show the content view with/without Paywall modal:
Now, in ContentView, I am displaying the modal sheet only if showModal is true (i.e. if user has NOT been granted access).
In addition, I am using another variable: selection, because there might be other modal sheets to display (like About, Info, etc.).
Furthermore, the selection could be Pro to show the Paywall modal sheet along with X image button in the corner, or NoPro to show it without X image button.
struct ContentView: View {
#State private var selection: String? = "NoPro"
#Binding var showModal: Bool
var body: some View {
NavigationView {
VStack {
....
}
}
.navigationBarItems(
trailing:
HStack {
Button(action: {
self.selection = "Pro"
self.showModal.toggle()
}, label: {
Image(systemName: UserDefaults.standard.bool(forKey: Constant.userDefaultsAccess) ? "checkmark.seal.fill" : "xmark.seal.fill")
})
.foregroundColor(UserDefaults.standard.bool(forKey: Constant.userDefaultsAccess) ? .green : .red)
.sheet(isPresented: $showModal) {
if self.selection == "Pro" {
Paywall(showModal: self.$showModal, pro: "Pro")
} else if self.selection == "NoPro" {
Paywall(showModal: self.$showModal, pro: "NoPro")
}
}
}
}
3- In the Paywall, when user purchases, hide the modal sheet:
This is the third layer of the app, the Paywall modal sheet.
Here, the user will be able to purchase the subscription, and if he do so, the Paywall should disappear automatically.
In addition, if the user has opened the Paywall modal sheet while he is already subscribed (for example, to see subscription info), he will be able to close the Paywall by tapping on the X image button, or by swiping down.
struct Paywall: View {
#Binding var showModal: Bool
#State var pro: String
var body: some View {
VStack {
....
// If user has access, show a closing X image button
if pro == "Pro" {
HStack {
Spacer()
VStack {
Image(systemName: "xmark.circle.fill")
.font(.largeTitle)
.padding()
.onTapGesture(count: 1, perform: {
self.showModal.toggle()
})
Spacer()
}
}
}
SubscriptionButton(showModal: $showModal)
}
.interactiveDismissDisabled(pro == "Pro" ? false : true)
}
}
struct SubscriptionButton: View {
#Binding var showModal: Bool
var body: some View {
if UserDefaults.standard.bool(forKey: Constant.userDefaultsAccess) {
Text("You are already Subscribed!")
} else {
Button(action: {
PurchaseManager.purchase(productId: Constant.productId) {
UserDefaults.standard.setValue(true, forKey: Constant.userDefaultsAccess)
showModal = false
}
}, label: {
VStack {
Text("Subscribe now!")
}
})
}
}
}
Is there anything wrong with this approach? What's the best way to do it?

NavigationLink pushes twice, then pops once

I have a login screen on which I programmatically push to the next screen using a hidden NavigationLink tied to a state variable. The push works, but it seems to push twice and pop once, as you can see on this screen recording:
This is my view hierarchy:
App
NavigationView
LaunchView
LoginView
HomeView
App:
var body: some Scene {
WindowGroup {
NavigationView {
LaunchView()
}
.navigationBarBackButtonHidden(true)
.navigationBarHidden(true)
.environmentObject(cache)
}
}
LaunchView:
struct LaunchView: View {
#EnvironmentObject var cache: API.Cache
#State private var shouldPush = API.shared.accessToken == nil
func getUser() {
[API call to get user, if already logged in](completion: { user in
if let user = user {
// in our example, this is NOT called
// thus `cache.user.hasData` remains `false`
cache.user = user
}
shouldPush = true
}
}
private var destinationView: AnyView {
cache.user.hasData
? AnyView(HomeView())
: AnyView(LoginView())
}
var body: some View {
if API.shared.accessToken != nil {
getUser()
}
return VStack {
ActivityIndicator(style: .medium)
NavigationLink(destination: destinationView, isActive: self.$shouldPush) {
EmptyView()
}.hidden()
}
.navigationBarTitle("")
.navigationBarHidden(true)
}
}
This is a cleaned version of my LoginView:
struct LoginView: View {
#EnvironmentObject var cache: API.Cache
#State private var shouldPushToHome = false
func login() {
[API call to get user](completion: { user in
self.cache.user = user
self.shouldPushToHome = true
})
}
var body: some View {
VStack {
ScrollView(showsIndicators: false) {
// labels
// textfields
// ...
PrimaryButton(title: "Anmelden", action: login)
NavigationLink(destination: HomeView(), isActive: self.$shouldPushToHome) {
EmptyView()
}.hidden()
}
// label
// signup button
}
.navigationBarTitle("")
.navigationBarHidden(true)
}
}
The LoginView itself is child of a NavigationView.
The HomeView is really simple:
struct HomeView: View {
#EnvironmentObject var cache: API.Cache
var body: some View {
let user = cache.user
return Text("Hello, \(user.contactFirstname ?? "") \(user.contactLastname ?? "")!")
.navigationBarTitle("")
.navigationBarHidden(true)
}
}
What's going wrong here?
Update:
I've realized that the issue does not occur, when I replace LaunchView() in App with LoginView() directly. Not sure how this is related...
Update 2:
As Tushar pointed out below, replacing destination: destinationView with destination: LoginView() fixes the problem – but obviously lacks required functionality.
So I played around with that and now understand what's going on:
LaunchView is rendered
LaunchView finds there's no user data yet, so pushes to LoginView
upon user interaction, LoginView pushes to HomeView
at this point, the NavigationLink inside LaunchView is called again (idk why but a breakpoint showed this), and since there is user data now, it renders the HomeView instead of the LoginView.
That's why we see only one push animation, and the LoginView becoming the HomeView w/o any push animation, b/c it's replaced, essentially.
So now the objective is preventing LaunchView's NavigationLink to re-render its destination view.
I was finally able to resolve the issue thanks to Tushar's help in the comments.
Problem
The main problem lies in the fact I didn't understand how the environment object triggers re-renders. Here's what was going on:
LaunchView has the environment object cache, which is changed in LoginView, when we set cache.user = user.
That triggers the LaunchView to re-render its body.
since the access token is not nil after login, on each re-render, the user would be fetched from the API via getUser().
disregarding the fact whether that api call yields a valid user, shouldPush is set to true
LaunchView's body is rendered again and the destinationView is computed again
since now the user does have data, the computed view becomes HomeView
This is why we see the LoginView becoming the HomeView w/o any push – it's being replaced.
at the same time, the LoginView pushes to HomeView, but since that view is already presented, it pops back to its first instance
Solution
To fix this, we need to make the property not computed, so that it only changes when we want it to. To do so, we can make it a state-managed variable and set it manually in the response of the getUser api call:
Excerpt from LaunchView:
// default value is `LoginView`, we could also
// set that in the guard statement in `getUser`
#State private var destinationView = AnyView(LoginView())
func getUser() {
// only fetch if we have an access token
guard API.shared.accessToken != nil else {
return
}
API.shared.request(User.self, for: .user(action: .get)) { user, _, _ in
cache.user = user ?? cache.user
shouldPush = true
// manually assign the destination view based on the api response
destinationView = cache.user.hasData
? AnyView(HomeView())
: AnyView(LoginView())
}
}
var body: some View {
// only fetch if user hasn't been fetched
if cache.user.hasData.not {
getUser()
}
return [all the views]
}

Press SwiftUI button and go to the next screen (next view) when server callback

I stuck at the pretty simple step when I want to press on the button show loading indicator and if server returns success response then show new view
It's pretty straightforward in UIKit, but with SwiftUI I stuck to do this.
I need to know how to init/add activity indicator I found some cool examples here. Can I just store it as a let variable in my view sruct?
Then by pressing button Unhide/Animate indicator
Make a server request via my rest api service
Wait some time and show new view on success callback or error message.
Nothing super hard, but I stuck here is a button which is a part of my NavigationView. Please help me push to new screen.
Button(action: {
// show indicator or animate
// call rest api service
// wait for callback and show next view or error alert
})
I found some link but not sure how to use it right.
Not sure I need PresentationButton or NavigationLink at all as I already have a simple Button and want to kind of push new view controller.
Very similar question to this one but I have not find it useful as I don't know how to use step by step how to "Create hidden NavigationLink and bind to that state"
EDITED:
I also found this video answer looks like I figure out how to do navigation. But still need to figure out how to show activity indicator when button pressed.
To show anything you need at some point in SwiftUI, simply use a #State variable.
You can use as many of these Bool as needed. You can toggle a new view, animation...
Example
#State var showNextView = false
#State var showLoadingAnimation = false
Button(action: {
self.showLoadingAnimation.toggle()
self.makeApiCall()
}) {
Text("Show next view on api call success")
}
// Method that handle your api call
func makeApiCall() {
// Your api call
if success {
showLoadingAnimation = false
showNextView = true
}
}
As for the animation, I would suggest the use the Lottie framework. You can find some really cool animations:
https://github.com/airbnb/lottie-ios
You can find many animations here:
https://lottiefiles.com
And you can create a class to implement your Lottie animation via a JSON file that you dropped in your project:
import SwiftUI
import Lottie
struct LottieRepresentable: UIViewRepresentable {
let named: String // name of your lottie file
let loop: Bool
func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
let animationView = AnimationView()
let animation = Animation.named(named)
animationView.animation = animation
animationView.contentMode = .scaleAspectFit
if loop { animationView.loopMode = .loop }
animationView.play()
animationView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(animationView)
NSLayoutConstraint.activate([
animationView.widthAnchor.constraint(equalTo: view.widthAnchor),
animationView.heightAnchor.constraint(equalTo: view.heightAnchor)
])
return view
}
func updateUIView(_ uiView: UIView, context: Context) { }
}
Create a SwiftUI file to use your lottie animation in your code:
// MARK: - Show LottieRespresentable as view
struct LottieView: View {
let named: String
let loop: Bool
let size: CGFloat
var body: some View {
VStack {
LottieRepresentable(named: named, loop: loop)
.frame(width: size, height: size)
}
}
}
So the final code would look like this with a NavigationLink, and you will have your loader starting at the beginning of your api call, and ending when api call succeeds:
import SwiftUI
//MARK: - Content view
struct ContentView: View {
#State var showMessageView = false
#State var loopAnimation = false
var body: some View {
NavigationView {
ZStack {
NavigationLink(destination: MessageView(),
isActive: $showMessageView) {
Text("")
VStack {
Button(action: {
self.loopAnimation.toggle()
self.makeApiCall()
}) {
if self.loopAnimation {
Text("")
}
else {
Text("Submit")
}
}
}
if self.loopAnimation {
LottieView(named: "Your lottie json file name",
loop: self.loopAnimation,
size: 50)
}
}
.navigationBarTitle("Content View")
}
}
}
func makeApiCall() {
// your api call
if success {
loopAnimation = false
showMessageView = true
}
}
}

SwiftUI - Form with error message on button press and navigation

I have the following scenario. I have a text field and a button, what I would need is to show an error message in case the field is empty and if not, navigate the user to the next screen.
I have tried showing the error message conditionally by using the field value and checking if it is empty on button press, but then, I don't know how to navigate to the next screen.
struct SomeView: View {
#State var fieldValue = ""
#State var showErrorMessage = false
var body: some View {
NavigationView {
VStack {
TextField("My Field", text: $fieldValue).textFieldStyle(RoundedBorderTextFieldStyle())
if showErrorMessage {
Text("Error, please enter value")
}
Button(action: {
if self.fieldValue.isEmpty {
self.showErrorMessage = true
} else {
self.showErrorMessage = false
//How do I put navigation here, navigation link does not work, if I tap, nothing happens
}
}) {
Text("Next")
}
}
}
}
}
Using UIKit would be easy since I could use self.navigationController.pushViewController
Thanks to part of an answer here, here's some working code.
First, I moved everything into an EnvronmentObject to make things easier to pass to your second view. I also added a second toggle variable:
class Model: ObservableObject {
#Published var fieldValue = ""
#Published var showErrorMessage = false
#Published var showSecondView = false
}
Next, change two things in your ContentView. I added a hidden NavigationLink (with a isActive parameter) to actually trigger the push, along with changing your Button action to execute a local function:
struct ContentView: View {
#EnvironmentObject var model: Model
var body: some View {
NavigationView {
VStack {
TextField("My Field", text: $model.fieldValue).textFieldStyle(RoundedBorderTextFieldStyle())
NavigationLink(destination: SecondView(), isActive: $model.showSecondView) {
Text("NavLink")
}.hidden()
Button(action: {
self.checkForText()
}) {
Text("Next")
}
.alert(isPresented: self.$model.showErrorMessage) {
Alert(title: Text("Error"), message: Text("Please enter some text!"), dismissButton: .default(Text("OK")))
}
}
}
}
func checkForText() {
if model.fieldValue.isEmpty {
model.showErrorMessage.toggle()
} else {
model.showSecondView.toggle()
}
}
}
Toggling showErrorMessage will show the Alert and toggling `showSecondView will take you to the next view.
Finally, the second view:
struct SecondView: View {
#EnvironmentObject var model: Model
var body: some View {
ZStack {
Rectangle().fill(Color.green)
// workaround
.navigationBarBackButtonHidden(true) // not needed, but just in case
.navigationBarItems(leading: MyBackButton(label: "Back!") {
self.model.showSecondView = false
})
Text(model.fieldValue)
}
}
func popSecondView() {
model.showSecondView.toggle()
}
}
struct MyBackButton: View {
let label: String
let closure: () -> ()
var body: some View {
Button(action: { self.closure() }) {
HStack {
Image(systemName: "chevron.left")
Text(label)
}
}
}
}
This is where the above linked answer helped me. It appears there's a bug in navigation back that still exists in beta 6. Without this workaround (that toggles showSecondView) you will get sent back to the second view one more time.
You didn't post any details on the second view contents, so I took the liberty to add someText into the model to show you how to easily pass things into it can be using an EnvironmentObject. There is one bit of setup needed to do this in SceneDelegate:
var window: UIWindow?
var model = Model()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let contentView = ContentView()
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView.environmentObject(model))
self.window = window
window.makeKeyAndVisible()
}
}
I noticed a slight change in this, depending on when your project was created (beta 6 declares an instance of contentView where older versions do not). Either way, declare an instance of model and then add the envoronmentObject modifier to contentView.
Another approach is to make the "Next" button conditionally a Button when the fieldValue is empty and a NavigationLink when the fieldValue is valid. The Button case will trigger your error message view and the NavigationLink will do the navigation for you. Keeping this close to your sample, the following seems to do the trick.
struct SomeView: View {
#State var fieldValue = ""
#State var showErrorMessage = false
var body: some View {
NavigationView {
VStack {
TextField("My Field", text: $fieldValue).textFieldStyle(RoundedBorderTextFieldStyle())
if showErrorMessage {
Text("Please Enter Data")
}
if fieldValue == "" {
Button(action: {
if self.fieldValue == "" {
self.showErrorMessage = true
}
}, label: {
Text("Next")
})
} else {
// move on case
NavigationLink("Next", destination: Text("Next View"))
}
}
}
}
}
By using this code we can display the alert if the fields are empty else . it will navigate.
struct SomeView: View {
#State var userName = ""
#State var password = ""
#State var showErrorMessage = false
var body: some View {
NavigationView {
VStack {
TextField("Enter Username", text: $userName).textFieldStyle(RoundedBorderTextFieldStyle())
SecureField("Enter Your Password", text: $password)
.textFieldStyle(RoundedBorderTextFieldStyle())
if userName == "" || password == "" {
Button(action: {
if self.userName == "" || self.password == "" {
self.showErrorMessage = true
}
}, label: {
Text("Login")
})
} else {
// move case
NavigationLink("Login", destination: Text("Login successful"))
}
}.alert(isPresented: $showErrorMessage) { () -> Alert in
Alert(title: Text("Important Message"), message: Text("Please Fill all the Fields"), primaryButton: .default(Text("Ok")), secondaryButton: .destructive(Text("Cancel")))
}
}
}
}