From baa3668160e0645014ffe2d1c319107f6fa450ae Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 25 Apr 2016 00:08:42 -0700 Subject: [PATCH] Add support for delete animation in LayoutAnimation on iOS Summary:This adds support for delete view animations in LayoutAnimation for iOS. It supports the same properties as the create animation (alpha, scale). This allows making simple animations when removing a view which is normally hard to do in React since we need to not remove the view node immediately. **Test plan** Tested add/removing views in the UIExample explorer with and without setting a LayoutAnimation. Also tested that the completion callback still works properly. Tested that user interation during the animation is properly disabled. ![layout-anim2](https://cloud.githubusercontent.com/assets/2677334/14595471/86fb1654-050d-11e6-8b38-fe45cc2dcd71.gif) I also plan to work on improving the doc for LayoutAnimation as well as making this PR for android too. Closes https://github.com/facebook/react-native/pull/6779 Differential Revision: D3215525 Pulled By: sahrens fb-gh-sync-id: 526120acd371c8d1af433e8f199cfed336183775 fbshipit-source-id: 526120acd371c8d1af433e8f199cfed336183775 --- Examples/UIExplorer/LayoutAnimationExample.js | 171 ++++++++++++++++++ Examples/UIExplorer/UIExplorerList.android.js | 4 + Examples/UIExplorer/UIExplorerList.ios.js | 4 + Libraries/LayoutAnimation/LayoutAnimation.js | 8 + React/Modules/RCTUIManager.m | 100 ++++++---- 5 files changed, 252 insertions(+), 35 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 00000000000..f2ac7b35087 --- /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 5e8690db250..17e8b99f945 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 95768877d5c..2f03b91d521 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 108c4d5428e..8a8f221ce02 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 51470a2368a..283c1b55ec9 100644 --- a/React/Modules/RCTUIManager.m +++ b/React/Modules/RCTUIManager.m @@ -191,7 +191,6 @@ static UIViewAnimationOptions UIViewAnimationOptionsFromRCTAnimationType(RCTAnim NSMutableArray *_pendingUIBlocks; // Animation - RCTLayoutAnimation *_nextLayoutAnimation; // RCT thread only RCTLayoutAnimation *_layoutAnimation; // Main thread only NSMutableDictionary *_shadowViewRegistry; // RCT thread only @@ -582,11 +581,7 @@ RCT_EXPORT_MODULE() // 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 @@ RCT_EXPORT_MODULE() 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 @@ RCT_EXPORT_MODULE() completion(YES); } } + + _layoutAnimation = nil; }; } @@ -729,9 +730,49 @@ RCT_EXPORT_METHOD(removeSubviewsFromContainerWithID:(nonnull NSNumber *)containe - (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 @@ RCT_EXPORT_METHOD(manageChildren:(nonnull 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 @@ RCT_EXPORT_METHOD(dispatchViewManagerCommand:(nonnull NSNumber *)reactTag [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 @@ RCT_EXPORT_METHOD(dispatchViewManagerCommand:(nonnull NSNumber *)reactTag [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 @@ RCT_EXPORT_METHOD(configureNextLayoutAnimation:(NSDictionary *)config 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); } - if (config[@"delete"] != nil) { - RCTLogError(@"LayoutAnimation only supports create and update right now. 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;