@formidable-webview/webshell
Version:
🔥 Craft Robust React Native WebView-based components with ease.
370 lines (359 loc) • 11.4 kB
text/typescript
import * as React from 'react';
import type {
DOMRectSize,
ExtractFeatureFromClass,
MinimalWebViewProps,
WebshellProps
} from '../types';
import type {
HTMLDimensions,
HandleHTMLDimensionsFeature,
HTMLDimensionsImplementation
} from '../features/HandleHTMLDimensionsFeature';
import { StyleProp, ViewStyle } from 'react-native';
import Feature from '../Feature';
const initialDimensions = { width: undefined, height: undefined };
const overridenWebViewProps = {
scalesPageToFit: false,
showsVerticalScrollIndicator: false,
disableScrollViewPanResponder: true,
contentMode: 'mobile'
} as const;
const overridenWebViewKeys = Object.keys(overridenWebViewProps);
/**
* The state of synchronization between viewport and content size:
*
* - `init`: the initial state;
* - `syncing`: the content size is being determined;
* - `synced`: the viewport size has been adjusted to content size.
*
* @public
*/
export type AutoheightSyncState = 'init' | 'syncing' | 'synced';
/**
* The state returned by {@link useAutoheight} hook.
*
* @typeparam S - The type of the `Webshell` props used by this hook.
*
* @public
*/
export interface AutoheightState<
S extends WebshellProps<
MinimalWebViewProps,
[ExtractFeatureFromClass<typeof HandleHTMLDimensionsFeature>]
>
> {
/**
* The props to inject into webshell in order to support "autoheight"
* behavior.
*/
autoheightWebshellProps: Pick<
S,
| 'webshellDebug'
| 'onDOMHTMLDimensions'
| 'style'
| 'scalesPageToFit'
| 'showsVerticalScrollIndicator'
| 'disableScrollViewPanResponder'
| 'contentMode'
> &
Partial<S>;
/**
* The implementation used to generate resize events.
*/
resizeImplementation: HTMLDimensionsImplementation | null;
/**
* An object describing the content size. When the size is not yet known,
* this object fields will be undefined.
*/
contentSize: Partial<DOMRectSize>;
/**
* The state of synchronization between viewport and content size:
*
* - `'init'`: the initial, "onMount" state;
* - `'syncing'`: the content size is being determined;
* - `'synced'`: the viewport size has been adjusted to content size.
*
*/
syncState: AutoheightSyncState;
}
/**
* Named parameters for autoheight hook.
*
* @typeparam S - The type of the `Webshell` props used by this hook.
*
* @public
*/
export interface AutoheightParams<
S extends WebshellProps<MinimalWebViewProps, Feature<any, any>[]>
> {
/**
* It's best to pass all props directed to `Webshell` here. This is
* advised because the hook might react to specific props and warn you of
* some incompatibilities.
*/
webshellProps: S;
/**
* By default, the width of `Webshell` will grow to the horizontal space available.
* This is realized with `width: '100%'` and `alignSelf: 'stretch'`.
* If you need to set explicit width, do it here.
*/
width?: number;
/**
* The height occupied by the `WebView` prior to knowing its content height.
* It will be reused each time the source changes.
*
* @defaultValue 0
*/
initialHeight?: number;
/**
* When a width change is detected on viewport, the height of the `WebView`
* will be set to `undefined` for a few milliseconds. This will allow the
* best handling of height constraint in edge-cases with, for example,
* content expanding vertically (display: flex), at the cost of a small flash.
*
* @defaultValue true
*/
resetHeightOnViewportWidthChange?: boolean;
}
interface AutoheightInternalState {
implementation: HTMLDimensionsImplementation | null;
contentSize: Partial<DOMRectSize>;
syncState: AutoheightSyncState;
lastFrameChangedWidth: boolean;
viewportWidth: number;
}
const initialState: AutoheightInternalState = {
implementation: null,
contentSize: initialDimensions,
syncState: 'init',
lastFrameChangedWidth: false,
viewportWidth: 0
};
function useDevFeedbackEffect({
autoHeightParams: { webshellProps },
state: {
implementation,
contentSize: { width, height }
}
}: {
autoHeightParams: AutoheightParams<WebshellProps<MinimalWebViewProps, []>>;
state: AutoheightInternalState;
}) {
const numberOfEventsRef = React.useRef(0);
const { webshellDebug } = webshellProps;
const forbiddenWebViewProps = overridenWebViewKeys
.map((key) => [key, webshellProps[key]])
.reduce((prev, [key, value]) => {
prev[key] = value;
return prev;
}, {} as any);
React.useEffect(
function warnOverridenProps() {
for (const forbiddenKey of overridenWebViewKeys) {
if (
forbiddenWebViewProps[forbiddenKey] !== undefined &&
forbiddenWebViewProps[forbiddenKey] !==
overridenWebViewProps[forbiddenKey]
) {
console.warn(
`${useAutoheight.name}: You cannot set "${forbiddenKey}" prop to "${webshellProps[forbiddenKey]}" with autoheight hook. The value will be overriden to "${overridenWebViewProps[forbiddenKey]}".`
);
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[webshellDebug, ...Object.values(forbiddenWebViewProps)]
);
React.useEffect(
function debugDOMHTMLDimensions() {
webshellDebug &&
console.info(
`${
useAutoheight.name
}: DOMHTMLDimensions event #${++numberOfEventsRef.current} (implementation: ${implementation}, content width: ${width}, content height: ${height})`
);
},
[webshellDebug, implementation, height, width]
);
}
function useAutoheightState<
S extends WebshellProps<
MinimalWebViewProps,
[ExtractFeatureFromClass<typeof HandleHTMLDimensionsFeature>]
>
>(autoHeightParams: AutoheightParams<S>) {
const { webshellProps, initialHeight } = autoHeightParams;
const { source = {}, webshellDebug } = webshellProps;
const [state, setState] = React.useState<AutoheightInternalState>(
initialState
);
if (__DEV__) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useDevFeedbackEffect({ autoHeightParams, state });
}
React.useEffect(
function resetHeightOnSourceChanges() {
setState(({ contentSize, viewportWidth }) => ({
viewportWidth,
contentSize: {
height: undefined,
width: contentSize.width
},
implementation: null,
syncState: 'syncing',
lastFrameChangedWidth: false
}));
__DEV__ &&
webshellDebug &&
console.info(
`${useAutoheight.name}: source change detected, resetting height to ${initialHeight}dp.`
);
},
[source.uri, source.html, webshellDebug, initialHeight]
);
if (__DEV__) {
}
return { state, setState };
}
/**
*
* This hook will provide props to inject in a shell component to implement an "autoheight" behavior.
* It requires {@link HandleHTMLDimensionsFeature} to have be instantiated in the shell.
* Also recommend (see remarks):
*
* - {@link ForceElementSizeFeature},
* - {@link ForceResponsiveViewportFeature}.
*
* @remarks
* This hook has caveats you must understand:
*
* - Because the viewport height is now bound to the content heigh, you cannot
* and must not have an element which height depends on viewport, such as
* when using `vh` unit or `height: 100%;` on body. That will either create
* an infinite loop, or a zero-height page (this happens for Wikipedia).
* Hence, it is strongly advised that you use autoheight only with content
* you have been able to test. This can be worked around by forcing body
* height to 'auto', see {@link ForceElementSizeFeature}.
* - In some circumstances, the mobile browser might use a virtual
* viewport much larger then the available width in the `<WebView />`, often
* around 980px for websites which have been built for desktop. For
* this autoheight component to be reliable, you must ensure that the
* content has a [meta viewport element](https://www.w3schools.com/css/css_rwd_viewport.asp)
* in the header. You can enforce this behavior with {@link ForceResponsiveViewportFeature}.
*
* @example
*
* ```tsx
* export default function MinimalAutoheightWebView(
* webshellProps: ComponentProps<typeof Webshell>
* ) {
* const { autoheightWebshellProps } = useAutoheight({
* webshellProps
* });
* return <Webshell {...autoheightWebshellProps} />;
* }
* ```
*
* @param params - The parameters to specify autoheight behavior.
* @typeparam S - The type of the `Webshell` props used by this hook.
* @returns - An object to implement autoheight behavior.
*
* @public
*/
export default function useAutoheight<
S extends WebshellProps<
MinimalWebViewProps,
[ExtractFeatureFromClass<typeof HandleHTMLDimensionsFeature>]
>
>(params: AutoheightParams<S>): AutoheightState<S> {
const {
webshellProps,
initialHeight = 0,
width: userExplicitWidth,
resetHeightOnViewportWidthChange = true
} = params;
const {
style,
onNavigationStateChange,
scalesPageToFit,
webshellDebug,
onDOMHTMLDimensions,
...passedProps
} = webshellProps;
const { state, setState } = useAutoheightState(params);
const {
contentSize: { height },
implementation,
lastFrameChangedWidth
} = state;
const shouldReinitNextFrameHeight =
typeof userExplicitWidth !== 'number' &&
lastFrameChangedWidth &&
resetHeightOnViewportWidthChange;
const handleOnDOMHTMLDimensions = React.useCallback(
function handleOnDOMHTMLDimensions(htmlDimensions: HTMLDimensions) {
setState((prevState) => {
return {
viewportWidth: htmlDimensions.layoutViewport.width,
implementation: htmlDimensions.implementation,
contentSize: htmlDimensions.content,
syncState: 'synced',
lastFrameChangedWidth:
prevState.viewportWidth !== htmlDimensions.layoutViewport.width
};
});
typeof onDOMHTMLDimensions === 'function' &&
onDOMHTMLDimensions(htmlDimensions);
},
[setState, onDOMHTMLDimensions]
);
const autoHeightStyle = React.useMemo<StyleProp<ViewStyle>>(
() => [
style as StyleProp<ViewStyle>,
{
width:
typeof userExplicitWidth === 'number' ? userExplicitWidth : '100%',
height: shouldReinitNextFrameHeight
? undefined
: typeof height === 'number'
? height
: initialHeight,
alignSelf: 'stretch'
}
],
[
height,
userExplicitWidth,
initialHeight,
style,
shouldReinitNextFrameHeight
]
);
React.useEffect(
function resetLastFrameChangedWidth() {
const timeout = setTimeout(
() =>
setState((prevState) => ({
...prevState,
lastFrameChangedWidth: false
})),
50
);
return () => clearTimeout(timeout);
},
[shouldReinitNextFrameHeight, setState]
);
return {
autoheightWebshellProps: {
...passedProps,
webshellDebug,
onDOMHTMLDimensions: handleOnDOMHTMLDimensions,
style: autoHeightStyle,
...overridenWebViewProps
} as AutoheightState<S>['autoheightWebshellProps'],
resizeImplementation: implementation,
contentSize: state.contentSize,
syncState: state.syncState
};
}