diff --git a/Examples/UIExplorer/LayoutAnimationExample.js b/Examples/UIExplorer/LayoutAnimationExample.js new file mode 100644 index 000000000000..f2ac7b350871 --- /dev/null +++ b/Examples/UIExplorer/LayoutAnimationExample.js @@ -0,0 +1,171 @@ +/** + * 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'; + +const React = require('react'); +const ReactNative = require('react-native'); +const { + LayoutAnimation, + StyleSheet, + Text, + View, + TouchableOpacity, +} = ReactNative; + +const AddRemoveExample = React.createClass({ + + getInitialState() { + return { + views: [], + }; + }, + + componentWillUpdate() { + LayoutAnimation.easeInEaseOut(); + }, + + _onPressAddView() { + this.setState((state) => ({views: [...state.views, {}]})); + }, + + _onPressRemoveView() { + this.setState((state) => ({views: state.views.slice(0, -1)})); + }, + + render() { + const views = this.state.views.map((view, i) => + + {i} + + ); + return ( + + + + Add view + + + + + Remove view + + + + {views} + + + ); + }, +}); + +const GreenSquare = () => + + Green square + ; + +const BlueSquare = () => + + Blue square + ; + +const CrossFadeExample = React.createClass({ + + getInitialState() { + return { + toggled: false, + }; + }, + + _onPressToggle() { + LayoutAnimation.easeInEaseOut(); + this.setState((state) => ({toggled: !state.toggled})); + }, + + render() { + return ( + + + + Toggle + + + + { + this.state.toggled ? + : + + } + + + ); + }, +}); + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + button: { + borderRadius: 5, + backgroundColor: '#eeeeee', + padding: 10, + marginBottom: 10, + }, + buttonText: { + fontSize: 16, + }, + viewContainer: { + flex: 1, + flexDirection: 'row', + flexWrap: 'wrap', + }, + view: { + height: 54, + width: 54, + backgroundColor: 'red', + margin: 8, + alignItems: 'center', + justifyContent: 'center', + }, + greenSquare: { + width: 150, + height: 150, + backgroundColor: 'green', + alignItems: 'center', + justifyContent: 'center', + }, + blueSquare: { + width: 150, + height: 150, + backgroundColor: 'blue', + alignItems: 'center', + justifyContent: 'center', + }, +}); + +exports.title = 'Layout Animation'; +exports.description = 'Layout animation'; +exports.examples = [{ + title: 'Add and remove views', + render(): ReactElement { + return ; + }, +}, { + title: 'Cross fade views', + render(): ReactElement { + return ; + }, +}]; diff --git a/Examples/UIExplorer/UIExplorerList.android.js b/Examples/UIExplorer/UIExplorerList.android.js index 5e8690db2508..17e8b99f9453 100644 --- a/Examples/UIExplorer/UIExplorerList.android.js +++ b/Examples/UIExplorer/UIExplorerList.android.js @@ -138,6 +138,10 @@ const APIExamples = [ key: 'LinkingExample', module: require('./LinkingExample'), }, + { + key: 'LayoutAnimationExample', + module: require('./LayoutAnimationExample'), + }, { key: 'LayoutExample', module: require('./LayoutExample'), diff --git a/Examples/UIExplorer/UIExplorerList.ios.js b/Examples/UIExplorer/UIExplorerList.ios.js index 95768877d5c5..2f03b91d5211 100644 --- a/Examples/UIExplorer/UIExplorerList.ios.js +++ b/Examples/UIExplorer/UIExplorerList.ios.js @@ -192,6 +192,10 @@ var APIExamples: Array = [ key: 'ImageEditingExample', module: require('./ImageEditingExample'), }, + { + key: 'LayoutAnimationExample', + module: require('./LayoutAnimationExample'), + }, { key: 'LayoutExample', module: require('./LayoutExample'), diff --git a/Libraries/LayoutAnimation/LayoutAnimation.js b/Libraries/LayoutAnimation/LayoutAnimation.js index 108c4d5428e5..8a8f221ce026 100644 --- a/Libraries/LayoutAnimation/LayoutAnimation.js +++ b/Libraries/LayoutAnimation/LayoutAnimation.js @@ -86,6 +86,10 @@ function create(duration: number, type, creationProp): Config { update: { type, }, + delete: { + type, + property: creationProp, + }, }; } @@ -106,6 +110,10 @@ var Presets = { type: Types.spring, springDamping: 0.4, }, + delete: { + type: Types.linear, + property: Properties.opacity, + }, }, }; diff --git a/React/Modules/RCTUIManager.m b/React/Modules/RCTUIManager.m index 51470a2368a8..283c1b55ec95 100644 --- a/React/Modules/RCTUIManager.m +++ b/React/Modules/RCTUIManager.m @@ -191,7 +191,6 @@ @implementation RCTUIManager NSMutableArray *_pendingUIBlocks; // Animation - RCTLayoutAnimation *_nextLayoutAnimation; // RCT thread only RCTLayoutAnimation *_layoutAnimation; // Main thread only NSMutableDictionary *_shadowViewRegistry; // RCT thread only @@ -582,11 +581,7 @@ - (RCTViewManagerUIBlock)uiBlockWithLayoutUpdateForRootView:(RCTRootShadowView * // Perform layout (possibly animated) return ^(__unused RCTUIManager *uiManager, NSDictionary *viewRegistry) { - RCTResponseSenderBlock callback = self->_layoutAnimation.callback; - - // It's unsafe to call this callback more than once, so we nil it out here - // to make sure that doesn't happen. - _layoutAnimation.callback = nil; + RCTLayoutAnimation *layoutAnimation = _layoutAnimation; __block NSUInteger completionsCalled = 0; for (NSUInteger ii = 0; ii < frames.count; ii++) { @@ -595,14 +590,18 @@ - (RCTViewManagerUIBlock)uiBlockWithLayoutUpdateForRootView:(RCTRootShadowView * CGRect frame = [frames[ii] CGRectValue]; BOOL isNew = [areNew[ii] boolValue]; - RCTAnimation *updateAnimation = isNew ? nil : _layoutAnimation.updateAnimation; + RCTAnimation *updateAnimation = isNew ? nil : layoutAnimation.updateAnimation; BOOL shouldAnimateCreation = isNew && ![parentsAreNew[ii] boolValue]; - RCTAnimation *createAnimation = shouldAnimateCreation ? _layoutAnimation.createAnimation : nil; + RCTAnimation *createAnimation = shouldAnimateCreation ? layoutAnimation.createAnimation : nil; void (^completion)(BOOL) = ^(BOOL finished) { completionsCalled++; - if (callback && completionsCalled == frames.count) { - callback(@[@(finished)]); + if (layoutAnimation.callback && completionsCalled == frames.count) { + layoutAnimation.callback(@[@(finished)]); + + // It's unsafe to call this callback more than once, so we nil it out here + // to make sure that doesn't happen. + layoutAnimation.callback = nil; } }; @@ -656,6 +655,8 @@ - (RCTViewManagerUIBlock)uiBlockWithLayoutUpdateForRootView:(RCTRootShadowView * completion(YES); } } + + _layoutAnimation = nil; }; } @@ -729,9 +730,49 @@ - (void)_amendPendingUIBlocksWithStylePropagationUpdateForShadowView:(RCTShadowV - (void)_removeChildren:(NSArray> *)children fromContainer:(id)container + permanent: (BOOL)permanent { + RCTLayoutAnimation *layoutAnimation = _layoutAnimation; + RCTAnimation *deleteAnimation = layoutAnimation.deleteAnimation; + + __block NSUInteger completionsCalled = 0; + for (id removedChild in children) { - [container removeReactSubview:removedChild]; + + void (^completion)(BOOL) = ^(BOOL finished) { + completionsCalled++; + + [container removeReactSubview:removedChild]; + + if (layoutAnimation.callback && completionsCalled == children.count) { + layoutAnimation.callback(@[@(finished)]); + + // It's unsafe to call this callback more than once, so we nil it out here + // to make sure that doesn't happen. + layoutAnimation.callback = nil; + } + }; + + if (permanent && deleteAnimation && [removedChild isKindOfClass: [UIView class]]) { + UIView *view = (UIView *)removedChild; + + // Disable user interaction while the view is animating since JS won't receive + // the view events anyway. + view.userInteractionEnabled = NO; + + [deleteAnimation performAnimations:^{ + if ([deleteAnimation.property isEqual:@"scaleXY"]) { + view.layer.transform = CATransform3DMakeScale(0, 0, 0); + } else if ([deleteAnimation.property isEqual:@"opacity"]) { + view.layer.opacity = 0.0; + } else { + RCTLogError(@"Unsupported layout animation createConfig property %@", + deleteAnimation.property); + } + } withCompletionBlock:completion]; + } else { + [container removeReactSubview:removedChild]; + } } } @@ -848,8 +889,8 @@ - (void)_manageChildren:(NSNumber *)containerReactTag [self _childrenToRemoveFromContainer:container atIndices:removeAtIndices]; NSArray> *temporarilyRemovedChildren = [self _childrenToRemoveFromContainer:container atIndices:moveFromIndices]; - [self _removeChildren:permanentlyRemovedChildren fromContainer:container]; - [self _removeChildren:temporarilyRemovedChildren fromContainer:container]; + [self _removeChildren:permanentlyRemovedChildren fromContainer:container permanent:true]; + [self _removeChildren:temporarilyRemovedChildren fromContainer:container permanent:false]; [self _purgeChildren:permanentlyRemovedChildren fromRegistry:registry]; @@ -1015,14 +1056,6 @@ - (void)_layoutAndMount [self addUIBlock:uiBlock]; } - // Set up next layout animation - if (_nextLayoutAnimation) { - RCTLayoutAnimation *layoutAnimation = _nextLayoutAnimation; - [self addUIBlock:^(RCTUIManager *uiManager, __unused NSDictionary *viewRegistry) { - uiManager->_layoutAnimation = layoutAnimation; - }]; - } - // Perform layout for (NSNumber *reactTag in _rootViewTags) { RCTRootShadowView *rootView = (RCTRootShadowView *)_shadowViewRegistry[reactTag]; @@ -1030,14 +1063,6 @@ - (void)_layoutAndMount [self _amendPendingUIBlocksWithStylePropagationUpdateForShadowView:rootView]; } - // Clear layout animations - if (_nextLayoutAnimation) { - [self addUIBlock:^(RCTUIManager *uiManager, __unused NSDictionary *viewRegistry) { - uiManager->_layoutAnimation = nil; - }]; - _nextLayoutAnimation = nil; - } - [self addUIBlock:^(RCTUIManager *uiManager, __unused NSDictionary *viewRegistry) { /** * TODO(tadeu): Remove it once and for all @@ -1429,14 +1454,19 @@ static void RCTMeasureLayout(RCTShadowView *view, withCallback:(RCTResponseSenderBlock)callback errorCallback:(__unused RCTResponseSenderBlock)errorCallback) { - if (_nextLayoutAnimation && ![config isEqualToDictionary:_nextLayoutAnimation.config]) { - RCTLogWarn(@"Warning: Overriding previous layout animation with new one before the first began:\n%@ -> %@.", _nextLayoutAnimation.config, config); - } - if (config[@"delete"] != nil) { - RCTLogError(@"LayoutAnimation only supports create and update right now. Config: %@", config); + RCTLayoutAnimation *currentAnimation = _layoutAnimation; + + if (currentAnimation && ![config isEqualToDictionary:currentAnimation.config]) { + RCTLogWarn(@"Warning: Overriding previous layout animation with new one before the first began:\n%@ -> %@.", currentAnimation.config, config); } - _nextLayoutAnimation = [[RCTLayoutAnimation alloc] initWithDictionary:config + + RCTLayoutAnimation *nextLayoutAnimation = [[RCTLayoutAnimation alloc] initWithDictionary:config callback:callback]; + + // Set up next layout animation + [self addUIBlock:^(RCTUIManager *uiManager, __unused NSDictionary *viewRegistry) { + uiManager->_layoutAnimation = nextLayoutAnimation; + }]; } static UIView *_jsResponder;