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?
Related
When you navigate and open the confirmation dialog. When you select Yes, No or Cancel the page that the app was on is dismissed and it takes you back to the form on the previous page.
We also found this happens with alerts too.
It's a simple enough app structure, top level tabs then a menu which links to sub pages.
Here is a quick demo of the bug:
We put together an example app that demonstrates this.
How can we prevent this from happening while also maintaining the app structure?
import SwiftUI
#main
struct testApp: App {
var body: some Scene {
WindowGroup {
NavigationView {
TabView() {
Form {
NavigationLink(destination: SubPage()) {
Image(systemName: "clock")
Text("Sub Page")
}
// This is where more menu options would be
}
.tag(1)
.tabItem {
Image(systemName: "square.grid.2x2")
Text("Tab 1")
}
// This is where more tab pages would be
}
}
}
}
}
struct SubPage: View {
#State private var confirmDialogVisible = false
var body: some View {
VStack {
Button{
confirmDialogVisible = true
} label: {
Text("popup")
}
}
.confirmationDialog("Confirm?", isPresented: $confirmDialogVisible) {
Button("Yes") {
print("yes")
}
Button("No", role: .destructive) {
print("no")
}
}
}
}
We are using XCode 14.1
And running on iOS 16.1
I usually use ViewModifer to keep consistency between tabs. One modifier for the root of the tab and one for the children.
///Modifier that uses `ToolbarViewModifier` and includes a `NavigationView`
struct NavigationViewModifier: ViewModifier{
func body(content: Content) -> some View {
NavigationView{
content
.modifier(ToolbarViewModifier())
}
}
}
///`toolbar` that can be used by the root view of the navigation
///and the children of the navigation
struct ToolbarViewModifier: ViewModifier{
let title: String = "Company Name"
func body(content: Content) -> some View {
content
.toolbar {
ToolbarItem(placement: .principal) {
VStack{
Image(systemName: "sparkles")
Text(title)
}
}
}
}
}
Then the Views use it something like this.
import SwiftUI
struct CustomTabView: View {
var body: some View {
TabView{
ForEach(0..<4){ n in
CustomChildView(title: n.description)
//Each tab gets a `NavigationView` and the shared toolbar
.modifier(NavigationViewModifier())
.tabItem {
Text(n.description)
}
}
}
}
}
struct CustomChildView: View {
let title: String
#State private var showConfirmation: Bool = false
var body: some View {
VStack{
Text(title)
NavigationLink {
CustomChildView(title: "\(title) :: \(UUID().uuidString)")
} label: {
Text("open child")
}
Button("show confirmation") {
showConfirmation.toggle()
}
.confirmationDialog("Confirm?", isPresented: $showConfirmation) {
Button("Yes") {
print("yes")
}
Button("No", role: .destructive) {
print("no")
}
}
}
//Each child uses the shared toolbar
.modifier(ToolbarViewModifier())
}
}
I stuck with NavigationView since that is what you have in your code but
if we take into consideration the new NavigationStack the possibilities of these two modifiers become exponentially better.
You can include custom back buttons that only appear if the path is not empty, return to the root from anywhere, etc.
Apple says that
Tab bars use bar items to navigate between mutually exclusive panes of content in the same view
Make sure the tab bar is visible when people navigate to different areas in your app
https://developer.apple.com/design/human-interface-guidelines/components/navigation-and-search/tab-bars/
Having the NavigationView or NavigationStack on top goes against these guidelines and therefore are the source of endless bugs, Especially when you take into consideration iPadOS.
Simple solution would be to use navigationDestination.
struct testApp: App {
#State var goToSubPage = false
var body: some Scene {
WindowGroup {
NavigationStack {
TabView() {
Form {
VStack {
Image(systemName: "clock")
Text("Sub Page")
}
.onTapGesture {
goToSubPage = true
}
// This is where more menu options would be
}
.navigationDestination(isPresented: $goToSubPage, destination: {
SubPage()
})
.tag(1)
.tabItem {
Image(systemName: "square.grid.2x2")
Text("Tab 1")
}
// This is where more tab pages would be
}
}
}
}
}
I tested it and it won't popped off itself anymore.
So basically in my SwiftUI app, there is a login screen and if you are already logged in then you move on to the HomeView(). If not, then you stay on the LoginView(). However, every time I open the app, the .fullScreenCover flickers before the .onAppear{} statement realizes it's time for the cover to disappear. Here is the code:
struct HomeView: View {
#ObservedObject var fireViewModel = FirebaseViewModel()
#State var loginPresented = true
var body: some View {
ZStack {
VStack {
Text("You are already Signed in")
Button(action: {
fireViewModel.signOut()
}, label: {
Text("Sign Out")
})
}
}
.onAppear {
if fireViewModel.signedIn {
loginPresented = true
} else {
loginPresented = false
}
}
.fullScreenCover(isPresented: $loginPresented, onDismiss: nil, content: {
LoginView()
})
}
}
Setting the
#State var loginPresented = false
In the beginning solves the problem.
I have a TabView with two tabs in a SwiftUI lifecycle app, one of them has complex view structure: NavigationView with a lot of sub-views inside, i.e.: NavigationLinks and their DestinationViews are spread on multiple levels down the view tree, each sub-view on its own is another view hierarchy with sheets and / or other DestinationViews. At some point inside this hierarchy, I want to reset the TabView to its original state which is displaying the first most view, so the user can restart their journey right at that state, as they were to open the app for the first time, so it's kinda impossible to track down something like isActive & isPresented bindings to pop-off or dismiss the views and sheets.
I thought of wrapping the TabView inside another view: RootView in an attempt to find an easy way to recreate that TabView from scratch or something like refreshing / resetting the TabView, but couldn't find a clew on how to do it.
Here's my code snippet:
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
RootView()
}
}
}
struct RootView: View {
var body: some View {
ContentView()
}
}
struct ContentView: View {
var body: some View {
TabView { // <-- I need to reset it to its original state
View1() // <---- this view has complex view hierarchy
.tabItem {
Text("Home")
}.tag(0)
View2()
.tabItem {
Text("Settings")
}.tag(1)
}
}
}
p.s. I'm not looking for "popping off the view to root view", because this can't be done when there are many active NavigationLink destinations where the user might open one of the sheets and start a new navigation journey inside the sheet.
****** UPDATE ******
I've created a new Environment value to hold a boolean that should indicate whether the TabView should reset or not, and I've tracked every isPresented and isActive state variables in every view and reset them once that environment value is set to true like this:
struct ResetTabView: EnvironmentKey {
static var defaultValue: Binding<ResetTabObservable> = .constant(ResetTabObservable())
}
extension EnvironmentValues {
var resetTabView: Binding<ResetTabObservable> {
get { self[ResetTabView.self] }
set { self[ResetTabView.self] = newValue }
}
}
class ResetTabObservable: ObservableObject {
#Published var newValue = false
}
in every view that will present a sheet or push a new view I added something like this:
struct View3: View {
#State var showSheet = false
#Environment(\.resetTabView) var reset
var body: some View {
Text("This is view 3")
Button(action: {
showSheet = true
}, label: {
Text("show view 4")
})
.sheet(isPresented: $showSheet) {
View4()
}
.onReceive(reset.$newValue.wrappedValue, perform: { val in
if val == true {
showSheet = false
}
})
}
}
and in the last view (which will reset the TabView) I toggle the Environment value like this:
struct View5: View {
#Environment(\.resetTabView) var reset
var body: some View {
VStack {
Text("This is view 5")
Button(action: {
reset.newValue.wrappedValue = true
}, label: {
Text("reset tab view")
})
}
}
}
This resulted in awkward dismissal for views:
What i do for this is i make all my presentation bindings be stored using #SceneStorage("key") (instead of #State) this way they not only respect state restoration ! but you can also access them throughout your app easily by using the same key. This post gives a good example of how this enables the switching from Tab to Sidebar view on iPad.
I used this in my apps so if i have a button or something that needs to unwind many presentations it can read on all of these values and reset them back to wanted value without needing to pass around a load of bindings.
I would like to have a modal sheet appear with several options for the user to choose from. (The share sheet is a perfect example.) When the user makes a selection, the option sheet disappears and a second sheet appears with the selected option. In the share sheet example, if the user selects print, the share sheet slides down and the print sheet pops up.
I can get the option sheet to appear easily enough. But I haven't figured out how to get the second sheet to appear. I tried attaching the sheet to an empty view and then used UserDefaults to set the bool that activates the second sheet. Nothing.
First Sheet
Button(action: {
UserDefaults.standard.set(true, forKey: showSelectedOption)
showOptionForm = true
}) {
Image(systemName: "square.and.arrow.up")
}
.sheet(isPresented: $showOptionForm) {
OptionView().environment(\.managedObjectContext, self.moc)
})
SecondSheet
EmptyView()
.sheet(isPresented: $showSelectedOption) {
SelectedOptionView().environment(\.managedObjectContext, self.moc)
}
I tried setting the bool shown below in .onAppear, but it does not get called when a modal sheet is dismissed. Is there a way to tell when a view is no longer being covered by a sheet? In UIKit it would have been presentationControllerDidDismiss(_:). Of course, this is assuming that my idea to attach the second sheet to an empty view is even workable.
let showSelectedOption = UserDefaults.standard.bool(forKey: "showSelectedOption")
Here is demo of possible approach - you activate second sheet in onDismiss of first one. Tested with Xcode 12 / iOS 14.
struct DemoTwoSheets: View {
#State private var firstSheet = false
#State private var secondSheet = false
var body: some View {
VStack {
Button("Tap") { self.firstSheet = true }
.sheet(isPresented: $firstSheet, onDismiss: {
self.secondSheet = true
}) {
Text("First sheet")
}
EmptyView()
.sheet(isPresented: $secondSheet) {
Text("Second sheet")
}
}
}
}
Update:
Here is an alternate which works for SwiftUI 1.0 as well. Tested with Xcode 11.4 / iOS 13.4 and Xcode 12b5 / iOS 14.
struct DemoTwoSheets: View {
#State private var firstSheet = false
#State private var secondSheet = false
var body: some View {
VStack {
Button("Tap") { self.firstSheet = true }
.sheet(isPresented: $firstSheet, onDismiss: {
self.secondSheet = true
}) {
Text("First sheet")
}
.background(Color.clear
.sheet(isPresented: $secondSheet) {
Text("Second sheet")
})
}
}
}
I can't prevent SwiftUI's NavigationLink from being activated when in a List, I have this simple piece of code in which I need to do some kind of business check before deciding to show the details page or not (in a real world app, there might be some business logic happens inside the button's action):
struct ContentView: View {
#State var showDetail = false
var body: some View {
NavigationView {
List {
Text("Text 1")
Text("Text 2")
Text("Text 3")
NavigationLink(destination: DetailView(), isActive: $showDetail) {
LinkView(showDetails: $showDetail)
}
}
}
}
}
struct LinkView: View {
#Binding var showDetails: Bool
var body: some View {
Button(action: {
self.showDetails = false
}) {
Text("Open Details")
}
}
}
struct DetailView: View {
var body: some View {
Text("Detail View")
}
}
how can I prevent navigation link from opening the details page in this case ? and is this a bug in the SDK ?
p.s. XCode version: 13.3.1 and iOS version (real device): 13.3.1
Edit
I can't replace List with ScrollView because I have a ForEach list of items in my real app, so don't post an answer considering using ScrollView.
in a real world app, there might be some business logic happens inside
the button's action
seems to be a little bit alogical.You can simply conditionally disable the link (and inform the user, that the link is unavailable by visual appearance)
NavigationLink(...).disabled(onCondition)
where
func disabled(_ disabled: Bool) -> some View
Parameters
disabled
A Boolean value that determines whether users can interact with this view.
Return Value
A view that controls whether users can interact with this view.
Discussion
The higher views in a view hierarchy can override the value you set on this view. In the following example, the button isn’t interactive because the outer disabled(_:) modifier overrides the inner one:
HStack {
Button(Text("Press")) {}
.disabled(false)
}
.disabled(true)
If I correctly understood your goal, it can be as follows
List {
Text("Text 1")
Text("Text 2")
Text("Text 3")
LinkView(showDetails: $showDetail)
.background(
NavigationLink(destination: DetailView(), isActive: $showDetail) { EmptyView() })
}
and
struct LinkView: View {
#Binding var showDetails: Bool
var body: some View {
Button(action: {
self.showDetails = true // < activate by some logic
}) {
Text("Open Details")
}
}
}
If you use .disable(true) it will reduce your list item opacity like a disabled button, to prevent this. use below code style. Use Navigation Link in backGround and check your navigation condition on Tap Gesture of your view.
VStack{
List(0..<yourListArray.count, id: \.self) { index in
{
Text("\(yourListArr[index].firstName)")
}().onTapGesture{
let jobType = getFlags(jobsArr: yourListArray, index:index)
if jobType.isCancelledFlag == true{
self.shouldNavigate = false
}else{
self.shouldNavigate = true
}
}//Tap Gesture End
.background(NavigationLink(destination: YourDestinationView(),isActive: self.$shouldNavigate) {
}.hidden())}}//vStack