---
title: 'The WebView-Based Approach'
source: 'https://academia.sh/en/courses/cross-platform-approaches/webview-based-approach'
course: 'Cross-Platform Approaches'
language: en
updated: '2026-10-21T05:02:50+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "Measures why the function that draws a page's list row comes out shared on both platforms"
    - 'Shows that the safe area inset the page reads from the shell turns the sharing from shared to branched'
    - 'Distinguishes that the sharing follows the input the shell provides, not the page code'
---

# The WebView-Based Approach

The WebView-based approach draws the interface from a single page and places the page inside a native shell on each platform. The lesson measures the sharing of this mechanism and shows how the input the page takes from the shell changes the sharing.

The previous lesson introduced the four sharings and taught how to classify how a feature's code stands on two platforms. This lesson runs the first approach through that classification: the WebView-based approach. The question of the course does not change: **how is this feature's code shared between the platforms?** And the lesson asks this question of a mechanism.

A WebView is a view placed inside a native app that displays a web page. The app's interface is this page: the list, the form, and the button are drawn inside the page. The native part surrounding the page is called the host shell. The shell carries the app's launch, the window, the package that goes to the app store, and the channel that talks to the page. This mechanism is also called a hybrid app. The page is written once and loaded on both platforms; the shell is written separately on each platform but kept thin.

This mechanism piles most of the sharing onto the page. Every function in the page is the same function on both platforms, because both shells load the same file. The measure of the lesson is to find where this breaks.

## The Page's Own Code

The first function to read is the one that draws a list row. A row carries a title, a count, and an index; from these the function produces a row to be written into the page. The same page also builds the message to be sent to the shell when the row is tapped. Both functions look only at their inputs and do not read the platform.

**AP1.** The model builds this sharing's rule; the model does not run a WebView or a page. In the model, the page code is the same function object on both platforms, and a row is a plain object with a title, a count, an index, and a pinned flag.

```javascript
// sharing.mjs — classifies how a feature's code is shared between the platforms
export const PLATFORMS = ["ios", "android"];

export class Sharing {
  constructor(label, impl, samples) {
    this.label = label;
    this.total = samples.length;
    this.missing = PLATFORMS.filter((p) => typeof impl[p] !== "function");
    const present = PLATFORMS.filter((p) => !this.missing.includes(p));
    this.code = new Set(present.map((p) => impl[p])).size;
    if (this.missing.length > 0) {
      this.differ = null;
      this.verdict = "single";
      return;
    }
    this.differ = samples.filter((s) => {
      const outs = PLATFORMS.map((p) => JSON.stringify(impl[p](s, p)));
      return new Set(outs).size > 1;
    }).length;
    if (this.code > 1) this.verdict = "split";
    else this.verdict = this.differ === 0 ? "shared" : "branched";
  }

  line() {
    const detail = this.differ === null
      ? `missing=${this.missing.join(",")}`
      : `diff=${this.differ}/${this.total}`;
    console.log(`${this.label}: code=${this.code} | ${detail} -> ${this.verdict}`);
  }
}

export function countSharing(items) {
  for (const name of ["shared", "branched", "split", "single"]) {
    console.log(`${name}: ${items.filter((s) => s.verdict === name).length}`);
  }
}
```

The measurement below tests two features with four rows.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";

function renderRow(row) {
  return `<li>${row.title} (${row.count})</li>`;
}

function encodeMessage(row) {
  return JSON.stringify({ type: "open", id: row.index });
}

const rows = [
  { index: 0, title: "Coffee", count: 2, pinned: false },
  { index: 1, title: "Weekly budget", count: 7, pinned: true },
  { index: 2, title: "Shopping list", count: 0, pinned: false },
  { index: 3, title: "Annual plan", count: 12, pinned: false },
];

const features = [
  new Sharing("row-rendering", { ios: renderRow, android: renderRow }, rows),
  new Sharing("message-format", { ios: encodeMessage, android: encodeMessage }, rows),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
row-rendering: code=1 | diff=0/4 -> shared
message-format: code=1 | diff=0/4 -> shared
shared: 2
branched: 0
split: 0
single: 0
```

Both features came out shared. `code=1` says the two shells run the same function, and `diff=0/4` says the four rows are drawn the same on both. This is the purest form of the approach's promise: the format of the row is written in one place, tested in one place, and goes to both platforms together. A bug fix takes effect on both platforms at the same time.

The page reaches the shell as follows. The piece below shows three functions of the page; the shell carries the native code that receives the message and writes the safe area inset into the page.

```html
<!-- Platform code: page -->
<ul id="rows"></ul>
<script>
  function showRows(rows) {
    const items = rows.map((r) => {
      const item = document.createElement("li");
      item.textContent = `${r.title} (${r.count})`;
      return item;
    });
    document.getElementById("rows").replaceChildren(...items);
  }
  function setInsets(top, bottom) {
    document.body.style.paddingTop = `${top}px`;
    document.body.style.paddingBottom = `${bottom}px`;
  }
  function openRow(id) {
    const message = JSON.stringify({ type: "open", id: id });
    (window.webkit?.messageHandlers.host ?? window.Host).postMessage(message);
  }
</script>
```

This piece is not measured; the model does not run the page itself. The row title is data that comes from the server, and `textContent` writes it as text, not as markup. On a page connected to a bridge this distinction matters: a title interpreted as markup can run a script on the page and send a message to the shell over the bridge. The last line of the piece, however, shows a detail that matters for the lesson: the way of delivering the message to the shell differs on the two platforms, and the page separates this inside itself. This distinction concerns not the content of the message but the delivery channel; `message-format` stays shared, because what the rule looks at is the message text. The difference in the channel stays outside the measurement and is stated in the lesson text as a maintenance cost.

The part of the shell on the iOS side that writes the inset into the page looks like this.

```swift
// Platform code: HostViewController
import WebKit

@MainActor
func pushInsets(to webView: WKWebView) {
    let insets = webView.safeAreaInsets
    webView.evaluateJavaScript("setInsets(\(insets.top), \(insets.bottom))")
}
```

This piece is not measured either. What it shows is that the shell gives the page a number; the page itself decides what to do with that number. Here `setInsets` sets the page's top and bottom padding to that inset.

## The Input the Shell Provides

The page cannot do everything with its own knowledge. Information such as the notch and bottom-bar inset of the screen is known by the device's window. A WebView can pass part of this inset to the page itself; in this model, however, the inset is an input that comes from the shell. The shell gives this inset to the page, and the page places a pinned row below the inset.

**AP2.** In the model, the safe area inset is a fixed table in the file `shell.mjs`: for iOS the top inset is 47 and the bottom inset is 34; for Android the top inset is 24 and the bottom inset is 0. These numbers are inputs of the model, not the measure of a device; the real inset is the value the shell reports on every call, depending on the device and the orientation.

```javascript
// shell.mjs — the safe area inset the shell gives to the page
export function safeInset(platform) {
  return platform === "ios"
    ? { top: 47, bottom: 34 }
    : { top: 24, bottom: 0 };
}

export function flatInset() {
  return { top: 0, bottom: 0 };
}
```

```javascript
// page.mjs — the page's function that places a row takes the shell's inset from outside
export function makePlacer(insetOf) {
  return function placeRow(row, platform) {
    const y = row.pinned ? insetOf(platform).top : row.index * 40;
    return `${row.title}:${y}`;
  };
}
```

`makePlacer` takes a shell function and returns the function that places the row. A pinned row sits below the inset, and the other rows are positioned in order. The same page code is measured on two platforms.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";
import { safeInset } from "./shell.mjs";
import { makePlacer } from "./page.mjs";

function renderRow(row) {
  return `<li>${row.title} (${row.count})</li>`;
}

const placeRow = makePlacer(safeInset);

const rows = [
  { index: 0, title: "Coffee", count: 2, pinned: false },
  { index: 1, title: "Weekly budget", count: 7, pinned: true },
  { index: 2, title: "Shopping list", count: 0, pinned: false },
  { index: 3, title: "Annual plan", count: 12, pinned: false },
];

const features = [
  new Sharing("row-rendering", { ios: renderRow, android: renderRow }, rows),
  new Sharing("row-placement", { ios: placeRow, android: placeRow }, rows),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
row-rendering: code=1 | diff=0/4 -> shared
row-placement: code=1 | diff=1/4 -> branched
shared: 1
branched: 1
split: 0
single: 0
```

`row-placement` is branched. Because `code=1`, the page code is the same on both platforms; but on the single pinned row the results of the two platforms differ, because the shell gives a different inset on each platform. The other three rows are positioned in order and produce no difference. So of the four samples, only one triggered the branch.

What deserves notice here is that the branch is not in the page's code. `makePlacer` never says "if the platform is this, do that". The platform is carried by the shell's answer, and the page uses it as it is. For this reason the sharing follows not the page code but the input the shell provides.

## Common Misconceptions

Three misconceptions circle around the WebView-based approach, and the measurement gives a concrete answer to each.

The first is the idea that "a single page means a single sharing". The page code being single gives `code=1`, but whether the sharing is shared or branched is decided by the `diff` value. In the measurement above the page was single and yet one feature was branched. Single code does not guarantee that the feature behaves the same on both platforms; it guarantees only that the code is written in one place.

The second is the idea that "the shell can be ignored". Even if the shell is thin, it is written separately on each platform. The code that reads the inset, receives the message, and loads the page is two separate pieces of native code on two platforms. If the shell's functions were classified as features on their own, most would come out split. The approach's promise is not to reduce these splits to zero but to make them smaller: the number of splits stays low and each one stays small, but what ensures this is the shell's own discipline.

The third is the idea that "every capability called from the page is shared like the page". The page requests a capability such as the camera, a permission, or a file through a message; the line that builds the request is shared. The code that fulfills the capability, however, sits in the shell, written separately on each platform, and may be missing entirely on one platform. For the same capability, the call in the page is shared, the counterpart in the shell is split, and if it is missing on one platform it is single. Saying "this feature is shared" without saying which line was measured is an incomplete sentence. The native module need topic handles this distinction separately.

What the three misconceptions have in common is confusing the promise of the mechanism with the name of a sharing. The mechanism gathers the code in one place; whether the code behaves the same for every input depends on the inputs the shell provides being the same.

## The Bounding Measurement

This inference can be tested. We take the same page code, the same four rows, and the same two platforms; we change only the shell. The second shell gives zero inset on both platforms.

```javascript
// main.mjs
import { Sharing, countSharing } from "./sharing.mjs";
import { safeInset, flatInset } from "./shell.mjs";
import { makePlacer } from "./page.mjs";

const withInset = makePlacer(safeInset);
const withoutInset = makePlacer(flatInset);

const rows = [
  { index: 0, title: "Coffee", count: 2, pinned: false },
  { index: 1, title: "Weekly budget", count: 7, pinned: true },
  { index: 2, title: "Shopping list", count: 0, pinned: false },
  { index: 3, title: "Annual plan", count: 12, pinned: false },
];

const features = [
  new Sharing("placement-with-inset", { ios: withInset, android: withInset }, rows),
  new Sharing("placement-without-inset", { ios: withoutInset, android: withoutInset }, rows),
];
features.forEach((f) => f.line());
countSharing(features);
```

```text
placement-with-inset: code=1 | diff=1/4 -> branched
placement-without-inset: code=1 | diff=0/4 -> shared
shared: 1
branched: 1
split: 0
single: 0
```

The page code did not change; `makePlacer` is the same function in both measurements, and `code=1` came out in both. When the shell gave an inset the sharing came out branched, and when it gave none the sharing came out shared. The two measurements are not two separate pages; they are the same page measured with two separate shells.

In approach selection this result means the following. In the WebView-based approach, keeping the page code single is not enough; every input the shell gives the page affects the sharing. As inputs such as the safe area inset increase, branched features increase. The rule shows this but does not show what it costs: the cost of reading an inset wrongly is visible only on the device concerned, and testing the page does not catch it.

Another limit of the approach is the shell itself. The package that goes to the app store, permission requests, and native capabilities sit in the shell. These are classified separately as features outside the page; they are taken up again in the following lessons and in the platform realities topic.

## Summary

- This lesson's sharing: shared. The function that draws a page's list row is the same function on both platforms and does not read the platform.
- The WebView-based approach draws the interface from a single page; the shell is a separate, thin native piece on each platform.
- When the page read the safe area inset from the shell, the same page code came out branched: of four samples, only the pinned row differed.
- The same page code came out shared with a shell that gives no inset: the sharing follows the input the shell provides, not the page code.
- The rule sees how the sharing is structured, not what it costs; the cost of every inset read from the shell is stated in the lesson text.

## Next Step

In this lesson the interface was drawn from a single page, and the page talked only to the shell. The second mechanism does not draw the interface in a page; it maps a tree produced by shared code onto the native components of each platform. The next lesson, `The Bridge-Based Approach`, measures the sharing of this mapping.
