The Mystery Behind View Equality

At the time of this writing, if you looked for EquatableView on Google, you would only hear crickets. So I decided to investigate this view myself. After banging my head against the monitor, I finally realized the main reason I wasn’t getting anywhere, is the fact that there is some implicit behavior going on.

In this short article, we are going to explore several aspects of View equality. We’ll see what happens when we make a view conform to Equatable, and what is the purpose of EquatableView and the .equatable() modifier. Let’s dive in.

View Body Computation Economy

You probably already noticed that SwiftUI does a very decent job trying to determine when a view body needs to be computed. It basically monitors the view properties for any change. If a change is detected, a new body is computed.

Most of the time, that is the right decision. However, since you are the master of your views, you probably know best. There may be some scenarios where the view state may change, but the body may not necessarily have to be recomputed. Fortunately, there is a way of preventing unnecessary body computations.

Consider this. Suppose you have a view that receives an integer number as a parameter. If the number is odd, the view shows the text “ODD”, if the number is even, it shows the text “EVEN”. Pretty simple, right? To spice things up, we’ll add a nice continuous rotation:

extension Int {
    var isEven: Bool { return self % 2 == 0 }
    var isOdd: Bool { return self % 2 != 0 }
}

struct ContentView: View {
    @State private var n = 3
    
    var body: some View {
        VStack {
            NumberParity(number: n)
            
            Button("New Random Number") {
                self.n = Int.random(in: 1...1000)
            }.padding(.top, 80)
            
            Text("\(n)")
        }
    }
}

struct NumberParity: View {
    let number: Int
    @State private var flag = false
    
    var body: some View {
        let animation = Animation.linear(duration: 3.0).repeatForever(autoreverses: false)
        
        return VStack {
            if number.isOdd {
                Text("ODD")
            } else {
                Text("EVEN")
            }
        }
        .foregroundColor(.white)
        .padding(20)
        .background(RoundedRectangle(cornerRadius: 10).fill(self.number.isOdd ? Color.red : Color.green))
        .rotationEffect(self.flag ? Angle(degrees: 0) : Angle(degrees: 360))
        .onAppear { withAnimation(animation) { self.flag.toggle() } }
    }
}

The example works fine. Every time you push the button, a new random number gets generated and the view reflects the parity of the number appropriately. I think you already know where I’m going… if the new number has the same parity as the previous number, wouldn’t it be nice if we could prevent its body computation. After all, it doesn’t affect the result. This is where making our view conform to Equatable comes into play:

Conforming to Equatable

struct NumberParity: View, Equatable {
    
    let number: Int
    @State private var flag = false
    
    var body: some View {
        let animation = Animation.linear(duration: 3.0).repeatForever(autoreverses: false)

        print("Body computed for number = \(number)")
        
        return VStack {
            if number.isOdd {
                Text("ODD")
            } else {
                Text("EVEN")
            }
        }
        .foregroundColor(.white)
        .padding(20)
        .background(RoundedRectangle(cornerRadius: 10).fill(self.number.isOdd ? Color.red : Color.green))
        .rotationEffect(self.flag ? Angle(degrees: 0) : Angle(degrees: 360))
        .onAppear { withAnimation(animation) { self.flag.toggle() } }
    }
    
    static func == (lhs: NumberParity, rhs: NumberParity) -> Bool {
        return lhs.number.isOdd == rhs.number.isOdd
    }
}

In order to conform to Equatable, we add the Equatable protocol name to the first line and implement the static func == (lhs, rhs) method. To make sure the body is only computed when there is a change in parity, we also added a “print” statement to be our witness.

There, we have optimized our view to compute its body only when strictly necessary. Now SwiftUI will be able to determine when exactly is the body computation needed. Everyone is happy! I could end this article right here… but I won’t… Just bear with me a little longer.


Now that I look at the results, I really don’t like that rotation. It is rather tasteless, so let’s get rid of it:

struct NumberParity: View, Equatable {
    
    let number: Int
    
    var body: some View {
        print("Body computed for number = \(number)")
        
        return VStack {
            if number.isOdd {
                Text("ODD")
            } else {
                Text("EVEN")
            }
        }
        .foregroundColor(.white)
        .padding(20)
        .background(RoundedRectangle(cornerRadius: 10).fill(self.number.isOdd ? Color.red : Color.green))
    }
    
    static func == (lhs: NumberParity, rhs: NumberParity) -> Bool {
        return lhs.number.isOdd == rhs.number.isOdd
    }
}

If we run the modified version, you’ll notice that now the view is back to computing EVERY TIME! Even when the number parity does not change. The == method is never called! And why is that? Well, we’ll address that later… but in all cases, what changes the behavior, is the type of the properties in the View.

You see, the tasteless animation had an ulterior motive. I just put it there to trick the system into using my version of the == function. However, once I removed the animation (and its State variable), we have uncovered a conflict. So what can we do?

Fortunately, no matter the reason our == method is ignored, the solution is always the same. There is a way out of this mess. Let’s welcome EquatableView and .equatable()

Using EquatableView

It turns out, forcing SwiftUI to use our implementation of the == function to compare the view, is very easy, simply replace:

NumberParity(number: n)

with

EquatableView(content: NumberParity(number: n))

Run the example again, and you will notice that now our == method is called properly and the body will be computed only when strictly necessary.

Using .equatable()

If we look at the definition of the .equatable() modifier, we’ll find it is a nice shortcut to using EquatableView directly:

extension View {
    public func equatable() -> EquatableView<Self> {
        return EquatableView(content: self)
    }
}

The following two lines have the same effect, but the second option is easier to read and a few characters shorter:

EquatableView(content: NumberParity(number: n))
NumberParity(number: n).equatable()

Of course, using EquatableView (or equatable()) requires a View that conforms to Equatable!

When Do I Need EquatableView Then?

We now know that depending on the type of properties a View has, we may need to use EquatableView, or else, our == method will be ignored. But how do we know when is that necessary?

To play it safe, I would say the answer is: always. That is the only way to make sure no matter what happens under the hood, we are covered. If Apple later decides to change its strategy, the only way to be sure we’ll get consistent results, is by making sure we use .equatable() on our view. Plus, it makes the code more clear in its intent, which is especially important if someone else (or your future you) comes back to maintain it later.

However, if you are curious what is SwiftUI’s current strategy to determine if the == is used or not, check these tweets:

These tweets are from John Harper, perhaps you remember him from session 237 at the WWDC. Thanks @jsh8080 for your insight!

Summary

This short article aims at explaining another undocumented bit of the SwiftUI framework. When your views start to get too complex, your application can benefit from putting this into practice. If you found this article useful, you may also like Identifying SwiftUI Views

Please feel free to comment below, and follow me on twitter if you would like to be notified when new articles come out. Until next time!

13 thoughts on “The Mystery Behind View Equality”

  1. > Swift will auto synthesize the static func ==(lhs, rhs) method, and our own version will be ignored.

    I think your conclusion on the “why” is incorrect here. The own version will not be ignored just because Swift can synthesize it, you can easily test this by doing something like this:

    
    let a = NumberParity()
    let b = NumberParity()
    print("Same:", a == b)
    

    This is going to call your `==` version.

    I think what is going on here is that SwiftUI uses reflection to determine a comparison strategy. If the View only has simple base types, I suspect it simply falls back to a plain `memcmp` for performance reasons.
    If the View contains more complex stuff, it triggers a more complex comparison strategy, which might involve checking dynamically whether things confirm to `Equatable`.

    Lots of guesswork here, but that the own Equatable implementation is ignored in favour of the synthesised one is very unlikely, IMO. I think SwiftUI doesn’t even consider the `Equatable` conformance.

    Reply
    • Hi Helge. Yes! There’s a lot of guesswork. I updated the article to make it more ambiguous on the reason. Fortunately, the action we must take to solve the problem is the same, no matter the reason.

      Reply
      • I think a way to test out the memcmp theory is to use a struct which has content that needs padding/alignment, say

        
        struct MyView: View, Equatable {
          let x : Int16 = 1337
          let y : Int8 = -42
        }
        

        That has a stride of 4 because y needs to be aligned. Now, without modifying the actual value of y, patch the padding byte of y. A memcmp would report that as a change while Int8 Equatable wouldn’t.

        Reply
  2. Here is an interesting one.

    If your view has an environment object which publishes a change, then SwiftUI recalculates the view even if
    you have implemented Equatable and returned true for isEqual.

    And even if the view only uses the environmentObject for non-display reasons (like an action on a button)

    Reply
      • Make the result of isOdd be the @State and pass a binding to NumberParity. NumberParity does not need number it only needs isOdd. It’s best to design Views to only have the data they require.

        Reply
      • Like this:

        
        import SwiftUI
        import AppStorage
        
        extension Int {
            var isEven: Bool { return self % 2 == 0 }
            var isOdd: Bool { return self % 2 != 0 }
        }
        
        struct ContentView: View {
            @State private var n = 3
            
            var body: some View {
                VStack {
                    NumberParity(isOdd: n.isOdd)
                    
                    Button("New Random Number") {
                        n = Int.random(in: 1...1000)
                    }.padding(.top, 80)
                    
                    Text("\(n)")
                }
            }
        }
        
        struct NumberParity: View {
            let isOdd: Bool
            @State private var flag = false
            
            var body: some View {
                let animation = Animation.linear(duration: 3.0).repeatForever(autoreverses: false)
                
                return VStack {
                    if isOdd {
                        Text("ODD")
                    } else {
                        Text("EVEN")
                    }
                }
                .foregroundColor(.white)
                .padding(20)
                .background(RoundedRectangle(cornerRadius: 10).fill(isOdd ? Color.red : Color.green))
                .rotationEffect(self.flag ? Angle(degrees: 0) : Angle(degrees: 360))
                .onAppear { withAnimation(animation) { self.flag.toggle() } }
            }
        }
        
        Reply
  3. FYI, I had to add a where clause to my extension for it to compile:

    extension View where Self: Equatable {
    public func equatable() -> EquatableView {
    return EquatableView(content: self)
    }
    }

    Reply

Leave a Comment

By continuing to use the site, you agree to the use of cookies. more information

The cookie settings on this website are set to "allow cookies" to give you the best browsing experience possible. If you continue to use this website without changing your cookie settings or you click "Accept" below then you are consenting to this.

Close