I'm currently playing with Combine and SwiftUI and have built a prototype app using the MVVM pattern. The app utilises a timer and the state of the button controlling this is bound (inelegantly) to the view model utilising a PassThroughSubject.
When the button is pressed, this should toggle the value of a state variable; the value of this is passed to the view model's subject (using .send) which should send a single event per button press. However, there appears to be recursion or something equally weird going on as multiple events are sent to the subject and a runtime crash results without the UI ever being updated.
It's all a bit puzzling and I'm not sure if this is a bug in Combine or I've missed something. Any pointers would be much appreciated. Code below - I know it's messy ;-) I've trimmed it down to what appears to be relevant but let me know if you need more.
View:
struct ControlPanelView : View {
#State private var isTimerRunning = false
#ObjectBinding var viewModel: ControlPanelViewModel
var body: some View {
HStack {
Text("Case ID") // replace with binding to viewmode
Spacer()
Text("00:00:00") // repalce with binding to viewmodel
Button(action: {
self.isTimerRunning.toggle()
self.viewModel.apply(.isTimerRunning(self.isTimerRunning))
print("Button press")
}) {
isTimerRunning ? Image(systemName: "stop") : Image(systemName: "play")
}
}
// .onAppear(perform: { self.viewModel.apply(.isTimerRunning(self.isTimerRunning)) })
.font(.title)
.padding(EdgeInsets(top: 0, leading: 32, bottom: 0, trailing: 32))
}
}
Viewmodel:
final class ControlPanelViewModel: BindableObject, UnidirectionalDataType {
typealias InputType = Input
typealias OutputType = Output
private let didChangeSubject = PassthroughSubject<Void, Never>()
private var cancellables: [AnyCancellable] = []
let didChange: AnyPublisher<Void, Never>
// MARK:- Input
...
private let isTimerRunningSubject = PassthroughSubject<Bool, Never>()
....
enum Input {
...
case isTimerRunning(Bool)
...
}
func apply(_ input: Input) {
switch input {
...
case .isTimerRunning(let state): isTimerRunningSubject.send(state)
...
}
}
// MARK:- Output
struct Output {
var isTimerRunning = false
var elapsedTime = TimeInterval(0)
var concernId = ""
}
private(set) var output = Output() {
didSet { didChangeSubject.send() }
}
// MARK:- Lifecycle
init(timerService: TimerService = TimerService()) {
self.timerService = timerService
didChange = didChangeSubject.eraseToAnyPublisher()
bindInput()
bindOutput()
}
private func bindInput() {
utilities.debugSubject(subject: isTimerRunningSubject)
let timerToggleStream = isTimerRunningSubject
.subscribe(isTimerRunningSubject)
...
cancellables += [
timerToggleStream,
elapsedTimeStream,
concernIdStream
]
}
private func bindOutput() {
let timerToggleStream = isTimerRunningSubject
.assign(to: \.output.isTimerRunning, on: self)
...
cancellables += [
timerToggleStream,
elapsedTimeStream,
idStream
]
}
}
In your bindInput method isTimerRunningSubject subscribes to itself. I suspect this is not what you intended and probably explains the weird recursion you are describing. Maybe you're missing a self. somewhere?
Also odd is that both bindInput and bindOutput add all streams to the cancellables array, so they'll be in there twice.
Hope this helps.
This example works as expected but I discovered during the process that you cannot use the pattern in the original code (internal Structs to define Input and Output) with #Published. This causes some fairly odd errors (and BAD_ACCESS in a Playground) and is a bug that was reported in Combine beta 3.
final class ViewModel: BindableObject {
var didChange = PassthroughSubject<Void, Never>()
#Published var isEnabled = false
private var cancelled = [AnyCancellable]()
init() {
bind()
}
private func bind() {
let t = $isEnabled
.map { _ in }
.eraseToAnyPublisher()
.subscribe(didChange)
cancelled += [t]
}
}
struct ContentView : View {
#ObjectBinding var viewModel = ViewModel()
var body: some View {
HStack {
viewModel.isEnabled ? Text("Button ENABLED") : Text("Button disabled")
Spacer()
Toggle(isOn: $viewModel.isEnabled, label: { Text("Enable") })
}
.padding()
}
}
Related
I'm trying to append an array on a tap gesture, but it seems that it replaces any item in the array with the current one.
The Array and functions:
class ChipsViewModel: ObservableObject {
#Published var selectedCategories = [String]()
func appendArray(title:String) {
selectedCategories.append(title)
}
func removeFromArray(title:String){
if let index = selectedCategories.firstIndex(of: title) {
selectedCategories.remove(at: index)
}
}
}
The button itself:
#ObservedObject var viewModel = ChipsViewModel()
let systemImage: String
let titleKey: String
#State var isSelected: Bool
var body: some View {
HStack {
...
}.onTapGesture {
isSelected.toggle()
if(isSelected){
viewModel.appendArray(title: titleKey)
} else if (!isSelected){
viewModel.removeFromArray(title: titleKey)
}
print(viewModel.selectedCategories)
}
}
Not really sure why is this happening, it worked when i implemented a binding foreach, but using that setup the chips don't work as needed.
It looks like you are trying to keep state in sync in two places at the same time, it is usually not a good idea. It is best to keep it your view model
class ChipsViewModel: ObservableObject {
#Published var selectedCategories = [String]()
func isSelected(_ title: String) -> Bool {
selectedCategories.contains(title)
}
func toggleSelection(_ title: String) {
if let index = selectedCategories.firstIndex(of: title) {
selectedCategories.remove(at: index)
} else {
selectedCategories.append(title)
}
}
}
and then your View gets simpler and the state (i.e. whether a title is selected or not) cannot get out of sync:
#StateObject var viewModel = ChipsViewModel()
let systemImage: String
let titleKey: String
var isSelected: Bool {
viewModel.isSelected(titleKey)
}
var body: some View {
HStack {
//...
}
.onTapGesture {
viewModel.toggleSelection(titleKey)
}
}
The State needs to be initialized, like
let systemImage: String
let titleKey: String
#State var isSelected: Bool = false // << here !!
In my project i hold a large dict of items that are updated via grpc stream. Inside the app there are several places i am rendering these items to UI and i would like to propagate the realtime updates.
Simplified code:
struct Item: Identifiable {
var id:String = UUID().uuidString
var name:String
var someKey:String
init(name:String){
self.name=name
}
}
class DataRepository {
public var serverSymbols: [String: CurrentValueSubject<Item, Never>] = [:]
// method that populates the dict
func getServerSymbols(serverID:Int){
someService.fetchServerSymbols(serverID: serverID){ response in
response.data.forEach { (name,sym) in
self.serverSymbols[name] = CurrentValueSubject(Item(sym))
}
}
}
// background stream that updates the values
func serverStream(symbols:[String] = []){
someService.initStream(){ update in
DispatchQueue.main.async {
self.serverSymbols[data.id]?.value.someKey = data.someKey
}
}
}
}
ViewModel:
class SampleViewModel: ObservableObject {
#Injected var repo:DataRepository // injection via Resolver
// hardcoded value here for simplicity (otherwise dynamically added/removed by user)
#Published private(set) var favorites:[String] = ["item1","item2"]
func getItem(item:String) -> Item {
guard let item = repo.serverSymbols[item] else { return Item(name:"N/A")}
return ItemPublisher(item: item).data
}
}
class ItemPublisher: ObservableObject {
#Published var data:Item = Item(name:"")
private var cancellables = Set<AnyCancellable>()
init(item:CurrentValueSubject<Item, Never>){
item
.receive(on: DispatchQueue.main)
.assignNoRetain(to: \.data, on: self)
.store(in: &cancellables)
}
}
Main View with subviews:
struct FavoritesView: View {
#ObservedObject var viewModel: QuotesViewModel = Resolver.resolve()
var body: some View {
VStack {
ForEach(viewModel.favorites, id: \.self) { item in
FavoriteCardView(item: viewModel.getItem(item: item))
}
}
}
}
struct FavoriteCardView: View {
var item:Item
var body: some View {
VStack {
Text(item.name)
Text(item.someKey) // dynamic value that should receive the updates
}
}
}
I must've clearly missed something or it's a completely wrong approach, however my Item cards do not receive any updates (i verified the backend stream is active and serverSymbols dict is getting updated). Any advice would be appreciated!
I've realised i've made a mistake - in order to receive the updates i need to pass down the ItemPublisher itself. (i was incorrectly returning ItemPublisher.data from my viewModel's method)
I've refactored the code and make the ItemPublisher provide the data directly from my repository using the item key, so now each card is subscribing individualy using the publisher.
Final working code now:
class SampleViewModel: ObservableObject {
// hardcoded value here for simplicity (otherwise dynamically added/removed by user)
#Published private(set) var favorites:[String] = ["item1","item2"]
}
MainView and CardView:
struct FavoritesView: View {
#ObservedObject var viewModel: QuotesViewModel = Resolver.resolve()
var body: some View {
VStack {
ForEach(viewModel.favorites, id: \.self) { item in
FavoriteCardView(item)
}
}
}
}
struct FavoriteCardView: View {
var itemName:String
#ObservedObject var item:ItemPublisher
init(_ itemName:String){
self.itemName = itemName
self.item = ItemPublisher(item:item)
}
var body: some View {
let itemData = item.data
VStack {
Text(itemData.name)
Text(itemData.someKey)
}
}
}
and lastly, modified ItemPublisher:
class ItemPublisher: ObservableObject {
#Injected var repo:DataRepository
#Published var data:Item = Item(name:"")
private var cancellables = Set<AnyCancellable>()
init(item:String){
self.data = Item(name:item)
if let item = repo.serverSymbols[item] {
self.data = item.value
item.receive(on: DispatchQueue.main)
.assignNoRetain(to: \.data, on: self)
.store(in: &cancellables)
}
}
}
What is the best approach to have swiftUI still update based on nested observed objects?
The following example shows what I mean with nested observed objects. The balls array of the ball manager is a published property that contains an array of observable objects, each with a published property itself (the color string).
Unfortunately, when tapping one of the balls it dos not update the balls name, nor does it receive an update. So I might have messed up how combine was ment to work in that case?
import SwiftUI
class Ball: Identifiable, ObservableObject {
let id: UUID
#Published var color: String
init(ofColor color: String) {
self.id = UUID()
self.color = color
}
}
class BallManager: ObservableObject {
#Published var balls: [Ball]
init() {
self.balls = []
}
}
struct Arena: View {
#StateObject var bm = BallManager()
var body: some View {
VStack(spacing: 20) {
ForEach(bm.balls) { ball in
Text(ball.color)
.onTapGesture {
changeBall(ball)
}
}
}
.onAppear(perform: createBalls)
.onReceive(bm.$balls, perform: {
print("ball update: \($0)")
})
}
func createBalls() {
for i in 1..<4 {
bm.balls.append(Ball(ofColor: "c\(i)"))
}
}
func changeBall(_ ball: Ball) {
ball.color = "cx"
}
}
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<Ball, Never>()
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.
You just create a BallView and Observe it and make changes from there. You have to Observe each ObservableObject directly
struct Arena: View {
#StateObject var bm = BallManager()
var body: some View {
VStack(spacing: 20) {
ForEach(bm.balls) { ball in
BallView(ball: ball)
}
}
.onAppear(perform: createBalls)
.onReceive(bm.$balls, perform: {
print("ball update: \($0)")
})
}
func createBalls() {
for i in 1..<4 {
bm.balls.append(Ball(ofColor: "c\(i)"))
}
}
}
struct BallView: View {
#ObservedObject var ball: Ball
var body: some View {
Text(ball.color)
.onTapGesture {
changeBall(ball)
}
}
func changeBall(_ ball: Ball) {
ball.color = "cx"
}
}
You do not need nested ObserverObjects for this example:
Model should be a simple struct:
struct Ball: Identifiable {
let id: UUID
let color: String
init(id: UUID = UUID(),
color: String) {
self.id = id
self.color = color
}
}
ViewModel should handle all the logic, that's why I have moved all the functions that manipulate balls here and made the array of balls private set. Because calling changeBall replaces one struct in the array with another one objectWillChange is fired an the view gets updated and onReceive gets triggered.
class BallManager: ObservableObject {
#Published private (set) var balls = [Ball]()
func changeBall(_ ball: Ball) {
guard let index = balls.firstIndex(where: { $0.id == ball.id }) else { return }
balls[index] = Ball(id: ball.id, color: "cx")
}
func createBalls() {
for i in 1..<4 {
balls.append(Ball(color: "c\(i)"))
}
}
}
The View should just communicate user intentions to the ViewModel:
struct Arena: View {
#StateObject var ballManager = BallManager()
var body: some View {
VStack(spacing: 20) {
ForEach(ballManager.balls) { ball in
Text(ball.color)
.onTapGesture {
ballManager.changeBall(ball)
}
}
}
.onAppear(perform: ballManager.createBalls)
.onReceive(ballManager.$balls) {
print("ball update: \($0)")
}
}
}
Model
The Ball is a model and could be a struct or a class (struct is usually recommended, you can find more information here)
ViewModel
Usually you use ObservableObject as a ViewModel or component that manages the data. It is usually common to set a default value for the balls (models), so you can set an empty array. Then you can populate the models with a network request from a database or storage.
The BallManager can be renamed to BallViewModel
The BallViewModel has a function that changes the underlying model based on the index in the ForEach component. The id: \.self basically renders the ball (model) for the current index.
Proposed solution
The following changes will work for achieving what you want to do
struct Ball: Identifiable {
let id: UUID
var color: String
init(ofColor color: String) {
self.id = UUID()
self.color = color
}
}
class BallViewModel: ObservableObject {
#Published var balls: [Ball] = []
func changeBallColor(in index: Int, color: String) {
balls[index] = Ball(ofColor: color)
}
}
struct Arena: View {
#StateObject var bm = BallViewModel()
var body: some View {
VStack(spacing: 20) {
ForEach(bm.balls.indices, id: \.self) { index in
Button(bm.balls[index].color) {
bm.changeBallColor(in: index, color: "cx")
}
}
}
.onAppear(perform: createBalls)
.onReceive(bm.$balls, perform: {
print("ball update: \($0)")
})
}
func createBalls() {
for i in 1..<4 {
bm.balls.append(Ball(ofColor: "c\(i)"))
}
}
}
I'm trying to wrap my mind around how Combine works. I believe I'm doing something wrong when I use the .assign operator to mutate the #Published property I'm operating on. I've read the documentation on Publishers, Subscribers, and Operators. But I'm a bit loose on where exactly to create the Publisher if I don't want it to be a function call.
import SwiftUI
import Combine
struct PhoneNumberField: View {
let title: String
#ObservedObject var viewModel = ViewModel()
var body: some View {
TextField(title,text: $viewModel.text)
}
class ViewModel: ObservableObject {
#Published var text: String = ""
private var disposables = Set<AnyCancellable>()
init() {
$text.map { value -> String in
self.formattedNumber(number: value)
}
//something wrong here
.assign(to: \.text, on: self)
.store(in: &disposables)
}
func formattedNumber(number: String) -> String {
let cleanPhoneNumber = number.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()
let mask = "+X (XXX) XXX-XXXX"
var result = ""
var index = cleanPhoneNumber.startIndex
for ch in mask where index < cleanPhoneNumber.endIndex {
if ch == "X" {
result.append(cleanPhoneNumber[index])
index = cleanPhoneNumber.index(after: index)
} else {
result.append(ch)
}
}
return result
}
}
}
struct PhoneNumberParser_Previews: PreviewProvider {
static var previews: some View {
PhoneNumberField(title: "Phone Number")
}
}
Use .receive(on:):
$text.map { self.formattedNumber(number: $0) }
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] value in
self?.text = value
})
.store(in: &disposables)
This will allow you to listen to changes of the text variable and update it in the main queue. Using main queue is necessary if you want to update #Published variables read by some View.
And to avoid having a retain cycle (self -> disposables -> assign -> self) use sink with a weak self.
I want to delete an object which is marked as #ObjectBinding, in order to clean up some TextFields for example.
I tried to set the object reference to nil, but it didn't work.
import SwiftUI
import Combine
class A: BindableObject {
var didChange = PassthroughSubject<Void, Never>()
var text = "" { didSet { didChange.send() } }
}
class B {
var property = "asdf"
}
struct DetailView : View {
#ObjectBinding var myObject: A = A() //#ObjectBinding var myObject: A? = A() -> Gives an error.
#State var mySecondObject: B? = B()
var body: some View {
VStack {
TextField($myObject.text, placeholder: Text("Enter some text"))
Button(action: {
self.test()
}) {
Text("Clean up")
}
}
}
func test() {
//myObject = nil
mySecondObject = nil
}
}
If I try to use an optional with #ObjectBinding, I'm getting the Error
"Cannot convert the value of type 'ObjectBinding' to specified type
'A?'".
It just works with #State.
Regards
You can do something like this:
class A: BindableObject {
var didChange = PassthroughSubject<Void, Never>()
var form = FormData() { didSet { didChange.send() } }
struct FormData {
var firstname = ""
var lastname = ""
}
func cleanup() {
form = FormData()
}
}
struct DetailView : View {
#ObjectBinding var myObject: A = A()
var body: some View {
VStack {
TextField($myObject.form.firstname, placeholder: Text("Enter firstname"))
TextField($myObject.form.lastname, placeholder: Text("Enter lastname"))
Button(action: {
self.myObject.cleanup()
}) {
Text("Clean up")
}
}
}
}
I absolutely agree with #kontiki , but you should remember to don't use #State when variable can get outside. #ObjectBinding right way in this case. Also all new way of memory management already include optional(weak) if they need it.
Check this to get more information about memory management in SwiftUI
Thats how to use #ObjectBinding
struct DetailView : View {
#ObjectBinding var myObject: A
and
DetailView(myObject: A())