Convert SwiftUI View to NSImage - swift

How can I create a SwiftUI View extension to return a NSImage? I have seen how it can be done on iOS using UIGraphicsImageRenderer but it seems there is no macOS equivalent.

View+Image.swift
import SwiftUI
extension View {
func renderAsImage() -> NSImage? {
let view = NoInsetHostingView(rootView: self)
view.setFrameSize(view.fittingSize)
return view.bitmapImage()
}
}
NoInsetHostingView.swift
import SwiftUI
class NoInsetHostingView<V>: NSHostingView<V> where V: View {
override var safeAreaInsets: NSEdgeInsets {
return .init()
}
}
NSView+Image.swift
public extension NSView {
func bitmapImage() -> NSImage? {
guard let rep = bitmapImageRepForCachingDisplay(in: bounds) else {
return nil
}
cacheDisplay(in: bounds, to: rep)
guard let cgImage = rep.cgImage else {
return nil
}
return NSImage(cgImage: cgImage, size: bounds.size)
}
}
I'm not sure exactly why NoInsetHostingView is needed, but with just a normal NSHostingView the image has undesired insets, and this fixes it.

Related

Rasterising an NSView to NSImage in Swift

I'm trying to convert an NSView to a NSImage. The most popular solution suggested here is using bitmapImageRepForCachingDisplay which works fine most of the time. But in my particular case it fails to capture part of the view.
Here is the result I'm getting.
Input NSView:
Output Image:
As you can the code snippet highlighting is ignored in the output image. I'm using CodeEditor as the editor here. Is there an alternative way to capture the view?
import SwiftUI
import CodeEditor // from https://github.com/ZeeZide/CodeEditor
extension NSView {
func bitmapImage() -> NSImage? {
guard let rep = bitmapImageRepForCachingDisplay(in: bounds) else {
return nil
}
cacheDisplay(in: bounds, to: rep)
guard let cgImage = rep.cgImage else {
return nil
}
return NSImage(cgImage: cgImage, size: bounds.size)
}
}
extension View {
func renderAsImage() -> NSImage? {
let view = NSHostingView(rootView: self)
view.setFrameSize(view.fittingSize)
return view.bitmapImage()
}
}
struct CustomCodeEditor: View {
#State var source = """
struct ContentView: View {
var body: some View {
Text("Hello SwiftUI!")
.padding()
}
}
"""
var codeEditorView : some View {
return CodeEditor(source: $source, language: .swift, theme: .default).frame(width: 500, height: 500)
}
var body: some View{
Image(nsImage: codeEditorView.renderAsImage()!)
}
}

Create pdf with WKWebView.pdf(configuration:)

I want to create a pdf on macOS with the new WKWebView.pdf(configuration:) which was introduced in macOS 12/iOS 15. It tries to make use of the new async/await functionality (which I most likely have not grasped entirely I am afraid...).
Right now, I am getting an error that I have no idea how to handle:
Error Domain=WKErrorDomain Code=1 "An unknown error occurred"
UserInfo={NSLocalizedDescription=An unknown error occurred}
I try to load a html string into a web view, which I then want to generate the pdf from. The function I use to generate my PDFDocument looks like this:
func generatePdf() async {
let webView = WKWebView()
await webView.loadHTMLString(html, baseURL: nil)
let config = WKPDFConfiguration()
config.rect = .init(origin: .zero, size: .init(width: 595.28, height: 841.89))
do {
//this is where the error is happening
let pdfData = try await webView.pdf(configuration: config)
self.pdf = PDFDocument(data: pdfData)
} catch {
print(error) //this error gets printed
}
}
My best guess as it currently stands is that WKWebView's loadHTMLString has not finished loading the html–I did allow for outgoing connection in the app sandbox that's not it...
For the sake of completeness, here's the entire code:
import SwiftUI
import PDFKit
import WebKit
#main
struct AFPdfApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#StateObject private var vm = ViewModel()
var body: some View {
VStack {
TextEditor(text: $vm.html)
.frame(width: 300.0, height: 200.0)
.border(Color.accentColor, width: 1.0)
.padding()
PdfViewWrapper(pdfDocument: $vm.pdf)
}
.toolbar {
Button("Create PDF") {
Task {
await vm.generatePdf()
}
}
}
}
static let initHtml = """
<h1>Some fancy html</h1>
<h2>…and now how do I create a pdf from this?</h2>
"""
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
class ViewModel: ObservableObject {
#Published var html = """
<h1>Some fancy html</h1>
<h2>…and now let's create some pdf…</h2>
"""
#Published var pdf: PDFDocument? = nil
func generatePdf() async {
let webView = WKWebView()
await webView.loadHTMLString(html, baseURL: nil)
let config = WKPDFConfiguration()
config.rect = .init(origin: .zero, size: .init(width: 595.28, height: 841.89))
do {
let pdfData = try await webView.pdf(configuration: config)
self.pdf = PDFDocument(data: pdfData)
} catch {
print(error)
}
}
}
struct PdfViewWrapper: NSViewRepresentable {
#Binding var pdfDocument: PDFDocument?
func makeNSView(context: Context) -> PDFView {
return PDFView()
}
func updateNSView(_ nsView: PDFView, context: Context) {
nsView.document = pdfDocument
}
}
After Chris made me take another look at it (thanks for that :-) ) I am now a step closer to a working solution.
It really seems as though I really have to wait for the webView to load the html prior to creating the pdf. While I was not able to make it work with WKWebView.pdf(configuration:), I now have a (kind of…) working solution by using WKWebView.createPDF(configuration:completionHandler:):
func generatePdf() {
webView.loadHTMLString(htmlString, baseURL: nil)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
let config = WKPDFConfiguration()
config.rect = .init(origin: .zero, size: .init(width: 595.28, height: 841.89))
self.webView.createPDF(configuration: config){ result in
switch result {
case .success(let data):
self.pdf = PDFDocument(data: data)
case .failure(let error):
print(error)
}
}
}
}
I said "kind of works" above, because the resulting pdf seems to introduce a new line after each word, which is weird–but I will scope that issue to another research/question on SO.
Again, for the sake of completeness, here's the whole "app":
import SwiftUI
import PDFKit
import WebKit
#main
struct AFPdfApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
//MARK: View
struct ContentView: View {
#StateObject private var vm = ViewModel()
var body: some View {
VStack {
TextEditor(text: $vm.htmlString)
.frame(width: 300.0, height: 200.0)
.border(Color.accentColor, width: 1.0)
.padding()
WebViewWrapper(htmlString: $vm.htmlString)
PdfViewRepresentable(pdfDocument: $vm.pdf)
}
.toolbar {
Button("Create PDF") {
Task {
vm.generatePdf()
}
}
}
}
}
//MARK: ViewModel
class ViewModel: ObservableObject {
#Published var htmlString = """
<h1>Some fancy html</h1>
<h2>…and now let's create some pdf…</h2>
"""
#Published var webView = WKWebView()
#Published var pdf: PDFDocument? = nil
func generatePdf() {
webView.loadHTMLString(htmlString, baseURL: nil)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
let config = WKPDFConfiguration()
config.rect = .init(origin: .zero, size: .init(width: 595.28, height: 841.89))
self.webView.createPDF(configuration: config){ result in
switch result {
case .success(let data):
self.pdf = PDFDocument(data: data)
case .failure(let error):
print(error)
}
}
}
}
}
//MARK: ViewRepresentables
struct WebViewWrapper: NSViewRepresentable {
#Binding var htmlString: String
public func makeNSView(context: Context) -> WKWebView {
return WKWebView()
}
public func updateNSView(_ nsView: WKWebView, context: Context) {
nsView.loadHTMLString(htmlString, baseURL: nil)
}
}
struct PdfViewRepresentable: NSViewRepresentable {
#Binding var pdfDocument: PDFDocument?
func makeNSView(context: Context) -> PDFView {
return PDFView()
}
func updateNSView(_ nsView: PDFView, context: Context) {
nsView.document = pdfDocument
}
}
Here is a way that I got this working. It uses a completely hidden WKWebView to render the content, using a delegate callback and javascript execution to determine when the page has fully loaded.
When ready, the new iOS 15 WKWebView method .pdf() is called, which generates the PDF data.
Bindings are used to update the parent view, which generates a PDFDocument from the data and navigates to it.
Caveat: The native WKWebView PDF methods do not produce paginated PDF files, so it will be one long page!
In the main view it starts with a button tap. This view has a pdfData variable that is of type Data and will be used to make the PDF document.
Button("Generate PDF") {
// Prevent triggering again while already processing
guard !isWorking else { return }
// Reset flags
isReadyToRenderPDF = false
isWorking = true
// Initialise webView with frame equal to A4 paper size in points
// (I also tried UIScreen.main.bounds)
self.wkWebView = WKWebView(frame: CGRect(origin: .zero, size: CGSize(width: 595, height: 842))) // A4 size
// Set web view navigation delegate.
// You must keep a strong reference to this, so I made it an #State property on this view
// This is a class that takes bindings so it can update the data and signal completion
self.navigationDelegate = WKWebViewDelegate(wkWebView!, pdfData: $data, isReadyToRenderPDF: $isReadyToRenderPDF)
wkWebView!.navigationDelegate = self.navigationDelegate
// Generate HTML. You could also use a simple HTML string.
// This is just my implementation.
let htmlContent = htmlComposer.makeHTMLString()
// Load HTML into web view
wkWebView!.loadHTMLString(htmlContent, baseURL: nil)
// Now the navigation delegate responds when data is updated.
}
The WKWebView delegate class has this callback method.
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// This ensures following code only runs once, as delegate method is called multiple times.
guard !readyOnce else { return }
// Use javascript to check document ready state (all images and resources loaded)
webView.evaluateJavaScript("document.readyState == \"complete\"") { result, error in
if (error != nil) {
print(error!.localizedDescription)
return
} else if result as? Int == 1 {
self.readyOnce = true
do {
// Create PDF using WKWebView method and assign it to the binding (updates data variable in main view)
self.pdfData = try await webView.pdf()
// Signal to parent view via binding
self.isReadyToRenderPDF = true
} catch {
print(error.localizedDescription)
}
} else {
return
}
}
}
Back in parent view, respond to the change of Boolean value.
.onChange(of: isReadyToRenderPDF) { _ in
isWorking = false
DispatchQueue.main.async {
// This toggle navigation link or sheet displaying PDF View using pdfData variable
isShowingPDFView = true
}
}
Finally, here is a PDFKit PDFView wrapped in UIViewRepresentable.
import SwiftUI
import PDFKit
struct PDFKitRepresentedView: UIViewRepresentable {
let data: Data
init(_ data: Data) {
self.data = data
}
func makeUIView(context: UIViewRepresentableContext<PDFKitRepresentedView>) -> PDFKitRepresentedView.UIViewType {
// Create PDFKit view and document
let pdfView = PDFView()
pdfView.document = PDFDocument(data: data)
pdfView.autoScales = true
pdfView.displaysPageBreaks = true
pdfView.usePageViewController(true, withViewOptions: nil)
pdfView.displayDirection = .horizontal
pdfView.displayMode = .singlePage
return pdfView
}
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<PDFKitRepresentedView>) {
// Not implemented
}
}

SwiftUI - AVPlayerViewController Full Screen on tvOS

I am able to present an AVPlayerViewController from SwiftUI but there is some padding around the video and I would like for it to be full-screen.
From the SwiftUI portion there is the following:
var body: some View {
NavigationView {
List {
ForEach(topicsArray) { topic in
Section(header: Text(topic.title)) {
ForEach(0..<topic.shows.count) { index in
NavigationLink(destination: PlayerView(showID: topic.shows[index])) {
ShowCell(showID: topic.shows[index])
}
.navigationBarTitle("")
.navigationBarHidden(true)
}
}
}
}
.listStyle(GroupedListStyle())
.padding()
}.onAppear(perform: initialDataLoad)
}
The code being called from the NavigationLink that shows the player is:
struct PlayerView: UIViewControllerRepresentable {
var showID:Int
func makeUIViewController(context: Context) -> AVPlayerViewController {
let pv = PlayerViewController()
pv.showID = showID
return pv
}
func updateUIViewController(_ viewController: AVPlayerViewController, context: Context) {
}
}
class PlayerViewController: AVPlayerViewController {
var showID:Int! {
didSet {
setup()
}
}
private var videoLaunch:VideoLaunch!
private func setup() {
videoLaunch = VideoLaunch(showID: showID,
season: nil,
episodeID: nil,
selectedIndex: IndexPath(row: 0, section: 0),
showType: .single,
dataStructure: topics as Any,
screenType: .live)
playVideo()
}
private func playVideo() {
guard let videoURL = self.videoLaunch.getMediaURL() else {print("Problem getting media URL");return}
self.player = AVPlayer(url: videoURL)
self.videoGravity = .resizeAspectFill
self.player?.play()
}
I have tried setting the bounds and using the modalpresentationstyle for fullscreen, but none have had any impact. There is still what looks like a 10 point border around the video.
I was able to solve the issue by inserting the following within the PlayerViewController class.
override func viewDidLayoutSubviews() {
self.view.bounds = UIScreen.main.bounds
}

Change search field's icon

I try to implement search behavior like in Xcode: if you enter something in search field, icon changes color.
I delegate both searchFieldDidStartSearching and searchFieldDidEndSearching to controller and change the image.
The problem is icon's image changes only when window lose it's focus.
class ViewController: NSViewController {
#IBOutlet weak var searchField: NSSearchField!
func searchFieldDidStartSearching(_ sender: NSSearchField) {
print("\(#function)")
(searchField.cell as! NSSearchFieldCell).searchButtonCell?.image = NSImage.init(named: "NSActionTemplate")
}
func searchFieldDidEndSearching(_ sender: NSSearchField) {
print("\(#function)")
(searchField.cell as! NSSearchFieldCell).searchButtonCell?.image = NSImage.init(named: "NSHomeTemplate")
}
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
}
override var representedObject: Any? {
didSet {
// Update the view, if already loaded.
}
}
}
Thanks in advance for any ideas/suggestions.
Although I don't know the reason, it works:
NSApp.mainWindow?.resignMain()
NSApp.mainWindow?.becomeMain()
Here is the whole code:
class MyViewController: NSViewController {
private lazy var searchField: NSSearchField = {
let searchField = NSSearchField(string: "")
if let searchButtonCell = searchField.searchButtonCell {
searchButtonCell.setButtonType(.toggle)
let filterImage = #imageLiteral(resourceName: "filter")
searchButtonCell.image = filterImage.tinted(with: .systemGray)
searchButtonCell.alternateImage = filterImage.tinted(with: .systemBlue)
}
searchField.focusRingType = .none
searchField.bezelStyle = .roundedBezel
searchField.delegate = self
return searchField
}()
...
}
extension MyViewController: NSSearchFieldDelegate {
func searchFieldDidStartSearching(_ sender: NSSearchField) {
sender.searchable = true
}
func searchFieldDidEndSearching(_ sender: NSSearchField) {
sender.searchable = false
}
}
extension NSSearchField {
var searchButtonCell: NSButtonCell? {
(self.cell as? NSSearchFieldCell)?.searchButtonCell
}
var searchable: Bool {
get {
self.searchButtonCell?.state == .on
}
set {
self.searchButtonCell?.state = newValue ? .on : .off
self.refreshSearchIcon()
}
}
private func refreshSearchIcon() {
NSApp.mainWindow?.resignMain()
NSApp.mainWindow?.becomeMain()
}
}
extension NSImage {
func tinted(with color: NSColor) -> NSImage? {
guard let image = self.copy() as? NSImage else { return nil }
image.lockFocus()
color.set()
NSRect(origin: NSZeroPoint, size: self.size).fill(using: .sourceAtop)
image.unlockFocus()
image.isTemplate = false
return image
}
}
I was having the same issue. A simple override fixed this issue for me
extension NSSearchField{
open override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
}
}
As you can see when you click inside the view it's still focussed on the search text field(as you can still type in it after you clicked underneath it). Since the change image is on when it loses focus, you should check if you clicked outside of the text field.
Solve problem by subclassing NSSearchFieldCell and assign this class to field's cell.
You don't even need to subclass NSSearchFieldCell.
When you create your NSSearchField from code, you can do something like this:
if let searchFieldCell = searchField.cell as? NSSearchFieldCell {
let image = NSImage(named: "YourImageName")
searchFieldCell.searchButtonCell?.image = image
searchFieldCell.searchButtonCell?.alternateImage = image // Optionally
}
If you're using storyboards, you can do the same in didSet of your #IBOutlet.

Swift find superview of given class with generics

I guess I'm struggling with generics. I want to create simple UIView extension to find recursively a superview of class passed in the function param. I want the function to return optional containing obviously either nil, or object visible as instance of provided class.
extension UIView {
func superviewOfClass<T>(ofClass: T.Type) -> T? {
var currentView: UIView? = self
while currentView != nil {
if currentView is T {
break
} else {
currentView = currentView?.superview
}
}
return currentView as? T
}
}
Any help much appreciated.
Swift 3/4
This is a more concise way:
extension UIView {
func superview<T>(of type: T.Type) -> T? {
return superview as? T ?? superview.compactMap { $0.superview(of: type) }
}
func subview<T>(of type: T.Type) -> T? {
return subviews.compactMap { $0 as? T ?? $0.subview(of: type) }.first
}
}
Usage:
let tableView = someView.superview(of: UITableView.self)
let tableView = someView.subview(of: UITableView.self)
No need to pass in the type of the class you want (at least in Swift 4.1)…
extension UIView {
func firstSubview<T: UIView>() -> T? {
return subviews.compactMap { $0 as? T ?? $0.firstSubview() as? T }.first
}
}
I'm using this.
// Lookup view ancestry for any `UIScrollView`.
if let scrollView = view.searchViewAnchestors(for: UIScrollView.self) {
print("Found scrollView: \(scrollView)")
}
Extension is really a single statement.
extension UIView {
func searchViewAnchestors<ViewType: UIView>(for viewType: ViewType.Type) -> ViewType? {
if let matchingView = self.superview as? ViewType {
return matchingView
} else {
return superview?.searchViewAnchestors(for: viewType)
}
}
}
With this alternative implementation below, you can actually let the call site determine what type to look for, but I found it somewhat unconventional.
extension UIView {
func searchInViewAnchestors<ViewType: UIView>() -> ViewType? {
if let matchingView = self.superview as? ViewType {
return matchingView
} else {
return superview?.searchInViewAnchestors()
}
}
}
You can call it like this.
if let scrollView: UIScrollView = view.searchInViewAnchestors() {
print("Found scrollView: \(scrollView)")
}