Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/toolbox/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -368,3 +368,22 @@ Button {
.sliders-demo-page Slider.stops-slider {
background: linear-gradient(to right, red 0%, yellow 50%, green 100%);
}

/* The env() half of the edge-to-edge suite. */
.env-css-probe {
overflow-safe-area: bottom;
padding-top: 6dip;
padding-bottom: calc(6dip + env(safe-area-inset-bottom));
padding-left: calc(6dip + env(safe-area-inset-left));
padding-right: calc(6dip + env(safe-area-inset-right));
}

/* padding plus the inset still left at the view. */
.env-shorthand-probe {
safe-area-padding: 6dip;
}

/* The nested case: correct whatever an ancestor already consumed. */
.ns-safe-bottom {
padding-bottom: env(ns-safe-area-inset-bottom);
}
1 change: 1 addition & 0 deletions apps/toolbox/src/main-page.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<Button text="webview" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="winter-tc" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="ete" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="ete/hub" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
</StackLayout>
</ScrollView>
</StackLayout>
Expand Down
60 changes: 60 additions & 0 deletions apps/toolbox/src/pages/ete/bars.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Application, Color, EventData, Frame, Observable, Page, Utils } from '@nativescript/core';
import { deviceSummary } from './shared';

class Bars extends Observable {
private dark = true;
private lightContent = false;
private tinted = false;

constructor(private page: Page) {
super();
this.publish();

const onAppearance = () => this.publish();
Application.on(Application.systemAppearanceChangedEvent, onAppearance);
this.page.on(Page.navigatedFromEvent, () => Application.off(Application.systemAppearanceChangedEvent, onAppearance));
}

toggleStyle = () => {
this.dark = !this.dark;
this.publish();
};

toggleContent = () => {
this.lightContent = !this.lightContent;
this.publish();
};

/** The colours only show up behind translucent bars, which is the edge-to-edge case. */
toggleBarColors = () => {
this.tinted = !this.tinted;
const activity = Utils.android.getCurrentActivity() as androidx.appcompat.app.AppCompatActivity;
if (this.tinted) {
Utils.android.setStatusBarColor({ activity, lightColor: new Color('#3320c997'), darkColor: new Color('#33064e3b') });
Utils.android.setNavigationBarColor({ activity, lightColor: new Color('#33f59e0b'), darkColor: new Color('#33451a03') });
} else {
Utils.android.setStatusBarColor({ activity, lightColor: new Color('transparent'), darkColor: new Color('transparent') });
Utils.android.setNavigationBarColor({ activity, lightColor: new Color('transparent'), darkColor: new Color('transparent') });
}
this.publish();
};

back = () => {
Frame.topmost().goBack();
};

private publish() {
const style = this.dark ? 'dark' : 'light';
this.set('statusBarStyle', style);
this.set('statusBarLine', `statusBarStyle: ${style}`);
this.set('contentColor', this.lightContent ? '#f8fafc' : '#111827');
this.set('appearanceLine', `system appearance: ${Application.systemAppearance()}`);
this.set('ignoreOlderLine', `ignoreEdgeToEdgeOnOlderDevices: ${Utils.android.getIgnoreEdgeToEdgeOnOlderDevices()}`);
this.set('device', deviceSummary());
}
}

export function navigatingTo(args: EventData) {
const page = <Page>args.object;
page.bindingContext = new Bars(page);
}
32 changes: 32 additions & 0 deletions apps/toolbox/src/pages/ete/bars.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<Page xmlns="http://schemas.nativescript.org/tns.xsd" navigatingTo="navigatingTo" actionBarHidden="true" androidOverflowEdge="ignore" backgroundColor="#3f0d12" statusBarStyle="{{ statusBarStyle }}">
<GridLayout id="probe" backgroundColor="#166534" androidOverflowEdge="top,bottom">
<GridLayout backgroundColor="{{ contentColor }}" rows="auto, auto, *">
<StackLayout row="0" padding="12" marginTop="48">
<Label text="System bars" color="#ffffff" fontWeight="bold" fontSize="17" />
<Label text="The probe overflows top and bottom, so the content colour runs under both bars - that is the only way to tell whether the bar icons are still readable." color="#e5e7eb" fontSize="13" textWrap="true" />
</StackLayout>

<StackLayout row="1" padding="8">
<GridLayout columns="*, *">
<Button col="0" text="statusBarStyle" tap="{{ toggleStyle }}" color="#ffffff" backgroundColor="#111827" />
<Button col="1" text="content colour" tap="{{ toggleContent }}" color="#ffffff" backgroundColor="#111827" />
</GridLayout>
<GridLayout columns="*, *" marginTop="4">
<Button col="0" text="bar colours" tap="{{ toggleBarColors }}" color="#ffffff" backgroundColor="#111827" />
<Button col="1" text="back" tap="{{ back }}" color="#ffffff" backgroundColor="#111827" />
</GridLayout>
</StackLayout>

<ScrollView row="2">
<StackLayout padding="12">
<Label text="{{ statusBarLine }}" color="#ffffff" fontFamily="monospace" fontSize="14" />
<Label text="{{ appearanceLine }}" color="#ffffff" fontFamily="monospace" fontSize="14" marginTop="4" />
<Label text="{{ ignoreOlderLine }}" color="#ffffff" fontFamily="monospace" fontSize="14" marginTop="4" />
<Label text="{{ device }}" color="#e5e7eb" fontSize="13" textWrap="true" marginTop="8" />
<Label text="Switch the system theme while this page is open: the bar icons have to flip with it, not stay on the old theme." color="#fde68a" fontSize="13" textWrap="true" marginTop="12" />
</StackLayout>
</ScrollView>

</GridLayout>
</GridLayout>
</Page>
145 changes: 145 additions & 0 deletions apps/toolbox/src/pages/ete/double.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { CoreTypes, EventData, Frame, GridLayout, Observable, Page, SafeArea, Screen, StackLayout, View } from '@nativescript/core';

interface Case {
host: CoreTypes.SafeAreaEdges;
padding: string;
hostPadding?: string;
title: string;
expected: (inset: number) => number;
}

const CASES: Case[] = [
{ host: 'none', padding: '', title: 'framework only - the host is inset, the probe adds nothing', expected: (i) => i },
{ host: 'bottom', padding: 'env(safe-area-inset-bottom)', title: 'author only - the host overflows, the probe pads itself', expected: (i) => i },
{ host: 'none', padding: 'env(safe-area-inset-bottom)', title: 'BOTH - the host is inset AND the probe pads itself', expected: (i) => i * 2 },
{ host: 'bottom', padding: '', title: 'NEITHER - the host overflows and nobody pads', expected: () => 0 },
{ host: 'none', padding: '', hostPadding: 'env(safe-area-inset-bottom)', title: 'SMART - the host pads itself, so the framework stops insetting it there', expected: (i) => i },
{ host: 'none', padding: 'env(ns-safe-area-inset-bottom)', title: 'BOTH, remaining inset - the host consumed the bottom, so the probe adds 0', expected: (i) => i },
];

function verdict(gap: number | null, inset: number): string {
if (gap === null) {
return 'not laid out';
}
if (Math.abs(gap - inset) <= 1) {
return 'OK';
}
if (inset > 0 && Math.abs(gap - inset * 2) <= 1) {
return 'DOUBLE PADDING';
}
if (gap <= 1) {
return 'UNDER THE BAR';
}

return `unexpected ${gap}dip`;
}

class DoublePadding extends Observable {
private index = 0;

constructor(private page: Page) {
super();
SafeArea.on('insetsChanged', this.measure, this);
page.on(Page.unloadedEvent, () => SafeArea.off('insetsChanged', this.measure, this));
this.apply();
setTimeout(() => this.runAll(), 600);
}

get host(): GridLayout {
return this.page.getViewById<GridLayout>('host');
}

get marker(): StackLayout {
return this.page.getViewById<StackLayout>('marker');
}

// The padding sits inside the probe, so the probe's own bottom edge never moves -
// what matters is where its last content pixel ended up.
get line(): View {
return this.page.getViewById<View>('line');
}

next = () => {
this.index = (this.index + 1) % CASES.length;
this.apply();
};

prev = () => {
this.index = (this.index - 1 + CASES.length) % CASES.length;
this.apply();
};

back = () => Frame.topmost().goBack();

/** Walks every case on a timer and prints one table, so no tapping is needed. */
runAll = () => {
const lines: string[] = [];
const fmt = (v: any) => (v ? `${v.left},${v.top},${v.right},${v.bottom}` : 'n/a');
lines.push(`[double-padding] page.getSafeAreaInsets = ${fmt(this.page.getSafeAreaInsets())}`);
lines.push(`[double-padding] host.getSafeAreaInsets = ${fmt(this.host.getSafeAreaInsets())}`);
lines.push(`[double-padding] host.native.safeAreaInsets = ${fmt((this.host as any).nativeViewProtected?.safeAreaInsets)}`);
let i = 0;

const step = () => {
// Read late: on Android the first insets pass can land after the page is shown.
const inset = SafeArea.insets.bottom;
if (i > 0) {
const c = CASES[i - 1];
const gap = this.gapBelow(this.line);
lines.push(`[double-padding] host=${c.host.padEnd(6)} probe=${(c.padding || 'none').padEnd(30)} hostPad=${(c.hostPadding || 'none').padEnd(27)} gap=${gap}dip expected=${c.expected(inset)}dip ${verdict(gap, inset)}`);
}

if (i === CASES.length) {
lines.unshift(`[double-padding] safe-area-inset-bottom = ${inset}dip`);
lines.forEach((line) => console.log(line));
this.set('report', lines.slice(1).join('\n'));
this.index = 0;
this.apply();

return;
}

this.index = i++;
this.apply();
setTimeout(step, 350);
};

step();
};

private apply() {
const current = CASES[this.index];
this.host.overflowSafeArea = current.host;
this.host.style.paddingBottom = (current.hostPadding || 0) as never;
this.marker.style.paddingBottom = (current.padding || 0) as never;

this.set('heading', `host overflowSafeArea="${current.host}" probe padding-bottom: ${current.padding || 'none'}`);
this.set('description', current.title);
this.set('hint', `${this.index + 1} of ${CASES.length}`);

setTimeout(() => this.measure(), 120);
}

measure = () => {
const inset = SafeArea.insets.bottom;
const gap = this.gapBelow(this.line);

this.set('numbers', `safe-area-inset-bottom ${inset}dip\ngap below the probe ${gap === null ? '?' : gap}dip\nexpected for this case ${CASES[this.index].expected(inset)}dip`);
this.set('verdict', verdict(gap, inset));
};

// Distance from the view's bottom edge to the bottom of the screen.
private gapBelow(view: View): number | null {
const location = view?.getLocationOnScreen();
if (!location) {
return null;
}

return Math.round(Screen.mainScreen.heightDIPs - (location.y + view.getActualSize().height));
}
}

export function navigatingTo(args: EventData) {
const page = <Page>args.object;
page.bindingContext = new DoublePadding(page);
}
31 changes: 31 additions & 0 deletions apps/toolbox/src/pages/ete/double.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<Page xmlns="http://schemas.nativescript.org/tns.xsd" navigatingTo="navigatingTo" actionBarHidden="true" androidOverflowEdge="ignore" backgroundColor="#3f0d12">
<!--
The host is the view the framework insets. The probe inside it is what an author
would pad with env(). When both happen the gap below the probe is two insets.
-->
<GridLayout id="host" rows="auto, auto, auto, *" backgroundColor="#1d4ed8">
<StackLayout row="0" padding="12">
<Label text="{{ heading }}" color="#ffffff" fontWeight="bold" fontSize="14" textWrap="true" fontFamily="monospace" />
<Label text="{{ description }}" color="#bfdbfe" fontSize="13" textWrap="true" marginTop="4" />
</StackLayout>

<GridLayout row="1" columns="*, *, *" padding="8">
<Button col="0" text="&lt; prev" tap="{{ prev }}" color="#ffffff" backgroundColor="#1e40af" />
<Button col="1" text="next &gt;" tap="{{ next }}" color="#ffffff" backgroundColor="#1e40af" />
<Button col="2" text="back" tap="{{ back }}" color="#ffffff" backgroundColor="#1e40af" />
</GridLayout>

<StackLayout row="2" padding="12">
<Label text="{{ numbers }}" color="#bfdbfe" fontFamily="monospace" fontSize="13" textWrap="true" />
<Label text="{{ verdict }}" color="#fde68a" fontWeight="bold" fontSize="15" textWrap="true" marginTop="8" />
<Label text="{{ hint }}" color="#bfdbfe" fontSize="12" marginTop="4" />
<Label text="{{ report }}" color="#a7f3d0" fontFamily="monospace" fontSize="10" textWrap="true" marginTop="8" />
</StackLayout>

<!-- The probe is pinned to the bottom; the orange line is its last content pixel. -->
<StackLayout row="3" id="marker" verticalAlignment="bottom" backgroundColor="#166534">
<Label text="probe" color="#ffffff" fontSize="11" textAlignment="center" padding="4" />
<StackLayout id="line" height="4" backgroundColor="#b45309" />
</StackLayout>
</GridLayout>
</Page>
81 changes: 81 additions & 0 deletions apps/toolbox/src/pages/ete/edges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { CoreTypes, EventData, Frame, GridLayout, Observable, Page } from '@nativescript/core';
import { readEdges, readWindowInsets } from './shared';

interface Case {
edge: CoreTypes.AndroidOverflow;
description: string;
}

/**
* One entry per shape androidOverflowEdge can take. Walk them with prev/next and the
* green ring tells you which edges the probe padded.
*/
const CASES: Case[] = [
{ edge: 'none', description: 'pads every edge and consumes them - expect a green ring all the way round' },
{ edge: 'ignore', description: 'takes no part at all - expect no green ring, and no ring left over from the previous case' },
{ edge: 'top', description: 'overflows the top - blue should reach up under the status bar, green on the other three' },
{ edge: 'bottom', description: 'overflows the bottom - blue should reach down under the navigation bar' },
{ edge: 'left', description: 'overflows the left - only visible when there is a left inset (landscape / gesture nav)' },
{ edge: 'right', description: 'overflows the right - only visible when there is a right inset' },
{ edge: 'top,bottom', description: 'overflows both - green only on left/right, which are usually 0 in portrait' },
{ edge: 'left,right', description: 'overflows the sides - green top and bottom' },
{ edge: 'all-but-top', description: 'only the top is padded' },
{ edge: 'all-but-bottom', description: 'only the bottom is padded' },
{ edge: 'top-dont-consume', description: 'pads the top AND passes the top inset on to children' },
{ edge: 'bottom-dont-consume', description: 'pads the bottom AND passes the bottom inset on to children' },
{ edge: 'dont-apply', description: 'hands the insets to JS and pads nothing - expect no green ring' },
{ edge: 'none,none', description: 'a stacked value that resolves to none - must behave exactly like the first case' },
{ edge: 'ignore,bottom', description: 'ignore wins over anything after it - must behave exactly like ignore' },
{ edge: 'none,cutout', description: 'like none, but the display cutout counts too - rotate to landscape, where the camera sits on an edge with no system bar' },
{ edge: 'top,cutout', description: 'overflows the top while still keeping clear of the cutout on the other edges' },
];

class EdgeMatrix extends Observable {
private index = 0;

constructor(private page: Page) {
super();
this.apply();
}

get probe(): GridLayout {
return this.page.getViewById<GridLayout>('probe');
}

next = () => {
this.index = (this.index + 1) % CASES.length;
this.apply();
};

prev = () => {
this.index = (this.index - 1 + CASES.length) % CASES.length;
this.apply();
};

back = () => {
Frame.topmost().goBack();
};

private apply() {
const current = CASES[this.index];
this.set('edge', current.edge);
this.set('heading', `androidOverflowEdge = "${current.edge}"`);
this.set('description', current.description);
this.set('hint', `green ring = padded edge, blue to the screen edge = overflowing (${this.index + 1} of ${CASES.length})`);
// The insets land on the next layout pass, so read the probe after it.
setTimeout(() => this.refresh(), 80);
}

refresh() {
const readout = readEdges(this.probe);
this.set('padding', readout.padding);
this.set('edgeInsets', readout.edgeInsets);
this.set('imeInsets', readout.imeInsets);
this.set('windowInsets', readWindowInsets(this.page));
}
}

export function navigatingTo(args: EventData) {
const page = <Page>args.object;
page.bindingContext = new EdgeMatrix(page);
}
Loading
Loading