Browse Source

3D Touch Preview API (#2186)

Add the preview and pop feature

To use the feature you simply call `navigator.push()` but with additional parameter `previewViewID` which is a React Node.

```js
<TouchableOpacity
  onPressIn={() => this.props.navigator.push({
    screen: 'screenid',
    previewView: this.viewRef,
    previewActions: [{
      title: 'Foo',
      style: 'selected', // none, selected, destructive
      actions: [{ title: 'Bar' }],
    }],
 })
/>
```
Birkir Rafn Guðjónsson 7 years ago
parent
commit
52d85bca54

+ 22
- 1
docs/screen-api.md View File

@@ -17,7 +17,17 @@ this.props.navigator.push({
17 17
   backButtonTitle: undefined, // override the back button title (optional)
18 18
   backButtonHidden: false, // hide the back button altogether (optional)
19 19
   navigatorStyle: {}, // override the navigator style for the pushed screen (optional)
20
-  navigatorButtons: {} // override the nav buttons for the pushed screen (optional)
20
+  navigatorButtons: {}, // override the nav buttons for the pushed screen (optional)
21
+  // enable peek and pop - commited screen will have `isPreview` prop set as true.
22
+  previewView: undefined, // react ref or node id (optional)
23
+  previewHeight: undefined, // set preview height, defaults to full height (optional)
24
+  previewCommit: true, // commit to push preview controller to the navigation stack (optional)
25
+  previewActions: [{ // action presses can be detected with the `PreviewActionPress` event on the commited screen.
26
+    id: '', // action id (required)
27
+    title: '', // action title (required)
28
+    style: undefined, // 'selected' or 'destructive' (optional)
29
+    actions: [], // list of sub-actions
30
+  }],
21 31
 });
22 32
 ```
23 33
 
@@ -295,6 +305,8 @@ export default class ExampleScreen extends Component {
295 305
         break;
296 306
       case 'didDisappear':
297 307
         break;
308
+      case 'willCommitPreview':
309
+        break;
298 310
     }
299 311
   }
300 312
 }
@@ -348,3 +360,12 @@ export default class ExampleScreen extends Component {
348 360
   }
349 361
 }
350 362
 ```
363
+
364
+# Peek and pop (3D touch)
365
+
366
+react-native-navigation supports the [Peek and pop](
367
+https://developer.apple.com/library/content/documentation/UserExperience/Conceptual/Adopting3DTouchOniPhone/#//apple_ref/doc/uid/TP40016543-CH1-SW3) feature by setting a react view reference as a `previewView` parameter when doing a push, more options are available in the `push` section.
368
+
369
+You can define actions and listen for interactions on the pushed screen with the `PreviewActionPress` event.
370
+
371
+Previewed screens will have the prop `isPreview` that can be used to render different things when the screen is in the "Peek" state and will then recieve a navigator event of `willCommitPreview` when in the "Pop" state.

+ 20
- 15
example/src/components/Row.js View File

@@ -2,27 +2,32 @@ import React from 'react';
2 2
 import PropTypes from 'prop-types';
3 3
 import {StyleSheet, View, Text, TouchableHighlight, Platform} from 'react-native';
4 4
 
5
-function Row({title, onPress, platform, testID}) {
6
-  if (platform && platform !== Platform.OS) {
7
-    return <View />;
8
-  }
5
+class Row extends React.PureComponent {
6
+  render() {
7
+    const {title, onPress, onPressIn, platform, testID} = this.props;
8
+    if (platform && platform !== Platform.OS) {
9
+      return <View />;
10
+    }
9 11
 
10
-  return (
11
-    <TouchableHighlight
12
-      onPress={onPress}
13
-      testID={testID}
14
-      underlayColor={'rgba(0, 0, 0, 0.054)'}
15
-    >
16
-      <View style={styles.row}>
17
-        <Text style={styles.text}>{title}</Text>
18
-      </View>
19
-    </TouchableHighlight>
20
-  );
12
+    return (
13
+      <TouchableHighlight
14
+        onPress={onPress}
15
+        onPressIn={onPressIn}
16
+        testID={testID}
17
+        underlayColor={'rgba(0, 0, 0, 0.054)'}
18
+      >
19
+        <View style={styles.row}>
20
+          <Text style={styles.text}>{title}</Text>
21
+        </View>
22
+      </TouchableHighlight>
23
+    );
24
+  }
21 25
 }
22 26
 
23 27
 Row.propTypes = {
24 28
   title: PropTypes.string.isRequired,
25 29
   onPress: PropTypes.func.isRequired,
30
+  onPressIn: PropTypes.func
26 31
 };
27 32
 
28 33
 const styles = StyleSheet.create({

+ 29
- 0
example/src/screens/NavigationTypes.js View File

@@ -34,6 +34,28 @@ class NavigationTypes extends React.Component {
34 34
     });
35 35
   };
36 36
 
37
+  previewScreen = () => {
38
+    this.props.navigator.push({
39
+      screen: 'example.Types.Push',
40
+      title: 'New Screen',
41
+      previewCommit: true,
42
+      previewHeight: 250,
43
+      previewView: this.previewRef,
44
+      previewActions: [{
45
+        id: 'action-cancel',
46
+        title: 'Cancel'
47
+      }, {
48
+        id: 'action-delete',
49
+        title: 'Delete',
50
+        actions: [{
51
+          id: 'action-delete-sure',
52
+          title: 'Are you sure?',
53
+          style: 'destructive'
54
+        }]
55
+      }]
56
+    });
57
+  };
58
+
37 59
   pushListScreen = () => {
38 60
     console.log('RANG', 'pushListScreen');
39 61
     this.props.navigator.push({
@@ -107,6 +129,13 @@ class NavigationTypes extends React.Component {
107 129
       <ScrollView style={styles.container}>
108 130
         <Row title={'Toggle Drawer'} onPress={this.toggleDrawer}/>
109 131
         <Row title={'Push Screen'} testID={'pushScreen'} onPress={this.pushScreen}/>
132
+        <Row
133
+          ref={(ref) => (this.previewRef = ref)}
134
+          title={'Preview Screen'}
135
+          testID={'previewScreen'}
136
+          onPress={this.pushScreen}
137
+          onPressIn={this.previewScreen}
138
+        />
110 139
         {/*<Row title={'Push List Screen'} testID={'pushListScreen'} onPress={this.pushListScreen}/>*/}
111 140
         <Row title={'Custom TopBar'} onPress={this.pushCustomTopBarScreen}/>
112 141
         <Row title={'Custom Button'} onPress={this.pushCustomButtonScreen}/>

+ 17
- 1
example/src/screens/types/Push.js View File

@@ -1,8 +1,24 @@
1 1
 import React, {Component} from 'react';
2
-import {StyleSheet, View, Text, Button} from 'react-native';
2
+import {StyleSheet, View, Text, Button, Alert} from 'react-native';
3 3
 
4 4
 class Push extends Component {
5 5
 
6
+  constructor(props) {
7
+    super(props);
8
+    this.props.navigator.setOnNavigatorEvent(this.onNavigatorEvent.bind(this));
9
+  }
10
+
11
+  onNavigatorEvent(event) {
12
+    if (event.type === 'PreviewActionPress') {
13
+      if (event.id === 'action-cancel') {
14
+        Alert.alert('Cancelled');
15
+      }
16
+      if (event.id === 'action-delete-sure') {
17
+        Alert.alert('Deleted');
18
+      }
19
+    }
20
+  }
21
+
6 22
   onPushAnother = () => {
7 23
     this.props.navigator.push({
8 24
       screen: 'example.Types.Push',

+ 31
- 0
ios/RCCNavigationController.m View File

@@ -2,6 +2,10 @@
2 2
 #import "RCCViewController.h"
3 3
 #import "RCCManager.h"
4 4
 #import <React/RCTEventDispatcher.h>
5
+#import <React/RCTUIManager.h>
6
+#if __has_include(<React/RCTUIManagerUtils.h>)
7
+#import <React/RCTUIManagerUtils.h>
8
+#endif
5 9
 #import <React/RCTConvert.h>
6 10
 #import <React/RCTRootView.h>
7 11
 #import <objc/runtime.h>
@@ -160,6 +164,33 @@ NSString const *CALLBACK_ASSOCIATED_ID = @"RCCNavigationController.CALLBACK_ASSO
160 164
     {
161 165
       [self setButtons:rightButtons viewController:viewController side:@"right" animated:NO];
162 166
     }
167
+
168
+    NSArray *previewActions = actionParams[@"previewActions"];
169
+    NSString *previewViewID = actionParams[@"previewViewID"];
170
+    if (previewViewID) {
171
+      if ([self.topViewController isKindOfClass:[RCCViewController class]])
172
+      {
173
+        RCCViewController *topViewController = ((RCCViewController*)self.topViewController);
174
+        viewController.previewActions = previewActions;
175
+        viewController.previewCommit = actionParams[@"previewCommit"] ? [actionParams[@"previewCommit"] boolValue] : YES;
176
+        NSNumber *previewHeight = actionParams[@"previewHeight"];
177
+        if (previewHeight) {
178
+          viewController.preferredContentSize = CGSizeMake(viewController.view.frame.size.width, [previewHeight floatValue]);
179
+        }
180
+        if (topViewController.traitCollection.forceTouchCapability == UIForceTouchCapabilityAvailable)
181
+        {
182
+          dispatch_async(RCTGetUIManagerQueue(), ^{
183
+            [bridge.uiManager addUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
184
+              UIView *view = viewRegistry[previewViewID];
185
+              topViewController.previewView = view;
186
+              [topViewController registerForPreviewingWithDelegate:(id)topViewController sourceView:view];
187
+            }];
188
+          });
189
+          topViewController.previewController = viewController;
190
+        }
191
+        return;
192
+      }
193
+    }
163 194
     
164 195
     NSString *animationType = actionParams[@"animationType"];
165 196
     if ([animationType isEqualToString:@"fade"])

+ 4
- 0
ios/RCCViewController.h View File

@@ -18,6 +18,10 @@ extern NSString* const RCCViewControllerCancelReactTouchesNotification;
18 18
 @property (nonatomic, strong) NSString *controllerId;
19 19
 @property (nonatomic, strong) NSString *commandType;
20 20
 @property (nonatomic, strong) NSString *timestamp;
21
+@property (nonatomic) RCCViewController *previewController;
22
+@property (nonatomic) UIView *previewView;
23
+@property (nonatomic) NSArray *previewActions;
24
+@property (nonatomic) BOOL previewCommit;
21 25
 
22 26
 + (UIViewController*)controllerWithLayout:(NSDictionary *)layout globalProps:(NSDictionary *)globalProps bridge:(RCTBridge *)bridge;
23 27
 

+ 64
- 1
ios/RCCViewController.m View File

@@ -19,7 +19,7 @@ const NSInteger BLUR_STATUS_TAG = 78264801;
19 19
 const NSInteger BLUR_NAVBAR_TAG = 78264802;
20 20
 const NSInteger TRANSPARENT_NAVBAR_TAG = 78264803;
21 21
 
22
-@interface RCCViewController() <UIGestureRecognizerDelegate>
22
+@interface RCCViewController() <UIGestureRecognizerDelegate, UIViewControllerPreviewingDelegate>
23 23
 @property (nonatomic) BOOL _hidesBottomBarWhenPushed;
24 24
 @property (nonatomic) BOOL _statusBarHideWithNavBar;
25 25
 @property (nonatomic) BOOL _statusBarHidden;
@@ -794,6 +794,37 @@ const NSInteger TRANSPARENT_NAVBAR_TAG = 78264803;
794 794
   }
795 795
 }
796 796
 
797
+#pragma mark - Preview Actions
798
+
799
+- (void)onActionPress:(NSString *)id {
800
+  if ([self.view isKindOfClass:[RCTRootView class]]) {
801
+    RCTRootView *rootView = (RCTRootView *)self.view;
802
+    if (rootView.appProperties && rootView.appProperties[@"navigatorEventID"]) {
803
+      [[[RCCManager sharedInstance] getBridge].eventDispatcher
804
+       sendAppEventWithName:rootView.appProperties[@"navigatorEventID"]
805
+       body:@{
806
+              @"type": @"PreviewActionPress",
807
+              @"id": id
808
+              }];
809
+    }
810
+  }
811
+}
812
+
813
+- (UIPreviewAction *) convertAction:(NSDictionary *)action {
814
+  NSString *actionId = action[@"id"];
815
+  NSString *actionTitle = action[@"title"];
816
+  UIPreviewActionStyle actionStyle = UIPreviewActionStyleDefault;
817
+  if ([action[@"style"] isEqualToString:@"selected"]) {
818
+    actionStyle = UIPreviewActionStyleSelected;
819
+  }
820
+  if ([action[@"style"] isEqualToString:@"destructive"]) {
821
+    actionStyle = UIPreviewActionStyleDestructive;
822
+  }
823
+  return [UIPreviewAction actionWithTitle:actionTitle style:actionStyle handler:^(UIPreviewAction * _Nonnull action, UIViewController * _Nonnull previewViewController) {
824
+    [self onActionPress:actionId];
825
+  }];
826
+}
827
+
797 828
 #pragma mark - NewRelic
798 829
 
799 830
 - (NSString*) customNewRelicInteractionName
@@ -830,4 +861,36 @@ const NSInteger TRANSPARENT_NAVBAR_TAG = 78264803;
830 861
   return !disabledSimultaneousGestureBool;
831 862
 }
832 863
 
864
+#pragma mark - UIViewControllerPreviewingDelegate
865
+- (UIViewController *)previewingContext:(id<UIViewControllerPreviewing>)previewingContext viewControllerForLocation:(CGPoint)location {
866
+  return self.previewController;
867
+}
868
+
869
+- (void)previewingContext:(id<UIViewControllerPreviewing>)previewingContext commitViewController:(UIViewController *)viewControllerToCommit {
870
+  if (self.previewController.previewCommit == YES) {
871
+    [self.previewController sendGlobalScreenEvent:@"willCommitPreview" endTimestampString:[self.previewController getTimestampString] shouldReset:YES];
872
+    [self.previewController sendScreenChangedEvent:@"willCommitPreview"];
873
+    [self.navigationController pushViewController:self.previewController animated:false];
874
+  }
875
+}
876
+
877
+- (NSArray<id<UIPreviewActionItem>> *)previewActionItems {
878
+  NSMutableArray *actions = [[NSMutableArray alloc] init];
879
+  for (NSDictionary *previewAction in self.previewActions) {
880
+    UIPreviewAction *action = [self convertAction:previewAction];
881
+    NSDictionary *actionActions = previewAction[@"actions"];
882
+    if (actionActions.count > 0) {
883
+      NSMutableArray *group = [[NSMutableArray alloc] init];
884
+      for (NSDictionary *previewGroupAction in actionActions) {
885
+        [group addObject:[self convertAction:previewGroupAction]];
886
+      }
887
+      UIPreviewActionGroup *actionGroup = [UIPreviewActionGroup actionGroupWithTitle:action.title style:UIPreviewActionStyleDefault actions:group];
888
+      [actions addObject:actionGroup];
889
+    } else {
890
+      [actions addObject:action];
891
+    }
892
+  }
893
+  return actions;
894
+}
895
+
833 896
 @end

+ 16
- 0
src/deprecated/platformSpecificDeprecated.ios.js View File

@@ -1,4 +1,6 @@
1 1
 /*eslint-disable*/
2
+import { Component } from 'react';
3
+import { findNodeHandle } from 'react-native';
2 4
 import Navigation from './../Navigation';
3 5
 import Controllers, {Modal, Notification, ScreenUtils} from './controllers';
4 6
 const React = Controllers.hijackReact();
@@ -234,7 +236,15 @@ function navigatorPush(navigator, params) {
234 236
     console.error('Navigator.push(params): params.screen is required');
235 237
     return;
236 238
   }
239
+  let previewViewID;
237 240
   const screenInstanceID = _.uniqueId('screenInstanceID');
241
+  if (params.previewView instanceof Component) {
242
+    previewViewID = findNodeHandle(params.previewView)
243
+  } else if (typeof params.previewView === 'number') {
244
+    previewViewID = params.previewView;
245
+  } else if (params.previewView) {
246
+    console.error('Navigator.push(params): params.previewView is not a valid react view');
247
+  }
238 248
   const {
239 249
     navigatorStyle,
240 250
     navigatorButtons,
@@ -246,6 +256,8 @@ function navigatorPush(navigator, params) {
246 256
   passProps.navigatorID = navigator.navigatorID;
247 257
   passProps.screenInstanceID = screenInstanceID;
248 258
   passProps.navigatorEventID = navigatorEventID;
259
+  passProps.previewViewID = previewViewID;
260
+  passProps.isPreview = !!previewViewID;
249 261
 
250 262
   params.navigationParams = {
251 263
     screenInstanceID,
@@ -270,6 +282,10 @@ function navigatorPush(navigator, params) {
270 282
     backButtonHidden: params.backButtonHidden,
271 283
     leftButtons: navigatorButtons.leftButtons,
272 284
     rightButtons: navigatorButtons.rightButtons,
285
+    previewViewID: previewViewID,
286
+    previewActions: params.previewActions,
287
+    previewHeight: params.previewHeight,
288
+    previewCommit: params.previewCommit,
273 289
     timestamp: Date.now()
274 290
   });
275 291
 }