Lesson 04 / 10
The Own-Rendering-Engine Approach
The own-rendering-engine approach does not use the platform's native components; it draws the interface onto a canvas with its own engine. The lesson measures the sharing of layout, paint order, and touch resolution, and shows how text width coming from the platform branches the layout.
Published Updated
In this lesson
- Explains how the own-rendering-engine approach handles the canvas, the component tree, and the layout
- Measures why the layout function that computes the position of boxes comes out shared on both platforms
- Shows, in terms of sharing, that the engine resolves touch and scrolling by itself
- Measures that the same layout function comes out branched when the text width comes from the platform's font metrics
Contents
The previous lesson showed that the shared code builds a tree of nodes and that the bridge maps this tree to the platform’s own components; the sharing came out branched, because the identity of the component belonged to the platform. The third mechanism removes this mapping altogether: the platform’s native components are not used. The app draws everything with its own rendering engine onto a single surface that the platform provides, the canvas.
The question of the course does not change: how is this feature’s code shared between the platforms? The rendering engine gives a bold answer to this question. The button, the list, the text field, and the scroll bar are the engine’s own code; the platform has no counterpart for them. For this reason the code can be shared in the widest way.
The mechanism works as follows. The app builds a component tree; each item in the tree is sized and positioned by a layout rule. The engine walks the tree, draws each item onto the canvas, and takes mainly inputs from the platform: touch points, the size of the canvas, and a signal for each frame. Accessibility, the lifecycle, and platform messages are also provided by the platform. Because there is no native component, the look or behavior of the platform’s own button is not used; the engine imitates these itself or draws its own design.
AP1. The model builds this sharing’s rule; the model does not run the rendering engine, the canvas, or a frame. In the model, the engine’s layout function produces rows from the widths of boxes. The container width is 100, and this number is an input of the model; it is not the screen size of a device.
// 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}`); } }
Layout: The Position of Boxes
The simplest form of layout is line wrapping. Boxes line up from left to right in order; if the next box does not fit in the container, a new row starts. The function produces rows from the widths of boxes and never reads the platform. We put the paint order in the same block: layers are sorted by their z value, and the smaller z is painted first.
// main.mjs import { Sharing, countSharing } from "./sharing.mjs"; function layoutBoxes(widths) { const rows = []; let row = []; let used = 0; for (const w of widths) { if (row.length > 0 && used + w > 100) { rows.push(row); row = []; used = 0; } row.push(w); used += w; } rows.push(row); return rows; } function paintOrder(layers) { return [...layers].sort((a, b) => a.z - b.z).map((l) => l.name).join(">"); } const boxSets = [[30, 40, 20], [60, 50], [100], [45, 45, 45]]; const layerSets = [ [{ name: "text", z: 2 }, { name: "background", z: 0 }, { name: "shadow", z: 1 }], [{ name: "button", z: 1 }, { name: "background", z: 0 }, { name: "hint", z: 3 }], [{ name: "list", z: 1 }, { name: "background", z: 0 }, { name: "title", z: 2 }], ]; const features = [ new Sharing("box-placement", { ios: layoutBoxes, android: layoutBoxes }, boxSets), new Sharing("paint-order", { ios: paintOrder, android: paintOrder }, layerSets), ]; features.forEach((f) => f.line()); countSharing(features);
box-placement: code=1 | diff=0/4 -> shared paint-order: code=1 | diff=0/3 -> shared shared: 2 branched: 0 split: 0 single: 0
Both features are shared. For box-placement the output was code=1 and diff=0/4: in none of the four box sets did the two platforms produce different rows. The layout is computed inside the engine, and its only input is the box widths. The same component tree gives the same placement on both platforms; this is the strongest promise of the approach. A layout that is independent of the platform means that errors specific to the platform, arising from the component’s own layout, disappear; inputs that come from the platform, such as a font, are outside this. When a layout error is fixed, both platforms are fixed together.
The side that uses the engine in code writes the drawing job onto the canvas like this. The piece is not measured; the model does not run the engine.
// Platform code: LabelPainter
import 'package:flutter/widgets.dart';
class LabelPainter extends CustomPainter {
LabelPainter(this.text);
final String text;
@override
void paint(Canvas canvas, Size size) {
final painter = TextPainter(
text: TextSpan(text: text, style: const TextStyle(color: Color(0xFF202020))),
textDirection: TextDirection.ltr,
)..layout(maxWidth: size.width);
painter.paint(canvas, Offset.zero);
}
@override
bool shouldRepaint(LabelPainter old) => old.text != text;
}
The critical line of the piece is the layout(maxWidth: size.width) line. How much room the text will take is computed here, and this computation determines what the engine will draw onto the canvas. The question of where the input of this computation comes from will meet us again in the bounding measurement of the lesson.
Input and Scrolling in the Engine
The engine does not only draw; it also resolves touch by itself. The platform gives the touch point, and the engine finds which item was touched. The engine also limits the scroll position: if the content is longer than a view, the position stays between zero and the difference of the content. Neither function reads the platform.
AP2. The fixed boxes are three regions in the model. The content height is 300 and the view height is 100; these numbers are an input of the model, and the scroll position stays between 0 and 200.
// main.mjs import { Sharing, countSharing } from "./sharing.mjs"; const regions = [ { x: 0, y: 0, w: 50, h: 40 }, { x: 50, y: 0, w: 50, h: 40 }, { x: 0, y: 40, w: 100, h: 60 }, ]; function hitTest(point) { const [px, py] = point; return regions.findIndex( (r) => px >= r.x && px < r.x + r.w && py >= r.y && py < r.y + r.h, ); } function clampScroll(offset) { return Math.min(Math.max(offset, 0), 200); } const points = [[5, 5], [60, 10], [30, 70], [200, 5]]; const offsets = [-10, 0, 120, 500]; const features = [ new Sharing("touch-target", { ios: hitTest, android: hitTest }, points), new Sharing("scroll-limit", { ios: clampScroll, android: clampScroll }, offsets), ]; features.forEach((f) => f.line()); countSharing(features);
touch-target: code=1 | diff=0/4 -> shared scroll-limit: code=1 | diff=0/4 -> shared shared: 2 branched: 0 split: 0 single: 0
So four features came out shared: placement, paint order, touch target, and scroll limit. All four are inside the engine, and all four do not read the platform. The promise of sharing in this approach holds to the extent that the engine really leaves the native components behind.
The cost of this promise is the loss of what comes from the platform’s own component. The accessibility behavior, the keyboard layout, and the selection menu of a native text field are the platform’s job. The engine has to imitate these in an area it draws itself; if the imitation falls short, the user feels it. The rule does not see this shortfall, because the shortfall is lived not as a difference in sharing but as a difference in behavior.
The Bounding Measurement
We said the layout does not read the platform, but is the input of the layout only the box widths? Text is a box, and the width of the box comes from the character metrics of the font used. If the font is one that ships with the engine, the metric is the same on both platforms. If the platform’s own font is used, the metric changes by platform.
AP3. The character width is an input in the model: 6 in the engine’s own font, 6 in the system font of iOS, and 7 in the system font of Android. Real font metrics change from character to character, and these three numbers are a single representative value. The container width is 60; in this case the number of characters per line is 10 on iOS and 8 on Android.
// text.mjs — splits text into lines by character width export function glyphWidth(platform) { return platform === "ios" ? 6 : 7; } export function bundledWidth() { return 6; } export function makeWrap(widthOf) { return function wrapText(text, platform) { const perLine = Math.floor(60 / widthOf(platform)); const lines = []; let line = ""; for (const word of text.split(" ")) { const next = line === "" ? word : `${line} ${word}`; if (line !== "" && next.length > perLine) { lines.push(line); line = word; } else { line = next; } } lines.push(line); return lines.join("/"); }; }
The same four texts are measured twice: once with the width of the font that ships with the engine, once with the width of the platform’s font. The wrapping function is the same; the only thing that changes is where the width comes from.
// main.mjs import { Sharing, countSharing } from "./sharing.mjs"; import { glyphWidth, bundledWidth, makeWrap } from "./text.mjs"; const bundledWrap = makeWrap(bundledWidth); const platformWrap = makeWrap(glyphWidth); const texts = ["Coffee", "Black tea", "Weekly plan", "Tea mixes"]; const features = [ new Sharing("text-engine-font", { ios: bundledWrap, android: bundledWrap }, texts), new Sharing("text-platform-font", { ios: platformWrap, android: platformWrap }, texts), ]; features.forEach((f) => f.line()); countSharing(features);
text-engine-font: code=1 | diff=0/4 -> shared text-platform-font: code=1 | diff=2/4 -> branched shared: 1 branched: 1 split: 0 single: 0
In both measurements code=1 and the engine is the same. With the font that ships with the engine, the four texts were split into the same lines on the two platforms and the sharing came out shared. With the platform’s font, two texts diverged: “Black tea” and “Tea mixes”. These two texts of nine characters fit in a line of ten characters on iOS, do not fit in a line of eight characters on Android, and are split in two. “Weekly plan”, on the other hand, is split in two on both platforms, because its eleven characters exceed both limits; the result stays the same.
Even if the engine draws every pixel itself, a part of its input comes from the platform. When an input such as the choice of font is tied to the platform, the layout branches without the placement itself changing. In this approach the way to keep the sharing is to minimize taking input from the platform; the font that ships with the engine is for this reason the precondition of a shared layout. The rule does not see where the input comes from; it sees only that the result diverged, and the prose says the reason for the divergence.
Summary
- This lesson’s sharing: shared. The layout function that computes the position of boxes does not read the platform and gave the same placement on the two platforms in four box sets.
- In the own-rendering-engine approach the layout, the paint order, the touch target, and the scroll limit are inside the engine; all four came out shared.
- When the text width came from the platform’s font metrics, the same layout function broke lines differently in two of four texts, and the sharing turned branched.
- With the font that ships with the engine, the same texts came out shared: taking input from the platform branches the sharing without changing the code.
- The rule sees how the sharing is structured, not what it costs; the loss of the accessibility and selection behavior of the native component is said in the prose.
Next Step
In this lesson the platform’s native components were left behind entirely, and the sharing reached its widest form. The fourth mechanism goes in the opposite direction: it shares only the business rules and writes the interface natively on each platform. The next lesson measures the sharing of the interface in this mechanism.
To keep your progress and take notes, Log in
My notes
Log in to take notes.