How do I render a SwiftUI View that is not at the root hierarchy as a UIImage? - uikit

Suppose I have a simple SwiftUI View that is not the ContentView such as this:
struct Test: View {
var body: some View {
VStack {
Text("Test 1")
Text("Test 2")
}
}
}
How can I render this view as a UIImage?
I've looked into solutions such as :
extension UIView {
func asImage() -> UIImage {
let renderer = UIGraphicsImageRenderer(bounds: bounds)
return renderer.image { rendererContext in
layer.render(in: rendererContext.cgContext)
}
}
}
But it seems that solutions like that only work on UIView, not a SwiftUI View.

Here is the approach that works for me, as I needed to get image exactly sized as it is when placed alongside others. Hope it would be helpful for some else.
Demo: above divider is SwiftUI rendered, below is image (in border to show size)
Update: re-tested with Xcode 13.4 / iOS 15.5
Test module in project is here
extension View {
func asImage() -> UIImage {
let controller = UIHostingController(rootView: self)
// locate far out of screen
controller.view.frame = CGRect(x: 0, y: CGFloat(Int.max), width: 1, height: 1)
let size = controller.sizeThatFits(in: UIScreen.main.bounds.size)
controller.view.bounds = CGRect(origin: .zero, size: size)
controller.view.sizeToFit()
UIApplication.shared.windows.first?.rootViewController?.view.addSubview(controller.view)
let image = controller.view.asImage()
controller.view.removeFromSuperview()
return image
}
}
extension UIView {
func asImage() -> UIImage {
let renderer = UIGraphicsImageRenderer(bounds: bounds)
return renderer.image { rendererContext in
// [!!] Uncomment to clip resulting image
// rendererContext.cgContext.addPath(
// UIBezierPath(roundedRect: bounds, cornerRadius: 20).cgPath)
// rendererContext.cgContext.clip()
// As commented by #MaxIsom below in some cases might be needed
// to make this asynchronously, so uncomment below DispatchQueue
// if you'd same met crash
// DispatchQueue.main.async {
layer.render(in: rendererContext.cgContext)
// }
}
}
}
// TESTING
struct TestableView: View {
var body: some View {
VStack {
Text("Test 1")
Text("Test 2")
}
}
}
struct TestBackgroundRendering: View {
var body: some View {
VStack {
TestableView()
Divider()
Image(uiImage: render())
.border(Color.black)
}
}
private func render() -> UIImage {
TestableView().asImage()
}
}

Solution of Asperi works, but if you need image without white background you have to add this line:
controller.view.backgroundColor = .clear
And your View extension will be:
extension View {
func asImage() -> UIImage {
let controller = UIHostingController(rootView: self)
// locate far out of screen
controller.view.frame = CGRect(x: 0, y: CGFloat(Int.max), width: 1, height: 1)
UIApplication.shared.windows.first!.rootViewController?.view.addSubview(controller.view)
let size = controller.sizeThatFits(in: UIScreen.main.bounds.size)
controller.view.bounds = CGRect(origin: .zero, size: size)
controller.view.sizeToFit()
controller.view.backgroundColor = .clear
let image = controller.view.asImage()
controller.view.removeFromSuperview()
return image
}
}

Related

SwiftUI exporting the content of Canvas

Does anyone know how to export the content of a Canvas into an Image?
With SwiftUI, it is possible to generate an Image from a View with an extension
func snapshot() -> UIImage {
let controller = UIHostingController(rootView: self)
let view = controller.view
let targetSize = controller.view.intrinsicContentSize
view?.bounds = CGRect(origin: .zero, size: targetSize)
view?.backgroundColor = .clear
let renderer = UIGraphicsImageRenderer(size: targetSize)
return renderer.image { _ in
view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
}
}
This works great for simple views like Button, but for Canvas it always generates an empty image.
For example, with the following code, the image generated by the button is fine, but the one of the Canvas is always empty.
import SwiftUI
extension View {
func snapshot() -> UIImage {
let controller = UIHostingController(rootView: self)
let view = controller.view
let targetSize = controller.view.intrinsicContentSize
view?.bounds = CGRect(origin: .zero, size: targetSize)
view?.backgroundColor = .clear
let renderer = UIGraphicsImageRenderer(size: targetSize)
return renderer.image { _ in
view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
}
}
}
struct ContentView: View {
var textView: some View {
Text("Hello, SwiftUI")
.padding()
.background(Color.green)
.foregroundColor(.white)
.clipShape(Capsule())
}
var canvas: some View {
Canvas { context, size in
var path = Path()
path.move(to: CGPoint(x: 0, y:0))
path.addLine(to: CGPoint(x: size.width/2, y:0))
path.addLine(to: CGPoint(x: size.width/2, y:size.height/2))
path.addLine(to: CGPoint(x: 0, y:size.height/2))
path.closeSubpath()
context.fill(path, with: .color(.blue))
}
}
var body: some View {
VStack {
textView
canvas
Button("Save to image: Canvas") {
if let view = canvas as? Canvas<EmptyView> {
let image = view.snapshot()
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
}
}
Button("Save to image: Text") {
if let view = textView as? Text {
let image = view.snapshot()
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
}
}
}
}
}
Apply a frame to the canvas and it should work. E.g.
canvas.frame(width: 300, height: 300)
Answer is here: Swift UI exporting content of canvas. Apply the frame in the line where you get the snapshot, i.e.
let newImage = canvasView.frame(width: 300, height: 300).snapshot()

How to auto-expand height of NSTextView in SwiftUI?

How do I properly implement NSView constraints on the NSTextView below so it interacts with SwiftUI .frame()?
Goal
An NSTextView that, upon new lines, expands its frame vertically to force a SwiftUI parent view to render again (i.e., expand a background panel that's under the text + push down other content in VStack). The parent view is already wrapped in a ScrollView. Since the SwiftUI TextEditor is ugly and under-featured, I'm guessing several others new to MacOS will wonder how to do the same.
Update
#Asperi pointed out a sample for UIKit buried in another thread. I tried adapting that for AppKit, but there's some loop in the async recalculateHeight function. I'll look more at it with coffee tomorrow. Thanks Asperi. (Whoever you are, you are the SwiftUI SO daddy.)
Problem
The NSTextView implementation below edits merrily, but disobeys SwiftUI's vertical frame. Horizontally all is obeyed, but texts just continues down past the vertical height limit. Except, when switching focus away, the editor crops that extra text... until editing begins again.
What I've Tried
Sooo many posts as models. Below are a few. My shortfall I think is misunderstanding how to set constraints, how to use NSTextView objects, and perhaps overthinking things.
I've tried implementing an NSTextContainer, NSLayoutManager, and NSTextStorage stack together in the code below, but no progress.
I've played with GeometryReader inputs, no dice.
I've printed LayoutManager and TextContainer variables on textdidChange(), but am not seeing dimensions change upon new lines. Also tried listening for .boundsDidChangeNotification / .frameDidChangeNotification.
GitHub: unnamedd MacEditorTextView.swift <- Removed its ScrollView, but couldn't get text constraints right after doing so
SO: Multiline editable text field in SwiftUI <- Helped me understand how to wrap, removed the ScrollView
SO: Using a calculation by layoutManager <- My implementation didn't work
Reddit: Wrap NSTextView in SwiftUI <- Tips seem spot on, but lack AppKit knowledge to follow
SO: Autogrow height with intrinsicContentSize <- My implementation didn't work
SO: Changing a ScrollView <- Couldn't figure out how to extrapolate
SO: Cocoa tutorial on setting up an NSTextView
Apple NSTextContainer Class
Apple Tracking the Size of a Text View
ContentView.swift
import SwiftUI
import Combine
struct ContentView: View {
#State var text = NSAttributedString(string: "Testing.... testing...")
let nsFont: NSFont = .systemFont(ofSize: 20)
var body: some View {
// ScrollView would go here
VStack(alignment: .center) {
GeometryReader { geometry in
NSTextEditor(text: $text.didSet { text in react(to: text) },
nsFont: nsFont,
geometry: geometry)
.frame(width: 500, // Wraps to width
height: 300) // Disregards this during editing
.background(background)
}
Text("Editing text above should push this down.")
}
}
var background: some View {
...
}
// Seeing how updates come back; I prefer setting them on textDidEndEditing to work with a database
func react(to text: NSAttributedString) {
print(#file, #line, #function, text)
}
}
// Listening device into #State
extension Binding {
func didSet(_ then: #escaping (Value) ->Void) -> Binding {
return Binding(
get: {
return self.wrappedValue
},
set: {
then($0)
self.wrappedValue = $0
}
)
}
}
NSTextEditor.swift
import SwiftUI
struct NSTextEditor: View, NSViewRepresentable {
typealias Coordinator = NSTextEditorCoordinator
typealias NSViewType = NSTextView
#Binding var text: NSAttributedString
let nsFont: NSFont
var geometry: GeometryProxy
func makeNSView(context: NSViewRepresentableContext<NSTextEditor>) -> NSTextEditor.NSViewType {
return context.coordinator.textView
}
func updateNSView(_ nsView: NSTextView, context: NSViewRepresentableContext<NSTextEditor>) { }
func makeCoordinator() -> NSTextEditorCoordinator {
let coordinator = NSTextEditorCoordinator(binding: $text,
nsFont: nsFont,
proxy: geometry)
return coordinator
}
}
class NSTextEditorCoordinator : NSObject, NSTextViewDelegate {
let textView: NSTextView
var font: NSFont
var geometry: GeometryProxy
#Binding var text: NSAttributedString
init(binding: Binding<NSAttributedString>,
nsFont: NSFont,
proxy: GeometryProxy) {
_text = binding
font = nsFont
geometry = proxy
textView = NSTextView(frame: .zero)
textView.autoresizingMask = [.height, .width]
textView.textColor = NSColor.textColor
textView.drawsBackground = false
textView.allowsUndo = true
textView.isAutomaticLinkDetectionEnabled = true
textView.displaysLinkToolTips = true
textView.isAutomaticDataDetectionEnabled = true
textView.isAutomaticTextReplacementEnabled = true
textView.isAutomaticDashSubstitutionEnabled = true
textView.isAutomaticSpellingCorrectionEnabled = true
textView.isAutomaticQuoteSubstitutionEnabled = true
textView.isAutomaticTextCompletionEnabled = true
textView.isContinuousSpellCheckingEnabled = true
textView.usesAdaptiveColorMappingForDarkAppearance = true
// textView.importsGraphics = true // 100% size, layoutManger scale didn't fix
// textView.allowsImageEditing = true // NSFileWrapper error
// textView.isIncrementalSearchingEnabled = true
// textView.usesFindBar = true
// textView.isSelectable = true
// textView.usesInspectorBar = true
// Context Menu show styles crashes
super.init()
textView.textStorage?.setAttributedString($text.wrappedValue)
textView.delegate = self
}
// Calls on every character stroke
func textDidChange(_ notification: Notification) {
switch notification.name {
case NSText.boundsDidChangeNotification:
print("bounds did change")
case NSText.frameDidChangeNotification:
print("frame did change")
case NSTextView.frameDidChangeNotification:
print("FRAME DID CHANGE")
case NSTextView.boundsDidChangeNotification:
print("BOUNDS DID CHANGE")
default:
return
}
// guard notification.name == NSText.didChangeNotification,
// let update = (notification.object as? NSTextView)?.textStorage else { return }
// text = update
}
// Calls only after focus change
func textDidEndEditing(_ notification: Notification) {
guard notification.name == NSText.didEndEditingNotification,
let update = (notification.object as? NSTextView)?.textStorage else { return }
text = update
}
}
Quick Asperi's answer from a UIKit thread
Crash
*** Assertion failure in -[NSCGSWindow setSize:], NSCGSWindow.m:1458
[General] Invalid parameter not satisfying:
size.width >= 0.0
&& size.width < (CGFloat)INT_MAX - (CGFloat)INT_MIN
&& size.height >= 0.0
&& size.height < (CGFloat)INT_MAX - (CGFloat)INT_MIN
import SwiftUI
struct AsperiMultiLineTextField: View {
private var placeholder: String
private var onCommit: (() -> Void)?
#Binding private var text: NSAttributedString
private var internalText: Binding<NSAttributedString> {
Binding<NSAttributedString>(get: { self.text } ) {
self.text = $0
self.showingPlaceholder = $0.string.isEmpty
}
}
#State private var dynamicHeight: CGFloat = 100
#State private var showingPlaceholder = false
init (_ placeholder: String = "", text: Binding<NSAttributedString>, onCommit: (() -> Void)? = nil) {
self.placeholder = placeholder
self.onCommit = onCommit
self._text = text
self._showingPlaceholder = State<Bool>(initialValue: self.text.string.isEmpty)
}
var body: some View {
NSTextViewWrapper(text: self.internalText, calculatedHeight: $dynamicHeight, onDone: onCommit)
.frame(minHeight: dynamicHeight, maxHeight: dynamicHeight)
.background(placeholderView, alignment: .topLeading)
}
#ViewBuilder
var placeholderView: some View {
if showingPlaceholder {
Text(placeholder).foregroundColor(.gray)
.padding(.leading, 4)
.padding(.top, 8)
}
}
}
fileprivate struct NSTextViewWrapper: NSViewRepresentable {
typealias NSViewType = NSTextView
#Binding var text: NSAttributedString
#Binding var calculatedHeight: CGFloat
var onDone: (() -> Void)?
func makeNSView(context: NSViewRepresentableContext<NSTextViewWrapper>) -> NSTextView {
let textField = NSTextView()
textField.delegate = context.coordinator
textField.isEditable = true
textField.font = NSFont.preferredFont(forTextStyle: .body)
textField.isSelectable = true
textField.drawsBackground = false
textField.allowsUndo = true
/// Disabled these lines as not available/neeed/appropriate for AppKit
// textField.isUserInteractionEnabled = true
// textField.isScrollEnabled = false
// if nil != onDone {
// textField.returnKeyType = .done
// }
textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return textField
}
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text, height: $calculatedHeight, onDone: onDone)
}
func updateNSView(_ NSView: NSTextView, context: NSViewRepresentableContext<NSTextViewWrapper>) {
NSTextViewWrapper.recalculateHeight(view: NSView, result: $calculatedHeight)
}
fileprivate static func recalculateHeight(view: NSView, result: Binding<CGFloat>) {
/// UIView.sizeThatFits is not available in AppKit. Tried substituting below, but there's a loop that crashes.
// let newSize = view.sizeThatFits(CGSize(width: view.frame.size.width, height: CGFloat.greatestFiniteMagnitude))
// tried reportedSize = view.frame, view.intrinsicContentSize
let reportedSize = view.fittingSize
let newSize = CGSize(width: reportedSize.width, height: CGFloat.greatestFiniteMagnitude)
if result.wrappedValue != newSize.height {
DispatchQueue.main.async {
result.wrappedValue = newSize.height // !! must be called asynchronously
}
}
}
final class Coordinator: NSObject, NSTextViewDelegate {
var text: Binding<NSAttributedString>
var calculatedHeight: Binding<CGFloat>
var onDone: (() -> Void)?
init(text: Binding<NSAttributedString>, height: Binding<CGFloat>, onDone: (() -> Void)? = nil) {
self.text = text
self.calculatedHeight = height
self.onDone = onDone
}
func textDidChange(_ notification: Notification) {
guard notification.name == NSText.didChangeNotification,
let textView = (notification.object as? NSTextView),
let latestText = textView.textStorage else { return }
text.wrappedValue = latestText
NSTextViewWrapper.recalculateHeight(view: textView, result: calculatedHeight)
}
func textView(_ textView: NSTextView, shouldChangeTextIn: NSRange, replacementString: String?) -> Bool {
if let onDone = self.onDone, replacementString == "\n" {
textView.resignFirstResponder()
onDone()
return false
}
return true
}
}
}
Solution thanks to #Asperi's tip to convert his UIKit code in this post. A few things had to change:
NSView also lacks the view.sizeThatFits() for a proposed bounds change, so I found that the view's .visibleRect would work instead.
Bugs:
There is a bobble on first render (from smaller vertically to the proper size). I thought it was caused by the recalculateHeight(), which would print out some smaller values initially. A gating statement there stopped those values, but the bobble is still there.
Currently I set the placeholder text's inset by a magic number, which should be done based on the NSTextView's attributes, but I didn't find anything usable yet. If it has the same font I guess I could just add a space or two in front of the placeholder text and be done with it.
Hope this saves some others making SwiftUI Mac apps some time.
import SwiftUI
// Wraps the NSTextView in a frame that can interact with SwiftUI
struct MultilineTextField: View {
private var placeholder: NSAttributedString
#Binding private var text: NSAttributedString
#State private var dynamicHeight: CGFloat // MARK TODO: - Find better way to stop initial view bobble (gets bigger)
#State private var textIsEmpty: Bool
#State private var textViewInset: CGFloat = 9 // MARK TODO: - Calculate insetad of magic number
var nsFont: NSFont
init (_ placeholder: NSAttributedString = NSAttributedString(string: ""),
text: Binding<NSAttributedString>,
nsFont: NSFont) {
self.placeholder = placeholder
self._text = text
_textIsEmpty = State(wrappedValue: text.wrappedValue.string.isEmpty)
self.nsFont = nsFont
_dynamicHeight = State(initialValue: nsFont.pointSize)
}
var body: some View {
ZStack {
NSTextViewWrapper(text: $text,
dynamicHeight: $dynamicHeight,
textIsEmpty: $textIsEmpty,
textViewInset: $textViewInset,
nsFont: nsFont)
.background(placeholderView, alignment: .topLeading)
// Adaptive frame applied to this NSViewRepresentable
.frame(minHeight: dynamicHeight, maxHeight: dynamicHeight)
}
}
// Background placeholder text matched to default font provided to the NSViewRepresentable
var placeholderView: some View {
Text(placeholder.string)
// Convert NSFont
.font(.system(size: nsFont.pointSize))
.opacity(textIsEmpty ? 0.3 : 0)
.padding(.leading, textViewInset)
.animation(.easeInOut(duration: 0.15))
}
}
// Creates the NSTextView
fileprivate struct NSTextViewWrapper: NSViewRepresentable {
#Binding var text: NSAttributedString
#Binding var dynamicHeight: CGFloat
#Binding var textIsEmpty: Bool
// Hoping to get this from NSTextView,
// but haven't found the right parameter yet
#Binding var textViewInset: CGFloat
var nsFont: NSFont
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text,
height: $dynamicHeight,
textIsEmpty: $textIsEmpty,
nsFont: nsFont)
}
func makeNSView(context: NSViewRepresentableContext<NSTextViewWrapper>) -> NSTextView {
return context.coordinator.textView
}
func updateNSView(_ textView: NSTextView, context: NSViewRepresentableContext<NSTextViewWrapper>) {
NSTextViewWrapper.recalculateHeight(view: textView, result: $dynamicHeight, nsFont: nsFont)
}
fileprivate static func recalculateHeight(view: NSView, result: Binding<CGFloat>, nsFont: NSFont) {
// Uses visibleRect as view.sizeThatFits(CGSize())
// is not exposed in AppKit, except on NSControls.
let latestSize = view.visibleRect
if result.wrappedValue != latestSize.height &&
// MARK TODO: - The view initially renders slightly smaller than needed, then resizes.
// I thought the statement below would prevent the #State dynamicHeight, which
// sets itself AFTER this view renders, from causing it. Unfortunately that's not
// the right cause of that redawing bug.
latestSize.height > (nsFont.pointSize + 1) {
DispatchQueue.main.async {
result.wrappedValue = latestSize.height
print(#function, latestSize.height)
}
}
}
}
// Maintains the NSTextView's persistence despite redraws
fileprivate final class Coordinator: NSObject, NSTextViewDelegate, NSControlTextEditingDelegate {
var textView: NSTextView
#Binding var text: NSAttributedString
#Binding var dynamicHeight: CGFloat
#Binding var textIsEmpty: Bool
var nsFont: NSFont
init(text: Binding<NSAttributedString>,
height: Binding<CGFloat>,
textIsEmpty: Binding<Bool>,
nsFont: NSFont) {
_text = text
_dynamicHeight = height
_textIsEmpty = textIsEmpty
self.nsFont = nsFont
textView = NSTextView(frame: .zero)
textView.isEditable = true
textView.isSelectable = true
// Appearance
textView.usesAdaptiveColorMappingForDarkAppearance = true
textView.font = nsFont
textView.textColor = NSColor.textColor
textView.drawsBackground = false
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
// Functionality (more available)
textView.allowsUndo = true
textView.isAutomaticLinkDetectionEnabled = true
textView.displaysLinkToolTips = true
textView.isAutomaticDataDetectionEnabled = true
textView.isAutomaticTextReplacementEnabled = true
textView.isAutomaticDashSubstitutionEnabled = true
textView.isAutomaticSpellingCorrectionEnabled = true
textView.isAutomaticQuoteSubstitutionEnabled = true
textView.isAutomaticTextCompletionEnabled = true
textView.isContinuousSpellCheckingEnabled = true
super.init()
// Load data from binding and set font
textView.textStorage?.setAttributedString(text.wrappedValue)
textView.textStorage?.font = nsFont
textView.delegate = self
}
func textDidChange(_ notification: Notification) {
// Recalculate height after every input event
NSTextViewWrapper.recalculateHeight(view: textView, result: $dynamicHeight, nsFont: nsFont)
// If ever empty, trigger placeholder text visibility
if let update = (notification.object as? NSTextView)?.string {
textIsEmpty = update.isEmpty
}
}
func textDidEndEditing(_ notification: Notification) {
// Update binding only after editing ends; useful to gate NSManagedObjects
$text.wrappedValue = textView.attributedString()
}
}
I found nice gist code created by unnamedd.
https://gist.github.com/unnamedd/6e8c3fbc806b8deb60fa65d6b9affab0
Sample Usage:
MacEditorTextView(
text: $text,
isEditable: true,
font: .monospacedSystemFont(ofSize: 12, weight: .regular)
)
.frame(minWidth: 300,
maxWidth: .infinity,
minHeight: 100,
maxHeight: .infinity)
.padding(12)
.cornerRadius(8)

Reset offset, onTapGesture works, but onRotated does not work, why?

I want the view containing 2 rectangles floating in the bottom of the screen, whatever the orientation is portrait or landscape.
code is a test, when orientationDidChangeNotification happened, I Found UIDevice.current.orientation.isPortrait and UIScreen.main.bounds.height often have wrong value, why?
Anyway, test code is just reset offset = 0 in onRotated(). but it doesn't work; otherwise onTapGesture works fine.
Q1: Is it a wrong way for SwiftUI? SceneDelegate.orientationDidChangeNotification -> contentView.onRotated()?
Q2: why do UIDevice.current.orientation.isPortrait and UIScreen.main.bounds.height often have wrong value?
Q3: How to let a view float at the bottom of screen in both portrait and landscape?
let height: CGFloat = 100
struct TestView: View {
#State var offset = (UIScreen.main.bounds.height - height) / 2
var body: some View {
ZStack {
Text("+")
VStack(spacing: 0) {
Rectangle().fill(Color.blue)
Rectangle().fill(Color.red)
}
.frame(width: 100, height: height)
.offset(y: offset)
.onTapGesture {
self.offset = 0
}
}
}
func onRotated() {
// let isPortrait = UIDevice.current.orientation.isPortrait
offset = 0//(UIScreen.main.bounds.height - height) / 2
// print("\(isPortrait), screen height = \(UIScreen.main.bounds.height)")
}
}
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let contentView = TestView()
if let windowScene = scene as? UIWindowScene {
NotificationCenter.default.addObserver(forName: UIDevice.orientationDidChangeNotification, object: nil, queue: nil) { notification in
contentView.onRotated()
}
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
...
}
contentView is not a reference, it is a value, so you call .onRotated on own copy of contentView value that lives only within callback
let contentView = TestView()
if let windowScene = scene as? UIWindowScene {
NotificationCenter.default.addObserver(forName: UIDevice.orientationDidChangeNotification, object: nil, queue: nil) { notification in
contentView.onRotated() // local copy!!!
}
instead create listener for notification publisher inside TestView, so it can change self internally.
Moreover, it is not clear the intention but SwiftUI gives possibility to track size classes via EnvironmentValues.horizontalSizeClass and EnvironmentValues.verticalSizeClass which are automatically changed on device orientation, so it is possible to make your view layout depending on those environment values even w/o notification.
See here good example on how to use size classes

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:
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
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)
}
...
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)
}
}
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:
#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)
}
}

Coredata Image conversion swiftUI

I'm building a simple CoreData app. At one point the user is able to upload and store an image with CoreData in the NSData format. Saving the managedobjectcontext works like follows:
let item = Item(context: self.managedObjectContext)
item.theImage = selectedImageFromPicker.pngData() as NSData?
//saving the MOC
Now, when im trying to retrieve the image im facing a series of issues.
struct Box {
var id: Int
let title: String
let image: NSData?
}
struct BoxView: View {
let box: Box
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
var item: Item
var body: some View {
VStack {
Image(uiImage: UIImage(data: box.image) as! Data)
.resizable()
.frame(width: 120, height: 120, alignment: .center)
.aspectRatio(contentMode: .fill)
}
}
}
I'm pretty sure displaying the Image() with a UIImage that contains data is faulty here, but I cant figure out how to convert the data to a format that is displayed by the Image() method.
Unfortunately Xcode won't help with its error messages either and rather blames it on the VStack.
Maybe someone else was in the situation before and could help because I didn't find anything online to this specific issue.
As far as I understood you need something like the following (I removed non-related to image code for shortness and simplicity)
var body: some View {
VStack {
image
}
}
var image: some View {
if let nsData = box.image, let uiImage = UIImage(data: nsData as Data) {
return AnyView(Image(uiImage: uiImage)
.resizable()
.frame(width: 120, height: 120, alignment: .center)
.aspectRatio(contentMode: .fill))
}
return AnyView(EmptyView())
}
Here's how I did it. It works with a list.
I put this function in the struct view...
func getImageFromData(show: MyShow) -> UIImage {
//this is just a placeholder
var finalImage = (UIImage(systemName: "xmark"))
if let data = show.image {
if let image = UIImage(data: data as Data) {
finalImage = image
}
}
return finalImage!
}
and then I put this in the body view...
var body: some View {
NavigationView {
List {
ForEach(myShows, id: \.self) { (show: MyShow) in
VStack {
Image(uiImage: self.getImageFromData(show: show))
}
}
}
}

Resources