Pro to SwiftUI: .sheet() pitfalls & workaround
Preface
This article is part of the "Pro to SwiftUI" series, mainly sharing some insights, experiences, and pitfalls encountered in developing several apps using SwiftUI. Hope it can help you.
This article will introduce the bug and workaround of .sheet()
in specific scenarios in SwiftUI.
What is .sheet()?
In SwiftUI, you use .sheet() to present a sheet page and display your SwiftUI View inside it.
According to HIG on Sheet, it is described as follows:
A sheet helps people perform a scoped task that’s closely related to their current context.
On iOS, a new page will be presented in a nearly full-screen manner. By default, the bottom page will "slide" and scale to the back, creating a visual effect of two stacked pages. Without additional settings or gesture conflicts, users can close the new page by dragging it downwards. In iOS 16.0, you can also set PresentationDetent to change the dynamic height at which the sheet is displayed. This means you can set the sheet to initially display at a specific height, and then users can manually drag it to show the specified maximum height.
On iPadOS, if the horizontalSizeClass is Compact, the effect will be the same as on iOS. If it is Regular, the effect will be the same as on macOS.
On macOS, a modal page will be presented in the center. This page does not provide a close button, so developers need to provide an explicit close button to trigger the page to disappear.
The .sheet()
method in SwiftUI has two overloaded methods:
and:
The things done behind the scenes are the same: observing specific conditions to display a Sheet page. On iOS, it is equivalent to using UISheetPresentationController
; on macOS, it is equivalent to using presentAsSheet(_:)
.
.sheet() pitfall: Under certain conditions, it causes the top to be unable to trigger touch events.
While .sheet() is simple to use, it's hard to encounter any particular pitfalls - of course, if there are, it also means it will be difficult to completely solve them.
Two points were mentioned earlier:
By default, when the Sheet appears, the page at the bottom will "retreat" to the back using translation and scaling, giving a visual effect of two pages stacking.
On iOS 16.0, you can change the dynamic display height of the Sheet by setting PresentationDetent. That is, you can set it to only display a specific height at the beginning, and then the user can manually drag it to display the specified maximum height.
Given the above background, .sheet()
will have problems under the following operations on iOS 16.0 or above:
Use SwiftUI to write a page, then use .sheet() to display a Sheet, without changing
PresentationDetents
or setting it to Large using presentationDetents(_:).This page uses
ScrollView
or other Views, but uses.ignoresSafeArea()
The page calling .sheet() has some buttons (or other Views that trigger click behavior with
onTapGesture
) near the Status Bar at the top.Let the App be in the Background state when the Sheet is open: you can switch to other apps or go back to Home. Then return to your app.
Close the Sheet and try to click the button at the top.
You will find that the touch does not work, you need to restart the app (or go back to the desktop again on the current page and then come back) to return to normal - in essence, because the entire page at the bottom has moved down, you need to click the position below to trigger the original click behavior.
If you use
NavigationView
and place some buttons on the Toolbar, the buttons on theToolbar
are not affected.
The code to reproduce the issue:
The key points of the code that can trigger the problem are:
The
ScrollView
is used. ThisScrollView
will use.ignoreSafeArea()
, which is a necessary condition to trigger the problem mentioned above; therefore, if theScrollView
is replaced with the following code, it can also trigger the problem:
Speculation on the Cause of the Problem
As mentioned earlier, when the Sheet appears, the page at the bottom will "retreat" to the back using translation and scaling, giving a visual effect of two pages stacked.
This can be confirmed by looking at the layout in Xcode. The following image shows the page layout captured in the View Hierarchy when a problem occurs (i.e., when the top button cannot be clicked):
One of the root Views of
UIWindow
is aUITransitionView
, which is an internal UIView of Apple. It is part of theUIPresentationController
and is used to manipulate the layout transformation of the View during presentation. The Transform of the bottom page when displaying the Sheet is operated by thisUITransitionView
. In the Object Inspector on the right, you can see this through the Frame and Transform of its direct sub UIViewUIDropShadowView
.Look at the outline information of
ContentView
underHostingView
in the View Hierarchy, its position and size are obviously incorrect - although visually, the image it draws is correctly displayed on the screen, this may be simply because the layout information of CALayer is correct, but the properties generated internally by UIView that handles touch events are wrong, which leads to the final touch event not being triggered normally.
Workaround
After understanding the background of the issue and conducting a preliminary analysis, it was found that it could be resolved in this way. When the Sheet disappears, execute the following code:
The key point is to set the transform attribute of the root UIViewController's View twice.
The first time, you must change the value of y, and the second time, you must set it to identity. Although we all know that setting the transform twice will not make any actual difference in drawing, it will be re-laid out and redrawn in the next drawing cycle. So it doesn't matter how much the y value is set here, as long as it is not 0, we will not see any translation of this View visually.
But by setting it this way, the test can solve the problem.
The above code can be called in the onDismiss
of the Sheet. In order to make it easier for users to call, we can wrap a .sheetCompat()
.
You can find the code here:
https://github.com/JuniperPhoton/PhotonUtilityKit/blob/main/Sources/PhotonUtilityView/Compat/SheetCompat.swift
Because SwiftUI's .sheet() has two overloaded methods, it is necessary to handle .sheet(item:onDismiss:content)
and .sheet(isPresented:onDismiss:content)
separately.
The usage is also very simple, just change the original .sheet() to use .sheetCompat()
:
Conclusion
The above introduces the issue that the .sheet()
ViewModifier
triggers under certain conditions in iOS 16.0 or above, as well as the solution.
Although the triggering conditions seem a bit harsh, in the actual usage scenarios of users, I think the probability of triggering is quite high - after encountering it for the first time, I also thought it was a very low probability bug, but the number of times I encountered it during my own development process is increasing, so I finally tried to analyze and solve this problem, hoping to help you.
Of course, there is more than one pitfall with .sheet(). When writing this article, I have already encountered another pitfall with .sheet(item:onDismiss:content)
that is more likely to cause a crash. The crash information is:
As for the specific reason for this, we will talk about it next time.