Menu Command Buttons don't disable in reaction to Published variable changes - macos

Situation:
I need the menu bar to recognise the active tab in a TabView, even when multiple windows are open. I have found this solution (https://stackoverflow.com/a/68334001/2682035), which seems to work in principal, but not in practice.
Problem:
Menu bar buttons do not disable immediately when their corresponding tab is changed. However, they will disable correctly if another State variable is modified.
Minimal example which now works:
I made this without the code for managing multiple windows because the problem seemed to occur without that. Thanks to the suggestion from lorem ipsum, this will now function as expected.
import SwiftUI
enum TabType: String, Codable{
case tab1 = "first tab"
case tab2 = "second tab"
}
public class ViewModel: ObservableObject {
#Published var activeTab: TabType = .tab1
}
struct MenuCommands: Commands {
#ObservedObject var viewModel: ViewModel // CHANGED
#Binding var someInformation: String
var body: some Commands{
CommandMenu("My menu"){
Text(someInformation)
Button("Go to tab 1"){
viewModel.activeTab = .tab1
}
.disabled(viewModel.activeTab == .tab1) // this now works as expected
Button("Go to tab 2"){
viewModel.activeTab = .tab2
}
.disabled(viewModel.activeTab == .tab2) // this does too
Button("Print active tab"){
print(viewModel.activeTab) // this does too
}
}
}
}
struct Tab: View{
var tabText: String
#Binding var someInformation: String
var body: some View{
VStack{
Text("Inside tab " + tabText)
TextField("Info", text: $someInformation)
}
}
}
struct ContentView: View {
#EnvironmentObject var viewModel: ViewModel
#Binding var someInformation: String
var body: some View {
TabView(selection: $viewModel.activeTab){
Tab(tabText: "1", someInformation: $someInformation)
.tabItem{
Label("Tab 1", systemImage: "circle")
}
.tag(TabType.tab1)
Tab(tabText: "2", someInformation: $someInformation)
.tabItem{
Label("Tab 2", systemImage: "circle")
}
.tag(TabType.tab2)
}
}
}
#main
struct DisableMenuButtonsMultiWindowApp: App {
#StateObject var viewModel = ViewModel() // CHANGED
#State var someInformation: String = ""
var body: some Scene {
WindowGroup {
ContentView(someInformation: $someInformation)
.environmentObject(viewModel)
}
.commands{MenuCommands(viewModel: viewModel, someInformation: $someInformation)}
}
}
Slightly less minimal example that doesn't work:
Unfortunately that didn't work in my app, so here is a new minimal example that works closer to the actual app and will observe multiple windows, except it has the same issue as before.
import SwiftUI
enum TabType: String, Codable{
case tab1 = "first tab"
case tab2 = "second tab"
}
public class ViewModel: ObservableObject {
#Published var activeTab: TabType = .tab1
}
struct MenuCommands: Commands {
#ObservedObject var globalViewModel: GlobalViewModel
#Binding var someInformation: String
var body: some Commands{
CommandMenu("My menu"){
Text(someInformation)
Button("Go to tab 1"){
globalViewModel.activeViewModel?.activeTab = .tab1
}
.disabled(globalViewModel.activeViewModel?.activeTab == .tab1) // this will not disable when activeTab changes, but it will when someInformation changes
Button("Go to tab 2"){
globalViewModel.activeViewModel?.activeTab = .tab2
}
.disabled(globalViewModel.activeViewModel?.activeTab == .tab2) // this will not disable when activeTab changes, but it will when someInformation changes
Button("Print active tab"){
print(globalViewModel.activeViewModel?.activeTab ?? "") // this always returns correctly
}
}
}
}
struct Tab: View{
var tabText: String
#Binding var someInformation: String
var body: some View{
VStack{
Text("Inside tab " + tabText)
TextField("Info", text: $someInformation)
}
}
}
struct ContentView: View {
#EnvironmentObject var globalViewModel : GlobalViewModel
#Binding var someInformation: String
#StateObject var viewModel: ViewModel = ViewModel()
var body: some View {
HostingWindowFinder { window in
if let window = window {
self.globalViewModel.addWindow(window: window)
print("New Window", window.windowNumber)
self.globalViewModel.addViewModel(self.viewModel, forWindowNumber: window.windowNumber)
}
}
TabView(selection: $viewModel.activeTab){
Tab(tabText: "1", someInformation: $someInformation)
.tabItem{
Label("Tab 1", systemImage: "circle")
}
.tag(TabType.tab1)
Tab(tabText: "2", someInformation: $someInformation)
.tabItem{
Label("Tab 2", systemImage: "circle")
}
.tag(TabType.tab2)
}
}
}
#main
struct DisableMenuButtonsMultiWindowApp: App {
#StateObject var globalViewModel = GlobalViewModel()
#State var someInformation: String = ""
var body: some Scene {
WindowGroup {
ContentView(someInformation: $someInformation)
.environmentObject(globalViewModel)
}
.commands{MenuCommands(globalViewModel: globalViewModel, someInformation: $someInformation)}
}
}
// everything below is from other solution for observing multiple windows
class GlobalViewModel : NSObject, ObservableObject {
// all currently opened windows
#Published var windows = Set<NSWindow>()
// all view models that belong to currently opened windows
#Published var viewModels : [Int:ViewModel] = [:]
// currently active aka selected aka key window
#Published var activeWindow: NSWindow?
// currently active view model for the active window
#Published var activeViewModel: ViewModel?
override init() {
super.init()
// deallocate a window when it is closed
// thanks for this Maciej Kupczak 🙏
NotificationCenter.default.addObserver(
self,
selector: #selector(onWillCloseWindowNotification(_:)),
name: NSWindow.willCloseNotification,
object: nil
)
}
#objc func onWillCloseWindowNotification(_ notification: NSNotification) {
guard let window = notification.object as? NSWindow else {
return
}
var viewModels = self.viewModels
viewModels.removeValue(forKey: window.windowNumber)
self.viewModels = viewModels
}
func addWindow(window: NSWindow) {
window.delegate = self
windows.insert(window)
}
// associates a window number with a view model
func addViewModel(_ viewModel: ViewModel, forWindowNumber windowNumber: Int) {
viewModels[windowNumber] = viewModel
}
}
extension GlobalViewModel : NSWindowDelegate {
func windowWillClose(_ notification: Notification) {
if let window = notification.object as? NSWindow {
windows.remove(window)
viewModels.removeValue(forKey: window.windowNumber)
print("Open Windows", windows)
print("Open Models", viewModels)
// windows = windows.filter { $0.windowNumber != window.windowNumber }
}
}
func windowDidBecomeKey(_ notification: Notification) {
if let window = notification.object as? NSWindow {
print("Activating Window", window.windowNumber)
activeWindow = window
activeViewModel = viewModels[window.windowNumber]
}
}
}
struct HostingWindowFinder: NSViewRepresentable {
var callback: (NSWindow?) -> ()
func makeNSView(context: Self.Context) -> NSView {
let view = NSView()
DispatchQueue.main.async { [weak view] in
self.callback(view?.window)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
What I've tried:
Changing viewModel to Binding, StateObject and now changing to StateObject and Observed Object. It also doesn't matter if it's sent to the ContentView as a Binding or EnvironmentObject, it still happens.

In DisableMenuButtonsMultiWindowApp change
#State var viewModel = ViewModel()
To
#StateObject var viewModel: ViewModel = ViewModel()
And in the MenuCommands use
#ObservedObject var viewModel: ViewModel
instead
https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app
#State is used to initialize value types such as a struct and #StateObject is for reference type ObservableObjects such as your ViewModel.
Something else to note is that each #State does not know about changes to another #State an #Binding is a two-way connection.

Related

Allow custom tap gesture in List but maintain default selection gesture

I'm trying to create a List that allows multiple selection. Each row can be edited but the issue is that since there's a tap gesture on the Text element, the list is unable to select the item.
Here's some code:
import SwiftUI
struct Person: Identifiable {
let id: UUID
let name: String
init(_ name: String) {
self.id = UUID()
self.name = name
}
}
struct ContentView: View {
#State private var persons = [Person("Peter"), Person("Jack"), Person("Sophia"), Person("Helen")]
#State private var selectedPersons = Set<Person.ID>()
var body: some View {
VStack {
List(selection: $selectedPersons) {
ForEach(persons) { person in
PersonView(person: person, selection: $selectedPersons) { newValue in
// ...
}
}
}
}
.padding()
}
}
struct PersonView: View {
var person: Person
#Binding var selection: Set<Person.ID>
var onCommit: (String) -> Void = { newValue in }
#State private var isEditing = false
#State private var newValue = ""
#FocusState private var isInputActive: Bool
var body: some View {
if isEditing {
TextField("", text: $newValue, onCommit: {
onCommit(newValue)
isEditing = false
})
.focused($isInputActive)
.labelsHidden()
}
else {
Text(person.name)
.onTapGesture {
if selection.contains(person.id), selection.count == 1 {
newValue = person.name
isEditing = true
isInputActive = true
}
}
}
}
}
Right now, you need to tap on the row anywhere but on the text to select it. Then, if you tap on the text it'll go in edit mode.
Is there a way to let the list do its selection? I tried wrapping the tap gesture in simultaneousGesture but that didn't work.
Thanks!

SwiftUI, How to publish data from view to a viewModel then to a second view?

I have one view (with a Form), a viewModel, and a second view that I hope to display inputs in the Form of the first view. I thought property wrapping birthdate with #Published in the viewModel would pull the Form input, but so far I can't get the second view to read the birthdate user selects in the Form of the first view.
Here is my code for my first view:
struct ProfileFormView: View {
#EnvironmentObject var appViewModel: AppViewModel
#State var birthdate = Date()
var body: some View {
NavigationView {
Form {
Section(header: Text("Personal Information")) {
DatePicker("Birthdate", selection: $birthdate, displayedComponents: .date)
}
}
}
Here is my viewModel code:
class AppViewModel: ObservableObject {
#Published var birthdate = Date()
func calcAge(birthdate: String) -> Int {
let dateFormater = DateFormatter()
dateFormater.dateFormat = "MM/dd/yyyy"
let birthdayDate = dateFormater.date(from: birthdate)
let calendar: NSCalendar! = NSCalendar(calendarIdentifier: .gregorian)
let now = Date()
let calcAge = calendar.components(.year, from: birthdayDate!, to: now, options: [])
let age = calcAge.year
return age!
and here is my second view code:
struct UserDataView: View {
#EnvironmentObject var viewModel: AppViewModel
#StateObject var vm = AppViewModel()
var body: some View {
VStack {
Text("\(vm.birthdate)")
Text("You are signed in")
Button(action: {
viewModel.signOut()
}, label: {
Text("Sign Out")
.frame(width: 200, height: 50)
.foregroundColor(Color.blue)
})
}
}
And it may not matter, but here is my contentView where I can tab between the two views:
struct ContentView: View {
#EnvironmentObject var viewModel: AppViewModel
var body: some View {
NavigationView {
ZStack {
if viewModel.signedIn {
ZStack {
Color.blue.ignoresSafeArea()
.navigationBarHidden(true)
TabView {
ProfileFormView()
.tabItem {
Image(systemName: "square.and.pencil")
Text("Profile")
}
UserDataView()
.tabItem {
Image(systemName: "house")
Text("Home")
}
}
}
}
else
{
SignInView()
}
}
}
.onAppear {
viewModel.signedIn = viewModel.isSignedIn
}
}
One last note, I've got a second project that requires this functionality (view to viewmodel to view) so skipping the viewmodel and going direct from view to view will not help.
Thank you so much!!
Using a class AppViewModel: ObservableObject like you do is the appropriate way to "pass" the data around your app views. However, there are a few glitches in your code.
In your first view (ProfileFormView), remove #State var birthdate = Date() and use
DatePicker("Birthdate", selection: $appViewModel.birthdate, ....
Also remove #StateObject var vm = AppViewModel() in your second view (UserDataView),
you already have a #EnvironmentObject var viewModel: AppViewModel, no need for 2 of them.
Put #StateObject var vm = AppViewModel() up in your hierarchy of views,
and pass it down (as you do) using the #EnvironmentObject with
.environmentObject(vm)
Read this info to understand how to manage your data: https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app

SwiftUI presenting sheet with Binding variable doesn't work when first presented

I'm trying to present a View in a sheet with a #Binding String variable that just shows/binds this variable in a TextField.
In my main ContentView I have an Array of Strings which I display with a ForEach looping over the indices of the Array, showing a Button each with the text of the looped-over-element.
The Buttons action is simple: set an #State "index"-variable to the pressed Buttons' Element-index and show the sheet.
Here is my ContentView:
struct ContentView: View {
#State var array = ["first", "second", "third"]
#State var showIndex = 0
#State var showSheet = false
var body: some View {
VStack {
ForEach (0 ..< array.count, id:\.self) { i in
Button("\(array[i])") {
showIndex = i
showSheet = true
}
}
// Text("\(showIndex)") // if I uncomment this line, it works!
}
.sheet(isPresented: $showSheet, content: {
SheetView(text: $array[showIndex])
})
.padding()
}
}
And here is the SheetView:
struct SheetView: View {
#Binding var text: String
#Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
TextField("text:", text: $text)
Button("dismiss") {
presentationMode.wrappedValue.dismiss()
}
}.padding()
}
}
The problem is, when I first open the app and press on the "second" Button, the sheet opens and displays "first" in the TextField. I can then dismiss the Sheet and press the "second" Button again with the same result.
If I then press the "third" or "first" Button everything works from then on. Pressing any Button results in the correct behaviour.
Preview
Interestingly, if I uncomment the line with the Text showing the showIndex-variable, it works from the first time on.
Is this a bug, or am I doing something wrong here?
You should use custom Binding, custom Struct for solving the issue, it is complex issue. See the Example:
struct ContentView: View {
#State private var array: [String] = ["first", "second", "third"]
#State private var customStruct: CustomStruct?
var body: some View {
VStack {
ForEach (array.indices, id:\.self) { index in
Button(action: { customStruct = CustomStruct(int: index) }, label: {
Text(array[index]).frame(width: 100)
})
}
}
.frame(width: 300, height: 300, alignment: .center)
.background(Color.gray.opacity(0.5))
.sheet(item: $customStruct, content: { item in SheetView(text: Binding.init(get: { () -> String in return array[item.int] },
set: { (newValue) in array[item.int] = newValue }) ) })
}
}
struct CustomStruct: Identifiable {
let id: UUID = UUID()
var int: Int
}
struct SheetView: View {
#Binding var text: String
#Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
TextField("text:", text: $text)
Button("dismiss") {
presentationMode.wrappedValue.dismiss()
}
}.padding()
}
}
I had this happen to me before. I believe it is a bug, in that until it is used in the UI, it doesn't seem to get set in the ForEach. I fixed it essentially in the same way you did, with a bit of subtlety. Use it in each Button as part of the Label but hide it like so:
Button(action: {
showIndex = i
showSheet = true
}, label: {
HStack {
Text("\(array[i])")
Text(showIndex.description)
.hidden()
}
})
This doesn't change your UI, but you use it so it gets properly updated. I can't seem to find where I had the issue in my app, and I have changed the UI to get away from this, but I can't remember how I did it. I will update this if I can find it. This is a bit of a kludge, but it works.
Passing a binding to the index fix the issue like this
struct ContentView: View {
#State var array = ["First", "Second", "Third"]
#State var showIndex: Int = 0
#State var showSheet = false
var body: some View {
VStack {
ForEach (0 ..< array.count, id:\.self) { i in
Button(action:{
showIndex = i
showSheet.toggle()
})
{
Text("\(array[i])")
}.sheet(isPresented: $showSheet){
SheetView(text: $array, index: $showIndex)
}
}
}
.padding()
}
}
struct SheetView: View {
#Binding var text: [String]
#Binding var index: Int
#Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
TextField("text:", text: $text[index])
Button("dismiss") {
presentationMode.wrappedValue.dismiss()
}
}.padding()
}
}
In SwiftUI2 when calling isPresented if you don't pass bindings you're going to have some weird issues.
This is a simple tweak if you want to keep it with the isPresented and make it work but i would advise you to use the item with a costum struct like the answer of swiftPunk
This is how I would do it. You'll lose your form edits if you don't use #State variables.
This Code is Untested
struct SheetView: View {
#Binding var text: String
#State var draft: String
#Environment(\.presentationMode) var presentationMode
init(text: Binding<String>) {
self._text = text
self._draft = State(initialValue: text.wrappedValue)
}
var body: some View {
VStack {
TextField("text:", text: $draft)
Button("dismiss") {
text = draft
presentationMode.wrappedValue.dismiss()
}
}.padding()
}
}

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)")
})
}
}
}

macOS SwiftUI Textfield gets deleted after losing focus

I have a strange issue where the textfield gets deleted after selecting another textfield.
I have an EnvironmentObject
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Create the SwiftUI view that provides the window contents.
let shellInteractor = ShellInteractor()
let contentView = ContentView().environmentObject(shellInteractor)
}
injected in the view
struct ContentView: View {
#EnvironmentObject var shellInteractor: ShellInteractor
var body: some View {
ScrollView {
VStack {
HStack {
Text("Enter target bundle identifier:")
TextField("com.mycompany.app", text: $shellInteractor.bundleId)
}.padding()
HStack {
Text("Enter icon badge count:")
TextField("0", text: $shellInteractor.badgeNumber)
}.padding()
HStack {
Text("Enter message identifier:")
TextField("ABCDEFGHIJ", text: $shellInteractor.messageId)
}.padding()
Text("Found Running Sim: ")
Text(self.shellInteractor.shellOutput).fontWeight(.semibold)
Button(action: {
self.shellInteractor.sendNotification()
}) {
Text("SEND!!!")
.fontWeight(.semibold)
}.padding()
}.padding()
}
}
}
class ShellInteractor: ObservableObject {
#Published var shellOutput: String = ""
public var badgeNumber: String = ""
public var messageId: String = ""
public var bundleId: String = ""
}
As I said, when I enter a text in any of the textfields and select another text field or tap the TAB key (basically when losing focus), the textfield deletes the text and shows the placeholder again.
update your model
class ShellInteractor: ObservableObject {
#Published var shellOutput: String = ""
#Published var badgeNumber: String = ""
#Published var messageId: String = ""
#Published var bundleId: String = ""
}

Resources