I'm simplifying a more complex real world example. The real world example involves 2 videos side-by-side in landscape orientation, with a singular video control view. The user can select the left video, right video, or both. Play only what is selected, or both at the same time. Additionally, they can "scrub video". If both videos are selected, and they scrub the left, it should scrub the right as well.

Anyway, I've converted this into the following toy example.

Task:

Imagine we want to represent a simple finger counting system in an app.

Digits/fingers on one's left hand will count for 1. Each time we reach 5 fingers on the left hand, we'll increment the right hand by 1 to represent a value of 5.

So, 1 finger on the left hand and 2 fingers on the right hand will total 11. The total possible count would be 30 (5 fingers on right = 25, 5 fingers on left = 5).

  • Each "hand" is dumb. The hands fingers don't have logic or a brain, and their fingers aren't inherently worth 1 or 5. It's our human brain (our model) that is applying this temporary counter logic to these hands/fingers. Tomorrow, it might be 2 and 10 for values. So the hands/fingers must remain dumb.

  • The brain/model must also coordinate the "turnover" of when the left hand reaches 5. The brain/model will put all the fingers of the left hand down (back to 0), and add 1 finger on the right hand.

Current Toy Code:

We start with a FingerCounterViewModel. It can add a finger or subtract a finger. The hand is dumb and assigns no values to anything, other than how many fingers are showing. (To keep the code clean and example concise, there is no checking for 6 fingers, or negative fingers, etc).

class FingerCountViewModel: ObservableObject {
    @Published var fingers = 0
    
    func addFinger() {
        fingers += 1
    }
    
    func removeFinger() {
        fingers -= 1
    }
}

Now, we make a very simple view to represent a hand and how many fingers are showing:

struct FingerCounterView: View {
    @ObservedObject var fingerViewModel: FingerCountViewModel
    
    var body: some View {
        HStack {
                Spacer()
            Button {
                fingerViewModel.removeFinger()
            } label: {
                Text("-")
            }
                Spacer()
            Text("\(fingerViewModel.fingers)")
                Spacer()
            Button {
                fingerViewModel.addFinger()
            } label: {
                Text("+")
            }
                Spacer()
        }
    }
}

We accept a FingerCountViewModel, allow it add or remove a finger through the tap of a button, and display the amount of fingers showing on the hand.

Now, we need a brain/model. One that has the concept of a left hand and right hand, a concept of assigning a value of 5 to each finger on the right hand, and the concept of how to deal with "turnover".

class HandCounterViewModel: ObservableObject {
    @Published var leftHand = FingerCountViewModel()
    @Published var rightHand = FingerCountViewModel()
    
    var anyCancellableLeft: AnyCancellable? = nil
    var anyCancellableRight: AnyCancellable? = nil
    
    init() {
        anyCancellableLeft = leftHand.objectWillChange.sink { [weak self] (_) in
            self?.objectWillChange.send()
        }
        
        anyCancellableRight = rightHand.objectWillChange.sink { [weak self] (_) in
            self?.objectWillChange.send()
        }
    }
    
    func total() -> Int {
        return leftHand.fingers + (rightHand.fingers * 5)
    }
}

We represent the left and right hand in the brain. The total can be grabbed by multiplying 5 to right hand finger count, and adding it to the left hand finger count.

We also use Combine's objectWillChange.sink (to alert the view below that a change to the @Published's leftHand or rightHand has occurred).

Lastly, we need to show both of our hands on screen, and allow the user to start their counting system. We'll show how many fingers are visible on the left and right hand, and then display their total perceived value in our system at the bottom.

struct HandCounteViewr: View {
    @ObservedObject private var handViewModel = HandCounterViewModel()

    var body: some View {
        VStack {
                Spacer()
            FingerCounterView(fingerViewModel: handViewModel.leftHand)
                Spacer()
            FingerCounterView(fingerViewModel: handViewModel.rightHand)
                Spacer()
            Text("Total: \(handViewModel.total())")
                Spacer()
        }
    }
}

This works now in terms of adding the finger total. However, it's incomplete.

  1. It does not deal with "turnover". The brain/model needs to realize that when there are 5 fingers on the left hand, to turn them all down to 0, and increment the right hand by 1. However, if we did that in the leftHand.objectWillChange.sink closure... we would get an infinite loop for setting leftHand.fingers = 0.

How can we solve this?

  1. I've seen that using Combine's objectWillChange.sink in this way is inefficient, as it will trigger a greater number of UI changes than is necessary up the chain. Can we achieve something without it where
  • changing one model within the brain's model can affect another (this is necessary for the more complex video player model initially described. User can scrub video on a CustomVideoPlayerView with their finger. IF 2 videos are side by side, that change should be propagated up to a brain/model oarent, to it can send it down to the other video in question if it should change playback position at the same rate. Etc etc.)
2

There are 2 best solutions below

0
Scott Thompson On

Let's do some domain modeling. A hand can have five fingers. You can increase the finger count or decrease the finger count. We want to make the finger count observable. If you try to increase the count on a hand showing all fingers, close the fist and send an overflow message. If you have a closed fist and decrease the finger count, send an underflow message:

class Hand {
  let fingers = CurrentValueSubject<Int, Never>(0)
  let overflow = PassthroughSubject<Void, Never>()
  let underflow = PassthroughSubject<Void, Never>()

  func addFinger() {
    if(fingers.value == 5) {
      fingers.value = 0
      overflow.send()
    } else {
      fingers.value += 1
    }
  }

  func removeFinger() {
    if(fingers.value > 0) {
      fingers.value -= 1
    } else {
      underflow.send()
    }
  }
}

This model is imperfect because the fingers value is totally exposed outside of the class. Someone with a hand could set the fingers count to anything they wanted and that's a Bad Thing™. So there's room for improvement left as an exercise for the reader.

I didn't use ObservableObject here because this is a Model object, not a View Model. It is a source of truth and other models will depend on it, not just views.

Now we need a Torso. A Torso is a Model that has two hands and is responsible for coordinating the count of fingers on those hands. I particular if the left hand overflows then it should put up a finger on the right hand. Similarly if the left hand underflows, the torso may "borrow" from the right hand:

The Torso also keeps track of the total

class Torso {
  let leftHand = Hand()
  let rightHand = Hand()
  let total: AnyPublisher<Int, Never>
  var subscriptions = Set<AnyCancellable>()

  init() {
    total = leftHand.fingers
      .combineLatest(rightHand.fingers)
      .map { left, right in left + right * 6 }
      .eraseToAnyPublisher()

    leftHand.overflow.sink {
      self.rightHand.addFinger()
    }.store(in: &subscriptions)

    leftHand.underflow.sink {
      if self.rightHand.fingers.value > 0 {
        self.rightHand.removeFinger()
        self.leftHand.fingers.value = 5
      }
    }.store(in: &subscriptions)
  }
}

total is a publisher that combines the latest count of the left and right hands, publishing the total number of fingers counted. note in my example a finger on the right hand represents that six (6) fingers have been counted, not 5

If leftHand gives an overflow signal, then add a finger to the right hand.

if leftHand gives an underflow signal, and the right hand has fingers up, then we "borrow" a right hand finger, subtract 1, and put the remaining 5 "fingers" on the left hand. This exploits the fact that the fingers count on a hand is not encapsulated which as previously mentioned, is a Bad Thing™.

A FingerCountView is a view for looking at a Hand.

struct FingerCountView: View {
  @State var hand: Hand
  @State var fingers: Int = 0

  var body: some View {
    HStack {
      Spacer()
      Button {
        hand.removeFinger()
      } label: {
        Text("-")
      }

      Spacer()
      Text(String(fingers))
      Spacer()

      Button {
        hand.addFinger()
      } label: {
        Text("+")
      }
      Spacer()
    }.onReceive(hand.fingers) { count in
      self.fingers = count
    }
  }
}

Note that it keeps a reference to the hand and uses onReceive to find out when the finger count changes. It's "view model" is its fingers state variable.

TorsoView lets you see the left and right hands on a torso:

struct TorsoView: View {
  private var torso = Torso()
  @State var total: Int = 0

  var body: some View {
    VStack {
      Spacer()
      VStack {
        FingerCountView(hand: torso.leftHand)
        Text("Left Hand")
      }
      Spacer()
      VStack {
        FingerCountView(hand: torso.rightHand)
        Text("Right Hand")
      }
      Spacer()
      Text("Total: \(total)")
      Spacer()
    }.onReceive(torso.total) { total in
      self.total = total
    }
  }
}

putting it all together in a playground:


import Combine
import SwiftUI
import UIKit
import PlaygroundSupport


class Hand {
  let fingers = CurrentValueSubject<Int, Never>(0)
  let overflow = PassthroughSubject<Void, Never>()
  let underflow = PassthroughSubject<Void, Never>()

  func addFinger() {
    if(fingers.value == 5) {
      fingers.value = 0
      overflow.send()
    } else {
      fingers.value += 1
    }
  }

  func removeFinger() {
    if(fingers.value > 0) {
      fingers.value -= 1
    } else {
      underflow.send()
    }
  }
}

class Torso {
  let leftHand = Hand()
  let rightHand = Hand()
  let total: AnyPublisher<Int, Never>
  var subscriptions = Set<AnyCancellable>()

  init() {
    total = leftHand.fingers
      .combineLatest(rightHand.fingers)
      .map { left, right in left + right * 6 }
      .eraseToAnyPublisher()

    leftHand.overflow.sink {
      self.rightHand.addFinger()
    }.store(in: &subscriptions)

    leftHand.underflow.sink {
      if self.rightHand.fingers.value > 0 {
        self.rightHand.removeFinger()
        self.leftHand.fingers.value = 5
      }
    }.store(in: &subscriptions)
  }
}

struct FingerCountView: View {
  @State var hand: Hand
  @State var fingers: Int = 0

  var body: some View {
    HStack {
      Spacer()
      Button {
        hand.removeFinger()
      } label: {
        Text("-")
      }

      Spacer()
      Text(String(fingers))
      Spacer()

      Button {
        hand.addFinger()
      } label: {
        Text("+")
      }
      Spacer()
    }.onReceive(hand.fingers) { count in
      self.fingers = count
    }
  }
}

struct TorsoView: View {
  private var torso = Torso()
  @State var total: Int = 0

  var body: some View {
    VStack {
      Spacer()
      VStack {
        FingerCountView(hand: torso.leftHand)
        Text("Left Hand")
      }
      Spacer()
      VStack {
        FingerCountView(hand: torso.rightHand)
        Text("Right Hand")
      }
      Spacer()
      Text("Total: \(total)")
      Spacer()
    }.onReceive(torso.total) { total in
      self.total = total
    }
  }
}


let wrapper = UIHostingController(rootView: TorsoView())
PlaygroundSupport.PlaygroundPage.current.liveView = wrapper
4
chris P On

Based on @Sweeper first comment to the original question, here is how I've switched things around:

class FingerCountViewModel: ObservableObject {
    @Published var fingers = 0
    
    func addFinger() {
        fingers += 1
    }
    
    func removeFinger() {
        fingers -= 1
    }
}

struct FingerCounterView: View {
    @ObservedObject var fingerViewModel: FingerCountViewModel
    
    var body: some View {
        HStack {
                Spacer()
            Button {
                fingerViewModel.removeFinger()
            } label: {
                Text("-")
            }
                Spacer()
            Text("\(fingerViewModel.fingers)")
                Spacer()
            Button {
                fingerViewModel.addFinger()
            } label: {
                Text("+")
            }
                Spacer()
        }
    }
}

struct HandCounterView: View {
    @ObservedObject private var leftHand = FingerCountViewModel()
    @ObservedObject private var rightHand = FingerCountViewModel()
    
    func totalCount() -> Int {
        return leftHand.fingers + (rightHand.fingers * 5)
    }
    
    var body: some View {
        VStack {
            Spacer()
            FingerCounterView(fingerViewModel: leftHand)
            Spacer()
            FingerCounterView(fingerViewModel: rightHand)
            Spacer()
            Text("Total: \(totalCount())")
            Spacer()
        }
        .onChange(of: leftHand.fingers, perform: { newValue in
            if leftHand.fingers == 5 {
                rightHand.addFinger()
                leftHand.fingers = 0
            }
        })
    }
}

I'm going to attempt to take the principal to the non-trivial/toy example next. 2 video view models, and the overall "ComparisonVideoView" which allow a user to have 2 videos side by side that can operate/play separately or together in unison.

What's funny is I originally had 2 separate VM's as suggested, and I went and refactored to have a single "VM" as my custom player control (view with play, pause, and lots of custom video buttons) was using a single video VM at the time.

I need to put back the 2 video VM's, and have the player control view have it's own VM, and then the ComparisonVideoView I guess can coordinate between the 2 video VM's and 1 custom player control VM.