mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
fix(native): the top bar keeps clear of a camera in the corner of the screen
The iOS app hands the page the safe area it measures, a floor under env() where WebKit reports none, and on a phone the floating bars keep clear of the side insets as well.
This commit is contained in:
@@ -42,6 +42,7 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
||||
if let webView, let inner = webView.navigationDelegate {
|
||||
let proxy = TrekNavigationProxy(inner: inner)
|
||||
proxy.onErrorPage = { [weak self] error in self?.errorPageChanged(error) }
|
||||
proxy.onPageShown = { [weak self] in self?.publishSafeArea(force: true) }
|
||||
navigationProxy = proxy
|
||||
webView.navigationDelegate = proxy
|
||||
}
|
||||
@@ -88,6 +89,49 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
||||
webView?.underPageBackgroundColor = color
|
||||
}
|
||||
|
||||
// MARK: - The safe area the page keeps clear of
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
publishSafeArea()
|
||||
}
|
||||
|
||||
override func viewSafeAreaInsetsDidChange() {
|
||||
super.viewSafeAreaInsetsDidChange()
|
||||
publishSafeArea()
|
||||
}
|
||||
|
||||
private var publishedSafeArea: UIEdgeInsets?
|
||||
|
||||
/// WebKit's env(safe-area-inset-*) is the window's safe area only where the
|
||||
/// web view overlaps it. Where a release lays the web view out below the
|
||||
/// status bar it reports 0, yet a camera in a corner can still reach into
|
||||
/// the page. The part of the window's safe area (and of the status bar) that
|
||||
/// falls inside the web view goes to the page as --trek-native-safe-*, which
|
||||
/// mobile.css takes as a floor under env(), never in its place.
|
||||
func publishSafeArea(force: Bool = false) {
|
||||
guard let webView, let window = view.window else { return }
|
||||
let frame = webView.convert(webView.bounds, to: window)
|
||||
let safe = window.safeAreaInsets
|
||||
let statusBar = window.windowScene?.statusBarManager?.statusBarFrame.height ?? 0
|
||||
let insets = UIEdgeInsets(
|
||||
top: max(0, max(safe.top, statusBar) - frame.minY),
|
||||
left: max(0, safe.left - frame.minX),
|
||||
bottom: max(0, safe.bottom - (window.bounds.maxY - frame.maxY)),
|
||||
right: max(0, safe.right - (window.bounds.maxX - frame.maxX))
|
||||
)
|
||||
if !force, insets == publishedSafeArea { return }
|
||||
publishedSafeArea = insets
|
||||
let px = { (v: CGFloat) in "'\(Int(v.rounded(.up)))px'" }
|
||||
let js = "(function(s){"
|
||||
+ "s.setProperty('--trek-native-safe-top',\(px(insets.top)));"
|
||||
+ "s.setProperty('--trek-native-safe-left',\(px(insets.left)));"
|
||||
+ "s.setProperty('--trek-native-safe-right',\(px(insets.right)));"
|
||||
+ "s.setProperty('--trek-native-safe-bottom',\(px(insets.bottom)));"
|
||||
+ "})(document.documentElement.style)"
|
||||
webView.evaluateJavaScript(js, completionHandler: nil)
|
||||
}
|
||||
|
||||
// MARK: - A proxy's sign-in page
|
||||
|
||||
/// Over a proxy's sign-in page (Pangolin, authentik, ...) the server's UI is
|
||||
@@ -714,6 +758,8 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
private var serverShown = false
|
||||
/// Told on the main thread whether the main frame now shows an error page in place of the server's.
|
||||
var onErrorPage: ((Bool) -> Void)?
|
||||
/// Told when a page has finished loading: a new document starts without the safe area the controller handed the last one.
|
||||
var onPageShown: (() -> Void)?
|
||||
|
||||
init(inner: WKNavigationDelegate) {
|
||||
self.inner = inner
|
||||
@@ -766,6 +812,7 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
/// page once (TrekServerStore.takeWelcomeURL); it is on the server's origin.
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
inner.webView?(webView, didFinish: navigation)
|
||||
onPageShown?()
|
||||
if TrekSignInRedirect.onServer(server: TrekServerStore.url, url: webView.url) { serverShown = true }
|
||||
if let welcome = TrekServerStore.takeWelcomeURL() {
|
||||
webView.load(URLRequest(url: welcome))
|
||||
|
||||
@@ -62,7 +62,14 @@
|
||||
* 52px/26px offsets stand in for the phone status bar / home indicator, so
|
||||
* real devices derive them from the safe-area insets. */
|
||||
--bottom-nav-h: calc(84px + env(safe-area-inset-bottom, 0px));
|
||||
--m-safe-top: calc(env(safe-area-inset-top, 0px) + 12px);
|
||||
/* The safe area the iOS app measures (TrekBridgeViewController
|
||||
* publishSafeArea) is a floor under env(): where a release lays the web view
|
||||
* out below the status bar, WebKit reports 0 while a camera in a corner still
|
||||
* reaches into the page. In a browser the variables are unset: env() alone. */
|
||||
--m-safe-t: max(env(safe-area-inset-top, 0px), var(--trek-native-safe-top, 0px));
|
||||
--m-safe-l: max(env(safe-area-inset-left, 0px), var(--trek-native-safe-left, 0px));
|
||||
--m-safe-r: max(env(safe-area-inset-right, 0px), var(--trek-native-safe-right, 0px));
|
||||
--m-safe-top: calc(var(--m-safe-t) + 12px);
|
||||
/* The navigation rail's share of the leading edge (wide windows only), what
|
||||
* every full-width fixed bar and the page column start after. */
|
||||
--m-rail-w: 0px;
|
||||
@@ -75,10 +82,10 @@
|
||||
* reads the same values as the shell. */
|
||||
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-root {
|
||||
--bottom-nav-h: env(safe-area-inset-bottom, 0px);
|
||||
--m-rail-w: calc(env(safe-area-inset-left, 0px) + 100px);
|
||||
--m-rail-w: calc(var(--m-safe-l) + 100px);
|
||||
}
|
||||
html[dir='rtl']:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-root {
|
||||
--m-rail-w: calc(env(safe-area-inset-right, 0px) + 100px);
|
||||
--m-rail-w: calc(var(--m-safe-r) + 100px);
|
||||
}
|
||||
|
||||
.dark .m-root {
|
||||
@@ -570,6 +577,14 @@ html[dir='rtl']:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-
|
||||
inset-inline-start: var(--m-rail-w, 0px);
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
/* Without the rail the floating bars keep clear of the sides' safe area too:
|
||||
a camera in a corner can come as a side inset. Physical sides, as the
|
||||
insets are the screen's, not the text direction's. On a wide window the
|
||||
rail takes the leading side and a bar may centre itself (mx-auto). */
|
||||
html:not(:has(.m-shell[data-size='wide'], .m-shell[data-size='split'])) :is(.m-bar-x, .m-span-x) {
|
||||
margin-left: var(--m-safe-l, 0px);
|
||||
margin-right: var(--m-safe-r, 0px);
|
||||
}
|
||||
/* A screen whose background is a map reaches under the rail: the map runs to
|
||||
the window's edge and the rail floats over it like the top controls do. Its
|
||||
own bars then keep to the page column with m-bar-x / m-span-x. */
|
||||
|
||||
@@ -48,3 +48,26 @@ describe('MGlassBar', () => {
|
||||
expect(container.firstElementChild).toBe(screen.getByText('bar').parentElement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safe area in the touch shell', () => {
|
||||
// jsdom applies no stylesheet and has no env(), so the rules are read from source.
|
||||
const css = readFileSync(resolve(root, 'src/mobile/mobile.css'), 'utf8');
|
||||
|
||||
it('FE-MOB-CHROME-004: the top clearance takes env() with the measurement of the app as a floor, never in its place', () => {
|
||||
expect(css).toContain('--m-safe-t: max(env(safe-area-inset-top, 0px), var(--trek-native-safe-top, 0px));');
|
||||
expect(css).toContain('--m-safe-top: calc(var(--m-safe-t) + 12px);');
|
||||
expect(css).toContain('--m-safe-l: max(env(safe-area-inset-left, 0px), var(--trek-native-safe-left, 0px));');
|
||||
expect(css).toContain('--m-safe-r: max(env(safe-area-inset-right, 0px), var(--trek-native-safe-right, 0px));');
|
||||
});
|
||||
|
||||
it('FE-MOB-CHROME-005: without the rail the floating bars keep clear of a side inset, such as a camera in a corner', () => {
|
||||
const narrow = "html:not(:has(.m-shell[data-size='wide'], .m-shell[data-size='split'])) :is(.m-bar-x, .m-span-x) {";
|
||||
const rule = css.split(narrow)[1]?.split('}')[0] ?? '';
|
||||
expect(rule).toContain('margin-left: var(--m-safe-l, 0px);');
|
||||
expect(rule).toContain('margin-right: var(--m-safe-r, 0px);');
|
||||
// On a wide window the bars keep no side margin, so mx-auto still centres them.
|
||||
for (const cls of ['m-bar-x', 'm-span-x']) {
|
||||
expect(css.split(`\n.${cls} {`)[1]?.split('}')[0] ?? '').not.toContain('margin');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user