-
-
Save helje5/941f076a2f73a6bad0ba87eb4f67f229 to your computer and use it in GitHub Desktop.
// Created by Helge Heß on 06.04.21. | |
// Also available as a package: https://github.com/ZeeZide/SVGWebView | |
import SwiftUI | |
import WebKit | |
/** | |
* Display an SVG using a `WKWebView`. | |
* | |
* Used by [SVG Shaper for SwiftUI](https://zeezide.de/en/products/svgshaper/) | |
* to display the SVG preview in the sidebar. | |
* | |
* This patches the XML of the SVG to fit the WebView contents. | |
* | |
* IMPORTANT: On macOS `WKWebView` requires the "outgoing internet connection" | |
* entitlement to operate, otherwise it'll show up blank. | |
* Xcode Previews do not work quite right with the iOS variant, best to test in | |
* a real simulator. | |
*/ | |
public struct SVGWebView: View { | |
private let svg: String | |
public init(svg: String) { self.svg = svg } | |
public var body: some View { | |
WebView(html: | |
"<div style=\"width: 100%; height: 100%;\">\(rewriteSVGSize(svg))</div>" | |
) | |
} | |
/// A hacky way to patch the size in the SVG root tag. | |
private func rewriteSVGSize(_ string: String) -> String { | |
guard let startRange = string.range(of: "<svg") else { return string } | |
let remainder = startRange.upperBound..<string.endIndex | |
guard let endRange = string.range(of: ">", range: remainder) else { | |
return string | |
} | |
let tagRange = startRange.lowerBound..<endRange.upperBound | |
let oldTag = string[tagRange] | |
var attrs : [ String : String ] = { | |
final class Handler: NSObject, XMLParserDelegate { | |
var attrs : [ String : String ]? | |
func parser(_ parser: XMLParser, didStartElement: String, | |
namespaceURI: String?, qualifiedName: String?, | |
attributes: [ String : String ]) | |
{ | |
self.attrs = attributes | |
} | |
} | |
let parser = XMLParser(data: Data((string[tagRange] + "</svg>").utf8)) | |
let handler = Handler() | |
parser.delegate = handler | |
guard parser.parse() else { return [:] } | |
return handler.attrs ?? [:] | |
}() | |
if attrs["viewBox"] == nil && | |
(attrs["width"] != nil || attrs["height"] != nil) | |
{ // convert to viewBox | |
let w = attrs.removeValue(forKey: "width") ?? "100%" | |
let h = attrs.removeValue(forKey: "height") ?? "100%" | |
let x = attrs.removeValue(forKey: "x") ?? "0" | |
let y = attrs.removeValue(forKey: "y") ?? "0" | |
attrs["viewBox"] = "\(x) \(y) \(w) \(h)" | |
} | |
attrs.removeValue(forKey: "x") | |
attrs.removeValue(forKey: "y") | |
attrs["width"] = "100%" | |
attrs["height"] = "100%" | |
func renderTag(_ tag: String, attributes: [ String : String ]) -> String { | |
var ms = "<\(tag)" | |
for ( key, value ) in attributes { | |
ms += " \(key)=\"" | |
ms += value | |
.replacingOccurrences(of: "&", with: "&") | |
.replacingOccurrences(of: "<", with: "<") | |
.replacingOccurrences(of: ">", with: ">") | |
.replacingOccurrences(of: "'", with: "'") | |
.replacingOccurrences(of: "\"", with: """) | |
ms += "\"" | |
} | |
ms += ">" | |
return ms | |
} | |
let newTag = renderTag("svg", attributes: attrs) | |
return newTag == oldTag | |
? string | |
: string.replacingCharacters(in: tagRange, with: newTag) | |
} | |
#if os(macOS) | |
typealias UXViewRepresentable = NSViewRepresentable | |
#else | |
typealias UXViewRepresentable = UIViewRepresentable | |
#endif | |
private struct WebView : UXViewRepresentable { | |
let html : String | |
private func makeWebView() -> WKWebView { | |
let prefs = WKPreferences() | |
#if os(macOS) | |
if #available(macOS 10.5, *) {} else { prefs.javaEnabled = false } | |
#endif | |
if #available(macOS 11, *) {} else { prefs.javaScriptEnabled = false } | |
prefs.javaScriptCanOpenWindowsAutomatically = false | |
let config = WKWebViewConfiguration() | |
config.preferences = prefs | |
config.allowsAirPlayForMediaPlayback = false | |
if #available(macOS 10.5, *) { | |
let pagePrefs : WKWebpagePreferences = { | |
let prefs = WKWebpagePreferences() | |
prefs.preferredContentMode = .desktop | |
if #available(macOS 11, *) { | |
prefs.allowsContentJavaScript = false | |
} | |
return prefs | |
}() | |
config.defaultWebpagePreferences = pagePrefs | |
} | |
let webView = WKWebView(frame: .zero, configuration: config) | |
#if !os(macOS) | |
webView.scrollView.isScrollEnabled = false | |
#endif | |
webView.loadHTMLString(html, baseURL: nil) | |
// Sometimes necessary to make things show up initially. No idea why. | |
DispatchQueue.main.async { | |
let old = webView.frame | |
webView.frame = .zero | |
webView.frame = old | |
} | |
return webView | |
} | |
private func updateWebView(_ webView: WKWebView, context: Context) { | |
webView.loadHTMLString(html, baseURL: nil) | |
} | |
#if os(macOS) | |
func makeNSView(context: Context) -> WKWebView { | |
return makeWebView() | |
} | |
func updateNSView(_ webView: WKWebView, context: Context) { | |
updateWebView(webView, context: context) | |
} | |
#else // iOS etc | |
func makeUIView(context: Context) -> WKWebView { | |
return makeWebView() | |
} | |
func updateUIView(_ webView: WKWebView, context: Context) { | |
updateWebView(webView, context: context) | |
} | |
#endif | |
} | |
} | |
struct SVGWebView_Previews : PreviewProvider { | |
static var previews: some View { | |
SVGWebView(svg: | |
""" | |
<svg viewBox="0 0 100 100"> | |
<rect x="10" y="10" width="80" height="80" | |
fill="gold" stroke="blue" stroke-width="4" /> | |
</svg> | |
""" | |
) | |
.frame(width: 300, height: 200) | |
SVGWebView(svg: | |
""" | |
<svg width="120" height="120" version="1.1" xmlns="http://www.w3.org/2000/svg"> | |
<defs> | |
<linearGradient id="Gradient1"> | |
<stop offset="0%" stop-color="red"/> | |
<stop offset="50%" stop-color="black" stop-opacity="0"/> | |
<stop offset="100%" stop-color="blue"/> | |
</linearGradient> | |
</defs> | |
<rect x="10" y="10" rx="15" ry="15" width="100" height="100" | |
fill="url(#Gradient1)" /> | |
</svg> | |
""") | |
.frame(width: 200, height: 200) | |
} | |
} |
This is intended as a no-dependency option, to avoid linking a 3rd party SVG framework. If that's no problem, there are nice options for the latter:
- https://github.com/IconJar/IJSVG (used that initially, very nice, not too large)
- https://github.com/SVGKit/SVGKit/ (didn't try this, but supposed to be good)
Finally !!!! been searching for a mac solution for a week
On macOS, make sure to enable the "Outgoing Internet Connection" (Client)" entitlement in the sandbox settings, WKWebView requires this even if no outgoing Internet access is done.
I sat here for one hour wondering why I don't see anything rendered in my macOS WebView. Thanks for the hint!
Yeah, that is a very weird bug in WebKit.
Thanks, seems to work so far!
I tried SwiftDraw before (see https://github.com/swhitty/SwiftDraw). It rasters nicely and fast without using any webview but it's a really huge library :/
Some things maybe worth mentioning to anyone trying to use the solution above, i.e. SVGWebView
:
- make sure to not use to many of these on one view or it becomes incredibly slow
- Consider using a
Lazy
views of SwiftUI - at least on iOS you can zoom into the webview (or did I accidentally removed some code?), here is a small script you can inject to disable zooming: https://stackoverflow.com/a/41741125/1898677
- You most probably want to have
.clear
background. This can be achieved by setting
webView.isOpaque = false
webView.backgroundColor = .clear
Still unsure if I should use this or a similar solution in production. In general it is seems either rather laggy (in a VStack
) or it's a little bumpy when used in a LazyVStack
(because it seems to reload to often). Maybe the latter can be solved by somehow cache stuff, e.g. making snapshots or something.
On macOS, make sure to enable the "Outgoing Internet Connection" (Client)" entitlement in the sandbox settings, WKWebView requires this even if no outgoing Internet access is done.
Example usage in SVG Shaper for SwiftUI (it is the View displaying the SVG in the upper left):

Note: SVG Shaper is for converting SVGs to SwiftUI source code (which then gets compiled).
SVGWebView
is for displaying SVG resources (e.g. loaded from a bundle or the web) at runtime. They serve different purposes.