SwiftUI text field orphan on macOS - macos

I have a text field like this
Text("Hello, one two three four five six seven eight!")
.frame(width:270)
.border(.blue)
When it renders it decides to put seven and eight on the second line even though there is space for seven on the first line. Worse it decides to indent the truncated top line so it is centred within the frame.
How do I fix this so it wraps the text properly without taking into account the orphan?
Edit: Forgot to mention that I wanted this on macOS. I have tried to port it to the Mac. It does correctly left align the text but it doesn't wrap to the second line. The height of the box does get calculated accordingly though.
Here is my updated code:
struct NonOrphanedText: View
{
var text: String
#State private var height: CGFloat = .zero
var body: some View
{
InternalLabelView(text: text, dynamicHeight: $height)
.frame(maxHeight: height)
}
struct InternalLabelView: NSViewRepresentable
{
var text: String
#Binding var dynamicHeight: CGFloat
func makeNSView(context: Context) -> NSTextField
{
let label = NSTextField()
label.isEditable = false
label.isBezeled = false
label.drawsBackground = false
label.isSelectable = false
label.maximumNumberOfLines = 5
label.usesSingleLineMode = false
label.lineBreakStrategy = .init()
label.lineBreakMode = .byWordWrapping
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return label
}
func updateNSView(_ nsView: NSTextField, context: Context)
{
nsView.stringValue = text
DispatchQueue.main.async
{
dynamicHeight = nsView.sizeThatFits(CGSize(width: nsView.bounds.width, height: CGFloat.greatestFiniteMagnitude)).height
}
}
}
}

We need lineBreakStrategy but it is unavailable for now in SwiftUI, so possible solution is to use UILabel.
Here is a possible solution. Tested with Xcode 13.2 / iOS 15.2
struct LabelView: View {
var text: String
#State private var height: CGFloat = .zero
var body: some View {
InternalLabelView(text: text, dynamicHeight: $height)
.frame(maxHeight: height)
}
struct InternalLabelView: UIViewRepresentable {
var text: String
#Binding var dynamicHeight: CGFloat
func makeUIView(context: Context) -> UILabel {
let label = UILabel()
label.numberOfLines = 0
label.lineBreakStrategy = .init() // << here !!
label.lineBreakMode = .byWordWrapping
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return label
}
func updateUIView(_ uiView: UILabel, context: Context) {
uiView.text = text
DispatchQueue.main.async {
dynamicHeight = uiView.sizeThatFits(CGSize(width: uiView.bounds.width, height: CGFloat.greatestFiniteMagnitude)).height
}
}
}
}

Related

How to trigger SwiftUI animation via change of non-State variable

It's easy to have an animation begin when a view appears, by using .onAppear(). But I'd like to perform a repeating animation whenever one of the view's non-State variables changes. An example:
Here is a view that I would like to "throb" whenever its throbbing parameter, set externally, is true:
struct MyCircle: View {
var throbbing: Bool
#State var scale = 1.0
var body: some View {
Circle()
.frame(width: 100 * scale, height: 100 * scale)
.foregroundColor(.blue)
.animation(.easeInOut.repeatForever(), value: scale)
.onAppear { scale = 1.2 }
}
}
Currently the code begins throbbing immediately, regardless of the throbbing variable.
But imagine this scenario:
struct ContentView: View {
#State var throb: Bool = false
var body: some View {
VStack {
Button("Throb: \(throb ? "ON" : "OFF")") { throb.toggle() }
MyCircle(throbbing: throb)
}
}
}
It looks like this:
Any ideas how I can modify MyCircle so that the throbbing starts when the button is tapped and ends when it is tapped again?
You can use onChange to watch throbbing and then assign an animation. If true, add a repeating animation, and if false, just animate back to the original scale size:
struct MyCircle: View {
var throbbing: Bool
#State private var scale : CGFloat = 0
var body: some View {
Circle()
.frame(width: 100, height: 100)
.scaleEffect(1 + scale)
.foregroundColor(.blue)
.onChange(of: throbbing) { newValue in
if newValue {
withAnimation(.easeInOut.repeatForever()) {
scale = 0.2
}
} else {
withAnimation {
scale = 0
}
}
}
}
}
There're two interesting things that i've just found when trying to achieve yours target.
Animation will be added when the value changed
Animation that added to the view cannot be removed, and we need to drop the animated view from the view hiearachy by remove its id, new view will be created with zero animations.
import SwiftUI
import PlaygroundSupport
struct ContentView: View {
#State var throbbling = false
#State var circleId = UUID()
var body: some View {
VStack {
Toggle("Throbbling", isOn: $throbbling)
circle.id(circleId)
.scaleEffect(throbbling ? 1.2 : 1.0)
.animation(.easeInOut.repeatForever(), value: throbbling)
.onChange(of: throbbling) { newValue in
if newValue == false {
circleId = UUID()
}
}
}.padding()
}
#ViewBuilder
var circle: some View {
Circle().fill(.green).frame(width: 100, height: 100)
}
}
PlaygroundPage.current.setLiveView(ContentView())

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)

How to change UI state when user touch GoogleMaps' Marker in SwiftUI?

This question is about SwiftUI.
I'm trying to show a map and allow the user to touch any marker available. When it happens, I wish to change a text on my view, reflecting that user's action.
After a lot of search, I think the solution can be somewhere near Observable protocol, but I just can't figure out the right way for doing that. Here's my code:
struct Home: View {
// Here's the attribute I want to be changed when user touches the marker
var selectedMarker: GMSMarker?
var body: some View {
VStack(spacing: 0) {
// Condition to be applied when user touches the marker
if (selectedMarker == nil){
Text("No marker selected").padding()
}else{
Text("Now, there's a marker selected").padding()
}
GoogleMapsHome()
}
.navigationBarBackButtonHidden(true)
.navigationBarTitle(Text("Marker question"), displayMode: .inline)
}
}
struct Home_Previews: PreviewProvider {
static var previews: some View {
Home()
}
}
Here's the GoogleMaps definition:
struct GoogleMapsHome: UIViewRepresentable {
private let zoom: Float = 18
// Just for didactic purposes. Later, I'm going to use LocationManager
let lat: Double = -15.6692660716233
let lng: Double = -47.83980712156295
func makeUIView(context: Self.Context) -> GMSMapView {
let camera = GMSCameraPosition.camera(
withLatitude: lat,
longitude: lng,
zoom: zoom)
let mapView = GMSMapView.map(withFrame: CGRect.zero, camera: camera)
mapView.mapType = .hybrid
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ mapView: GMSMapView, context: Context) {
mapView.animate(toLocation: CLLocationCoordinate2D(latitude: lat, longitude: lng))
let position = CLLocationCoordinate2D(latitude: lat, longitude: lng)
let marker = GMSMarker(position: position)
marker.title = "You"
marker.map = mapView
}
func makeCoordinator() -> Coordinator {
Coordinator(owner: self)
}
class Coordinator: NSObject, GMSMapViewDelegate, ObservableObject {
let owner: GoogleMapsHome // access to owner view members,
init(owner: GoogleMapsHome) {
self.owner = owner
}
#Published var selectedMarker: GMSMarker? {
willSet { objectWillChange.send() }
}
func mapView(_ mapView: GMSMapView, didTap marker: GMSMarker) -> Bool {
print("A marker has been touched by the user")
self.selectedMarker = marker
return true
}
}
}
I hope someone can help me and, later, this question become useful for anyone with the same need.
Best regards!
After a while, I found a way to solve it.
The keywords for that are "Coordinator" and "Binding".
Of course, I'm not sure if this is the right way, or the best, but it worked, at least.
import Foundation
import SwiftUI
import GoogleMaps
struct Home: View {
#State var selectedMarker: GMSMarker?
var body: some View {
VStack(spacing: 0) {
if (selectedMarker == nil){
Text("No marker selected").padding()
}else{
Text("There's a marker selected").padding()
}
GoogleMapsHome(selectedMarker: self.$selectedMarker)
}
.navigationBarBackButtonHidden(true)
.navigationBarTitle(Text("Map Test"), displayMode: .inline)
}
}
struct Home_Previews: PreviewProvider {
static var previews: some View {
Home()
}
}
struct GoogleMapsHome: UIViewRepresentable {
private let zoom: Float = 18
let lat: Double = -15.6692660716233
let lng: Double = -47.83980712156295
#Binding var selectedMarker: GMSMarker?
func makeCoordinator() -> Coordinator {
return Coordinator(
owner: self,
selectedMarker: $selectedMarker)
}
func makeUIView(context: Self.Context) -> GMSMapView {
let camera = GMSCameraPosition.camera(
withLatitude: lat,
longitude: lng,
zoom: zoom)
let mapView = GMSMapView.map(withFrame: CGRect.zero, camera: camera)
mapView.mapType = .hybrid
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ mapView: GMSMapView, context: Context) {
mapView.animate(toLocation: CLLocationCoordinate2D(latitude: lat, longitude: lng))
let position = CLLocationCoordinate2D(latitude: lat, longitude: lng)
let marker = GMSMarker(position: position)
marker.title = "You"
marker.map = mapView
}
class Coordinator: NSObject, GMSMapViewDelegate, ObservableObject {
let owner: GoogleMapsHome // access to owner view members,
#Binding var selectedMarker: GMSMarker?
init(
owner: GoogleMapsHome,
selectedMarker: Binding<GMSMarker?>
) {
self.owner = owner
_selectedMarker = selectedMarker
}
func mapView(_ mapView: GMSMapView, didTap marker: GMSMarker) -> Bool {
print("A marker has been touched")
self.selectedMarker = marker
return true
}
}
}

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

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
}
}

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)
}
}

Resources