Skip to content
Merged
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
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: CI

on:
pull_request:
branches: [beta, main]
push:
branches: [beta, main]

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
js:
name: Typecheck, lint, test y build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version-file: .nvmrc
cache: yarn
- run: yarn install --immutable
- run: yarn typecheck
- run: yarn lint
- run: yarn test --ci
- run: yarn prepare

android:
name: Compilar módulo Android
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version-file: .nvmrc
cache: yarn
- uses: actions/setup-java@v5
with:
distribution: zulu
java-version: 17
- uses: gradle/actions/setup-gradle@v4
- run: yarn install --immutable
- run: ./gradlew :react-native-fold:compileDebugKotlin
working-directory: example/android
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,25 @@ Formato basado en [Keep a Changelog](https://keepachangelog.com/es-ES/); version
### Añadido

### Cambiado
- El repositorio se mudó de GitLab a GitHub (`acertijosapps/react-native-fold`): `repository` y `bugs` de `package.json`, `source` del podspec y la guía de contribución (Pull Requests).

### Corregido

### Eliminado

## [1.1.0] - 2026-11-01

### Añadido
- Prop `splitWhen` (`'separating' | 'halfOpened'`) en `FoldPane` y `FoldBookPane`, y el tipo `FoldSplitWhen`.
- Prop `splitWhenWide` en `FoldBookPane`: sin bisagra que separe y con la ventana de al menos 840 × 480 dp, divide `start` | `end` al 50 %. Sirve para tablets en horizontal; `useFold()` no cambia.
- CI en GitHub Actions para cada PR y push a `beta` y `main`: typecheck, lint, tests, build de la librería y compilación del módulo Android.

### Cambiado
- `FoldPane` y `FoldBookPane` dividen siempre que la bisagra separa el contenido (`isSeparating`), también con el dispositivo completamente abierto; antes exigían además `isHalfOpened`. En plegables Android de una pantalla no cambia nada. Para el comportamiento anterior: `splitWhen="halfOpened"`.
- El repositorio se mudó de GitLab a GitHub (`acertijosapps/react-native-fold`): `repository` y `bugs` de `package.json`, `source` del podspec y la guía de contribución (Pull Requests).

### Corregido
- Android: `getFoldState()` ya no devuelve "sin bisagra" antes del primer evento de `WindowInfoTracker`; lee el layout actual con `getCurrentWindowLayoutInfo`, así que el primer render de `FoldProvider` ya trae la bisagra en vez de saltar de `null` al estado real.

## [1.0.1] - 2026-10-19

### Corregido
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ When opening a PR:
- Keep it small and focused on one change.
- Assign at least one reviewer; merge requires their approval.
- Link the related work item for traceability.
- Gates must be green: `yarn typecheck`, `yarn lint`, `yarn test`.
- Gates must be green: `yarn typecheck`, `yarn lint`, `yarn test`. CI ([`.github/workflows/ci.yml`](./.github/workflows/ci.yml)) runs them on every PR to `beta` or `main`, plus the library build and the Android module compile.
- Include the `CHANGELOG.md` entry.

## Releasing (`beta` → `main`)
Expand Down
38 changes: 35 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,40 @@ type FoldState = {
`bounds` is always in dp: Android divides by density in Kotlin; iOS points are already
density-independent.

`FoldPane` splits only when `isSeparating && isHalfOpened && orientation === 'horizontal'`,
and falls back when the hinge is not strictly inside the pane. It measures its own position
`FoldPane` splits only when `isSeparating && orientation === 'horizontal'`, and falls back when the hinge is not strictly inside the pane. It measures its own position
in the window, so panels line up with the hinge even below a header; the spacer takes the
hinge height and it never splits 50/50.

`FoldBookPane` is the book-posture counterpart: it splits `start` | `end` side by side only
when `isSeparating && isHalfOpened && orientation === 'vertical'`, measures its own x offset
when `isSeparating && orientation === 'vertical'`, measures its own x offset
in the window and reserves `bounds.width` between both panels (the fold plus its margins on
iOS, the fold line on Android). Nest `FoldPane` as its `fallback` to cover both postures.

Both take `splitWhen` (`FoldSplitWhen`):

- `'separating'` (default) splits whenever the hinge separates the content: half opened, or a
fully open device whose hinge still divides the screen (dual-screen devices). On
single-screen Android foldables `isSeparating` is only true when half opened.
- `'halfOpened'` also requires `isHalfOpened`, the 1.0 behavior.

### Tablets and wide windows

`FoldBookPane` takes `splitWhenWide` (default `false`). When there is no separating hinge and
the window is at least **840 × 480 dp** (Material's expanded width, without a compact height),
it splits `start` | `end` 50/50 with no spacer:

```tsx
<FoldBookPane splitWhenWide start={<List />} end={<Detail />} fallback={<List />} />
```

- Tablets split in landscape and keep `fallback` in portrait; phones in landscape stay single
pane because their height is compact.
- It reads the window (`useWindowDimensions`), so iPad Split View, Stage Manager and Android
multi-window resize it correctly; a flat foldable's inner display counts as a small tablet.
- A real hinge always wins: a separating vertical hinge splits at the hinge, and a separating
horizontal one keeps `fallback` so a nested `FoldPane` can take the tabletop posture.
- `useFold()` is not affected: it still reports only physical hinges.

## Platform support

- Android: `WindowInfoTrackerCallbackAdapter` + `FoldingFeature` (`androidx.window` 1.5.1).
Expand All @@ -92,6 +116,14 @@ iOS, the fold line on Android). Nest `FoldPane` as its `fallback` to cover both
`PartiallyOpen`. On the cover display there is no division region and `useFold()` is `null`;
below iOS 27.1 it is always `null`.

## Author

Made by [AcertijosApps](https://acertijosapps.com/librerias). See the rest of our libraries at
[acertijosapps.com/librerias](https://acertijosapps.com/librerias).

Questions or proposals: [acertijos.contacto@gmail.com](mailto:acertijos.contacto@gmail.com) or open an
[issue](https://github.com/acertijosapps/react-native-fold/issues).

## License

MIT
20 changes: 17 additions & 3 deletions android/src/main/java/com/fold/FoldModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,18 @@ class FoldModule(reactContext: ReactApplicationContext) :
val height: Double
)

private val tracker =
WindowInfoTrackerCallbackAdapter(WindowInfoTracker.getOrCreate(reactContext))
private val windowInfoTracker = WindowInfoTracker.getOrCreate(reactContext)

private val tracker = WindowInfoTrackerCallbackAdapter(windowInfoTracker)

private val executor: Executor = ContextCompat.getMainExecutor(reactContext)

@Volatile private var lastData = FoldData(false, false, false, "horizontal", 0.0, 0.0, 0.0, 0.0)

@Volatile private var hasLayoutInfo = false

private val consumer = Consumer<WindowLayoutInfo> { layoutInfo ->
hasLayoutInfo = true
lastData = buildData(layoutInfo)
emitOnFoldChange(toWritableMap(lastData))
}
Expand All @@ -42,7 +46,17 @@ class FoldModule(reactContext: ReactApplicationContext) :
reactContext.addLifecycleEventListener(this)
}

override fun getFoldState(): WritableMap = toWritableMap(lastData)
override fun getFoldState(): WritableMap {
if (!hasLayoutInfo) {
readCurrentLayoutInfo()?.let { lastData = buildData(it) }
}
return toWritableMap(lastData)
}

private fun readCurrentLayoutInfo(): WindowLayoutInfo? {
val activity = reactApplicationContext.currentActivity ?: return null
return runCatching { windowInfoTracker.getCurrentWindowLayoutInfo(activity) }.getOrNull()
}

override fun onHostResume() {
val activity = reactApplicationContext.currentActivity ?: return
Expand Down
1 change: 1 addition & 0 deletions example/src/InboxScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ const InboxScreen = (): React.JSX.Element => {
<View style={styles.screen}>
<FoldStatusBar />
<FoldBookPane
splitWhenWide
start={list}
end={detail}
fallback={
Expand Down
12 changes: 10 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@acertijosapps/react-native-fold",
"version": "1.0.1",
"version": "1.1.0",
"description": "Foldable detection (tabletop/book) for React Native on Android and iOS",
"main": "./lib/module/index.js",
"types": "./lib/typescript/src/index.d.ts",
Expand Down Expand Up @@ -43,7 +43,15 @@
"keywords": [
"react-native",
"ios",
"android"
"android",
"foldable",
"foldables",
"dual-screen",
"hinge",
"iphone-duo",
"jetpack-windowmanager",
"turbo-module",
"new-architecture"
],
"repository": {
"type": "git",
Expand Down
48 changes: 40 additions & 8 deletions src/FoldBookPane.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,39 @@
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { type HostInstance, StyleSheet, View } from 'react-native';
import { type HostInstance, StyleSheet, useWindowDimensions, View } from 'react-native';

import type { FoldSplitWhen } from './types';
import { useFold } from './useFold';

export type FoldBookPaneProps = {
start: ReactNode;
end: ReactNode;
fallback: ReactNode;
splitWhen?: FoldSplitWhen;
splitWhenWide?: boolean;
};

const WIDE_MIN_WIDTH = 840;
const WIDE_MIN_HEIGHT = 480;

type PaneFrame = {
x: number;
width: number;
};

const FoldBookPane = ({ start, end, fallback }: FoldBookPaneProps): React.JSX.Element => {
type PaneSplit = {
start: number;
spacer: number;
};

const FoldBookPane = ({
start,
end,
fallback,
splitWhen = 'separating',
splitWhenWide = false,
}: FoldBookPaneProps): React.JSX.Element => {
const fold = useFold();
const windowSize = useWindowDimensions();
const paneRef = useRef<HostInstance>(null);
const [frame, setFrame] = useState<PaneFrame | null>(null);

Expand All @@ -34,25 +52,39 @@ const FoldBookPane = ({ start, end, fallback }: FoldBookPaneProps): React.JSX.El
}, [fold, measure]);

const left = fold != null && frame != null ? fold.bounds.x - frame.x : 0;
const isSplit =
const isFoldSplit =
fold != null &&
frame != null &&
fold.isSeparating &&
fold.isHalfOpened &&
(splitWhen === 'separating' || fold.isHalfOpened) &&
fold.orientation === 'vertical' &&
left > 0 &&
left + fold.bounds.width < frame.width;
const isWideSplit =
!isFoldSplit &&
splitWhenWide &&
frame != null &&
(fold == null || !fold.isSeparating) &&
windowSize.width >= WIDE_MIN_WIDTH &&
windowSize.height >= WIDE_MIN_HEIGHT;

let split: PaneSplit | null = null;
if (isFoldSplit) {
split = { start: left, spacer: fold.bounds.width };
} else if (isWideSplit) {
split = { start: frame.width / 2, spacer: 0 };
}

return (
<View
ref={paneRef}
style={isSplit ? styles.row : styles.container}
style={split != null ? styles.row : styles.container}
onLayout={measure}
>
{isSplit ? (
{split != null ? (
<>
<View style={{ width: left }}>{start}</View>
<View style={{ width: fold.bounds.width }} />
<View style={{ width: split.start }}>{start}</View>
<View style={{ width: split.spacer }} />
<View style={styles.fill}>{end}</View>
</>
) : (
Expand Down
11 changes: 9 additions & 2 deletions src/FoldPane.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { type HostInstance, StyleSheet, View } from 'react-native';

import type { FoldSplitWhen } from './types';
import { useFold } from './useFold';

export type FoldPaneProps = {
primary: ReactNode;
secondary: ReactNode;
fallback: ReactNode;
splitWhen?: FoldSplitWhen;
};

type PaneFrame = {
y: number;
height: number;
};

const FoldPane = ({ primary, secondary, fallback }: FoldPaneProps): React.JSX.Element => {
const FoldPane = ({
primary,
secondary,
fallback,
splitWhen = 'separating',
}: FoldPaneProps): React.JSX.Element => {
const fold = useFold();
const paneRef = useRef<HostInstance>(null);
const [frame, setFrame] = useState<PaneFrame | null>(null);
Expand All @@ -38,7 +45,7 @@ const FoldPane = ({ primary, secondary, fallback }: FoldPaneProps): React.JSX.El
fold != null &&
frame != null &&
fold.isSeparating &&
fold.isHalfOpened &&
(splitWhen === 'separating' || fold.isHalfOpened) &&
fold.orientation === 'horizontal' &&
top > 0 &&
top + fold.bounds.height < frame.height;
Expand Down
Loading
Loading