In this article, we will delve into a bug affecting SwiftUI applications in multi-window mode and propose effective temporary solutions. We will not only detail the manifestation of this issue but also share the entire process from discovery to diagnosis, and finally, resolution. Through this exploration, our aim is to offer guidance to developers facing similar challenges, helping them better navigate the complexities of SwiftUI development.
The issue discussed in this article has been fixed in Xcode 16.
The @Observable Macro in Multi-Window Mode: Is There a Problem?
A few days ago, a user on Reddit posed a question concerning the Observable
macro:
The user, revblaze, attempted to convert a simple piece of code built with Combine into the Observatio, but encountered a peculiar situation afterward.
When running the code based on Combine, everything worked as expected in every newly created window, with each browser functioning correctly:
struct ContentView: View {
@StateObject private var webViewManager = WebViewManager()
var body: some View {
WebView(manager: webViewManager)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
webViewManager.load("https://www.example.com")
}
}
}
class WebViewManager: ObservableObject {
@Published var urlString: String? = nil
let webView: WKWebView = .init()
func load(_ urlString: String) {
self.urlString = urlString
guard let url = URL(string: urlString) else { return }
let request = URLRequest(url: url)
webView.load(request)
}
}
struct WebView: NSViewRepresentable {
@ObservedObject var manager: WebViewManager
func makeNSView(context _: Context) -> WKWebView {
return manager.webView
}
func updateNSView(_: WKWebView, context _: Context) {}
}
However, after transitioning the same piece of code to the Observation, an issue arose. All windows shared a single WebViewManager
instance (to clarify the issue, I included a UUID in the WebViewManager and displayed it in the view):
import Observation
import SwiftUI
import WebKit
struct ContentView: View {
@State private var webViewManager = WebViewManager()
var body: some View {
Text("ID:\(webViewManager.id)")
WebView(manager: webViewManager)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
webViewManager.load("https://www.example.com")
}
}
}
@Observable
class WebViewManager {
var urlString: String?
let webView: WKWebView = .init()
let id = UUID()
func load(_ urlString: String) {
self.urlString = urlString
guard let url = URL(string: urlString) else { return }
let request = URLRequest(url: url)
webView.load(request)
}
}
struct WebView: NSViewRepresentable {
var manager: WebViewManager
func makeNSView(context _: Context) -> WKWebView {
return manager.webView
}
func updateNSView(_: WKWebView, context _: Context) {}
}
Initially, I found this question quite baffling. Based on my understanding of the Observation framework, this situation should not occur. However, observing that ContentView
declared the observable object with @State
reminded me of a similar issue I encountered a year ago.
The Consistency Issue of Dynamic Values in Multi-Window Mode with @State
Last year, at the SwiftUI Tech Salon held in Beijing, I showcased a project named Movie Hunter, demonstrating the cross-platform development capabilities of SwiftUI. In the project, I used a custom-developed SwiftUI Overlay Container, a customizable overlay view management library. To differentiate each overlay container, I decided to use UUID().uuidString
as the unique identifier for each window’s overlay container.
@State var containerName = UUID().uuidString
VStack {
...
}
.overlayContainer(containerName, containerConfiguration: ContainerConfiguration.share)
.environment(\.containerName, containerName)
I initially expected that SwiftUI would generate a new UUID for each container when constructing the root view for each window, thereby allowing each window to have its own independent container with a distinct name.
However, the reality was unexpected. Once the multi-window feature was enabled (whether on macOS or iPadOS), all windows synchronized and performed the same operations, indicating that the containerName
in each window was actually the same.
To more clearly demonstrate this issue, I wrote the following code:
@main
struct StateIssueInMultiWindowsApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
@State var sameIdByState = UUID()
@GestureState var sameIdByGestureState = UUID()
@SceneStorage("id") var sameIdBySceneStore = UUID().uuidString
@State var number = Int.random(in: 0...10000)
@State var date = Date.now.timeIntervalSince1970
var body: some View {
VStack {
Text("State \(sameIdByState)")
Text("GestureState \(sameIdByGestureState)")
Text("SceneStorage \(sameIdBySceneStore)")
Text("Dumber \(number)")
Text("Date \(date)")
}
.padding()
}
}
Through the video above, you can see that in the root view, the dynamic states declared with @State
, @SceneStorage
, @GestureState
(such as UUID, random numbers, current date) are the same across every window.
A series of tests revealed the following patterns:
- In the root view, dynamic data created through
@State
or similar mechanisms (such as@SceneStorage
,@GestureState
) are entirely the same in every new window. - The initial values of dynamic data declared with
@State
in new windows are the same as those in the first window. Even if the data in one window is modified, it does not affect the other windows, indicating that the states of each window are actually independent; only their initial values are copied. - This issue appears only in the root view.
- Observable objects declared with
StateObject
do not encounter this problem.
Given the specific conditions under which this issue occurs, I forgot to submit feedback to Apple after solving the problem. However, as developers turn to the new Observation framework and declare observable objects with @State
, the likelihood of encountering this type of issue significantly increases.
By modifying the test code, we added an observable object based on the Observation framework and declared with @State
:
@Observable
class ObservableID {
var id = UUID()
init(){
print("Observable init")
}
}
// Add in ContentView
@State var object = ObservableID()
Text("ObservableID \(object.id)")
The results show that all windows are using the same instance of the observable object, which is unacceptable for applications that need to provide an independent state container for each window.
Temporary Solutions
Although we cannot fully discern on which level the issue originates (whether it’s the optimization mechanism of @State
or the construction mechanism of the window root view), we have identified the pattern in which the issue manifests. Thus, we can employ some straightforward methods to circumvent it.
Wrapping Views to Isolate the Root View
By creating a new wrapping view to encompass the original root view (ContentView
), we can effectively bypass this issue.
@main
struct StateIssueInMultiWindowsApp: App {
var body: some Scene {
WindowGroup {
RootView()
}
}
}
struct RootView: View {
var body: some View {
ContentView()
}
}
Utilizing onAppear or Task to Reset States
Employing onAppear
or task
to reassign state values is also an effective strategy. This approach ensures that state values are refreshed when the root view is loaded, thus avoiding the issue of shared states.
.onAppear{
sameIdByState = UUID()
...
}
Adopting either of the above methods can successfully avoid the issue of consistency in state values in a multi-window environment and, of course, can also address the situation encountered by revblaze at the beginning of this article.
Additionally, if the scene’s root view can receive different parameter values from external sources each time it is created, or if scene construction methods such as
WindowGroup(for: Item.self)
are used, this approach can sometimes circumvent the issue, though it cannot guarantee absolute effectiveness. I have submitted feedback to Apple (FB13707448
) and hope that they will be able to provide a fix in the near future.
A Future Perspective on @State
The @State
property wrapper holds a pivotal role in SwiftUI, having supported the binding of views to local states since the early days of SwiftUI. Its key features include:
- High optimization for the SwiftUI view system, utilizing the
isRead
property to determine actual usage by views, ensuring precise association between state and view. - When developers use the
wrappedValue
of@State
to create custom Bindings, issues may arise in rare cases (such as application crashes due to state changes not synchronizing with view updates). However, theProjectedValue
provided by@State
, which directly manipulates the underlying data (bypassingwrappedValue
), offers a safer and more efficient approach. - Before strict concurrency checking was implemented,
@State
was considered thread-safe, allowing value-type states to be modified across different threads without issues.
However, with the introduction of the Observation framework, the usage scenarios and roles of @State
have undergone significant changes. It is no longer solely used for managing value-type states but has taken on broader responsibilities in lifecycle management. Moreover, as the concurrency programming model becomes more widespread and strict concurrency checks are enforced, the lack of explicit @MainActor
annotation in @State
, unlike in StateObject
, has gradually become more apparent.
Given the new issues discussed in this article, as well as the importance of @State
in modern SwiftUI applications and the challenges encountered in its use, we have reason to anticipate further developments and optimizations to @State
at the upcoming WWDC 2024.
Conclusion
This article has not only revealed the bug encountered with the use of @State
in multi-window mode of SwiftUI applications but also aims to encourage developers to think and analyze problems from more angles, rather than merely scratching the surface. Understanding the essence of the problem is key to solving it, guiding us to find the correct approach and methods.
Epilogue
Before the publication of this article, I received another request for help from a fellow netizen. He encountered a perplexing issue while using tvOS 17.
struct ContentView: View {
var body: some View {
HStack {
Text("Left")
Form {
Section(header: Text("rate")) {
Button("0.5") {}
Button("1.0") {}
Button("1.5") {}
}
}
}
}
}
This code worked fine on tvOS 16, but after upgrading to tvOS 17, when a Button gains focus, its left-side highlight is incomplete (it gets truncated).
After several tests, I observed the following patterns:
- Everything is normal on tvOS 16, but not on tvOS 17.
- Replacing
Form
withList
did not resolve the issue. - Replacing
Form
withVStack
solved the problem. - Replacing
Form
withScrollView + VStack
brought back the issue.
This indicates that the problem might be related to some enhancement made to ScrollView
in tvOS 17.
In my previous article, Deep Dive into the New Features of ScrollView in SwiftUI 5, I introduced all the new features that Apple brought to ScrollView
at WWDC 2023. One of them, scrollClipDisabled
, is intended to control whether scrolling content is clipped to fit the boundaries of the scroll container.
Based on the current phenomenon, it’s likely that this issue is related to that modifier. Thus, I made the following adjustment to the code:
struct ContentView: View {
var body: some View {
HStack {
Text("Left")
Form {
Section(header: Text("rate")) {
Button("0.5") {}
Button("1.0") {}
Button("1.5") {}
}
}
.scrollClipDisabled() // disable clip
}
}
}
Subsequently, the problem was resolved.
From starting to test the problem code to solving the issue, the entire process took less than ten minutes. This also validates the core message of this article: when encountering a problem, delving into its essence can often make solving it much simpler.