2

I have come across an interesting issue with SwiftUI's publishing going intermittently out of sync with the view which I can't seem to explain or solve. The problem occurs when using an associated enum with multiple cases.

My problem is best explained by a minimal code example. For this, let's say we are modelling a mathematical question with an answer. The answer can either be a number or a fraction. The values both of which can either be integers or doubles.

For the latter we define an associated enum as follows:

enum Number {
    case int(Int)
    case double(Double)
    
    var description: String {
        switch self {
        case let .int(i):    return i.description
        case let .double(d): return d.description
        }
    }
}

The answer is modelled as a struct with a nested associated enum as follows:

struct Answer {
    var answer: AnswerType
    
    enum AnswerType {
        case number(Number)
        case fraction(Number, Number)
    }
    
    var description: String {
        switch answer {
        case let .number(number):                   return number.description
        case let .fraction(numerator, denominator): return numerator.description + " / " + denominator.description
        }
    }
}

The question is also modelled as a struct. In this example it only contains the answer property and some (mutating) functions, which I will later use to show the problem. Only fractions are used as answers, since only in that case the problem arises.

struct Question {
    var answer: Answer
    
    mutating func randomFraction() {
        answer = Answer(answer: .fraction(.int(Int.random(in: 1...99)), .int(Int.random(in: 1...99))))
    }
    
    mutating func randomNumerator() {
        answer = Answer(answer: .fraction(.int(Int.random(in: 1...99)), .int(1)))
    }
    
    mutating func randomDenominator() {
        answer = Answer(answer: .fraction(.int(1), .int(Int.random(in: 1...99))))
    }
    
    mutating func emptyMutatingFunction() {
        
    }
    
    func emptyFunction() {
        
    }
}

The view model is a class publishing the question model and inherits from the ObservableObject:

class ExampleViewModel: ObservableObject {
    @Published var question: Question
    
    init() {
        question = Question(answer: Answer(answer: .fraction(.int(0), .int(0))))
    }
    
    func randomFraction() {
        question.randomFraction()
    }
    
    func randomNumerator() {
        question.randomNumerator()
    }
    
    func randomDenominator() {
        question.randomDenominator()
    }
    
    func emptyMutatingFunction() {
        question.emptyMutatingFunction()
    }
    
    func emptyFunction() {
        question.emptyFunction()
    }
}

Finally, to demonstrate the issue with the view getting intermittently out of sync with the ObservedObject, the view is setup to show the answer to the question in three different ways. Method A: Via a property in the subview, Method B: Via a binding property in the subview, Method C: Directly in the main view. I also added a few buttons to communicate with the view model. This results in the following:

struct ExampleView: View {
    @ObservedObject var viewModel: ExampleViewModel
    
    var body: some View {
        VStack(spacing: 20) {
            HStack {
                Text("A:")
                SubView1(question: viewModel.question)
            }
            
            HStack {
                Text("B:")
                SubView2(question: $viewModel.question)
            }
            
            HStack {
                Text("C:")
                Text(viewModel.question.answer.description)
            }
            
            Button(action: viewModel.randomFraction) {
                Text("Random Fraction")
            }
            
            Button(action: viewModel.randomNumerator) {
                Text("Random Numerator")
            }
            
            Button(action: viewModel.randomDenominator) {
                Text("Random Denominator")
            }
            
            Button(action: viewModel.emptyMutatingFunction) {
                Text("Empty Mutating Function")
            }
            
            Button(action: viewModel.emptyFunction) {
                Text("Empty Function")
            }
        }
    }
}

struct SubView1: View {
    let question: Question
    
    var body: some View {
        Text(question.answer.description)
    }
}

struct SubView2: View {
    @Binding var question: Question
    
    var body: some View {
        Text(question.answer.description)
    }
}

Finally, for completeness, the view model and view are initialised in the SceneDelegate scene function as follows:

    let viewModel = ExampleViewModel()
    let contentView = ExampleView(viewModel: viewModel)

    if let windowScene = scene as? UIWindowScene {
        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = UIHostingController(rootView: contentView)
        
        self.window = window
        window.makeKeyAndVisible()
    }

To demonstrate the problem I have made a small gif:

Demonstration of the problem in the Xcode Simulator (The problem also occurs on a real device)

As can be seen when a random fraction is generated, all three methods A, B and C are updated to show the same fractions simultaneously. However, as soon as the numerator of a fraction remains the same between updates (i.e., tapping on the 'random denominator' button), the view using method A is not updated, whereas the question model did update and published the changes to methods B and C. This problem occurs intermittently, meaning that when another fraction with equal numerator is generated (i.e., another press on the 'random denominator' button), the next update cycle or publishing results in all three methods showing the same fraction again. When the numerator is changed (i.e., tapping on the 'random numerator' button), regardless of the denominator value, the problem does not arise. The same holds for not using 'fractions' at all. Also with the problem occurring and the 'empty mutating function' button tapped, method A updates its view to reflect the change in the question struct. When the non-mutating function is tapped this does not occur.

During debugging I found that once I remove the 'double(Double)' case in the Number enum, the problem does not arise and all methods A, B and C always show the same fraction. Similarly, if I make the fractions of the double type and remove the 'case int(Int)', it also resolves the problem. Hence, it seems that the problem is related to the Number enum containing multiple associated cases such that SwiftUI somehow cannot tell whether the question model did update when using a direct property in the subview (Method A). Note that if this property is defined as a 'var' instead of a 'let' the problem still occurs.

Clearly, the problem does not occur when using a binding property in the subview (Method B). However, I do not think that this is a proper 'fix', since I don't require a binding. I just want to display the answer value in the view, not mutate it directly. The problem also does not occur when using Method C, but ultimately, I want to prevent massive views and separate view concerns into subviews.

Could this potentially be a bug in SwiftUI or what am I missing in my implementation or understanding of SwiftUI's update cycle or associated enums and SwiftUI's publishing?

Thank you all for your help and insights.

Update: Thanks to the comment of 'New Dev' I have made another minimal example that reproduces the problem. This results in the following:

enum Number {
    case int(Int)
    case double(Double)
    
    var description: String {
        switch self {
        case let .int(i):    return i.description
        case let .double(d): return d.description
        }
    }
}

enum AnswerType {
    case number(Number)
    case fraction(Number, Number)
    
    var description: String {
        switch self {
        case let .number(number):                   return number.description
        case let .fraction(numerator, denominator): return numerator.description + " / " + denominator.description
        }
    }
}

struct ExampleView: View {
    @State var answerType: AnswerType = .fraction(.int(0), .int(0))
    
    var body: some View {
        VStack(spacing: 20) {
            Text(answerType.description)
            
            Button(action: {
                self.answerType = .fraction(.int(Int.random(in: 1...99)), .int(Int.random(in: 1...99)))
            }) {
                Text("Random Fraction")
            }
            
            Button(action: {
                self.answerType = .fraction(.int(1), .int(Int.random(in: 1...99)))
            }) {
                Text("Random Denominator")
            }
        }
    }
}

Herein, when the 'random fraction' button is pressed, the random fraction is shown correctly. However, when the 'random denominator' button is pressed (i.e., the numerator stays the same between updates), the problem occurs cyclic between each button press. On the first button press the fraction 1 / XX is shown. On the second button press the problem occurs with only the number 1 shown, in fact, the answerType changed to the 'number' case by itself. On the third press the fraction 1 / XX is shown again, et cetera.

The problem is 'solved' when removing either one of the cases in the Number enum or in the AnswerType enum. Therefore, it seems that this is a SwiftUI bug where using two (or more) multiple-case associated enums, will not immediately result in the correct view update. What do you think?

3
  • I think it is a bug, actually... SwiftUI does some magic trying to compare and see if a view have changed. How would it know if SubView1 has changed and needs re-rendering? It does some reflection (and I think I read somewhere, even raw memory comparisons), and it might be tripped up when using enum with associated values. But if it is a bug, it's unrelated to ObservableObject. You can repro this with just @State var answer: AnswerType in ExampleView. Commented Nov 14, 2020 at 1:47
  • Thank you @NewDev, I have updated my question to include a second minimal bug reproducing example. The problem seems related to using more than one multiple-case associated enums. Potentially a SwiftUI bug indeed. Commented Nov 14, 2020 at 11:19
  • yes, I noticed that too. This 1 (instead of 1/XX) is AnswerType switching to number from fraction, for no apparent reason - hence why I thought it was a bug Commented Nov 14, 2020 at 16:13

1 Answer 1

1

After debugging some more I found a solution that does not require a @Binding like in the first example and also works with the second example.

The 'solution' is to swap the cases in the AnswerType enum such that the case with the multiple associated values is on top. In the example this means that the 'fraction' case should be on top of the 'number' case.

I do still believe that this is a SwiftUI bug. Is that true or did I miss something obvious?

A working code example (without the bug) is as follows:

enum Number {
    case int(Int)
    case double(Double)
    
    var description: String {
        switch self {
        case let .int(i):    return i.description
        case let .double(d): return d.description
        }
    }
}

enum AnswerType {
    case fraction(Number, Number) // Placed on top mitigates the bug
    case number(Number)
    
    var description: String {
        switch self {
        case let .number(number):                   return number.description
        case let .fraction(numerator, denominator): return numerator.description + " / " + denominator.description
        }
    }
}

struct ExampleView: View {
    @State var answerType: AnswerType = .fraction(.int(0), .int(0))        
    
    var body: some View {
        VStack(spacing: 20) {
            Text(answerType.description)
            
            Button(action: {
                self.answerType = .fraction(.int(Int.random(in: 1...99)), .int(Int.random(in: 1...99)))
            }) {
                Text("Random Fraction")
            }
            
            Button(action: {
                self.answerType = .fraction(.int(1), .int(Int.random(in: 1...99)))
            }) {
                Text("Random Denominator")
            }
        }
    }
}
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.