Refresh List After a New Entity is Added in Core Data for SwiftUI App - ios

I am building a small app using SwiftUI and Core Data. I have a main view, which launches the sheet. The sheet allows me to add a new movie to the SQLite database through Core Data. But I am having a hard time to refresh the parent view once the sheet is dismissed.
ContentView
struct ContentView: View {
#State private var isPresented: Bool = false
#StateObject private var vm = MovieListViewModel()
var body: some View {
NavigationView {
VStack {
List(vm.movies) { movie in
Text(movie.title)
}
}
.navigationTitle("Movies")
.toolbar(content: {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Add Movie") {
isPresented = true
}
}
})
.sheet(isPresented: $isPresented, content: {
AddMovieView()
})
.onAppear {
try? vm.populateMovies()
}.padding()
}
}
}
AddMovieView
struct AddMovieView: View {
#Environment(\.dismiss) private var dismiss
#StateObject private var vm = AddMovieViewModel()
var body: some View {
Form {
TextField("Title", text: $vm.title)
Button("Save") {
do {
try vm.saveMovie()
dismiss()
} catch {
print(error.localizedDescription)
}
}
}
}
}
Do I need to call vm.populateMovies() on the onDismiss function of the sheet from the ContentView?

You can use a #FetchRequest as follows:
struct ContentView: View {
#FetchRequest(sortDescriptors: []) var movies: FetchedResults<Movie>
#State private var isPresented: Bool = false
#StateObject private var vm = MovieListViewModel()
var body: some View {
NavigationView {
VStack {
List(movies) { movie in
Text(movie.title)
}
}
.navigationTitle("Movies")
.toolbar(content: {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Add Movie") {
isPresented = true
}
}
})
.sheet(isPresented: $isPresented, content: {
AddMovieView()
})
.padding()
}
}
}
You won't need a populateMovies() as the FetchRequest result is automatically populated.

Related

Swiftui view doesn't refresh when navigated to from a different view

I have, what is probably, a beginner question here. I'm hoping there is something simple I'm missing or I have done wrong.
I essentially have a view which holds a struct containing an array of id strings. I then have a #FirestoreQuery which accesses a collection which holds objects with these id's. My view then displays a list with two sections. One for the id's in the original struct, and one for the remaining ones in the collection which don't appear in the array.
Each listitem is a separate view which displays the details of that item and also includes a button. When this button is pressed it adds/removes that object from the parent list and the view should update to show that object in the opposite section of the list from before.
My issue is that this works fine in the 'preview' in xcode when I look at this view on it's own. However if I run the app in the simulator, or even preview a parent view and navigate to this one, the refreshing of the view doesn't seem to work. I can press the buttons, and nothing happens. If i leave the view and come back, everything appears where it should.
I'll include all the files below. Is there something I'm missing here?
Thanks
Main view displaying the list with two sections
import SwiftUI
import FirebaseFirestoreSwift
struct SessionInvitesView: View {
#Environment(\.presentationMode) private var presentationMode
#FirestoreQuery(collectionPath: "clients") var clients : [Client]
#Binding var sessionViewModel : TrainingSessionViewModel
#State private var searchText: String = ""
#State var refresh : Bool = false
var enrolledClients : [Client] {
return clients.filter { sessionViewModel.session.invites.contains($0.id!) }
}
var availableClients : [Client] {
return clients.filter { !sessionViewModel.session.invites.contains($0.id!) }
}
var searchFilteredClients : [Client] {
if searchText.isEmpty {
return availableClients
} else {
return availableClients.filter {
$0.dogName.localizedCaseInsensitiveContains(searchText) ||
$0.name.localizedCaseInsensitiveContains(searchText) ||
$0.dogBreed.localizedCaseInsensitiveContains(searchText) }
}
}
var backButton: some View {
Button(action: { self.onCancel() }) {
Text("Back")
}
}
var body: some View {
NavigationView {
List {
Section(header: Text("Enrolled")) {
ForEach(enrolledClients) { client in
SessionInviteListItem(client: client, isEnrolled: true, onTap: removeClient)
}
}
Section(header: Text("Others")) {
ForEach(searchFilteredClients) { client in
SessionInviteListItem(client: client, isEnrolled: false, onTap: addClient)
}
}
}
.listStyle(.insetGrouped)
.searchable(text: $searchText)
.navigationTitle("Invites")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: backButton)
}
}
func removeClient(clientId: String) {
self.sessionViewModel.session.invites.removeAll(where: { $0 == clientId })
refresh.toggle()
}
func addClient(clientId: String) {
self.sessionViewModel.session.invites.append(clientId)
refresh.toggle()
}
func dismiss() {
self.presentationMode.wrappedValue.dismiss()
}
func onCancel() {
self.dismiss()
}
}
struct SessionInvitesView_Previews: PreviewProvider {
#State static var model = TrainingSessionViewModel()
static var previews: some View {
SessionInvitesView(sessionViewModel: $model)
}
}
List item view
import SwiftUI
struct SessionInviteListItem: View {
var client : Client
#State var isEnrolled : Bool
var onTap : (String) -> ()
var body: some View {
HStack {
VStack(alignment: .leading) {
HStack {
Text(client.dogName.uppercased())
.bold()
Text("(\(client.dogBreed))")
}
Text(client.name)
.font(.subheadline)
}
Spacer()
Button(action: { onTap(client.id!) }) {
Image(systemName: self.isEnrolled ? "xmark.circle.fill" : "plus.circle.fill")
}
.buttonStyle(.borderless)
.foregroundColor(self.isEnrolled ? .red : .green)
}
}
}
struct SessionInviteListItem_Previews: PreviewProvider {
static func doNothing(_ : String) {}
static var previews: some View {
SessionInviteListItem(client: buildSampleClient(), isEnrolled: false, onTap: doNothing)
}
}
Higher level view used to navigate to this list view
import SwiftUI
import FirebaseFirestoreSwift
struct TrainingSessionEditView: View {
// MARK: - Member Variables
#Environment(\.presentationMode) private var presentationMode
#FirestoreQuery(collectionPath: "clients") var clients : [Client]
#StateObject var sheetManager = SheetManager()
var mode: Mode = .new
var dateManager = DateManager()
#State var viewModel = TrainingSessionViewModel()
#State var sessionDate = Date.now
#State var startTime = Date.now
#State var endTime = Date.now.addingTimeInterval(3600)
var completionHandler: ((Result<Action, Error>) -> Void)?
// MARK: - Local Views
var cancelButton: some View {
Button(action: { self.onCancel() }) {
Text("Cancel")
}
}
var saveButton: some View {
Button(action: { self.onSave() }) {
Text("Save")
}
}
var addInviteButton : some View {
Button(action: { sheetManager.showInvitesSheet.toggle() }) {
HStack {
Text("Add")
Image(systemName: "plus")
}
}
}
// MARK: - Main View
var body: some View {
NavigationView {
List {
Section(header: Text("Details")) {
TextField("Session Name", text: $viewModel.session.title)
TextField("Location", text: $viewModel.session.location)
}
Section {
DatePicker(selection: $sessionDate, displayedComponents: .date) {
Text("Date")
}
.onChange(of: sessionDate, perform: { _ in
viewModel.session.date = dateManager.dateToStr(date: sessionDate)
})
DatePicker(selection: $startTime, displayedComponents: .hourAndMinute) {
Text("Start Time")
}
.onAppear() { UIDatePicker.appearance().minuteInterval = 15 }
.onChange(of: startTime, perform: { _ in
viewModel.session.startTime = dateManager.timeToStr(date: startTime)
})
DatePicker(selection: $endTime, displayedComponents: .hourAndMinute) {
Text("End Time")
}
.onAppear() { UIDatePicker.appearance().minuteInterval = 15 }
.onChange(of: endTime, perform: { _ in
viewModel.session.endTime = dateManager.timeToStr(date: endTime)
})
}
Section {
HStack {
Text("Clients")
Spacer()
Button(action: { self.sheetManager.showInvitesSheet.toggle() }) {
Text("Edit").foregroundColor(.blue)
}
}
ForEach(viewModel.session.invites, id: \.self) { clientID in
self.createClientListElement(id: clientID)
}
.onDelete(perform: deleteInvite)
}
Section(header: Text("Notes")) {
TextField("Add notes here...", text: $viewModel.session.notes)
}
if mode == .edit {
Section {
HStack {
Spacer()
Button("Delete Session") {
sheetManager.showActionSheet.toggle()
}
.foregroundColor(.red)
Spacer()
}
}
}
}
.navigationTitle(mode == .new ? "New Training Session" : "Edit Training Session")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(
leading: cancelButton,
trailing: saveButton)
.actionSheet(isPresented: $sheetManager.showActionSheet) {
ActionSheet(title: Text("Are you sure?"),
buttons: [
.destructive(Text("Delete Session"), action: { self.onDelete() }),
.cancel()
])
}
.sheet(isPresented: $sheetManager.showInvitesSheet) {
SessionInvitesView(sessionViewModel: $viewModel)
}
}
}
func createClientListElement(id: String) -> some View {
let client = clients.first(where: { $0.id == id })
if let client = client {
return AnyView(ClientListItem(client: client))
}
else {
return AnyView(Text("Invalid Client ID: \(id)"))
}
}
func deleteInvite(indexSet: IndexSet) {
viewModel.session.invites.remove(atOffsets: indexSet)
}
// MARK: - Local Event Handlers
func dismiss() {
self.presentationMode.wrappedValue.dismiss()
}
func onCancel() {
self.dismiss()
}
func onSave() {
self.viewModel.onDone()
self.dismiss()
}
func onDelete() {
self.viewModel.onDelete()
self.dismiss()
self.completionHandler?(.success(.delete))
}
// MARK: - Sheet Management
class SheetManager : ObservableObject {
#Published var showActionSheet = false
#Published var showInvitesSheet = false
}
}
struct TrainingSessionEditView_Previews: PreviewProvider {
static var previews: some View {
TrainingSessionEditView(viewModel: TrainingSessionViewModel(session: buildSampleTrainingSession()))
}
}
I'm happy to include any of the other files if you think it would help. Thanks in advance!

EmptyBody().onAppear not works when sheet present with item

When I present sheet with .sheet(isPresented... onAppear of EmptyView() triggered
but when I use .sheet(item... then onAppear doesn't trigger. I don't understand what mistake I am doing?
item:
enum ActiveSheet: Identifiable {
var id: String { UUID().uuidString }
case customA
case customB
}
Main View:
struct ContentView: View {
#State private var activeSheet: ActiveSheet?
var body: some View {
VStack {
Button(action: { activeSheet = .customA }) {
Text("View A")
}
Button(action: { activeSheet = .customB }) {
Text("View B")
}
}
.buttonStyle(.borderedProminent)
//If I use this .sheet(isPresented... then onAppear triggers, but not with item
.sheet(item: $activeSheet) { item in
switch item {
case .customA:
CustomViewA()
case .customB:
CustomViewB()
}
}
}
}
Empty Views:
struct CustomViewA: View {
var body: some View {
EmptyView()
.onAppear {
print("OnAppear")
}
}
}
struct CustomViewB: View {
var body: some View {
EmptyView()
.onAppear {
print("OnAppear")
}
}
}

Swift - Update List from different View

I have 2 Views in my Swift Project and when I click on the Button on the secondView, I want to update the List in the First View. I don't know how to do it! If I use a static variable in my MainView and then edit this variable from the secondView, it works, but it won't update. And if I don't use static and instead use #State, it would update, but I can't access it from my secondView.
Here is the Code below:
import SwiftUI
struct ContentView: View {
var body: some View {
TabView {
MainView()
.tabItem() {
VStack {
Image(systemName: "circle.fill")
Text("MainView")
}
}.tag(0)
UpdateOtherViewFromHere()
.tabItem() {
VStack {
Image(systemName: "circle.fill")
Text("SecondView")
}
}.tag(1)
}
}
}
struct MainView: View {
var arrayList: [CreateListItems] = []
init() {
let a = CreateListItems(name: "First Name!")
let b = CreateListItems(name: "Second Name!")
let c = CreateListItems(name: "Third Name!")
arrayList.append(a)
arrayList.append(b)
arrayList.append(c)
}
var body: some View {
return VStack {
ZStack {
NavigationView {
List {
ForEach(arrayList) { x in
Text("\(x.name)")
}
}.navigationBarTitle("Main View")
}
}
}
}
}
struct UpdateOtherViewFromHere: View {
func updateList() {
//Code that should remove "FirstName" from the List in MainView
}
var body: some View {
return VStack {
Button(action: {
updateList()
}) {
Image(systemName: "heart.slash")
.font(.largeTitle)
Text("Click Me!")
}
}
}
}
struct CreateListItems: Identifiable {
var id: UUID = UUID()
var name: String
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You can share it using #State and #Binding if you put
struct ContentView: View {
#State var arrayList: [CreateListItems] = []
struct MainView: View {
#Binding var arrayList: [CreateListItems]
struct UpdateOtherViewFromHere: View {
#Binding var arrayList: [CreateListItems]
or you use the MVVM pattern and store the list in an ObservableObject and use #StateObject/#ObservedObject (source) and use #EnvironmentObject(connection) to share it between your Views.
https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app
class ParentViewModel: ObservableObject{
#Published var arrayList: [CreateListItems] = []
init(){
addSamples()
}
func addSamples() {
let a = CreateListItems(name: "First Name!")
let b = CreateListItems(name: "Second Name!")
let c = CreateListItems(name: "Third Name!")
arrayList.append(a)
arrayList.append(b)
arrayList.append(c)
}
func updateList() {
let a = CreateListItems(name: "\(arrayList.count + 1) Name!")
arrayList.append(a)
}
}
struct ParentView: View {
#StateObject var vm: ParentViewModel = ParentViewModel()
var body: some View {
TabView {
MainView().environmentObject(vm)
.tabItem() {
VStack {
Image(systemName: "circle.fill")
Text("MainView")
}
}.tag(0)
UpdateOtherViewFromHere().environmentObject(vm)
.tabItem() {
VStack {
Image(systemName: "circle.fill")
Text("SecondView")
}
}.tag(1)
}
}
}
struct MainView: View {
#EnvironmentObject var vm: ParentViewModel
var body: some View {
return VStack {
ZStack {
NavigationView {
List {
ForEach(vm.arrayList) { x in
Text(x.name)
}
}.navigationBarTitle("Main View")
}
}
}
}
}
struct UpdateOtherViewFromHere: View {
#EnvironmentObject var vm: ParentViewModel
var body: some View {
return VStack {
Button(action: {
vm.updateList()
}) {
Image(systemName: "heart.slash")
.font(.largeTitle)
Text("Click Me!")
}
}
}
}

SwiftUI: Using #Binding to dismiss a modal view not working

I'm passing a #State var down a few views, using #Binding on the child views and when I ultimately set the variable to back to false, sometimes my view doesn't dismiss.
It seems like I can run articleDisplayed.toggle() but if I run an additional function above or below, it won't work.
Any idea what's going on here?
Here's my code:
struct HomeView: View {
#EnvironmentObject var state: AppState
#State var articleDisplayed = false
// MARK: - Body
var body: some View {
NavigationView {
ZStack {
List {
ForEach(state.cards, id: \.id) { card in
Button(action: {
self.articleDisplayed = true // I set it to true here
self.state.activeCard = card
}) {
HomeCell(
card: card,
publicationColor: self.state.publication.brandColor
)
}.sheet(isPresented: self.$articleDisplayed) {
SafariQuickTopicView(articleDisplayed: self.$articleDisplayed)
.environmentObject(self.state)
.environment(\.colorScheme, .light)
}
}
}
}
}
}
}
Then in my SafariQuickTopicView:
struct SafariQuickTopicView: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#EnvironmentObject var state: AppState
#Binding var articleDisplayed: Bool
var body: some View {
NavigationView {
ZStack(alignment: .bottom) {
// doesn't matter what's in here
}
.navigationBarItems(trailing: passButton)
}
}
private var passButton: some View {
Button(action: self.state.pass {
DispatchQueue.main.async {
// self.state.removeActiveCardFromState()
self.articleDisplayed.toggle() // this will work but adding a second function in here prevents it from working, above or below the toggle.
}
}
}) {
Text("Pass")
}
}
Finally, in my AppState:
func pass(completion: () -> Void) { // need completion?
guard let activeCard = activeCard else { return }
if let index = cards.firstIndex(where: { $0.id == activeCard.id }) {
activeCard.add(comment: "pass")
rejectCurrentCard() // Does an async operation with an external API but we don't care about the result
addRemovedActiveCardToUserDefaults()
completion()
}
}
Move .sheet out of List, it must be one per view hierarchy, so like
List {
ForEach(state.cards, id: \.id) { card in
Button(action: {
self.articleDisplayed = true // I set it to true here
self.state.activeCard = card
}) {
HomeCell(
card: card,
publicationColor: self.state.publication.brandColor
)
}
}
}
.sheet(isPresented: self.$articleDisplayed) {
SafariQuickTopicView(articleDisplayed: self.$articleDisplayed)
.environmentObject(self.state)
.environment(\.colorScheme, .light)
}

Core Data Object still saved on dismiss/cancel in presentationMode SwiftUI

When I'm trying to dismiss/cancel an Add Object Modal, it is creating an empty object instead of just cancelling.
I've tried deleteObject, context.rollback(), and a bunch of other random things. Would love some help and able to answer any questions.
I realize that this isn't an issue by putting the Cancel button in a NavigationBarItem but would like to be able to understand how to make an separate "cancel (or dismiss)" button.
ContentView.swift
import SwiftUI
import CoreData
struct ContentView: View {
#Environment(\.managedObjectContext) var moc
#FetchRequest(entity: Game.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \Game.gameName, ascending: true)]) var games: FetchedResults<Game>
#State private var showingAddGame = false
var body: some View {
GeometryReader { geometry in
NavigationView {
List {
ForEach(self.games, id: \.self) { games in
NavigationLink(destination: GameGoalsDetail(game: games)) {
VStack(alignment: .leading) {
Text(games.gameName ?? "Unknown Game")
Text(games.gameDescription ?? "Unknown Game Description")
}
}
}
.onDelete(perform: self.removeGames)
}
.navigationBarItems(leading:
HStack {
Button(action: {
self.showingAddGame.toggle()
}) {
Text("Add Game")
.padding(.top, 50)
.foregroundColor(Color.yellow)
}.sheet(isPresented: self.$showingAddGame) {
AddGameView().environment(\.managedObjectContext, self.moc)
}
Image("Game Goals App Logo")
.resizable()
.frame(width: 100, height: 100)
.padding(.leading, (geometry.size.width / 2.0) + -160)
.padding(.bottom, -50)
}, trailing:
EditButton()
.padding(.top, 50)
.foregroundColor(Color.yellow)
)
}
}
}
func removeGames(at offsets: IndexSet) {
for index in offsets {
let game = games[index]
moc.delete(game)
}
try? moc.save()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
let newGame = Game(context: context)
newGame.gameName = "Apex Legends"
newGame.gameDescription = "Maybe this will work"
return ContentView().environment(\.managedObjectContext, context)
}
}
AddGameView.swift
import SwiftUI
import CoreData
struct AddGameView: View {
#Environment(\.managedObjectContext) var moc
#FetchRequest(entity: Game.entity(), sortDescriptors: []) var games: FetchedResults<Game>
#Environment(\.presentationMode) var presentationMode
#State private var gameName = ""
#State private var gameDescription = ""
#State private var showingAlert = false
var body: some View {
Form {
Section {
TextField("Game Name", text: $gameName)
TextField("Game Description", text: $gameDescription)
}
HStack {
Button("Add Game") {
let newGame = Game(context: self.moc)
newGame.gameName = self.gameName
newGame.gameDescription = self.gameDescription
do {
try self.moc.save()
self.presentationMode.wrappedValue.dismiss()
} catch {
print("Whoops! \(error.localizedDescription)")
}
}
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Cancel")
}
.padding(10)
.foregroundColor(Color.white)
.background(Color.red)
}
}
}
}
struct AddGameView_Previews: PreviewProvider {
static var previews: some View {
AddGameView()
}
}
I've searched all over so if there is something out there that I've missed as far as a stackoverflow post, please link it as I'd like to not only fix this but understand why.
Your Cancel button is not creating an empty object. The problem is that the whole row in your form that has Add and Cancel buttons is interactive and triggers actions of your both buttons.
I have found an answer here: https://stackoverflow.com/a/59402642/12315994
To keep your current layout you need to simply add one line to each of your buttons:
.buttonStyle(BorderlessButtonStyle())
After this, only taping on each button will trigger actions. Form's row with the buttons will not be clickable.
There are 2 other solutions. Both are to move your buttons out of Form.
Solution 1
is to move buttons to NavigationBarItems like this:
import SwiftUI
import CoreData
struct AddGameView: View {
#Environment(\.managedObjectContext) var moc
#FetchRequest(entity: Game.entity(), sortDescriptors: []) var games: FetchedResults<Game>
#Environment(\.presentationMode) var presentationMode
#State private var gameName = ""
#State private var gameDescription = ""
#State private var showingAlert = false
var body: some View {
NavigationView {
VStack {
Form {
Section {
TextField("Game Name", text: $gameName)
TextField("Game Description", text: $gameDescription)
}
}
}
.navigationBarItems(
leading:
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Cancel")
}
.padding(10)
.foregroundColor(Color.white)
.background(Color.red)
,
trailing:
Button(action: {
let newGame = Game(context: self.moc)
newGame.gameName = self.gameName
newGame.gameDescription = self.gameDescription
do {
try self.moc.save()
self.presentationMode.wrappedValue.dismiss()
} catch {
print("Whoops! \(error.localizedDescription)")
}
}) {
Text("Add Game")
}
)
}
}
}
Solution 2
Is to move buttons out of Form and move them to the bottom of the screen. Like this:
import SwiftUI
import CoreData
struct AddGameView: View {
#Environment(\.managedObjectContext) var moc
#FetchRequest(entity: Game.entity(), sortDescriptors: []) var games: FetchedResults<Game>
#Environment(\.presentationMode) var presentationMode
#State private var gameName = ""
#State private var gameDescription = ""
#State private var showingAlert = false
var body: some View {
VStack {
Form {
Section {
TextField("Game Name", text: $gameName)
TextField("Game Description", text: $gameDescription)
}
}
HStack {
Button(action: {
let newGame = Game(context: self.moc)
newGame.gameName = self.gameName
newGame.gameDescription = self.gameDescription
do {
try self.moc.save()
self.presentationMode.wrappedValue.dismiss()
} catch {
print("Whoops! \(error.localizedDescription)")
}
}) {
Text("Add Game")
}
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Cancel")
}
.padding(10)
.foregroundColor(Color.white)
.background(Color.red)
}
}
}
}
Both options are better than your current layout from the UX point of view, because buttons are now in more standard locations. Especially version 1 is a more standard way of presenting buttons like this in iOS.

Resources