0

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

  1. 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.

  2. 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!

  3. 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?

1 Answer 1

0

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?

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.