This guide explains how to host Sportradar web widgets inside a native iOS app using SwiftUI and WKWebView.
The integration pattern is reusable for all widgets. The only widget-specific parts are:
SIR("addWidget", ...)onTrack or onItemClickHow you assemble the HTML page is up to you. Follow the Sportradar web / JavaScript widget documentation for the loader bootstrap and SIR("addWidget", ...) call. On iOS, host that page with SportradarWidgetView, which provides layout helpers and JavaScript communication.
This tutorial is for:
WKWebView, UIViewRepresentable, and JavaScript bridgesBy completing this tutorial, you will:
SportradarWidgetView) around WKWebViewSIR(...) without reloadingBefore starting, ensure you have:
addWidget, updateWidget, setClientTheme, and related methods)The generic integration flow is:
Implement SportradarWidgetView with a message bridge, height observer, and optional JavaScript bridge.
Follow the Sportradar JS widget docs for the widgetloader bootstrap and SIR("addWidget", ...) call.
Pass the HTML into SportradarWidgetView and let the page load the loader / call SIR("addWidget", ...).
Receive widget events and height via srBridge, and optionally call SIR(...) from Swift without reloading.
The following pieces are enough for an iOS integration:
SportradarWidgetViewonMessage for height and widget callbacksonBridgeReady / SrJavaScriptBridge for Swift → JSYou may choose to add code that builds widget HTML from parameters or automatically injects the Sportradar loader bootstrap. Those helpers are useful, but they are not required for a basic implementation.
SportradarWidgetView / SrJavaScriptBridgesrBridge message handlingHow you store or generate the HTML is an app concern, not part of the host.
The steps below walk through recreating SportradarWidgetView. Capabilities are added progressively. The full host reference shows the finished combined implementation. After the host is complete, Using SportradarWidgetView covers widget HTML and consumer usage.
SportradarWidgetView is a reusable SwiftUI wrapper around WKWebView that should:
loadHTMLString and base URL https://widgets.sir.sportradar.com/ (so relative loader/assets resolve)srBridge; any name works if Swift and JS agree)ResizeObserver and report height through the same handlerSrJavaScriptBridge for evaluating JavaScript (Swift → JS)srBridge in these examples is just the JavaScript bridge name exposed by iOS through WKScriptMessageHandler. It is not a required Sportradar name. You can rename it, as long as the Swift side and JavaScript side use the same name consistently.
Start with a host that only loads HTML into a WKWebView. No bridges yet — just enough to render a Sportradar widget page inside SwiftUI.
import SwiftUI
import WebKit
struct SportradarWidgetView: UIViewRepresentable {
let html: String
let disableScroll: Bool
private static let baseURL = URL
Key points:
https://widgets.sir.sportradar.com/ so relative loader/assets resolve correctlydisableScroll: true together with an explicit .frame(height:) when embedding inside NavigationStack, ScrollView, or List (see Step 6)Register a WKScriptMessageHandler so JavaScript can post into Swift via window.webkit.messageHandlers.srBridge. Swift forwards each payload to an onMessage closure.
This guide chooses:
"srBridge"type key on every payload, with branching on type in onMessageExtend the Step 1 host:
Coordinator conform to WKScriptMessageHandlerWKUserContentController in makeUIViewmessage.body to an onMessage closuredismantleUIView to avoid retain leaksstruct SportradarWidgetView: UIViewRepresentable
Always remove the message handler in dismantleUIView. Leaving it registered can cause retain cycles and crashes when the view is destroyed.
After this step, JS can send messages into Swift. Widget callbacks still post through the same srBridge handler — see Step 7 for onTrack / onItemClick examples.
Height reporting is necessary so SwiftUI can constrain the web view to the widget’s actual content height. Without a reported height, a WKWebView in a flexible layout typically expands to fill all available vertical space. That fights nested scroll UIs (ScrollView, List, NavigationStack): you usually want the web view sized to the widget and the native container to scroll.
With disableScroll: true and .frame(height: reportedHeight), the host matches the widget instead of stretching full-height. Consumer-side sizing is covered in Step 6.
WKWebView does not tell SwiftUI how tall its HTML content is, so the page must measure itself and send the value into Swift over srBridge. installHeightObserverJS is a JavaScript string you inject once via WKUserScript. It is not Sportradar-specific API — you write it. The script’s job is:
postMessage the height through srBridgeThis guide observes [data-sr-height-root] and falls back to document.body if that attribute is missing. Mark the mount in your widget HTML (see Step 5):
<div id="sr-widget" data-sr-height-root></div>Keep the widget HTML minimal and let the shared iOS host inject the height observer.
Construct the script from small pieces:
1. Resolve the element
function heightRoot() {
return document.querySelector("[data-sr-height-root]") || document.body;
}2. Measure and post — read layout height and send a JSON-friendly payload Swift can parse (type: "height" so onMessage can branch like other messages):
function postHeight() {
var el = heightRoot();
var height = Math.ceil(el.getBoundingClientRect().height);
if (height > 0 && window.webkit && window.webkit.messageHandlers.srBridge) {
window.webkit.messageHandlers.
3. Observe changes — attach a ResizeObserver and post once immediately so Swift gets an initial height:
new ResizeObserver(postHeight).observe(heightRoot());
postHeight();4. Wrap in an IIFE — keep locals off window:
(function() {
// heightRoot, postHeight, ResizeObserver…
})();private static let installHeightObserverJS = """
(function() {
function heightRoot() {
return document.querySelector("[data-sr-height-root]") || document.body;
}
function postHeight() {
var el = heightRoot();
var height = Math.ceil(el.getBoundingClientRect().height);
if (height > 0 && window.webkit && window.webkit.messageHandlers.srBridge) {
window.webkit.messageHandlers.srBridge.postMessage({
type: "height",
height: height
});
}
}
new ResizeObserver(postHeight).observe(heightRoot());
Inject it at document end in makeUIView (alongside the message handler):
configuration.userContentController.addUserScript(
WKUserScript(
source: Self.installHeightObserverJS,
injectionTime: .atDocumentEnd,
forMainFrameOnly: true
)
).atDocumentEnd runs after the DOM is available so querySelector can find the height root. avoids installing the observer in iframes.
Posted payloads look like:
{ "type": "height", "height": 420 }To call SIR(...) from Swift without rebuilding HTML, wrap WKWebView.evaluateJavaScript in a small bridge and hand it to the consumer once the web view exists.
final class SrJavaScriptBridge {
weak var webView: WKWebView?
func evaluate(_ javaScript: String, completion: ((Any?, Error?) -> Void)? = nil) {
DispatchQueue.main.async {
self.webView?.evaluateJavaScript(javaScript, completionHandler
Wire it into the host:
SrJavaScriptBridge on the coordinatorbridge.webView = webView in makeUIViewonBridgeReady: ((SrJavaScriptBridge) -> Void)? and call it on the next main-queue turn (mutations during makeUIView are often dropped, so deferring lets @State assignment work)var onBridgeReady: ((SrJavaScriptBridge) -> Void)?
// In Coordinator:
let bridge = SrJavaScriptBridge()
// In makeUIView, after creating the web view:
context.coordinator.bridge.webView = webView
let bridge = context.coordinator.bridge
DispatchQueue.main.async {
onBridgeReady?(bridge)
}When Steps 1–4 are combined, the surface looks like:
init(
html: String,
disableScroll: Bool = false,
onMessage: ((Any) -> Void)? = nil,
onBridgeReady: ((SrJavaScriptBridge) -> Void)? = nil
)Reference implementation of the finished host after Steps 1–4. Use it to check your own recreation:
With the host in place, build widget HTML and wire it into your SwiftUI UI.
Use Sportradar’s web widget documentation for:
widgetloader bootstrap for your client IDSIR("addWidget", selector, widgetName, props)See Getting Started and the SIR API for the authoritative JavaScript patterns.
A minimal page shape:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
html, body { margin: 0; padding: 0; }
Notes for iOS hosting:
data-sr-height-root on the element that should drive native auto-height (usually the mount div). Its height is reported (and updated) via onMessage. If omitted, height falls back to document.body.Replace YOUR_CLIENT_ID in the widgetloader URL with your actual Client ID before shipping.
Pass the HTML into the finished SportradarWidgetView, size from reported height, and optionally keep the JS bridge:
@State private var widgetHeight: CGFloat = 1
@State private var jsBridge: SrJavaScriptBridge?
SportradarWidgetView(
html: myWidgetHTML,
disableScroll: true,
onMessage: { message in
if let height = contentHeight(from: message) {
Use disableScroll: true when the widget sits inside a native scroll container (NavigationStack, ScrollView, List).
Parse height updates from onMessage and apply .frame(height:):
private func contentHeight(from message: Any) -> CGFloat? {
guard
let dict = message as? [String: Any],
dict["type"] as? String == "height",
let number = dict["height"] as? NSNumber
else { return
When the JS docs say to attach callbacks such as onTrack or onItemClick, forward them into native code through the message handler you registered (this guide uses srBridge):
onTrack: function(eventName, eventData) {
if (window.webkit && window.webkit.messageHandlers.srBridge) {
window.webkit.messageHandlers.srBridge.postMessage({
type: "onTrack",
eventName: eventName,
eventData: eventData
}
Payloads must be JSON-serializable. For nested objects, stringify in JS:
onItemClick: function(target, data) {
if (window.webkit && window.webkit.messageHandlers.srBridge) {
window.webkit.messageHandlers.srBridge.postMessage({
type: "onItemClick",
target: String(target),
data: JSON.
Handle those messages in the same onMessage closure used for height. Branch on type:
onMessage: { message in
guard let dict = message as? [String: Any],
let type = dict["type"] as? String else { return }
switch type {
case "onTrack":
print("onTrack", dict["eventName"] ?? ""
This pattern stays the same for all widgets:
postMessage to your message handler (for example srBridge) with a typetype (or equivalent) in SwiftOnly the callback name and remaining payload fields change per widget.
Learn more about tracking in the Widget Tracking & Analytics Guide.
Keep the HTML string stable and update via the bridge from onBridgeReady:
jsBridge?.evaluate("""
SIR("updateWidget", "#sr-widget", { matchId: \(matchID) });
""")
jsBridge?.evaluate("SIR(\"setClientTheme\", \"sportradardark\");")
// SIR("changeLanguage", "de")Wait for onBridgeReady before evaluating. Prefer these API calls over regenerating the HTML string (which reloads the loader and is slower).
Swapping widgets should not require changing SportradarWidgetView. Change only the HTML you pass in (and how you interpret onMessage).
The SwiftUI host, height observer, and bridge remain the same.
SportradarWidgetView, srBridge, height reporting, and the base URL stay shared across every widget.
Widget name, props, loader options, and callbacks live in the HTML / props layer only.
Use disableScroll: true and .frame(height:) from srBridge height messages inside native scroll containers.
Call SIR(...) through SrJavaScriptBridge instead of regenerating HTML for live updates.
addWidgetupdateWidget, themes, language, and other methodsonTrack and analytics patternsDependencies are system frameworks only: SwiftUI and WebKit.
At this point you can pass widget HTML into SportradarWidgetView and see it render. The next steps add communication and auto-height.
forMainFrameOnly: trueAfter this step, the host can evaluate JavaScript. Step 8 shows how to call SIR(...) from your app.
| Feature | Behavior |
|---|---|
html | Full HTML via loadHTMLString; reloads only when the string changes |
disableScroll | Disables web-view scroll and bounce |
onMessage | Every srBridge payload (height + widget callbacks) |
onBridgeReady | Fired once after web view creation; provides SrJavaScriptBridge |
| Height observer | Injected script observes [data-sr-height-root] (fallback body) and posts { type: "height", height } via srBridge |
| Base URL | https://widgets.sir.sportradar.com/ |
| Transparent background | Clear web view / scroll view so SwiftUI shows through |
import SwiftUI
import WebKit
/// Evaluates JavaScript in the widget web view (Swift → JS).
final class SrJavaScriptBridge {
weak var webView: WKWebView?
func evaluate(_ javaScript: String, completion: ((Any?, Error?) -> Void)? = nil) {
DispatchQueue.main.async {
self.webView?.evaluateJavaScript(javaScript, completionHandler: completion)
}
}
}
struct SportradarWidgetView: UIViewRepresentable {
/// Handler name exposed to JS as `webkit.messageHandlers.srBridge`.
static let messageHandlerName = "srBridge"
/// Observes `[data-sr-height-root]` (falls back to `body`) and posts `{ type: "height", height }` via `srBridge`.
private static let installHeightObserverJS = """
(function() {
function heightRoot() {
return document.querySelector("[data-sr-height-root]") || document.body;
}
function postHeight() {
var el = heightRoot();
var height = Math.ceil(el.getBoundingClientRect().height);
if (height > 0 && window.webkit && window.webkit.messageHandlers.srBridge) {
window.webkit.messageHandlers.srBridge.postMessage({
type: "height",
height: height
});
}
}
new ResizeObserver(postHeight).observe(heightRoot());
postHeight();
})();
"""
let html: String
let disableScroll: Bool
/// Called when JS posts via `webkit.messageHandlers.srBridge.postMessage(...)`.
var onMessage: ((Any) -> Void)?
/// Provides a bridge for evaluating JavaScript after the web view is created.
var onBridgeReady: ((SrJavaScriptBridge) -> Void)?
private static let baseURL = URL(string: "https://widgets.sir.sportradar.com/")!
init(
html: String,
disableScroll: Bool = false,
onMessage: ((Any) -> Void)? = nil,
onBridgeReady: ((SrJavaScriptBridge) -> Void)? = nil
) {
self.html = html
self.disableScroll = disableScroll
self.onMessage = onMessage
self.onBridgeReady = onBridgeReady
}
func makeCoordinator() -> Coordinator {
Coordinator(onMessage: onMessage)
}
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
// JS → Swift: window.webkit.messageHandlers.srBridge.postMessage(payload)
configuration.userContentController.add(
context.coordinator,
name: Self.messageHandlerName
)
configuration.userContentController.addUserScript(
WKUserScript(
source: Self.installHeightObserverJS,
injectionTime: .atDocumentEnd,
forMainFrameOnly: true
)
)
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.isOpaque = false
webView.backgroundColor = .clear
webView.scrollView.backgroundColor = .clear
webView.scrollView.isScrollEnabled = !disableScroll
webView.scrollView.bounces = !disableScroll
context.coordinator.bridge.webView = webView
// Defer so consumers can safely assign @State (mutations during makeUIView are often dropped).
let bridge = context.coordinator.bridge
DispatchQueue.main.async {
onBridgeReady?(bridge)
}
context.coordinator.load(html, into: webView)
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
context.coordinator.onMessage = onMessage
context.coordinator.load(html, into: webView)
}
static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
uiView.configuration.userContentController
.removeScriptMessageHandler(forName: messageHandlerName)
}
final class Coordinator: NSObject, WKScriptMessageHandler {
let bridge = SrJavaScriptBridge()
var onMessage: ((Any) -> Void)?
private var loadedHTML: String?
init(onMessage: ((Any) -> Void)?) {
self.onMessage = onMessage
}
func load(_ html: String, into webView: WKWebView) {
guard html != loadedHTML else { return }
loadedHTML = html
webView.loadHTMLString(html, baseURL: SportradarWidgetView.baseURL)
}
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard message.name == SportradarWidgetView.messageHandlerName else { return }
onMessage?(message.body)
}
}
}If you want a fallback until the widget reports its real size, start from a small default or a minimum native height and replace it once the first height message arrives.
SIR("addWidget", "#sr-widget", "match.lmtPlus", {
matchId: 61591316,
layout: "single",
onTrack: function(eventName, eventData) {
/* post to srBridge */
}
});