Am trying to implement like and unlike button, to allow a user to like a card and unlike. When a user likes a button the id is saved and the icon changes from line heart to filled heart. I can save the id correctly but the issue is that at times the icon does not switch to filled one to show the user the changes especially after selecting the first one. The subsequent card won't change state but remain the same, while it will add save the id correctly. To be able to see the other card I have to, unlike the first card it cand display both like at the same time. I have tried both Observable and Environmental.
My Class to handle like and unlike
import Foundation
import Disk
class FavouriteRest: ObservableObject {
#Published private var fav = [Favourite]()
init() {
getFav()
}
func getFav(){
if let retrievedFav = try? Disk.retrieve("MyApp/favourite.json", from: .documents, as: [Favourite].self) {
fav = retrievedFav
} else {
print("")
}
}
//Get single data
func singleFave(id: String) -> Bool {
for x in fav {
if id == x.id {
return true
}
return false
}
return false
}
func addFav(favourite: Favourite){
if singleFave(id: favourite.id) == false {
self.fav.append(favourite)
self.saveFave()
}
}
//Remove Fav
func removeFav(_ favourite: Favourite) {
if let index = fav.firstIndex(where: { $0.id == favourite.id }) {
fav.remove(at: index)
saveFave()
}
}
//Save Fav
func saveFave(){
do {
try Disk.save(self.fav, to: .documents, as: "SmartParking/favourite.json")
}
catch let error as NSError {
fatalError("""
Domain: \(error.domain)
Code: \(error.code)
Description: \(error.localizedDescription)
Failure Reason: \(error.localizedFailureReason ?? "")
Suggestions: \(error.localizedRecoverySuggestion ?? "")
""")
}
}
}
Single Card
#EnvironmentObject var favourite:FavouriteRest
HStack(alignment: .top){
VStack(alignment: .leading, spacing: 4){
Text(self.myViewModel.myModel.title)
.font(.title)
.fontWeight(.bold)
Text("Some text")
.foregroundColor(Color("Gray"))
.font(.subheadline)
.fontWeight(.bold)
}
Spacer()
VStack{
self.favourite.singleFave(id: self.myViewModel.myModel.id) ? Heart(image: "suit.heart.fill").foregroundColor(Color.red) : Heart(image: "suit.heart").foregroundColor(Color("Gray"))
}
.onTapGesture {
if self.favourite.singleFave(id: self.myViewModel.myModel.id) {
self.favourite.removeFav(Favourite(id: self.myViewModel.myModel.id))
} else {
self.favourite.addFav(favourite: Favourite(id: self.myViewModel.myModel.id))
}
}
}
I was able to solve the question by moving the code inside the card view and using #States as shown below.
#State private var fav = [Favourite]()
#State var liked = false
VStack{
// Heading
HStack(alignment: .top){
VStack{
self.liked ? Heart(image:"suit.heart.fill").foregroundColor(Color.red) : Heart(image: "suit.heart").foregroundColor(Color("Gray"))
}
.onTapGesture {
if self.liked {
self.removeFav(self.singleFav!)
} else {
let faveID = self.myViewModel.myModel.id
let myFav = Favourite(id:faveID)
self.fav.append(myFav)
self.saveFave()
}
}
}
In the method for fetch and remove, I updated the #State var liked. Everything working as expected now.
Related
First Dabble in SwiftUI, I've managed to get the below code working such that when I press a button, it will show a "selected" state and add the selected sports into an array. (and remove from the array if "deselected")
However, I can't figure out how to initialise the sportsArray with ALL values within the enum HelperIntervalsIcu.icuActivityType.allCases if it is initially empty.
I tried to put in
if sportsArray.isEmpty {
HelperIntervalsIcu.icuActivityType.allCases.forEach {
sportsArray.append($0.rawvalue)
}
but Xcode keeps telling me type() cannot conform to View or things along those lines
struct selectSports: View {
#State private var sportsArray = [String]()
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
// https://stackoverflow.com/a/69455949/14414215
ForEach(Array(HelperIntervalsIcu.icuActivityType.allCases), id:\.rawValue) { sport in
Button(action: {
addSports(sport: sport.rawValue)
}) {
HStack {
Image(getSportsIcon(sport: sport.rawValue))
.selectedSportsImageStyle(sportsArray: sportsArray, sport: sport.rawValue)
Text(sport.rawValue)
}
}
.buttonStyle(SelectedSportButtonStyle(sportsArray: sportsArray, sport: sport.rawValue))
}
}
}
}
struct SelectedSportButtonStyle: ButtonStyle {
var sportsArray: [String]
var sport: String
var selectedSport : Bool {
if sportsArray.contains(sport) {
return true
} else {
return false
}
}
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.font(selectedSport ? Font.subheadline.bold() : Font.subheadline)
.aspectRatio(contentMode: .fit)
.foregroundColor(selectedSport ? Color.orange : Color(UIColor.label))
.padding([.leading, .trailing], 15)
.padding([.top, .bottom],10)
.overlay(
RoundedRectangle(cornerRadius: 5.0)
.stroke(lineWidth: 2.0)
.foregroundColor(selectedSport ? Color.orange : Color.gray)
)
.offset(x: 10, y: 0)
}
}
func addSports(sport: String) {
if sportsArray.contains(sport) {
let sportsIndex = sportsArray.firstIndex(where: { $0 == sport })
sportsArray.remove(at: sportsIndex!)
} else {
sportsArray.append(sport)
}
print("Selected Sports:\(sportsArray)")
}
}
No Sports Selected (in this case sportsArray is empty and thus the default state which I would like have would be to have ALL sports Pre-selected)
2 Sports Selected
I assume you wanted to do that in onAppear, like
struct selectSports: View {
#State private var sportsArray = [String]()
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
// .. other code
}
.onAppear {
if sportsArray.isEmpty { // << here !!
HelperIntervalsIcu.icuActivityType.allCases.forEach {
sportsArray.append($0.rawvalue)
}
}
}
}
}
Struggling to figure out how to store the selection of a button here and hoping somebody could help. I am first making a db query to return a list of results, each item has a button that when clicked, it will always show that they clicked it. The way it is currently working, while I'm on the page, I can click to add, which works and writes to the db, and when clicked it changes to the Undo button, which also works the way I want it to, but as soon as I leave this view and come back, it reverts back to the original with the "Add" button. If a user presses Add, i would like it to persist and always show the Undo button when the user comes back to this screen. Any help would be amazing as I'm quite new to this.
Code below:
Main View
struct AdditemView: View {
let movie: Movie
#State var movieitems = [MovieitemsModel]()
#State var movieID = ""
#State var selection = ""
var body: some View {
VStack {
ForEach(movieitems.indices, id: \.self) { i in
HStack {
Text(movieitems[i].name)
Spacer()
if movieitems[i].selected == false {
Button(action: {
self.selection = movieitems[i].name
self.movieitems[i].selected = true
self.yesitem()
}) {
Text("Add")
}
} else {
HStack {
Text("Added!")
Button(action: {
self.selection = movieitems[i].name
self.movieitems[i].selected = false
self.undoitem()
}) {
Text("Undo")
}
}
}
}
Divider()
}
}
.onAppear {
self.movieID = "\(movie.id)"
fetchitems()
}
}
}
func fetchitems() {
let db = Firestore.firestore()
db.collection("movies").document("\(self.movieID)").collection("items").getDocuments { NewQuerySnapshot, error in
guard let documents = NewQuerySnapshot?.documents else {
print("No Documents")
return
}
movieitems = documents.map { (NewQueryDocumentSnapshot) -> MovieitemsModel in
let data = NewQueryDocumentSnapshot.data()
let name = data["name"] as? String ?? ""
let yes = data["yes"] as? Int?
let no = data["no"] as? Int?
return MovieitemsModel(name: name, yes: yes! ?? 0, no: no! ?? 0)
}
}
}
func yesitem() {
let db = Firestore.firestore()
db.collection("movies").document(movieID).collection("items").document("\(self.selection)").updateData([AnyHashable("yes"): FieldValue.increment(Int64(1))])
}
func undoitem() {
let db = Firestore.firestore()
db.collection("movies").document(movieID).collection("items").document("\(self.selection)").updateData([AnyHashable("yes"): FieldValue.increment(Int64(-1))])
}
Items Model
struct MovieitemsModel: Identifiable {
var id: String = UUID().uuidString
var name: String
var yes: Int
var no: Int
var selected:Bool = false
}
One way of doing this is writing another function that saves the movies data back to the database and call it in each button’s action handler.
// Example button
Button(action: {
self.selection = movieitems[i].name
self.movieitems[i].selected = true
self.undoitem()
self.saveItems()
}) {
Text("Undo")
}
// The save method
func saveItems():
// Your Firebase saving code here
I want to simply get the list of local notifications that are scheduled and populate a SwiftUI List using forEach. I believe it should work like I have done below, but the array is always empty as it seems to be used before the for loop is finished. I tried the getNotifications() function with a completion handler, and also as a return function, but both ways still didn't work. How can I wait until the for loop is done to populate my list? Or if there is another way to do this please let me know, thank you.
var notificationArray = [UNNotificationRequest]()
func getNotifications() {
print("getNotifications")
center.getPendingNotificationRequests(completionHandler: { requests in
for request in requests {
print(request.content.title)
notificationArray.append(request)
}
})
}
struct ListView: View {
var body: some View {
NavigationView {
List {
ForEach(notificationArray, id: \.content) { notification in
HStack {
VStack(alignment: .leading, spacing: 10) {
let notif = notification.content
Text(notif.title)
Text(notif.subtitle)
.opacity(0.5)
}
}
}
}
.onAppear() {
getNotifications()
}
}
}
Update:
Here is how I am adding a new notification and calling getNotifications again. I want the list to dynamically update as the new array is made. Printing to console shows that the getNotifications is working correctly and the new array contains the added notiication.
Section {
Button(action: {
print("Adding Notification: ", title, bodyText, timeIntValue[previewIndex])
addNotification(title: title, bodyText: bodyText, timeInt: timeIntValue[previewIndex])
showDetail = false
self.vm.getNotifications()
}) {
Text("Save Notification")
}
}.disabled(title.isEmpty || bodyText.isEmpty)
Your global notificationArray is not observed by view. It should be dynamic property... possible solution is to wrap it into ObservableObject view model.
Here is a demo of solution:
class ViewModel: ObservableObject {
#Published var notificationArray = [UNNotificationRequest]()
func getNotifications() {
print("getNotifications")
center.getPendingNotificationRequests(completionHandler: { requests in
var newArray = [UNNotificationRequest]()
for request in requests {
print(request.content.title)
newArray.append(request)
}
DispatchQueue.main.async {
self.notificationArray = newArray
}
})
}
}
struct ListView: View {
#ObservedObject var vm = ViewModel()
//#StateObject var vm = ViewModel() // << for SwiftUI 2.0
var body: some View {
NavigationView {
List {
ForEach(vm.notificationArray, id: \.content) { notification in
HStack {
VStack(alignment: .leading, spacing: 10) {
let notif = notification.content
Text(notif.title)
Text(notif.subtitle)
.opacity(0.5)
}
}
}
}
.onAppear() {
self.vm.getNotifications()
}
}
}
I've implemented a List with a search bar in SwiftUI. Now I want to implement paging for this list. When the user scrolls to the bottom of the list, new elements should be loaded. My problem is, how can I detect that the user scrolled to the end? When this happens I want to load new elements, append them and show them to the user.
My code looks like this:
import Foundation
import SwiftUI
struct MyList: View {
#EnvironmentObject var webService: GetRequestsWebService
#ObservedObject var viewModelMyList: MyListViewModel
#State private var query = ""
var body: some View {
let binding = Binding<String>(
get: { self.query },
set: { self.query = $0; self.textFieldChanged($0) }
)
return NavigationView {
// how to detect here when end of the list is reached by scrolling?
List {
// searchbar here inside the list element
TextField("Search...", text: binding) {
self.fetchResults()
}
ForEach(viewModelMyList.items, id: \.id) { item in
MyRow(itemToProcess: item)
}
}
.navigationBarTitle("Title")
}.onAppear(perform: fetchResults)
}
private func textFieldChanged(_ text: String) {
text.isEmpty ? viewModelMyList.fetchResultsThrottelt(for: nil) : viewModelMyList.fetchResultsThrottelt(for: text)
}
private func fetchResults() {
query.isEmpty ? viewModelMyList.fetchResults(for: nil) : viewModelMyList.fetchResults(for: query)
}
}
Also a little bit special this case, because the list contains the search bar. I would be thankful for any advice because with this :).
As you have already a List with an artificial row for the search bar, you can simply add another view to the list which will trigger another fetch when it appears on screen (using onAppear() as suggested by Josh). By doing this you do not have to do any "complicated" calculations to know whether a row is the last row... the artificial row is always the last one!
I already used this in one of my projects and I've never seen this element on the screen, as the loading was triggered so quickly before it appeared on the screen. (You surely can use a transparent/invisible element, or perhaps even use a spinner ;-))
List {
TextField("Search...", text: binding) {
/* ... */
}
ForEach(viewModelMyList.items, id: \.id) { item in
// ...
}
if self.viewModelMyList.hasMoreRows {
Text("Fetching more...")
.onAppear(perform: {
self.viewModelMyList.fetchMore()
})
}
}
Add a .onAppear() to the MyRow and have it call the viewModel with the item that just appears. You can then check if its equal to the last item in the list or if its n items away from the end of the list and trigger your pagination.
This one worked for me:
You can add pagination with two different approaches to your List: Last item approach and Threshold item approach.
That's way this package adds two functions to RandomAccessCollection:
isLastItem
Use this function to check if the item in the current List item iteration is the last item of your collection.
isThresholdItem
With this function you can find out if the item of the current List item iteration is the item at your defined threshold. Pass an offset (distance to the last item) to the function so the threshold item can be determined.
import SwiftUI
extension RandomAccessCollection where Self.Element: Identifiable {
public func isLastItem<Item: Identifiable>(_ item: Item) -> Bool {
guard !isEmpty else {
return false
}
guard let itemIndex = lastIndex(where: { AnyHashable($0.id) == AnyHashable(item.id) }) else {
return false
}
let distance = self.distance(from: itemIndex, to: endIndex)
return distance == 1
}
public func isThresholdItem<Item: Identifiable>(
offset: Int,
item: Item
) -> Bool {
guard !isEmpty else {
return false
}
guard let itemIndex = lastIndex(where: { AnyHashable($0.id) == AnyHashable(item.id) }) else {
return false
}
let distance = self.distance(from: itemIndex, to: endIndex)
let offset = offset < count ? offset : count - 1
return offset == (distance - 1)
}
}
Examples
Last item approach:
struct ListPaginationExampleView: View {
#State private var items: [String] = Array(0...24).map { "Item \($0)" }
#State private var isLoading: Bool = false
#State private var page: Int = 0
private let pageSize: Int = 25
var body: some View {
NavigationView {
List(items) { item in
VStack(alignment: .leading) {
Text(item)
if self.isLoading && self.items.isLastItem(item) {
Divider()
Text("Loading ...")
.padding(.vertical)
}
}.onAppear {
self.listItemAppears(item)
}
}
.navigationBarTitle("List of items")
.navigationBarItems(trailing: Text("Page index: \(page)"))
}
}
}
extension ListPaginationExampleView {
private func listItemAppears<Item: Identifiable>(_ item: Item) {
if items.isLastItem(item) {
isLoading = true
/*
Simulated async behaviour:
Creates items for the next page and
appends them to the list after a short delay
*/
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3) {
self.page += 1
let moreItems = self.getMoreItems(forPage: self.page, pageSize: self.pageSize)
self.items.append(contentsOf: moreItems)
self.isLoading = false
}
}
}
}
Threshold item approach:
struct ListPaginationThresholdExampleView: View {
#State private var items: [String] = Array(0...24).map { "Item \($0)" }
#State private var isLoading: Bool = false
#State private var page: Int = 0
private let pageSize: Int = 25
private let offset: Int = 10
var body: some View {
NavigationView {
List(items) { item in
VStack(alignment: .leading) {
Text(item)
if self.isLoading && self.items.isLastItem(item) {
Divider()
Text("Loading ...")
.padding(.vertical)
}
}.onAppear {
self.listItemAppears(item)
}
}
.navigationBarTitle("List of items")
.navigationBarItems(trailing: Text("Page index: \(page)"))
}
}
}
extension ListPaginationThresholdExampleView {
private func listItemAppears<Item: Identifiable>(_ item: Item) {
if items.isThresholdItem(offset: offset,
item: item) {
isLoading = true
/*
Simulated async behaviour:
Creates items for the next page and
appends them to the list after a short delay
*/
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5) {
self.page += 1
let moreItems = self.getMoreItems(forPage: self.page, pageSize: self.pageSize)
self.items.append(contentsOf: moreItems)
self.isLoading = false
}
}
}
}
String Extension:
/*
If you want to display an array of strings
in the List view you have to specify a key path,
so each string can be uniquely identified.
With this extension you don't have to do that anymore.
*/
extension String: Identifiable {
public var id: String {
return self
}
}
Christian Elies, code reference
I have a CoreData object Day which I am editing within this view, when in "editing" mode the card allows for a Picker view and then a TextField to allow user input. These all work as expected, however when I tap "done" which runs the saveEditToDay() function which is just saving those into the Day variable. This crashes with Thread 1: EXC_BREAKPOINT (code=1, subcode=0x1cffc77e0) and gives the hint Modifying state during view update, this will cause undefined behavior.. I am not clear what state I modifying in this case?
Here is the view:
import SwiftUI
struct EditCard: View {
#Binding var day: Day?
var timeOfDay: Time
#State var isEditing: Bool = false
#State var selectedRating = 0
#State var description = ""
var ratings = Rating.getAllRatings()
// MARK: - Body
var body: some View {
VStack(alignment: .leading) {
HStack {
Text(timeOfDay.rawValue).font(.headline)
Spacer()
Button(action: {
if self.isEditing { self.saveEditToDay() }
self.isEditing.toggle()
}, label: { self.isEditing ? Text("Done") : Text("Edit") })
.foregroundColor(Color("PrimaryColour"))
}
Divider()
VStack(alignment: .leading) {
Text("RATING")
.font(.caption)
.foregroundColor(Color(UIColor.systemGray))
if isEditing {
Picker(selection: $selectedRating, label: Text("")) {
ForEach(0 ..< ratings.count) {
Text(self.ratings[$0]).tag([$0])
}
}
} else {
Text(day!.getRating(for: timeOfDay))
}
}.padding(.trailing)
VStack(alignment: .leading) {
Text("REASON")
.font(.caption)
.foregroundColor(Color(UIColor.systemGray))
if isEditing {
TextField(description, text: $description)
} else {
Text(description)
.multilineTextAlignment(.leading)
.lineLimit(nil)
}
}
}
.padding()
.background(Color(UIColor.secondarySystemGroupedBackground))
.cornerRadius(10)
.onAppear { self.setSelected() }
.onDisappear{ self.saveEditToDay(); print("Toodles") }
}
// MARK: - Functions
func setSelected() {
guard let day = day else { return }
self.selectedRating = ratings.firstIndex(of: day.getRating(for: timeOfDay))!
self.description = day.getDescription(for: timeOfDay)
}
func saveEditToDay() {
guard let day = day else { return }
day.setRating(rating: ratings[selectedRating], for: timeOfDay)
day.setDescription(description: description, for: timeOfDay)
}
}
Here at the Extensions to the NSManagedObject that i'm calling:
func setRating(rating: String, for time: Time) {
switch time {
case .morning: self.morning = rating;
case .afternoon: self.afternoon = rating;
case .evening: self.evening = rating;
}
}
func setDescription(description: String, for time: Time) {
switch time {
case .morning: self.morningDescription = description;
case .afternoon: self.afternoonDescription = description;
case .evening: self.eveningDescription = description;
}
}
You are calling setSelected() in onApear(), which modifies two of your variables marked as #State: selectedRating and description. This means that as the view gets drawn or redrawn you are changing the variables that define what is being drawn halfway through the process.
onApear() is a good place to trigger a background refresh, but not modify any #State variables instantly.
You probably should write an initialiser that takes binding to a Day and Time sets the other two variables just like you do in setSelected().