SwiftUI: Stop Continuous Animations When Views Leave Screen - animation

Context
NB: The question does NOT pertain to iOS
I have a Mac app that shows an NSPopover. The content of that popover is an NSHostingView that displays a simple SwiftUI view:
struct PopoverView: View
{
#State private var buttonWidthScale: CGFloat = 1.0
var body: some View
{
Button {
...
} label: {
RoundedRectangle(cornerRadius: 6.0)
.fill(.blue)
.scaleEffect(CGSize(width: buttonWidthScale, height: 1))
.animation(.easeInOut(duration: 2.5).repeatForever(), value: buttonWidthScale)
.onAppear {
buttonWidthScale = 0.96
}
}
}
}
The goal is to have a blue rectangle that very subtly "pulses" its width. The above works just fine to do that.
The Problem
I assumed (quite stupidly) that SwiftUI is smart enough to suspend the animation when the popover closes and the view is no longer on screen. That is not the case. Once the view appears for the first time, the app will now consume 5-6% CPU forever. The app correctly uses 0% CPU before this NSPopover appears for the first time and the animation kicks off.
What I Need
The SwiftUI .onAppear() and .onDisappear() methods are poorly named. They should really be called .onInsertion() and .onRemoval(), because they are only called when the view is added/removed from the hierarchy. (The "on appear" and "on disappear" names have historical meaning from NSViewController and Apple should never have recycled those names for a different intent.) As such, I cannot use these methods to start/stop the animation. .onAppear() is ever called only once and .onDisappear() is never called at all.
This animation should run continuously whenever the view is ON-SCREEN and then stop when the view disappears. So I need a replacement for .onAppear() and .onDisappear() that.....actually do what they imply they do!
My current approach is very hacky. From the NSViewController that holds the NSHostingView, I do this:
extension PopoverController: NSPopoverDelegate
{
func popoverWillShow(_ notification: Notification)
{
hostingView.rootView.popoverDidAppear()
}
func popoverDidClose(_ notification: Notification)
{
hostingView.rootView.popoverDidDisappear()
}
}
Where popoverDidAppear() and popoverDidDisappear() are two functions I've added to the PopoverView that replace the animation completely, as appropriate. (You can get rid of a .repeatForever() animation by replacing it with a new animation that is finite.)
But...this CANNOT be the right way, can it? There MUST be a canonical SwiftUI solution here that I just don't know about? Surely the future of Apple UI frameworks cannot need AppKit's help just to know when a view is shown and not shown?

This approach works, but I don't know if it's the "correct" way:
1. Add a Published Property in AppKit
To the NSViewController that manages the NSHostingView, I added this:
final class PopoverController: NSViewController, NSPopoverDelegate
{
#Published var popoverIsVisible: Bool = false
func popoverWillShow(_ notification: Notification)
{
popoverIsVisible = true
}
func popoverDidClose(_ notification: Notification)
{
popoverIsVisible = false
}
}
2. Use Combine in SwiftUI
In my SwiftUI view, I then did this:
struct PopoverView: View
{
#ObservedObject var popoverController: PopoverController
#State private var buttonWidthScale: CGFloat = 1.0
var body: some View
{
Button {
...
} label: {
RoundedRectangle(cornerRadius: 6.0)
.fill(.blue)
.scaleEffect(CGSize(width: buttonWidthScale, height: 1))
.onReceive(popoverController.$popoverIsVisible.dropFirst()) { isVisible in
if isVisible
{
withAnimation(.easeInOut(duration: 2.5).repeatForever()) {
buttonWidthScale = 0.96
}
}
else
{
// Replacing the repeating animation with a non-repeating one eliminates all animations.
withAnimation(.linear(duration: 0.001)) {
buttonWidthScale = 1.0
}
}
}
}
}
}
This appears to resolve the issue: CPU usage drops back to 0% when the popover is closed and the SwiftUI view leaves screen. The animation works correctly whenever the view appears.
But, again, there must be a better way to do this, right? This is a bunch of tight coupling and extra work just to accomplish something that ought to be automatic: "Don't waste CPU cycles on animations if the views aren't even on screen." Surely I'm just missing a SwiftUI idiom or modifier that does that?

Related

Can you dynamically change window size of macos app built with SwiftUI?

I'm building an app for MacOS. The functionality requires the window to increase in size in certain situations and to scale back outside of these situations. Is this possible? any pointers or examples are appreciated
Yes, this is possible by updating the frame of the NSWindow for your application, using setFrame. This method allows for setting the new frame (position and size) with and/or without animation.
Without animation (see setFrame(NSRect, display: Bool))
window.setFrame(newFrame, display: true)
With animation (see setFrame(NSRect, display: Bool, animate: Bool))
window.setFrame(newFrame, display: true, animated: true)
Calling any of these two methods will update the window of your application to whatever new frame you pass, including position and size.
The tricky part in SwiftUI is to get a hold to the NSWindow, since it is not trivial. Here is the TLDR:
Create a NSViewRepresentable to access the backing window:
struct WindowAccessor: NSViewRepresentable {
#Binding var window: NSWindow?
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
self.window = view.window
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
The window is set in the next run loop using dispatch async, since it would be nil beforehand.
Add a window property to your SwiftUI view, and use the window accessor with a background modifier.
struct MyView: View {
#State private var window: NSWindow?
var body: some View {
VStack {
// Your view content. Note: window might be nil until set
}
.background(WindowAccessor(window: $window))
.onChange(of: window) {
// This method will get called once when the window is set
}
}
}

WKWebView shows white bar until window moved/resized

I'm trying to show a WKWebView in SwiftUI on MacOS. When the app initially loads, the WKWebView has a large, white bar at the top. Moving or resizing the window causes this to immediately disappear and display correctly. Interestingly, the blue border around the view does not exhibit the bad behavior.
My guess is that I'm missing some action in updateNSView.
I'm on MacOS 11.1 Big Sur, Xcode 12.2, Intel. Another thing to note is that I need to enable the "Outgoing Connections" entitlement in the App Sandbox to get WKWebView to render anything at all, even tho the content is provided locally from a string.
import SwiftUI
import WebKit
#main
struct ProblemWKWebViewApp: App {
var body: some Scene {
WindowGroup {
SwiftUIWebView()
.border(Color.blue, width: 2)
}
}
}
struct SwiftUIWebView: NSViewRepresentable {
public typealias NSViewType = WKWebView
func makeNSView(context: Context) -> WKWebView {
let webView = WKWebView()
webView.loadHTMLString("<body style=\"background-color: red;\"><h1>Hello World!</h1></body>", baseURL: nil)
return webView
}
func updateNSView(_ nsView: WKWebView, context: Context) {
}
}
I had the same problem. I suppose that it is a bug. However I managed to fix it with adding ignoresSafeArea():
#main
struct ProblemWKWebViewApp: App {
var body: some Scene {
WindowGroup {
SwiftUIWebView()
.border(Color.blue, width: 2)
// Shows weird black bar on top without this on macOS
.ignoresSafeArea()
}
}
}

How do I get the window coordinates in SwiftUI?

I want to make a parallax background view, where the image behind the UI stays nearly still as the window moves around on the screen. To do this on macOS, I want to get the window's coordinates. How do I get the window's coordinates?
I ask this because I can't find anywhere that says how to do this:
Google searches which helped me find the following results:
SwiftUI window coordinates, SwiftUI window location, SwiftUI window get frame, SwiftUI get window, SwiftUI macOS window coordinates, SwiftUI macOS window location, SwiftUI macOS window get frame, SwiftUI macOS get window
Apple Developer Documentation:
GeometryReader - I had hoped that this would contain an API to give me the frame in system coordinate space, but it seems all the approaches it contains only reference within-the-window coordinates
Creating a macOS App — SwiftUI Tutorials - I was hoping Apple would have mentioned windowing in this, but it's not mentioned at all, aside from saying that you can preview the main window's contents in an Xcode preview pane
Fruitless searches: SwiftUI window coordinates, SwiftUI window location, SwiftUI window get frame
Other SO questions:
How to access own window within SwiftUI view? - I was optimistic that this would have an answer which would give me a SwiftUI API to access the window, but instead it uses a shim to access the AppKit window representation.
Define macOS window size using SwiftUI - Similar hopes as the above question, but this time the answer was just to read the frame of the content view, which again, always has a (0, 0) origin
SwiftUI coordinate space - I was hoping this answer would let me know how to transform the coordinates given by GeometryReader into screen coordinates, but unfortunately the coordinates are again constrained to within the window
Elsewhere on the web:
SwiftUI for Mac - Part 2 by TrozWare - I was hoping that this would give me some tips for using SwiftUI on Mac, such as interacting with windows, since most tutorials focus on iOS/iPadOS. Unfortunately, although it has lots of good information about how SwiftUI works with windows, it has no information on interacting with nor parsing those windows, themselves
SwiftUI Layout System by Alexander Grebenyuk - Was hoping for window layout within the screen, but this is all for full-screen iOS apps
SwiftUI by Example by Hacking with Swift - Was hoping for an example for how to get the position of a window, but it seems windows aren't really mentioned at all in the listed examples
As I listed, I found that all these either didn't relate to my issue, or only reference the coordinates within the window, but not the window's coordinates within the screen. Some mention ways to dip into AppKit, but I want to avoid that if possible.
The closest I got was trying to use a GeometryReader like this:
GeometryReader { geometry in
Text(verbatim: "\(geometry.frame(in: .global))")
}
but the origin was always (0, 0), though the size did change as I adjusted the window.
What I was envisioning was something perhaps like this:
public struct ParallaxBackground<Background: View>: View {
var background: Background
#Environment(\.windowFrame)
var windowFrame: CGRect
public var body: some View {
background
.offset(x: windowFrame.minX / 10,
y: windowFrame.minY / 10)
}
}
but \.windowFrame isn't real; it doesn't point to any keypath on EnvironmentValues. I can't find where I would get such a value.
As of today we have macOS 12 widely deployed/installed and SwiftUI has not gained a proper model for the macOS window. And from what I learned so far about macOS 13, there won't be a SwiftUI model for the window coming either.
Today (since macOS 11) we are not opening windows in the AppDelegate anymore but are now defining windows using the WindowGroup scene modifiers:
#main
struct HandleWindowApp: App {
var body: some Scene {
WindowGroup(id: "main") {
ContentView()
}
}
}
But there is no standard way to control or access the underlying window (e.g. NSWindow). To do this multiple answers on stackoverflow suggest to use a WindowAccessor which installs a NSView in the background of the ContentView and then accessing its window property. I also wrote my version of it to control the placement of windows.
In your case, it is sufficient to get a handle to the NSWindow instance and then observe the NSWindow.didMoveNotification. It will get called whenever the window did move.
If your app is using only a single window (e.g. you somehow inhibit that multiple windows can be created by the user), you can even observe the frames positions globally:
NotificationCenter.default
.addObserver(forName: NSWindow.didMoveNotification, object: nil, queue: nil) { (notification) in
if let window = notification.object as? NSWindow,
type(of: window).description() == "SwiftUI.SwiftUIWindow"
{
print(window.frame)
}
}
If you want the window frame:
The SceneDelegate keeps track of all the windows, so you can use it to make an EnvironmentObject with a reference to their frames and pass that to your View. Update the environment object values in the delegate method: func windowScene(_ windowScene: UIWindowScene, didUpdate previousCoordinateSpace: UICoordinateSpace, ...
If it's a one window app, it's much more straight forward. You could use UIScreen.main.bounds (if full screen) or a computed variable in you view:
var frame: CGRect { (UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate)?.window?.frame ?? .zero }
But if you are looking for the frame of the view in the window, try something like this:
struct ContentView: View {
#State var frame: CGRect = .zero
var orientationChangedPublisher = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
var body: some View {
VStack {
Text("text frame georeader \(frame.debugDescription)")
}
.background(GeometryReader { geometry in
Color.clear // .edgesIgnoringSafeArea(.all) // may need depending
.onReceive(self.orientationChangedPublisher.removeDuplicates()) { _ in
self.frame = geometry.frame(in: .global)
}
})
}
}
But having said all that, usually you don't need an absolute frame. Alignment guides let you place things relative to each other.
// For macOS App, using Frame Changed Notification and passing as Environment Object to SwiftUI View
class WindowInfo: ObservableObject {
#Published var frame: CGRect = .zero
}
#NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
var window: NSWindow!
let windowInfo = WindowInfo()
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Create the SwiftUI view that provides the window contents.
let contentView = ContentView()
.environmentObject(windowInfo)
// Create the window and set the content view.
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
window.center()
window.setFrameAutosaveName("Main Window")
window.contentView = NSHostingView(rootView: contentView)
window.contentView?.postsFrameChangedNotifications = true
window.makeKeyAndOrderFront(nil)
NotificationCenter.default.addObserver(forName: NSView.frameDidChangeNotification, object: nil, queue: nil) { (notification) in
self.windowInfo.frame = self.window.frame
}
}
struct ContentView: View {
#EnvironmentObject var windowInfo: WindowInfo
var body: some View {
Group {
Text("Hello, World! \(windowInfo.frame.debugDescription)")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}

Popover does not appear when closed during TextField edit

I want to edit objects using popovers in my macOS application. But for some reason the popover does not appear anymore, when it was closed the popover while editing a TextField. (see gif bellow)
Any ideas, why this is happening?
Code:
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
SubView()
SubView()
SubView()
}.padding()
}
}
struct SubView: View {
#State var showPopover = false
var body: some View {
VStack {
Text("Label")
}.onTapGesture {
self.showPopover = true
}
.popover(isPresented: $showPopover, arrowEdge: .trailing) {
Popover()
}
}
}
struct Popover: View {
#State var test: String = ""
var body: some View {
TextField("Text", text: $test)
}
}
It looks like it is not enough one event to resign editor first responder and close previous popover, so state of following popover is toggled, but new popover is not allowed, because previous is still on-screen.
The following workaround is possible (tested & works with Xcode 11.2)
}.onTapGesture {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
self.showPopover = true // delay activating new popover
}
}
Also it is possible to consider design approach when there is only one popover bindable to models of different subviews (which seems to me more appropriate) and manageable by the one state.

Animating constraints with layer-backed NSView

I'm attempting to implement an animation that shows/hides a view in a horizontal arrangement. I'd like this to happen with slide, and with no opacity changes. I'm using auto-layout everywhere.
Critically, the total width of the containing view changes with the window. So, constant-based animations are not possible (or so I believe, but happy to be proved wrong).
|- viewA -|- viewB -|
My first attempt was to use NSStackView, and animate the isHidden property of an arranged subview. Despite seeming like it might do the trick, I was not able to pull off anything close to what I was after.
My second attempt was to apply two constraints, one to force viewB to be zero width, and a second to ensure the widths are equal. On animation I change the priorities of these constraints from defaultHigh <-> defaultLow.
This results in the correct layout in both cases, but the animation is not working out.
With wantsLayer = true on the containing view, no animation occurs whatsoever. The views just jump to their final states. Without wantsLayer, the views do animate. However, when collapsing, viewA does a nice slide, but viewB instantly disappears. As an experiment, I changed the zero width to a fixed 10.0, and with that, the animation works right in both directions. However, I want the view totally hidden.
So, a few questions:
Is it possible to animate layouts like this with layer-backed views?
Are there other techniques possible for achieving the same effect?
Any ideas on how to achieve these nicely with NSStackView?
class LayoutAnimationViewController: NSViewController {
let containerView: NSView
let view1: ColorView
let view2: ColorView
let widthEqualContraint: NSLayoutConstraint
let widthZeroConstraint: NSLayoutConstraint
init() {
self.containerView = NSView()
self.view1 = ColorView(color: NSColor.red)
self.view2 = ColorView(color: NSColor.blue)
self.widthEqualContraint = view2.widthAnchor.constraint(equalTo: view1.widthAnchor)
widthEqualContraint.priority = .defaultLow
self.widthZeroConstraint = view2.widthAnchor.constraint(equalToConstant: 0.0)
widthZeroConstraint.priority = .defaultHigh
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
self.view = containerView
// view.wantsLayer = true
view.addSubview(view1)
view.addSubview(view2)
view.subviewsUseAutoLayout = true
NSLayoutConstraint.activate([
view1.topAnchor.constraint(equalTo: view.topAnchor),
view1.bottomAnchor.constraint(equalTo: view.bottomAnchor),
view1.leadingAnchor.constraint(equalTo: view.leadingAnchor),
// view1.trailingAnchor.constraint(equalTo: view2.leadingAnchor),
view2.topAnchor.constraint(equalTo: view.topAnchor),
view2.bottomAnchor.constraint(equalTo: view.bottomAnchor),
view2.leadingAnchor.constraint(equalTo: view1.trailingAnchor),
view2.trailingAnchor.constraint(equalTo: view.trailingAnchor),
widthEqualContraint,
widthZeroConstraint,
])
}
func runAnimation() {
view.layoutSubtreeIfNeeded()
self.widthEqualContraint.toggleDefaultPriority()
self.widthZeroConstraint.toggleDefaultPriority()
// self.leadingConstraint.toggleDefaultPriority()
NSAnimationContext.runAnimationGroup({ (context) in
context.allowsImplicitAnimation = true
context.duration = 3.0
self.view.layoutSubtreeIfNeeded()
}) {
Swift.print("animation complete")
}
}
}
extension LayoutAnimationViewController {
#IBAction func runTest1(_ sender: Any?) {
self.runAnimation()
}
}
Also, some potentially relevant, but so far unhelpful, related questions:
Animating Auto Layout changes concurrently with NSPopover contentSize change
Animating Auto Layout constraints with NSView.layoutSubtreeIfNeeded() not working on macOS High Sierra
Hide view item of NSStackView with animation

Resources