How to Tell Swiftui Views to Bind to Nested Observableobjects

How to tell SwiftUI views to bind to nested ObservableObjects

Nested models does not work yet in SwiftUI, but you could do something like this

class SubModel: ObservableObject {
@Published var count = 0
}

class AppModel: ObservableObject {
@Published var submodel: SubModel = SubModel()

var anyCancellable: AnyCancellable? = nil

init() {
anyCancellable = submodel.objectWillChange.sink { [weak self] (_) in
self?.objectWillChange.send()
}
}
}

Basically your AppModel catches the event from SubModel and send it further to the View.

Edit:

If you do not need SubModel to be class, then you could try something like this either:

struct SubModel{
var count = 0
}

class AppModel: ObservableObject {
@Published var submodel: SubModel = SubModel()
}

How to tell SwiftUI views to bind to more than one nested ObservableObject

You can expand upon the answer in the question you linked to by using CombineLatest to have your model fire its objectWillChange publisher when either of the underlying objects change:

import Combine

class Model: ObservableObject {
@Published var submodel1: Submodel1 = Submodel1()
@Published var submodel2: Submodel2 = Submodel2()

var anyCancellable: AnyCancellable? = nil

init() {
anyCancellable = Publishers.CombineLatest(submodel1.$count,submodel2.$count).sink(receiveValue: {_ in
self.objectWillChange.send()
})
}
}

SwiftUI Combine: Nested Observed-Objects

When a Ball in the balls array changes, you can call objectWillChange.send() to update the ObservableObject.

The follow should work for you:

class BallManager: ObservableObject {
@Published var balls: [Ball] {
didSet { setCancellables() }
}
let ballPublisher = PassthroughSubject()
private var cancellables = [AnyCancellable]()

init() {
self.balls = []
}

private func setCancellables() {
cancellables = balls.map { ball in
ball.objectWillChange.sink { [weak self] in
guard let self = self else { return }
self.objectWillChange.send()
self.ballPublisher.send(ball)
}
}
}
}

And get changes with:

.onReceive(bm.ballPublisher) { ball in
print("ball update:", ball.id, ball.color)
}

Note: If the initial value of balls was passed in and not always an empty array, you should also call setCancellables() in the init.

Updates changes for nested models

In your current code, menu is only ever set once (on init). You want to set menu every time the object updates.

To do this, change the menu after the settings object has changed one of the values. Here we receive on the main thread, then update the menu with setMenu().

You could also change the UserSettings to use didSet instead of willSet, and then you will no longer require the .receive(on: DispatchQueue.main). However, this may be a bit counter-intuitive to the meaning of objectWillChange.

Code:

class ViewModel: ObservableObject {
let settings = UserSettings()
@Published private(set) var menu: [Item] = []
private var cancellables: [AnyCancellable] = []

init() {
setMenu()

settings.objectWillChange.receive(on: DispatchQueue.main).sink { [unowned self] in
setMenu()
}
.store(in: &cancellables)
}

private func setMenu() {
menu = [
.item(Language(id: "a", enable: settings.itemA)),
.item(Language(id: "b", enable: settings.itemB)),
.item(Language(id: "c", enable: settings.itemC))
]
}
}

I did change some things slightly which would be unrelated, such as the access levels. You don't want the user to accidentally edit menu directly.

ObservableObject not updating view in nested loop SWIFTUI

You implementation seems overly complicated and error prone. I´ve practically rewritten the code for this. I´ve added comments to make it clear what and why I have done certain things. If you don´t understand why, don´t hesitate to ask a question. But please read and try to understand the code first.

//Create one Model containing the individuals
struct Person: Identifiable, Codable{
var id = UUID()
var name: String
var amountToPay: Double = 0.0
var shouldPay: Bool = false
}

//Create one Viewmodel
class Viewmodel:ObservableObject{
//Entities being observed by the View
@Published var persons: [Person] = []

init(){
//Create data
persons = (0...4).map { index in
Person(name: "name \(index)")
}
}

//Function that can be called by the View to toggle the state
func togglePersonPay(with id: UUID){

let index = persons.firstIndex { $0.id == id}

guard let index = index else {
return
}
//Assign new value. This will trigger the UI to update
persons[index].shouldPay.toggle()
}

//Function to calculate the individual amount that should be paid and assign it
func calculatePayment(for amount: Double){
//Get all persons wich should pay
let personsToPay = persons.filter { $0.shouldPay }
//Calcualte the individual amount
let individualAmount = amount / Double(personsToPay.count)
//and assign it. This implementation will trigger the UI only once to update
persons = persons.map { person in
var person = person
person.amountToPay = person.shouldPay ? individualAmount : 0
return person
}

}
}

struct PersonView: View{
//pull the viewmodel from the environment
@EnvironmentObject private var viewmodel: Viewmodel
//The Entity that holds the individual data
var person: Person
var body: some View{
VStack{
HStack{
Text(person.name)
Text("\(person.amountToPay, specifier: "%.2f")$")
}
Button{
//toggle the state
viewmodel.togglePersonPay(with: person.id)
} label: {
//Assign label depending on person state
Image(systemName: "\(person.shouldPay ? "minus" : "plus")")
}
}
}
}

struct ContentView: View{
//Create and observe the viewmodel
@StateObject private var viewmodel = Viewmodel()
var body: some View{
VStack{
//Create loop to display person.
//Dont´t itterate over the indices this is bad practice
// itterate over the items themselves
ForEach(viewmodel.persons){ person in
PersonView(person: person )
.environmentObject(viewmodel)
.padding(10)
}

Button{
//call the func to calculate the result
viewmodel.calculatePayment(for: 100)
}label: {
Text("SHOW ME")
}
}
}
}

SwiftUI: matchedGeometryEffect With Nested Views

Here is an approach:

  • Get the size of MainView with GeometryReader and pass it down.
  • in DetailView use .overlay which can grow bigger than its parent view,
    if you specify an explicit .frame
  • You need another inner GeometryReaderto get the top pos of inner view for offset.

Sample Image

struct ContentView: View {

var body: some View {
// get size of overall view
GeometryReader { geo in
ScrollView(.vertical) {
Text("ShortCutView()")

PopularMoviesView(geo: geo)
}
}
}
}


struct PopularMoviesView: View {

// passed in geometry from parent view
var geo: GeometryProxy
// own view's top position, will be updated by GeometryReader further down
@State var ownTop = CGFloat.zero

@Namespace var namespace
@State var showDetails: Bool = false
@State var selectedMovie: Int?


var body: some View {

if !showDetails {
VStack {
HStack {
Text("Popular")
.font(.caption)
.padding()
Spacer()
Image(systemName: "arrow.forward")
.font(Font.title.weight(.medium))
.padding()

}
ScrollView(.horizontal) {
HStack {
ForEach(0..<10, id: \.self) { movie in
Text("MovieCell \(movie)")
.padding()
.matchedGeometryEffect(id: movie, in: namespace)
.frame(width: 200, height: 300)
.background(.yellow)

.onTapGesture {
self.selectedMovie = movie

withAnimation(Animation.interpolatingSpring(stiffness: 270, damping: 15)) {
showDetails.toggle()
}
}
}
}
}
}
}

if showDetails, let movie = selectedMovie {
// to get own view top pos
GeometryReader { geo in Color.clear.onAppear {
ownTop = geo.frame(in: .global).minY
print(ownTop)
}}

// overlay can become bigger than parent
.overlay (
Text("MovieDetail \(movie)")
.font(.largeTitle)
.matchedGeometryEffect(id: movie, in: namespace)
.frame(width: geo.size.width, height: geo.size.height)
.background(.gray)
.position(x: geo.frame(in: .global).midX, y: geo.frame(in: .global).midY - ownTop)

.onTapGesture {
withAnimation(Animation.interpolatingSpring(stiffness: 270, damping: 15)) {
showDetails.toggle()
}
}
)
}
}
}


Related Topics



Leave a reply



Submit