I am trying to display a custom layout using ZStack of a table with a column titles and some rows, but I get a reversed layout to the order I specify in the body. Here is the code:
import SwiftUI
struct Test: View {
var columns : [String] = ["Column 1","Column 2"]
var cells : [[String]] = [
["(1,1)","(1,2)"],
["(2,1)","(2,2)"],
["(3,1)","(3,2)"],
]
var body: some View {
ZStack(alignment: .topLeading) {
ForEach(0..<self.columns.count, id: \.self) { column in
self.viewForCell(self.columns[column], row: 0, column: column)
}
ForEach(0..<self.cells.count, id: \.self) { row in
ForEach(0..<self.cells[row].count, id: \.self ) { column in
self.viewForCell(self.cells[row][column], row: 1+row, column: column)
}
}
}
}
func viewForCell(_ string: String, row: Int, column: Int) -> some View {
Text(string)
.alignmentGuide(.leading, computeValue: { _ in CGFloat(96 * column) })
.alignmentGuide(.top, computeValue: { _ in CGFloat(24 * row) })
}
}
struct Test_Previews: PreviewProvider {
static var previews: some View {
Test()
}
}
And here is a screenshot of what I get:
What I want is:
"Column 1" "Column 2"
"(1,1)" "(1,2)"
"(2,1)" "(2,2)"
"(3,1)" "(3,2)"
It appears as if the horizontal and vertical alignments are flipped.
import SwiftUI
struct Test: View {
var columns : [String] = ["Column 1","Column 2"]
var cells : [[String]] = [
["(1,1)","(1,2)"],
["(2,1)","(2,2)"],
["(3,1)","(3,2)"],
]
var body: some View {
ZStack(alignment: .topLeading) {
ForEach(0..<self.columns.count, id: \.self) { column in
self.viewForCell(self.columns[column], row: 0, column: column)
}
ForEach(0..<self.cells.count, id: \.self) { row in
ForEach(0..<self.cells[row].count, id: \.self ) { column in
self.viewForCell(self.cells[row][column], row: 1+row, column: column)
}
}
}
}
func viewForCell(_ string: String, row: Int, column: Int) -> some View {
Text(string)
.alignmentGuide(.leading, computeValue: { _ in CGFloat(96 * ( self.columns.count - column ) ) })
.alignmentGuide(.top, computeValue: { _ in CGFloat(24 * ( self.cells.count - row ) )})
}
}
struct Test_Previews: PreviewProvider {
static var previews: some View {
Test()
}
}
#andrewz the problem was in the value of the leading and the top of the alignmentGuide of the cell
the above code will work as you expect :)
Related
Starting with this
var body: some View {
ScrollView {
VStack(spacing: 0.0) {
Some views here
}
}
.edgesIgnoringSafeArea(.top)
}
How would I add
List(suggestions, rowContent: { text in
NavigationLink(destination: ResultsPullerView(searchText: text)) {
Text(text)
}
})
.searchable(text: $searchText)
on top if that scrollable content?
Cause no matter how I hoax this together when
#State private var suggestions: [String] = []
gets populated (non empty) the search results are not squeezed in (or, better yet, shown on top of
"Some views here"
So what I want to achieve in different terms: search field is on top, scrollable content driven by the search results is underneath, drop down with search suggestions either temporarily squeeses scrollable content down or is overlaid on top like a modal sheet.
Thanks!
If you are looking for UIKit like search behaviour you have to display your results in an overlay:
1. Let's declare a screen to display the results:
struct SearchResultsScreen: View {
#Environment(\.isSearching) private var isSearching
var results: [String]?
var body: some View {
if isSearching, let results {
if results.isEmpty {
Text("nothing to see here")
} else {
List(results, id: \.self) { fruit in
NavigationLink(destination: Text(fruit)) {
Text(fruit)
}
}
}
}
}
}
2. Let's have an ObservableObject to handle the logic:
class Search: ObservableObject {
static private let fruit = [
"Apples 🍏",
"Cherries 🍒",
"Pears 🍐",
"Oranges 🍊",
"Pineapples 🍍",
"Bananas 🍌"
]
#Published var text: String = ""
var results: [String]? {
if text.isEmpty {
return nil
} else {
return Self.fruit.filter({ $0.contains(text)})
}
}
}
3. And lastly lets declare the main screen where the search bar is displayed:
struct ContentView: View {
#StateObject var search = Search()
var body: some View {
NavigationView {
LinearGradient(colors: [.orange, .red], startPoint: .topLeading, endPoint: .bottomTrailing)
.overlay(SearchResultsScreen(results: search.results))
.searchable(text: $search.text)
.navigationTitle("Find that fruit")
}
}
}
I do not understand why this code does not work to update a list when navigating "back" from a DetailView(). As far as I can tell, I'm calling a new fetchRequest each time I want to update the list and it seems that request should always return object with current properties. But as others have said they are "stale", reflecting whatever was the property BEFORE the update was committed in the DetailView. And tapping a Navigation link from a "Stale" row, opens a DetailView with the current values of the properties, so I know they have been sacved to the context (haven't they?).
First I have a "dataservice" like this:
import CoreData
import SwiftUI
protocol CategoryDataServiceProtocol {
func getCategories() -> [Category]
func getCategoryById(id: NSManagedObjectID) -> Category?
func addCategory(name: String, color: String)
func updateCategory(_ category: Category)
func deleteCategory(_ category: Category)
}
class CategoryDataService: CategoryDataServiceProtocol {
var viewContext: NSManagedObjectContext = PersistenceController.shared.viewContext
///Shouldn't this next function always return an updated version of my list of categories?
func getCategories() -> [Category] {
let request: NSFetchRequest<Category> = Category.fetchRequest()
let sort: NSSortDescriptor = NSSortDescriptor(keyPath: \Category.name_, ascending: true)
request.sortDescriptors = [sort]
///This line appears to do nothing if I insert it:
request.shouldRefreshRefetchedObjects = true
do {
///A print statement here does run, so it's getting this far...
print("Inside get categories func")
return try viewContext.fetch(request)
} catch {
return []
}
}
func getCategoryById(id: NSManagedObjectID) -> Category? {
do {
return try viewContext.existingObject(with: id) as? Category
} catch {
return nil
}
}
func addCategory(name: String, color: String) {
let newCategory = Category(context: viewContext)
newCategory.name = name
newCategory.color = color
saveContext()
}
func updateCategory(_ category: Category) {
saveContext()
}
func deleteCategory(_ category: Category) {
viewContext.delete(category)
saveContext()
}
func saveContext() {
PersistenceController.shared.save()
}
}
class MockCategoryDataService: CategoryDataService {
override init() {
super .init()
self.viewContext = PersistenceController.preview.viewContext
print("MOCK INIT")
func addCategory(name: String, color: String) {
let newCategory = Category(context: viewContext)
newCategory.name = name
newCategory.color = color
saveContext()
}
}
}
And I have a viewModel like this:
import SwiftUI
extension CategoriesList {
class ViewModel: ObservableObject {
let dataService: CategoryDataServiceProtocol
#Published var categories: [Category] = []
init(dataService: CategoryDataServiceProtocol = CategoryDataService()) {
self.dataService = dataService
}
func getCategories() {
self.categories = dataService.getCategories()
}
func deleteCategories(at offsets: IndexSet) {
offsets.forEach { index in
let category = categories[index]
dataService.deleteCategory(category)
}
}
}
}
Then my view:
import SwiftUI
struct CategoriesList: View {
#StateObject private var viewModel: CategoriesList.ViewModel
init(viewModel: CategoriesList.ViewModel = .init()) {
_viewModel = StateObject(wrappedValue: viewModel)
}
#State private var isShowingSheet = false
var body: some View {
NavigationView {
List {
ForEach(viewModel.categories) { category in
NavigationLink(
destination: CategoryDetail(category: category)) {
CategoryRow(category: category)
.padding(0)
}
}
.onDelete(perform: { index in
viewModel.deleteCategories(at: index)
viewModel.getCategories()
})
}
.listStyle(PlainListStyle())
.onAppear(perform: {
viewModel.getCategories()
})
.navigationBarTitle(Text("Categories"))
.toolbar {
ToolbarItem(placement: .navigationBarLeading, content: { EditButton() })
ToolbarItem(placement: .navigationBarTrailing) {
Button(
action: {
isShowingSheet = true
viewModel.getCategories()
},
label: { Image(systemName: "plus.circle").font(.system(size: 20)) }
)
}
}
.sheet(isPresented: $isShowingSheet, onDismiss: {
viewModel.getCategories()
}, content: {
CategoryForm()
})
}
}
}
struct CategoriesList_Previews: PreviewProvider {
static var previews: some View {
let viewModel: CategoriesList.ViewModel = .init(dataService: MockCategoryDataService())
return CategoriesList(viewModel: viewModel)
}
}
So, when I navigate to the DetailView and change the name of the category, all is fine there. But then tapping the back button or swiping to return to the view - and the view still shows the old name.
I understand that the #Published array of [Category] is probably not looking at changes to objects inside the array, only if an object is removed or added, I guess.
But why is my list not updating anyways, since I am calling viewModel.getCategories() and that is triggering the fetch request in the dataservice getCategories function?
And if Combine is the answer, then how? Or what else am I missing? Does request.shouldRefreshRefetchedObjects = true offer anything? Or is it a bug as I read here: https://mjtsai.com/blog/2019/10/17/core-data-derived-attributes/
I've written this small example of MVVM in a SwiftUI app using CoreData, but I wonder if there are better ways to do this such as using a nested viewcontext?
The object of the code is to not touch the CoreData entity until the user has updated all the fields needed and taps "Save". In other words, to not have to undo any fields if the user enters a lot of properties and then "Cancels". But how do I approach this in SwiftUI?
Currently, the viewModel has #Published vars which take their cue from the entity, but are not bound to its properties.
Here is the code:
ContentView
This view is pretty standard, but here is the NavigationLink in the List, and the Fetch:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Contact.lastName, ascending: true)],
animation: .default)
private var contacts: FetchedResults<Contact>
var body: some View { List {
ForEach(contacts) { contact in
NavigationLink (
destination: ContactProfile(contact: contact)) {
Text("\(contact.firstName ?? "") \(contact.lastName ?? "")")
}
}
.onDelete(perform: deleteItems)
} ///Etc...the rest of the code is standard
ContactProfile.swift in full:
import SwiftUI
struct ContactProfile: View {
#ObservedObject var contact: Contact
#ObservedObject var viewModel: ContactProfileViewModel
init(contact: Contact) {
self.contact = contact
self._viewModel = ObservedObject(initialValue: ContactProfileViewModel(contact: contact))
}
#State private var isEditing = false
#State private var errorAlertIsPresented = false
#State private var errorAlertTitle = ""
var body: some View {
VStack {
if !isEditing {
Text("\(contact.firstName ?? "") \(contact.lastName ?? "")")
.font(.largeTitle)
.padding(.top)
Spacer()
} else {
Form{
TextField("First Name", text: $viewModel.firstName)
TextField("First Name", text: $viewModel.lastName)
}
}
}
.navigationBarTitle("", displayMode: .inline)
.navigationBarBackButtonHidden(isEditing ? true : false)
.navigationBarItems(leading:
Button (action: {
withAnimation {
self.isEditing = false
viewModel.reset() /// <- Is this necessary? I'm not sure it is, the code works
/// with or without it. I don't see a
/// difference in calling viewModel.reset()
}
}, label: {
Text(isEditing ? "Cancel" : "")
}),
trailing:
Button (action: {
if isEditing { saveContact() }
withAnimation {
if !errorAlertIsPresented {
self.isEditing.toggle()
}
}
}, label: {
Text(!isEditing ? "Edit" : "Done")
})
)
.alert(
isPresented: $errorAlertIsPresented,
content: { Alert(title: Text(errorAlertTitle)) }) }
private func saveContact() {
do {
try viewModel.saveContact()
} catch {
errorAlertTitle = (error as? LocalizedError)?.errorDescription ?? "An error occurred"
errorAlertIsPresented = true
}
}
}
And the ContactProfileViewModel it uses:
import UIKit
import Combine
import CoreData
/// The view model that validates and saves an edited contact into the database.
///
final class ContactProfileViewModel: ObservableObject {
/// A validation error that prevents the contact from being 8saved into
/// the database.
enum ValidationError: LocalizedError {
case missingFirstName
case missingLastName
var errorDescription: String? {
switch self {
case .missingFirstName:
return "Please enter a first name for this contact."
case .missingLastName:
return "Please enter a last name for this contact."
}
}
}
#Published var firstName: String = ""
#Published var lastName: String = ""
/// WHAT ABOUT THIS NEXT LINE? Should I be making a ref here
/// or getting it from somewhere else?
private let moc = PersistenceController.shared.container.viewContext
var contact: Contact
init(contact: Contact) {
self.contact = contact
updateViewFromContact()
}
// MARK: - Manage the Contact Form
/// Validates and saves the contact into the database.
func saveContact() throws {
if firstName.isEmpty {
throw ValidationError.missingFirstName
}
if lastName.isEmpty {
throw ValidationError.missingLastName
}
contact.firstName = firstName
contact.lastName = lastName
try moc.save()
}
/// Resets form values to the original contact values.
func reset() {
updateViewFromContact()
}
// MARK: - Private
private func updateViewFromContact() {
self.firstName = contact.firstName ?? ""
self.lastName = contact.lastName ?? ""
}
}
Most of the viewmodel code is adapted from the GRDB Combine example. So, I wasn't always sure what to exclude. what to include.
I have opted to avoid a viewModel in this case after discovering:
moc.refresh(contact, mergeChanges: false)
Apple docs: https://developer.apple.com/documentation/coredata/nsmanagedobjectcontext/1506224-refresh
So you can toss aside the ContactViewModel, keep the ContentView as is and use the following:
Contact Profile
The enum havae been made an extension to the ContactProfile view.
import SwiftUI
import CoreData
struct ContactProfile: View {
#Environment(\.managedObjectContext) private var moc
#ObservedObject var contact: Contact
#State private var isEditing = false
#State private var errorAlertIsPresented = false
#State private var errorAlertTitle = ""
var body: some View {
VStack {
if !isEditing {
Text("\(contact.firstName ?? "") \(contact.lastName ?? "")")
.font(.largeTitle)
.padding(.top)
Spacer()
} else {
Form{
TextField("First Name", text: $contact.firstName ?? "")
TextField("First Name", text: $contact.lastName ?? "")
}
}
}
.navigationBarTitle("", displayMode: .inline)
.navigationBarBackButtonHidden(isEditing ? true : false)
.navigationBarItems(leading:
Button (action: {
/// This is the key change:
moc.refresh(contact, mergeChanges: false)
withAnimation {
self.isEditing = false
}
}, label: {
Text(isEditing ? "Cancel" : "")
}),
trailing:
Button (action: {
if isEditing { saveContact() }
withAnimation {
if !errorAlertIsPresented {
self.isEditing.toggle()
}
}
}, label: {
Text(!isEditing ? "Edit" : "Done")
})
)
.alert(
isPresented: $errorAlertIsPresented,
content: { Alert(title: Text(errorAlertTitle)) }) }
private func saveContact() {
do {
if contact.firstName!.isEmpty {
throw ValidationError.missingFirstName
}
if contact.lastName!.isEmpty {
throw ValidationError.missingLastName
}
try moc.save()
} catch {
errorAlertTitle = (error as? LocalizedError)?.errorDescription ?? "An error occurred"
errorAlertIsPresented = true
}
}
}
extension ContactProfile {
enum ValidationError: LocalizedError {
case missingFirstName
case missingLastName
var errorDescription: String? {
switch self {
case .missingFirstName:
return "Please enter a first name for this contact."
case .missingLastName:
return "Please enter a last name for this contact."
}
}
}
}
This also requires the code below that can be found at this link:
SwiftUI Optional TextField
import SwiftUI
func ??<T>(lhs: Binding<Optional<T>>, rhs: T) -> Binding<T> {
Binding(
get: { lhs.wrappedValue ?? rhs },
set: { lhs.wrappedValue = $0 }
)
}
I am trying to remove rows inside a ForEach. Removing the last row always throws an index out of range exception. Removing any other row does not.
ForEach(Array(player.scores.enumerated()), id: \.element) { index, score in
HStack {
if self.isEditSelected {
Button(action: {
self.player.scores.remove(at: index)
}, label: {
Image("delete")
})
}
TextField("\(score)", value: self.$player.scores[index], formatter: NumberFormatter())
}
}
I have tried using ForEach(player.indices...) & ForEach(player.scores...), but see the same problem.
Looks to me like the crash happens here self.$player.scores[index], as hardcoding the index to any value other that the last row is working.
Does anyone know how to fix this? Or if there is a better approach.
Here is fix
ForEach(Array(player.scores.enumerated()), id: \.element) { index, score in
HStack {
if self.isEditSelected {
Button(action: {
self.player.scores.remove(at: index)
}, label: {
Image("delete")
})
}
TextField("\(score)", value: Binding( // << use proxy binding !!
get: { self.player.scores[index] },
set: { self.player.scores[index] = $0 }),
formatter: NumberFormatter())
}
}
Based on #Asperi answer
public extension Binding where Value: Equatable {
static func proxy(_ source: Binding<Value>) -> Binding<Value> {
self.init(
get: { source.wrappedValue },
set: { source.wrappedValue = $0 }
)
}
}
You can use this as follows:
TextField("Name", text: .proxy($variable))
Xcode 13.0 beta introduced a new way to establish two-way-bindings between the elements of a collection and the views built by ForEach / List.
This method fixes the crash related to deleting the last row.
struct Score: Identifiable {
let id = UUID()
var value: Int
}
struct Player {
var scores: [Score] = (1...10).map {_ in .init(value: Int.random(in: 0...25))}
}
struct BindingTest: View {
#State private var player = Player()
var body: some View {
List {
ForEach($player.scores) { $score in
HStack {
TextField("\(score.value)", value: $score.value,
formatter: NumberFormatter())
}
}
.onDelete { player.scores.remove(atOffsets: $0)}
}
}
}
I have a two CoreData objects:
RoadTrip
StatePlate.
Each RoadTrip items holds an NSSet of StatePlate.
Screen 1 (TripList) shows a list of all RoadTrip items. Screen 2 (StateList) shows a list of all StatePlate items in associated with the RoadTrip that a user selects. Selecting a StatePlate item in Screen 2 will toggle a bool value associated with that item.
Even though I can show the data and can toggle the bool value of each StatePlate, I am not seeing an immediate change to the UI of the screen. The StatePlate should jump from Section to Section in Screen 2 when it's bool value is toggled.
How can I pass this FetchedObject correctly from Screen 1 to Screen 2 so the UI is binded with the data?
Screen 1 (TripList)
struct TripList: View {
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(entity: RoadTrip.entity(), sortDescriptors: []) var roadTripItems: FetchedResults<RoadTrip>
var body: some View {
List {
ForEach(roadTripItems, id: \.self) { trip in
NavigationLink(destination: StateList(trip: trip)
.environment(\.managedObjectContext, self.managedObjectContext)) {
TripRow(roadTrip: trip)
}
}
}
}
}
Screen 2 (StateList)
struct StateList: View {
#Environment(\.managedObjectContext) var managedObjectContext
var trip: RoadTrip
var plates: [StatePlate] {
trip.plateArray
}
var unseenPlates: [StatePlate] {
trip.plateArray.filter { !$0.hasBeenSeen }
}
var seenPlates: [StatePlate] {
trip.plateArray.filter { $0.hasBeenSeen }
}
var body: some View {
List {
if !unseenPlates.isEmpty {
Section(header: Text("Unseen Plates")) {
ForEach(unseenPlates, id: \.self) { plate in
StateRow(plate: plate)
}
}
}
if !seenPlates.isEmpty {
Section(header: Text("Seen Plates")) {
ForEach(seenPlates, id: \.self) { plate in
StateRow(plate: plate)
}
}
}
}
}
}
StateRow
struct StateRow: View {
#Environment(\.managedObjectContext) var managedObjectContext
#ObservedObject var plate: StatePlate
var body: some View {
Button(action: {
self.plate.hasBeenSeen.toggle()
try? self.managedObjectContext.save()
}) {
HStack {
Text(String(describing: plate.name!))
Spacer()
if plate.hasBeenSeen {
Image(systemName: "eye.fill")
} else {
Image(systemName: "")
}
}
}
}
}
Your trip as object is not changed when plate has changed, so even if it was observed UI was not refreshed.
Here is possible force-refresh approach.
struct StateList: View {
#Environment(\.managedObjectContext) var managedObjectContext
#ObservedObject var trip: RoadTrip // << make observed
// .. other code
and add handling for updated plate/s
StateRow(plate: plate)
.onReceive(plate.objectWillChange) { _ in
self.trip.objectWillChange.send()
}