diff --git a/Examples/UIExplorer/UIExplorerList.android.js b/Examples/UIExplorer/UIExplorerList.android.js index e3d05a2b1..327bf457b 100644 --- a/Examples/UIExplorer/UIExplorerList.android.js +++ b/Examples/UIExplorer/UIExplorerList.android.js @@ -34,6 +34,7 @@ var COMPONENTS = [ require('./TouchableExample'), require('./ViewExample'), require('./ViewPagerAndroidExample.android'), + require('./WebViewExample'), ]; var APIS = [ diff --git a/Libraries/Components/WebView/WebView.android.js b/Libraries/Components/WebView/WebView.android.js index d5569dcad..f4bc52045 100644 --- a/Libraries/Components/WebView/WebView.android.js +++ b/Libraries/Components/WebView/WebView.android.js @@ -32,15 +32,14 @@ var WebViewState = keyMirror({ }); /** - * Note that WebView is only supported on iOS for now, - * see https://facebook.github.io/react-native/docs/known-issues.html + * Renders a native WebView. */ var WebView = React.createClass({ propTypes: { ...View.propTypes, - renderError: PropTypes.func, // view to show if there's an error - renderLoading: PropTypes.func, // loading indicator to show + renderError: PropTypes.func, + renderLoading: PropTypes.func, url: PropTypes.string, html: PropTypes.string, automaticallyAdjustContentInsets: PropTypes.bool, @@ -48,6 +47,11 @@ var WebView = React.createClass({ onNavigationStateChange: PropTypes.func, startInLoadingState: PropTypes.bool, // force WebView to show loadingView on first load style: View.propTypes.style, + + /** + * Used on Android only, JS is enabled by default for WebView on iOS + * @platform android + */ javaScriptEnabledAndroid: PropTypes.bool, /** @@ -56,10 +60,11 @@ var WebView = React.createClass({ injectedJavaScript: PropTypes.string, /** - * Sets the user-agent for this WebView. The user-agent can also be set in native through - * WebViewConfig, but this can and will overwrite that config. + * 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. */ diff --git a/Libraries/Components/WebView/WebView.ios.js b/Libraries/Components/WebView/WebView.ios.js index 0e5452c9d..a51ed3fce 100644 --- a/Libraries/Components/WebView/WebView.ios.js +++ b/Libraries/Components/WebView/WebView.ios.js @@ -77,9 +77,6 @@ var defaultRenderError = (errorDomain, errorCode, errorDesc) => ( /** * Renders a native WebView. - * - * Note that WebView is only supported on iOS for now, - * see https://facebook.github.io/react-native/docs/known-issues.html */ var WebView = React.createClass({ statics: { @@ -91,9 +88,21 @@ var WebView = React.createClass({ ...View.propTypes, url: PropTypes.string, html: PropTypes.string, + /** + * Function that returns a view to show if there's an error. + */ renderError: PropTypes.func, // view to show if there's an error - renderLoading: PropTypes.func, // loading indicator to show + /** + * Function that returns a loading indicator. + */ + renderLoading: PropTypes.func, + /** + * @platform ios + */ bounces: PropTypes.bool, + /** + * @platform ios + */ scrollEnabled: PropTypes.bool, automaticallyAdjustContentInsets: PropTypes.bool, contentInset: EdgeInsetsPropType, @@ -102,7 +111,7 @@ var WebView = React.createClass({ style: View.propTypes.style, /** - * Used for android only, JS is enabled by default for WebView on iOS + * Used on Android only, JS is enabled by default for WebView on iOS * @platform android */ javaScriptEnabledAndroid: PropTypes.bool, diff --git a/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java b/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java index aae0e9322..51ca4de44 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java +++ b/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java @@ -42,6 +42,7 @@ import com.facebook.react.views.toolbar.ReactToolbarManager; import com.facebook.react.views.view.ReactViewManager; import com.facebook.react.views.viewpager.ReactViewPagerManager; import com.facebook.react.views.swiperefresh.SwipeRefreshLayoutManager; +import com.facebook.react.views.webview.ReactWebViewManager; import com.facebook.react.modules.clipboard.ClipboardModule; /** @@ -86,6 +87,7 @@ public class MainReactPackage implements ReactPackage { new ReactViewPagerManager(), new ReactTextInlineImageViewManager(), new ReactVirtualTextViewManager(), - new SwipeRefreshLayoutManager()); + new SwipeRefreshLayoutManager(), + new ReactWebViewManager()); } } 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 000000000..05f50dcfb --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/webview/ReactWebViewManager.java @@ -0,0 +1,325 @@ +/** + * 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.util.Map; + +import android.graphics.Bitmap; +import android.os.Build; +import android.os.SystemClock; +import android.text.TextUtils; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import com.facebook.catalyst.views.webview.events.TopLoadingErrorEvent; +import com.facebook.catalyst.views.webview.events.TopLoadingFinishEvent; +import com.facebook.catalyst.views.webview.events.TopLoadingStartEvent; +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.WritableMap; +import com.facebook.react.common.MapBuilder; +import com.facebook.react.common.build.ReactBuildConfig; +import com.facebook.react.uimanager.ReactProp; +import com.facebook.react.uimanager.SimpleViewManager; +import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.uimanager.UIManagerModule; +import com.facebook.react.uimanager.events.EventDispatcher; + +/** + * 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"; + + public static final int COMMAND_GO_BACK = 1; + public static final int COMMAND_GO_FORWARD = 2; + public static final int COMMAND_RELOAD = 3; + + // 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; + + static { + if (ReactBuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + WebView.setWebContentsDebuggingEnabled(true); + } + } + + 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(); + emitFinishEvent(webView); + } + } + + @Override + public void onPageStarted(WebView webView, String url, Bitmap favicon) { + super.onPageStarted(webView, url, favicon); + mLastLoadFailed = false; + + ReactContext reactContext = (ReactContext) ((ReactWebView) webView).getContext(); + EventDispatcher eventDispatcher = + reactContext.getNativeModule(UIManagerModule.class).getEventDispatcher(); + eventDispatcher.dispatchEvent( + new TopLoadingStartEvent( + webView.getId(), + SystemClock.uptimeMillis(), + createWebViewEvent(webView))); + } + + @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 oposite way, so we need to simulate that behavior + emitFinishEvent(webView); + + ReactContext reactContext = (ReactContext) ((ReactWebView) webView).getContext(); + WritableMap eventData = createWebViewEvent(webView); + eventData.putDouble("code", errorCode); + eventData.putString("description", description); + + EventDispatcher eventDispatcher = + reactContext.getNativeModule(UIManagerModule.class).getEventDispatcher(); + eventDispatcher.dispatchEvent( + new TopLoadingErrorEvent(webView.getId(), SystemClock.uptimeMillis(), eventData)); + } + + @Override + public void doUpdateVisitedHistory(WebView webView, String url, boolean isReload) { + super.doUpdateVisitedHistory(webView, url, isReload); + + ReactContext reactContext = (ReactContext) webView.getContext(); + EventDispatcher eventDispatcher = + reactContext.getNativeModule(UIManagerModule.class).getEventDispatcher(); + eventDispatcher.dispatchEvent( + new TopLoadingStartEvent( + webView.getId(), + SystemClock.uptimeMillis(), + createWebViewEvent(webView))); + } + + private void emitFinishEvent(WebView webView) { + ReactContext reactContext = (ReactContext) webView.getContext(); + + EventDispatcher eventDispatcher = + reactContext.getNativeModule(UIManagerModule.class).getEventDispatcher(); + eventDispatcher.dispatchEvent( + new TopLoadingFinishEvent( + webView.getId(), + SystemClock.uptimeMillis(), + createWebViewEvent(webView))); + } + + private WritableMap createWebViewEvent(WebView webView) { + WritableMap event = Arguments.createMap(); + event.putDouble("target", webView.getId()); + event.putString("url", webView.getUrl()); + 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; + + /** + * 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 callInjectedJavaScript() { + if ( + getSettings().getJavaScriptEnabled() && + injectedJS != null && + !TextUtils.isEmpty(injectedJS)) { + loadUrl("javascript:(function() {\n" + injectedJS + ";\n})();"); + } + } + + 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); + reactContext.addLifecycleEventListener(webView); + mWebViewConfig.configWebView(webView); + return webView; + } + + @ReactProp(name = "javaScriptEnabledAndroid") + public void setJavaScriptEnabled(WebView view, boolean enabled) { + view.getSettings().setJavaScriptEnabled(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 = "injectedJavaScript") + public void setInjectedJavaScript(WebView view, @Nullable String injectedJavaScript) { + ((ReactWebView) view).setInjectedJavaScript(injectedJavaScript); + } + + @ReactProp(name = "html") + public void setHtml(WebView view, @Nullable String html) { + if (html != null) { + view.loadData(html, HTML_MIME_TYPE, HTML_ENCODING); + } else { + view.loadUrl(BLANK_URL); + } + } + + @ReactProp(name = "url") + public void setUrl(WebView view, @Nullable String url) { + // TODO(8495359): url and html are coupled as they both call loadUrl, therefore in case when + // property url is removed in favor of property html being added in single transaction we may + // end up in a state when blank url is loaded as it depends onthe oreder of update operations! + if (url != null) { + view.loadUrl(url); + } else { + view.loadUrl(BLANK_URL); + } + } + + @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); + } + + @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; + } + } + + @Override + public void onDropViewInstance(ThemedReactContext reactContext, WebView webView) { + super.onDropViewInstance(reactContext, webView); + reactContext.removeLifecycleEventListener((ReactWebView) webView); + ((ReactWebView) webView).cleanupCallbacksAndDestroy(); + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/webview/WebViewConfig.java b/ReactAndroid/src/main/java/com/facebook/react/views/webview/WebViewConfig.java new file mode 100644 index 000000000..7b2f1241e --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/webview/WebViewConfig.java @@ -0,0 +1,21 @@ +/** + * 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 android.webkit.WebView; + +/** + * Implement this interface in order to config your {@link WebView}. An instance of that + * implementation will have to be given as a constructor argument to {@link ReactWebViewManager}. + */ +public interface WebViewConfig { + + void configWebView(WebView webView); +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/webview/events/TopLoadingErrorEvent.java b/ReactAndroid/src/main/java/com/facebook/react/views/webview/events/TopLoadingErrorEvent.java new file mode 100644 index 000000000..f97b30e90 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/webview/events/TopLoadingErrorEvent.java @@ -0,0 +1,49 @@ +/** + * 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.catalyst.views.webview.events; + +import com.facebook.react.bridge.WritableMap; +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 TopLoadingErrorEvent extends Event { + + public static final String EVENT_NAME = "topLoadingError"; + private WritableMap mEventData; + + public TopLoadingErrorEvent(int viewId, long timestampMs, WritableMap eventData) { + super(viewId, timestampMs); + mEventData = eventData; + } + + @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) { + rctEventEmitter.receiveEvent(getViewTag(), getEventName(), mEventData); + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/webview/events/TopLoadingFinishEvent.java b/ReactAndroid/src/main/java/com/facebook/react/views/webview/events/TopLoadingFinishEvent.java new file mode 100644 index 000000000..b03447043 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/webview/events/TopLoadingFinishEvent.java @@ -0,0 +1,49 @@ +/** + * 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.catalyst.views.webview.events; + +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.uimanager.events.Event; +import com.facebook.react.uimanager.events.RCTEventEmitter; + +/** + * Event emitted when loading is completed. + */ +public class TopLoadingFinishEvent extends Event { + + public static final String EVENT_NAME = "topLoadingFinish"; + private WritableMap mEventData; + + public TopLoadingFinishEvent(int viewId, long timestampMs, WritableMap eventData) { + super(viewId, timestampMs); + mEventData = eventData; + } + + @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) { + rctEventEmitter.receiveEvent(getViewTag(), getEventName(), mEventData); + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/webview/events/TopLoadingStartEvent.java b/ReactAndroid/src/main/java/com/facebook/react/views/webview/events/TopLoadingStartEvent.java new file mode 100644 index 000000000..5154ab807 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/webview/events/TopLoadingStartEvent.java @@ -0,0 +1,49 @@ +/** + * 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.catalyst.views.webview.events; + +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.uimanager.events.Event; +import com.facebook.react.uimanager.events.RCTEventEmitter; + +/** + * Event emitted when loading has started + */ +public class TopLoadingStartEvent extends Event { + + public static final String EVENT_NAME = "topLoadingStart"; + private WritableMap mEventData; + + public TopLoadingStartEvent(int viewId, long timestampMs, WritableMap eventData) { + super(viewId, timestampMs); + mEventData = eventData; + } + + @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) { + rctEventEmitter.receiveEvent(getViewTag(), getEventName(), mEventData); + } +}