How to implement multi window with menu commands in SwiftUI for macOS? - swift

Situation
Implement a multi window application, where each window has its own state.
Example
Here is an example (on github) to showcase the question:
import SwiftUI
#main
struct multi_window_menuApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}.commands {
MenuCommands()
}
}
}
struct ContentView: View {
#StateObject var viewModel: ViewModel = ViewModel()
var body: some View {
TextField("", text: $viewModel.inputText)
.disabled(true)
.padding()
}
}
public class ViewModel: ObservableObject {
#Published var inputText: String = "" {
didSet {
print("content was updated...")
}
}
}
Question
How should we programmatically figure out what is the currently selected view so we can update the state when the menu command is about to finish and update the state in the view model?
import Foundation
import SwiftUI
import Combine
struct MenuCommands: Commands {
var body: some Commands {
CommandGroup(after: CommandGroupPlacement.newItem, addition: {
Divider()
Button(action: {
let dialog = NSOpenPanel();
dialog.title = "Choose a file";
dialog.showsResizeIndicator = true;
dialog.showsHiddenFiles = false;
dialog.allowsMultipleSelection = false;
dialog.canChooseDirectories = false;
if (dialog.runModal() == NSApplication.ModalResponse.OK) {
let result = dialog.url
if (result != nil) {
let path: String = result!.path
do {
let string = try String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8)
print(string)
// how to get access to the currently active view model to update the inputText variable?
// viewModel.inputText = string
}
catch {
print("Error \(error)")
}
}
} else {
return
}
}, label: {
Text("Open File")
})
.keyboardShortcut("O", modifiers: .command)
})
}
}
Links that might be useful to figure this out:
http://www.gfrigerio.com/build-a-macos-app-with-swiftui/
https://troz.net/post/2021/swiftui_mac_menus/
https://onmyway133.com/posts/how-to-manage-windowgroup-in-swiftui-for-macos/

Useful links:
How to access NSWindow from #main App using only SwiftUI?
How to access own window within SwiftUI view?
https://lostmoa.com/blog/ReadingTheCurrentWindowInANewSwiftUILifecycleApp/
(this is what I was able to come up with, if anyone has a better idea/approach, please share)
The idea is to create a shared "global" view model that keeps track of opened windows and view models. Each NSWindow has an attribute with a unique windowNumber. When a window becomes active (key), it looks up the view model by the windowNumber and sets it as the activeViewModel.
import SwiftUI
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?
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
}
}
Then, react on every change on window (when it is being closed and when it becomes an active aka key window):
import SwiftUI
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)
}
}
func windowDidBecomeKey(_ notification: Notification) {
if let window = notification.object as? NSWindow {
print("Activating Window", window.windowNumber)
activeWindow = window
activeViewModel = viewModels[window.windowNumber]
}
}
}
Provide a way to lookup window that is associated to the current view:
import SwiftUI
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) {}
}
Here is the view that is updating the global view model with the current window and viewModel:
import SwiftUI
struct ContentView: View {
#EnvironmentObject var globalViewModel : GlobalViewModel
#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)
}
}
TextField("", text: $viewModel.inputText)
.disabled(true)
.padding()
}
}
Then we need to create the global view model and send it to the views and commands:
import SwiftUI
#main
struct multi_window_menuApp: App {
#State var globalViewModel = GlobalViewModel()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(self.globalViewModel)
}
.commands {
MenuCommands(globalViewModel: self.globalViewModel)
}
Settings {
VStack {
Text("My Settingsview")
}
}
}
}
Here is how the commands look like, so they can access the currently selected/active viewModel:
import Foundation
import SwiftUI
import Combine
struct MenuCommands: Commands {
var globalViewModel: GlobalViewModel
var body: some Commands {
CommandGroup(after: CommandGroupPlacement.newItem, addition: {
Divider()
Button(action: {
let dialog = NSOpenPanel();
dialog.title = "Choose a file";
dialog.showsResizeIndicator = true;
dialog.showsHiddenFiles = false;
dialog.allowsMultipleSelection = false;
dialog.canChooseDirectories = false;
if (dialog.runModal() == NSApplication.ModalResponse.OK) {
let result = dialog.url
if (result != nil) {
let path: String = result!.path
do {
let string = try String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8)
print("Active Window", self.globalViewModel.activeWindow?.windowNumber)
self.globalViewModel.activeViewModel?.inputText = string
}
catch {
print("Error \(error)")
}
}
} else {
return
}
}, label: {
Text("Open File")
})
.keyboardShortcut("O", modifiers: [.command])
})
}
}
All is updated and runnable under this github project: https://github.com/ondrej-kvasnovsky/swiftui-multi-window-menu

I came across this question when I was solving a similar problem. I believe the SwiftUI way is to use FocusedValue:
// create an active viewmodel key
struct ActiveViewModelKey: FocusedValueKey {
typealias Value = ViewModel
}
extension FocusedValues {
var activeViewModel: ViewModel? {
get { self[ActiveViewModelKey.self] }
set { self[ActiveViewModelKey.self] = newValue }
}
}
struct ContentView: View {
#StateObject var viewModel: ViewModel = ViewModel()
var body: some View {
TextField("", text: $viewModel.inputText)
...
.focusedValue(\.activeViewModel, viewModel) // inject the focused value
}
}
struct MenuCommands: Commands {
#FocusedValue(\.activeViewModel) var activeViewModel // inject the active viewmodel
var body: some Commands {
CommandGroup(after: CommandGroupPlacement.newItem, addition: {
Divider()
Button(action: {
...
activeViewModel?.inputText = string
}, label: {
Text("Open File")
})
.keyboardShortcut("O", modifiers: [.command])
})
}
}

Related

SwiftUI: How to update textfield with live changes

In my content view i have function that detects whenever a user copies a website address
ContentView
#State private var detectedurl = ""
.................
.onAppear {
urlclipboardwatcher()
}
func urlclipboardwatcher() {
let pasteboard = NSPasteboard.general
var changeCount = NSPasteboard.general.changeCount
Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { _ in
if let copiedString = pasteboard.string(forType: .string) {
...............
if copiedString.starts(with: "https://") {
detectedurl = copiedString
}
}
}
}
I want to pass this value to the textfield in my NewBookmark View. How do i update the textfield with any changes that happen with the pasteboard?
struct NewBookmark: View {
#Binding var detectedurl: String
#ObservedObject private var vm: AddNewBookmarkViewModel
init(vm: AddNewBookmarkViewModel, detectedurl: Binding<String>) {
self.vm = vm
self._detectedurl = detectedurl
}
TextField("Enter a URL", text: $vm.url)
// i want the detected url to automatically populate this textfield
Button("Save") {
vm.save()
}.disabled(vm.url.isEmpty)
AddBookMarkViewModel
class AddNewBookmarkViewModel: ObservableObject {
#Published var url: String = ""
.............
func save() {
do {
let myBM = MyBookmark(context: context)
myBM.url = url
try myBM.save()
} catch {
print(error)
}
}
}
Tbh, I am not really sure how the code which you posted works. But I did something similar in the past. Maybe it helps.
What I basically did is, one viewModel with two views. Both views hold on to the viewModel PasteboardViewModel. PasteboardViewModel is a StateObject which is passed on two the second view via. environmentObject. And url variable in the viewModel is bound to the PasteboardView. So every time this Publisher changes the TextField does it too.
struct ContentView: View {
#StateObject var viewModel: PasteboardViewModel = .init()
var body: some View {
VStack {
.....
PasteboardView()
.environmentObject(viewModel)
}
.onAppear {
viewModel.watchPasteboard()
}
.padding()
}
}
struct PasteboardView: View {
#EnvironmentObject var viewModel: PasteboardViewModel
var body: some View {
TextField(text: $viewModel.url) {
Text("Test")
}
}
}
class PasteboardViewModel: ObservableObject {
#Published var url: String = ""
func watchPasteboard() {
let pasteboard = UIPasteboard.general
var changeCount = UIPasteboard.general.changeCount
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
if let copiedString = pasteboard.string {
if pasteboard.changeCount != changeCount {
self.url = copiedString
changeCount = pasteboard.changeCount
}
}
}
}
}

SwiftUI: #Environment(\.presentationMode)'s dismiss not working in iOS14

I have a view that shows a sheet for filtering the items in a list. The view has this var:
struct JobsTab: View {
#State private var jobFilter: JobFilter = JobFilter()
var filter: some View {
Button {
self.showFilter = true
} label: {
Image(systemName: "line.horizontal.3.decrease.circle")
.renderingMode(.original)
}
.sheet(isPresented: $showFilter) {
FilterView($jobFilter, categoriesViewModel, jobsViewModel)
}
}
However, in the sheet, I'm trying the following and I can't make the view dismissed when clicking on the DONE button, only on the CANCEL button:
struct FilterView: View {
#Environment(\.presentationMode) var presentationMode
#ObservedObject var categoriesViewModel: CategoriesViewModel
#ObservedObject var jobsViewModel: JobsViewModel
let filterViewModel: FilterViewModel
#Binding var jobFilter: JobFilter
#State private var draft: JobFilter
#State var searchText = ""
init(_ jobFilter: Binding<JobFilter>, _ categoriesViewModel: CategoriesViewModel, _ jobsViewModel: JobsViewModel) {
_jobFilter = jobFilter
_draft = State(wrappedValue: jobFilter.wrappedValue)
self.categoriesViewModel = categoriesViewModel
self.jobsViewModel = jobsViewModel
self.filterViewModel = FilterViewModel()
}
...
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("FilterView.Button.Cancel.Text".capitalizedLocalization) {
presentationMode.wrappedValue.dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("FilterView.Button.Done.Text".capitalizedLocalization) {
let request = Job.defaultRequest()
request.predicate = filterViewModel.buildPredicate(withJobFilterDraft: self.draft)
request.sortDescriptors = [NSSortDescriptor(key: #keyPath(Job.publicationDate), ascending: false)]
jobsViewModel.filteredJobsFetchRequest = request
self.jobFilter = self.draft
presentationMode.wrappedValue.dismiss()
}
}
}
I have also tried with a #Binding like Paul says here but there's no luck.
Is there any workaround, or am I doing something wrong?
Thanks in advance!
EDIT: I've posted the properties of both views, because I think the problem comes from the the line in FilterView self.jobFilter = self.draft.
What I'm trying to do here is create a filter view, and the aforementioned line will be executed when the user presses the DONE button: I want to assign my binding jobFilter in the JobsTab the value of the FilterView source of truth (which is a #State) and probably, since I'm updating the binding jobFilter the FilterView is being shown again even though the $showFilter is false? I don't know to be honest.
EDIT2: I have also tried
``
if #available(iOS 15.0, *) {
let _ = Self._printChanges()
} else {
// Fallback on earlier versions
}
in both `FilterView` and its called `JobTabs` and in both, I get the same result: unchanged
According to your code, I assumed your FilterView() is not a sub view, but an independent view by its own.
Therefore, to make sure "presentationMode.wrappedValue.dismiss()" works, you don't need to create #Binding or #State variables outside the FilerView() for passing the data back and forth between different views. Just create one variable inside your FilterView() to make it works.
I don't have your full code, but I created a similar situation to your problem as below code:
import SwiftUI
struct Main: View {
#State private var showFilter = false
var body: some View {
Button {
self.showFilter = true
} label: {
Image(systemName: "line.horizontal.3.decrease.circle")
.renderingMode(.original)
}
.sheet(isPresented: $showFilter) {
FilterView()
}
}
}
struct FilterView: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
NavigationView {
VStack {
Text("Filter View")
}.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
presentationMode.wrappedValue.dismiss()
} label: {
Text("cancel")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
presentationMode.wrappedValue.dismiss()
} label: {
Text("okay")
}
}
}
}
}
}
the parameter onDismiss is missing:
.sheet(isPresented: $showFilter, onDismiss: { isPresented = false }) { ... }
View model objects are usually a source of bugs because we don't use those in SwiftUI instead we use structs for speed and consistency. I recommend making an #State struct containing a bool property isSheetPresented and also the data the sheet needs. Add a mutating func and call it on the struct from the button event. Pass a binding to the struct into the sheet's view where you can call another mutating function to set the bool to false. Something like this:
struct SheetConfig {
var isPresented = false
var data: [String] = []
mutating func show(initialData: [String] ) {
isPresented = true
data = initialData
}
mutating func hide() {
isPresented = false
}
}
struct ContentView: View {
#State var config = SheetConfig()
var body: some View {
Button {
config.show(intialData: ...)
} label: {
}
.sheet(isPresented: $config.isPresented) {
FilterView($config)
}
Here is what I did to handle this issue.
Create a new protocol to avoid repeatations.
public protocol UIViewControllerHostedView where Self: View {
/// A method which should be triggered whenever dismiss is needed.
/// - Note: Since `presentationMode & isPresented` are not working for presented UIHostingControllers on lower iOS versions than 15. You must call, this method whenever you want to dismiss the presented SwiftUI.
func dismissHostedView(presentationMode: Binding<PresentationMode>)
}
public extension UIViewControllerHostedView {
func dismissHostedView(presentationMode: Binding<PresentationMode>) {
// Note: presentationMode & isPresented are not working for presented UIHostingControllers on lower iOS versions than 15.
if #available(iOS 15, *) {
presentationMode.wrappedValue.dismiss()
} else {
self.topViewController?.dismisOrPopBack()
}
}
}
Extend UIWindow and UIApplication
import UIKit
public extension UIWindow {
// Credits: - https://gist.github.com/matteodanelli/b8dcdfef39e3417ec7116a2830ff67cf
func visibleViewController() -> UIViewController? {
if let rootViewController: UIViewController = self.rootViewController {
return UIWindow.getVisibleViewControllerFrom(vc: rootViewController)
}
return nil
}
class func getVisibleViewControllerFrom(vc:UIViewController) -> UIViewController {
switch(vc){
case is UINavigationController:
let navigationController = vc as! UINavigationController
return UIWindow.getVisibleViewControllerFrom( vc: navigationController.visibleViewController!)
case is UITabBarController:
let tabBarController = vc as! UITabBarController
return UIWindow.getVisibleViewControllerFrom(vc: tabBarController.selectedViewController!)
default:
if let presentedViewController = vc.presentedViewController {
if let presentedViewController2 = presentedViewController.presentedViewController {
return UIWindow.getVisibleViewControllerFrom(vc: presentedViewController2)
}
else{
return vc;
}
}
else{
return vc;
}
}
}
}
#objc public extension UIApplication {
/// LCUIComponents: Returns the current visible top most window of the app.
var topWindow: UIWindow? {
return windows.first(where: { $0.isKeyWindow })
}
var topViewController: UIViewController? {
return topWindow?.visibleViewController()
}
}
Extend View to handle UIHostingController presented View`
public extension View {
weak var topViewController: UIViewController? {
UIApplication.shared.topViewController
}
}
Finally, use the helper method in your SwiftUI view as follows: -
struct YourView: View, UIViewControllerHostedView {
// MARK: - Properties
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body some View {
Button {
// Note: this part trigger a method in a created protocol above.
dismissHostedView(presentationMode: presentationMode)
} label: {
Text("Tap to dismiss")
}
}
In the sheet try adding this instead.
#Environment(\.dismiss) var dismiss // EnvironmentValue
Button("Some text") {
// Code
dismiss()
}

SwiftUI: ViewModel making app crash when loading a view

I want to rename an item in a ForEach list. When i try to load the EditListView for a selected list the entire app crashes.
This is a SwiftUI macOS app and the items are saved using CoreData.
The crash happens as soon as you click on "Edit List" for any of the list items.
It doesn't crash if i remove this view model var listVM: MyListViewModel from the EditListViewModel.
Here's the EditListView
struct EditListView: View {
let name: String
#Binding var isVisible: Bool
var list: MyListViewModel
#ObservedObject var editListVM: EditListViewModel
init(name: String,list: MyListViewModel, isVisible: Binding<Bool> ) {
self.list = list
editListVM = EditListViewModel(listVM: list)
_isVisible = isVisible
self.name = name
}
var body: some View {
VStack {
Text(name)
Button(action: {
editListItemVM.update()
}) {
Text("Update List Name")
}
Button(action: {
self.isVisible = false
}) {
Text("Cancel")
}
}......
EditListViewModel
class EditListViewModel: ObservableObject {
var listVM: MyListViewModel
#Published var name: String = ""
init(listVM: MyListViewModel) {
self.listVM = listVM
name = listVM.name
}
func update(){
....}
}
MyListViewModel
struct MyListViewModel: Identifiable {
private let myList: MyList
init(myList: MyList) {
self.myList = myList
}
var id: NSManagedObjectID {
myList.objectID
}
var name: String {
myList.name ?? ""
}
}
MyList Model
#objc(MyList)
public class MyList: NSManagedObject, BaseModel {
static var all: NSFetchRequest<MyList> {
let request: NSFetchRequest<MyList> = MyList.fetchRequest()
request.sortDescriptors = []
return request
}
}
extension MyList {
#nonobjc public class func fetchRequest() -> NSFetchRequest<MyList> {
return NSFetchRequest<MyList>(entityName: "MyList")
}
#NSManaged public var name: String?
}
extension MyList : Identifiable {
}
Here's the Main View
struct MyListsView: View {
#StateObject var vm: MyListsViewModel
#State private var showPopover: Bool = false
init(vm: MyListsViewModel) {
_vm = StateObject(wrappedValue: vm)
}
List {
Text("My Lists")
ForEach(vm.myLists) { myList in
NavigationLink {
MyListItemsHeaderView(name: myList.name)
.sheet(isPresented: $showPopover) {
EditListView(name: myList.name, list: MyListViewModel(myList: MyList()), isVisible: $showPopover)
}
}
}.contextMenu {
Button {
showPopover = true
// Show the EditListView
} label: {
Label("Edit List", systemImage: "pen.circle")
}......
First get rid of your view model objects we don't use those in SwiftUI. We use the View struct and the property wrappers like #FetchRequest make the struct behave like an object. It looks like this:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
List {
ForEach(items) { item in
ItemView(item: item)
I recommend looking at Xcode's app template with core data checked.
Then for editing you can use .sheet(item: like this:
struct ItemEditor: View {
#ObservedObject var item: Item // this is the scratch pad item
#Environment(\.managedObjectContext) private var context
#Environment(\.dismiss) private var dismiss // causes body to run
let onSave: () -> Void
#State var errorMessage: String?
var body: some View {
NavigationView {
Form {
Text(item.timestamp!, formatter: itemFormatter)
if let errorMessage = errorMessage {
Text(errorMessage)
}
Button("Update Time") {
item.timestamp = Date()
}
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
// first save the scratch pad context then call the handler which will save the view context.
do {
try context.save()
errorMessage = nil
onSave()
} catch {
let nsError = error as NSError
errorMessage = "Unresolved error \(nsError), \(nsError.userInfo)"
}
}
}
}
}
}
}
struct ItemEditorConfig: Identifiable {
let id = UUID()
let context: NSManagedObjectContext
let item: Item
init(viewContext: NSManagedObjectContext, objectID: NSManagedObjectID) {
// create the scratch pad context
context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
context.parent = viewContext
// load the item into the scratch pad
item = context.object(with: objectID) as! Item
}
}
struct EditItemButton: View {
let itemObjectID: NSManagedObjectID
#Environment(\.managedObjectContext) private var viewContext
#State var itemEditorConfig: ItemEditorConfig?
var body: some View {
Button(action: edit) {
Text("Edit")
}
.sheet(item: $itemEditorConfig, onDismiss: didDismiss) { config in
ItemEditor(item: config.item) {
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
itemEditorConfig = nil // dismiss the sheet
}
.environment(\.managedObjectContext, config.context)
}
}
func edit() {
itemEditorConfig = ItemEditorConfig(viewContext: viewContext, objectID: itemObjectID)
}
func didDismiss() {
// Handle the dismissing action.
}
}
struct ItemView: View {
#ObservedObject var item: Item
var body: some View {
Text("Item at \(item.timestamp!, formatter: itemFormatter)")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditItemButton(itemObjectID: item.objectID)
}
}
}
}
the params for EditListView in the main view were incorrect.
Fixed it with the following params:
.sheet(isPresented: $showPopover) {
EditListView(name: myList.name, list: myList, isVisible: $showPopover)
}

#ObservedObject not triggering redraw if in conditional

I have the following code:
import SwiftUI
struct RootView: View {
#ObservedObject var authentication: AuthenticationModel
var body: some View {
ZStack {
if self.authentication.loading {
Text("Loading")
} else if self.authentication.userId == nil {
SignInView()
} else {
ContentView()
}
}
}
}
However, the #ObservedObject's changes doesn't seem to trigger the switch to the other views. I can "fix" this by rendering
var body: some View {
VStack {
Text("\(self.authentication.loading ? "true" : "false") \(self.authentication.userId ?? "0")")
}.font(.largeTitle)
ZStack {
if self.authentication.loading {
Text("Loading")
} else if self.authentication.userId == nil {
SignInView()
} else {
ContentView()
}
}
}
and suddenly it starts working. Why does #ObservedObject not seem to trigger a rerender if the watched properties are only used in conditionals?
The code for AuthenticationModel is:
import SwiftUI
import Combine
import Firebase
import FirebaseAuth
class AuthenticationModel: ObservableObject {
#Published var userId: String?
#Published var loading = true
init() {
// TODO: Properly clean up this handle.
Auth.auth().addStateDidChangeListener { [unowned self] (auth, user) in
self.userId = user?.uid
self.loading = false
}
}
}
I think the problem could be that you aren't creating an instance of AuthenticationModel.
Can you try the following in RootView?:
#ObservedObject var authentication = AuthenticationModel()

Reinstantiate an #EnvironmentObject in a view

In my application, I have the need to reinstantiate an #EnvironmentObject at the tap of a button. Where is the right spot to do that?
Here's a screenshot of my view - in the Archived Objects list, I want to have immutable objects. So, when I tap Archive Object, I want to add the current object to the array and create a new current object.
A simplified version of the app looks as follows.
I instantiate the object in the SceneDelegate:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var myObject = MyObject()
let myObjects = MyObjects()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let contentView = ContentView()
.environmentObject(myObject)
.environmentObject(myObjects)
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
//.....
}
Here's my models:
class MyObject: ObservableObject, Identifiable {
#Published var id = UUID()
}
class MyObjects: ObservableObject {
#Published var values: [MyObject] = [
MyObject(),
MyObject()
]
}
In my view, I have the need to put my current object to an (archive) array and then create a new current object to work with. Here's what I'm trying:
struct ContentView: View {
#EnvironmentObject var currentObject: MyObject
#EnvironmentObject var objects: MyObjects
var sceneDelegate: UISceneDelegate {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let sd = windowScene.delegate as? SceneDelegate else { fatalError() }
return sd
}
var body: some View {
VStack {
Form {
Section(header: Text("Current Object")) {
Text(currentObject.id.uuidString)
}
Section(header: Text("Archived Objects")) {
List(objects.values, id: \.id) { object in
Text(object.id.uuidString)
}
Button(action: {
self.objects.values.append(self.currentObject)
// as the environment object is get only, I cannot reinstantiate it here...
// self.currentObject = MyObject()
}) {
Text("Archive Object")
}
}
}
Spacer()
}
}
}
Here is a solution. See comments inline.
Tested with Xcode 11.4 / iOS 13.4
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
// Usage
ContentView(current: MyObject())
.environmentObject(MyObjects())
}
}
struct ContentView: View {
#EnvironmentObject var objects: MyObjects
#State private var current: MyObject // tracks current
init(current: MyObject) {
_current = State(initialValue: current)
}
var body: some View {
// rebuilt on *current* changed
ContentInnerView(current: $current)
.environmentObject(current)
}
struct ContentInnerView: View {
#Binding var current: MyObject // change current
#EnvironmentObject var objects: MyObjects
#EnvironmentObject var currentObject: MyObject
init(current: Binding<MyObject>) {
_current = current
}
var sceneDelegate: UISceneDelegate {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let sd = windowScene.delegate as? SceneDelegate else { fatalError() }
return sd
}
var body: some View {
VStack {
Form {
Section(header: Text("Current Object")) {
Text(currentObject.id.uuidString)
}
Section(header: Text("Archived Objects")) {
List(objects.values, id: \.id) { object in
Text(object.id.uuidString)
}
Button(action: {
self.objects.values.append(self.currentObject)
self.current = MyObject() // << works !!
}) {
Text("Archive Object")
}
}
}
Spacer()
}
}
}
}