Created
May 10, 2021 12:54
-
-
Save helje5/941f076a2f73a6bad0ba87eb4f67f229 to your computer and use it in GitHub Desktop.
A SwiftUI View to display SVGs using WKWebView
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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) | |
} | |
} |
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.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Yeah, that is a very weird bug in WebKit.