SwiftUI MVVM Binding List Item - ios

I am trying to create a list view and a detailed screen like this:
struct MyListView: View {
#StateObject var viewModel: MyListViewModel = MyListViewModel()
LazyVStack {
// https://www.swiftbysundell.com/articles/bindable-swiftui-list-elements/
ForEach(viewModel.items.identifiableIndicies) { index in
MyListItemView($viewModel.items[index])
}
}
}
class MyListViewModel: ObservableObject {
#Published var items: [Item] = []
...
}
struct MyListItemView: View {
#Binding var item: Item
var body: some View {
NavigationLink(destination: MyListItemDetailView(item: $item), label: {
...
})
}
}
struct MyListItemDetailView: View {
#Binding var item: Item
#StateObject var viewModel: MyListViewItemDetailModel
init(item: Binding<Item>) {
viewModel = MyListViewItemDetailModel(item: item)
}
var body: some View {
...
}
}
class MyListViewItemDetailModel: ObservableObject {
var item: Binding<Item>
...
}
I am not sure what's wrong with it, but I found that item variables are not synced with each other, even between MyListItemDetailView and MyListItemDetailViewModel.
Is there anyone who can provide the best practice and let me know what's wrong in my implmentation?

I think you should think about a minor restructure of your code, and use only 1
#StateObject/ObservableObject. Here is a cut down version of your code using
only one StateObject source of truth:
Note: AFAIK Binding is meant to be used in View struct not "ordinary" classes.
PS: what is identifiableIndicies?
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct Item: Identifiable {
let id = UUID().uuidString
var name: String = ""
}
struct MyListView: View {
#StateObject var viewModel: MyListViewModel = MyListViewModel()
var body: some View {
LazyVStack {
ForEach(viewModel.items.indices) { index in
MyListItemView(item: $viewModel.items[index])
}
}
}
}
class MyListViewModel: ObservableObject {
#Published var items: [Item] = [Item(name: "one"), Item(name: "two")]
}
struct MyListItemView: View {
#Binding var item: Item
var body: some View {
NavigationLink(destination: MyListItemDetailView(item: $item)){
Text(item.name)
}
}
}
class MyAPIModel {
func fetchItemData(completion: #escaping (Item) -> Void) {
// do your fetching here
completion(Item(name: "new data from api"))
}
}
struct MyListItemDetailView: View {
#Binding var item: Item
let myApiModel = MyAPIModel()
var body: some View {
VStack {
Button(action: fetchNewData) {
Text("Fetch new data")
}
TextField("edit item", text: $item.name).border(.red).padding()
}
}
func fetchNewData() {
myApiModel.fetchItemData() { itemData in
item = itemData
}
}
}
struct ContentView: View {
var body: some View {
NavigationView {
MyListView()
}.navigationViewStyle(.stack)
}
}
EDIT1:
to setup an API to call some functions, you could use something like this:
class MyAPI {
func fetchItemData(completion: #escaping (Item) -> Void) {
// do your stuff
}
}
and use it to obtain whatever data you require from the server.
EDIT2: added some code to demonstrate the use of an API.

Related

Cannot convert value of type 'Binding<_>' to expected argument type 'Binding<Card>'

I am trying to create a binding to a FetchedResults item, error is on $items[i]:
struct NavView: View {
#Binding var item : Card
...
}
struct ContentView: View {
private var items: FetchedResults<Card>
var body: some View {
List {
ForEach(items.indices, id:\.self) { i in
NavigationLink {
NavView(item: $items[i])
}
}
}
}
}
Changing the Binding to ObservedObject compiles and seems to work properly, although I feel like I'm violating single source of truth policy by creating a new ObservedObject.
struct NavView: View {
#ObservedObject var item : Card
...
}
struct ContentView: View {
private var items: FetchedResults<Card>
var body: some View {
List {
ForEach(items) { item in
NavigationLink {
NavView(item: item)
}
}
}
}
}

SwiftUI three column navigation layout not closing when selecting same item

I'm using a three column navigation layout and facing the issue, that when selecting the same second column's item, the drawers won't close. If I take the files app as reference, selecting the same item again will close the drawer. Can someone tell me what's the issue? And is drawer the correct term?
Thanks in advance, Carsten
Code to reproduce:
import SwiftUI
extension UISplitViewController {
open override func viewDidLoad() {
preferredDisplayMode = .twoBesideSecondary
}
}
#main
struct TestApp: App {
#Environment(\.factory) var factory
var body: some Scene {
WindowGroup {
NavigationView {
ContentView(viewModel: factory.createVM1())
ContentView2(viewModel: factory.createVM2())
EmptyView()
}
}
}
}
struct FactoryKey: EnvironmentKey {
static let defaultValue: Factory = Factory()
}
extension EnvironmentValues {
var factory: Factory {
get {
return self[FactoryKey.self]
}
set {
self[FactoryKey.self] = newValue
}
}
}
class Factory {
func createVM1() -> ViewModel1 {
ViewModel1()
}
func createVM2() -> ViewModel2 {
ViewModel2()
}
func createVM3(from item: ViewModel2.Model) -> ViewModel3 {
ViewModel3(item: item)
}
}
class ViewModel1: ObservableObject {
struct Model: Identifiable {
let id: UUID = UUID()
let name: String
}
#Published var items: [Model]
init() {
items = (1 ... 4).map { Model(name: "First Column Item \($0)") }
}
}
struct ContentView: View {
#Environment(\.factory) var factory
#StateObject var viewModel: ViewModel1
var body: some View {
List {
ForEach(viewModel.items) { item in
NavigationLink(
destination: ContentView2(viewModel: factory.createVM2()),
label: {
Text(item.name)
})
}
}
}
}
class ViewModel2: ObservableObject {
struct Model: Identifiable {
let id: UUID = UUID()
let name: String
}
#Published var items: [Model]
init() {
items = (1 ... 4).map { Model(name: "Second Column Item \($0)") }
}
}
struct ContentView2: View {
#Environment(\.factory) var factory
#StateObject var viewModel: ViewModel2
var body: some View {
List {
ForEach(viewModel.items) { item in
NavigationLink(
destination: Detail(viewModel: factory.createVM3(from: item)),
label: {
Text(item.name)
})
}
}
}
}
class ViewModel3: ObservableObject {
let item: ViewModel2.Model
init(item: ViewModel2.Model) {
self.item = item
}
}
struct Detail: View {
#StateObject var viewModel: ViewModel3
var body: some View {
Text(viewModel.item.name)
}
}

SwiftUI #Binding property not updating nested views - Xcode 12

I have problems updating my nested SwiftUI views with #Binding property.
I declared a DataModel with ObservableObject protocol:
class DataModel: ObservableObject {
#Published var subjects: [Subject] = []
...
}
I added it to my main app:
#main
struct LessonToTextApp: App {
#ObservedObject private var data = DataModel()
var body: some Scene {
WindowGroup {
NavigationView {
SubjectsView(subjects: $data.subjects) {
data.save()
}
}
.onAppear {
data.load()
}
}
}
}
I passed the Subjects array to the first view
struct SubjectsView: View {
#Binding var subjects: [Subject]
var body: some View {
List {
if subjects.isEmpty {
Text("subjects.empty")
} else {
ForEach(subjects) { subject in
NavigationLink(destination: DetailView(subject: binding(for: subject), saveAction: saveAction)) {
CardView(subject: subject)
}
.listRowBackground(subject.color)
.cornerRadius(10)
}
}
}
private func binding(for subject: Subject) -> Binding<Subject> {
guard let subIndex = subjects.firstIndex(where: { $0.id == subject.id }) else {
fatalError("Can't find subject in array")
}
return $subjects[subIndex]
}
And then i passed the single subject to the Second view using the function binding declared above:
struct DetailView: View {
#Binding var subject: Subject
var body: some View {
ForEach(subject.lessons) { lesson in
NavigationLink(destination: LessonView(lesson: lesson)) {
Text(lesson.date, style: .date)
}
}
.onDelete { indexSet in
self.subject.lessons.remove(atOffsets: indexSet)
}
}
In the DetailView, when i delete an item in ForEach the item still appear, the view doesn't update.
I'm using SwiftUI 2.0 on Xcode 12.3 (12C33)
EDIT
This is the Model:
struct Subject: Identifiable, Codable {
let id: UUID
var name: String
var teacher: String
var color: Color
var lessons: [Lesson]
}
struct Lesson: Identifiable, Codable {
let id: UUID
let date: Date
var lenghtInMinutes: Int
var transcript: String
}
Your SubjectsView should take in the entire DataModel as an #EnvironmentObject
struct LessonToTextApp: App {
#StateObject private var data = DataModel()
var body: some Scene {
WindowGroup {
NavigationView {
SubjectsView().environmentObject(data) {
data.save()
}
...
struct SubjectsView: View {
#EnvironmentObject var data = DataModel
var body: some View {
List {
if data.subjects.isEmpty {
Text("subjects.empty")
...
Also, struct are immutable
class Subject: Identifiable, Codable, ObservableObject {
let id: UUID
#Published var name: String
#Published var teacher: String
#Published var color: Color
...
}
struct DetailView: View {
#ObservedObject var subject: Subject
var body: some View {
...
That way you can get to the DetailView with
DetailView(subject: subject)

Using #StateObject in iOS 14.0 while supporting iOS 13.0

I need help finding the best way to support the new #StateObject in iOS 14.0 and still supporting some alternative in iOS 13.0. Admittedly, I do not know what is the best approach in iOS 13.0. Below is what I currently have.
Does anyone have ideas on a better approach?
struct HomeView: View {
let viewModel: HomeViewModel
var body: some View {
if #available(iOS 14, *) {
HomeViewWrapper(viewModel: viewModel)
} else {
CompatibleHomeViewWrapper(viewModel: viewModel)
}
}
}
#available(iOS 14, *)
private struct HomeViewWrapper: View {
#StateObject var viewModel: HomeViewModel
var body: some View {
CompatibleHomeView(viewModel: viewModel)
}
}
private struct CompatibleHomeViewWrapper: View {
#State var viewModel: HomeViewModel
var body: some View {
CompatibleHomeView(viewModel: viewModel)
}
}
struct CompatibleHomeView: View {
#ObservedObject var viewModel: HomeViewModel
var body: some View {
Text(viewModel.someRandomName)
}
}
You can get the #StateObject behaviour by wrapping a custom propertyWrapper around #State and #ObservedObject like so:
import Combine
import PublishedObject // https://github.com/Amzd/PublishedObject
/// A property wrapper type that instantiates an observable object.
#propertyWrapper
public struct StateObject<ObjectType: ObservableObject>
where ObjectType.ObjectWillChangePublisher == ObservableObjectPublisher {
/// Wrapper that helps with initialising without actually having an ObservableObject yet
private class ObservedObjectWrapper: ObservableObject {
#PublishedObject var wrappedObject: ObjectType? = nil
init() {}
}
private var thunk: () -> ObjectType
#ObservedObject private var observedObject = ObservedObjectWrapper()
#State private var state = ObservedObjectWrapper()
public var wrappedValue: ObjectType {
if state.wrappedObject == nil {
// There is no State yet so we need to initialise the object
state.wrappedObject = thunk()
}
if observedObject.wrappedObject == nil {
// Retrieve the object from State and observe it in ObservedObject
observedObject.wrappedObject = state.wrappedObject
}
return state.wrappedObject!
}
public init(wrappedValue thunk: #autoclosure #escaping () -> ObjectType) {
self.thunk = thunk
}
}
I use this myself too so I will keep it updated at:
https://gist.github.com/Amzd/8f0d4d94fcbb6c9548e7cf0c1493eaff
Note: The most upvoted comment was that ObservedObject was very similar, which is just not true at all.
StateObject retains the object between view inits AND relays object changes to the view through willChangeObserver.
ObservedObject only relays changes to the view BUT if you create it in the view init, every time the parent view changes the object will be initialised again (losing your state).
This explanation is very crude and there is better out there please read up on it as it is an impactful part of SwiftUI.
struct StateObjectView<ViewModel: ObservableObject, Content: View>: View {
let viewModel: ViewModel
let content: () -> Content
var body: some View {
VStack {
if #available(iOS 14, *) {
StateObjectView14(viewModel: viewModel, content: content)
} else {
StateObjectView13(viewModel: viewModel, content: content)
}
}
}
}
#available(iOS 14.0, *)
struct StateObjectView14<ViewModel: ObservableObject, Content: View>: View {
#SwiftUI.StateObject var viewModel : ViewModel
let content: () -> Content
var body: some View {
content()
.environmentObject(viewModel)
}
}
struct StateObjectView13<ViewModel: ObservableObject, Content: View>: View {
#Backport.StateObject var viewModel : ViewModel
let content: () -> Content
var body: some View {
content()
.environmentObject(viewModel)
}
}
Usage:
struct ContentView: View {
#State private var reset = false
var body: some View {
VStack {
Button("reset") {
reset.toggle()
}
ScoreView()
}
}
}
class ScoreViewModel: ObservableObject {
init() {
//score = 0
print("Model Created")
}
#Published var score: Int = 0
}
struct ScoreView: View {
let viewModel : ScoreViewModel = .init()
var body: some View {
StateObjectView(viewModel: viewModel) {
ScoreContentView()
}
.onAppear {
print("ScoreView Appear")
}
}
}
struct ScoreContentView: View {
//#ObservedObject var viewModel : ScoreViewModel = .init()
#EnvironmentObject var viewModel : ScoreViewModel
#State private var niceScore = false
var body: some View {
VStack {
Button("Add Score") {
viewModel.score += 1
print("viewModel.score: \(viewModel.score)")
if viewModel.score > 3 {
niceScore = true
}
}
Text("Content Score: \(viewModel.score)")
Text("Nice? \(niceScore ? "YES" : "NO")")
}
.padding()
.background(Color.red)
}
}
Backports:
https://github.com/shaps80/SwiftUIBackports

Why my SwiftUI List is not getting updated?

I use a SwiftUI List and pass a String to a different view via a Binding.
But the list get not updated when I went back.
Here is my example:
struct ContentView: View {
#State private var list = ["a", "b", "c"]
#State private var item: String?
#State private var showSheet = false
var body: some View {
List {
ForEach(list.indices) { i in
Button(action: {
item = list[i]
showSheet.toggle()
}) {
Text(list[i])
}
}
}
.sheet(isPresented: $showSheet, content: {
DetailView(input: $item)
})
}
}
And the detail page:
struct DetailView: View {
#Binding var input: String?
var body: some View {
Text(input ?? "")
.onDisappear{
print("changed to changed")
input = "changed"
}
}
}
What I want to achieve is, that on every Item I click, I see the detail page. After that the element should change to "changed". But this does not happen. Why?
You update item but not list, so don't see any result. Here is corrected variant - store selected index and pass binding to list by index.
Tested with Xcode 12.1 / iOS 14.1
struct ContentView: View {
#State private var list = ["a", "b", "c"]
#State private var item: Int?
var body: some View {
List {
ForEach(list.indices) { i in
Button(action: {
item = i
}) {
Text(list[i])
}
}
}
.sheet(item: $item, content: { i in
DetailView(input: $list[i])
})
}
}
extension Int: Identifiable {
public var id: Self { self }
}
struct DetailView: View {
#Binding var input: String
var body: some View {
Text(input)
.onDisappear{
print("changed to changed")
input = "changed"
}
}
}
I recommend you use .sheet(item:content:) instead of .sheet(isPresented:content:)
struct ContentView: View {
#State private var items = ["a", "b", "c"]
#State private var selectedIndex: Int?
var body: some View {
List {
ForEach(items.indices) { index in
Button(action: {
selectedIndex = index
}) {
Text(items[index])
}
}
}
.sheet(item: $selectedIndex) { index in
DetailView(item: $items[index])
}
}
}
struct DetailView: View {
#Binding var item: String
var body: some View {
Text(item)
.onDisappear {
print("changed to changed")
item = "changed"
}
}
}
This will, however, require the selectedIndex to conform to Identifiable.
You can either create an Int extension:
extension Int: Identifiable {
public var id: Int { self }
}
or create a custom struct for your data (and conform to Identifiable).

Resources