How to activate / deactivate menu items in SwiftUI - macos

I am trying to create custom menu items in SwiftUI app for mac os (SwiftUI App life cycle). I don't quite understand the following behaviour:
In the code below:
import SwiftUI
#main
struct MySQLtoolApp: App {
#ObservedObject var mysql = MySQL()
var flags = UtilFlags()
var selectedDB = "mysql"
var body: some Scene {
let mainWindow = WindowGroup(Text("title")) {
ContentView()
.environmentObject(mysql)
.environmentObject(flags)
}
.windowStyle(HiddenTitleBarWindowStyle())
.commands {
CommandMenu("Tools", content: {
Button("Connect...", action: {flags.connectDialogVisibilityFlag = true }).disabled(mysql.connected)
Button("Disconnect", action: { mysql.closeBase()
mysql.connected = false}).disabled(!mysql.connected)
})
CommandGroup(after: .help, addition: {
Button("Test button", action: {print("Test button pressed")})
.disabled(!mysql.connected)
})
}
return mainWindow
}
}
the buttons added via CommandMenu behave as expected (i.e. activate and deactivate according to the changing value of 'mysql.connected'. The button added via CommandGroup gets correctly configured according to the value of 'mysql.connected' at launch, but ignores the changes of 'mysql.connected' and doesn't change its state. What am I missing?
I rewrote the above segment to emphasize the problem. In a nutshell:
import SwiftUI
#main
struct MenuTestApp: App {
#State var active = false
var body: some Scene {
WindowGroup {
ContentView()
}
.commands(content: {
CommandMenu("Tools", content: {
Button("Normally active", action: {active = !active}).disabled(active)
Button("Normally inactive", action: {active = !active}).disabled(!active)
})
CommandGroup(after: .newItem, addition: {
Button("Normally active", action: {active = !active}).disabled(active)
Button("Normally inactive", action: {active = !active}).disabled(!active)
})
})
}
}
Buttons added via CommandMenu behave correctly (activate and deactivate according to the value of 'active'. Buttons added via CommandGroup ignore the value of 'active' and keep their initial states.

I got a great suggestion in Apple dev. forum (thanks OOPer!). It may not address the root cause (is it a bug in SwiftUI?), but it provides a good workaround. If I wrap my "misbehaving" button into a view and add this, everything is working as expected:
import SwiftUI
#main
struct MySQLtoolApp: App {
#StateObject var mysql = MySQL()
var flags = UtilFlags()
var selectedDB = "mysql"
var body: some Scene {
let mainWindow = WindowGroup(Text("title")) {
ContentView()
.environmentObject(mysql)
.environmentObject(flags)
}
.windowStyle(HiddenTitleBarWindowStyle())
.commands {
CommandMenu("Tools", content: {
Button("Connect...", action: {flags.connectDialogVisibilityFlag = true }).disabled(mysql.connected)
Button("Disconnect", action: { mysql.closeBase()
mysql.connected = false}).disabled(!mysql.connected)
})
CommandGroup(after: .newItem, addition: {
MyButtonsGroup().environmentObject(mysql)
})
}
return mainWindow
}
}
struct MyButtonsGroup: View {
#EnvironmentObject var mysql: MySQL
var body: some View {
Button("Test button", action: {print("Test button pressed")})
.disabled(mysql.connected)
}
}

Related

SwiftUI Toggle onChange not called for macOS menu item

I am adding a sort menu to my macOS SwiftUI app. Some of the items can have checkmarks next to them. For each menu item I create a Toggle item bound to an #State Bool. When an item is clicked I would like to run additional code to update the sort, so I added an .onChange, but it is never called. The checkmark for the item appears and disappears as I would expect, but the .onChange never gets hit.
Here is an example of creating a macOS menu item with checkmark. The "change" is never printed and a breakpoint set there never gets hit.
import SwiftUI
struct ViewMenuCommands: Commands {
#State
var isAlphabetical = false
#CommandsBuilder var body: some Commands {
CommandMenu("ViewTest") {
Toggle("Alphabetical", isOn: $isAlphabetical)
.onChange(of: isAlphabetical) { value in
print( "change" )
}
}
}
}
And here's where I add the menu to the app:
import SwiftUI
#main
struct MenuToggleTestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.commands {
ViewMenuCommands()
}
}
}
The problem is a result of #State not notifying Commands of its change -- #State will only notify a View of a change.
The solution is to wrap your menu item in a view that can respond to changes (via either #State or an ObservableObject -- I've chosen then latter).
Example:
#main
struct MainApp: App {
#StateObject var menuState = MenuState()
var body: some Scene {
WindowGroup {
ContentView()
}
.commands {
CommandMenu("ViewTest") {
AlphabeticalMenuItem(menuState: menuState)
}
}
}
}
class MenuState : ObservableObject {
#Published var isAlphabetical = false
}
struct AlphabeticalMenuItem : View {
#ObservedObject var menuState : MenuState
var body: some View {
Toggle("Alphabetical", isOn: $menuState.isAlphabetical)
.onChange(of: menuState.isAlphabetical) { value in
print( "change" )
}
}
}

Passing a Value from App to ContentView in SwiftUI

I find a lot of resources out there as to how to let the user select a menu item and then open a folder. The following is what I have.
import SwiftUI
#main
struct Oh_My_App: App {
var body: some Scene {
WindowGroup {
ContentView()
.frame(width: 480.0, height: 320.0)
}.commands {
CommandGroup(after: .newItem) {
Button {
if let url = showFileOpenPanel() {
print(url.path)
}
} label: {
Text("Open file...")
}
.keyboardShortcut("O")
}
}
}
func showFileOpenPanel() -> URL? {
let openPanel = NSOpenPanel()
openPanel.canChooseDirectories = true
openPanel.canCreateDirectories = false
openPanel.canChooseFiles = false
openPanel.title = "Selecting a folder..."
openPanel.message = "Please select a folder containing one or more files."
let response = openPanel.runModal()
return response == .OK ? openPanel.url : nil
}
}
Okay. That's no problem. I can print the file path. Well, my actual question is how to return this value to ContentView? It is ContentView that is virtually running the show in this sample application. So I use ObservableObject as follows.
import SwiftUI
#main
struct Oh_My_App: App {
#StateObject var menuObservable = MenuObservable()
#NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}.commands {
CommandGroup(after: .newItem) {
Button {
menuObservable.openFile()
} label: {
Text("Open file...")
}
.keyboardShortcut("O")
}
}
}
}
class MenuObservable: ObservableObject {
#Published var fileURL: URL = URL(fileURLWithPath: "")
func openFile() {
if let openURL = showFileOpenPanel() {
fileURL = openURL
}
}
func showFileOpenPanel() -> URL? {
let openPanel = NSOpenPanel()
openPanel.canChooseDirectories = true
openPanel.canCreateDirectories = false
openPanel.canChooseFiles = false
openPanel.title = "Selecting a folder..."
openPanel.message = "Please select a folder containing one or more files."
let response = openPanel.runModal()
return response == .OK ? openPanel.url : nil
}
}
// ContentView.swift //
import SwiftUI
struct ContentView: View {
#ObservedObject var menuObservable = MenuObservable()
#State var filePath: String = ""
var body: some View {
ZStack {
VStack {
Text("Hello: \(filePath)")
}.onChange(of: menuObservable.fileURL) { newValue in
filePath = newValue.path
}
}
}
}
My ContentView won't get updated. So how do I get ContentView to receive a value from the menu call from App? Thanks.
Right now, you're creating a new instance of MenuObservable in ContentView, so it doesn't have any connection to the instance that received the menu command. You need to pass a reference to your existing instance (ie the one owned by Oh_My_App).
In your ContentView, change #ObservedObject var menuObservable = MenuObservable() to:
#ObservedObject var menuObservable : MenuObservable
And in your Oh_My_App:
WindowGroup {
ContentView(menuObservable: menuObservable)
}

SwitfUI: access the specific scene's ViewModel on macOS

In this simple example app, I have the following requirements:
have multiple windows, each having it's own ViewModel
toggling the Toggle in one window should not update the other window's
I want to also be able to toggle via menu
As it is right now, the first two points are not given, the last point works though. I do already know that when I move the ViewModel's single source of truth to the ContentView works for the first two points, but then I wouldn't have access at the WindowGroup level, where I inject the commands.
import SwiftUI
#main
struct ViewModelAndCommandsApp: App {
var body: some Scene {
ContentScene()
}
}
class ViewModel: ObservableObject {
#Published var toggleState = true
}
struct ContentScene: Scene {
#StateObject private var vm = ViewModel()// injecting here fulfills the last point only…
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(vm)
.frame(width: 200, height: 200)
}
.commands {
ContentCommands(vm: vm)
}
}
}
struct ContentCommands: Commands {
#ObservedObject var vm: ViewModel
var body: some Commands {
CommandGroup(before: .toolbar) {
Button("Toggle Some State") {
vm.toggleState.toggle()
}
}
}
}
struct ContentView: View {
#EnvironmentObject var vm: ViewModel//injecting here will result in window independant ViewModels, but make them unavailable in `ContactScene` and `ContentCommands`…
var body: some View {
Toggle(isOn: $vm.toggleState, label: {
Text("Some State")
})
}
}
How can I fulfill theses requirements–is there a SwiftUI solution to this or will I have to implement a SceneDelegate (is this the solution anyway?)?
Edit:
To be more specific: I'd like to know how I can go about instantiating a ViewModel for each individual scene and also be able to know from the menu bar which ViewModel is meant to be changed.
Long story short, see the code below. The project is called WindowSample this needs to match your app name in the URL registration.
import SwiftUI
#main
struct WindowSampleApp: App {
var body: some Scene {
ContentScene()
}
}
//This can be done several different ways. You just
//need somewhere to store multiple copies of the VM
class AStoragePlace {
private static var viewModels: [ViewModel] = []
static func getAViewModel(id: String?) -> ViewModel? {
var result: ViewModel? = nil
if id != nil{
result = viewModels.filter({$0.id == id}).first
if result == nil{
let newVm = ViewModel(id: id!)
viewModels.append(newVm)
result = newVm
}
}
return result
}
}
struct ContentCommands: Commands {
#ObservedObject var vm: ViewModel
var body: some Commands {
CommandGroup(before: .toolbar) {
Button("Toggle Some State \(vm.id)") {
vm.testMenu()
}
}
}
}
class ViewModel: ObservableObject, Identifiable {
let id: String
#Published var toggleState = true
init(id: String) {
self.id = id
}
func testMenu() {
toggleState.toggle()
}
}
struct ContentScene: Scene {
var body: some Scene {
//Trying to init from 1 windowGroup only makes a copy not a new scene
WindowGroup("1") {
ToggleView(vm: AStoragePlace.getAViewModel(id: "1")!)
.frame(width: 200, height: 200)
}
.commands {
ContentCommands(vm: AStoragePlace.getAViewModel(id: "1")!)
}.handlesExternalEvents(matching: Set(arrayLiteral: "1"))
//To open this go to File>New>New 2 Window
WindowGroup("2") {
ToggleView(vm: AStoragePlace.getAViewModel(id: "2")!)
.frame(width: 200, height: 200)
}
.commands {
ContentCommands(vm: AStoragePlace.getAViewModel(id: "2")!)
}.handlesExternalEvents(matching: Set(arrayLiteral: "2"))
}
}
struct ToggleView: View {
#Environment(\.openURL) var openURL
#ObservedObject var vm: ViewModel
var body: some View {
VStack{
//Makes copies of the window/scene
Button("new-window-of type \(vm.id)", action: {
//appname needs to be a registered url in info.plist
//Info Property List>Url types>url scheme>item 0 == appname
//Info Property List>Url types>url identifier == appname
if let url = URL(string: "WindowSample://\(vm.id)") {
openURL(url)
}
})
//Toggle the state
Toggle(isOn: $vm.toggleState, label: {
Text("Some State \(vm.id)")
})
}
}
}

exc_breakpoint while trying to load view in SwiftUI

I'm trying to make barcode scanner app. I want the scanner to load as the app first launches, like a background. Then when a barcode is scanned, load a view on top displaying the product.
In my ContentsView() I load this View, which starts the scanner and then navigates to FoundItemSheet() when a barcode has been found.
import Foundation
import SwiftUI
import CodeScanner
struct barcodeScannerView: View {
#State var isPresentingScanner = false
#State var scannedCode: String?
#State private var isShowingScanner = false
var body: some View {
NavigationView {
if self.scannedCode != nil {
NavigationLink("Next page", destination: FoundItemSheet(scannedCode: scannedCode!), isActive: .constant(true)).hidden()
}
}
.onAppear(perform: {
self.isPresentingScanner = true
})
.sheet(isPresented: $isShowingScanner) {
self.scannerSheet
}
}
var scannerSheet : some View {
CodeScannerView(
codeTypes: [.qr],
completion: { result in
if case let .success(code) = result {
self.scannedCode = code
self.isPresentingScanner = false
}
}
)
}
}
When navigation view is replaced with a button, like this:
VStack(spacing: 10) {
if self.scannedCode != nil {
NavigationLink("Next page", destination: FoundItemSheet(scannedCode: scannedCode!), isActive: .constant(true)).hidden()
}
Button("Scan Code") {
self.isPresentingScanner = true
}
.sheet(isPresented: $isPresentingScanner) {
self.scannerSheet
}
Text("Scan a QR code to begin")
It works with this solution, but I want the scanner to show when the app loads, not on a button press. I tried replacing the button with .onAppear with the same contents as the button, but it doesn't work.
This is foundItemSheet()
struct FoundItemSheet: View {
#State private var bottomSheetShown = false
#State var scannedCode: String?
var body: some View {
GeometryReader { geometry in
BottomSheetView(
scannedCode: self.$scannedCode,
isOpen: self.$bottomSheetShown,
maxHeight: geometry.size.height * 0.7
) {
Color.blue
}
}.edgesIgnoringSafeArea(.all)
}
}
struct FoundItemSheet_Previews: PreviewProvider {
static var previews: some View {
FoundItemSheet()
}
}
I'm getting exc_breakpoint I believe where CodeScanner is declared.
I've been stuck on this for hours, so I'll reply quick to any questions.
Couple of thoughts..
1) change order of modifiers
.sheet(isPresented: $isShowingScanner) {
self.scannerSheet
}
.onAppear(perform: {
self.isPresentingScanner = true
})
2) make delayed sheet activation (as view hierarchy might not ready for that custom scanner view)
.onAppear(perform: {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5)
self.isPresentingScanner = true
}
})

Use of unresolved identifier 'PresentationButton'

Use of unresolved identifier 'PresentationButton', is there any new Class introduced for 'PresentationButton'?
Did anyone have used the 'PresentationButton' in their code. I would like to open an view on click of the image or content frame.
PresentationButton(destination: ContentView()) {
CourseView()
}
I did tried to find out the documentation in apple developer's website but I do not see any.
PresentationButton is deprecated. If you want to present a sheet use something like below.
struct ModalExample: View {
#State var show = false
var detail: ModalView {
return ModalView()
}
var body: some View {
VStack {
Button("Present") {
self.show = true
}
}
.sheet(isPresented: $show, content: { ModalView() })
}
}
struct ModalView : View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
Text("Modal")
Button("Close") {
self.presentationMode.value.dismiss()
}
}
}
}
If you're not showing the view modally (in which case .sheet is the way to go), the preferred way in Xcode 11 beta 5 is using NavigationLink.
NavigationLink(destination: ContentView()) {
Text("show ContentView")
}

Resources