wigging
wigging

Reputation: 9170

Change window size based on NavigationView in a SwiftUI macOS app

I'm using SwiftUI for a Mac app where the main window contains a NavigationView. This NavigationView contains a sidebar list. When an item in the sidebar is selected, it changes the view displayed in the detail view. The views presented in the detail view are different sizes which should cause the size of the window to change when they are displayed. However, when the detail view changes size the window does not change size to accommodate the new detail view.

How can I make the window size change according to the size of the NavigationView?

My example code for the app is below:

import SwiftUI

struct View200: View {
    var body: some View {
        Text("200").font(.title)
            .frame(width: 200, height: 400)
            .background(Color(.systemRed))
    }
}

struct View500: View {
    var body: some View {
        Text("500").font(.title)
            .frame(width: 500, height: 300)
            .background(Color(.systemBlue))
    }
}

struct ViewOther: View {
    let item: Int
    var body: some View {
        Text("\(item)").font(.title)
            .frame(width: 300, height: 200)
            .background(Color(.systemGreen))
    }
}

struct DetailView: View {

    let item: Int

    var body: some View {
        switch item {
        case 2:
            return AnyView(View200())
        case 5:
            return AnyView(View500())
        default:
            return AnyView(ViewOther(item: item))
        }
    }
}

struct ContentView: View {
    var body: some View {
        NavigationView {
             List {
                 ForEach(1...10, id: \.self) { index in
                     NavigationLink(destination: DetailView(item: index)) {
                         Text("Link \(index)")
                     }
                 }
             }
             .listStyle(SidebarListStyle())
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

And here is what the example app looks like when the detail view changes size:

screen recording

Upvotes: 8

Views: 7135

Answers (3)

Dominic Holmes
Dominic Holmes

Reputation: 598

Asperi's answer works for me, but the animation is not working on Big Sur 11.0.1, Xcode 12.2. Thankfully, the animation works if you wrap it in an NSAnimationContext:

NSAnimationContext.runAnimationGroup({ context in
    context.timingFunction = CAMediaTimingFunction(name: .easeIn)
    window!.animator().setFrame(frame, display: true, animate: true)
}, completionHandler: {
})

Also it should be mentioned that ResizingView and window don't have to be initialized inside AppDelegate; you can continue using SwiftUI's App struct:

@main
struct MyApp: App {

    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup {
            ResizingView()
        }
    }
}

class AppDelegate: NSObject, NSApplicationDelegate {

    var window: NSWindow?
    var subscribers = Set<AnyCancellable>()

    func applicationDidBecomeActive(_ notification: Notification) {
        self.window = NSApp.mainWindow
    }

    func applicationDidFinishLaunching(_ aNotification: Notification) {
        setupResizeNotification()
    }

    private func setupResizeNotification() {
        NotificationCenter.default.publisher(for: ResizingView.needsNewSize)
            .sink(receiveCompletion: {_ in}) { [unowned self] notificaiton in
                if let size = notificaiton.object as? CGSize, self.window != nil {
                    var frame = self.window!.frame
                    let old = self.window!.contentRect(forFrameRect: frame).size
                    let dX = size.width - old.width
                    let dY = size.height - old.height
                    frame.origin.y -= dY // origin in flipped coordinates
                    frame.size.width += dX
                    frame.size.height += dY
                    NSAnimationContext.runAnimationGroup({ context in
                        context.timingFunction = CAMediaTimingFunction(name: .easeIn)
                        window!.animator().setFrame(frame, display: true, animate: true)
                    }, completionHandler: {
                    })
                }
            }
            .store(in: &subscribers)
    }
}

Upvotes: 4

kontiki
kontiki

Reputation: 40489

the following will not solve your problem, but might (with some extra work), lead you to a solution.

I did not have much to investigate further, but it's possible to overwrite the setContentSize method in NSWindow (by subclassing of course). That way you can override the default behavior, which is setting the window frame without an animation.

The problem you will have to figure out is the fact that for complex views such as yours, the setContentSize method is called repeatedly, causing the animation not to work properly.

The following example works fine, but that's because we are dealing with a very simple view:

enter image description here

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {

    var window: NSWindow!


    func applicationDidFinishLaunching(_ aNotification: Notification) {
        // Create the SwiftUI view that provides the window contents.
        let contentView = ContentView()

        // Create the window and set the content view. 
        window = AnimatableWindow(
            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.makeKeyAndOrderFront(nil)
    }

    func applicationWillTerminate(_ aNotification: Notification) {
        // Insert code here to tear down your application
    }
}


class AnimatableWindow: NSWindow {
    var lastContentSize: CGSize = .zero

    override func setContentSize(_ size: NSSize) {

        if lastContentSize == size { return } // prevent multiple calls with the same size

        lastContentSize = size

        self.animator().setFrame(NSRect(origin: self.frame.origin, size: size), display: true, animate: true)
    }

}

struct ContentView: View {
    @State private var flag = false

    var body: some View {
        VStack {
            Button("Change") {
                self.flag.toggle()
            }
        }.frame(width: self.flag ? 100 : 300 , height: 200)
    }
}

Upvotes: 3

Asperi
Asperi

Reputation: 257493

Here is demo of possible approach that works. I did it on one different view, because you will need to redesign your solution to adopt it.

Demo

SwiftUI animate window frame

1) The view requiring window animated resize

struct ResizingView: View {
    public static let needsNewSize = Notification.Name("needsNewSize")

    @State var resizing = false
    var body: some View {
        VStack {
            Button(action: {
                self.resizing.toggle()
                NotificationCenter.default.post(name: Self.needsNewSize, object: 
                    CGSize(width: self.resizing ? 800 : 400, height: self.resizing ? 350 : 200))
            }, label: { Text("Resize") } )
        }
        .frame(minWidth: 400, maxWidth: .infinity, minHeight: 200, maxHeight: .infinity)
    }
}

2) Window's owner (in this case AppDelegate)

import Cocoa
import SwiftUI
import Combine

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {

    var window: NSWindow!
    var subscribers = Set<AnyCancellable>()

    func applicationDidFinishLaunching(_ aNotification: Notification) {

        let contentView = ResizingView()

        window = NSWindow(
            contentRect: NSRect(x: 0, y: 0, width: 480, height: 300), // just default
            styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
            backing: .buffered, defer: false)
        window.center()
        window.setFrameAutosaveName("Main Window")
        window.contentView = NSHostingView(rootView: contentView)
        window.makeKeyAndOrderFront(nil)

        NotificationCenter.default.publisher(for: ResizingView.needsNewSize)
            .sink(receiveCompletion: {_ in}) { [unowned self] notificaiton in
                if let size = notificaiton.object as? CGSize {
                    var frame = self.window.frame
                    let old = self.window.contentRect(forFrameRect: frame).size

                    let dX = size.width - old.width
                    let dY = size.height - old.height

                    frame.origin.y -= dY // origin in flipped coordinates
                    frame.size.width += dX
                    frame.size.height += dY
                    self.window.setFrame(frame, display: true, animate: true)
                }
            }
            .store(in: &subscribers)
    }
    ...

Upvotes: 5

Related Questions