A phone website needs nothing special: <KandloChart/> and
createKandloChart() work in mobile browsers like on the desktop, and the
embed drops the desktop page's minimum size so the chart fits a phone-width
frame.
A native app (React Native, Swift/UIKit or SwiftUI, Kotlin/Java) shows the
chart in a web view: react-native-webview, WKWebView on iOS, WebView on
Android. This page explains how to load the chart there and how the native
code talks to it.
1. Why a host page#
The chart's embed bridge only starts when the chart page is inside a
frame and its URL has embed=1 (API reference).
A web view shows its page at the top level, so loading the chart page
directly gives you the full standalone chart with its own toolbar and no
API: the URL options are not read and no messages are answered.
So the web view loads a small host page that frames the chart and relays the postMessage protocol between the chart and your native code, unchanged:
native code --evaluate JS--> host page (app.example.com) --postMessage--> chart page (charts.example.com)
<--message bridge-- <--postMessage--examples/mobile-webview/kandlo-webview-host.html is that page. It has no
dependencies and does not need @kandlo/react.
2. Set up the host page#
Copy
kandlo-webview-host.htmlto your web server, for examplehttps://app.example.com/kandlo-host.html.Edit
CHART_URLat the top of its script to your chart page,https://charts.example.com/.Add the host page's origin to the chart page's allow-list (API reference):
JavaScriptAppData['embed-allowed-origins'] = ['https://app.example.com'];If the chart page sends
Content-Security-Policy: frame-ancestors, include the host page's origin there too.
The host page forwards these query parameters to the chart as its initial
state: symbol, timeframe, chartType, theme, chrome. Load it as
https://app.example.com/kandlo-host.html?symbol=EURUSD&timeframe=M5&chrome=minimal.
The host page must have an http(s) origin: the chart only accepts an
http(s) parent origin. A page loaded from the app bundle (file://) does not
qualify. If you prefer to ship the host page inside the app, load it as an
HTML string with a base URL on your domain, so that its origin is that
domain (React Native: source={{ html, baseUrl: 'https://app.example.com/' }};
iOS: loadHTMLString(_:baseURL:); Android: loadDataWithBaseURL(...)). This
variant was not tested; the hosted page is the tested path.
3. The message bridge#
The protocol is the one in 03-api-reference.md, section 2. Only the transport between native code and the host page differs per platform.
| Native to chart | Chart to native | |
|---|---|---|
Browser <iframe> (for comparison) |
iframe.contentWindow.postMessage(obj, chartOrigin) |
message event, check event.origin and event.source |
| React Native WebView | webViewRef.current.injectJavaScript("window.kandloHost.send(...); true;") |
window.ReactNativeWebView.postMessage(string) arrives in onMessage as event.nativeEvent.data |
iOS WKWebView |
webView.evaluateJavaScript("window.kandloHost.send(...)") |
window.webkit.messageHandlers.kandlo.postMessage(string) arrives in your WKScriptMessageHandler |
Android WebView |
webView.evaluateJavascript("window.kandloHost.send(...)", null) |
window.KandloAndroid.postMessage(string) arrives in your WebMessageListener (or @JavascriptInterface) |
Differences from the browser case that matter:
- Strings, not objects. Every message from the host page to native code is
a JSON string.
window.kandloHost.send()accepts an object or a JSON string. - You check the sender yourself. In a browser the client checks
event.origin. In a web view, check where a message came from on the native side: the page URL (React Native),frameInfo(iOS) or the origin rules of the listener (Android). The examples below do. - Wait for
ready. The host page queues up to 50 commands until the chart'sreadyevent and then delivers them, so you can send the initial setup straight away. A 51st is dropped with a host error (send: queue full before the chart was ready).readyis also your signal that data is on screen. - Request ids are yours. Keep a map of
idto pending callback and settle it on the reply with the samere. Add your own timeout; the browser client uses 10 seconds. - The host page also sends its own messages
{ "kandloHost": 1, "event": "error", "message": "..." }(for example when it was loaded from a non-http origin, orsendgot something that is not a kandlo message). These have nokandlofield.
React Native (react-native-webview)#
examples/mobile-webview/KandloWebChart.jsx (examples/mobile-webview/KandloWebChart.jsx)
is a ready component. Copy it into your app, npm install react-native-webview,
and set its three constants (HOST_URL, HOST_ORIGIN, CHART_ORIGIN).
import { useRef } from 'react';
import { KandloWebChart } from './KandloWebChart';
export function PriceScreen() {
const chart = useRef(null);
return (
<KandloWebChart
ref={chart}
symbol="EURUSD"
timeframe="M5"
theme="dark-blue"
onEvent={(name, payload) => {
if (name === 'ready') chart.current.request('addIndicator', { type: 'rsi' });
}}
/>
);
}
// Anywhere later:
// const candles = await chart.current.request('getCandles', { count: 100 });
// await chart.current.request('setSymbol', { symbol: 'BTCUSD' });What the component does:
request(type, payload)injectswindow.kandloHost.send(...)into the web view and returns a promise settled by the reply with the same id; a failed command rejects with anErrorwhosecodeis the protocol's error code. No reply in 10 seconds rejects withcode: 'timeout'; unmounting rejects what is still waiting withcode: 'destroyed'.onMessageaccepts messages only while the web view shows a page underHOST_ORIGIN, then routes replies to their request and passes events toonEvent(name, payload). The host page's own errors arrive asonEvent('hostError', msg), with the whole host message{ kandloHost: 1, event: 'error', message }.symbol(defaultEURUSD),timeframe(defaultM5),chartTypeandthemego into the host page's URL as the initial state, withchrome=minimalalways set. Change them later withrequest(); changing the props reloads the web view.
iOS (WKWebView, Swift)#
import WebKit
/// Receives the host page's messages. Kept separate from the view controller:
/// WKUserContentController holds its handlers strongly.
final class KandloMessageHandler: NSObject, WKScriptMessageHandler {
var onMessage: (([String: Any]) -> Void)?
func userContentController(_ controller: WKUserContentController,
didReceive message: WKScriptMessage) {
// Only the host page itself, on your origin.
guard message.frameInfo.isMainFrame,
message.frameInfo.securityOrigin.host == "app.example.com",
let text = message.body as? String,
let data = text.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return }
onMessage?(obj)
}
}
final class KandloChartController: UIViewController {
private let handler = KandloMessageHandler()
private var webView: WKWebView!
private var seq = 0
private var pending: [String: ([String: Any]) -> Void] = [:]
override func viewDidLoad() {
super.viewDidLoad()
let config = WKWebViewConfiguration()
config.userContentController.add(handler, name: "kandlo") // window.webkit.messageHandlers.kandlo
webView = WKWebView(frame: view.bounds, configuration: config)
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(webView)
handler.onMessage = { [weak self] msg in
if let re = msg["re"] as? String, let done = self?.pending.removeValue(forKey: re) {
done(msg) // { ok, result } or { ok: false, error }
} else if let event = msg["event"] as? String, event == "ready" {
self?.send("addIndicator", ["type": "rsi"])
}
}
webView.load(URLRequest(url: URL(string:
"https://app.example.com/kandlo-host.html?symbol=EURUSD&timeframe=M5&chrome=minimal")!))
}
func send(_ type: String, _ payload: [String: Any] = [:],
done: (([String: Any]) -> Void)? = nil) {
seq += 1
let id = "n\(seq)"
if let done = done { pending[id] = done }
let msg: [String: Any] = ["kandlo": 1, "id": id, "type": type, "payload": payload]
guard let data = try? JSONSerialization.data(withJSONObject: msg),
let json = String(data: data, encoding: .utf8) else { return }
webView.evaluateJavaScript("window.kandloHost.send(\(json));") // a JSON object is a valid JS literal
}
deinit {
webView?.configuration.userContentController.removeScriptMessageHandler(forName: "kandlo")
}
}Android (WebView, Kotlin)#
Use WebViewCompat.addWebMessageListener from androidx.webkit. It injects
window.KandloAndroid only into pages whose origin matches the rule you give,
so the chart frame and any other origin cannot call it. (A plain
addJavascriptInterface object is visible to every frame and origin in the
web view; if you must use it, check the sender inside the page.)
import android.webkit.WebView
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import org.json.JSONObject
class KandloChart(private val web: WebView) {
private var seq = 0
private val pending = mutableMapOf<String, (JSONObject) -> Unit>()
var onEvent: ((String, JSONObject?) -> Unit)? = null
fun load(symbol: String = "EURUSD") {
web.settings.javaScriptEnabled = true
web.settings.domStorageEnabled = true // the chart uses localStorage
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
WebViewCompat.addWebMessageListener(
web, "KandloAndroid", setOf("https://app.example.com"),
) { _, message, _, isMainFrame, _ ->
if (!isMainFrame) return@addWebMessageListener
val msg = JSONObject(message.data ?: return@addWebMessageListener)
val re = msg.optString("re", "")
if (re.isNotEmpty()) pending.remove(re)?.invoke(msg)
else onEvent?.invoke(msg.optString("event"), msg.optJSONObject("payload"))
}
}
// Add the listener before loading the page.
web.loadUrl("https://app.example.com/kandlo-host.html?symbol=$symbol&chrome=minimal")
}
/** Call on the main thread. */
fun send(type: String, payload: JSONObject = JSONObject(), done: ((JSONObject) -> Unit)? = null) {
val id = "n${++seq}"
if (done != null) pending[id] = done
val msg = JSONObject().put("kandlo", 1).put("id", id).put("type", type).put("payload", payload)
web.evaluateJavascript("window.kandloHost.send($msg);", null)
}
}4. Web view settings that matter#
| Setting | Why |
|---|---|
| JavaScript on | The chart is a script page. On by default in react-native-webview and WKWebView; set javaScriptEnabled = true on Android. |
| DOM storage on (Android) | The chart reads and writes localStorage. Android's default is off: set domStorageEnabled = true. Running the chart without storage was not tested. |
https everywhere |
Android and iOS refuse plain http content inside an https page by default. Use https for the host page and chart page and wss for the feed. |
| WebGL | The chart draws with WebGL. Current iOS and Android system web views provide it; very old devices may not. |
| Navigation allow-list | If you restrict navigation (React Native originWhitelist, a navigation delegate on iOS, shouldOverrideUrlLoading on Android), allow both the host page's and the chart page's origins. |
The embed bridge removes the desktop page's min-width: 1023px /
min-height: 650px, so the chart follows the web view's size, including
rotation. chrome=minimal hides the chart's toolbar, which otherwise takes a
large part of a phone screen; build the controls in your native UI and send
commands instead.
Inside a web view, cross-site frames can have their storage partitioned or blocked, depending on the platform and its privacy settings. The embedded chart does not need persistent storage (an embed does not save its chart type, timeframe or theme), but check on your target platforms that nothing you rely on is lost.