Phecda

feat: add RNCWebView

... ... @@ -4,6 +4,7 @@
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/no-unused-vars": "off"
"@typescript-eslint/no-unused-vars": "off",
"no-console": "warn"
}
}
... ...
... ... @@ -235,6 +235,8 @@ PODS:
- React-jsinspector (0.62.1)
- react-native-safe-area-context (0.7.3):
- React
- react-native-webview (9.1.1):
- React
- React-RCTActionSheet (0.62.1):
- React-Core/RCTActionSheetHeaders (= 0.62.1)
- React-RCTAnimation (0.62.1):
... ... @@ -294,6 +296,8 @@ PODS:
- React-cxxreact (= 0.62.1)
- React-jsi (= 0.62.1)
- ReactCommon/callinvoker (= 0.62.1)
- ReactNativeART (1.2.0):
- React
- ReactNativeDarkMode (0.2.2):
- React
- RNCMaskedView (0.1.7):
... ... @@ -335,6 +339,7 @@ DEPENDENCIES:
- React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`)
- React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector`)
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- react-native-webview (from `../node_modules/react-native-webview`)
- React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
- React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`)
- React-RCTBlob (from `../node_modules/react-native/Libraries/Blob`)
... ... @@ -346,6 +351,7 @@ DEPENDENCIES:
- React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`)
- ReactCommon/callinvoker (from `../node_modules/react-native/ReactCommon`)
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
- "ReactNativeART (from `../node_modules/@react-native-community/art`)"
- ReactNativeDarkMode (from `../node_modules/react-native-dark-mode`)
- "RNCMaskedView (from `../node_modules/@react-native-community/masked-view`)"
- RNDeviceInfo (from `../node_modules/react-native-device-info`)
... ... @@ -401,6 +407,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/jsinspector"
react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context"
react-native-webview:
:path: "../node_modules/react-native-webview"
React-RCTActionSheet:
:path: "../node_modules/react-native/Libraries/ActionSheetIOS"
React-RCTAnimation:
... ... @@ -421,6 +429,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/Libraries/Vibration"
ReactCommon:
:path: "../node_modules/react-native/ReactCommon"
ReactNativeART:
:path: "../node_modules/@react-native-community/art"
ReactNativeDarkMode:
:path: "../node_modules/react-native-dark-mode"
RNCMaskedView:
... ... @@ -465,6 +475,7 @@ SPEC CHECKSUMS:
React-jsiexecutor: e9698dee4fd43ceb44832baf15d5745f455b0157
React-jsinspector: f74a62727e5604119abd4a1eda52c0a12144bcd5
react-native-safe-area-context: e200d4433aba6b7e60b52da5f37af11f7a0b0392
react-native-webview: 0633fd7861a9bd7a80bacaee7da763c3afc248fa
React-RCTActionSheet: af8f28dd82fec89b8fe29637b8c779829e016a88
React-RCTAnimation: 0d21fff7c20fb8ee41de5f2ebb63221127febd96
React-RCTBlob: 9496bd93130b22069bfbc5d35e98653dae7c35c6
... ... @@ -475,6 +486,7 @@ SPEC CHECKSUMS:
React-RCTText: 239e040f401505001327a109f9188a4e6dad1bd2
React-RCTVibration: 072c3b427dd29e730c2ee5bfc509cf5054741a50
ReactCommon: 3585806280c51d5c2c0d3aa5a99014c3badb629d
ReactNativeART: 78edc68dd4a1e675338cd0cd113319cf3a65f2ab
ReactNativeDarkMode: 0178ffca3b10f6a7c9f49d6f9810232b328fa949
RNCMaskedView: 76c40a1d41c3e2535df09246a2b5487f04de0814
RNDeviceInfo: 6a3d16fce033f6979c4a6a41e62244d183e8c765
... ...
... ... @@ -11,7 +11,10 @@
"commit": "git-cz"
},
"dependencies": {
"@huse/boolean": "^1.0.2",
"@huse/immer": "^1.0.2",
"@huse/previous-value": "^1.0.1",
"@react-native-community/art": "^1.2.0",
"@react-native-community/masked-view": "^0.1.7",
"@react-navigation/bottom-tabs": "^5.2.5",
"@react-navigation/drawer": "^5.4.0",
... ... @@ -24,11 +27,14 @@
"react-native-device-info": "^5.5.4",
"react-native-elements": "^1.2.7",
"react-native-gesture-handler": "^1.6.1",
"react-native-progress": "^4.1.2",
"react-native-reanimated": "^1.7.1",
"react-native-safe-area-context": "^0.7.3",
"react-native-screens": "^2.4.0",
"react-native-tab-view": "^2.13.0",
"react-native-vector-icons": "^6.6.0"
"react-native-vector-icons": "^6.6.0",
"react-native-webview": "9.1.1",
"url-parse": "^1.4.7"
},
"devDependencies": {
"@babel/core": "^7.6.2",
... ... @@ -39,6 +45,7 @@
"@types/jest": "^24.0.24",
"@types/react-native": "^0.62.0",
"@types/react-test-renderer": "16.9.2",
"@types/url-parse": "^1.4.3",
"@typescript-eslint/eslint-plugin": "^2.27.0",
"@typescript-eslint/parser": "^2.27.0",
"@welldone-software/why-did-you-render": "^4.0.7",
... ...
... ... @@ -16,6 +16,8 @@ import DesignList from './DesignList';
import { useDarkMode } from 'react-native-dark-mode';
import { themeForNav } from '../design';
import RNDeviceInfoList from './RNDeviceInfo';
import WebviewScreen from './WebviewScreen';
import { Platform } from 'react-native';
const MainTab = createBottomTabNavigator<MainTabParamList>();
... ... @@ -74,7 +76,7 @@ const Home = () => {
const MainStack = createStackNavigator<MainStackParamList>();
export default () => {
const Container = () => {
const inDarkMode = useDarkMode();
return (
<NavigationContainer
... ... @@ -96,7 +98,17 @@ export default () => {
name="RNDeviceInfoList"
component={RNDeviceInfoList}
/>
<MainStack.Screen
name="WebviewScreen"
component={WebviewScreen}
options={({ navigation, route }) => ({
// FIXME: https://github.com/react-native-community/react-native-webview/issues/575#issuecomment-587267906
animationEnabled: Platform.OS === 'ios',
})}
/>
</MainStack.Navigator>
</NavigationContainer>
);
};
export default Container;
... ...
... ... @@ -48,6 +48,15 @@ const SystemInfo = ({
onPress={() => navigation.navigate('RNDeviceInfoList')}
chevron
/>
<Divider />
<ListItem
title={'RNCWebview'}
onPress={() =>
navigation.navigate('WebviewScreen', {
uri: 'https://www.baidu.com',
})
}
/>
</Card>
</BGScroll>
);
... ...
import React, { useCallback } from 'react';
import RNCWebView, { WebViewNavigation } from 'react-native-webview';
import { WebviewState, WebviewActions, webActions } from './reducer';
import {
WebViewProgressEvent,
WebViewErrorEvent,
OnShouldStartLoadWithRequest,
} from 'react-native-webview/lib/WebViewTypes';
import ErrorView from './ErrorView';
import { Linking } from 'react-native';
const Body = React.forwardRef<
RNCWebView,
{
state: WebviewState;
dispatch: React.Dispatch<WebviewActions>;
initialUrl: string;
}
>(({ state, dispatch, initialUrl }, ref) => {
const onNavigationStateChange = useCallback(
(s: WebViewNavigation) => {
dispatch(webActions.changeNavigationState(s));
},
[dispatch]
);
const onLoadProgress = useCallback(
(s: WebViewProgressEvent) => {
dispatch(webActions.onLoadProgress(s.nativeEvent));
},
[dispatch]
);
const onError = useCallback(
(s: WebViewErrorEvent) => {
dispatch(webActions.onLoadError(s.nativeEvent));
},
[dispatch]
);
const shouldRequest: OnShouldStartLoadWithRequest = useCallback(
(request) => {
const { url } = request;
if (url.startsWith('http') || url === 'about:blank') {
return true;
} else {
dispatch(webActions.changeNavigationState(request));
Linking.canOpenURL(url)
.then((canOpen) => {
if (canOpen) {
return Linking.openURL(url);
}
})
.catch(() => {});
return false;
}
},
[dispatch]
);
return (
<RNCWebView
ref={ref}
source={{ uri: initialUrl }}
onNavigationStateChange={onNavigationStateChange}
onLoadProgress={onLoadProgress}
onError={onError}
onShouldStartLoadWithRequest={shouldRequest}
renderError={(_, code, description) => (
<ErrorView code={code} description={description} />
)}
/>
);
});
export default Body;
... ...
import React from 'react';
import { SafeAreaView, Text, StyleSheet } from 'react-native';
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import {
DynamicStyleSheet,
useDynamicStyleSheet,
useDynamicValue,
} from 'react-native-dark-mode';
import { colorPreset } from '../../design';
const dynamicStyles = new DynamicStyleSheet({
background: {
backgroundColor: colorPreset.backgroundColor.secondary,
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
errorCode: {
color: colorPreset.labelColor.primary,
},
description: {
color: colorPreset.labelColor.primary,
},
});
export default function ErrorView({
code,
description,
}: {
code: number;
description: string;
}) {
const styles = useDynamicStyleSheet(dynamicStyles);
const redColor = useDynamicValue(colorPreset.rainbow.red);
return (
<SafeAreaView style={styles.background}>
<MaterialCommunityIcons
name={'close-circle'}
size={60}
color={redColor}
/>
<Text style={styles.errorCode}>{code}</Text>
<Text style={styles.description}>{description}</Text>
</SafeAreaView>
);
}
... ...
import React, {
useState,
useCallback,
useEffect,
useLayoutEffect,
} from 'react';
import URL from 'url-parse';
import { useToggle } from '@huse/boolean';
import RNCWebview from 'react-native-webview';
import {
DynamicStyleSheet,
useDynamicStyleSheet,
useDynamicValue,
} from 'react-native-dark-mode';
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import * as Progress from 'react-native-progress';
import {
Platform,
SafeAreaView,
View,
Text,
TouchableOpacity,
StyleSheet,
Animated,
TextInput,
NativeSyntheticEvent,
TextInputSubmitEditingEventData,
LayoutAnimation,
} from 'react-native';
import { colorPreset } from '../../design';
import { WebviewState, WebviewActions, webActions } from './reducer';
const dynamicStyles = new DynamicStyleSheet({
headerContainer: {
backgroundColor: colorPreset.backgroundColor.primary,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colorPreset.separator.opaque,
},
containerStyle: {
backgroundColor: colorPreset.backgroundColor.secondary,
borderRadius: 10,
margin: 10,
...Platform.select({
ios: {
height: 36,
marginTop: 4,
},
android: {
height: 36,
},
}),
overflow: 'hidden',
},
labelContainer: {
flexDirection: 'row',
flex: 1,
alignItems: 'center',
},
hostLabel: {
color: colorPreset.labelColor.primary,
flex: 1,
textAlign: 'center',
fontSize: 17,
},
refreshButton: {
width: 36,
height: 36,
justifyContent: 'center',
alignItems: 'center',
},
inputContainerStyle: {
backgroundColor: colorPreset.backgroundColor.secondary,
},
inputStyle: {
flex: 1,
paddingHorizontal: 16,
},
progressBar: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 2,
},
});
interface Props {
state: WebviewState;
dispatch: React.Dispatch<WebviewActions>;
webview: React.RefObject<RNCWebview>;
}
// EXPERIMENT
function WebviewHeader({ state, dispatch, webview }: Props) {
const [uri, setUri] = useState<URL>();
const [focused, toggleFocused] = useToggle(false);
const progressBarOpacity = new Animated.Value(1);
const { url, progress, loading } = state;
useEffect(() => {
const newUrl = new URL(url);
setUri(newUrl);
}, [url]);
useEffect(() => {
if (progress === 1) {
Animated.timing(progressBarOpacity, {
toValue: 0,
useNativeDriver: true,
duration: 1000,
}).start();
}
}, [progress, progressBarOpacity]);
const onSubmitEditing = useCallback(
(e: NativeSyntheticEvent<TextInputSubmitEditingEventData>) => {
toggleFocused();
dispatch(webActions.loadText(e.nativeEvent.text));
},
[toggleFocused, dispatch]
);
const onPressLoading = useCallback(() => {
if (loading) {
webview.current?.stopLoading();
} else {
webview.current?.reload();
}
}, [loading, webview]);
useLayoutEffect(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
}, [focused]);
const styles = useDynamicStyleSheet(dynamicStyles);
const redColor = useDynamicValue(colorPreset.rainbow.red);
const greenColor = useDynamicValue(colorPreset.rainbow.green);
const primaryLabelColor = useDynamicValue(colorPreset.labelColor.primary);
return (
<SafeAreaView style={styles.headerContainer}>
<View style={styles.containerStyle}>
{focused ? (
<View style={styles.labelContainer}>
<TextInput
defaultValue={url}
onSubmitEditing={onSubmitEditing}
style={styles.inputStyle}
underlineColorAndroid="transparent"
textContentType="URL"
selectTextOnFocus
keyboardType="url"
returnKeyType="go"
autoCapitalize="none"
autoCorrect={false}
autoFocus
onBlur={(e) => {
toggleFocused();
}}
clearButtonMode="while-editing"
/>
</View>
) : (
<TouchableOpacity
activeOpacity={1}
onPress={toggleFocused}
style={styles.labelContainer}
>
<View style={styles.refreshButton}>
<MaterialCommunityIcons
name={
uri?.protocol === 'https:'
? 'shield-outline'
: 'shield-off-outline'
}
color={uri?.protocol === 'https:' ? greenColor : redColor}
size={20}
/>
</View>
<Text style={styles.hostLabel}>
{uri?.hostname.replace(/^www\./, '')}
</Text>
<TouchableOpacity
onPress={onPressLoading}
style={styles.refreshButton}
>
<MaterialCommunityIcons
name={loading ? 'close' : 'refresh'}
color={primaryLabelColor}
size={20}
/>
</TouchableOpacity>
<Animated.View
style={[{ opacity: progressBarOpacity }, styles.progressBar]}
>
<Progress.Bar
progress={progress}
borderWidth={0}
borderRadius={0}
width={null}
useNativeDriver
/>
</Animated.View>
</TouchableOpacity>
)}
</View>
</SafeAreaView>
);
}
export default WebviewHeader;
... ...
import React, { useEffect, useMemo, useState, useRef } from 'react';
import { Animated, StyleSheet } from 'react-native';
import * as Progress from 'react-native-progress';
const styles = StyleSheet.create({
progressBar: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: 1,
},
});
export default function ProgressBar({
progress,
loading,
}: {
progress: number;
loading: boolean;
}) {
const progressBarOpacity = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (!loading) {
Animated.timing(progressBarOpacity, {
toValue: 0,
useNativeDriver: true,
duration: 1000,
}).start();
} else {
progressBarOpacity.setValue(1);
}
}, [loading, progressBarOpacity]);
return (
<Animated.View
style={[{ opacity: progressBarOpacity }, styles.progressBar]}
>
<Progress.Bar
progress={progress}
borderWidth={0}
borderRadius={0}
width={null}
height={4}
useNativeDriver
/>
</Animated.View>
);
}
... ...
import React, { useCallback } from 'react';
import { SafeAreaView, TouchableOpacity, StyleSheet, View } from 'react-native';
import RNCWebview from 'react-native-webview';
import EvilIcons from 'react-native-vector-icons/EvilIcons';
import { WebviewState, WebviewActions } from './reducer';
import {
DynamicStyleSheet,
useDynamicStyleSheet,
useDynamicValue,
} from 'react-native-dark-mode';
import { colorPreset } from '../../design';
interface Props {
state: WebviewState;
dispatch: React.Dispatch<WebviewActions>;
webview: React.RefObject<RNCWebview>;
}
const dynamicStyles = new DynamicStyleSheet({
background: {
backgroundColor: colorPreset.backgroundColor.primary,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colorPreset.separator.opaque,
},
container: {
height: 44,
flexDirection: 'row',
justifyContent: 'space-evenly',
},
button: {
width: 44,
height: 44,
justifyContent: 'center',
alignItems: 'center',
},
});
const Toolbar = ({ state, webview }: Props) => {
const { canGoBack, canGoForward, loading } = state;
const styles = useDynamicStyleSheet(dynamicStyles);
const primaryLabelColor = useDynamicValue(colorPreset.labelColor.primary);
const secondaryLabelColor = useDynamicValue(colorPreset.labelColor.tertiary);
const onPressLoading = useCallback(() => {
if (loading) {
webview.current?.stopLoading();
} else {
webview.current?.reload();
}
}, [loading, webview]);
return (
<SafeAreaView style={styles.background}>
<View style={styles.container}>
<TouchableOpacity
style={styles.button}
disabled={!canGoBack}
onPress={() => webview.current?.goBack()}
>
<EvilIcons
name="chevron-left"
size={40}
color={canGoBack ? primaryLabelColor : secondaryLabelColor}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.button}
disabled={!canGoForward}
onPress={() => webview.current?.goForward()}
>
<EvilIcons
name="chevron-right"
size={40}
color={canGoForward ? primaryLabelColor : secondaryLabelColor}
/>
</TouchableOpacity>
<TouchableOpacity style={styles.button} onPress={onPressLoading}>
<EvilIcons
name={loading ? 'close' : 'refresh'}
size={40}
color={primaryLabelColor}
/>
</TouchableOpacity>
</View>
</SafeAreaView>
);
};
export default Toolbar;
... ...
import React, { useRef, useEffect, useReducer } from 'react';
import { useImmerReducer } from '@huse/immer';
import RNCWebview from 'react-native-webview';
import { reducer, defaultState, webActions, WebviewActions } from './reducer';
import { BGView } from '../../component/View';
import Header from './Header';
import Body from './Body';
import { MainStackScreenProps } from '../../type/Navigation';
import Toolbar from './Toolbar';
import ProgressBar from './ProgressBar';
const WebviewScreen = ({
navigation,
route,
}: MainStackScreenProps<'WebviewScreen'>) => {
const paramUri = route.params?.uri ?? 'https://about:blank';
const [state, dispatch] = useImmerReducer(reducer, {
...defaultState,
url: paramUri,
});
const webview = useRef<RNCWebview>(null);
return (
<BGView>
<Body
initialUrl={paramUri}
state={state}
dispatch={dispatch}
ref={webview}
/>
<ProgressBar progress={state.progress} loading={state.loading} />
<Toolbar state={state} dispatch={dispatch} webview={webview} />
</BGView>
);
};
export default WebviewScreen;
... ...
import React, { Reducer } from 'react';
import { WebViewNavigation } from 'react-native-webview';
import { ImmerReducer } from '@huse/immer';
import {
WebViewError,
WebViewProgressEvent,
WebViewNativeProgressEvent,
WebViewNativeEvent,
} from 'react-native-webview/lib/WebViewTypes';
export type WebviewActionTypes =
| 'ChangeNavigationState'
| 'OnLoadError'
| 'OnLoadProgress'
| 'LoadText';
type PayloadAction<Type extends WebviewActionTypes, Payload> = {
type: Type;
payload: Payload;
};
export type WebviewActions =
| PayloadAction<'ChangeNavigationState', WebViewNavigation>
| PayloadAction<'LoadText', string>
| PayloadAction<'OnLoadError', WebViewError>
| PayloadAction<'OnLoadProgress', WebViewNativeProgressEvent>;
type PayloadOf<
A extends { type: string; payload: any },
T extends string
> = A extends {
type: T;
payload: infer R;
}
? R
: never;
const createAction = <
T extends WebviewActionTypes,
P extends PayloadOf<WebviewActions, T>
>(
type: T
) => (payload: P) => ({
type,
payload,
});
export const webActions = {
changeNavigationState: createAction('ChangeNavigationState'),
onLoadError: createAction('OnLoadError'),
onLoadProgress: createAction('OnLoadProgress'),
loadText: createAction('LoadText'),
};
export type WebviewState = WebViewNativeEvent & {
domain?: string;
code?: number;
description?: string;
progress: number;
};
export const defaultState: WebviewState = {
canGoBack: false,
canGoForward: false,
url: 'https://about:blank',
title: '',
lockIdentifier: 0,
loading: false,
progress: 0,
};
export const reducer: ImmerReducer<WebviewState, WebviewActions> = (
state,
action
) => {
switch (action.type) {
case 'ChangeNavigationState':
case 'OnLoadProgress':
case 'OnLoadError':
return { ...state, ...action.payload };
case 'LoadText':
state.url = action.payload;
break;
}
};
... ...
... ... @@ -10,6 +10,7 @@ export type MainTabParamList = {
export type MainStackParamList = {
MainTab: undefined;
RNDeviceInfoList: undefined;
WebviewScreen: { uri: string } | undefined;
};
export type MainTabScreenProps<RouteName extends keyof MainTabParamList> = {
... ...
This diff is collapsed. Click to expand it.