Skip to content

Commit 043b032

Browse files
Add iPadOS 26 window controls offset for Capacitor web view (#818)
iPadOS 26 shows traffic-light window controls in the top-left corner of windowed apps. WKWebView does not expose this region via CSS env() variables, so content (back buttons, hamburger menu) gets occluded. An invisible observer view reads the corner adaptation margin from the native UIView API and injects it as a --window-controls-left CSS custom property. The CSS variable is applied to ActionBar and EditCodeDialog and the model theme (to avoid title overlap for full screen modals). As noted on review it's not perfect for the navigation drawer but a big improvement and let's us avoid the compat flag.
1 parent b23f9c1 commit 043b032

File tree

5 files changed

+83
-2
lines changed

5 files changed

+83
-2
lines changed

ios/App/App/AppDelegate.swift

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import UIKit
22
import Capacitor
3+
import WebKit
34

45
@UIApplicationMain
56
class AppDelegate: UIResponder, UIApplicationDelegate {
67

78
var window: UIWindow?
9+
private var windowControlsObserver: WindowControlsObserver?
810

911
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
1012
// Override point for customization after application launch.
@@ -34,6 +36,32 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
3436
let webView = vc.webView else { return }
3537
webView.setNeedsLayout()
3638
webView.layoutIfNeeded()
39+
40+
setupWindowControlsObserver(webView: webView)
41+
}
42+
43+
/// Adds an invisible view that tracks the iPadOS 26 window-controls
44+
/// corner adaptation margin and injects it as a CSS variable.
45+
private func setupWindowControlsObserver(webView: WKWebView) {
46+
guard windowControlsObserver == nil,
47+
let parent = webView.superview else { return }
48+
let observer = WindowControlsObserver()
49+
observer.attach(to: webView)
50+
observer.translatesAutoresizingMaskIntoConstraints = false
51+
observer.isUserInteractionEnabled = false
52+
observer.alpha = 0
53+
observer.isAccessibilityElement = false
54+
observer.accessibilityElementsHidden = true
55+
parent.insertSubview(observer, belowSubview: webView)
56+
NSLayoutConstraint.activate([
57+
observer.topAnchor.constraint(equalTo: webView.topAnchor),
58+
observer.leadingAnchor.constraint(equalTo: webView.leadingAnchor),
59+
observer.trailingAnchor.constraint(equalTo: webView.trailingAnchor),
60+
observer.bottomAnchor.constraint(equalTo: webView.bottomAnchor),
61+
])
62+
windowControlsObserver = observer
63+
parent.layoutIfNeeded()
64+
observer.injectWindowControlsInset()
3765
}
3866

3967
func applicationWillTerminate(_ application: UIApplication) {
@@ -54,3 +82,53 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
5482
}
5583

5684
}
85+
86+
/// Invisible view that observes layout changes to detect the iPadOS 26
87+
/// window-controls region and inject its width as a CSS custom property
88+
/// (`--window-controls-left`) into the Capacitor web view.
89+
///
90+
/// On older iOS versions or when window controls are absent the value is 0.
91+
private class WindowControlsObserver: UIView {
92+
weak var webView: WKWebView?
93+
private var lastLeft: CGFloat = -1
94+
private var loadingObservation: NSKeyValueObservation?
95+
96+
func attach(to webView: WKWebView) {
97+
self.webView = webView
98+
// Re-inject after page reload (document.documentElement is replaced).
99+
loadingObservation = webView.observe(\.isLoading) { [weak self] wv, _ in
100+
if !wv.isLoading {
101+
self?.lastLeft = -1
102+
self?.injectWindowControlsInset()
103+
}
104+
}
105+
}
106+
107+
override func layoutSubviews() {
108+
super.layoutSubviews()
109+
injectWindowControlsInset()
110+
}
111+
112+
func injectWindowControlsInset() {
113+
var left: CGFloat = 0
114+
if #available(iOS 26.0, *) {
115+
let corner = edgeInsets(
116+
for: .margins(cornerAdaptation: .horizontal)
117+
)
118+
let base = edgeInsets(for: .margins())
119+
// Only non-zero when window controls are actually present.
120+
// Use the full corner value so the variable represents the
121+
// complete inset from the window edge.
122+
if corner.left > base.left {
123+
left = corner.left
124+
}
125+
}
126+
guard left != lastLeft else { return }
127+
lastLeft = left
128+
let px = Int(left)
129+
let js = "document.documentElement.style.setProperty('--window-controls-left','\(px)px')"
130+
DispatchQueue.main.async { [weak self] in
131+
self?.webView?.evaluateJavaScript(js, completionHandler: nil)
132+
}
133+
}
134+
}

src/components/ActionBar/ActionBar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const ActionBar = ({
5151
gap={0}
5252
h="64px"
5353
w="100%"
54+
sx={{ pl: "var(--window-controls-left, 0px)" }}
5455
>
5556
<HStack
5657
flex={itemsCenter ? { base: "0 1 max-content", xl: "1 0" } : "4 0"}

src/components/EditCodeDialog.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ const EditCodeDialog = forwardRef<MakeCodeFrameDriver, EditCodeDialogProps>(
5959
sx={{
6060
paddingTop: "env(safe-area-inset-top)",
6161
paddingBottom: "env(safe-area-inset-bottom)",
62-
paddingLeft: "var(--safe-area-nav-left, 0px)",
62+
paddingLeft:
63+
"max(var(--window-controls-left, 0px), var(--safe-area-nav-left, 0px))",
6364
paddingRight: "var(--safe-area-nav-right, 0px)",
6465
}}
6566
>

src/deployment/default/components/modal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const full = definePartsStyle({
5151
},
5252
header: {
5353
flexShrink: 0,
54+
pl: "calc(var(--window-controls-left, 0px) + var(--chakra-space-6))",
5455
},
5556
body: {
5657
flex: 1,

workflow-config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"theme-package-version": "0.2.0-beta.95"
2+
"theme-package-version": "0.2.0-beta.96"
33
}

0 commit comments

Comments
 (0)