-
-
Save kovs705/2e034e9feebc0acbf8eea2b962986018 to your computer and use it in GitHub Desktop.
A SwiftUI View to display SVGs using WKWebView
This file contains 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. | |
// Modified by Kovs705 on 04.10.2023 | |
// 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. | |
*/ | |
// MARK: SVGWebView | |
public struct SVGWebView: View { | |
private let svg: String | |
public init(svg: String) { | |
print("public init stuff") | |
self.svg = svg | |
} | |
public var body: some View { | |
WebView(html: | |
// "<div style=\"width: 100%; height: 100%;\">\(rewriteSVGSize(svg))</div>" | |
"<html><body><div style=\"width: 100%; height: 100%;\">\(rewriteSVGSize(svg))</div></body></html>" | |
) | |
} | |
/// 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) | |
} | |
// MARK: - WebView | |
private struct WebView : UIViewRepresentable { | |
@State private var isLoading = true | |
let html : String | |
let loadingView: UIActivityIndicatorView = { | |
let indicator = UIActivityIndicatorView(style: .medium) | |
indicator.translatesAutoresizingMaskIntoConstraints = false | |
return indicator | |
}() | |
private func makeWebView() -> WKWebView { | |
let prefs = WKPreferences() | |
let webPagePref = WKWebpagePreferences() | |
let userContentController = WKUserContentController() | |
prefs.javaScriptCanOpenWindowsAutomatically = false | |
let config = WKWebViewConfiguration() | |
config.preferences = prefs | |
config.defaultWebpagePreferences = webPagePref | |
config.allowsAirPlayForMediaPlayback = false | |
config.userContentController = userContentController | |
let script = WKUserScript(source: Constants.source, injectionTime: .atDocumentEnd, forMainFrameOnly: true) | |
userContentController.addUserScript(script) | |
let webView = WKWebView(frame: .zero, configuration: config) | |
let coordinator = makeCoordinator() | |
webView.navigationDelegate = coordinator | |
webView.scrollView.isScrollEnabled = false | |
webView.loadHTMLString(html, baseURL: nil) | |
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) | |
} | |
func makeUIView(context: Context) -> WKWebView { | |
let webView = makeWebView() | |
webView.addSubview(loadingView) | |
loadingView.centerXAnchor.constraint(equalTo: webView.centerXAnchor).isActive = true | |
loadingView.centerYAnchor.constraint(equalTo: webView.centerYAnchor).isActive = true | |
if isLoading { | |
loadingView.startAnimating() | |
loadingView.isHidden = false | |
} else { | |
loadingView.stopAnimating() | |
loadingView.isHidden = true | |
} | |
webView.navigationDelegate = context.coordinator | |
return webView | |
} | |
func updateUIView(_ webView: WKWebView, context: Context) { | |
if isLoading { | |
loadingView.startAnimating() | |
loadingView.isHidden = false | |
} else { | |
loadingView.stopAnimating() | |
loadingView.isHidden = true | |
} | |
updateWebView(webView, context: context) | |
} | |
func makeCoordinator() -> Coordinator { | |
Coordinator(isLoading: $isLoading) | |
} | |
} | |
class Coordinator: NSObject, WKNavigationDelegate { | |
@Binding var isLoading: Bool | |
init(isLoading: Binding<Bool>) { | |
_isLoading = isLoading | |
} | |
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { | |
isLoading = false | |
} | |
} | |
} | |
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) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I modified original SVGWebView by helje5 with loading indicator before the svg appears + disabled zooming since I saw the comment by misteu