@erkamyaman/capacitor-foldable 8.2.0 → 8.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ErkamyamanCapacitorFoldable.podspec +1 -0
- package/Package.swift +2 -1
- package/README.md +23 -1
- package/android/src/main/kotlin/io/github/erkamyaman/foldable/DisplayModes.kt +22 -2
- package/android/src/main/kotlin/io/github/erkamyaman/foldable/FoldablePlugin.kt +26 -4
- package/ionic-tabs-ios26.css +9 -0
- package/ionic-tabs.css +28 -2
- package/ios/Sources/FoldablePlugin/Resources/PrivacyInfo.xcprivacy +14 -0
- package/package.json +7 -6
- package/skills/capacitor-foldable/SKILL.md +88 -0
- package/skills/capacitor-foldable/references/android.md +54 -0
- package/skills/capacitor-foldable/references/api.md +114 -0
- package/skills/capacitor-foldable/references/css-layout.md +145 -0
- package/skills/capacitor-foldable/references/ionic.md +54 -0
- package/skills/capacitor-foldable/references/iphone-duo.md +54 -0
- package/skills/capacitor-foldable/references/testing.md +53 -0
|
@@ -11,6 +11,7 @@ Pod::Spec.new do |s|
|
|
|
11
11
|
s.author = package['author']
|
|
12
12
|
s.source = { :git => package['repository']['url'], :tag => s.version.to_s }
|
|
13
13
|
s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
|
|
14
|
+
s.resource_bundles = { 'ErkamyamanCapacitorFoldablePrivacy' => ['ios/Sources/FoldablePlugin/Resources/PrivacyInfo.xcprivacy'] }
|
|
14
15
|
s.ios.deployment_target = '14.0'
|
|
15
16
|
s.dependency 'Capacitor'
|
|
16
17
|
s.swift_version = '5.1'
|
package/Package.swift
CHANGED
|
@@ -19,7 +19,8 @@ let package = Package(
|
|
|
19
19
|
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
|
20
20
|
.product(name: "Cordova", package: "capacitor-swift-pm")
|
|
21
21
|
],
|
|
22
|
-
path: "ios/Sources/FoldablePlugin"
|
|
22
|
+
path: "ios/Sources/FoldablePlugin",
|
|
23
|
+
resources: [.copy("Resources/PrivacyInfo.xcprivacy")]),
|
|
23
24
|
.testTarget(
|
|
24
25
|
name: "FoldablePluginTests",
|
|
25
26
|
dependencies: ["FoldablePlugin"],
|
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ const { state, hingeOrientation } = await Foldable.getFoldState();
|
|
|
37
37
|
|
|
38
38
|
| Platform | Status | Minimum |
|
|
39
39
|
| -------- | ------ | ------- |
|
|
40
|
-
| Android | Supported | API 24, `compileSdk`
|
|
40
|
+
| Android | Supported | API 24, `compileSdk` 36 |
|
|
41
41
|
| iOS | Size classes everywhere; fold, hinge and bar placement on iPhone Duo with Capacitor 8.5+, Xcode 27.1+ and iOS 27.1+ | iOS 15 |
|
|
42
42
|
| Web | Stub, returns `flat` | |
|
|
43
43
|
|
|
@@ -53,6 +53,8 @@ Web views already resize with the window, so a responsive layout that pads with
|
|
|
53
53
|
- **Where iPhone Duo puts native bars**, so your HTML tab bar can follow. For Ionic's `ion-tabs` there's a ready-made stylesheet, plus a fix for the tab bar disappearing when you fold.
|
|
54
54
|
- **The outer display on Android foldables**: rear display and dual screen.
|
|
55
55
|
|
|
56
|
+
Google's [adaptive app quality guidelines](https://developer.android.com/develop/adaptive-apps/quality-guidelines/adaptive-app-quality) make posture support a Tier 1 check and size-class-driven layouts a Tier 2 one, and Apple's [iPhone Duo guidance](https://developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo) asks for layouts that adapt to the fold rather than to a device. This plugin reports what both are about.
|
|
57
|
+
|
|
56
58
|
## Installation
|
|
57
59
|
|
|
58
60
|
```bash
|
|
@@ -123,6 +125,26 @@ Using Ionic's `ion-tabs`? `import '@erkamyaman/capacitor-foldable/ionic-tabs.css
|
|
|
123
125
|
- **[examples](examples)** in this repo is a catalogue of one-file demos, one per API, laid out as two pages around the crease on iPhone Duo.
|
|
124
126
|
- **[example-app](example-app)** is the plain single-screen app that shows every value the plugin reports at once.
|
|
125
127
|
|
|
128
|
+
## Store review
|
|
129
|
+
|
|
130
|
+
The plugin is built to give store review nothing to object to.
|
|
131
|
+
|
|
132
|
+
**iOS.** Public APIs only, UIKit and Capacitor, with no dynamic lookup or private symbols. It touches none of Apple's [required reason APIs](https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api), collects nothing and tracks nobody, and it ships a `PrivacyInfo.xcprivacy` saying exactly that, which Xcode folds into your app's privacy report. Nothing it uses needs a purpose string in `Info.plist`. The iOS 27.1 code is behind a compile-time SDK check, so any Xcode that Capacitor 8 supports builds fine.
|
|
133
|
+
|
|
134
|
+
**Android.** The plugin adds no permissions and no `uses-feature` to your app, so it changes nothing about which devices can install it. Its own manifest is empty; Jetpack WindowManager merges two optional `<uses-library>` entries, which are how it binds to the OEM window extensions and filter nothing. The hinge angle comes from `TYPE_HINGE_ANGLE`, which needs no permission, and rear display and dual screen go through `WindowAreaController`, also permission free. `minSdk` is 24, the same as Capacitor's own default, and `targetSdk` follows your project.
|
|
135
|
+
|
|
136
|
+
**Data safety and privacy labels.** Everything stays on the device: the plugin has no network code and stores nothing. There is no entry for it to add to Play's Data safety form or to your App Store privacy label.
|
|
137
|
+
|
|
138
|
+
## Skill for coding agents
|
|
139
|
+
|
|
140
|
+
The repo ships an agent skill, so Claude Code and other agents know how to use the plugin instead of guessing at the API:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
npx skills add erkamyaman/capacitor-foldable@capacitor-foldable
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
It is one skill with the platform detail split out: laying out around the crease in CSS, the full API, Ionic specifics, iPhone Duo, Android foldables, and how to test on both. It is beta, so tell me if any of it steers you wrong. It also ships inside the npm package under [`skills/`](skills/capacitor-foldable), so an agent working in a project that depends on the plugin can find it in `node_modules`.
|
|
147
|
+
|
|
126
148
|
## Guides
|
|
127
149
|
|
|
128
150
|
- [Examples](docs/examples.md)
|
|
@@ -3,6 +3,7 @@ package io.github.erkamyaman.foldable
|
|
|
3
3
|
import android.app.Activity
|
|
4
4
|
import android.content.Context
|
|
5
5
|
import android.view.View
|
|
6
|
+
import android.webkit.WebView
|
|
6
7
|
import androidx.core.content.ContextCompat
|
|
7
8
|
import androidx.window.area.WindowAreaCapability
|
|
8
9
|
import androidx.window.area.WindowAreaController
|
|
@@ -31,6 +32,7 @@ class DisplayModes(private val activity: Activity) {
|
|
|
31
32
|
private var rearDisplayArea: WindowAreaInfo? = null
|
|
32
33
|
private var rearDisplaySession: WindowAreaSession? = null
|
|
33
34
|
private var dualScreenSession: WindowAreaSessionPresenter? = null
|
|
35
|
+
private var dualScreenContent: WebView? = null
|
|
34
36
|
|
|
35
37
|
fun statuses(): Flow<DisplayModeStatuses> =
|
|
36
38
|
controller.windowAreaInfos
|
|
@@ -72,7 +74,7 @@ class DisplayModes(private val activity: Activity) {
|
|
|
72
74
|
|
|
73
75
|
fun startDualScreen(createContent: (Context) -> View, onStarted: () -> Unit, onEnded: (Throwable?) -> Unit) {
|
|
74
76
|
dualScreenSession?.let { session ->
|
|
75
|
-
session
|
|
77
|
+
replaceContent(session, createContent)
|
|
76
78
|
onStarted()
|
|
77
79
|
return
|
|
78
80
|
}
|
|
@@ -86,11 +88,12 @@ class DisplayModes(private val activity: Activity) {
|
|
|
86
88
|
object : WindowAreaPresentationSessionCallback {
|
|
87
89
|
override fun onSessionStarted(session: WindowAreaSessionPresenter) {
|
|
88
90
|
dualScreenSession = session
|
|
89
|
-
session
|
|
91
|
+
replaceContent(session, createContent)
|
|
90
92
|
onStarted()
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
override fun onSessionEnded(t: Throwable?) {
|
|
96
|
+
destroyContent()
|
|
94
97
|
dualScreenSession = null
|
|
95
98
|
onEnded(t)
|
|
96
99
|
}
|
|
@@ -101,10 +104,27 @@ class DisplayModes(private val activity: Activity) {
|
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
fun stopDualScreen() {
|
|
107
|
+
destroyContent()
|
|
104
108
|
dualScreenSession?.close()
|
|
105
109
|
dualScreenSession = null
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
/** A web view left alive keeps its timers and connections running. */
|
|
113
|
+
private fun replaceContent(session: WindowAreaSessionPresenter, createContent: (Context) -> View) {
|
|
114
|
+
destroyContent()
|
|
115
|
+
val content = createContent(session.context)
|
|
116
|
+
dualScreenContent = content as? WebView
|
|
117
|
+
session.setContentView(content)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private fun destroyContent() {
|
|
121
|
+
dualScreenContent?.apply {
|
|
122
|
+
loadUrl("about:blank")
|
|
123
|
+
destroy()
|
|
124
|
+
}
|
|
125
|
+
dualScreenContent = null
|
|
126
|
+
}
|
|
127
|
+
|
|
108
128
|
private fun statusOf(area: WindowAreaInfo?, operation: WindowAreaCapability.Operation): String =
|
|
109
129
|
when (area?.getCapability(operation)?.status) {
|
|
110
130
|
WindowAreaCapability.Status.WINDOW_AREA_STATUS_ACTIVE -> "active"
|
|
@@ -216,13 +216,15 @@ class FoldablePlugin : Plugin() {
|
|
|
216
216
|
@PluginMethod
|
|
217
217
|
fun startDualScreen(call: PluginCall) {
|
|
218
218
|
val url = call.getString("url") ?: return call.reject("url is required.")
|
|
219
|
+
val target = resolveUrl(url)
|
|
220
|
+
?: return call.reject("url must be a page in this app, or a data: URL you built yourself.")
|
|
219
221
|
val displayModes = this.displayModes ?: return call.unavailable("Dual-screen mode needs an activity.")
|
|
220
222
|
|
|
221
223
|
scope.launch {
|
|
222
224
|
when (lastDisplayModes?.dualScreen) {
|
|
223
225
|
"available", "active" -> {
|
|
224
226
|
val settle = Settle(call, "Dual-screen mode")
|
|
225
|
-
displayModes.startDualScreen({ context -> dualScreenWebView(context,
|
|
227
|
+
displayModes.startDualScreen({ context -> dualScreenWebView(context, target) }, settle::started, settle::ended)
|
|
226
228
|
}
|
|
227
229
|
else -> call.unavailable("Dual-screen mode is not available on this device right now.")
|
|
228
230
|
}
|
|
@@ -294,12 +296,32 @@ class FoldablePlugin : Plugin() {
|
|
|
294
296
|
webViewClient = object : WebViewClient() {
|
|
295
297
|
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? =
|
|
296
298
|
bridge.localServer.shouldInterceptRequest(request)
|
|
299
|
+
|
|
300
|
+
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean =
|
|
301
|
+
!isAppUrl(request.url.toString())
|
|
297
302
|
}
|
|
298
|
-
loadUrl(
|
|
303
|
+
loadUrl(url)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The page on the other display shares the app's web view profile, so it is
|
|
308
|
+
* kept to the app's own origin: anything else is refused rather than loaded.
|
|
309
|
+
*/
|
|
310
|
+
private fun resolveUrl(url: String): String? {
|
|
311
|
+
if (url.startsWith("//")) return null
|
|
312
|
+
|
|
313
|
+
val scheme = Uri.parse(url).scheme
|
|
314
|
+
if (scheme == null) return URL(URL(bridge.appUrl), url).toString().takeIf(::isAppUrl)
|
|
315
|
+
if (!scheme.equals("http", true) && !scheme.equals("https", true) && scheme != "data") return null
|
|
316
|
+
|
|
317
|
+
return url.takeIf { scheme == "data" || isAppUrl(it) }
|
|
299
318
|
}
|
|
300
319
|
|
|
301
|
-
private fun
|
|
302
|
-
|
|
320
|
+
private fun isAppUrl(url: String): Boolean {
|
|
321
|
+
val app = Uri.parse(bridge.appUrl)
|
|
322
|
+
val target = Uri.parse(url)
|
|
323
|
+
return target.scheme == app.scheme && target.host == app.host && target.port == app.port
|
|
324
|
+
}
|
|
303
325
|
|
|
304
326
|
private inner class Settle(private val call: PluginCall, private val mode: String) {
|
|
305
327
|
private var settled = false
|
package/ionic-tabs-ios26.css
CHANGED
|
@@ -52,3 +52,12 @@ html.ios:not(.vertical-bars-leading):not(.vertical-bars-trailing) ion-tab-button
|
|
|
52
52
|
background: rgba(255, 255, 255, 0.1);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
@media (prefers-reduced-transparency: reduce), (prefers-contrast: more), (forced-colors: active) {
|
|
57
|
+
html.ios ion-tab-bar {
|
|
58
|
+
background: Canvas;
|
|
59
|
+
-webkit-backdrop-filter: none;
|
|
60
|
+
backdrop-filter: none;
|
|
61
|
+
border: 1px solid CanvasText;
|
|
62
|
+
}
|
|
63
|
+
}
|
package/ionic-tabs.css
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
bottom: var(--vertical-tab-bar-bottom, 24px);
|
|
5
5
|
z-index: 10;
|
|
6
6
|
flex-direction: column;
|
|
7
|
-
width:
|
|
7
|
+
width: 52px;
|
|
8
8
|
height: auto;
|
|
9
9
|
padding: 3px 2px;
|
|
10
10
|
border: none;
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
background: rgba(255, 255, 255, 0.78);
|
|
14
14
|
-webkit-backdrop-filter: blur(12px) saturate(180%);
|
|
15
15
|
backdrop-filter: blur(12px) saturate(180%);
|
|
16
|
+
--color-selected: #0071e3;
|
|
16
17
|
box-shadow:
|
|
17
18
|
inset 0 0 0 0.5px rgba(0, 0, 0, 0.2),
|
|
18
19
|
inset 0 0 8px rgba(0, 0, 0, 0.04),
|
|
@@ -37,14 +38,27 @@
|
|
|
37
38
|
--padding-end: 0;
|
|
38
39
|
flex: 0 0 auto;
|
|
39
40
|
width: 100%;
|
|
41
|
+
min-width: 44px;
|
|
40
42
|
max-width: none;
|
|
41
43
|
height: 54px;
|
|
44
|
+
min-height: 48px;
|
|
42
45
|
border-radius: 22px;
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
/* Hidden to the eye, still there for VoiceOver and TalkBack: `display: none`
|
|
49
|
+
would take the tab's name out of the accessibility tree. */
|
|
45
50
|
.vertical-bars-leading ion-tab-button ion-label,
|
|
46
51
|
.vertical-bars-trailing ion-tab-button ion-label {
|
|
47
|
-
|
|
52
|
+
position: absolute;
|
|
53
|
+
width: 1px;
|
|
54
|
+
height: 1px;
|
|
55
|
+
margin: -1px;
|
|
56
|
+
padding: 0;
|
|
57
|
+
border: 0;
|
|
58
|
+
overflow: hidden;
|
|
59
|
+
clip: rect(0 0 0 0);
|
|
60
|
+
clip-path: inset(50%);
|
|
61
|
+
white-space: nowrap;
|
|
48
62
|
}
|
|
49
63
|
|
|
50
64
|
.vertical-bars-leading ion-tab-button.tab-selected,
|
|
@@ -85,3 +99,15 @@ ion-tab-bar[hidden] {
|
|
|
85
99
|
background: rgba(255, 255, 255, 0.1);
|
|
86
100
|
}
|
|
87
101
|
}
|
|
102
|
+
|
|
103
|
+
/* Reduced transparency, higher contrast, forced colours: drop the blur and
|
|
104
|
+
give the pill an opaque background so the icons stay legible. */
|
|
105
|
+
@media (prefers-reduced-transparency: reduce), (prefers-contrast: more), (forced-colors: active) {
|
|
106
|
+
.vertical-bars-leading ion-tab-bar,
|
|
107
|
+
.vertical-bars-trailing ion-tab-bar {
|
|
108
|
+
background: Canvas;
|
|
109
|
+
-webkit-backdrop-filter: none;
|
|
110
|
+
backdrop-filter: none;
|
|
111
|
+
border: 1px solid CanvasText;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>NSPrivacyTracking</key>
|
|
6
|
+
<false/>
|
|
7
|
+
<key>NSPrivacyTrackingDomains</key>
|
|
8
|
+
<array/>
|
|
9
|
+
<key>NSPrivacyCollectedDataTypes</key>
|
|
10
|
+
<array/>
|
|
11
|
+
<key>NSPrivacyAccessedAPITypes</key>
|
|
12
|
+
<array/>
|
|
13
|
+
</dict>
|
|
14
|
+
</plist>
|
package/package.json
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erkamyaman/capacitor-foldable",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.3.1",
|
|
4
4
|
"description": "Fold-state and dual-display APIs for foldable devices in Capacitor apps",
|
|
5
5
|
"main": "dist/plugin.cjs.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
7
7
|
"types": "dist/esm/index.d.ts",
|
|
8
8
|
"unpkg": "dist/plugin.js",
|
|
9
9
|
"files": [
|
|
10
|
+
"android/build.gradle",
|
|
11
|
+
"android/src/main/",
|
|
12
|
+
"dist/",
|
|
10
13
|
"ErkamyamanCapacitorFoldable.podspec",
|
|
11
14
|
"ionic-tabs-ios26.css",
|
|
12
15
|
"ionic-tabs.css",
|
|
16
|
+
"ios/Sources",
|
|
17
|
+
"ios/Tests",
|
|
13
18
|
"LICENSE",
|
|
14
19
|
"Package.swift",
|
|
15
|
-
"
|
|
16
|
-
"android/src/main/",
|
|
17
|
-
"dist/",
|
|
18
|
-
"ios/Sources",
|
|
19
|
-
"ios/Tests"
|
|
20
|
+
"skills/"
|
|
20
21
|
],
|
|
21
22
|
"author": "Erkam Yaman <erkamyaman35@gmail.com>",
|
|
22
23
|
"license": "MIT",
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: capacitor-foldable
|
|
3
|
+
description: Build Capacitor apps that adapt to foldable devices, iPhone Duo and Android foldables, with @erkamyaman/capacitor-foldable. Trigger when laying out around the crease or fold, reading the hinge angle or posture, using window size classes, polyfilling the Device Posture and Viewport Segments APIs, telling the outer display from the inner one, using rear display or dual screen on an Android foldable, moving an Ionic tab bar where iPhone Duo puts native bars, or testing on the iPhone Duo simulator or a foldable emulator.
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
author: erkamyaman
|
|
7
|
+
version: '0.1'
|
|
8
|
+
status: beta
|
|
9
|
+
checked: 2026-09, against Xcode 27.1, Capacitor 8.5, Play target API 36
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Capacitor Foldable
|
|
13
|
+
|
|
14
|
+
Guidance for [`@erkamyaman/capacitor-foldable`](https://github.com/erkamyaman/capacitor-foldable), which reports the fold to a Capacitor web view: where the crease is, the hinge angle, the posture, and which display the app is on.
|
|
15
|
+
|
|
16
|
+
This skill is new. Everything in it was checked against the plugin source and measured on the iPhone Duo simulator, but it has not been through much real use yet, so [report anything that misleads you](https://github.com/erkamyaman/capacitor-foldable/issues).
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @erkamyaman/capacitor-foldable
|
|
22
|
+
npx cap sync
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Capacitor 8 uses the current major. **If you are on Capacitor 7 and building with Xcode 27, upgrade to 8.5 or later before anything else**: those apps crash at launch, because iOS 27 needs the scene lifecycle that only 8.5 adopted, and a crash on launch is an automatic App Review rejection. Capacitor 7 apps can install `@erkamyaman/capacitor-foldable@7` and build with Xcode 26, which reports the fold on Android and size classes everywhere but has no iPhone Duo support. That route closes when Apple requires the iOS 27 SDK in April 2027. The 7.x line also predates the `--fold-*` variables (8.1.0) and the Ionic tab stylesheets, so the CSS below needs Capacitor 8.
|
|
26
|
+
|
|
27
|
+
## Start here: CSS before JavaScript
|
|
28
|
+
|
|
29
|
+
Install the polyfill once at startup, then lay out with CSS. Most foldable work needs no JavaScript at all.
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import { Foldable, installFoldablePolyfill } from '@erkamyaman/capacitor-foldable';
|
|
33
|
+
|
|
34
|
+
await installFoldablePolyfill();
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Whenever the device reports a fold, that sets `--fold-left`, `--fold-top`, `--fold-width`, `--fold-height` and `--fold-margin-*` on `<html>`, and adds `fold-vertical` or `fold-horizontal`. It also sets the `vertical-bars-*` classes the Ionic stylesheet needs, and fills in the standard Viewport Segments and Device Posture APIs, which Safari and Android's WebView do not provide.
|
|
38
|
+
|
|
39
|
+
Prefer the `--fold-*` variables over the polyfilled segment variables for layout: the segments only split when the fold actually separates the window, which on iPhone Duo means half open, while the fold variables are there when it is open flat too.
|
|
40
|
+
|
|
41
|
+
```css
|
|
42
|
+
.fold-vertical .reader { position: fixed; inset: 0; }
|
|
43
|
+
|
|
44
|
+
.fold-vertical .reader .page {
|
|
45
|
+
position: absolute;
|
|
46
|
+
top: 0;
|
|
47
|
+
bottom: 0;
|
|
48
|
+
padding: 14px;
|
|
49
|
+
box-sizing: border-box;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.fold-vertical .reader .page:first-child { left: 0; width: var(--fold-left); }
|
|
53
|
+
.fold-vertical .reader .page:last-child { left: calc(var(--fold-left) + var(--fold-width)); right: 0; }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
On a phone with no fold there are no `--fold-*` variables and no `fold-*` class, so write the flat layout first and let these rules refine it. The install is also a no-op off native, so nothing appears in a desktop browser: iterate in the simulator or on a device.
|
|
57
|
+
|
|
58
|
+
Handle both orientations. On iPhone Duo the crease is vertical in landscape and horizontal in portrait, so a layout that only styles `.fold-vertical` flattens when the phone is turned.
|
|
59
|
+
|
|
60
|
+
Reach for the plugin's methods when CSS cannot answer the question: the hinge angle, the posture, which display you are on, or where the system put its bars.
|
|
61
|
+
|
|
62
|
+
## When to consult these references
|
|
63
|
+
|
|
64
|
+
- **Laying out around the crease** (variables, classes, worked layouts): [css-layout.md](references/css-layout.md)
|
|
65
|
+
- **The API** (methods, events, and what is platform specific): [api.md](references/api.md)
|
|
66
|
+
- **Ionic apps** (`ion-tabs` on the side, the keyboard event a fold fires, Angular's CSS rule): [ionic.md](references/ionic.md)
|
|
67
|
+
- **iPhone Duo specifics** (reserved regions, vertical bars, safe areas, what the hinge status gets wrong): [iphone-duo.md](references/iphone-duo.md)
|
|
68
|
+
- **Android foldables** (postures, rear display, dual screen): [android.md](references/android.md)
|
|
69
|
+
- **Testing** (iPhone Duo simulator, foldable emulator, driving the hinge from the terminal): [testing.md](references/testing.md)
|
|
70
|
+
|
|
71
|
+
## Hard rules
|
|
72
|
+
|
|
73
|
+
- ✅ **Split on `var(--fold-left)`, never on 50%.** The variables give the band's leading edge, not its centre, and the coordinates are measured against the web view, so any inset moves them off the viewport midpoint. On iPhone Duo the fold is a 40 point band, so a halfway split also lands inside the part the system wants kept clear.
|
|
74
|
+
- ✅ **Treat `--fold-width` as the keep-clear band.** The margins are *inside* it, so `--fold-margin-*` tells you how much of the band is margin rather than physical crease. Do not add them on top of it. They are iOS only and read `0px` on Android, where a seamless fold also reports zero width, so keep your own minimum clearance: `max(12px, var(--fold-width, 0px))`, with the fallback because the variable does not exist at all on a phone with no fold.
|
|
75
|
+
- ✅ **Expect the fold when the device is fully open, not just half.** The position is reported flat as well as folded, so a layout can line up with the crease before anyone bends anything. A *closed* device reports `state: 'flat'` and no `hingeBounds`, because the plugin drops the fold below 20 degrees.
|
|
76
|
+
- ✅ **Let the layout expand; do not design a screen per pose.** Apple: "Don't reinvent your app when it resizes; allow the existing layout to expand based on the available space instead", and "Avoid extreme layout changes as people fold the device." Google's equivalent is to adapt to window size classes rather than device type, and to consider whether controls and text should avoid the hinge space.
|
|
77
|
+
- ❌ **Do not use `setVerticalBarBehavior({ behavior: 'disabled' })` to avoid adapting.** Apple's HIG asks you not to override bar placement. UIKit's own reference gives the exception: disable it only for interfaces better served by horizontal bars, "such as a fullscreen video player with toolbar controls or a non-scrolling layout like a calculator". It returns the bars to horizontal; it does not hand you the full display.
|
|
78
|
+
- ❌ **Do not poll `getFoldState()` on a timer** when `foldStateChange` will tell you. The exception is the moment the app moves between displays, where iOS pauses the web view: see [iphone-duo.md](references/iphone-duo.md).
|
|
79
|
+
- ❌ **Do not confuse "this device folds" with "there is a fold on this screen".** `isDeviceFoldable()` answers the first, though it can answer `false` on an Android foldable that advertises no tabletop posture and no hinge sensor. The presence of `hingeBounds`, or `--fold-left` in CSS, answers the second, and is what layout should key on. For the *outer screen*, neither is the gate: use `getDisplayModes()` and `displayModeChange`, since rear display and dual screen are Android only and read `unsupported` on iPhone Duo even though it folds. Every *read* method answers safely on any device; `startRearDisplay()` and `startDualScreen()` reject when the mode is unavailable.
|
|
80
|
+
|
|
81
|
+
## Before reporting done
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npm run build # or the app's build
|
|
85
|
+
npx cap sync
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Then run it open flat, half open and closed, and fold it shut and open again, which suspends and resumes the web view on iOS. Do one pass at the largest system text size and one with VoiceOver or TalkBack on, since a layout that splits at the crease is exactly where reading order and target sizes go wrong. On iPhone Duo the posture picks the display, so that covers both screens; on Android the outer display needs rear display mode. [testing.md](references/testing.md) has the commands.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Android foldables
|
|
2
|
+
|
|
3
|
+
Backed by Jetpack WindowManager, so the fold comes from whatever the device reports: Pixel Fold, Galaxy Z Fold and Flip, and the foldable emulators.
|
|
4
|
+
|
|
5
|
+
## Postures
|
|
6
|
+
|
|
7
|
+
`getFoldState()` reports `posture` as `flat`, `tabletop` or `book`, and `foldStateChange` fires as the device moves. Tabletop is half open with a horizontal hinge, which is the laptop pose: content above the crease, controls below. Book is half open with a vertical hinge.
|
|
8
|
+
|
|
9
|
+
`isSeparating` says whether the fold actually splits the window into two areas. A flat *foldable* still reports `hingeBounds`, which is what lets a layout line up with the crease before anyone folds it. A device with no fold reports `posture: 'flat'` and no `hingeBounds`, which is the difference worth testing for. It still reports `cameraBounds` from the display cutouts.
|
|
10
|
+
|
|
11
|
+
`hingeMargins`, `activeDisplay` and the hinge `status` are iOS only and never appear here. `cameraBounds` and `occludedBounds` do: the cutouts, and the fold's own bounds when it fully occludes content. To tell the cover screen from the inner one, lean on `getSizeClass()`, where the outer display shows up as a compact width, keeping in mind it describes the window rather than the panel, so rotation and multi-window move it too.
|
|
12
|
+
|
|
13
|
+
## The hinge angle
|
|
14
|
+
|
|
15
|
+
Only devices with a hinge sensor report an angle, and only on Android 11 and later; everything else answers `null`, and the fold state still works, since it comes from WindowManager rather than the sensor. The plugin registers at the normal sensor rate, so it needs no permission and is nowhere near the 200 Hz threshold that would require `HIGH_SAMPLING_RATE_SENSORS`.
|
|
16
|
+
|
|
17
|
+
## Rear display: move the app to the outer screen
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
const { rearDisplay } = await Foldable.getDisplayModes();
|
|
21
|
+
if (rearDisplay === 'available') await Foldable.startRearDisplay();
|
|
22
|
+
await Foldable.stopRearDisplay();
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Statuses are `active`, `available`, `unavailable` and `unsupported`. The classic use is a selfie with the main camera, previewing on the outer screen. The system asks the user to confirm before the app moves.
|
|
26
|
+
|
|
27
|
+
The status the `start` methods check comes from a background collector rather than from `getDisplayModes()`, so immediately after launch a positive check can still be rejected. Listen for `displayModeChange` before offering the button, or retry.
|
|
28
|
+
|
|
29
|
+
## Dual screen: a second window on the other display
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
await Foldable.startDualScreen({ url: 'cover.html' });
|
|
33
|
+
await Foldable.stopDualScreen();
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Load only content you control.** The plugin refuses anything that is not a page of your own app or a `data:` URL, and keeps the second web view on your origin once it is loaded, because that view shares the app's cookies and storage. Never pass a URL that came from a server, a deep link, a push payload or a user.
|
|
37
|
+
|
|
38
|
+
A `data:` URL is the easier route for dynamic content and is what the plugin's own example uses, but note two things. It gets an **opaque origin**, so `localStorage` and friends throw there and it cannot read the app's storage: put what the page needs into the document. And `encodeURIComponent` is URL encoding, not HTML escaping, so interpolating a name or a message into `data:text/html,...` is an injection into a page that has JavaScript on. Escape it for HTML first, or ship a static page and pass values in the fragment for its own script to insert with `textContent`.
|
|
39
|
+
|
|
40
|
+
Treat it as a presentation surface: a plain web view with no Capacitor bridge, so no plugin API inside it. Calling `startDualScreen` again replaces the page rather than messaging it.
|
|
41
|
+
|
|
42
|
+
Both modes share one window area behind the scenes, and both stop when the activity goes away.
|
|
43
|
+
|
|
44
|
+
Both modes report through `displayModeChange`, and both are Android only. On iOS and web they read `unsupported`, and the `start` methods reject.
|
|
45
|
+
|
|
46
|
+
Emulators do not offer dual screen at all, and a Z Flip only in Flex mode, so `unsupported` there is the emulator rather than a bug in your app.
|
|
47
|
+
|
|
48
|
+
## Permissions
|
|
49
|
+
|
|
50
|
+
None. The plugin's manifest is empty and adds nothing to your app.
|
|
51
|
+
|
|
52
|
+
## Quality guidelines
|
|
53
|
+
|
|
54
|
+
Google's adaptive app quality guidelines make posture support a Tier 1 check and adaptive layouts driven by window size classes a Tier 2 one. Keeping controls and text off the fold is separate fold-aware design guidance rather than a quality check: "Don't place UI controls too close to a fold or hinge when `isSeparating` is true." `posture`, `getSizeClass()` and the fold variables are what all three ask for.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# The API, and when each part is the right tool
|
|
2
|
+
|
|
3
|
+
Every **read** method resolves safely on any device, foldable or not. `startRearDisplay()` and `startDualScreen()` reject when the mode is not available; the `stop` methods always resolve.
|
|
4
|
+
|
|
5
|
+
Platform note up front: `hingeMargins`, `activeDisplay` and the hinge `status` are iOS only; `occludedBounds` is Android only. `cameraBounds` is reported on both, and on iOS it carries every active occlusion, which includes the vertical status bar strip as well as the camera.
|
|
6
|
+
|
|
7
|
+
Import both the plugin and the polyfill from the package root:
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import { Foldable, installFoldablePolyfill } from '@erkamyaman/capacitor-foldable';
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Ask once
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
const { foldable, supportsTabletop } = await Foldable.isDeviceFoldable();
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`supportsTabletop` is a real check on Android (the system advertises the posture). On iOS it mirrors `foldable`, so it is not an independent signal there.
|
|
20
|
+
|
|
21
|
+
Do not gate layout on this: an Android foldable that advertises no tabletop posture and has no hinge sensor answers `false` while still reporting a fold. For "is there a fold on this screen", test for `hingeBounds`, remembering a closed device has none. For the outer screen on Android, gate on `getDisplayModes()` instead.
|
|
22
|
+
|
|
23
|
+
## The fold
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
const fold = await Foldable.getFoldState();
|
|
27
|
+
// state: 'flat' | 'half-opened' | 'closed' ('closed' is never emitted today)
|
|
28
|
+
// posture: 'flat' | 'tabletop' | 'book'
|
|
29
|
+
// isSeparating, hingeOrientation, hingeBounds
|
|
30
|
+
// cameraBounds (both: cutouts on Android, active occlusions on iOS)
|
|
31
|
+
// occludedBounds (Android only, when the fold fully occludes content)
|
|
32
|
+
// hingeMargins, activeDisplay (iOS only)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Use `posture` for layout decisions rather than the raw angle. On iOS it is a threshold on the angle at 20 and 160 degrees with no hysteresis, so avoid designs that flip hard exactly at the boundary; on Android it comes straight from WindowManager's own fold state.
|
|
36
|
+
|
|
37
|
+
`activeDisplay` tells the outer display from the inner one, but only on iOS. On Android use `getSizeClass()` for the same job.
|
|
38
|
+
|
|
39
|
+
Listen with `foldStateChange` rather than polling. On Android the very first call can time out after a second and answer `flat`, so treat the first event as the real answer rather than a boot-time read.
|
|
40
|
+
|
|
41
|
+
## The hinge angle
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
const { angle, status } = await Foldable.getHingeAngle();
|
|
45
|
+
// angle: degrees, 0 shut and 180 flat, or null without a hinge sensor
|
|
46
|
+
// status: 'closed' | 'partiallyOpen' | 'fullyOpen' | null (iOS only)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`hingeAngleChange` fires as it moves, carrying the angle alone: call `getHingeAngle()` if you also need the status. On iOS the system's `status` updates lazily and can disagree with the angle; the plugin trusts the angle, and returns both so you can see it. Android reports no status at all.
|
|
50
|
+
|
|
51
|
+
Good for effects and continuous interactions, not for deciding layout.
|
|
52
|
+
|
|
53
|
+
## Reserved regions (iOS 27.1)
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
const { regions } = await Foldable.getReservedRegions();
|
|
57
|
+
// kind: 'division' | 'occlusion', isActive, x/y/width/height, margins
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Everything the system reserves on the current display: the fold, the camera, and the strip the vertical status bar occupies. Inactive regions are included, which is how the fold is reported while the device is open flat. Returns `[]` on Android, on web, and on iOS before 27.1.
|
|
61
|
+
|
|
62
|
+
These coordinates come from the view controller's view, while `hingeBounds` is measured against the web view, so do not mix the two if your web view is inset.
|
|
63
|
+
|
|
64
|
+
## Size classes
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
const { horizontal, vertical, widthClass, heightClass } = await Foldable.getSizeClass();
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Apple's compact and regular plus Material's window classes. On iOS the first pair are real UIKit traits; on Android and web they are breakpoints on the window size. Either way it is the portable way to tell a small screen from a large one, rather than measuring pixels yourself. `sizeClassChange` fires when it changes.
|
|
71
|
+
|
|
72
|
+
## Where the system put its bars
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
const { verticalBarEdge } = await Foldable.getBarPlacement(); // 'leading' | 'trailing' | null
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`null` means no vertical bar edge is reported: bars are horizontal, or the platform cannot say, which is always the case on Android, web and iOS before 27.1. On iPhone Duo the bar also arrives as a safe-area inset, so simple layouts need nothing beyond `env(safe-area-inset-*)`. Use this when positioning your own chrome to match. `barPlacementChange` fires when it moves.
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
await Foldable.setVerticalBarBehavior({ behavior: 'disabled' }); // everything horizontal
|
|
82
|
+
await Foldable.setVerticalBarBehavior({ behavior: 'automatic' }); // system default
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Needs `FoldableBridgeViewController`, and resolves `{ applied: false }` when that is missing, on Android, and on web. On iOS any other string rejects, while a missing one falls back to `automatic`; Android and web resolve without validating. Apple asks you not to override bar placement, so keep this for immersive interfaces.
|
|
86
|
+
|
|
87
|
+
## While the hinge moves
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
await Foldable.addListener('foldingChange', ({ folding }) => { /* pause heavy work */ });
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Only fires where a hinge angle is available: an Android foldable with a hinge sensor, or iPhone Duo on iOS 27.1.
|
|
94
|
+
|
|
95
|
+
## Android display modes
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
const { rearDisplay, dualScreen } = await Foldable.getDisplayModes();
|
|
99
|
+
// each: 'active' | 'available' | 'unavailable' | 'unsupported'
|
|
100
|
+
|
|
101
|
+
if (rearDisplay === 'available') await Foldable.startRearDisplay();
|
|
102
|
+
if (dualScreen === 'available') await Foldable.startDualScreen({ url: 'cover.html' });
|
|
103
|
+
|
|
104
|
+
await Foldable.stopRearDisplay();
|
|
105
|
+
await Foldable.stopDualScreen();
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`startRearDisplay()` shows a system confirmation first, and `startDualScreen()` rejects outright without a `url`. Both accept `available` or `active`.
|
|
109
|
+
|
|
110
|
+
`displayModeChange` reports both modes together. Relative URLs resolve against the app's own URL, so `cover.html` has to be a page you ship in the web build, which for a bundler means adding it as an extra entry point. A `data:` URL works too, and is the easier route for dynamic content.
|
|
111
|
+
|
|
112
|
+
The page must be content you wrote: the plugin rejects any other origin, and `encodeURIComponent` is URL encoding rather than HTML escaping, so escape anything you interpolate into a `data:text/html` document. See [android.md](android.md). That page runs in its own web view with no access to Capacitor plugins, and calling `startDualScreen` again replaces it.
|
|
113
|
+
|
|
114
|
+
Android only; both read as `unsupported` on iOS and web, where the `start` methods reject. See [android.md](android.md).
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Laying out around the crease
|
|
2
|
+
|
|
3
|
+
After `installFoldablePolyfill()` on a native platform, the plugin keeps these on `<html>`.
|
|
4
|
+
|
|
5
|
+
| | |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| Direction, while a fold is reported | `.fold-vertical` (crease runs down the screen), `.fold-horizontal` (across) |
|
|
8
|
+
| Position and size of the fold band | `var(--fold-left)`, `var(--fold-top)`, `var(--fold-width)`, `var(--fold-height)` |
|
|
9
|
+
| How much of the band is margin (iOS only) | `var(--fold-margin-top)`, `--fold-margin-right`, `--fold-margin-bottom`, `--fold-margin-left` |
|
|
10
|
+
| While the hinge moves | `.folding` |
|
|
11
|
+
| Where native bars went | `.vertical-bars-leading`, `.vertical-bars-trailing`, `var(--vertical-tab-bar-bottom)` |
|
|
12
|
+
| Standard APIs, polyfilled | `.horizontal-viewport-segments-2`, `.vertical-viewport-segments-2`, `.device-posture-folded`, `.device-posture-continuous`, `var(--viewport-segment-*)` |
|
|
13
|
+
|
|
14
|
+
Three things to know before using them:
|
|
15
|
+
|
|
16
|
+
- **They are viewport coordinates**, measured against the web view. A grid built from `--fold-left` only lines up if the element's own content box starts at viewport 0. Subtract your own insets otherwise, as the examples app does.
|
|
17
|
+
- **`--fold-width` can be `0px`.** A seamless fold, the kind with no physical gap, reports zero thickness whatever the state, and `--fold-margin-*` is iOS only and reads `0px` on Android. Keep your own minimum gutter, `max(12px, var(--fold-width, 0px))`, rather than relying on the band for visual separation. The fallback matters: on a phone with no fold the variable does not exist, and a bare `var()` with no fallback makes the whole declaration invalid.
|
|
18
|
+
- **`.fold-horizontal` means the hinge is horizontal, not that the device is in tabletop.** It is set while flat too. The tabletop and book postures have no class of their own: read `posture` from `getFoldState()`. (The `.device-posture-*` classes in the table are the Device Posture API's folded and continuous, which is a different thing.)
|
|
19
|
+
|
|
20
|
+
What is conditional: the `--fold-*` variables, `.fold-*`, `.vertical-bars-*`, `.folding` and the `-viewport-segments-2` classes. What is always set once installed: a `device-posture` class (`continuous` unless half opened), the first segment's `--viewport-segment-*-0-0` variables, and `--vertical-tab-bar-bottom`.
|
|
21
|
+
|
|
22
|
+
For safe areas, write `var(--safe-area-inset-top, env(safe-area-inset-top))`. Capacitor injects those variables on **Android** from 8.3 with `insetsHandling: 'css'`, where `env()` is unreliable before WebView 140; on iOS nothing sets them and the `env()` fallback is what resolves.
|
|
23
|
+
|
|
24
|
+
## Two pages, one either side
|
|
25
|
+
|
|
26
|
+
```css
|
|
27
|
+
.reader { display: grid; gap: 12px; }
|
|
28
|
+
|
|
29
|
+
.fold-vertical .reader {
|
|
30
|
+
position: fixed;
|
|
31
|
+
inset: auto 0 0 0;
|
|
32
|
+
top: calc(var(--safe-area-inset-top, env(safe-area-inset-top)) + 56px);
|
|
33
|
+
display: block;
|
|
34
|
+
border: 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.fold-vertical .reader .page {
|
|
38
|
+
position: absolute;
|
|
39
|
+
top: 0;
|
|
40
|
+
bottom: 0;
|
|
41
|
+
padding: 14px;
|
|
42
|
+
box-sizing: border-box;
|
|
43
|
+
overflow: auto;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.fold-vertical .reader .page:first-child { left: 0; width: var(--fold-left); }
|
|
47
|
+
.fold-vertical .reader .page:last-child { left: calc(var(--fold-left) + var(--fold-width)); right: 0; }
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`position: fixed` with `inset` is what makes the viewport coordinates line up, and `top`/`bottom` on the pages is what makes them fill the container. Keep a border off the container: unlike padding, a border shrinks the containing block and shifts both pages off the fold. The pages carry their own padding, with `box-sizing: border-box` so it eats into the width instead of overflowing the crease.
|
|
51
|
+
|
|
52
|
+
## A grid whose gutter is the crease
|
|
53
|
+
|
|
54
|
+
Give the crease its own column, so the normal gutter survives when `--fold-width` is zero, and pin the tiles to the columns either side. Do not rely on a `::before` to reserve the crease column: with no explicit rows, `grid-row: 1 / -1` spans row 1 only, and auto-placement drops tiles into the crease from row 2 down.
|
|
55
|
+
|
|
56
|
+
```css
|
|
57
|
+
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
|
58
|
+
|
|
59
|
+
.fold-vertical .grid {
|
|
60
|
+
--grid-inset: 16px; /* viewport left edge to this element's content box */
|
|
61
|
+
grid-template-columns: calc(var(--fold-left) - var(--grid-inset)) var(--fold-width) 1fr;
|
|
62
|
+
column-gap: 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.fold-vertical .grid > *:nth-child(odd) { grid-column: 1; }
|
|
66
|
+
.fold-vertical .grid > *:nth-child(even) { grid-column: 3; }
|
|
67
|
+
.fold-vertical .grid > * { margin: 0 8px; }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The crease is always the middle track, so the pinning selectors have to skip it. `nth-child(odd)` and `nth-child(even)` is the two column special case; with four columns the tracks become
|
|
71
|
+
|
|
72
|
+
```css
|
|
73
|
+
.fold-vertical .grid {
|
|
74
|
+
grid-template-columns:
|
|
75
|
+
repeat(2, calc((var(--fold-left) - var(--grid-inset)) / 2))
|
|
76
|
+
var(--fold-width)
|
|
77
|
+
repeat(2, 1fr);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.fold-vertical .grid > *:nth-child(4n + 1) { grid-column: 1; }
|
|
81
|
+
.fold-vertical .grid > *:nth-child(4n + 2) { grid-column: 2; }
|
|
82
|
+
.fold-vertical .grid > *:nth-child(4n + 3) { grid-column: 4; }
|
|
83
|
+
.fold-vertical .grid > *:nth-child(4n) { grid-column: 5; }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Apple asks for an even number of columns so content divides cleanly. Either way the tracks left of the crease must add up to `--fold-left` minus your inset, so use explicit widths there rather than `1fr`.
|
|
87
|
+
|
|
88
|
+
## Controls below the crease in tabletop
|
|
89
|
+
|
|
90
|
+
Tabletop is a posture, so it comes from JavaScript. Set a class yourself and let CSS follow:
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
const apply = ({ posture }: { posture: string }) =>
|
|
94
|
+
document.documentElement.classList.toggle('tabletop', posture === 'tabletop');
|
|
95
|
+
|
|
96
|
+
apply(await Foldable.getFoldState());
|
|
97
|
+
const handle = await Foldable.addListener('foldStateChange', apply);
|
|
98
|
+
// later: await handle.remove();
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```css
|
|
102
|
+
.tabletop .stage {
|
|
103
|
+
position: fixed;
|
|
104
|
+
inset: 0;
|
|
105
|
+
display: grid;
|
|
106
|
+
grid-template-rows: var(--fold-top) 1fr;
|
|
107
|
+
row-gap: max(12px, var(--fold-height));
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`position: fixed; inset: 0` matters here too: `--fold-top` is measured from the top of the viewport, and a `.stage` sitting under a header is not.
|
|
112
|
+
|
|
113
|
+
## Pause work while the hinge moves
|
|
114
|
+
|
|
115
|
+
```css
|
|
116
|
+
.folding .spinner { animation-play-state: paused; }
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`animation-play-state` is not inherited and only affects elements that carry a CSS animation, so put it on the animated element itself. For a chart or anything driven by JavaScript, listen to `foldingChange` and stop redrawing instead. The class appears as the hinge starts moving and goes half a second after it stops, and only on devices that report a hinge angle: an Android foldable without a hinge sensor never gets it.
|
|
120
|
+
|
|
121
|
+
## Accessibility
|
|
122
|
+
|
|
123
|
+
The crease is exactly where reading order and target sizes go wrong, so four rules:
|
|
124
|
+
|
|
125
|
+
- **Keep source order equal to visual order.** The `nth-child` pinning above works only while it is. Re-order the markup rather than moving things visually, or a screen reader reads the pages in the wrong sequence.
|
|
126
|
+
- **Name each page and let the keyboard reach it.** Both panes scroll independently, so give them `role="region"` with an `aria-label`, and `tabindex="0"` on the scroll container.
|
|
127
|
+
- **Use `rem` for your own gutters and insets**, not pixels. Android scales web view text with the system font size while iOS does not unless you opt in, so fixed pixel padding drifts in one direction and never moves in the other. Grid items pinned to a fixed track also need `min-width: 0`, or long words push them into the crease.
|
|
128
|
+
- **Targets stay at least 44 pt, or 48 dp on Android**, which matters most for anything sitting beside the fold.
|
|
129
|
+
|
|
130
|
+
`prefers-reduced-motion` is a separate concern from `.folding`: the class is a device signal that comes and goes with the hinge, so honour the media query independently, and use `animation: none` there rather than pausing on an arbitrary frame.
|
|
131
|
+
|
|
132
|
+
Right to left needs its own arm, because the fold variables are physical viewport coordinates while `:first-child` is not:
|
|
133
|
+
|
|
134
|
+
```css
|
|
135
|
+
[dir='rtl'] .fold-vertical .reader .page:first-child { left: calc(var(--fold-left) + var(--fold-width)); right: 0; width: auto; }
|
|
136
|
+
[dir='rtl'] .fold-vertical .reader .page:last-child { left: 0; width: var(--fold-left); }
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## What not to do
|
|
140
|
+
|
|
141
|
+
- Splitting at `50%`. The coordinates are relative to the web view, and on iPhone Duo the band has width.
|
|
142
|
+
- Relying on `--fold-width` for visual separation. It is `0px` on a seamless fold.
|
|
143
|
+
- Treating `.fold-horizontal` as tabletop, or writing a separate layout per posture. Let one layout expand, and move only what must move.
|
|
144
|
+
- Styling only `.fold-vertical`. iPhone Duo's crease is vertical in landscape and horizontal in portrait, so both need an arm or the layout flattens when the phone turns.
|
|
145
|
+
- Building layout on the polyfilled `--viewport-segment-*` variables. They only describe two segments while the fold actually separates the window, so an open-flat device gives you one segment; the `--fold-*` variables are there either way.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Ionic apps
|
|
2
|
+
|
|
3
|
+
## The tab bar belongs on the side
|
|
4
|
+
|
|
5
|
+
On iPhone Duo the system moves native toolbars and tab bars to the side of the display as a floating pill. Ionic's `ion-tabs` is HTML, so it stays a full width bar at the bottom, next to a status bar that has already moved.
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import '@erkamyaman/capacitor-foldable/ionic-tabs.css';
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
That turns `ion-tabs` into a side pill exactly where native bars go, and only where the system actually puts bars there. It keys on the `vertical-bars-leading` and `vertical-bars-trailing` classes, which `installFoldablePolyfill()` sets, so the stylesheet does nothing on its own. There is also `ionic-tabs-ios26.css`, which gives the horizontal bar iOS 26's floating look on every iPhone.
|
|
12
|
+
|
|
13
|
+
**Angular cannot import CSS from TypeScript.** That is TypeScript's error rather than Angular's: a side-effect import of a `.css` file has no type declarations, which TypeScript 6 reports as TS2882 (`noUncheckedSideEffectImports` is on by default there) and older versions as TS2307. Angular's `styles` array in `angular.json` is the other normal route. Put it in `src/global.scss` instead, and **as the first rule in the file**, because Sass rejects `@use` after any other rule, including the `@import` lines a stock Ionic `global.scss` starts with:
|
|
14
|
+
|
|
15
|
+
```scss
|
|
16
|
+
@use '@erkamyaman/capacitor-foldable/ionic-tabs.css';
|
|
17
|
+
|
|
18
|
+
@import "@ionic/angular/css/core.css";
|
|
19
|
+
// ...the rest of the Ionic imports
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
React and Vue import it from the entry file as usual, since their bundlers ship type shims for CSS.
|
|
23
|
+
|
|
24
|
+
## A fold fires a keyboard event
|
|
25
|
+
|
|
26
|
+
iPhone Duo sends a keyboard event when the device folds. Ionic's keyboard controller measures the app container's height the first time the keyboard opens and caches it, then waits for that exact height before showing the tab bar again. After a fold the height no longer matches, so the bar never comes back. Opt in to the fix:
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
await installFoldablePolyfill({ ionicKeyboard: true });
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
It swallows Capacitor's raw keyboard events in the capture phase and puts a `foldable-keyboard-open` class on `<html>` only when something is genuinely focused. That class does nothing on its own: `ionic-tabs.css` carries the rule that hides the bar while the keyboard is up, so take both or neither. The `ios26` stylesheet does not carry that rule. Off by default, since it takes over those events.
|
|
33
|
+
|
|
34
|
+
## Two views of one screen
|
|
35
|
+
|
|
36
|
+
If your app has two modes that are really one screen, swap the view in place rather than routing between tabs. Routing mounts a lazy page on every tap, which shows as a blank frame, and Ionic's page transition then slides the new page in sideways, which is very visible on a wide inner display.
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
readonly mode = signal<'game' | 'meter'>('game');
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```html
|
|
43
|
+
@if (mode() === 'game') { <app-game /> } @else { <app-meter /> }
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
If you do route, `provideIonicAngular({ animated: false })` removes the slide, but be aware it disables every Ionic animation app-wide, including modals and ripples.
|
|
47
|
+
|
|
48
|
+
## Apple's rules for the side bar
|
|
49
|
+
|
|
50
|
+
The vertical bar has an order: primary navigation at the top, then prominent actions. Apple also asks you to prefer a symbol over text there, since anything with a label stays in a horizontal bar. The stylesheet hides the labels visually for that reason but keeps them for VoiceOver, so leave them in your markup.
|
|
51
|
+
|
|
52
|
+
## Styling gotcha
|
|
53
|
+
|
|
54
|
+
`ion-tab-bar` ships `contain: strict`, which brings size containment with it. The moment you let either dimension come from its contents, it lays out as if empty and collapses: height in the side pill, width in the iOS 26 variant. Both shipped stylesheets set `contain: none` for that reason.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# iPhone Duo
|
|
2
|
+
|
|
3
|
+
Needs iOS 27.1 and Xcode 27.1 or later. Older versions compile and simply report no fold.
|
|
4
|
+
|
|
5
|
+
## Measured geometry
|
|
6
|
+
|
|
7
|
+
Inner display 951 x 669 points in landscape, outer 466 x 678. The fold arrives in JavaScript as `hingeBounds` of x 456, width 40, and a separate `hingeMargins` of 20 points left and right. Those margins are *inside* the band rather than extra to it, so the crease itself is the zero width centre and the whole 40 points is the keep-clear area.
|
|
8
|
+
|
|
9
|
+
UIKit reports the region on a half point, and the plugin rounds to whole CSS pixels.
|
|
10
|
+
|
|
11
|
+
## The fold is inactive when flat
|
|
12
|
+
|
|
13
|
+
A fully open iPhone Duo still has a fold, but the system marks that region inactive. The plugin always asks for inactive regions, which is why `--fold-left` is set whether the device is open flat or half folded. Below 20 degrees the plugin drops the fold, so a closed device reports no `hingeBounds` and no `--fold-*` on the outer display, even though `getReservedRegions()` still lists the region.
|
|
14
|
+
|
|
15
|
+
Writing native code beside the plugin, the same call needs it spelled out:
|
|
16
|
+
|
|
17
|
+
```swift
|
|
18
|
+
if #available(iOS 27.1, *) {
|
|
19
|
+
let regions = view.reservedRegions(kind: .division, options: .includeInactive)
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## The hinge status lags the angle
|
|
24
|
+
|
|
25
|
+
Apple's header suggests preferring `status` when you only need open, closed or partly open. In practice it updates lazily, so the two disagree while the device moves, which is this plugin's own observation rather than documented behaviour. The plugin decides the fold state from the angle whenever a fold region exists, falling back to the status otherwise, and `getHingeAngle()` returns both so you can see the disagreement yourself.
|
|
26
|
+
|
|
27
|
+
## Vertical bars and safe areas
|
|
28
|
+
|
|
29
|
+
The system moves bars to the side, and the web view's safe areas follow. Measured on the simulator: with bars on the side, the strip arrives as `env(safe-area-inset-right)` of 84 points on both displays. Calling `setVerticalBarBehavior({ behavior: 'disabled' })` moves it to `env(safe-area-inset-top)` of 82 and drops the right inset to zero, live and without a reload.
|
|
30
|
+
|
|
31
|
+
So for most layouts, padding with the safe-area insets is enough, and `getBarPlacement()` is for when you are positioning your own chrome to match the system's.
|
|
32
|
+
|
|
33
|
+
The opt-out needs the plugin's view controller in `ios/App/App/SceneDelegate.swift`:
|
|
34
|
+
|
|
35
|
+
```swift
|
|
36
|
+
import Capacitor
|
|
37
|
+
import FoldablePlugin
|
|
38
|
+
|
|
39
|
+
window?.rootViewController = FoldableBridgeViewController()
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`FoldablePlugin` is the module name for a Swift Package install, which is what `npx cap add ios` sets up for a new Capacitor 8 app. On a CocoaPods install the module is `ErkamyamanCapacitorFoldable`. Older apps that build the controller in `Main.storyboard` set the class there instead.
|
|
43
|
+
|
|
44
|
+
## The web view pauses between displays
|
|
45
|
+
|
|
46
|
+
When the app moves between the outer and inner display, the web view stops running: timers freeze and events queued during the fold arrive late. That is this project's own observation from the simulator rather than documented behaviour, but it is consistent and worth designing around. If your app reads the hinge continuously, refresh state on `visibilitychange`, `focus` and `resize` after a fold, and keep a slow poll as a safety net.
|
|
47
|
+
|
|
48
|
+
The window can also report its previous size for a moment after a fold. The polyfill already measures again on the next frame and once more shortly after, so CSS variables settle by themselves.
|
|
49
|
+
|
|
50
|
+
## What crosses the crease
|
|
51
|
+
|
|
52
|
+
Keep interactive and readable content out of the fold band; continuous visual content such as a photo, a map or a drawing canvas can cross it, and so can scrolling content.
|
|
53
|
+
|
|
54
|
+
Apple's own fold guidance is to prefer a layout container that adapts by itself, to "prefer an even number of columns so content divides cleanly" in a grid, to "use the `ReservedRegion` API to keep important elements clear of the center if the system doesn't move them automatically", and to favour small adjustments over rearrangement as the device folds.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Testing a foldable layout
|
|
2
|
+
|
|
3
|
+
## iPhone Duo simulator
|
|
4
|
+
|
|
5
|
+
Needs the iOS 27.1 runtime, which the device type refuses to go below, and Xcode 27.1 to build against the 27.1 SDK. Build against the 27.0 SDK and the app fills more of the inner display but still leaves gaps; build against anything older and it runs in compatibility mode inside a black border with no fold reported. In Xcode 27 the simulator lives inside **DeviceHub.app**, not `Simulator.app`.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
open "$(dirname "$(xcode-select -p)")/Applications/DeviceHub.app"
|
|
9
|
+
npx cap run ios # pick the iPhone Duo
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
If several Xcodes are installed, check `xcode-select -p` first, or set `DEVELOPER_DIR` to the 27.1 one for the build, or the app compiles against the wrong SDK and reports no fold.
|
|
13
|
+
|
|
14
|
+
Fold it with the hinge slider in DeviceHub; hold Option for finer control. The inner and outer displays are separate windows.
|
|
15
|
+
|
|
16
|
+
Screenshots from the terminal, where display 1 is the outer screen and display 3 the inner. The ids are not guaranteed, so `xcrun simctl io <udid> enumerate` lists them:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
xcrun simctl io <udid> screenshot --display=3 inner.png
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Driving the hinge from the terminal is far more repeatable than dragging the slider, using [`hinge`](https://github.com/artemnovichkov/hinge):
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
hinge -d <udid> sweep 180 60 2 # 180 to 60 degrees over 2 seconds
|
|
26
|
+
hinge -d <udid> get
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Sweep rather than jumping: an instant change can confuse the simulator's display switching.
|
|
30
|
+
|
|
31
|
+
Known limitation: taps and key input do not land on the Duo simulator through automation ([XcodeBuildMCP #537](https://github.com/getsentry/XcodeBuildMCP/issues/537)), so scripted UI tests cannot drive it yet. Screenshots work from the terminal with an explicit `--display`; through tooling that cannot pass one, a folded screenshot comes back black because it always targets the inner panel.
|
|
32
|
+
|
|
33
|
+
## Android foldable emulator
|
|
34
|
+
|
|
35
|
+
Create a Pixel 9 Pro Fold or 7.6" foldable AVD, then fold it from the extended controls, or:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
adb emu fold # folded
|
|
39
|
+
adb emu unfold # unfolded
|
|
40
|
+
adb shell cmd device_state print-states # this device's state ids
|
|
41
|
+
adb shell cmd device_state state <id> # force one, ids differ per device
|
|
42
|
+
adb shell cmd device_state state reset # release the override
|
|
43
|
+
adb emu sensor set hinge-angle0 90 # a specific angle
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`adb emu` only talks to an emulator; `cmd device_state` works on real devices too. The state ids are device specific, so print them rather than guessing.
|
|
47
|
+
|
|
48
|
+
## What to actually check
|
|
49
|
+
|
|
50
|
+
- Open flat, half open, and closed, so you see **both displays**. These are hands-on checks in DeviceHub, since scripted drivers cannot tap the Duo. A layout only ever seen flat has not been tested.
|
|
51
|
+
- Nothing interactive sits inside the fold band: `var(--fold-width)` wide from `var(--fold-left)`, or `var(--fold-height)` tall from `var(--fold-top)` for a horizontal fold.
|
|
52
|
+
- The app survives being folded shut and opened again, which on iOS suspends and resumes the web view.
|
|
53
|
+
- Text stays readable when the window is short and wide (the inner display held sideways, 951 x 669) and when it is tall and narrow (the outer display upright, 466 x 678). Both panels rotate, so check the other orientation of each.
|