From e4c53c28aea7e067e48f5c8c0100c7cafc031b06 Mon Sep 17 00:00:00 2001 From: Nick Lockwood Date: Thu, 14 Jan 2016 14:03:31 -0800 Subject: [PATCH] Improved shadow performance Summary: public React Native currently exposes the iOS layer shadow properties more-or-less directly, however there are a number of problems with this: 1) Performance when using these properties is poor by default. That's because iOS calculates the shadow by getting the exact pixel mask of the view, including any tranlucent content, and all of its subviews, which is very CPU and GPU-intensive. 2) The iOS shadow properties do not match the syntax or semantics of the CSS box-shadow standard, and are unlikely to be possible to implement on Android. 3) We don't expose the `layer.shadowPath` property, which is crucial to getting good performance out of layer shadows. This diff solves problem number 1) by implementing a default `shadowPath` that matches the view border for views with an opaque background. This improves the performance of shadows by optimizing for the common usage case. I've also reinstated background color propagation for views which have shadow props - this should help ensure that this best-case scenario occurs more often. For views with an explicit transparent background, the shadow will continue to work as it did before ( `shadowPath` will be left unset, and the shadow will be derived exactly from the pixels of the view and its subviews). This is the worst-case path for performance, however, so you should avoid it unless absolutely necessary. **Support for this may be disabled by default in future, or dropped altogether.** For translucent images, it is suggested that you bake the shadow into the image itself, or use another mechanism to pre-generate the shadow. For text shadows, you should use the textShadow properties, which work cross-platform and have much better performance. Problem number 2) will be solved in a future diff, possibly by renaming the iOS shadowXXX properties to boxShadowXXX, and changing the syntax and semantics to match the CSS standards. Problem number 3) is now mostly moot, since we generate the shadowPath automatically. In future, we may provide an iOS-specific prop to set the path explicitly if there's a demand for more precise control of the shadow. Reviewed By: weicool Differential Revision: D2827581 fb-gh-sync-id: 853aa018e1d61d5f88304c6fc1b78f9d7e739804 --- Examples/UIExplorer/BoxShadowExample.js | 85 +++++++++++++++++++ Examples/UIExplorer/UIExplorerList.ios.js | 1 + .../Components/View/ShadowPropTypesIOS.js | 42 +++++++++ .../Components/View/ViewStylePropTypes.js | 8 +- Libraries/Image/ImageStylePropTypes.js | 2 + Libraries/Text/RCTShadowText.m | 1 - Libraries/Text/RCTTextManager.m | 1 - React/Views/RCTView.m | 59 +++++++++++-- 8 files changed, 183 insertions(+), 16 deletions(-) create mode 100644 Examples/UIExplorer/BoxShadowExample.js create mode 100644 Libraries/Components/View/ShadowPropTypesIOS.js diff --git a/Examples/UIExplorer/BoxShadowExample.js b/Examples/UIExplorer/BoxShadowExample.js new file mode 100644 index 00000000000000..12568c1b67bc3e --- /dev/null +++ b/Examples/UIExplorer/BoxShadowExample.js @@ -0,0 +1,85 @@ +/** + * 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. + */ +'use strict'; + +var React = require('react-native'); +var { + Image, + StyleSheet, + View +} = React; + +var styles = StyleSheet.create({ + box: { + width: 100, + height: 100, + borderWidth: 2, + }, + shadow1: { + shadowOpacity: 0.5, + shadowRadius: 3, + shadowOffset: {width: 2, height: 2}, + }, + shadow2: { + shadowOpacity: 1.0, + shadowColor: 'red', + shadowRadius: 0, + shadowOffset: {width: 3, height: 3}, + }, +}); + +exports.title = 'Box Shadow'; +exports.description = 'Demonstrates some of the shadow styles available to Views.'; +exports.examples = [ + { + title: 'Basic shadow', + description: 'shadowOpacity: 0.5, shadowOffset: {2, 2}', + render() { + return ; + } + }, + { + title: 'Colored shadow', + description: 'shadowColor: \'red\', shadowRadius: 0', + render() { + return ; + } + }, + { + title: 'Shaped shadow', + description: 'borderRadius: 50', + render() { + return ; + } + }, + { + title: 'Image shadow', + description: 'Image shadows are derived exactly from the pixels.', + render() { + return ; + } + }, + { + title: 'Child shadow', + description: 'For views without an opaque background color, shadow will be derived from the subviews.', + render() { + return + + ; + } + }, +]; diff --git a/Examples/UIExplorer/UIExplorerList.ios.js b/Examples/UIExplorer/UIExplorerList.ios.js index 0c1e9e2e7153d2..92f0f0bad1b850 100644 --- a/Examples/UIExplorer/UIExplorerList.ios.js +++ b/Examples/UIExplorer/UIExplorerList.ios.js @@ -66,6 +66,7 @@ var APIS = [ require('./AppStateIOSExample'), require('./AsyncStorageExample'), require('./BorderExample'), + require('./BoxShadowExample'), require('./CameraRollExample'), require('./ClipboardExample'), require('./GeolocationExample'), diff --git a/Libraries/Components/View/ShadowPropTypesIOS.js b/Libraries/Components/View/ShadowPropTypesIOS.js new file mode 100644 index 00000000000000..c770ca9ed26755 --- /dev/null +++ b/Libraries/Components/View/ShadowPropTypesIOS.js @@ -0,0 +1,42 @@ +/** + * 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 ShadowPropTypesIOS + * @flow + */ +'use strict'; + +var ColorPropType = require('ColorPropType'); +var ReactPropTypes = require('ReactPropTypes'); + +var ShadowPropTypesIOS = { + /** + * Sets the drop shadow color + * @platform ios + */ + shadowColor: ColorPropType, + /** + * Sets the drop shadow offset + * @platform ios + */ + shadowOffset: ReactPropTypes.shape( + {width: ReactPropTypes.number, height: ReactPropTypes.number} + ), + /** + * Sets the drop shadow opacity (multiplied by the color's alpha component) + * @platform ios + */ + shadowOpacity: ReactPropTypes.number, + /** + * Sets the drop shadow blur radius + * @platform ios + */ + shadowRadius: ReactPropTypes.number, +}; + +module.exports = ShadowPropTypesIOS; diff --git a/Libraries/Components/View/ViewStylePropTypes.js b/Libraries/Components/View/ViewStylePropTypes.js index cb193be1e9cf8e..619df34c8d9641 100644 --- a/Libraries/Components/View/ViewStylePropTypes.js +++ b/Libraries/Components/View/ViewStylePropTypes.js @@ -14,6 +14,7 @@ var LayoutPropTypes = require('LayoutPropTypes'); var ReactPropTypes = require('ReactPropTypes'); var ColorPropType = require('ColorPropType'); +var ShadowPropTypesIOS = require('ShadowPropTypesIOS'); var TransformPropTypes = require('TransformPropTypes'); /** @@ -21,6 +22,7 @@ var TransformPropTypes = require('TransformPropTypes'); */ var ViewStylePropTypes = { ...LayoutPropTypes, + ...ShadowPropTypesIOS, ...TransformPropTypes, backfaceVisibility: ReactPropTypes.oneOf(['visible', 'hidden']), backgroundColor: ColorPropType, @@ -42,12 +44,6 @@ var ViewStylePropTypes = { borderLeftWidth: ReactPropTypes.number, opacity: ReactPropTypes.number, overflow: ReactPropTypes.oneOf(['visible', 'hidden']), - shadowColor: ColorPropType, - shadowOffset: ReactPropTypes.shape( - {width: ReactPropTypes.number, height: ReactPropTypes.number} - ), - shadowOpacity: ReactPropTypes.number, - shadowRadius: ReactPropTypes.number, /** * (Android-only) Sets the elevation of a view, using Android's underlying * [elevation API](https://developer.android.com/training/material/shadows-clipping.html#Elevation). diff --git a/Libraries/Image/ImageStylePropTypes.js b/Libraries/Image/ImageStylePropTypes.js index ec806b1b85cb10..5e99a54b08c0c1 100644 --- a/Libraries/Image/ImageStylePropTypes.js +++ b/Libraries/Image/ImageStylePropTypes.js @@ -15,10 +15,12 @@ var ImageResizeMode = require('ImageResizeMode'); var LayoutPropTypes = require('LayoutPropTypes'); var ReactPropTypes = require('ReactPropTypes'); var ColorPropType = require('ColorPropType'); +var ShadowPropTypesIOS = require('ShadowPropTypesIOS'); var TransformPropTypes = require('TransformPropTypes'); var ImageStylePropTypes = { ...LayoutPropTypes, + ...ShadowPropTypesIOS, ...TransformPropTypes, resizeMode: ReactPropTypes.oneOf(Object.keys(ImageResizeMode)), backfaceVisibility: ReactPropTypes.oneOf(['visible', 'hidden']), diff --git a/Libraries/Text/RCTShadowText.m b/Libraries/Text/RCTShadowText.m index 4d4380d428d1ae..eebbc9c32b98f8 100644 --- a/Libraries/Text/RCTShadowText.m +++ b/Libraries/Text/RCTShadowText.m @@ -373,7 +373,6 @@ - (void)set##setProp:(type)value; \ RCT_TEXT_PROPERTY(LetterSpacing, _letterSpacing, CGFloat) RCT_TEXT_PROPERTY(LineHeight, _lineHeight, CGFloat) RCT_TEXT_PROPERTY(NumberOfLines, _numberOfLines, NSUInteger) -RCT_TEXT_PROPERTY(ShadowOffset, _shadowOffset, CGSize) RCT_TEXT_PROPERTY(TextAlign, _textAlign, NSTextAlignment) RCT_TEXT_PROPERTY(TextDecorationColor, _textDecorationColor, UIColor *); RCT_TEXT_PROPERTY(TextDecorationLine, _textDecorationLine, RCTTextDecorationLineType); diff --git a/Libraries/Text/RCTTextManager.m b/Libraries/Text/RCTTextManager.m index 7a28ab92875f41..2a7e4d98b47275 100644 --- a/Libraries/Text/RCTTextManager.m +++ b/Libraries/Text/RCTTextManager.m @@ -51,7 +51,6 @@ - (RCTShadowView *)shadowView RCT_EXPORT_SHADOW_PROPERTY(letterSpacing, CGFloat) RCT_EXPORT_SHADOW_PROPERTY(lineHeight, CGFloat) RCT_EXPORT_SHADOW_PROPERTY(numberOfLines, NSUInteger) -RCT_EXPORT_SHADOW_PROPERTY(shadowOffset, CGSize) RCT_EXPORT_SHADOW_PROPERTY(textAlign, NSTextAlignment) RCT_EXPORT_SHADOW_PROPERTY(textDecorationStyle, NSUnderlineStyle) RCT_EXPORT_SHADOW_PROPERTY(textDecorationColor, UIColor) diff --git a/React/Views/RCTView.m b/React/Views/RCTView.m index 7c773691eeb126..010c62c492933a 100644 --- a/React/Views/RCTView.m +++ b/React/Views/RCTView.m @@ -515,8 +515,11 @@ - (void)reactSetFrame:(CGRect)frame // If frame is zero, or below the threshold where the border radii can // be rendered as a stretchable image, we'll need to re-render. // TODO: detect up-front if re-rendering is necessary + CGSize oldSize = self.bounds.size; [super reactSetFrame:frame]; - [self.layer setNeedsDisplay]; + if (!CGSizeEqualToSize(self.bounds.size, oldSize)) { + [self.layer setNeedsDisplay]; + } } - (void)displayLayer:(CALayer *)layer @@ -525,6 +528,8 @@ - (void)displayLayer:(CALayer *)layer return; } + RCTUpdateShadowPathForView(self); + const RCTCornerRadii cornerRadii = [self cornerRadii]; const UIEdgeInsets borderInsets = [self bordersAsInsets]; const RCTBorderColors borderColors = [self borderColors]; @@ -608,6 +613,44 @@ - (void)displayLayer:(CALayer *)layer [self updateClippingForLayer:layer]; } +static BOOL RCTLayerHasShadow(CALayer *layer) +{ + return layer.shadowOpacity * CGColorGetAlpha(layer.shadowColor) > 0; +} + +- (void)reactSetInheritedBackgroundColor:(UIColor *)inheritedBackgroundColor +{ + // Inherit background color if a shadow has been set, as an optimization + if (RCTLayerHasShadow(self.layer)) { + self.backgroundColor = inheritedBackgroundColor; + } +} + +static void RCTUpdateShadowPathForView(RCTView *view) +{ + if (RCTLayerHasShadow(view.layer)) { + if (CGColorGetAlpha(view.backgroundColor.CGColor) > 0.999) { + + // If view has a solid background color, calculate shadow path from border + const RCTCornerRadii cornerRadii = [view cornerRadii]; + const RCTCornerInsets cornerInsets = RCTGetCornerInsets(cornerRadii, UIEdgeInsetsZero); + CGPathRef shadowPath = RCTPathCreateWithRoundedRect(view.bounds, cornerInsets, NULL); + view.layer.shadowPath = shadowPath; + CGPathRelease(shadowPath); + + } else { + + // Can't accurately calculate box shadow, so fall back to pixel-based shadow + view.layer.shadowPath = nil; + + RCTLogWarn(@"View #%@ of type %@ has a shadow set but cannot calculate " + "shadow efficiently. Consider setting a background color to " + "fix this, or apply the shadow to a more specific component.", + view.reactTag, [view class]); + } + } +} + - (void)updateClippingForLayer:(CALayer *)layer { CALayer *mask = nil; @@ -691,14 +734,14 @@ - (void)setBorder##side##Radius:(CGFloat)radius \ #pragma mark - Border Style -#define setBorderStyle(side) \ +#define setBorderStyle(side) \ - (void)setBorder##side##Style:(RCTBorderStyle)style \ - { \ - if (_border##side##Style == style) { \ - return; \ - } \ - _border##side##Style = style; \ - [self.layer setNeedsDisplay]; \ + { \ + if (_border##side##Style == style) { \ + return; \ + } \ + _border##side##Style = style; \ + [self.layer setNeedsDisplay]; \ } setBorderStyle()