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;