From 6c37d2f0bc9a992c8456d277f9f5033af30f62c3 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Sat, 2 Apr 2016 17:13:40 -0400 Subject: [PATCH 1/5] Support delete animation for LayoutAnimation on iOS --- Examples/UIExplorer/LayoutAnimationExample.js | 107 ++++++++++++++++++ Examples/UIExplorer/UIExplorerList.android.js | 4 + Examples/UIExplorer/UIExplorerList.ios.js | 4 + Libraries/LayoutAnimation/LayoutAnimation.js | 10 ++ React/Modules/RCTUIManager.m | 84 +++++++++----- 5 files changed, 178 insertions(+), 31 deletions(-) create mode 100644 Examples/UIExplorer/LayoutAnimationExample.js diff --git a/Examples/UIExplorer/LayoutAnimationExample.js b/Examples/UIExplorer/LayoutAnimationExample.js new file mode 100644 index 000000000000..95a2c9437aad --- /dev/null +++ b/Examples/UIExplorer/LayoutAnimationExample.js @@ -0,0 +1,107 @@ +/** + * 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-native'); +const { + LayoutAnimation, + StyleSheet, + Text, + View, + TouchableOpacity, +} = React; + +const AddRemoveExample = React.createClass({ + + getInitialState() { + return { + views: [], + }; + }, + + _onPressAddView() { + LayoutAnimation.easeInEaseOut(); + this.setState({views: [...this.state.views, {}]}); + }, + + _onPressRemoveView() { + LayoutAnimation.easeInEaseOut(); + this.setState({views: this.state.views.slice(0, -1)}); + }, + + render() { + const views = this.state.views.map((view, i) => + + {i} + + ); + return ( + + + + Add view + + + + + Remove view + + + + {views} + + + ); + } +}); + +var 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', + }, +}); + +exports.title = 'Layout Animation'; +exports.description = 'Layout animation'; +exports.examples = [ +{ + title: 'Add and remove 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..524b9c0c6fb9 100644 --- a/Libraries/LayoutAnimation/LayoutAnimation.js +++ b/Libraries/LayoutAnimation/LayoutAnimation.js @@ -74,6 +74,12 @@ function configureNext(config: Config, onAnimationDidEnd?: Function) { UIManager.configureNextLayoutAnimation( config, onAnimationDidEnd || function() {}, function() { /* unused */ } ); + // Clear the layout animation configuration after the current frame. + requestAnimationFrame(() => { + UIManager.configureNextLayoutAnimation( + null, function() { /* unused */ }, function() { /* unused */ } + ); + }); } function create(duration: number, type, creationProp): Config { @@ -86,6 +92,10 @@ function create(duration: number, type, creationProp): Config { update: { type, }, + delete: { + type, + property: creationProp, + }, }; } diff --git a/React/Modules/RCTUIManager.m b/React/Modules/RCTUIManager.m index 51470a2368a8..b56f587587a5 100644 --- a/React/Modules/RCTUIManager.m +++ b/React/Modules/RCTUIManager.m @@ -582,11 +582,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 +591,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; } }; @@ -729,9 +729,44 @@ - (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; + [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 +883,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 +1050,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 +1057,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 @@ -1432,11 +1451,14 @@ static void RCTMeasureLayout(RCTShadowView *view, 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); - } + _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; From 35533a89932191d6b2a18a553440b89b6a0c63eb Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 18 Apr 2016 01:52:34 -0400 Subject: [PATCH 2/5] Clear the layout animation properly at the end of the layout --- Libraries/LayoutAnimation/LayoutAnimation.js | 10 +++---- React/Modules/RCTUIManager.m | 29 +++++++++++--------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/Libraries/LayoutAnimation/LayoutAnimation.js b/Libraries/LayoutAnimation/LayoutAnimation.js index 524b9c0c6fb9..8a8f221ce026 100644 --- a/Libraries/LayoutAnimation/LayoutAnimation.js +++ b/Libraries/LayoutAnimation/LayoutAnimation.js @@ -74,12 +74,6 @@ function configureNext(config: Config, onAnimationDidEnd?: Function) { UIManager.configureNextLayoutAnimation( config, onAnimationDidEnd || function() {}, function() { /* unused */ } ); - // Clear the layout animation configuration after the current frame. - requestAnimationFrame(() => { - UIManager.configureNextLayoutAnimation( - null, function() { /* unused */ }, function() { /* unused */ } - ); - }); } function create(duration: number, type, creationProp): Config { @@ -116,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 b56f587587a5..3246c066aad7 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 @@ -599,7 +598,7 @@ - (RCTViewManagerUIBlock)uiBlockWithLayoutUpdateForRootView:(RCTRootShadowView * completionsCalled++; 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; }; } @@ -733,25 +734,25 @@ - (void)_removeChildren:(NSArray> *)children { RCTLayoutAnimation *layoutAnimation = _layoutAnimation; RCTAnimation *deleteAnimation = layoutAnimation.deleteAnimation; - + __block NSUInteger completionsCalled = 0; - + for (id removedChild in children) { - + 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; [deleteAnimation performAnimations:^{ @@ -1448,16 +1449,18 @@ 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); + 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; + uiManager->_layoutAnimation = nextLayoutAnimation; }]; } From 99df8bd50537c070f21521b80ec590da9b82a5c8 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 18 Apr 2016 02:21:50 -0400 Subject: [PATCH 3/5] Add crossfade example --- Examples/UIExplorer/LayoutAnimationExample.js | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/Examples/UIExplorer/LayoutAnimationExample.js b/Examples/UIExplorer/LayoutAnimationExample.js index 95a2c9437aad..65b88e74d1a7 100644 --- a/Examples/UIExplorer/LayoutAnimationExample.js +++ b/Examples/UIExplorer/LayoutAnimationExample.js @@ -68,6 +68,49 @@ const AddRemoveExample = React.createClass({ } }); +const GreenSquare = () => + + Green square + ; + +const BlueSquare = () => + + Blue square + ; + +const CrossFadeExample = React.createClass({ + + getInitialState() { + return { + toggled: false, + }; + }, + + _onPressToggle() { + LayoutAnimation.easeInEaseOut(); + this.setState({toggled: !this.state.toggled}); + }, + + render() { + return ( + + + + Toggle + + + + { + this.state.toggled ? + : + + } + + + ); + } +}); + var styles = StyleSheet.create({ container: { flex: 1, @@ -94,6 +137,20 @@ var styles = StyleSheet.create({ 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'; @@ -104,4 +161,9 @@ exports.examples = [ render(): ReactElement { return ; }, +}, { + title: 'Cross fade views', + render(): ReactElement { + return ; + } }]; From 5f41e50ecdd36a5517a7c3d679bc48170fb30fcb Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Fri, 22 Apr 2016 14:11:39 -0400 Subject: [PATCH 4/5] Fix nits and add userInteractionEnabled = NO during delete animation --- Examples/UIExplorer/LayoutAnimationExample.js | 23 ++++++++++--------- React/Modules/RCTUIManager.m | 9 ++++++-- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/Examples/UIExplorer/LayoutAnimationExample.js b/Examples/UIExplorer/LayoutAnimationExample.js index 65b88e74d1a7..4cf9d25ea9e9 100644 --- a/Examples/UIExplorer/LayoutAnimationExample.js +++ b/Examples/UIExplorer/LayoutAnimationExample.js @@ -32,14 +32,16 @@ const AddRemoveExample = React.createClass({ }; }, - _onPressAddView() { + componentWillUpdate() { LayoutAnimation.easeInEaseOut(); - this.setState({views: [...this.state.views, {}]}); + }, + + _onPressAddView() { + this.setState((state) => ({views: [...state.views, {}]})); }, _onPressRemoveView() { - LayoutAnimation.easeInEaseOut(); - this.setState({views: this.state.views.slice(0, -1)}); + this.setState((state) => ({views: state.views.slice(0, -1)})); }, render() { @@ -65,7 +67,7 @@ const AddRemoveExample = React.createClass({ ); - } + }, }); const GreenSquare = () => @@ -88,7 +90,7 @@ const CrossFadeExample = React.createClass({ _onPressToggle() { LayoutAnimation.easeInEaseOut(); - this.setState({toggled: !this.state.toggled}); + this.setState((state) => ({toggled: !state.toggled})); }, render() { @@ -108,10 +110,10 @@ const CrossFadeExample = React.createClass({ ); - } + }, }); -var styles = StyleSheet.create({ +const styles = StyleSheet.create({ container: { flex: 1, }, @@ -155,8 +157,7 @@ var styles = StyleSheet.create({ exports.title = 'Layout Animation'; exports.description = 'Layout animation'; -exports.examples = [ -{ +exports.examples = [{ title: 'Add and remove views', render(): ReactElement { return ; @@ -165,5 +166,5 @@ exports.examples = [ title: 'Cross fade views', render(): ReactElement { return ; - } + }, }]; diff --git a/React/Modules/RCTUIManager.m b/React/Modules/RCTUIManager.m index 3246c066aad7..283c1b55ec95 100644 --- a/React/Modules/RCTUIManager.m +++ b/React/Modules/RCTUIManager.m @@ -755,6 +755,11 @@ - (void)_removeChildren:(NSArray> *)children 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); @@ -884,8 +889,8 @@ - (void)_manageChildren:(NSNumber *)containerReactTag [self _childrenToRemoveFromContainer:container atIndices:removeAtIndices]; NSArray> *temporarilyRemovedChildren = [self _childrenToRemoveFromContainer:container atIndices:moveFromIndices]; - [self _removeChildren:permanentlyRemovedChildren fromContainer:container permanent: true]; - [self _removeChildren:temporarilyRemovedChildren fromContainer:container permanent: false]; + [self _removeChildren:permanentlyRemovedChildren fromContainer:container permanent:true]; + [self _removeChildren:temporarilyRemovedChildren fromContainer:container permanent:false]; [self _purgeChildren:permanentlyRemovedChildren fromRegistry:registry]; From 7b07bcb8fdb4cb451c96149ec48acba3adbc906c Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Sat, 23 Apr 2016 02:24:45 -0400 Subject: [PATCH 5/5] Fix React import --- Examples/UIExplorer/LayoutAnimationExample.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Examples/UIExplorer/LayoutAnimationExample.js b/Examples/UIExplorer/LayoutAnimationExample.js index 4cf9d25ea9e9..f2ac7b350871 100644 --- a/Examples/UIExplorer/LayoutAnimationExample.js +++ b/Examples/UIExplorer/LayoutAnimationExample.js @@ -15,14 +15,15 @@ */ 'use strict'; -const React = require('react-native'); +const React = require('react'); +const ReactNative = require('react-native'); const { LayoutAnimation, StyleSheet, Text, View, TouchableOpacity, -} = React; +} = ReactNative; const AddRemoveExample = React.createClass({