diff --git a/Examples/UIExplorer/js/WebViewExample.js b/Examples/UIExplorer/js/WebViewExample.js new file mode 100644 index 00000000000000..be1fb44aec1148 --- /dev/null +++ b/Examples/UIExplorer/js/WebViewExample.js @@ -0,0 +1,446 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * The examples provided by Facebook are for non-commercial testing and + * evaluation purposes only. + * + * Facebook reserves all rights not expressly granted. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL + * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * @flow + */ +'use strict'; + +var React = require('react'); +var ReactNative = require('react-native'); +var { + StyleSheet, + Text, + TextInput, + TouchableWithoutFeedback, + TouchableOpacity, + View, + WebView +} = ReactNative; + +var HEADER = '#3b5998'; +var BGWASH = 'rgba(255,255,255,0.8)'; +var DISABLED_WASH = 'rgba(255,255,255,0.25)'; + +var TEXT_INPUT_REF = 'urlInput'; +var WEBVIEW_REF = 'webview'; +var DEFAULT_URL = 'https://m.facebook.com'; + +class WebViewExample extends React.Component { + state = { + url: DEFAULT_URL, + status: 'No Page Loaded', + backButtonEnabled: false, + forwardButtonEnabled: false, + loading: true, + scalesPageToFit: true, + }; + + inputText = ''; + + handleTextInputChange = (event) => { + var url = event.nativeEvent.text; + if (!/^[a-zA-Z-_]+:/.test(url)) { + url = 'http://' + url; + } + this.inputText = url; + }; + + render() { + this.inputText = this.state.url; + + return ( + + + + + {'<'} + + + + + {'>'} + + + + + + + Go! + + + + + + + {this.state.status} + + + ); + } + + goBack = () => { + this.refs[WEBVIEW_REF].goBack(); + }; + + goForward = () => { + this.refs[WEBVIEW_REF].goForward(); + }; + + reload = () => { + this.refs[WEBVIEW_REF].reload(); + }; + + onShouldStartLoadWithRequest = (event) => { + // Implement any custom loading logic here, don't forget to return! + return true; + }; + + onNavigationStateChange = (navState) => { + this.setState({ + backButtonEnabled: navState.canGoBack, + forwardButtonEnabled: navState.canGoForward, + url: navState.url, + status: navState.title, + loading: navState.loading, + scalesPageToFit: true + }); + }; + + onSubmitEditing = (event) => { + this.pressGoButton(); + }; + + pressGoButton = () => { + var url = this.inputText.toLowerCase(); + if (url === this.state.url) { + this.reload(); + } else { + this.setState({ + url: url, + }); + } + // dismiss keyboard + this.refs[TEXT_INPUT_REF].blur(); + }; +} + +class Button extends React.Component { + _handlePress = () => { + if (this.props.enabled !== false && this.props.onPress) { + this.props.onPress(); + } + }; + + render() { + return ( + + + {this.props.text} + + + ); + } +} + +class ScaledWebView extends React.Component { + state = { + scalingEnabled: true, + }; + + render() { + return ( + + + + { this.state.scalingEnabled ? + this.setState({scalingEnabled: false})} + /> : + this.setState({scalingEnabled: true})} + /> } + + + ); + } +} + +class MessagingTest extends React.Component { + webview = null + + state = { + messagesReceivedFromWebView: 0, + message: '', + } + + onMessage = e => this.setState({ + messagesReceivedFromWebView: this.state.messagesReceivedFromWebView + 1, + message: e.nativeEvent.data, + }) + + postMessage = () => { + if (this.webview) { + this.webview.postMessage('"Hello" from React Native!'); + } + } + + render(): ReactElement { + const {messagesReceivedFromWebView, message} = this.state; + + return ( + + + Messages received from web view: {messagesReceivedFromWebView} + {message || '(No message)'} + + + + + + { this.webview = webview; }} + style={{ + backgroundColor: BGWASH, + height: 100, + }} + source={require('./messagingtest.html')} + onMessage={this.onMessage} + /> + + + ); + } +} + +var styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: HEADER, + }, + addressBarRow: { + flexDirection: 'row', + padding: 8, + }, + webView: { + backgroundColor: BGWASH, + height: 350, + }, + addressBarTextInput: { + backgroundColor: BGWASH, + borderColor: 'transparent', + borderRadius: 3, + borderWidth: 1, + height: 24, + paddingLeft: 10, + paddingTop: 3, + paddingBottom: 3, + flex: 1, + fontSize: 14, + }, + navButton: { + width: 20, + padding: 3, + marginRight: 3, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: BGWASH, + borderColor: 'transparent', + borderRadius: 3, + }, + disabledButton: { + width: 20, + padding: 3, + marginRight: 3, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: DISABLED_WASH, + borderColor: 'transparent', + borderRadius: 3, + }, + goButton: { + height: 24, + padding: 3, + marginLeft: 8, + alignItems: 'center', + backgroundColor: BGWASH, + borderColor: 'transparent', + borderRadius: 3, + alignSelf: 'stretch', + }, + statusBar: { + flexDirection: 'row', + alignItems: 'center', + paddingLeft: 5, + height: 22, + }, + statusBarText: { + color: 'white', + fontSize: 13, + }, + spinner: { + width: 20, + marginRight: 6, + }, + buttons: { + flexDirection: 'row', + height: 30, + backgroundColor: 'black', + alignItems: 'center', + justifyContent: 'space-between', + }, + button: { + flex: 0.5, + width: 0, + margin: 5, + borderColor: 'gray', + borderWidth: 1, + backgroundColor: 'gray', + }, +}); + +const HTML = ` +\n + + + Hello Static World + + + + + + Hello Static World + + +`; + +exports.displayName = (undefined: ?string); +exports.title = ''; +exports.description = 'Base component to display web content'; +exports.examples = [ + { + title: 'Simple Browser', + render(): React.Element { return ; } + }, + { + title: 'Scale Page to Fit', + render(): React.Element { return ; } + }, + { + title: 'Bundled HTML', + render(): React.Element { + return ( + + ); + } + }, + { + title: 'Static HTML', + render(): React.Element { + return ( + + ); + } + }, + { + title: 'POST Test', + render(): React.Element { + return ( + + ); + } + }, + { + title: 'Mesaging Test', + render(): ReactElement { return ; } + } +]; diff --git a/Libraries/Components/WebView/WebView.android.js b/Libraries/Components/WebView/WebView.android.js new file mode 100644 index 00000000000000..3cc033a14976db --- /dev/null +++ b/Libraries/Components/WebView/WebView.android.js @@ -0,0 +1,355 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule WebView + */ +'use strict'; + +var EdgeInsetsPropType = require('EdgeInsetsPropType'); +var ActivityIndicator = require('ActivityIndicator'); +var React = require('React'); +var ReactNative = require('ReactNative'); +var ReactNativeViewAttributes = require('ReactNativeViewAttributes'); +var StyleSheet = require('StyleSheet'); +var UIManager = require('UIManager'); +var View = require('View'); + +var deprecatedPropType = require('deprecatedPropType'); +var keyMirror = require('fbjs/lib/keyMirror'); +var merge = require('merge'); +var requireNativeComponent = require('requireNativeComponent'); +var resolveAssetSource = require('resolveAssetSource'); + +var PropTypes = React.PropTypes; + +var RCT_WEBVIEW_REF = 'webview'; + +var WebViewState = keyMirror({ + IDLE: null, + LOADING: null, + ERROR: null, +}); + +var defaultRenderLoading = () => ( + + + +); + +/** + * Renders a native WebView. + */ +class WebView extends React.Component { + static propTypes = { + ...View.propTypes, + renderError: PropTypes.func, + renderLoading: PropTypes.func, + onLoad: PropTypes.func, + onLoadEnd: PropTypes.func, + onLoadStart: PropTypes.func, + onError: PropTypes.func, + automaticallyAdjustContentInsets: PropTypes.bool, + contentInset: EdgeInsetsPropType, + onNavigationStateChange: PropTypes.func, + onMessage: PropTypes.func, + onContentSizeChange: PropTypes.func, + startInLoadingState: PropTypes.bool, // force WebView to show loadingView on first load + style: View.propTypes.style, + + html: deprecatedPropType( + PropTypes.string, + 'Use the `source` prop instead.' + ), + + url: deprecatedPropType( + PropTypes.string, + 'Use the `source` prop instead.' + ), + + /** + * Loads static html or a uri (with optional headers) in the WebView. + */ + source: PropTypes.oneOfType([ + PropTypes.shape({ + /* + * The URI to load in the WebView. Can be a local or remote file. + */ + uri: PropTypes.string, + /* + * The HTTP Method to use. Defaults to GET if not specified. + * NOTE: On Android, only GET and POST are supported. + */ + method: PropTypes.oneOf(['GET', 'POST']), + /* + * Additional HTTP headers to send with the request. + * NOTE: On Android, this can only be used with GET requests. + */ + headers: PropTypes.object, + /* + * The HTTP body to send with the request. This must be a valid + * UTF-8 string, and will be sent exactly as specified, with no + * additional encoding (e.g. URL-escaping or base64) applied. + * NOTE: On Android, this can only be used with POST requests. + */ + body: PropTypes.string, + }), + PropTypes.shape({ + /* + * A static HTML page to display in the WebView. + */ + html: PropTypes.string, + /* + * The base URL to be used for any relative links in the HTML. + */ + baseUrl: PropTypes.string, + }), + /* + * Used internally by packager. + */ + PropTypes.number, + ]), + + /** + * Used on Android only, JS is enabled by default for WebView on iOS + * @platform android + */ + javaScriptEnabled: PropTypes.bool, + + /** + * Used on Android only, controls whether DOM Storage is enabled or not + * @platform android + */ + domStorageEnabled: PropTypes.bool, + + /** + * Sets the JS to be injected when the webpage loads. + */ + injectedJavaScript: PropTypes.string, + + /** + * Sets whether the webpage scales to fit the view and the user can change the scale. + */ + scalesPageToFit: PropTypes.bool, + + /** + * Sets the user-agent for this WebView. The user-agent can also be set in native using + * WebViewConfig. This prop will overwrite that config. + */ + userAgent: PropTypes.string, + + /** + * Used to locate this view in end-to-end tests. + */ + testID: PropTypes.string, + + /** + * Determines whether HTML5 audio & videos require the user to tap before they can + * start playing. The default value is `false`. + */ + mediaPlaybackRequiresUserAction: PropTypes.bool, + }; + + static defaultProps = { + javaScriptEnabled : true, + scalesPageToFit: true, + }; + + state = { + viewState: WebViewState.IDLE, + lastErrorEvent: null, + startInLoadingState: true, + }; + + componentWillMount() { + if (this.props.startInLoadingState) { + this.setState({viewState: WebViewState.LOADING}); + } + } + + render() { + var otherView = null; + + if (this.state.viewState === WebViewState.LOADING) { + otherView = (this.props.renderLoading || defaultRenderLoading)(); + } else if (this.state.viewState === WebViewState.ERROR) { + var errorEvent = this.state.lastErrorEvent; + otherView = this.props.renderError && this.props.renderError( + errorEvent.domain, + errorEvent.code, + errorEvent.description); + } else if (this.state.viewState !== WebViewState.IDLE) { + console.error('RCTWebView invalid state encountered: ' + this.state.loading); + } + + var webViewStyles = [styles.container, this.props.style]; + if (this.state.viewState === WebViewState.LOADING || + this.state.viewState === WebViewState.ERROR) { + // if we're in either LOADING or ERROR states, don't show the webView + webViewStyles.push(styles.hidden); + } + + var source = this.props.source || {}; + if (this.props.html) { + source.html = this.props.html; + } else if (this.props.url) { + source.uri = this.props.url; + } + + if (source.method === 'POST' && source.headers) { + console.warn('WebView: `source.headers` is not supported when using POST.'); + } else if (source.method === 'GET' && source.body) { + console.warn('WebView: `source.body` is not supported when using GET.'); + } + + var webView = + ; + + return ( + + {webView} + {otherView} + + ); + } + + goForward = () => { + UIManager.dispatchViewManagerCommand( + this.getWebViewHandle(), + UIManager.RCTWebView.Commands.goForward, + null + ); + }; + + goBack = () => { + UIManager.dispatchViewManagerCommand( + this.getWebViewHandle(), + UIManager.RCTWebView.Commands.goBack, + null + ); + }; + + reload = () => { + UIManager.dispatchViewManagerCommand( + this.getWebViewHandle(), + UIManager.RCTWebView.Commands.reload, + null + ); + }; + + stopLoading = () => { + UIManager.dispatchViewManagerCommand( + this.getWebViewHandle(), + UIManager.RCTWebView.Commands.stopLoading, + null + ); + }; + + postMessage = (data) => { + UIManager.dispatchViewManagerCommand( + this.getWebViewHandle(), + UIManager.RCTWebView.Commands.postMessage, + [String(data)] + ); + }; + + /** + * We return an event with a bunch of fields including: + * url, title, loading, canGoBack, canGoForward + */ + updateNavigationState = (event) => { + if (this.props.onNavigationStateChange) { + this.props.onNavigationStateChange(event.nativeEvent); + } + }; + + getWebViewHandle = () => { + return ReactNative.findNodeHandle(this.refs[RCT_WEBVIEW_REF]); + }; + + onLoadingStart = (event) => { + var onLoadStart = this.props.onLoadStart; + onLoadStart && onLoadStart(event); + this.updateNavigationState(event); + }; + + onLoadingError = (event) => { + event.persist(); // persist this event because we need to store it + var {onError, onLoadEnd} = this.props; + onError && onError(event); + onLoadEnd && onLoadEnd(event); + console.warn('Encountered an error loading page', event.nativeEvent); + + this.setState({ + lastErrorEvent: event.nativeEvent, + viewState: WebViewState.ERROR + }); + }; + + onLoadingFinish = (event) => { + var {onLoad, onLoadEnd} = this.props; + onLoad && onLoad(event); + onLoadEnd && onLoadEnd(event); + this.setState({ + viewState: WebViewState.IDLE, + }); + this.updateNavigationState(event); + }; + + onMessage = (event: Event) => { + var {onMessage} = this.props; + onMessage && onMessage(event); + } +} + +var RCTWebView = requireNativeComponent('RCTWebView', WebView, { + nativeOnly: { + messagingEnabled: PropTypes.bool, + }, +}); + +var styles = StyleSheet.create({ + container: { + flex: 1, + }, + hidden: { + height: 0, + flex: 0, // disable 'flex:1' when hiding a View + }, + loadingView: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingProgressBar: { + height: 20, + }, +}); + +module.exports = WebView; diff --git a/Libraries/Components/WebView/WebView.ios.js b/Libraries/Components/WebView/WebView.ios.js new file mode 100644 index 00000000000000..7961d777f7fc1b --- /dev/null +++ b/Libraries/Components/WebView/WebView.ios.js @@ -0,0 +1,590 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule WebView + * @noflow + */ +'use strict'; + +var ActivityIndicator = require('ActivityIndicator'); +var EdgeInsetsPropType = require('EdgeInsetsPropType'); +var React = require('React'); +var ReactNative = require('ReactNative'); +var StyleSheet = require('StyleSheet'); +var Text = require('Text'); +var UIManager = require('UIManager'); +var View = require('View'); +var ScrollView = require('ScrollView'); + +var deprecatedPropType = require('deprecatedPropType'); +var invariant = require('fbjs/lib/invariant'); +var keyMirror = require('fbjs/lib/keyMirror'); +var processDecelerationRate = require('processDecelerationRate'); +var requireNativeComponent = require('requireNativeComponent'); +var resolveAssetSource = require('resolveAssetSource'); + +var PropTypes = React.PropTypes; +var RCTWebViewManager = require('NativeModules').WebViewManager; + +var BGWASH = 'rgba(255,255,255,0.8)'; +var RCT_WEBVIEW_REF = 'webview'; + +var WebViewState = keyMirror({ + IDLE: null, + LOADING: null, + ERROR: null, +}); + +const NavigationType = keyMirror({ + click: true, + formsubmit: true, + backforward: true, + reload: true, + formresubmit: true, + other: true, +}); + +const JSNavigationScheme = 'react-js-navigation'; + +type ErrorEvent = { + domain: any, + code: any, + description: any, +} + +type Event = Object; + +const DataDetectorTypes = [ + 'phoneNumber', + 'link', + 'address', + 'calendarEvent', + 'none', + 'all', +]; + +var defaultRenderLoading = () => ( + + + +); +var defaultRenderError = (errorDomain, errorCode, errorDesc) => ( + + + Error loading page + + + {'Domain: ' + errorDomain} + + + {'Error Code: ' + errorCode} + + + {'Description: ' + errorDesc} + + +); + +/** + * `WebView` renders web content in a native view. + * + *``` + * import React, { Component } from 'react'; + * import { WebView } from 'react-native'; + * + * class MyWeb extends Component { + * render() { + * return ( + * + * ); + * } + * } + *``` + * + * You can use this component to navigate back and forth in the web view's + * history and configure various properties for the web content. + */ +class WebView extends React.Component { + static JSNavigationScheme = JSNavigationScheme; + static NavigationType = NavigationType; + + static propTypes = { + ...View.propTypes, + + html: deprecatedPropType( + PropTypes.string, + 'Use the `source` prop instead.' + ), + + url: deprecatedPropType( + PropTypes.string, + 'Use the `source` prop instead.' + ), + + /** + * Loads static html or a uri (with optional headers) in the WebView. + */ + source: PropTypes.oneOfType([ + PropTypes.shape({ + /* + * The URI to load in the `WebView`. Can be a local or remote file. + */ + uri: PropTypes.string, + /* + * The HTTP Method to use. Defaults to GET if not specified. + * NOTE: On Android, only GET and POST are supported. + */ + method: PropTypes.string, + /* + * Additional HTTP headers to send with the request. + * NOTE: On Android, this can only be used with GET requests. + */ + headers: PropTypes.object, + /* + * The HTTP body to send with the request. This must be a valid + * UTF-8 string, and will be sent exactly as specified, with no + * additional encoding (e.g. URL-escaping or base64) applied. + * NOTE: On Android, this can only be used with POST requests. + */ + body: PropTypes.string, + }), + PropTypes.shape({ + /* + * A static HTML page to display in the WebView. + */ + html: PropTypes.string, + /* + * The base URL to be used for any relative links in the HTML. + */ + baseUrl: PropTypes.string, + }), + /* + * Used internally by packager. + */ + PropTypes.number, + ]), + + /** + * Function that returns a view to show if there's an error. + */ + renderError: PropTypes.func, // view to show if there's an error + /** + * Function that returns a loading indicator. + */ + renderLoading: PropTypes.func, + /** + * Function that is invoked when the `WebView` has finished loading. + */ + onLoad: PropTypes.func, + /** + * Function that is invoked when the `WebView` load succeeds or fails. + */ + onLoadEnd: PropTypes.func, + /** + * Function that is invoked when the `WebView` starts loading. + */ + onLoadStart: PropTypes.func, + /** + * Function that is invoked when the `WebView` load fails. + */ + onError: PropTypes.func, + /** + * Boolean value that determines whether the web view bounces + * when it reaches the edge of the content. The default value is `true`. + * @platform ios + */ + bounces: PropTypes.bool, + /** + * A floating-point number that determines how quickly the scroll view + * decelerates after the user lifts their finger. You may also use the + * string shortcuts `"normal"` and `"fast"` which match the underlying iOS + * settings for `UIScrollViewDecelerationRateNormal` and + * `UIScrollViewDecelerationRateFast` respectively: + * + * - normal: 0.998 + * - fast: 0.99 (the default for iOS web view) + * @platform ios + */ + decelerationRate: ScrollView.propTypes.decelerationRate, + /** + * Boolean value that determines whether scrolling is enabled in the + * `WebView`. The default value is `true`. + * @platform ios + */ + scrollEnabled: PropTypes.bool, + /** + * Controls whether to adjust the content inset for web views that are + * placed behind a navigation bar, tab bar, or toolbar. The default value + * is `true`. + */ + automaticallyAdjustContentInsets: PropTypes.bool, + /** + * The amount by which the web view content is inset from the edges of + * the scroll view. Defaults to {top: 0, left: 0, bottom: 0, right: 0}. + */ + contentInset: EdgeInsetsPropType, + /** + * Function that is invoked when the `WebView` loading starts or ends. + */ + onNavigationStateChange: PropTypes.func, + /** + * A function that is invoked when the webview calls `window.postMessage`. + * Setting this property will inject a `postMessage` global into your + * webview, but will still call pre-existing values of `postMessage`. + * + * `window.postMessage` accepts one argument, `data`, which will be + * available on the event object, `event.nativeEvent.data`. `data` + * must be a string. + */ + onMessage: PropTypes.func, + /** + * Boolean value that forces the `WebView` to show the loading view + * on the first load. + */ + startInLoadingState: PropTypes.bool, + /** + * The style to apply to the `WebView`. + */ + style: View.propTypes.style, + + /** + * Determines the types of data converted to clickable URLs in the web view’s content. + * By default only phone numbers are detected. + * + * You can provide one type or an array of many types. + * + * Possible values for `dataDetectorTypes` are: + * + * - `'phoneNumber'` + * - `'link'` + * - `'address'` + * - `'calendarEvent'` + * - `'none'` + * - `'all'` + * + * @platform ios + */ + dataDetectorTypes: PropTypes.oneOfType([ + PropTypes.oneOf(DataDetectorTypes), + PropTypes.arrayOf(PropTypes.oneOf(DataDetectorTypes)), + ]), + + /** + * Boolean value to enable JavaScript in the `WebView`. Used on Android only + * as JavaScript is enabled by default on iOS. The default value is `true`. + * @platform android + */ + javaScriptEnabled: PropTypes.bool, + + /** + * Boolean value to control whether DOM Storage is enabled. Used only in + * Android. + * @platform android + */ + domStorageEnabled: PropTypes.bool, + + /** + * Set this to provide JavaScript that will be injected into the web page + * when the view loads. + */ + injectedJavaScript: PropTypes.string, + + /** + * Sets the user-agent for the `WebView`. + * @platform android + */ + userAgent: PropTypes.string, + + /** + * Boolean that controls whether the web content is scaled to fit + * the view and enables the user to change the scale. The default value + * is `true`. + */ + scalesPageToFit: PropTypes.bool, + + /** + * Function that allows custom handling of any web view requests. Return + * `true` from the function to continue loading the request and `false` + * to stop loading. + * @platform ios + */ + onShouldStartLoadWithRequest: PropTypes.func, + + /** + * Boolean that determines whether HTML5 videos play inline or use the + * native full-screen controller. The default value is `false`. + * + * **NOTE** : In order for video to play inline, not only does this + * property need to be set to `true`, but the video element in the HTML + * document must also include the `webkit-playsinline` attribute. + * @platform ios + */ + allowsInlineMediaPlayback: PropTypes.bool, + + /** + * Boolean that determines whether HTML5 audio and video requires the user + * to tap them before they start playing. The default value is `true`. + */ + mediaPlaybackRequiresUserAction: PropTypes.bool, + }; + + state = { + viewState: WebViewState.IDLE, + lastErrorEvent: (null: ?ErrorEvent), + startInLoadingState: true, + }; + + componentWillMount() { + if (this.props.startInLoadingState) { + this.setState({viewState: WebViewState.LOADING}); + } + } + + render() { + var otherView = null; + + if (this.state.viewState === WebViewState.LOADING) { + otherView = (this.props.renderLoading || defaultRenderLoading)(); + } else if (this.state.viewState === WebViewState.ERROR) { + var errorEvent = this.state.lastErrorEvent; + invariant( + errorEvent != null, + 'lastErrorEvent expected to be non-null' + ); + otherView = (this.props.renderError || defaultRenderError)( + errorEvent.domain, + errorEvent.code, + errorEvent.description + ); + } else if (this.state.viewState !== WebViewState.IDLE) { + console.error( + 'RCTWebView invalid state encountered: ' + this.state.loading + ); + } + + var webViewStyles = [styles.container, styles.webView, this.props.style]; + if (this.state.viewState === WebViewState.LOADING || + this.state.viewState === WebViewState.ERROR) { + // if we're in either LOADING or ERROR states, don't show the webView + webViewStyles.push(styles.hidden); + } + + var onShouldStartLoadWithRequest = this.props.onShouldStartLoadWithRequest && ((event: Event) => { + var shouldStart = this.props.onShouldStartLoadWithRequest && + this.props.onShouldStartLoadWithRequest(event.nativeEvent); + RCTWebViewManager.startLoadWithResult(!!shouldStart, event.nativeEvent.lockIdentifier); + }); + + var decelerationRate = processDecelerationRate(this.props.decelerationRate); + + var source = this.props.source || {}; + if (this.props.html) { + source.html = this.props.html; + } else if (this.props.url) { + source.uri = this.props.url; + } + + const messagingEnabled = typeof this.props.onMessage === 'function'; + + var webView = + ; + + return ( + + {webView} + {otherView} + + ); + } + + /** + * Go forward one page in the web view's history. + */ + goForward = () => { + UIManager.dispatchViewManagerCommand( + this.getWebViewHandle(), + UIManager.RCTWebView.Commands.goForward, + null + ); + }; + + /** + * Go back one page in the web view's history. + */ + goBack = () => { + UIManager.dispatchViewManagerCommand( + this.getWebViewHandle(), + UIManager.RCTWebView.Commands.goBack, + null + ); + }; + + /** + * Reloads the current page. + */ + reload = () => { + this.setState({viewState: WebViewState.LOADING}); + UIManager.dispatchViewManagerCommand( + this.getWebViewHandle(), + UIManager.RCTWebView.Commands.reload, + null + ); + }; + + /** + * Stop loading the current page. + */ + stopLoading = () => { + UIManager.dispatchViewManagerCommand( + this.getWebViewHandle(), + UIManager.RCTWebView.Commands.stopLoading, + null + ); + }; + + /** + * Posts a message to the web view, which will emit a `message` event. + * Accepts one argument, `data`, which must be a string. + * + * In your webview, you'll need to something like the following. + * + * ```js + * document.addEventListener('message', e => { document.title = e.data; }); + * ``` + */ + postMessage = (data) => { + UIManager.dispatchViewManagerCommand( + this.getWebViewHandle(), + UIManager.RCTWebView.Commands.postMessage, + [String(data)] + ); + }; + + /** + * We return an event with a bunch of fields including: + * url, title, loading, canGoBack, canGoForward + */ + _updateNavigationState = (event: Event) => { + if (this.props.onNavigationStateChange) { + this.props.onNavigationStateChange(event.nativeEvent); + } + }; + + /** + * Returns the native `WebView` node. + */ + getWebViewHandle = (): any => { + return ReactNative.findNodeHandle(this.refs[RCT_WEBVIEW_REF]); + }; + + _onLoadingStart = (event: Event) => { + var onLoadStart = this.props.onLoadStart; + onLoadStart && onLoadStart(event); + this._updateNavigationState(event); + }; + + _onLoadingError = (event: Event) => { + event.persist(); // persist this event because we need to store it + var {onError, onLoadEnd} = this.props; + onError && onError(event); + onLoadEnd && onLoadEnd(event); + console.warn('Encountered an error loading page', event.nativeEvent); + + this.setState({ + lastErrorEvent: event.nativeEvent, + viewState: WebViewState.ERROR + }); + }; + + _onLoadingFinish = (event: Event) => { + var {onLoad, onLoadEnd} = this.props; + onLoad && onLoad(event); + onLoadEnd && onLoadEnd(event); + this.setState({ + viewState: WebViewState.IDLE, + }); + this._updateNavigationState(event); + }; + + _onMessage = (event: Event) => { + var {onMessage} = this.props; + onMessage && onMessage(event); + } +} + +var RCTWebView = requireNativeComponent('RCTWebView', WebView, { + nativeOnly: { + onLoadingStart: true, + onLoadingError: true, + onLoadingFinish: true, + onMessage: true, + messagingEnabled: PropTypes.bool, + }, +}); + +var styles = StyleSheet.create({ + container: { + flex: 1, + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: BGWASH, + }, + errorText: { + fontSize: 14, + textAlign: 'center', + marginBottom: 2, + }, + errorTextTitle: { + fontSize: 15, + fontWeight: '500', + marginBottom: 10, + }, + hidden: { + height: 0, + flex: 0, // disable 'flex:1' when hiding a View + }, + loadingView: { + backgroundColor: BGWASH, + flex: 1, + justifyContent: 'center', + alignItems: 'center', + height: 100, + }, + webView: { + backgroundColor: '#ffffff', + } +}); + +module.exports = WebView; diff --git a/RNTester/js/assets/messagingtest.html b/RNTester/js/assets/messagingtest.html index 31200b7665da5b..c9258dbf5104a9 100644 --- a/RNTester/js/assets/messagingtest.html +++ b/RNTester/js/assets/messagingtest.html @@ -6,7 +6,7 @@ - Messages received from React Native: 0 + Messages recieved from React Native: 0 (No messages) Send message to React Native @@ -17,7 +17,7 @@ document.addEventListener('message', function(e) { messagesReceivedFromReactNative += 1; document.getElementsByTagName('p')[0].innerHTML = - 'Messages received from React Native: ' + messagesReceivedFromReactNative; + 'Messages recieved from React Native: ' + messagesReceivedFromReactNative; document.getElementsByTagName('p')[1].innerHTML = e.data; }); diff --git a/React/Views/RCTWebView.h b/React/Views/RCTWebView.h new file mode 100644 index 00000000000000..c2c41431f632d2 --- /dev/null +++ b/React/Views/RCTWebView.h @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +#import "RCTView.h" + +@class RCTWebView; + +/** + * Special scheme used to pass messages to the injectedJavaScript + * code without triggering a page load. Usage: + * + * window.location.href = RCTJSNavigationScheme + '://hello' + */ +extern NSString *const RCTJSNavigationScheme; + +@protocol RCTWebViewDelegate + +- (BOOL)webView:(RCTWebView *)webView +shouldStartLoadForRequest:(NSMutableDictionary *)request + withCallback:(RCTDirectEventBlock)callback; + +@end + +@interface RCTWebView : RCTView + +@property (nonatomic, weak) id delegate; + +@property (nonatomic, copy) NSDictionary *source; +@property (nonatomic, assign) UIEdgeInsets contentInset; +@property (nonatomic, assign) BOOL automaticallyAdjustContentInsets; +@property (nonatomic, assign) BOOL messagingEnabled; +@property (nonatomic, copy) NSString *injectedJavaScript; +@property (nonatomic, assign) BOOL scalesPageToFit; + +- (void)goForward; +- (void)goBack; +- (void)reload; +- (void)stopLoading; +- (void)postMessage:(NSString *)message; + +@end diff --git a/React/Views/RCTWebView.m b/React/Views/RCTWebView.m new file mode 100644 index 00000000000000..23fe37b84dca0f --- /dev/null +++ b/React/Views/RCTWebView.m @@ -0,0 +1,311 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +#import "RCTWebView.h" + +#import + +#import "RCTAutoInsetsProtocol.h" +#import "RCTConvert.h" +#import "RCTEventDispatcher.h" +#import "RCTLog.h" +#import "RCTUtils.h" +#import "RCTView.h" +#import "UIView+React.h" + +NSString *const RCTJSNavigationScheme = @"react-js-navigation"; +NSString *const RCTJSPostMessageHost = @"postMessage"; + +@interface RCTWebView () + +@property (nonatomic, copy) RCTDirectEventBlock onLoadingStart; +@property (nonatomic, copy) RCTDirectEventBlock onLoadingFinish; +@property (nonatomic, copy) RCTDirectEventBlock onLoadingError; +@property (nonatomic, copy) RCTDirectEventBlock onShouldStartLoadWithRequest; +@property (nonatomic, copy) RCTDirectEventBlock onMessage; + +@end + +@implementation RCTWebView +{ + UIWebView *_webView; + NSString *_injectedJavaScript; +} + +- (void)dealloc +{ + _webView.delegate = nil; +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if ((self = [super initWithFrame:frame])) { + super.backgroundColor = [UIColor clearColor]; + _automaticallyAdjustContentInsets = YES; + _contentInset = UIEdgeInsetsZero; + _webView = [[UIWebView alloc] initWithFrame:self.bounds]; + _webView.delegate = self; + [self addSubview:_webView]; + } + return self; +} + +RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder) + +- (void)goForward +{ + [_webView goForward]; +} + +- (void)goBack +{ + [_webView goBack]; +} + +- (void)reload +{ + NSURLRequest *request = [RCTConvert NSURLRequest:self.source]; + if (request.URL && !_webView.request.URL.absoluteString.length) { + [_webView loadRequest:request]; + } + else { + [_webView reload]; + } +} + +- (void)stopLoading +{ + [_webView stopLoading]; +} + +- (void)postMessage:(NSString *)message +{ + NSDictionary *eventInitDict = @{ + @"data": message, + }; + NSString *source = [NSString + stringWithFormat:@"document.dispatchEvent(new MessageEvent('message', %@));", + RCTJSONStringify(eventInitDict, NULL) + ]; + [_webView stringByEvaluatingJavaScriptFromString:source]; +} + +- (void)setSource:(NSDictionary *)source +{ + if (![_source isEqualToDictionary:source]) { + _source = [source copy]; + + // Check for a static html source first + NSString *html = [RCTConvert NSString:source[@"html"]]; + if (html) { + NSURL *baseURL = [RCTConvert NSURL:source[@"baseUrl"]]; + if (!baseURL) { + baseURL = [NSURL URLWithString:@"about:blank"]; + } + [_webView loadHTMLString:html baseURL:baseURL]; + return; + } + + NSURLRequest *request = [RCTConvert NSURLRequest:source]; + // Because of the way React works, as pages redirect, we actually end up + // passing the redirect urls back here, so we ignore them if trying to load + // the same url. We'll expose a call to 'reload' to allow a user to load + // the existing page. + if ([request.URL isEqual:_webView.request.URL]) { + return; + } + if (!request.URL) { + // Clear the webview + [_webView loadHTMLString:@"" baseURL:nil]; + return; + } + [_webView loadRequest:request]; + } +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + _webView.frame = self.bounds; +} + +- (void)setContentInset:(UIEdgeInsets)contentInset +{ + _contentInset = contentInset; + [RCTView autoAdjustInsetsForView:self + withScrollView:_webView.scrollView + updateOffset:NO]; +} + +- (void)setScalesPageToFit:(BOOL)scalesPageToFit +{ + if (_webView.scalesPageToFit != scalesPageToFit) { + _webView.scalesPageToFit = scalesPageToFit; + [_webView reload]; + } +} + +- (BOOL)scalesPageToFit +{ + return _webView.scalesPageToFit; +} + +- (void)setBackgroundColor:(UIColor *)backgroundColor +{ + CGFloat alpha = CGColorGetAlpha(backgroundColor.CGColor); + self.opaque = _webView.opaque = (alpha == 1.0); + _webView.backgroundColor = backgroundColor; +} + +- (UIColor *)backgroundColor +{ + return _webView.backgroundColor; +} + +- (NSMutableDictionary *)baseEvent +{ + NSMutableDictionary *event = [[NSMutableDictionary alloc] initWithDictionary:@{ + @"url": _webView.request.URL.absoluteString ?: @"", + @"loading" : @(_webView.loading), + @"title": [_webView stringByEvaluatingJavaScriptFromString:@"document.title"], + @"canGoBack": @(_webView.canGoBack), + @"canGoForward" : @(_webView.canGoForward), + }]; + + return event; +} + +- (void)refreshContentInset +{ + [RCTView autoAdjustInsetsForView:self + withScrollView:_webView.scrollView + updateOffset:YES]; +} + +#pragma mark - UIWebViewDelegate methods + +- (BOOL)webView:(__unused UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request + navigationType:(UIWebViewNavigationType)navigationType +{ + BOOL isJSNavigation = [request.URL.scheme isEqualToString:RCTJSNavigationScheme]; + + static NSDictionary *navigationTypes; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + navigationTypes = @{ + @(UIWebViewNavigationTypeLinkClicked): @"click", + @(UIWebViewNavigationTypeFormSubmitted): @"formsubmit", + @(UIWebViewNavigationTypeBackForward): @"backforward", + @(UIWebViewNavigationTypeReload): @"reload", + @(UIWebViewNavigationTypeFormResubmitted): @"formresubmit", + @(UIWebViewNavigationTypeOther): @"other", + }; + }); + + // skip this for the JS Navigation handler + if (!isJSNavigation && _onShouldStartLoadWithRequest) { + NSMutableDictionary *event = [self baseEvent]; + [event addEntriesFromDictionary: @{ + @"url": (request.URL).absoluteString, + @"navigationType": navigationTypes[@(navigationType)] + }]; + if (![self.delegate webView:self + shouldStartLoadForRequest:event + withCallback:_onShouldStartLoadWithRequest]) { + return NO; + } + } + + if (_onLoadingStart) { + // We have this check to filter out iframe requests and whatnot + BOOL isTopFrame = [request.URL isEqual:request.mainDocumentURL]; + if (isTopFrame) { + NSMutableDictionary *event = [self baseEvent]; + [event addEntriesFromDictionary: @{ + @"url": (request.URL).absoluteString, + @"navigationType": navigationTypes[@(navigationType)] + }]; + _onLoadingStart(event); + } + } + + if (isJSNavigation && [request.URL.host isEqualToString:RCTJSPostMessageHost]) { + NSString *data = request.URL.query; + data = [data stringByReplacingOccurrencesOfString:@"+" withString:@" "]; + data = [data stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding]; + + NSMutableDictionary *event = [self baseEvent]; + [event addEntriesFromDictionary: @{ + @"data": data, + }]; + _onMessage(event); + } + + // JS Navigation handler + return !isJSNavigation; +} + +- (void)webView:(__unused UIWebView *)webView didFailLoadWithError:(NSError *)error +{ + if (_onLoadingError) { + if ([error.domain isEqualToString:NSURLErrorDomain] && error.code == NSURLErrorCancelled) { + // NSURLErrorCancelled is reported when a page has a redirect OR if you load + // a new URL in the WebView before the previous one came back. We can just + // ignore these since they aren't real errors. + // http://stackoverflow.com/questions/1024748/how-do-i-fix-nsurlerrordomain-error-999-in-iphone-3-0-os + return; + } + + NSMutableDictionary *event = [self baseEvent]; + [event addEntriesFromDictionary:@{ + @"domain": error.domain, + @"code": @(error.code), + @"description": error.localizedDescription, + }]; + _onLoadingError(event); + } +} + +- (void)webViewDidFinishLoad:(UIWebView *)webView +{ + if (_messagingEnabled) { + #if RCT_DEV + // See isNative in lodash + NSString *testPostMessageNative = @"String(window.postMessage) === String(Object.hasOwnProperty).replace('hasOwnProperty', 'postMessage')"; + BOOL postMessageIsNative = [ + [webView stringByEvaluatingJavaScriptFromString:testPostMessageNative] + isEqualToString:@"true" + ]; + if (!postMessageIsNative) { + RCTLogError(@"Setting onMessage on a WebView overrides existing values of window.postMessage, but a previous value was defined"); + } + #endif + NSString *source = [NSString stringWithFormat: + @"window.originalPostMessage = window.postMessage;" + "window.postMessage = function(data) {" + "window.location = '%@://%@?' + encodeURIComponent(String(data));" + "};", RCTJSNavigationScheme, RCTJSPostMessageHost + ]; + [webView stringByEvaluatingJavaScriptFromString:source]; + } + if (_injectedJavaScript != nil) { + NSString *jsEvaluationValue = [webView stringByEvaluatingJavaScriptFromString:_injectedJavaScript]; + + NSMutableDictionary *event = [self baseEvent]; + event[@"jsEvaluationValue"] = jsEvaluationValue; + + _onLoadingFinish(event); + } + // we only need the final 'finishLoad' call so only fire the event when we're actually done loading. + else if (_onLoadingFinish && !webView.loading && ![webView.request.URL.absoluteString isEqualToString:@"about:blank"]) { + _onLoadingFinish([self baseEvent]); + } +} + +@end diff --git a/React/Views/RCTWebViewManager.m b/React/Views/RCTWebViewManager.m new file mode 100644 index 00000000000000..2730124fffa281 --- /dev/null +++ b/React/Views/RCTWebViewManager.m @@ -0,0 +1,148 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +#import "RCTWebViewManager.h" + +#import "RCTBridge.h" +#import "RCTUIManager.h" +#import "RCTWebView.h" +#import "UIView+React.h" + +@interface RCTWebViewManager () + +@end + +@implementation RCTWebViewManager +{ + NSConditionLock *_shouldStartLoadLock; + BOOL _shouldStartLoad; +} + +RCT_EXPORT_MODULE() + +- (UIView *)view +{ + RCTWebView *webView = [RCTWebView new]; + webView.delegate = self; + return webView; +} + +RCT_EXPORT_VIEW_PROPERTY(source, NSDictionary) +RCT_REMAP_VIEW_PROPERTY(bounces, _webView.scrollView.bounces, BOOL) +RCT_REMAP_VIEW_PROPERTY(scrollEnabled, _webView.scrollView.scrollEnabled, BOOL) +RCT_REMAP_VIEW_PROPERTY(decelerationRate, _webView.scrollView.decelerationRate, CGFloat) +RCT_EXPORT_VIEW_PROPERTY(scalesPageToFit, BOOL) +RCT_EXPORT_VIEW_PROPERTY(messagingEnabled, BOOL) +RCT_EXPORT_VIEW_PROPERTY(injectedJavaScript, NSString) +RCT_EXPORT_VIEW_PROPERTY(contentInset, UIEdgeInsets) +RCT_EXPORT_VIEW_PROPERTY(automaticallyAdjustContentInsets, BOOL) +RCT_EXPORT_VIEW_PROPERTY(onLoadingStart, RCTDirectEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onLoadingFinish, RCTDirectEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onLoadingError, RCTDirectEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onMessage, RCTDirectEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onShouldStartLoadWithRequest, RCTDirectEventBlock) +RCT_REMAP_VIEW_PROPERTY(allowsInlineMediaPlayback, _webView.allowsInlineMediaPlayback, BOOL) +RCT_REMAP_VIEW_PROPERTY(mediaPlaybackRequiresUserAction, _webView.mediaPlaybackRequiresUserAction, BOOL) +RCT_REMAP_VIEW_PROPERTY(dataDetectorTypes, _webView.dataDetectorTypes, UIDataDetectorTypes) + +RCT_EXPORT_METHOD(goBack:(nonnull NSNumber *)reactTag) +{ + [self.bridge.uiManager addUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary *viewRegistry) { + RCTWebView *view = viewRegistry[reactTag]; + if (![view isKindOfClass:[RCTWebView class]]) { + RCTLogError(@"Invalid view returned from registry, expecting RCTWebView, got: %@", view); + } else { + [view goBack]; + } + }]; +} + +RCT_EXPORT_METHOD(goForward:(nonnull NSNumber *)reactTag) +{ + [self.bridge.uiManager addUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary *viewRegistry) { + id view = viewRegistry[reactTag]; + if (![view isKindOfClass:[RCTWebView class]]) { + RCTLogError(@"Invalid view returned from registry, expecting RCTWebView, got: %@", view); + } else { + [view goForward]; + } + }]; +} + +RCT_EXPORT_METHOD(reload:(nonnull NSNumber *)reactTag) +{ + [self.bridge.uiManager addUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary *viewRegistry) { + RCTWebView *view = viewRegistry[reactTag]; + if (![view isKindOfClass:[RCTWebView class]]) { + RCTLogError(@"Invalid view returned from registry, expecting RCTWebView, got: %@", view); + } else { + [view reload]; + } + }]; +} + +RCT_EXPORT_METHOD(stopLoading:(nonnull NSNumber *)reactTag) +{ + [self.bridge.uiManager addUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary *viewRegistry) { + RCTWebView *view = viewRegistry[reactTag]; + if (![view isKindOfClass:[RCTWebView class]]) { + RCTLogError(@"Invalid view returned from registry, expecting RCTWebView, got: %@", view); + } else { + [view stopLoading]; + } + }]; +} + +RCT_EXPORT_METHOD(postMessage:(nonnull NSNumber *)reactTag message:(NSString *)message) +{ + [self.bridge.uiManager addUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary *viewRegistry) { + RCTWebView *view = viewRegistry[reactTag]; + if (![view isKindOfClass:[RCTWebView class]]) { + RCTLogError(@"Invalid view returned from registry, expecting RCTWebView, got: %@", view); + } else { + [view postMessage:message]; + } + }]; +} + +#pragma mark - Exported synchronous methods + +- (BOOL)webView:(__unused RCTWebView *)webView +shouldStartLoadForRequest:(NSMutableDictionary *)request + withCallback:(RCTDirectEventBlock)callback +{ + _shouldStartLoadLock = [[NSConditionLock alloc] initWithCondition:arc4random()]; + _shouldStartLoad = YES; + request[@"lockIdentifier"] = @(_shouldStartLoadLock.condition); + callback(request); + + // Block the main thread for a maximum of 250ms until the JS thread returns + if ([_shouldStartLoadLock lockWhenCondition:0 beforeDate:[NSDate dateWithTimeIntervalSinceNow:.25]]) { + BOOL returnValue = _shouldStartLoad; + [_shouldStartLoadLock unlock]; + _shouldStartLoadLock = nil; + return returnValue; + } else { + RCTLogWarn(@"Did not receive response to shouldStartLoad in time, defaulting to YES"); + return YES; + } +} + +RCT_EXPORT_METHOD(startLoadWithResult:(BOOL)result lockIdentifier:(NSInteger)lockIdentifier) +{ + if ([_shouldStartLoadLock tryLockWhenCondition:lockIdentifier]) { + _shouldStartLoad = result; + [_shouldStartLoadLock unlockWithCondition:0]; + } else { + RCTLogWarn(@"startLoadWithResult invoked with invalid lockIdentifier: " + "got %zd, expected %zd", lockIdentifier, _shouldStartLoadLock.condition); + } +} + +@end diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.java index 0a42948bf7e4ac..a09a897f0d84c2 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.java @@ -56,6 +56,7 @@ /* package */ static Map getDirectEventTypeConstants() { final String rn = "registrationName"; return MapBuilder.builder() +<<<<<<< HEAD .put("topContentSizeChange", MapBuilder.of(rn, "onContentSizeChange")) .put("topLayout", MapBuilder.of(rn, "onLayout")) .put("topLoadingError", MapBuilder.of(rn, "onLoadingError")) @@ -71,6 +72,15 @@ .put("topScroll", MapBuilder.of(rn, "onScroll")) .put("topMomentumScrollBegin", MapBuilder.of(rn, "onMomentumScrollBegin")) .put("topMomentumScrollEnd", MapBuilder.of(rn, "onMomentumScrollEnd")) +======= + .put("topContentSizeChange", MapBuilder.of("registrationName", "onContentSizeChange")) + .put("topLayout", MapBuilder.of("registrationName", "onLayout")) + .put("topLoadingError", MapBuilder.of("registrationName", "onLoadingError")) + .put("topLoadingFinish", MapBuilder.of("registrationName", "onLoadingFinish")) + .put("topLoadingStart", MapBuilder.of("registrationName", "onLoadingStart")) + .put("topSelectionChange", MapBuilder.of("registrationName", "onSelectionChange")) + .put("topMessage", MapBuilder.of("registrationName", "onMessage")) +>>>>>>> abb8ea3aea... Implement a postMessage function and an onMessage event for webviews … .build(); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/webview/BUCK b/ReactAndroid/src/main/java/com/facebook/react/views/webview/BUCK new file mode 100644 index 00000000000000..7a9875b621e92d --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/webview/BUCK @@ -0,0 +1,21 @@ +include_defs('//ReactAndroid/DEFS') + +android_library( + name = 'webview', + srcs = glob(['**/*.java']), + deps = [ + react_native_dep('libraries/fbcore/src/main/java/com/facebook/common/logging:logging'), + react_native_target('java/com/facebook/react/bridge:bridge'), + react_native_target('java/com/facebook/react/uimanager:uimanager'), + react_native_target('java/com/facebook/react/uimanager/annotations:annotations'), + react_native_target('java/com/facebook/react/common:common'), + react_native_dep('third-party/java/jsr-305:jsr-305'), + ], + visibility = [ + 'PUBLIC', + ], +) + +project_config( + src_target = ':webview', +) diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/webview/ReactWebViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/webview/ReactWebViewManager.java new file mode 100644 index 00000000000000..735f23d333cd22 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/webview/ReactWebViewManager.java @@ -0,0 +1,521 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.views.webview; + +import javax.annotation.Nullable; + +import java.io.UnsupportedEncodingException; +import java.util.HashMap; +import java.util.Map; + +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.Picture; +import android.net.Uri; +import android.os.Build; +import android.text.TextUtils; +import android.view.ViewGroup.LayoutParams; +import android.webkit.GeolocationPermissions; +import android.webkit.WebChromeClient; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.webkit.JavascriptInterface; +import android.webkit.ValueCallback; + +import com.facebook.common.logging.FLog; +import com.facebook.react.common.ReactConstants; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.LifecycleEventListener; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.ReadableMapKeySetIterator; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.common.MapBuilder; +import com.facebook.react.common.build.ReactBuildConfig; +import com.facebook.react.uimanager.SimpleViewManager; +import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.uimanager.UIManagerModule; +import com.facebook.react.uimanager.annotations.ReactProp; +import com.facebook.react.uimanager.events.ContentSizeChangeEvent; +import com.facebook.react.uimanager.events.Event; +import com.facebook.react.uimanager.events.EventDispatcher; +import com.facebook.react.views.webview.events.TopLoadingErrorEvent; +import com.facebook.react.views.webview.events.TopLoadingFinishEvent; +import com.facebook.react.views.webview.events.TopLoadingStartEvent; +import com.facebook.react.views.webview.events.TopMessageEvent; + +import org.json.JSONObject; +import org.json.JSONException; + +/** + * Manages instances of {@link WebView} + * + * Can accept following commands: + * - GO_BACK + * - GO_FORWARD + * - RELOAD + * + * {@link WebView} instances could emit following direct events: + * - topLoadingFinish + * - topLoadingStart + * - topLoadingError + * + * Each event will carry the following properties: + * - target - view's react tag + * - url - url set for the webview + * - loading - whether webview is in a loading state + * - title - title of the current page + * - canGoBack - boolean, whether there is anything on a history stack to go back + * - canGoForward - boolean, whether it is possible to request GO_FORWARD command + */ +public class ReactWebViewManager extends SimpleViewManager { + + private static final String REACT_CLASS = "RCTWebView"; + + private static final String HTML_ENCODING = "UTF-8"; + private static final String HTML_MIME_TYPE = "text/html; charset=utf-8"; + private static final String BRIDGE_NAME = "__REACT_WEB_VIEW_BRIDGE"; + + private static final String HTTP_METHOD_POST = "POST"; + + public static final int COMMAND_GO_BACK = 1; + public static final int COMMAND_GO_FORWARD = 2; + public static final int COMMAND_RELOAD = 3; + public static final int COMMAND_STOP_LOADING = 4; + public static final int COMMAND_POST_MESSAGE = 5; + + // Use `webView.loadUrl("about:blank")` to reliably reset the view + // state and release page resources (including any running JavaScript). + private static final String BLANK_URL = "about:blank"; + + private WebViewConfig mWebViewConfig; + private @Nullable WebView.PictureListener mPictureListener; + + private static class ReactWebViewClient extends WebViewClient { + + private boolean mLastLoadFailed = false; + + @Override + public void onPageFinished(WebView webView, String url) { + super.onPageFinished(webView, url); + + if (!mLastLoadFailed) { + ReactWebView reactWebView = (ReactWebView) webView; + reactWebView.callInjectedJavaScript(); + reactWebView.linkBridge(); + emitFinishEvent(webView, url); + } + } + + @Override + public void onPageStarted(WebView webView, String url, Bitmap favicon) { + super.onPageStarted(webView, url, favicon); + mLastLoadFailed = false; + + dispatchEvent( + webView, + new TopLoadingStartEvent( + webView.getId(), + createWebViewEvent(webView, url))); + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + if (url.startsWith("http://") || url.startsWith("https://") || + url.startsWith("file://")) { + return false; + } else { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + view.getContext().startActivity(intent); + return true; + } + } + + @Override + public void onReceivedError( + WebView webView, + int errorCode, + String description, + String failingUrl) { + super.onReceivedError(webView, errorCode, description, failingUrl); + mLastLoadFailed = true; + + // In case of an error JS side expect to get a finish event first, and then get an error event + // Android WebView does it in the opposite way, so we need to simulate that behavior + emitFinishEvent(webView, failingUrl); + + WritableMap eventData = createWebViewEvent(webView, failingUrl); + eventData.putDouble("code", errorCode); + eventData.putString("description", description); + + dispatchEvent( + webView, + new TopLoadingErrorEvent(webView.getId(), eventData)); + } + + @Override + public void doUpdateVisitedHistory(WebView webView, String url, boolean isReload) { + super.doUpdateVisitedHistory(webView, url, isReload); + + dispatchEvent( + webView, + new TopLoadingStartEvent( + webView.getId(), + createWebViewEvent(webView, url))); + } + + private void emitFinishEvent(WebView webView, String url) { + dispatchEvent( + webView, + new TopLoadingFinishEvent( + webView.getId(), + createWebViewEvent(webView, url))); + } + + private WritableMap createWebViewEvent(WebView webView, String url) { + WritableMap event = Arguments.createMap(); + event.putDouble("target", webView.getId()); + // Don't use webView.getUrl() here, the URL isn't updated to the new value yet in callbacks + // like onPageFinished + event.putString("url", url); + event.putBoolean("loading", !mLastLoadFailed && webView.getProgress() != 100); + event.putString("title", webView.getTitle()); + event.putBoolean("canGoBack", webView.canGoBack()); + event.putBoolean("canGoForward", webView.canGoForward()); + return event; + } + } + + /** + * Subclass of {@link WebView} that implements {@link LifecycleEventListener} interface in order + * to call {@link WebView#destroy} on activty destroy event and also to clear the client + */ + private static class ReactWebView extends WebView implements LifecycleEventListener { + private @Nullable String injectedJS; + private boolean messagingEnabled = false; + + private class ReactWebViewBridge { + ReactWebView mContext; + + ReactWebViewBridge(ReactWebView c) { + mContext = c; + } + + @JavascriptInterface + public void postMessage(String message) { + mContext.onMessage(message); + } + } + + /** + * WebView must be created with an context of the current activity + * + * Activity Context is required for creation of dialogs internally by WebView + * Reactive Native needed for access to ReactNative internal system functionality + * + */ + public ReactWebView(ThemedReactContext reactContext) { + super(reactContext); + } + + @Override + public void onHostResume() { + // do nothing + } + + @Override + public void onHostPause() { + // do nothing + } + + @Override + public void onHostDestroy() { + cleanupCallbacksAndDestroy(); + } + + public void setInjectedJavaScript(@Nullable String js) { + injectedJS = js; + } + + public void setMessagingEnabled(boolean enabled) { + if (messagingEnabled == enabled) { + return; + } + + messagingEnabled = enabled; + if (enabled) { + addJavascriptInterface(new ReactWebViewBridge(this), BRIDGE_NAME); + linkBridge(); + } else { + removeJavascriptInterface(BRIDGE_NAME); + } + } + + public void callInjectedJavaScript() { + if (getSettings().getJavaScriptEnabled() && + injectedJS != null && + !TextUtils.isEmpty(injectedJS)) { + loadUrl("javascript:(function() {\n" + injectedJS + ";\n})();"); + } + } + + public void linkBridge() { + if (messagingEnabled) { + if (ReactBuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + // See isNative in lodash + String testPostMessageNative = "String(window.postMessage) === String(Object.hasOwnProperty).replace('hasOwnProperty', 'postMessage')"; + evaluateJavascript(testPostMessageNative, new ValueCallback() { + @Override + public void onReceiveValue(String value) { + if (value.equals("true")) { + FLog.w(ReactConstants.TAG, "Setting onMessage on a WebView overrides existing values of window.postMessage, but a previous value was defined"); + } + } + }); + } + + loadUrl("javascript:(" + + "window.originalPostMessage = window.postMessage," + + "window.postMessage = function(data) {" + + BRIDGE_NAME + ".postMessage(String(data));" + + "}" + + ")"); + } + } + + public void onMessage(String message) { + dispatchEvent(this, new TopMessageEvent(this.getId(), message)); + } + + private void cleanupCallbacksAndDestroy() { + setWebViewClient(null); + destroy(); + } + } + + public ReactWebViewManager() { + mWebViewConfig = new WebViewConfig() { + public void configWebView(WebView webView) { + } + }; + } + + public ReactWebViewManager(WebViewConfig webViewConfig) { + mWebViewConfig = webViewConfig; + } + + @Override + public String getName() { + return REACT_CLASS; + } + + @Override + protected WebView createViewInstance(ThemedReactContext reactContext) { + ReactWebView webView = new ReactWebView(reactContext); + webView.setWebChromeClient(new WebChromeClient() { + @Override + public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermissions.Callback callback) { + callback.invoke(origin, true, false); + } + }); + reactContext.addLifecycleEventListener(webView); + mWebViewConfig.configWebView(webView); + webView.getSettings().setBuiltInZoomControls(true); + webView.getSettings().setDisplayZoomControls(false); + + // Fixes broken full-screen modals/galleries due to body height being 0. + webView.setLayoutParams( + new LayoutParams(LayoutParams.MATCH_PARENT, + LayoutParams.MATCH_PARENT)); + + if (ReactBuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + WebView.setWebContentsDebuggingEnabled(true); + } + + return webView; + } + + @ReactProp(name = "javaScriptEnabled") + public void setJavaScriptEnabled(WebView view, boolean enabled) { + view.getSettings().setJavaScriptEnabled(enabled); + } + + @ReactProp(name = "scalesPageToFit") + public void setScalesPageToFit(WebView view, boolean enabled) { + view.getSettings().setUseWideViewPort(!enabled); + } + + @ReactProp(name = "domStorageEnabled") + public void setDomStorageEnabled(WebView view, boolean enabled) { + view.getSettings().setDomStorageEnabled(enabled); + } + + @ReactProp(name = "userAgent") + public void setUserAgent(WebView view, @Nullable String userAgent) { + if (userAgent != null) { + // TODO(8496850): Fix incorrect behavior when property is unset (uA == null) + view.getSettings().setUserAgentString(userAgent); + } + } + + @ReactProp(name = "mediaPlaybackRequiresUserAction") + public void setMediaPlaybackRequiresUserAction(WebView view, boolean requires) { + view.getSettings().setMediaPlaybackRequiresUserGesture(requires); + } + + @ReactProp(name = "injectedJavaScript") + public void setInjectedJavaScript(WebView view, @Nullable String injectedJavaScript) { + ((ReactWebView) view).setInjectedJavaScript(injectedJavaScript); + } + + @ReactProp(name = "messagingEnabled") + public void setMessagingEnabled(WebView view, boolean enabled) { + ((ReactWebView) view).setMessagingEnabled(enabled); + } + + @ReactProp(name = "source") + public void setSource(WebView view, @Nullable ReadableMap source) { + if (source != null) { + if (source.hasKey("html")) { + String html = source.getString("html"); + if (source.hasKey("baseUrl")) { + view.loadDataWithBaseURL( + source.getString("baseUrl"), html, HTML_MIME_TYPE, HTML_ENCODING, null); + } else { + view.loadData(html, HTML_MIME_TYPE, HTML_ENCODING); + } + return; + } + if (source.hasKey("uri")) { + String url = source.getString("uri"); + String previousUrl = view.getUrl(); + if (previousUrl != null && previousUrl.equals(url)) { + return; + } + if (source.hasKey("method")) { + String method = source.getString("method"); + if (method.equals(HTTP_METHOD_POST)) { + byte[] postData = null; + if (source.hasKey("body")) { + String body = source.getString("body"); + try { + postData = body.getBytes("UTF-8"); + } catch (UnsupportedEncodingException e) { + postData = body.getBytes(); + } + } + if (postData == null) { + postData = new byte[0]; + } + view.postUrl(url, postData); + return; + } + } + HashMap headerMap = new HashMap<>(); + if (source.hasKey("headers")) { + ReadableMap headers = source.getMap("headers"); + ReadableMapKeySetIterator iter = headers.keySetIterator(); + while (iter.hasNextKey()) { + String key = iter.nextKey(); + headerMap.put(key, headers.getString(key)); + } + } + view.loadUrl(url, headerMap); + return; + } + } + view.loadUrl(BLANK_URL); + } + + @ReactProp(name = "onContentSizeChange") + public void setOnContentSizeChange(WebView view, boolean sendContentSizeChangeEvents) { + if (sendContentSizeChangeEvents) { + view.setPictureListener(getPictureListener()); + } else { + view.setPictureListener(null); + } + } + + @Override + protected void addEventEmitters(ThemedReactContext reactContext, WebView view) { + // Do not register default touch emitter and let WebView implementation handle touches + view.setWebViewClient(new ReactWebViewClient()); + } + + @Override + public @Nullable Map getCommandsMap() { + return MapBuilder.of( + "goBack", COMMAND_GO_BACK, + "goForward", COMMAND_GO_FORWARD, + "reload", COMMAND_RELOAD, + "stopLoading", COMMAND_STOP_LOADING, + "postMessage", COMMAND_POST_MESSAGE); + } + + @Override + public void receiveCommand(WebView root, int commandId, @Nullable ReadableArray args) { + switch (commandId) { + case COMMAND_GO_BACK: + root.goBack(); + break; + case COMMAND_GO_FORWARD: + root.goForward(); + break; + case COMMAND_RELOAD: + root.reload(); + break; + case COMMAND_STOP_LOADING: + root.stopLoading(); + break; + case COMMAND_POST_MESSAGE: + try { + JSONObject eventInitDict = new JSONObject(); + eventInitDict.put("data", args.getString(0)); + root.loadUrl("javascript:(document.dispatchEvent(new MessageEvent('message', " + eventInitDict.toString() + ")))"); + } catch (JSONException e) { + throw new RuntimeException(e); + } + break; + } + } + + @Override + public void onDropViewInstance(WebView webView) { + super.onDropViewInstance(webView); + ((ThemedReactContext) webView.getContext()).removeLifecycleEventListener((ReactWebView) webView); + ((ReactWebView) webView).cleanupCallbacksAndDestroy(); + } + + private WebView.PictureListener getPictureListener() { + if (mPictureListener == null) { + mPictureListener = new WebView.PictureListener() { + @Override + public void onNewPicture(WebView webView, Picture picture) { + dispatchEvent( + webView, + new ContentSizeChangeEvent( + webView.getId(), + webView.getWidth(), + webView.getContentHeight())); + } + }; + } + return mPictureListener; + } + + private static void dispatchEvent(WebView webView, Event event) { + ReactContext reactContext = (ReactContext) webView.getContext(); + EventDispatcher eventDispatcher = + reactContext.getNativeModule(UIManagerModule.class).getEventDispatcher(); + eventDispatcher.dispatchEvent(event); + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/webview/events/TopMessageEvent.java b/ReactAndroid/src/main/java/com/facebook/react/views/webview/events/TopMessageEvent.java new file mode 100644 index 00000000000000..db5a4200d7fd6e --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/webview/events/TopMessageEvent.java @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.views.webview.events; + +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.uimanager.events.Event; +import com.facebook.react.uimanager.events.RCTEventEmitter; + +/** + * Event emitted when there is an error in loading. + */ +public class TopMessageEvent extends Event { + + public static final String EVENT_NAME = "topMessage"; + private final String mData; + + public TopMessageEvent(int viewId, String data) { + super(viewId); + mData = data; + } + + @Override + public String getEventName() { + return EVENT_NAME; + } + + @Override + public boolean canCoalesce() { + return false; + } + + @Override + public short getCoalescingKey() { + // All events for a given view can be coalesced. + return 0; + } + + @Override + public void dispatch(RCTEventEmitter rctEventEmitter) { + WritableMap data = Arguments.createMap(); + data.putString("data", mData); + rctEventEmitter.receiveEvent(getViewTag(), EVENT_NAME, data); + } +}
Messages received from React Native: 0
Messages recieved from React Native: 0
(No messages)