diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..fcfd0c5
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5832600..b955d1c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 435a8c9..b666090 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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`)
diff --git a/README.md b/README.md
index f5ddf5f..15f006e 100644
--- a/README.md
+++ b/README.md
@@ -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
+} end={} fallback={
} />
+```
+
+- 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).
@@ -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
diff --git a/android/src/main/java/com/fold/FoldModule.kt b/android/src/main/java/com/fold/FoldModule.kt
index f6181c9..16fc424 100644
--- a/android/src/main/java/com/fold/FoldModule.kt
+++ b/android/src/main/java/com/fold/FoldModule.kt
@@ -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 { layoutInfo ->
+ hasLayoutInfo = true
lastData = buildData(layoutInfo)
emitOnFoldChange(toWritableMap(lastData))
}
@@ -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
diff --git a/example/src/InboxScreen.tsx b/example/src/InboxScreen.tsx
index 0b61fe2..45fc82c 100644
--- a/example/src/InboxScreen.tsx
+++ b/example/src/InboxScreen.tsx
@@ -160,6 +160,7 @@ const InboxScreen = (): 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(null);
const [frame, setFrame] = useState(null);
@@ -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 (
- {isSplit ? (
+ {split != null ? (
<>
- {start}
-
+ {start}
+
{end}
>
) : (
diff --git a/src/FoldPane.tsx b/src/FoldPane.tsx
index ef1bb4d..c062cdd 100644
--- a/src/FoldPane.tsx
+++ b/src/FoldPane.tsx
@@ -1,12 +1,14 @@
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 = {
@@ -14,7 +16,12 @@ type PaneFrame = {
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(null);
const [frame, setFrame] = useState(null);
@@ -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;
diff --git a/src/__tests__/FoldBookPane.test.tsx b/src/__tests__/FoldBookPane.test.tsx
index 1ca30be..4c54232 100644
--- a/src/__tests__/FoldBookPane.test.tsx
+++ b/src/__tests__/FoldBookPane.test.tsx
@@ -1,19 +1,28 @@
-import { Text, View } from 'react-native';
+import { Text, useWindowDimensions, View } from 'react-native';
-import { afterEach, describe, expect, it, jest } from '@jest/globals';
+import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react-native';
-import FoldBookPane from '../FoldBookPane';
+import FoldBookPane, { type FoldBookPaneProps } from '../FoldBookPane';
import type { FoldState } from '../types';
import { useFold } from '../useFold';
jest.mock('../useFold', () => ({ useFold: jest.fn() }));
+jest.mock('react-native/Libraries/Utilities/useWindowDimensions', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
type MeasureInWindow = (
callback: (x: number, y: number, width: number, height: number) => void,
) => void;
const mockedUseFold = jest.mocked(useFold);
+const mockedUseWindowDimensions = jest.mocked(useWindowDimensions);
+
+const mockWindow = (width: number, height: number): void => {
+ mockedUseWindowDimensions.mockReturnValue({ width, height, scale: 2, fontScale: 1 });
+};
const mockPaneFrame = (x: number, width: number): void => {
const { prototype } = View as unknown as { prototype: { measureInWindow: MeasureInWindow } };
@@ -30,15 +39,20 @@ const book = (overrides: Partial> = {}): FoldState => ({
...overrides,
});
-const renderPane = () =>
+const renderPane = (props: Partial = {}) =>
render(
start}
end={end}
fallback={fallback}
+ {...props}
/>,
);
+beforeEach(() => {
+ mockWindow(951, 669);
+});
+
afterEach(() => {
jest.restoreAllMocks();
});
@@ -101,6 +115,24 @@ describe('FoldBookPane', () => {
expect(screen.getByTestId('fallback')).toBeTruthy();
});
+ it('splits around a separating hinge on a fully open device', async () => {
+ mockedUseFold.mockReturnValue(book({ isHalfOpened: false }));
+ mockPaneFrame(0, 951);
+
+ await renderPane();
+
+ expect(screen.getByTestId('start').parent?.props.style).toEqual({ width: 455.5 });
+ });
+
+ it('falls back on a fully open device when splitWhen is halfOpened', async () => {
+ mockedUseFold.mockReturnValue(book({ isHalfOpened: false }));
+ mockPaneFrame(0, 951);
+
+ await renderPane({ splitWhen: 'halfOpened' });
+
+ expect(screen.getByTestId('fallback')).toBeTruthy();
+ });
+
it('falls back when the hinge reaches the pane end edge', async () => {
mockedUseFold.mockReturnValue(book());
mockPaneFrame(0, 480);
@@ -109,4 +141,108 @@ describe('FoldBookPane', () => {
expect(screen.getByTestId('fallback')).toBeTruthy();
});
+
+ describe('splitWhenWide', () => {
+ it('splits in half on a wide window without a fold', async () => {
+ mockedUseFold.mockReturnValue(null);
+ mockWindow(1280, 800);
+ mockPaneFrame(0, 1280);
+
+ await renderPane({ splitWhenWide: true });
+
+ const startPanel = screen.getByTestId('start').parent;
+ const [, spacer] = startPanel?.parent?.children ?? [];
+
+ expect(startPanel?.props.style).toEqual({ width: 640 });
+ expect(typeof spacer === 'string' ? null : spacer?.props.style).toEqual({ width: 0 });
+ expect(screen.getByTestId('end').parent?.props.style).toEqual({ flex: 1 });
+ expect(screen.queryByTestId('fallback')).toBeNull();
+ });
+
+ it('splits in half over a flat non separating fold', async () => {
+ mockedUseFold.mockReturnValue(book({ isHalfOpened: false, isSeparating: false }));
+ mockWindow(900, 700);
+ mockPaneFrame(0, 900);
+
+ await renderPane({ splitWhenWide: true });
+
+ expect(screen.getByTestId('start').parent?.props.style).toEqual({ width: 450 });
+ });
+
+ it('keeps the fallback without the prop', async () => {
+ mockedUseFold.mockReturnValue(null);
+ mockWindow(1280, 800);
+ mockPaneFrame(0, 1280);
+
+ await renderPane();
+
+ expect(screen.getByTestId('fallback')).toBeTruthy();
+ });
+
+ it('keeps the fallback on a tablet in portrait', async () => {
+ mockedUseFold.mockReturnValue(null);
+ mockWindow(800, 1280);
+ mockPaneFrame(0, 800);
+
+ await renderPane({ splitWhenWide: true });
+
+ expect(screen.getByTestId('fallback')).toBeTruthy();
+ });
+
+ it('keeps the fallback on a phone in landscape', async () => {
+ mockedUseFold.mockReturnValue(null);
+ mockWindow(915, 412);
+ mockPaneFrame(0, 915);
+
+ await renderPane({ splitWhenWide: true });
+
+ expect(screen.getByTestId('fallback')).toBeTruthy();
+ });
+
+ it('keeps the fallback while a horizontal hinge separates', async () => {
+ mockedUseFold.mockReturnValue(
+ book({
+ orientation: 'horizontal',
+ bounds: { x: 0, y: 456, width: 1280, height: 40 },
+ }),
+ );
+ mockWindow(1280, 951);
+ mockPaneFrame(0, 1280);
+
+ await renderPane({ splitWhenWide: true });
+
+ expect(screen.getByTestId('fallback')).toBeTruthy();
+ });
+
+ it('prefers the hinge split over the wide split', async () => {
+ mockedUseFold.mockReturnValue(book());
+ mockWindow(951, 669);
+ mockPaneFrame(0, 951);
+
+ await renderPane({ splitWhenWide: true });
+
+ expect(screen.getByTestId('start').parent?.props.style).toEqual({ width: 455.5 });
+ });
+
+ it('keeps the same panel views when switching from the hinge to the wide split', async () => {
+ mockedUseFold.mockReturnValue(book());
+ mockWindow(951, 669);
+ mockPaneFrame(0, 951);
+ const { rerender } = await renderPane({ splitWhenWide: true });
+ const startPanel = screen.getByTestId('start').parent;
+
+ mockedUseFold.mockReturnValue(book({ isHalfOpened: false, isSeparating: false }));
+ await rerender(
+ start}
+ end={end}
+ fallback={fallback}
+ />,
+ );
+
+ expect(screen.getByTestId('start').parent).toBe(startPanel);
+ expect(startPanel?.props.style).toEqual({ width: 475.5 });
+ });
+ });
});
diff --git a/src/__tests__/FoldPane.test.tsx b/src/__tests__/FoldPane.test.tsx
index 71a19a7..ca691d3 100644
--- a/src/__tests__/FoldPane.test.tsx
+++ b/src/__tests__/FoldPane.test.tsx
@@ -3,7 +3,7 @@ import { Text, View } from 'react-native';
import { afterEach, describe, expect, it, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react-native';
-import FoldPane from '../FoldPane';
+import FoldPane, { type FoldPaneProps } from '../FoldPane';
import type { FoldState } from '../types';
import { useFold } from '../useFold';
@@ -30,12 +30,13 @@ const tabletop = (overrides: Partial> = {}): FoldState =>
...overrides,
});
-const renderPane = () =>
+const renderPane = (props: Partial = {}) =>
render(
primary}
secondary={secondary}
fallback={fallback}
+ {...props}
/>,
);
@@ -92,6 +93,24 @@ describe('FoldPane', () => {
expect(screen.getByTestId('fallback')).toBeTruthy();
});
+ it('splits around a separating hinge on a fully open device', async () => {
+ mockedUseFold.mockReturnValue(tabletop({ isHalfOpened: false }));
+ mockPaneFrame(100, 851);
+
+ await renderPane();
+
+ expect(screen.getByTestId('primary').parent?.props.style).toEqual({ height: 356 });
+ });
+
+ it('falls back on a fully open device when splitWhen is halfOpened', async () => {
+ mockedUseFold.mockReturnValue(tabletop({ isHalfOpened: false }));
+ mockPaneFrame(100, 851);
+
+ await renderPane({ splitWhen: 'halfOpened' });
+
+ expect(screen.getByTestId('fallback')).toBeTruthy();
+ });
+
it('falls back when the pane starts below the hinge', async () => {
mockedUseFold.mockReturnValue(tabletop());
mockPaneFrame(500, 451);
diff --git a/src/index.tsx b/src/index.tsx
index 7f6c5e2..5758e5f 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -4,5 +4,5 @@ export type { FoldPaneProps } from './FoldPane';
export { default as FoldPane } from './FoldPane';
export type { FoldProviderProps } from './FoldProvider';
export { default as FoldProvider } from './FoldProvider';
-export type { FoldOrientation, FoldState } from './types';
+export type { FoldOrientation, FoldSplitWhen, FoldState } from './types';
export { useFold } from './useFold';
diff --git a/src/types.ts b/src/types.ts
index 77cf585..c6a5bec 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,5 +1,7 @@
export type FoldOrientation = 'horizontal' | 'vertical';
+export type FoldSplitWhen = 'separating' | 'halfOpened';
+
export type FoldState = {
isSeparating: boolean;
isHalfOpened: boolean;