Hi I am starting to learn SwiftUI and macOS development. I am using the SwiftUI life cycle. How do I call a function from the focused window from the menu bar.
Besides Apple documentation, I found this reference and am able to create menu items using Commands but I have no idea how to call a function from my view.
For example:
Suppose this is my App struct:
import SwiftUI
#main
struct ExampleApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}.commands {
CommandMenu("First menu") {
Button("Action!") {
// How do I call the views action function?
}
}
}
}
and this is my View:
struct ContentView: View {
public func action() {
print("It works")
}
var body: some View {
Text("Example")
}
}
I just typed the example code sorry if there are any typos but I hope you can get the idea.
Because Views in SwiftUI are transient, you can't hold a reference to a specific instance of ContentView to call a function on it. What you can do, though, is change part of your state that gets passed down to the content view.
For example:
#main
struct ExampleApp: App {
#StateObject var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView(appState: appState)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}.commands {
CommandMenu("First menu") {
Button("Action!") {
appState.textToDisplay = "\(Date())"
}
}
}
}
}
class AppState : ObservableObject {
#Published var textToDisplay = "(not clicked yet)"
}
struct ContentView: View {
#ObservedObject var appState : AppState
var body: some View {
Text(appState.textToDisplay)
}
}
Note that the .commands modifier goes on WindowGroup { }
In this example, AppState is an ObservableObject that holds some state of the app. It's passed through to ContentView using a parameter. You could also pass it via an Environment Object (https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-environmentobject-to-share-data-between-views)
When the menu item is clicked, it sets textToDisplay which is a #Published property on AppState. ContentView will get updated any time a #Published property of AppState gets updated.
This is the general idea of the pattern you'd use. If you have a use case that isn't covered by this pattern, let me know in the comments.
Updates, based on your comments:
import SwiftUI
import Combine
#main
struct ExampleApp: App {
#StateObject var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView(appState: appState)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}.commands {
CommandMenu("First menu") {
Button("Action!") {
appState.textToDisplay = "\(Date())"
}
Button("Change background color") {
appState.contentBackgroundColor = Color.green
}
Button("Toggle view") {
appState.viewShown.toggle()
}
Button("CustomCopy") {
appState.customCopy.send()
}
}
}
}
}
class AppState : ObservableObject {
#Published var textToDisplay = "(not clicked yet)"
#Published var contentBackgroundColor = Color.clear
#Published var viewShown = true
var customCopy = PassthroughSubject<Void,Never>()
}
class ViewModel : ObservableObject {
#Published var text = "The text I have here"
var cancellable : AnyCancellable?
func connect(withAppState appState: AppState) {
cancellable = appState.customCopy.sink(receiveValue: { _ in
print("Do custom copy based on my state: \(self.text) or call a function")
})
}
}
struct ContentView: View {
#ObservedObject var appState : AppState
#State var text = "The text I have here"
#StateObject private var viewModel = ViewModel()
var body: some View {
VStack {
Text(appState.textToDisplay)
.background(appState.contentBackgroundColor)
if appState.viewShown {
Text("Shown?")
}
}
.onReceive(appState.$textToDisplay) { (newText) in
print("Got new text: \(newText)")
}
.onAppear {
viewModel.connect(withAppState: appState)
}
}
}
In my updates, you can see that I've addressed the question of the background color, showing hiding a view, and even getting a notification (via onReceive) when one of the #Published properties changes.
You can also see how I use a custom publisher (customCopy) to pass along an action to ContentView's ViewModel
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.
I am fairly new to SwiftUI and I am trying to build an app where you can favorite items in a list. It works in the ContentView but I would like to have the option to favorite and unfavorite an item in its DetailView.
I know that vm is not in the scope but how do I fix it?
Here is some of the code in the views. The file is long so I am just showing the relevant code
struct ContentView: View {
#StateObject private var vm = ViewModel()
//NavigationView with a List {
//This is the code I call for showing the icon. The index is the item in the list
Image(systemName: vm.contains(index) ? "heart.fill" : "heart")
.onTapGesture{
vm.toggleFav(item: index)
}
}
struct DetailView: View {
Hstack{
Image(systemName: vm.contains(entry) ? "heart.fill" : "heart") //Error is "Cannot find 'vm' in scope"
}
}
Here is the code that that vm is referring to
import Foundation
import SwiftUI
extension ContentView {
final class ViewModel: ObservableObject{
#Published var items = [Biase]()
#Published var showingFavs = false
#Published var savedItems: Set<Int> = [1, 7]
// Filter saved items
var filteredItems: [Biase] {
if showingFavs {
return items.filter { savedItems.contains($0.id) }
}
return items
}
private var BiasStruct: BiasData = BiasData.allBias
private var db = Database()
init() {
self.savedItems = db.load()
self.items = BiasStruct.biases
}
func sortFavs(){
withAnimation() {
showingFavs.toggle()
}
}
func contains(_ item: Biase) -> Bool {
savedItems.contains(item.id)
}
// Toggle saved items
func toggleFav(item: Biase) {
if contains(item) {
savedItems.remove(item.id)
} else {
savedItems.insert(item.id)
}
db.save(items: savedItems)
}
}
}
This is the list view...
enter image description here
Detail view...
enter image description here
I tried adding this code under the List(){} in the ContentView .environmentObject(vm)
And adding this under the DetailView #EnvironmentObject var vm = ViewModel() but it said it couldn't find ViewModel.
To put the view model inside the ContentView struct is wrong. Delete the enclosing extension.
If the view model is supposed to be accessed from everywhere it must be on the top level.
In the #main struct create the instance of the view model and inject it into the environment
#main
struct MyGreatApp: App {
#StateObject var viewModel = ViewModel()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(viewModel)
}
}
}
And in any struct you want to use it add
#EnvironmentObject var vm : ViewModel
without parentheses.
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 have a settings view that has a button which toggles a binding that's stored with UserDefaults.
struct Settings: View {
#ObservedObject var settingsVM = SetttingsViewModel()
var body: some View {
if settingsVM.settingActivated {
Text("Setting activated")
} else {
Text("Setting deactivated")
}
Button("Activate") {
settingsVM.settingActivated.toggle()
}
}
}
SettingsViewModel
class SetttingsViewModel: ObservableObject {
#Published var settingActivated: Bool = UserDefaults.standard.bool(forKey: "settingActivated") {
didSet {
UserDefaults.standard.set(self.settingActivated, forKey: "settingActivated")
}
}
}
The text("Setting activated/ Setting deactivated")in the Settings view update instantly when i press the button but the text in ContentView doesn't change unless i restart the app & i have no idea why.
struct ContentView: View {
#ObservedObject var settingsVM = SetttingsViewModel()
#State private var showsettings = false
var body: some View {
if settingsVM.settingActivated {
Text("Setting Activated")
.padding(.top)
} else {
Text("Setting Deactivated")
.padding(.top)
}
Button("Show Settings") {
showsettings.toggle()
}
.sheet(isPresented: $showsettings) {
Settings()
}
.frame(width: 300, height: 300)
}
}
This is for a macOS 10.15 app so i can't use #AppStorage
Right now, you don't have any code in you view model to react to a change in UserDefaults. Meaning, if UserDefaults gets a new value set, it won't know about it. And, since you're using a different instance of SettingsViewModel in your two different views, they can easily become out-of-sync.
The easiest change would be to pass the same instance of SettingsViewModel to Settings:
struct Settings: View {
#ObservedObject var settingsVM: SettingsViewModel //<-- Here
var body: some View {
if settingsVM.settingActivated {
Text("Setting activated")
} else {
Text("Setting deactivated")
}
Button("Activate") {
settingsVM.settingActivated.toggle()
}
}
}
struct ContentView: View {
#ObservedObject var settingsVM = SetttingsViewModel()
#State private var showsettings = false
var body: some View {
if settingsVM.settingActivated {
Text("Setting Activated")
.padding(.top)
} else {
Text("Setting Deactivated")
.padding(.top)
}
Button("Show Settings") {
showsettings.toggle()
}
.sheet(isPresented: $showsettings) {
Settings(settingsVM: settingsVM) //<-- Here
}
.frame(width: 300, height: 300)
}
}
Another option would be to use a custom property wrapper (like AppStorage, but available to earlier targets): https://xavierlowmiller.github.io/blog/2020/09/04/iOS-13-AppStorage
Also, #vadian's comment is important -- if you had access to it, you'd want to use #StateObject. But, since you don't, it's important to store your ObservableObject at the top level so it doesn't get recreated.
I want to be able to change a view in SwiftUI with the tap of a button. I have buttons setup to toggle the environmental variables as follows
struct SettingsButton: View {
#EnvironmentObject var settings: UserSettings
var body: some View {
VStack {
Button(action: { self.settings.settingsView.toggle() }) {
Image(systemName: "gear")
.font(Font.system(size: 25))
.frame(width: 25, height: 25)
.foregroundColor(.primary)
}
}
.offset(x: 180, y: -372)}
}
I've also declared the Observable object here
import Foundation
import GoogleSignIn
class UserSettings: ObservableObject {
#Published var studentID = ""
#Published var givenName = ""
#Published var settingsView = false
#Published var profileView = false
#Published var isLogged = GIDSignIn.sharedInstance()?.currentUser
}
And finally I have a ViewBuilder setup in the view that is loaded on start to listen for a change in the variable and to switch views accordingly, however when the app is loaded and the button is tapped the app freezes and remains unresponsive.
struct Login: View {
#EnvironmentObject var settings: UserSettings
#ViewBuilder var body : some View {
if settings.isLogged != nil {
MainView()
}
else {
LoginPage()
}
if settings.settingsView {
SettingsView()
}
}
}
I would like to know if there is any known way to attempt this without the use of .sheet or Navigation Links any help with be very much appreciated!
Without seeing your MainView(), LoginPage() and SettingsView() I think you should be doing something like this in your Login() view:
I added VStack around your views:
struct Login: View {
#EnvironmentObject var settings: UserSettings
#ViewBuilder var body: some View {
VStack {
if settings.isLogged != nil {
MainView()
} else {
LoginPage()
}
if settings.settingsView {
SettingsView()
}
}
}
}
Also ensure that you have the following in your SceneDelegate since your UserSettings() is defined as an EnvironmentObject:
// Create the SwiftUI view that provides the window contents.
let contentView = Login()
.environmentObject(UserSettings())