How to disable SwiftUI animations in UITests? - xcode

I tried to disable animation in UITests with the following code:
let app = XCUIApplication()
app.launchEnvironment = ["DISABLE_ANIMATIONS": "1"]
I also tried:
UIView.setAnimationsEnabled(false)
But it doesn't disable animation when I run UITests on simulator.
Is it because I'm using SwiftUI ?
The animation I want to disable is a view transition from one SwiftUI View to another one. Here is how I coded the transition:
NavigationLink(destination: MapView(), isActive: $viewModel.isDataLoaded) {
EmptyView()
}
Is there another way to disable animation in UITests when using SwiftUI ?

It is needed to be done explicitly (by-code) in main application, because UITests run in different process, ie. it should be like
struct YourApp: App {
init() {
let env = ProcessInfo.processInfo.environment
if env["DISABLE_ANIMATIONS"] == "1" { // << here !!
UIView.setAnimationsEnabled(false)
}
}
var body: some Scene {
// ... scene here
}
}
and then it can be used
let app = XCUIApplication()
app.launchEnvironment = ["DISABLE_ANIMATIONS": "1"]
Tested with Xcode 13.3 / iOS 15.4

Related

SwiftUI preview layout: size that fits does not work

I want my preview to only have the size of the view and not show the whole iPhone.
I have already tried it with this code:
struct TimerCardView: View {
var body: some View {
Text("Hello, World!")
.padding()
.background(.gray)
}
}
struct TimerCardView_Previews: PreviewProvider {
static var previews: some View {
TimerCardView()
.previewLayout(.sizeThatFits)
}
}
but nothing happened.
What am I doing wrong? I appreciate help
In Xcode 14+, the default mode for the Preview canvas is live mode, which as you’re seeing places your preview into a device screen context.
Manually adjusting the preview layout requires “selectable” mode, which was the default in previous versions of Xcode. You can switch between modes using the palette at the bottom of the toolbar canvas. For example:

How to keep newly opened macOS window in front & prevent being hidden with SwiftUI?

I am using SwiftUI to create a macOS app and need to create a new window that opens with an image inside of it, which I am successfully accomplishing currently.
However, if I click back on the main app window, the newly opened window goes to the background and is hidden (normal behavior), however, I want the newly opened window to always be on top of the main app window AFTER if I click back on the main application window.
The reason is that the new window (WindowGroup) opened contains an image with the information I need to enter in the main app so if it goes behind the main app window, I can't see the image anymore.
Is there a WindowGroup modifier I can implement so that after the WindowGroup("imageView") window opens, it is always on top & how can I integrate into my existing code?
Thank you!
#main
struct customApp: App {
#StateObject var session = SessionStore()
var body: some Scene {
WindowGroup("mainView") {
ContentView().environmentObject(session)
}.handlesExternalEvents(matching: ["mainView"])
WindowGroup("imageView") {
ImageView(url: SessionStore.imageUrl)
}.handlesExternalEvents(matching: ["imageView"])
}
}
View that opens new window
struct ImageViews: View {
#Environment(\.openURL) var openURL
var body: some View {
HStack {
WebImage(string: idUrl)
.onTapGesture {
guard let url = URL(string: "app://imageView") else { return }
openURL(url)
}
}
}
}
Set the window.level to always on top .floating. You can access it via NSApplication.shared.windows.
Button("Window level") {
for window in NSApplication.shared.windows {
window.level = .floating
}
}

SwiftUI animation - toggled Boolean always ends up as true

I'm trying to create an animation in my app when a particular action happens which will essentially make the background of a given element change colour and back x number of times to create a kind of 'pulse' effect. The application itself is quite large, but I've managed to re-create the issue in a very basic app.
So the ContentView is as follows:
struct ContentView: View {
struct Constants {
static let animationDuration = 1.0
static let backgroundAlpha: CGFloat = 0.6
}
#State var isAnimating = false
#ObservedObject var viewModel = ContentViewViewModel()
private let animation = Animation.easeInOut(duration: Constants.animationDuration).repeatCount(6, autoreverses: false)
var body: some View {
VStack {
Text("Hello, world!")
.padding()
Button(action: {
animate()
}) {
Text("Button")
.foregroundColor(Color.white)
}
}
.background(isAnimating ? Color.red : Color.blue)
.onReceive(viewModel.$shouldAnimate, perform: { _ in
if viewModel.shouldAnimate {
withAnimation(self.animation, {
self.isAnimating.toggle()
})
}
})
}
func animate() {
self.viewModel.isNew = true
}
}
And then my viewModel is:
import Combine
import SwiftUI
class ContentViewViewModel: ObservableObject {
#Published var shouldAnimate = false
#Published var isNew = false
var cancellables = Set<AnyCancellable>()
init() {
$isNew
.sink { result in
if result {
self.shouldAnimate = true
}
}
.store(in: &cancellables)
}
}
So the logic I am following is that when the button is tapped, we set 'isNew' to true. This in turn is a publisher which, when set to true, sets 'shouldAnimate' to true. In the ContentView, when shouldAnimate is received and is true, we toggle the background colour of the VStack x number of times.
The reason I am using this 'shouldAnimate' published property is because in the actual app, there are several different actions which may need to trigger the animation, and so it feels simpler to have this tied to one variable which we can listen for in the ContentView.
So in the code above, we should be toggling the isAnimating bool 6 times. So, we start with false then toggle as follows:
1: true, 2: false, 3: true, 4: false, 5: true, 6: false
So I would expect to end up on false and therefore have the background white. However, this is what I am getting:
I tried changing the repeatCount (in case I was misunderstanding how the count works):
private let animation = Animation.easeInOut(duration: Constants.animationDuration).repeatCount(7, autoreverses: false)
And I get the following:
No matter the count, I always end on true.
Update:
I have now managed to get the effect I am looking for by using the following loop:
for i in 0...5 {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i), execute: {
withAnimation(self.animation, {
self.isAnimating.toggle()
})
})
}
Not sure this is the best way to go though....
To understand what is going on, it would help to understand CALayer property animations.
When you define an animation the system captures the state of a Layer and watches for changes in the animatable properties of that layer. It records property changes for playback during the animation. To present the animation, it create a copy of the layer in its initial state (the presentationLayer). It then substitutes the copy in place of the actual layers on screen and runs the animation by manipulating the animatable properties of the presentation layer.
I this case, when you begin the animation, the system watches what happens to the CALayer that backs your view and captures the changes to any animatable properties (in this case the background color). It then creates a presentationLayer and replays those property changes repeatedly. It's not running your code repeatedly - it's changing the properties of the presentation Layer.
In other words the animation the system knows the layer's background color property should toggle back and forth because of the example you set in your animation block, but the animation toggles the background color back and forth without running your code again.

SwiftUI - How to change hierarchical position of View?

Here's a basic example of what doesn't work:
import SwiftUI
struct Test : View {
#State var swapped = false
var body: some View {
if swapped { Color.green }
Color.blue.tapAction {
withAnimation { self.swapped.toggle() }
}
if !swapped { Color.green }
}
}
SwiftUI has no way to figure out that I think of the first Color.green and the second Color.green as the same view, so of course the animation just fades one of them out while fading the other one in at the new location. I'm looking for the way to indicate to SwiftUI that these are the same view, and to animate it to the new location. I discovered the .id() modifier with great excitement because I believed that it would give me the effect that I want:
import SwiftUI
struct Test : View {
#State var swapped = false
var body: some View {
if swapped { Color.green.id("green") }
Color.blue.tapAction {
withAnimation { self.swapped.toggle() }
}
if !swapped { Color.green.id("green") }
}
}
This, unfortunately, does not work either. I'm unbelievably excited about SwiftUI, but it seems to me that the ability to change the structure of the view hierarchy while preserving view identity is quite important. The actual use case which prompted me to think about this is that I have a handful of views which I'm trying to create a fan animation for. The simplest way would be to have the items in a ZStack in one state so that they are all on top of each other, and then to have them in a VStack in the fanned out state, so that they're vertically spread out and all visible. Changing from a ZStack to a VStack of course counts as a change to the structure and therefore all continuity between the states is lost and everything just cross fades. Does anyone know what to do about this?
You can do this with SwiftUI 2 introduced in iOS 14 / macOS 10.16:
#Namespace private var animation
…
MyView.matchedGeometryEffect(id: "myID", in: animation)
I think I have the animation part of your question down, but unfortunately, I don't think it will keep the same instance of the view. I tried taking green out into a variable to see if that works, but if I understand SwiftUI correctly, that doesn't mean the same instance of the view will be shared in two places.
What I'm doing is adding a transition when the green view is added/removed. This way, the view moves to the location of its replacement before disappearing.
struct Test : View {
#State var swapped = false
let green = Color.green
var body: some View {
HStack {
if swapped {
green
.transition(.offset(CGSize(width: 200, height: 0)))
.animation(.basic())
}
Color.blue.animation(.basic()).tapAction {
withAnimation { self.swapped.toggle() }
}
if !swapped {
green.transition(.offset(CGSize(width: -200, height: 0)))
.animation(.basic())
}
}
}
}
This solution is quick-and-dirty and uses hard-coded values based on the iPhone 6/7/8 portrait screen size

Animating Auto Layout constraints with NSView.layoutSubtreeIfNeeded() not working on macOS High Sierra

I have a basic Mac app with a view animation done through Auto Layout:
I add a new view to the right of the current view
I update the constraints so that the new view ends up filling the window
→ The animation will make it appear as if the view slides in from the right.
The recommended way for animating Auto Layout changes is:
Update the constraints
Use NSAnimationContext.runAnimationGroup()
Set allowsImplicitAnimation to true inside the animation block
Call view.layoutSubtreeIfNeeded() inside the animation block
I followed this recommendation and everything worked fine on macOS Sierra, but on macOS High Sierra, the animation does not take place anymore. Instead the view shows up at its final position without the animation.
I found a workaround: I schedule the animation on the next runloop cycle using DispatchQueue.main.async. However, that seems like a hack and I'm wondering if there is something else I'm missing here.
Here is my actual code:
private func appendSlideViewControllerAnimated(_ viewController:NSViewController, to viewToTheLeft:NSView)
{
// Insert the new view on the very right, just outside the parent:
viewController.view.frame = self.view.bounds
viewController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(viewController.view)
viewController.view.topAnchor.constraint( equalTo: view.topAnchor ).isActive = true
viewController.view.bottomAnchor.constraint( equalTo: view.bottomAnchor ).isActive = true
viewController.view.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true
viewController.view.leadingAnchor.constraint(equalTo: viewToTheLeft.trailingAnchor).isActive = true
// Update the layout after we just added the view on the right:
view.layoutSubtreeIfNeeded()
// Starting with macOS High Sierra, animating constraint changes for the newly inserted view
// only works if scheduled on the next runloop:
//DispatchQueue.main.async {
// Update the constraints to pin the view to the left:
self.view.removeConstraint(self.activeSlideLeadingConstraint!)
self.activeSlideLeadingConstraint = viewController.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor)
self.activeSlideLeadingConstraint?.constant = 0
self.activeSlideLeadingConstraint?.isActive = true
NSAnimationContext.runAnimationGroup( { context in
self.isAnimating = true
context.duration = self.slidingAnimationDuration
context.allowsImplicitAnimation = true
self.view.layoutSubtreeIfNeeded()
}, completionHandler: {
viewToTheLeft.removeFromSuperview()
self.clearUndoHistory()
self.updateFirstResponder()
self.isAnimating = false
})
//}
}
Enable Core Animation backing for root view you trying to animate. It can be done in Interface Builder or programmatically:
override func viewDidLoad()
{
super.viewDidLoad()
view.wantsLayer = true
}

Resources