I have following code in my SwiftUI app
struct ContentView: View {
#State private var selectedCountry: Country?
#State private var showSetting = false
#FetchRequest(entity: Country.entity(),
sortDescriptors: [NSSortDescriptor(keyPath: \Country.cntryName, ascending: true)]
) var countries: FetchedResults<Country>
var body: some View {
NavigationView {
VStack {
Form {
Picker("Pick a country", selection: $selectedCountry) {
ForEach(countries, id: \Country.cntryName) { country in
Text(country.cntryName ?? "Error").tag(country as Country?)
}
}
if selectedCountry != nil {
DetailView(cntryName: (selectedCountry?.cntryName!)!)
}
}
}
.navigationBarTitle("UNECE Data")
.navigationBarItems(trailing: Button("Settings", action: {
self.showSetting.toggle()
}))
}
.sheet(isPresented: $showSetting) {
SettingsView(showSetting: self.$showSetting)
}
}
}
I do CoreData Country entity update in SettingView and once app is back in ContentView I`d like to delete all items from the Picker and load fresh data. Code above duplicate items in the Picker - add new ones to old set.
Related
I'm working on a SwiftUI + CoreData application which holds (for several entities) a list view with navigation to an edit view where I can make changes.
I decided to directly pass the managed object from the list to the edit view (observed) to avoid even one more code duplication of listing all attributes. My edit view is able to revert the changes made to the managed object when I go back to list without saving.
However I have a weird behavior: whenever I edit the field which is responsible for the sorting in the list view, e.g. title, and this changes the order in the list, the view slides back to list view and immediately forth to edit view. I can see, that the edited item changed position in the list. This happens on the first keypress that changes the title accordingly.
I want to avoid this (because it's distracting and I lose focus on the title field).
Any way to disable updates to the list or at least disable the switching to list and back?
struct GameListView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \Game.title, ascending: true),
NSSortDescriptor(keyPath: \Game.externalId, ascending: true),
],
animation: .default)
private var games: FetchedResults<Game>
#State private var showDetailView = false
var body: some View {
NavigationView {
List {
ForEach(games) { game in
NavigationLink(destination: GameEditView(game: game)) {
HStack {
Image("game-icon")
Text("\(game.title)")
}
}
}
}
.navigationBarTitle(NSLocalizedString("Games", comment: "NavigationBar title"))
}
}
}
struct GameEditView: View {
#ObservedObject var game: Game
#State private var showingCancelSheet = false
#Environment(\.managedObjectContext) private var viewContext
#Environment (\.presentationMode) var presentationMode
var body: some View {
VStack {
// TODO: add all the attributes of Game
GameFormView(title: $game.title, onSave: {
try game.validate()
try game.save()
presentationMode.wrappedValue.dismiss()
})
}
.navigationBarTitle(NSLocalizedString("Edit Game", comment: "NavigationBar title"))
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
if game.hasChanges {
self.showingCancelSheet = true
} else {
self.presentationMode.wrappedValue.dismiss()
}
},
label: {
Image(systemName: "chevron.left")
Text("Games", comment: "Button label back to list of games")
})
}
}
}
}
struct GameAddView: View {
#State private var title = ""
#Environment(\.managedObjectContext) private var viewContext
#Environment (\.presentationMode) var presentationMode
var body: some View {
GameFormView(title: $title, onSave: {
try Game.validate(title: self.title, game: nil, context: viewContext)
let game = Game.create(title: self.title, context: viewContext)
try game.save()
presentationMode.wrappedValue.dismiss()
})
.navigationBarTitle(NSLocalizedString("New Game", comment: "NavigationBar title"))
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(action: cancel) {
Text("Cancel", comment: "Button label cancel add game")
}
}
}
}
func cancel() {
self.presentationMode.wrappedValue.dismiss()
}
}
struct GameFormView: View {
#Binding var title : String
// TODO: add all the attributes of Game
public var onSave: () throws -> Void
#State private var titleError: String? = nil
var body: some View {
Form {
Section(header: Text("Game Title", comment: "Section header")) {
VStack {
TextField(NSLocalizedString("Title", comment: "TextField label game form"), text: $title)
if self.titleError != nil {
Text(self.titleError!)
.fontWeight(.light)
.font(.footnote)
.foregroundColor(.red)
}
}
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: saveForm,
label: { Text("Save", comment: "Button label save game") })
}
}
}
func saveForm() {
do {
try self.onSave()
} catch GameValidationError.emptyName {
self.titleError = NSLocalizedString("Title can't be empty", comment: "Validation error on game")
} catch GameValidationError.duplicateTitle(_) {
self.titleError = NSLocalizedString("Title already exists", comment: "Validation error on game")
} catch let error as NSError {
print("Unexpected error: \(error), \(error.userInfo)")
}
}
}
I have the following model object that I use to populate a List with a Toggle for each row, which is bound to measurement.isSelected
final class Model: ObservableObject {
struct Measurement: Identifiable {
var id = UUID()
let name: String
var isSelected: Binding<Bool>
var selected: Bool = false
init(name: String) {
self.name = name
let selected = CurrentValueSubject<Bool, Never>(false)
self.isSelected = Binding<Bool>(get: { selected.value }, set: { selected.value = $0 })
}
}
#Published var measurements: [Measurement]
#Published var hasSelection: Bool = false // How to set this?
init(measurements: [Measurement]) {
self.measurements = measurements
}
}
I'd like the hasSelection property to be true whenever any measurement.isSelected is true. I'm guessing somehow Model needs to observe changes in measurements and then update its hasSelection property… but I've no idea where to start!
The idea is that hasSelection will be bound to a Button to enable or disable it.
Model is used as follows…
struct MeasurementsView: View {
#ObservedObject var model: Model
var body: some View {
NavigationView {
List(model.measurements) { measurement in
MeasurementView(measurement: measurement)
}
.navigationBarTitle("Select Measurements")
.navigationBarItems(trailing: NavigationLink(destination: NextView(), isActive: $model.hasSelection, label: {
Text("Next")
}))
}
}
}
struct MeasurementView: View {
let measurement: Model.Measurement
var body: some View {
HStack {
Text(measurement.name)
.font(.subheadline)
Spacer()
Toggle(measurement.name, isOn: measurement.isSelected)
.labelsHidden()
}
}
}
For info, here's a screenshot of what I'm trying to achieve. A list of selectable items, with a navigation link that is enabled when one or more is selected, and disabled when no items are selected.
#user3441734 hasSelection should ideally be a get only property, that
is true if any of measurement.isSelected is true
struct Data {
var bool: Bool
}
class Model: ObservableObject {
#Published var arr: [Data] = []
var anyTrue: Bool {
arr.map{$0.bool}.contains(true)
}
}
example (as before) copy - paste - run
import SwiftUI
struct Data: Identifiable {
let id = UUID()
var name: String
var on_off: Bool
}
class Model: ObservableObject {
#Published var data = [Data(name: "alfa", on_off: false), Data(name: "beta", on_off: false), Data(name: "gama", on_off: false)]
var bool: Bool {
data.map {$0.on_off} .contains(true)
}
}
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
VStack {
List(0 ..< model.data.count) { idx in
HStack {
Text(verbatim: self.model.data[idx].name)
Toggle(isOn: self.$model.data[idx].on_off) {
EmptyView()
}
}
}
Text("\(model.bool.description)").font(.largeTitle).padding()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
When the model.data is updated
#Published var data ....
its publisher calls objectWillChange on ObservableObject.
Next SwiftUI recognize that ObservedObject needs the View to be "updated". The View is recreated, and that will force the model.bool.description will have fresh value.
LAST UPDATE
change this part of code
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
NavigationView {
List(0 ..< model.data.count) { idx in
HStack {
Text(verbatim: self.model.data[idx].name)
Toggle(isOn: self.$model.data[idx].on_off) {
EmptyView()
}
}
}.navigationBarTitle("List")
.navigationBarItems(trailing:
NavigationLink(destination: Text("next"), label: {
Text("Next")
}).disabled(!model.bool)
)
}
}
}
and it is EXACTLY, WHAT YOU HAVE in your updated question
Try it on real device, otherwise the NavigationLink is usable only once (this is well known simulator bug in current Xcode 11.3.1 (11C504)).
The problem with your code at the moment is that even if you observe the changes to measurements, they will not get updated when the selection updates, because you declared the var isSelected: Binding<Bool> as a Binding. This means that SwiftUI is storing it outside of your struct, and the struct itself doesn't update (stays immutable).
What you could try instead is declaring #Published var selectedMeasurementId: UUID? = nil on your model So your code would be something like this:
import SwiftUI
import Combine
struct NextView: View {
var body: some View {
Text("Next View")
}
}
struct MeasurementsView: View {
#ObservedObject var model: Model
var body: some View {
let hasSelection = Binding<Bool> (
get: {
self.model.selectedMeasurementId != nil
},
set: { value in
self.model.selectedMeasurementId = nil
}
)
return NavigationView {
List(model.measurements) { measurement in
MeasurementView(measurement: measurement, selectedMeasurementId: self.$model.selectedMeasurementId)
}
.navigationBarTitle("Select Measurements")
.navigationBarItems(trailing: NavigationLink(destination: NextView(), isActive: hasSelection, label: {
Text("Next")
}))
}
}
}
struct MeasurementView: View {
let measurement: Model.Measurement
#Binding var selectedMeasurementId: UUID?
var body: some View {
let isSelected = Binding<Bool>(
get: {
self.selectedMeasurementId == self.measurement.id
},
set: { value in
if value {
self.selectedMeasurementId = self.measurement.id
} else {
self.selectedMeasurementId = nil
}
}
)
return HStack {
Text(measurement.name)
.font(.subheadline)
Spacer()
Toggle(measurement.name, isOn: isSelected)
.labelsHidden()
}
}
}
final class Model: ObservableObject {
#Published var selectedMeasurementId: UUID? = nil
struct Measurement: Identifiable {
var id = UUID()
let name: String
init(name: String) {
self.name = name
}
}
#Published var measurements: [Measurement]
init(measurements: [Measurement]) {
self.measurements = measurements
}
}
I'm not sure exactly how you want the navigation button in the navbar to behave. For now I just set the selection to nil when it's tapped. You can modify it depending on what you want to do.
If you want to support multi-selection, you can use a Set of selected ids instead.
Also, seems like the iOS simulator has some problems with navigation, but I tested on a physical device and it worked.
I have this code in my SwiftUI project in works well
struct ContentView: View {
#State private var selectedCountry: Country?
#State private var showSetting = false
#FetchRequest(entity: Country.entity(),
sortDescriptors: [NSSortDescriptor(keyPath: \Country.cntryName, ascending: true)]
) var countries: FetchedResults<Country>
var body: some View {
NavigationView {
VStack {
Form {
Picker("Pick a country", selection: $selectedCountry) {
ForEach(countries, id: \Country.cntryName) { country in
Text(country.cntryName ?? "Error").tag(country as Country?)
}
}
if selectedCountry != nil {
DetailView(cntryName: (selectedCountry?.cntryName!)!)
}
}
}
.navigationBarTitle("UNECE Data")
.navigationBarItems(trailing: Button("Settings", action: {
self.showSetting.toggle()
}))
}
.sheet(isPresented: $showSetting) {
SettingsView(showSetting: self.$showSetting)
}
}
}
However I need to call FetchRequest dynamically end reload Picker view when SettingsView dismiss. Possibly I should use #ObservableObject but how to put there fetch request and use result in the Picker view ForEach? Thanks for hints.
You can customize most part of your FetchRequest:
#FetchRequest(entity: Country.entity(),
sortDescriptors: ObservableObject.sortDesc,
predicate : ObservableObject.predicate
) var countries: FetchedResults<Country>
I have reworked code like this
var body: some View {
NavigationView {
VStack {
Form {
//Text(String(describing: countries.count))
Picker("Pick a country", selection: $selectedCountry) {
ForEach(getAllCountries().wrappedValue, id: \Country.cntryName) { country in
Text(country.cntryName ?? "Error").tag(country as Country?)
}
}
if selectedCountry != nil {
DetailView(cntryName: (selectedCountry?.cntryName!)!)
}
}
}
.navigationBarTitle("UNECE Data")
.navigationBarItems(trailing: Button("Settings", action: {
self.showSetting.toggle()
}))
}
.sheet(isPresented: $showSetting) {
SettingsView(showSetting: self.$showSetting)
}
}
func getAllCountries() -> FetchRequest<Country> {
let request = FetchRequest<Country>(entity: Country.entity(),
sortDescriptors: [NSSortDescriptor(keyPath: \Country.cntryName, ascending: true)])
return request
}
but it reports fatal error "Thread 1: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0)" on ForEach line when runs.
In SettingsView I delete all data in Country entity, parse JSON file stored on my iCloud and save all data in Country entity.
Scenario:
I have a simple picker within a form.
I select a picker item (with chevron) from the form row.
I choose an item (row) from a list of items in the result panel.
The result panel slides away to reveal the original panel.
I am NOT able to repeat this procedure.
Here's my code:
class ChosenView: ObservableObject {
static let choices = ["Modal", "PopOver", "Circle", "CircleImage", "Scroll", "Segment", "Tab", "Multi-Line"]
#Published
var type = 0
}
struct ContentView: View {
#ObservedObject var chosenView = ChosenView()
#State private var isPresented = false
var body: some View {
VStack {
NavigationView {
Form {
Picker(selection: $chosenView.type, label: Text("The Panels")) {
ForEach(0..<ChosenView.choices.count) {
Text(ChosenView.choices[$0]).tag($0)
}
}
}.navigationBarTitle(Text("Available Views"))
.actionSheet(isPresented: $isPresented, content: {
ActionSheet(title: Text("Hello"))
})
}
Section {
Button(action: launchView) {
Text("Select: \(ChosenView.choices[chosenView.type])")
}
}
Spacer()
}
}
private func launchView() {
isPresented = true
}
}
What am I missing?
Why can't I repeat picker selection rather than having to reboot?
Working with Beta4, it seems that the bug is still existing. The following sequence of views (a list, where a tap on a list entry opens another list) allows to present the ListView exactly once; the onDisappear is never called, so the showModal flag changes, but does not triggers the redisplay of ListView when tapped again. So, for each GridCellBodyEntry, the .sheet presentation works exactly once, and then never again.
I tried around with several suggestions and workarounds, but none worked (e.g., encapsulating with a NavigationViewModel). I even tried to remove the List, because there was an assumption that the List causes that behaviour, but even this did not change anything.
Are there any ideas around?
The setup:
A GridCellBody with this view:
var body: some View {
GeometryReader { geometry in
VStack {
List {
Section(footer: self.footerView) {
ForEach(self.rawEntries) { rawEntry in
GridCellBodyEntry(entityType: rawEntry)
}
}
}
.background(Color.white)
}
}
}
A GridCellBodyEntry with this definition:
struct GridCellBodyEntry: View {
let entityType: EntityType
let viewModel: BaseViewModel
init(entityType: EntityType) {
self.entityType = entityType
self.viewModel = BaseViewModel(entityType: self.entityType)
}
#State var showModal = false {
didSet {
print("showModal: \(showModal)")
}
}
var body: some View {
Group {
Button(action: {
self.showModal.toggle()
},
label: {
Text(entityType.localizedPlural ?? "")
.foregroundColor(Color.black)
})
.sheet(isPresented: $showModal, content: {
ListView(showModal: self.$showModal,
viewModel: self.viewModel)
})
}.onAppear{
print("Profile appeared")
}.onDisappear{
print("Profile disappeared")
}
}
}
A ListView with this definition:
struct ListView: View {
// MARK: - Private properties
// MARK: - Public interface
#Binding var showModal: Bool
#ObjectBinding var viewModel: BaseViewModel
// MARK: - Main view
var body: some View {
NavigationView {
VStack {
List {
Section(footer: Text("\(viewModel.list.count) entries")) {
ForEach(viewModel.list, id: \.objectID) { item in
NavigationLink(destination: ItemView(),
label: {
Text("\(item.objectID)")
})
}
}
}
}
.navigationBarItems(leading:
Button(action: {
self.showModal = false
}, label: {
Text("Close")
}))
.navigationBarTitle(Text(viewModel.entityType.localizedPlural ?? ""))
}
}
}
The BaseViewModel (excerpt):
class BaseViewModel: BindableObject {
/// The binding support.
var willChange = PassthroughSubject<Void, Never>()
/// The context.
var context: NSManagedObjectContext
/// The current list of typed items.
var list: [NSManagedObject] = []
// ... other stuff ...
}
where willChange.send() is called whenever something changes (create, modify, delete operations).
This is a variant of swiftUI PresentaionLink does not work second time
The following simplified code exhibits the behavior you're experiencing (the sheet only displays once):
import SwiftUI
struct ContentView: View {
#State var isPresented = false
#State var whichPresented = -1
var body: some View {
NavigationView {
List {
ForEach(0 ..< 10) { i in
Button(action: {
self.whichPresented = i
self.isPresented.toggle()
})
{ Text("Button \(i)") }
}.sheet(isPresented: $isPresented, content: {
Text("Destination View \(self.whichPresented)") })
}
}
}
}
There appears to be a bug in SwiftUI when you put the .sheet inside a List or a ForEach. If you move the .sheet outside of the List, you should be able to get the correct behavior.
import SwiftUI
struct ContentView: View {
#State var isPresented = false
#State var whichPresented = -1
var body: some View {
NavigationView {
List {
ForEach(0 ..< 10) { i in
Button(action: {
self.whichPresented = i
self.isPresented.toggle()
})
{ Text("Button \(i)") }
}
}
}.sheet(isPresented: $isPresented, content: { Text("Destination View \(self.whichPresented)") })
}
}