kandlo Docs

4. Mobile apps (web views)

Last updated · 8 min read

On this page

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:

Text
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#

  1. Copy kandlo-webview-host.html to your web server, for example https://app.example.com/kandlo-host.html.

  2. Edit CHART_URL at the top of its script to your chart page, https://charts.example.com/.

  3. Add the host page's origin to the chart page's allow-list (API reference):

    JavaScript
    AppData['embed-allowed-origins'] = ['https://app.example.com'];
  4. 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's ready event 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). ready is also your signal that data is on screen.
  • Request ids are yours. Keep a map of id to pending callback and settle it on the reply with the same re. 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, or send got something that is not a kandlo message). These have no kandlo field.

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).

JSX
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) injects window.kandloHost.send(...) into the web view and returns a promise settled by the reply with the same id; a failed command rejects with an Error whose code is the protocol's error code. No reply in 10 seconds rejects with code: 'timeout'; unmounting rejects what is still waiting with code: 'destroyed'.
  • onMessage accepts messages only while the web view shows a page under HOST_ORIGIN, then routes replies to their request and passes events to onEvent(name, payload). The host page's own errors arrive as onEvent('hostError', msg), with the whole host message { kandloHost: 1, event: 'error', message }.
  • symbol (default EURUSD), timeframe (default M5), chartType and theme go into the host page's URL as the initial state, with chrome=minimal always set. Change them later with request(); changing the props reloads the web view.

iOS (WKWebView, Swift)#

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.)

Kotlin
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.

Type to search all guides.