SwiftUI MultiviewResponder mystery crash - ios

I came across this crash I can't explain, even after I boiling it down to the minimal components which would still cause the crash:
Repeatedly tapping the screen quickly (use two fingers) to hide/show an overlayed button with an explicit animation will crash the app under this VERY specific layout:
import SwiftUI
struct ContentView: View {
#State var showControls: Bool = true
var body: some View {
ZStack {
Rectangle().foregroundColor(Color.gray) // Removing this would fix it
VStack {
Text("someText")
Text("anotherText") // Removing this would also fix it
}
if showControls {
Text("Button")
.zIndex(1) // Removing this would also fix it
}
}
.onTapGesture {
withAnimation(.easeInOut(duration: 1)) { // Removing this would also fix it
showControls.toggle()
}
}
}
}
As mentioned in the code comments, any of these changes prevent the crash:
Remove the Rectangle from the bottom of the ZStack
Remove one of the two views from the VStack
Remove the zIndex (this will break the fade-out animation)
Remove the explicit animation call
Here is my console output:
I would like to know what's happening here, what's causing the crash ?

Related

How to only apply preferred color scheme to one view and not the rest of the views in the view hierarchy in SwiftUI

I am trying to make one view render in dark mode while the rest of my app is in the users chosen color scheme. When I apply .preferredColorScheme(.dark) to the subview, it causes other views to turn dark as well. How can I fix this behavior?
ContentView:
NavigationView {
ZStack {
NavigationLink(isActive: $showingGoalDashboardView) {
TestView(goal: goals.first!)
} label: {
EmptyView()
}
NavigationLink(isActive: $showingCreateGoalView) {
CreateGoalView(showingGoalCreateView: $showingCreateGoalView)
} label: {
EmptyView()
}
LoadingView()
}
}
LoadingView:
LoadingView just contains some UI elements, all wrapped in a ZStack with the property .preferredColorScheme(.dark) applied to it.
The preferredColorScheme works NOT per-view, but for current presentation - which in this case is a current window. See documentation:
Put LoadingView into sheet or popover, or new window, etc, and there dark mode will be applied independently.
Update: well, actually it can still be used View.colorScheme, and it works, but it has been deprecated - just be aware:
LoadingView()
.colorScheme(.dark)
Tested with Xcode 13.4 / iOS 15.5

SwiftUI animation problem with a binding to a StateObject inside a NavigationView

I have an interesting situation in regards to animations in SwiftUI. In its simplified form, I have a view that shows a rectangle which, when tapped, should toggle a Bool binding and represent the change with an animated transition of color. But it seems like the animation doesn't happen when the view is inside a NavigationView and the binding is coming from a StateObject instead of simple local state. I can't explain why that would be the case, and would appreciate any thoughts.
Below is the code that shows a simplified case that reproduces the issue. The app code isn't particularly interesting; it's the default code that creates a WindowGroup and shows an instance of ContentView in it.
import SwiftUI
class AppState: ObservableObject {
#Published var isRed = false
}
struct RectView: View {
#Binding var isRed: Bool
var body: some View {
Rectangle()
.fill(isRed ? Color.red : Color.gray)
.frame(width: 75, height: 75, alignment: .center)
.onTapGesture {
withAnimation(.easeInOut(duration: 1)) {
isRed.toggle()
}
}
}
}
struct ContentView: View {
#StateObject var appState = AppState()
#State private var childViewIsRed = false
var body: some View {
VStack {
NavigationView {
List {
NavigationLink("Link with binding to state object", destination: RectView(isRed: $appState.isRed))
NavigationLink("Link with binding to state variable", destination: RectView(isRed: $childViewIsRed))
}
}
.frame(height: 300)
RectView(isRed: $appState.isRed)
RectView(isRed: $childViewIsRed)
}
}
}
The gif/video below is me demonstrating four things, tapping on these views from bottom to top:
First I tap on the very bottom rectangle - the one with a binding to a #State property. It toggles with animation as expected. I tap again to leave it gray.
Then I tap the second rectangle from the bottom - one with a binding to a #Published property in the #StateObject. All is well.
Next, I tap on the NavigationLink that leads to a rectangle that is bound to the local #State property. When I tap on the rectangle the transition is animated fine. The very bottom rectangle also animates, which makes sense since they are bound to the same property.
Finally I tap on the top NavigationLink, which leads to a rectangle bound to the #Published property in the #StateObject. When I tap on this rectangle though, there is no animation. The rectangle snaps to red. The rectangle below it (which is bound to the same property) animates fine, proving that the property is indeed toggled. But there is no animation inside the NavigationView. Why? What am I missing?
I've searched for existing questions. I'm aware that there are some around NavigationView (like this) but that doesn't explain why one type of binding would work fine inside a NavigationView and another wouldn't. Similarly, there are ones around animating changes to #ObservedObjects (like this, would a #StateObject be similar?) but I don't follow why animating changes to such a binding would work fine outside of a NavigatonView and not inside one.
In case it's relevant I'm using Xcode 13.2.1, running on macOS 12.2.1, which is in turn running on a 16-inch M1 Max MacBook Pro. The simulator shown in the gif/video is an iPhone 11 Pro Max. I get the same results if I deploy this tiny test app to a physical iPhone 7 running iOS 15.3.1.
To be honest with you, I am not sure why, but utilizing the animation modifier on the RectView allows the animation to occur without any issues.
struct RectView: View {
#Binding var isRed: Bool
var body: some View {
Rectangle()
.fill(isRed ? Color.red : Color.gray)
.frame(width: 75, height: 75, alignment: .center)
.animation(.easeOut(duration: 1), value: isRed)
.onTapGesture { isRed.toggle() }
}
}
screen recording example

Animate removing/adding SwiftUI view while animating offset

I'm trying to animate the offset of a SwiftUI view, while at the same time fading out and removing a subview of that view. The problem I'm running into is that SwiftUI performs the offset and fade-out animations, but doesn't combine them.
What I want to achieve to animate the position of the whole SubView, while simultaneously fading out the subtitle text, so that the subtitle text moves vertically while fading in or out. I can achieve this by animating the opacity of the Text instead of removing it, but that means the text will still take up "layout space".
Is there a way to achieve this animation with the if showSubtitle statement?
The following code and GIF demonstrate the problem:
struct ContentView: View {
#State private var showSubtitle = true
var body: some View {
SubView(showSubtitle: showSubtitle)
.animation(.default)
.offset(y: showSubtitle ? 100 : 0)
.onTapGesture {
self.showSubtitle.toggle()
}
}
}
struct SubView: View {
let showSubtitle: Bool
var body: some View {
VStack {
Text("Header")
if showSubtitle {
Text("Subtitle")
}
}
}
}
Actually the observed behaviour is because .offset does not change layout, the view is stand at the same place. So when you remove subview it is removed in-place and animating that removal (with default .opacity transition). The part that starts offsetting does not contain already subview, so you don't see it in moving up part.
Here is something that might give some kind of effect you expect, but transitions are based on source size, so it is not so far and manually specified distance of offset. Anyway, try:
if showSubtitle {
Text("Subtitle")
.transition(AnyTransition.opacity.combined(with: AnyTransition.move(edge: .top)))
}
Tested with Xcode 12 / iOS 14

Status bar disappears after unlocking with SwiftUI

I have an app with Status Bar initially hidden: YES and
NavigationView {
}
.statusBar(hidden: true)
When the app starts, the bar is not visible but its space is not occupied (so it looks like additional padding from top). But when I lock/unlock the phone this padding disappears thus moving the whole app closer to the top.
Any suggestions what's causing it?
Problem
Just tested it with the following view and it really jumps up after you lock/unlock the screen. Try to take a screenshot and it jumps up as well (found this accidentally when I was taking screenshots for the answer).
struct ContentView: View {
var body: some View {
NavigationView {
Color.green
.navigationBarTitle("No status bar")
}
.statusBar(hidden: true)
}
}
Workaround
Just add the line marked in the code below.
struct ContentView: View {
var body: some View {
NavigationView {
Color.green
.navigationBarTitle("No status bar")
}
.edgesIgnoringSafeArea(.all) // <-------
.statusBar(hidden: true)
}
}
IMHO it should work without this line, but it doesn't apparently. This problem is here even if I set (Info.plist):
UIViewControllerBasedStatusBarAppearance to YES
UIStatusBarHidden to YES
Or if I hide status bar directly on the ContentView (SceneDelegate):
let contentView = ContentView().statusBar(hidden: true)

Not possible to control animations in a Form?

Is it not meant to be possible to control the animations that take place inside of a Form view? I have here a playground that demonstrates the issue, along with a gif of what happens. As you can see, my transition on the 2nd animated view is completely ignored, and I had to manually slow down the video because the durations are ignored, too.
I don't really want a scaling transition, this was just to demonstrate that no matter what I put in there the animation is the same. Is that expected, or is it a bug? Or am I just doing something totally wrong?
It's also not clear to me why the animation of the VStack is handled so differently than the simple Text field, which slides down nicely while the VStack seems to be getting some combination of .move and .opacity.
import SwiftUI
import PlaygroundSupport
struct ContentView: View {
#State var showGoodAnimation = false
#State var showBadAnimation = false
var body: some View {
Form {
Toggle(isOn: self.$showGoodAnimation.animation(.easeInOut(duration: 1))) {Text("Yay!")}
if self.showGoodAnimation {
Text("I animate beautifully.")
}
Toggle(isOn: self.$showBadAnimation.animation(.easeInOut(duration: 1))) {Text("Boo!")}
if self.showBadAnimation {
VStack {
Text("Hi.").padding()
Text("I'm a hot mess.").padding()
}
.frame(height: 250)
.transition(.scale)
}
Text("I'm just always here.")
}
}
}
PlaygroundPage.current.setLiveView(ContentView())
At a guess, probably worked around this question some time ago, but for the benefit of those beating their head's against SwiftUI Form and the like now (as I was :-) )
It turns out that Forms, Lists and (no doubt) other components purposely ignore animation customisation because they are "higher-level" SwiftUI View components (unlike V and HStack's).
They do this because SwiftUI's higher-level components are intended to convey semantic information and (more practically) to work well across all platforms. To achieve this, Apple's engineering decision has been to make animation "easy", but (as observed) only to the extent of essentially turning it "on" or "off".
It's likely engineered like this because if a developer wants more control. Then Apple believes encouraging them to use the lower-level components will be less painful than trying to work around the optimisations they have applied to their higher-level view components.
Anyway, for the determined, there is at least one escape hatch via wrapping the View in a container and specifying the .animation(nil) modifier (as mentioned in Asperi's SO answer here.
An example of this is shown below for completeness; personally, I'm avoiding this pattern as I suspect it's a bit of a footgun.
import PlaygroundSupport
import SwiftUI
struct ContentView: View {
#State var showGoodAnimation = false
#State var showBadAnimation = false
var body: some View {
Form {
Toggle(isOn: self.$showGoodAnimation.animation(.easeInOut(duration: 1))) { Text("Yay!") }
if self.showGoodAnimation {
Text("I animate beautifully.")
}
Toggle(isOn: self.$showBadAnimation.animation()) { Text("Boo!") }
VStack {
if self.showBadAnimation {
List {
Text("I animated differently").padding()
Text("But am I a footgun?").padding()
}
.transition(.asymmetric(insertion: .slide, removal: .opacity))
.animation(.easeOut(duration: 5))
}
}
.animation(nil)
.transition(.slide)
Text("I'm just always here.")
}
}
}
PlaygroundPage.current.setLiveView(ContentView())

Resources