I use an ObservableObject to keep the state of whether a user is subscribed to my app or not, and based on the subscription status, show different views. This worked fine prior to Xcode 13 and WatchOS 8, but now this is causing a runtime error of runtime: SwiftUI: Accessing State's value outside of being installed on a View. This will result in a constant Binding of the initial value and will not update. And, the binding does not update per the error. This occurs on both Xcode 13.1 and 13.2b2
This code below reproduces the error:
struct MultiPageView: View {
#ObservedObject var subscribed = SubscribedModel.shared
var body: some View {
if subscribed.value {
TabView {
ViewOne()
ViewTwo()
ViewThree()
ToggleView()
}
.tabViewStyle(PageTabViewStyle())
} else {
TabView {
ViewOne()
ToggleView()
}
.tabViewStyle(PageTabViewStyle())
}
}
}
struct ToggleView: View {
#ObservedObject var subscribed = SubscribedModel()
var body: some View {
Toggle(isOn: $subscribed.value) {
Text("Subscribed")
}
}
}
class SubscribedModel: ObservableObject {
public static let shared = SubscribedModel.shared
#Published var value: Bool = false
}
I am only listing ViewOne for brevity, but ViewTwo and ViewThree are the same with different text:
struct ViewOne: View {
var body: some View {
Text("View One")
.padding()
}
}
If you navigate to the ToggleView(), and switch the toggle, the error pops immediately. Any suggestions to fix this?
Update per #LoremIpsum comment:
struct MultiPageView: View {
#StateObject var subscribed = SubscribedModel()
var body: some View {
if subscribed.value {
TabView {
ViewOne()
ViewTwo()
ViewThree()
ToggleView(subscribed: $subscribed.value)
}
.tabViewStyle(PageTabViewStyle())
} else {
TabView {
ViewOne()
ToggleView(subscribed: $subscribed.value)
}
.tabViewStyle(PageTabViewStyle())
}
}
}
struct ToggleView: View {
#Binding var subscribed: Bool
var body: some View {
Toggle(isOn: $subscribed) {
Text("Subscribed")
}
}
}
It is now switching between the TabViews, but the error still remains, and is showing up immediately. Deleted DerivedData and cleaned build folder. Any thoughts?
I will add that this same basic code is running fine on iOS 15. It is just WatchOS that is popping the error.
I was having the same issue for a long time, and this is still happening on Xcode 13.2.1.
Seems to be an issue with TabView on watchOS, because if you replace the TabView for another View the error is gone.
The solution is to use the initialiser for TabView with a selection value: init(selection:content:)
1 Define a property for selection
#State private var selection = 0
2 Update TabView
From
TabView {
// content
}
To
TabView(selection: $selection) {
// content
}
Updating your code would look like this:
struct MultiPageView: View {
#StateObject var subscribed = SubscribedModel()
#State private var selection = 0
var body: some View {
if subscribed.value {
TabView(selection: $selection) {
ViewOne()
ViewTwo()
ViewThree()
ToggleView(subscribed: $subscribed.value)
}
.tabViewStyle(PageTabViewStyle())
} else {
TabView(selection: $selection) {
ViewOne()
ToggleView(subscribed: $subscribed.value)
}
.tabViewStyle(PageTabViewStyle())
}
}
}
Basically just defining a #State property for TabView.selection, and using it on both your TabViews (using separated properties would also work).
Related
I am working on a project where I need to reset a TabView's root view controllers (NavigationViews with Lists inside) when the TabViews selected item changes. This is pretty simple in UIKit, however in SwiftUI it doesn't seem that easy.
Let's say I have the following code:
class AppState: ObservableObject {
let objectWillChange = PassthroughSubject<AppState, Never>()
#Published var theScrollPosition: Int64? {
didSet {
print("Did set scroll position")
objectWillChange.send(self)
}
}
#Published var selectedTab: Tabs
{
didSet {
print("Tab switched, switching back to root view")
selectedItemID = nil
selectedRow = nil
theScrollPosition = -1
}
}
#Published var selectedItemID: Int64? {
didSet {
objectWillChange.send(self)
}
}
#Published var selectedRow: Int64? {
didSet {
objectWillChange.send(self)
}
}
}
struct ContentView: View {
#EnvironmentObject var state: AppState
var body: some View {
TabView {
View1().id(Tabs.Tab1)
View2().id(Tabs.Tab2)
View3().id(Tabs.Tab3)
}
}
}
struct View1: View {
#EnvironmentObject var state: AppState
#ObservedObject var viewModel: ListViewModel()
var body: some View {
ScrollViewReader { proxy in
List(viewModel.items) { item in
Section {
NavigationLink(destination:ListItemDetailView(item), tag: item.id, selection: state.selectedItemID) {
ListItemView(item)
}
}
}.onChange(of: self.state.selectedItemID) { newValue in
print("Scrolling to top")
proxy.scrollTo(0)
}
}
}
There may be some typos in the code as this is not the actual production code however the flow is this:
Selection state of TabView and NavigationLink are stored in a global EnvironmentObject. When the TabView selection changes, View1 should scroll back up to the top.
However, the onChange method is never called.
Next time, provide something runnable or at least a start...
But here is a example where view 1 will always scroll to the top, you are missing something like onAppear()
And you don't need to have AppState.theScrollPosition as a published, instead change to the right tab and have that view read the position or tag in the model.
import SwiftUI
struct ContentView: View {
var view: some View {
ScrollViewReader { proxy in
ScrollView {
VStack {
Text("TOP").id("topID")
Divider()
Spacer()
.frame(height:1000)
Text("BOTTOM").id("bottomID")
}
}
.onAppear {
proxy.scrollTo("topID")
}
}
}
var view2: some View {
VStack {
Text("View 2")
}
}
var body: some View {
TabView {
view.tag(0)
.tabItem {
Text("View 1")
}
view2.tag(1)
.tabItem {
Text("View 2")
}
}
}
}
Thanks, for some reason I was totally missing onAppear.
So I have been wanting to do this for some time, but I can't figure out how to approach this, so I'm reaching out to see if someone might be able to help me.
So let's say that I have the following code, which when the app loads, loads the "MainView":
struct MapGlider: App {
#ObservedObject var mainViewModel = MainViewModel()
var body: some Scene {
WindowGroup {
MainView()
.environmentObject(mainViewModel)
}
}
}
This loads the map as soon as the app is opened, which is great! All works great there.
Now I will be switching that out to show the OnboardingView() when the app loads such as:
struct MapGlider: App {
#ObservedObject var mainViewModel = MainViewModel()
var body: some Scene {
WindowGroup {
OnboardingView()
}
}
}
Now, I have a OnboardingView that shows a ZStack with some options, as show in this code below:
struct OnboardingView: View {
#State private var showGetStartedSheet = false
#ObservedObject var mainViewModel = MainViewModel()
var body: some View {
if #available(iOS 16.0, *) {
NavigationStack {
ZStack(alignment: .top) {
VStack {
LazyVGrid(columns: [GridItem(), GridItem(), GridItem(alignment: .topTrailing)], content: {
Spacer()
Image("onboarding-logo")
.border(.red)
NavigationLink(destination: MainView().environmentObject(mainViewModel), label: {
Text("Skip")
})
})
.border(.red)
}
}
.border(.blue)
}
} else {
// Fallback on earlier versions
}
}
}
Which outputs the following:
What I'm trying to achieve:
When someone clicks on the "Skip" text, to kill the OnboardingView and show the MainView().
The closest I got is setting a NavigationLink, but that had a back button and doesn't work so well, I want to be able to go to the MainView and not be able to go back to OnboardingView.
All help will be appreciated!
You could use a container view that conditionally displays the onboarding or main views, depending on the state of a variable (stored at the parent level). That variable can be passed down via a Binding:
Simplified example that should be easily applicable to your code:
class AppState: ObservableObject {
#Published var showOnboarding = true
}
#main
struct CustomCardViewApp: App {
#StateObject var appState = AppState()
var body: some Scene {
WindowGroup {
MainScreenContainer(showOnboarding: $appState.showOnboarding)
}
}
}
struct MainScreenContainer: View {
#Binding var showOnboarding: Bool
var body: some View {
if showOnboarding {
OnboardingView(showOnboarding: $showOnboarding)
} else {
MainView()
}
}
}
struct OnboardingView: View {
#Binding var showOnboarding: Bool
var body: some View {
Text("Onboarding")
Button("Skip") {
showOnboarding = false
}
}
}
struct MainView: View {
var body: some View {
Text("Main")
}
}
I'm seeing onChange(of: Bool) action tried to update multiple times per frame warnings when clicking on NavigationLinks in the sidebar for a SwiftUI macOS App.
Here's what I currently have:
import SwiftUI
#main
struct BazbarApp: App {
#StateObject private var modelData = ModelData()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(modelData)
}
}
}
class ModelData: ObservableObject {
#Published var myLinks = [URL(string: "https://google.com")!, URL(string: "https://apple.com")!, URL(string: "https://amazon.com")!]
}
struct ContentView: View {
#EnvironmentObject var modelData: ModelData
#State private var selected: URL?
var body: some View {
NavigationView {
List(selection: $selected) {
Section(header: Text("Bookmarks")) {
ForEach(modelData.myLinks, id: \.self) { url in
NavigationLink(destination: DetailView(selected: $selected) ) {
Text(url.absoluteString)
}
.tag(url)
}
}
}
.onDeleteCommand {
if let selected = selected {
modelData.myLinks.remove(at: modelData.myLinks.firstIndex(of: selected)!)
}
selected = nil
}
Text("Choose a link")
}
}
}
struct DetailView: View {
#Binding var selected: URL?
var body: some View {
if let selected = selected {
Text("Currently selected: \(selected)")
}
else {
Text("Choose a link")
}
}
}
When I alternate clicking on the second and third links in the sidebar, I eventually start seeing the aforementioned warnings in my console.
Here's a gif of what I'm referring to:
Interestingly, the warning does not appear when alternating clicks between the first and second link.
Does anyone know how to fix this?
I'm using macOS 12.2.1 & Xcode 13.2.1.
Thanks in advance
I think the issue is that both the List(selection:) and the NavigationLink are trying to update the state variable selected at once. A List(selection:) and a NavigationLink can both handle the task of navigation. The solution is to abandon one of them. You can use either to handle navigation.
Since List look good, I suggest sticking with that. The NavigationLink can then be removed. The second view under NavigationView is displayed on the right, so why not use DetailView(selected:) there. You already made the selected parameter a binding variable, so the view will update if that var changes.
struct ContentView: View {
#EnvironmentObject var modelData: ModelData
#State private var selected: URL?
var body: some View {
NavigationView {
List(selection: $selected) {
Section(header: Text("Bookmarks")) {
ForEach(modelData.myLinks, id: \.self) { url in
Text(url.absoluteString)
.tag(url)
}
}
}
.onDeleteCommand {
if let selected = selected {
modelData.myLinks.remove(at: modelData.myLinks.firstIndex(of: selected)!)
}
selected = nil
}
DetailView(selected: $selected)
}
}
}
I can recreate this problem with the simplest example I can think of so my guess is it's an internal bug in NavigationView.
struct ContentView: View {
var body: some View {
NavigationView {
List {
NavigationLink("A", destination: Text("A"))
NavigationLink("B", destination: Text("B"))
NavigationLink("C", destination: Text("C"))
}
}
}
}
I have a simple View showing a list of 3 items. When the user taps on an item, it navigates to the next view. This works fine. However, I would like to also perform an action (set a variable in a View Model) when a list item is tapped.
Is this possible? Here's the code:
import SwiftUI
struct SportSelectionView: View {
#EnvironmentObject var workoutSession: WorkoutManager
let sports = ["Swim", "Bike", "Run"]
var body: some View {
List(sports, id: \.self) { sport in
NavigationLink(destination: ContentView().environmentObject(workoutSession)) {
Text(sport)
}
}.onAppear() {
// Request HealthKit store authorization.
self.workoutSession.requestAuthorization()
}
}
}
struct DisciplineSelectionView_Previews: PreviewProvider {
static var previews: some View {
SportSelectionView().environmentObject(WorkoutManager())
}
}
The easiest way I've found to get around this issue is to add an .onAppear call to the destination view of the NavigationLink. Technically, the action will happen when the ContentView() appears and not when the NavigationLink is clicked.. but the difference will be milliseconds and probably irrelevant.
NavigationLink(destination:
ContentView()
.environmentObject(workoutSession)
.onAppear {
// add action here
}
)
Here's a solution that is a little different than the onAppear approach. By creating your own Binding for isActive in the NavigationLink, you can introduce a side effect when it's set. I've implemented it here all within the view, but I would probably do this in an ObservableObject if I were really putting it into practice:
struct ContentView: View {
#State var _navLinkActive = false
var navLinkBinding : Binding<Bool> {
Binding<Bool> { () -> Bool in
return _navLinkActive
} set: { (newValue) in
if newValue {
print("Side effect")
}
_navLinkActive = newValue
}
}
var body: some View {
NavigationView {
NavigationLink(
destination: Text("Dest"),
isActive: navLinkBinding,
label: {
Text("Navigate")
})
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
As detailed here (on an iOS topic), the following code can be used to make a SwiftUI View dismiss itself:
#Environment(\.presentationMode) var presentationMode
// ...
presentationMode.wrappedValue.dismiss()
However, this approach doesn't work for a native (not Catalyst) macOS NavigationView setup (such as the below), where the selected view is displayed alongside the List.
Ideally, when any of these sub-views use the above, the list would go back to having nothing selected (like when it first launched); however, the dismiss function appears to do nothing: the view remains exactly the same.
Is this a bug, or expected macOS behaviour?
Is there another approach that can be used instead?
struct HelpView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination:
AboutAppView()
) {
Text("About this App")
}
NavigationLink(destination:
Text("Here’s a User Guide")
) {
Text("User Guide")
}
}
}
}
}
struct AboutAppView: View {
#Environment(\.presentationMode) var presentationMode
public var body: some View {
Button(action: {
self.dismissSelf()
}) {
Text("Dismiss Me!")
}
}
private func dismissSelf() {
presentationMode.wrappedValue.dismiss()
}
}
FYI: The real intent is for less direct scenarios (such as triggering from an Alert upon completion of a task); the button setup here is just for simplicity.
The solution here is simple. Do not use Navigation View where you need to dismiss the view.
Check the example given by Apple https://developer.apple.com/tutorials/swiftui/creating-a-macos-app
If you need dismissable view, there is 2 way.
Create a new modal window (This is more complicated)
Use sheet.
Following is implimenation fo sheet in macOS with SwiftUI
struct HelpView: View {
#State private var showModal = false
var body: some View {
NavigationView {
List {
NavigationLink(destination:
VStack {
Button("About"){ self.showModal.toggle() }
Text("Here’s a User Guide")
}
) {
Text("User Guide")
}
}
}
.sheet(isPresented: $showModal) {
AboutAppView(showModal: self.$showModal)
}
}
}
struct AboutAppView: View {
#Binding var showModal: Bool
public var body: some View {
Button(action: {
self.showModal.toggle()
}) {
Text("Dismiss Me!")
}
}
}
There is also a 3rd option to use ZStack to create a Modal Card in RootView and change opacity to show and hide with dynamic data.