Equatable Protocol and View

Hi Swift Community,

Thank you for taking the time to look into this topic.

I've run into a challenge when trying to make a SwiftUI view conform to Equatable. In Swift 6, I'm seeing warnings and errors related to nonisolated protocol conformance and @MainActor. The specific issue is that the == operator required by Equatable cannot be used because it conflicts with the actor isolation of the view.

Main Questions:

  1. Is there an Equatable-like protocol or method specific to views that works within the constraints of @MainActor?
  2. Are there any known workarounds or best practices for comparing views that are actor-isolated without breaking thread safety or concurrency rules?

Any guidance or suggestions would be greatly appreciated!

2 Likes

My question would be, why are you trying to make a SwiftUI view conform to Equatable? I ask because I've never come across a scenario where I needed to do this, and I suspect that if you take a step back, there is a probably a way to accomplish your ultimate goal without going down that route.

Actor-isolated types generally can't conform to Equatable unless they a.) are reference types, and b.) use their object identity as the basis of the conformance.

Coincidentally, PointFree recently did a short series on the topic of Equatable recently. I'd highly recommend it if you're up for subscribing (very worth it if you haven't). Episode #297: Back to Basics: Equatable

1 Like

Why Make a View Equatable?

The equatable() view modifier ensures that SwiftUI only redraws a view when its properties have changed. This can improve performance, especially in complex UIs, by avoiding unnecessary rendering. However, for this optimization to work, the view needs to conform to Equatable, so SwiftUI can compare instances of it.

Example Scenario:

Suppose you have a custom view that renders data based on an input model. By conforming the view to Equatable, SwiftUI can detect when the input data is the same as before and prevent the view from being redrawn, which enhances the app's efficiency.

See also: equatable() | Apple Developer Documentation

I've personally never found myself needing that. Are you in a situation where you've measured performance and have reason to believe this would help?

If so... with some experimentation, I found doing something like this seemed to work well:

struct SimpleView: Equatable {
  let int: Int
  let string: String
}

extension SimpleView: View {
  var body: some View {
    Text(int, format: .number)
    Text(string)
  }
}

The problem arises when you try to use a custom Equatable conformance in SwiftUI, especially if you only need to compare some properties or part of some properties of your view but not others. This can become complicated due to Swift's concurrency model and actor isolation.

The workaround in SE-0423 usually works for me when trying to work around SwiftUI and concurrency:

import NotMyLibrary

@MainActor
class MyViewController: ViewDelegateProtocol {
  nonisolated func respondToUIEvent() {
    MainActor.assumeIsolated {
      // implementation...   
    }
  }
}

You might want to try @preconcurrency if you are only building from 6.0 and up.

… but you are kind of assuming at this point that you know the function is actually coming from main… we usually assume SwiftUI operates on main… so we should be good. But this is kind of a YOLO solution for now.

1 Like

To my best knowledge this is indeed not possible in general case. Currently nonisolated + Sendable means “any isolation”, and nonisolated without Sendable means “some unknown isolation”. But there is no way to express “some isolation which is also known to be main actor”.

Update: You cannot use MainActor.assumeIsolated because the render graph, which uses the com.apple.SwiftUI.AsyncRenderer queue, can operate off the main thread.

Hmm… weird… AFAIK SwiftUI View component types are now explicitly isolated to MainActor. Are you building in Swift 6 language mode? If you are building in Swift 6 language mode and some SwiftUI infra is calling to your View off-main in an unsafe way… this might potentially be a bug in the SwiftUI infra. Have you searched through Apple Dev Forums for anything similar that has been posted in the last few months?

While it is true that SwiftUI views are @MainActor bound, the protocol requirements of Equatable are non-isolated, and so SwiftUI would be allowed to invoke == on a background thread if it wanted without breaking any contracts. And if done, then assumeIsolated would crash. So I personally would stay away from trying to conform views to Equatable based on the mutable data they hold.

But it's also worth mentioning that there are some strange soundness things happening inside SwiftUI. Many of the APIs in SwiftUI deal with non-isolated @escaping closures. For example, the trailing closure for ForEach. If one wraps a ForEach in a lazy view, such as LazyVStack, then it is possible for the ForEach trailing closure to be invoked on a background thread, and in particular from the com.apple.SwiftUI.AsyncRenderer queue.

I have not yet tracked down how exactly this is possible. It may be a soundness hole in Swift left open for SwiftUI, or perhaps there is just something I am missing. However, you can see for yourself that it is completely possible to create SwiftUI views on background threads:

DispatchQueue.global().async {
  print(
    Text("Hi"),
    "Thread is main?",
    Thread.isMainThread
  )
}

This creates a Text view on a background thread, even though the initializer should be @MainActor bound and the initializer is not marked as nonisolated. And this compiles just fine with no warnings even in Swift 6 language mode.

It's worth noting that views we create outside of SwiftUI are not given such affordances:

struct MyView: View {
  init() {}
  var body: some View { EmptyView() }
}

DispatchQueue.global().async {
  print(
    MyView(),  // 🛑 Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context
    "Thread is main?",
    Thread.isMainThread
  )
}

This does correctly diagnose that we are trying to create something @MainActor bound in a non-@MainActor context. But if you mark the initializer as nonisolated it will compile.

3 Likes