I'm fairly new to SwiftUI and swift programming, and I have a question about how to modify an #AppStorage property.
I'm trying to program an app with an onboarding process, so that it shows several views to the user the first time the app is opened, and from then on, it goes straight to the primary view.
Here is my code in ContentView:
#AppStorage("onboardingView") var onboarding = true
var body: some View {
if onboarding {
Welcome()
}
else {
PrimaryView()
}
}
The Welcome() view is a NavigationView, and there's a series of views that it will take you through via navigation links. At the end of the chain, the last view has a button whose action is: UserDefaults.standard.set(false, forKey: "onboardingView").
I had expected this to work, since I read that #AppStorage writes to UserDefaults.standard, but the onboarding view appears every time.
How may I be able to make it so that the #AppStorage onboarding variable is set to false in a separate view?
Here: with Binding you can do it.
struct ContentView: View {
#AppStorage("onboardingView") var onboarding = true
var body: some View {
if onboarding {
WelcomeView(onboarding: $onboarding)
}
else {
PrimaryView(onboarding: $onboarding)
}
}
}
struct WelcomeView: View {
#Binding var onboarding: Bool
var body: some View {
VStack(spacing: 20.0) {
Text("WelcomeView").padding().background(Color.red).cornerRadius(10.0)
Button("update onboarding to true") { onboarding = true }
Button("update onboarding to false") { onboarding = false }
}
}
}
struct PrimaryView: View {
#Binding var onboarding: Bool
var body: some View {
VStack(spacing: 20.0) {
Text("PrimaryView").padding().background(Color.yellow).cornerRadius(10.0)
Button("update onboarding to true") { onboarding = true }
Button("update onboarding to false") { onboarding = false }
}
}
}
Try to put the same
#AppStorage("onboardingView") var onboarding
into that end-of-the-chain view and modify it directly
var body: some View {
...
onboarding = false
...
}
Note: make sure you modify this setting being on main queue
Related
I’m writing a workout app for Apple Watch but am running into issues with navigation. I have a version which includes the behaviour I’m looking for but am trying to simplify the navigation through the app which is leading to issues.
The “working” version of the app (code below) has the user select a workout from a launch page. When the workout is started, a tabbed view is displayed which contains controls on one page and metrics on another, cut down here for the purpose of demonstration. When End on the controls page is pressed, a boolean is set to true forcing a summary view to be displayed. When Done is pressed here, the view is dismissed returning the user to the launch page. A new workout can then be initiated.
As mentioned above, I’m trying to simplify the app, doing away with the workout selection and immediately leading to a start page. From there the functionality is basically the same. However, when I return to the start page, I am unable to initiate another workout without re-launching the app.
I’m just starting out on my SwiftUI journey so can only presume I’m misusing the NavigationStack or misunderstanding the concepts behind it but have not been able to see the issue.
Any assistance would be greatly appreciated.
Thanks much.
struct ContentView: View {
#StateObject private var workoutManager = WorkoutManager()
var body: some View {
NavigationStack {
List(Workout.workouts) { workout in
NavigationLink(value: workout) {
Text(workout.shortName)
}
}
.navigationDestination(for: Workout.self) { workout in
WorkoutSetupView()
}
.navigationDestination(isPresented: $workoutManager.showingSummaryView) {
SummaryView()
}
}
.environmentObject(workoutManager)
}
}
struct Workout: Identifiable, Hashable {
var id: String
var shortName: String
static var workouts: [Workout] {
[
Workout(id: "WORKOUT1", shortName: "Workout 1"),
Workout(id: "WORKOUT2", shortName: "Workout 2")
]
}
}
class WorkoutManager: NSObject, ObservableObject {
#Published var showingSummaryView: Bool = false
func endWorkout() {
showingSummaryView = true
}
}
struct SummaryView: View {
#Environment(\.dismiss) var dismiss
var body: some View {
ScrollView {
VStack{
Text("Results: 2")
Button("Done") {
dismiss()
}
}
}
.navigationTitle("Summary")
.navigationBarBackButtonHidden(true)
}
}
struct WorkoutSetupView: View {
var body: some View {
NavigationLink(
destination: SessionPagingView()) {
Image(systemName: "play")
}
}
}
struct SessionPagingView: View {
#EnvironmentObject var workoutManager: WorkoutManager
#State private var selection: Tab = .session
enum Tab {
case controls, session
}
var body: some View {
TabView(selection: $selection) {
Button {
workoutManager.endWorkout()
} label: {
Text("End")
}.tag(Tab.controls)
Text("0:10").tag(Tab.session)
}
.navigationBarBackButtonHidden(true)
}
}
If I replace the NavigationStack block in ContentView with the code below, the app works in a simplified way as expected but a new workout cannot be initiated without relaunching the app.
NavigationStack {
NavigationLink(
destination: SessionPagingView()) {
Image(systemName: "play")
}
.navigationDestination(isPresented: $workoutManager.showingSummaryView) {
SummaryView()
}
}
I've tried resetting showingSummaryView to false on pressing Done in SummaryView and also tried using a NavigationPath but neither had any noticeable effect.
Thanks
I have an app which presents a sheet. It works for the first time but when I click on it again it does not work. I am making isPresented false when you dismiss a sheet but when I tap on the Filter button again, it does not show the sheet.
ContentView
struct ContentView: View {
#State private var isPresented: Bool = false
var body: some View {
NavigationView {
List(1...20, id: \.self) { index in
Text("\(index)")
}.listStyle(.plain)
.navigationTitle("Hotels")
.toolbar {
Button("Filters") {
isPresented = true
}
}
.sheet(isPresented: $isPresented) {
isPresented = false
} content: {
FilterView()
}
}
}
}
FilterView:
import SwiftUI
struct FilterView: View {
#Environment(\.presentationMode) private var presentationMode
var body: some View {
ZStack {
Text("FilterView")
Button {
// action
presentationMode.wrappedValue.dismiss()
} label: {
Text("Dismiss")
}
}
}
}
struct FilterView_Previews: PreviewProvider {
static var previews: some View {
FilterView()
}
}
A couple of things to note from my experience.
Firstly, when using the isPresented binding to show a sheet, you don't need to reset the bound value in a custom onDismiss handler to reset it to false - that's handled for you internally by SwiftUI as part of the dismiss action.
So your modifier can be simplified a little:
.sheet(isPresented: $isPresented) {
FilterView()
}
Secondly, when running an app in the Simulator I've noticed that when you come back to the main view after dismissing a sheet you have to interact with the app somehow before clicking on the toolbar button, or the action won't trigger.
In cases like this, just scrolling the list up or down a little would be enough, and then the toolbar button works as you'd expect.
I've not encountered the same thing while running apps on a physical device – whether that's because the bug isn't present, or just that it's a lot easier to interact with the app in some microscopic form of gesture, I couldn't say.
I am starting out in SwiftUI and have an issue.
I have a main view loads a modal view, on iPhone this goes full screen, iPad by default covers part of the screen.
The below code appears to do the 'default' loads a view that is centered but not full screen.
What id ideally like is to be able to make the model view smaller. It is a login screen where user enters login details.
Using storyboards, I could achieve this with 'preferredcontentsize' but SwiftUI comparisons don't appear to work.
struct Login_View: View {
#State private var showingSheet = false
var body: some View {
Button("Show Alert") {
showingSheet = !showingSheet
}
.sheet(isPresented:$showingSheet) {
Credentials_View()
}
}
}
(Below is the modal view, atm it just shows some colours whilst I get to grips with that is going on)
struct Credentials_View: View {
var body: some View {
GeometryReader { metrics in
VStack(spacing: 0) {
Color.red.frame(height: metrics.size.height * 0.43)
Color.green.frame(height: metrics.size.height * 0.37)
Color.yellow
}
}
}
}
IOS 16+
As of iOS 16, a half sheet can be created using .sheet where the displayed view(example Credentials_View) has the .presentationDetents set to .medium.
struct Login_View: View {
#State private var showingSheet = false
var body: some View {
Button("Show Alert") {
showingSheet = !showingSheet
}
.sheet(isPresented:$showingSheet) {
Credentials_View()
.presentationDetents([.medium])
}
}
}
Result:
And .presentationDragIndicator can be added to make the indicator visible:
.presentationDragIndicator(.visible)
Result:
I am trying to rewrite my app using SwiftUI only and I am having difficulty with the EnvironmentObject, trying to understand how it works…
I want to redirect my app users to the appropriate page at launch, depending on:
if this is their first time
if they have a login,
if they want to start using without login
If it is the first time the app is launched, LocalStorage has no data so I present the app on a welcome page
I offer the choice of 2 buttons to click on:
“New User” which redirect to the main page of the app and create a new user
“Login” which present the login page to retrieve the last backup
If the app has previously been launched, I present the main page straight away.
Now said, if I initiate my “currentPage” as “MainView” or “LoginView”, it works - but NOT if it is set as “WelcomeView”.
I presume the problem comes when the variable gets changed from a subview? I thought the use of #EnvironmentObject was the way to get around this…
Can someone explain to me how it works?
My various files are:
import SwiftUI
import Combine
class ViewRouter: ObservableObject {
let objectWillChange = PassthroughSubject<ViewRouter,Never>()
var currentPage: String = "WelcomeView" {
didSet {
objectWillChange.send(self)
}
}
}
import SwiftUI
struct ParentView : View {
#EnvironmentObject var viewRouter: ViewRouter
var body: some View {
VStack {
if viewRouter.currentPage == "WelcomeView" {
WelcomeView()
}
else if viewRouter.currentPage == "MainView" {
MainView()
}
else if viewRouter.currentPage == "LoginView" {
LoginView()
}
}
}
}
import SwiftUI
struct WelcomeView: View {
#EnvironmentObject var viewRouter: ViewRouter
var body: some View {
ZStack{
// VStack { [some irrelevant extra code here] }
VStack {
LoginButtons().environmentObject(ViewRouter())
}
// VStack { [some irrelevant extra code here] }
}
}
}
import SwiftUI
struct LoginButtons: View {
#EnvironmentObject var viewRouter: ViewRouter
var body: some View {
VStack {
Button(action: {
self.viewRouter.currentPage = "MainView"
}) {
Text("NEW USER")
}
Button(action: {
self.viewRouter.currentPage = "LoginView"
}) {
Text("I ALREADY HAVE AN ACCOUNT")
}
}
}
}
import SwiftUI
struct MainView : View {
#EnvironmentObject var viewRouter: ViewRouter
var body: some View {
VStack {
// Just want to check if it is working for now before implementing the appropriate Views...
Button(action: {
self.viewRouter.currentPage = "WelcomeView"
}) {
Text("BACK")
}
}
}
}
import SwiftUI
struct LoginView : View {
#EnvironmentObject var viewRouter: ViewRouter
var body: some View {
VStack {
// Just want to check if it is working for now before implementing the appropriate Views...
Button(action: {
self.viewRouter.currentPage = "WelcomeView"
}) {
Text("BACK")
}
}
}
}
Many Thanks in advance! :wink:
Ok so in your main view, the one that you are going to decide where to send your user, you could check for the app if it was lunched before or not, depending on that do whatever you want. Once you know how to do this, you can adapt to the other things. This is how you can check for it, again, in your main view router:
init() {
// Create initial Data if not data has been setup
if (InitialAppSetup().initialDataLoaded == false) {
InitialAppSetup().createInitialData()
}
// Onboarding screen
if !UserDefaults.standard.bool(forKey: "didLaunchBefore") {
UserDefaults.standard.set(true, forKey: "didLaunchBefore")
currentPage = "onboardingView"
} else {
currentPage = "homeView"
}
}
The InitialAppSetup() class has a UserDefault which goes like this:
#Published var initialDataLoaded: Bool = UserDefaults.standard.bool(forKey: "InitialData") {
didSet {
UserDefaults.standard.set(self.initialDataLoaded, forKey: "InitialData")
}
}
Ok... My 'mistake' was to add an extra ".environmentObject(ViewRouter())" when calling my subview "LoginButtons".
If I remove it, it works!.. But why?!?
struct WelcomeView: View {
#EnvironmentObject var viewRouter: ViewRouter
var body: some View {
ZStack{
// VStack { [some irrelevant extra code here] }
VStack {
LoginButtons()
// --> .environmentObject(ViewRouter())
}
// VStack { [some irrelevant extra code here] }
}
}
}
I've presented a modal view but I would like the user to go through some steps before it can be dismissed.
Currently the view can be dragged to dismiss.
Is there a way to stop this from being possible?
I've watched the WWDC Session videos and they mention it but I can't seem to put my finger on the exact code I'd need.
struct OnboardingView2 : View {
#Binding
var dismissFlag: Bool
var body: some View {
VStack {
Text("Onboarding here! 🙌🏼")
Button(action: {
self.dismissFlag.toggle()
}) {
Text("Dismiss")
}
}
}
}
I currently have some text and a button I'm going to use at a later date to dismiss the view.
iOS 15+
Starting from iOS 15 we can use interactiveDismissDisabled:
func interactiveDismissDisabled(_ isDisabled: Bool = true) -> some View
We just need to attach it to the sheet. Here is an example from the documentation:
struct PresentingView: View {
#Binding var showTerms: Bool
var body: some View {
AppContents()
.sheet(isPresented: $showTerms) {
Sheet()
}
}
}
struct Sheet: View {
#State private var acceptedTerms = false
var body: some View {
Form {
Button("Accept Terms") {
acceptedTerms = true
}
}
.interactiveDismissDisabled(!acceptedTerms)
}
}
It is easy if you use the 3rd party lib Introspect, which is very useful as it access the corresponding UIKit component easily. In this case, the property in UIViewController:
VStack { ... }
.introspectViewController {
$0.isModalInPresentation = true
}
Not sure this helps or even the method to show the modal you are using but when you present a SwiftUI view from a UIViewController using UIHostingController
let vc = UIHostingController(rootView: <#your swiftUI view#>(<#your parameters #>))
you can set a modalPresentationStyle. You may have to decide which of the styles suits your needs but .currentContext prevents the dragging to dismiss.
Side note:I don't know how to dismiss a view presented from a UIHostingController though which is why I've asked a Q myself on here to find out 😂
I had a similar question here
struct Start : View {
let destinationView = SetUp()
.navigationBarItem(title: Text("Set Up View"), titleDisplayMode: .automatic, hidesBackButton: true)
var body: some View {
NavigationView {
NavigationButton(destination: destinationView) {
Text("Set Up")
}
}
}
}
The main thing here is that it is hiding the back button. This turns off the back button and makes it so the user can't swipe back ether.
For the setup portion of your app you could create a new SwiftUI file and add a similar thing to get home, while also incorporating your own setup code.
struct SetUp : View {
let destinationView = Text("Your App Here")
.navigationBarItem(title: Text("Your all set up!"), titleDisplayMode: .automatic, hidesBackButton: true)
var body: some View {
NavigationView {
NavigationButton(destination: destinationView) {
Text("Done")
}
}
}
}
There is an extension to make controlling the modal dismission effortless, at https://gist.github.com/mobilinked/9b6086b3760bcf1e5432932dad0813c0
A temporary solution before the official solution released by Apple.
/// Example:
struct ContentView: View {
#State private var presenting = false
var body: some View {
VStack {
Button {
presenting = true
} label: {
Text("Present")
}
}
.sheet(isPresented: $presenting) {
ModalContent()
.allowAutoDismiss { false }
// or
// .allowAutoDismiss(false)
}
}
}