ソースを参照

[v2][ios11, ios12] Add searchBar option for topBar (#3303)

* [ios11+] Add searchBar option for topBar

* Fix formatting and linter issues

* Add docs about topBar.searchBar

* Fix missing semicolons

* Revert prettier changes

* Add js tests for onSearchBarUpdated to achieve 100% coverage

* Mark searchBar test as :ios: specific
Dima Loktev 6 年 前
コミット
b7b7985934

+ 14
- 4
docs/docs/events.md ファイルの表示

@@ -16,7 +16,7 @@ Called each time this component appears on screen (attached to the view heirarch
16 16
 ```js
17 17
 class MyComponent extends Component {
18 18
   componentDidAppear() {
19
-    
19
+
20 20
   }
21 21
 }
22 22
 ```
@@ -39,7 +39,7 @@ Called each time this component disappears from screen (detached from the view h
39 39
 ```js
40 40
 class MyComponent extends Component {
41 41
   componentDidAppear() {
42
-    
42
+
43 43
   }
44 44
 }
45 45
 ```
@@ -89,7 +89,17 @@ Called when a TopBar button is pressed.
89 89
 ```js
90 90
 class MyComponent extends Component {
91 91
   onNavigationButtonPressed(buttonId) {
92
-    
92
+
93 93
   }
94 94
 }
95
-```
95
+```
96
+
97
+## onSearchBarUpdated (iOS 11+ only)
98
+Called when a SearchBar from NavigationBar gets updated.
99
+```js
100
+class MyComponent extends Component {
101
+  onSearchBarUpdated(query, isFocused) {
102
+
103
+  }
104
+}
105
+```

+ 5
- 1
docs/docs/styling.md ファイルの表示

@@ -19,7 +19,7 @@ export default class StyledScreen extends Component {
19 19
       }
20 20
     };
21 21
   }
22
-  
22
+
23 23
   constructor(props) {
24 24
     super(props);
25 25
   }
@@ -74,6 +74,10 @@ Navigation.mergeOptions(this.props.componentId, {
74 74
     buttonColor: 'black',
75 75
     drawBehind: false,
76 76
     testID: 'topBar',
77
+    largeTitle: true, // iOS 11+ Large Title
78
+    searchBar: true, // iOS 11+ native UISearchBar inside topBar
79
+    searchBarHiddenWhenScrolling: true,
80
+    searchBarPlaceholder: 'Search', // iOS 11+ SearchBar placeholder
77 81
     component: {
78 82
       name: 'example.CustomTopBar'
79 83
     },

+ 9
- 0
e2e/ScreenStyle.test.js ファイルの表示

@@ -133,4 +133,13 @@ describe('screen style', () => {
133 133
     await elementById(testIDs.SHOW_TOPBAR_REACT_VIEW).tap();
134 134
     await expect(elementByLabel('Press Me')).toBeVisible();
135 135
   });
136
+
137
+  it(':ios: set searchBar and handle onSearchUpdated event', async () => {
138
+    await elementById(testIDs.SHOW_TOPBAR_SEARCHBAR).tap();
139
+    await expect(elementByLabel('Start Typing')).toBeVisible();
140
+    await elementByLabel('Start Typing').tap();
141
+    const query = '124';
142
+    await elementByLabel('Start Typing').typeText(query);
143
+    await expect(elementById(testIDs.SEARCH_RESULT_ITEM)).toHaveText(`Item ${query}`);
144
+  });
136 145
 });

+ 2
- 0
lib/ios/RNNEventEmitter.h ファイルの表示

@@ -18,4 +18,6 @@
18 18
 
19 19
 -(void)sendOnNavigationCommand:(NSString *)commandName params:(NSDictionary*)params;
20 20
 
21
+-(void)sendOnSearchBarUpdated:(NSString *)componentId text:(NSString*)text isFocused:(BOOL)isFocused;
22
+
21 23
 @end

+ 10
- 0
lib/ios/RNNEventEmitter.m ファイルの表示

@@ -47,6 +47,16 @@ static NSString* const navigationEvent	= @"RNN.nativeEvent";
47 47
 	[self send:navigationEvent body:@{@"name":commandName , @"params": params}];
48 48
 }
49 49
 
50
+-(void)sendOnSearchBarUpdated:(NSString *)componentId
51
+						 text:(NSString*)text
52
+					isFocused:(BOOL)isFocused {
53
+	[self send:navigationEvent body:@{@"name": @"searchBarUpdated",
54
+									  @"params": @{
55
+												  @"componentId": componentId,
56
+												  @"text": text,
57
+												  @"isFocused": @(isFocused)}}];
58
+}
59
+
50 60
 - (void)addListener:(NSString *)eventName {
51 61
 	[super addListener:eventName];
52 62
 	if ([eventName isEqualToString:onAppLaunched]) {

+ 1
- 1
lib/ios/RNNRootViewController.h ファイルの表示

@@ -9,7 +9,7 @@
9 9
 #import "RNNTopTabsViewController.h"
10 10
 #import "RNNRootViewProtocol.h"
11 11
 
12
-@interface RNNRootViewController : UIViewController	<RNNRootViewProtocol, UIViewControllerPreviewingDelegate>
12
+@interface RNNRootViewController : UIViewController	<RNNRootViewProtocol, UIViewControllerPreviewingDelegate, UISearchResultsUpdating>
13 13
 
14 14
 @property (nonatomic, strong) RNNNavigationOptions* options;
15 15
 @property (nonatomic, strong) RNNEventEmitter *eventEmitter;

+ 11
- 0
lib/ios/RNNRootViewController.m ファイルの表示

@@ -59,6 +59,11 @@
59 59
 -(void)viewDidAppear:(BOOL)animated {
60 60
 	[super viewDidAppear:animated];
61 61
 	[self.eventEmitter sendComponentDidAppear:self.componentId componentName:self.componentName];
62
+	if (@available(iOS 11.0, *)) {
63
+		if (self.navigationItem.searchController && [self.options.topBar.searchBarHiddenWhenScrolling boolValue]) {
64
+			self.navigationItem.hidesSearchBarWhenScrolling = YES;
65
+		}
66
+	}
62 67
 }
63 68
 
64 69
 - (void)viewWillDisappear:(BOOL)animated {
@@ -70,6 +75,12 @@
70 75
 	[self.eventEmitter sendComponentDidDisappear:self.componentId componentName:self.componentName];
71 76
 }
72 77
 
78
+-(void)updateSearchResultsForSearchController:(UISearchController *)searchController {
79
+	[self.eventEmitter sendOnSearchBarUpdated:self.componentId
80
+										 text:searchController.searchBar.text
81
+									isFocused:searchController.searchBar.isFirstResponder];
82
+}
83
+
73 84
 - (void)viewDidLoad {
74 85
 	[super viewDidLoad];
75 86
 }

+ 3
- 0
lib/ios/RNNTopBarOptions.h ファイルの表示

@@ -26,6 +26,9 @@
26 26
 @property (nonatomic, strong) NSNumber* backButtonHidden;
27 27
 @property (nonatomic, strong) NSString* backButtonTitle;
28 28
 @property (nonatomic, strong) NSNumber* hideBackButtonTitle;
29
+@property (nonatomic, strong) NSNumber* searchBar;
30
+@property (nonatomic, strong) NSNumber* searchBarHiddenWhenScrolling;
31
+@property (nonatomic, strong) NSString* searchBarPlaceholder;
29 32
 
30 33
 @property (nonatomic, strong) RNNComponentOptions* component;
31 34
 

+ 13
- 0
lib/ios/RNNTopBarOptions.m ファイルの表示

@@ -42,6 +42,19 @@ extern const NSInteger BLUR_TOPBAR_TAG;
42 42
 			viewController.navigationController.navigationBar.prefersLargeTitles = NO;
43 43
 			viewController.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever;
44 44
 		}
45
+		if ([self.searchBar boolValue] && !viewController.navigationItem.searchController) {
46
+			UISearchController *search = [[UISearchController alloc]initWithSearchResultsController:nil];
47
+			search.dimsBackgroundDuringPresentation = NO;
48
+			if ([viewController conformsToProtocol:@protocol(UISearchResultsUpdating)]) {
49
+				[search setSearchResultsUpdater:((UIViewController <UISearchResultsUpdating> *) viewController)];
50
+			}
51
+			if (self.searchBarPlaceholder) {
52
+				search.searchBar.placeholder = self.searchBarPlaceholder;
53
+			}
54
+			viewController.navigationItem.searchController = search;
55
+			// enable it back if needed on componentDidAppear
56
+			viewController.navigationItem.hidesSearchBarWhenScrolling = NO;
57
+		}
45 58
 	}
46 59
 	
47 60
 	if (self.visible) {

+ 14
- 0
lib/src/components/ComponentWrapper.test.tsx ファイルの表示

@@ -146,6 +146,7 @@ describe('ComponentWrapper', () => {
146 146
     const componentDidAppearCallback = jest.fn();
147 147
     const componentDidDisappearCallback = jest.fn();
148 148
     const onNavigationButtonPressedCallback = jest.fn();
149
+    const onSearchBarCallback = jest.fn();
149 150
 
150 151
     class MyLifecycleComponent extends MyComponent {
151 152
       componentDidAppear() {
@@ -159,6 +160,10 @@ describe('ComponentWrapper', () => {
159 160
       onNavigationButtonPressed() {
160 161
         onNavigationButtonPressedCallback();
161 162
       }
163
+
164
+      onSearchBarUpdated() {
165
+        onSearchBarCallback();
166
+      }
162 167
     }
163 168
 
164 169
     it('componentDidAppear, componentDidDisappear and onNavigationButtonPressed are optional', () => {
@@ -167,6 +172,7 @@ describe('ComponentWrapper', () => {
167 172
       expect(() => tree.getInstance()!.componentDidAppear()).not.toThrow();
168 173
       expect(() => tree.getInstance()!.componentDidDisappear()).not.toThrow();
169 174
       expect(() => tree.getInstance()!.onNavigationButtonPressed()).not.toThrow();
175
+      expect(() => tree.getInstance()!.onSearchBarUpdated()).not.toThrow();
170 176
     });
171 177
 
172 178
     it('calls componentDidAppear on OriginalComponent', () => {
@@ -192,5 +198,13 @@ describe('ComponentWrapper', () => {
192 198
       tree.getInstance()!.onNavigationButtonPressed();
193 199
       expect(onNavigationButtonPressedCallback).toHaveBeenCalledTimes(1);
194 200
     });
201
+
202
+    it('calls onSearchBarUpdated on OriginalComponent', () => {
203
+      const NavigationComponent = ComponentWrapper.wrap(componentName, MyLifecycleComponent, store);
204
+      const tree = renderer.create(<NavigationComponent componentId={'component1'} />);
205
+      expect(onSearchBarCallback).toHaveBeenCalledTimes(0);
206
+      tree.getInstance()!.onSearchBarUpdated();
207
+      expect(onSearchBarCallback).toHaveBeenCalledTimes(1);
208
+    });
195 209
   });
196 210
 });

+ 6
- 0
lib/src/components/ComponentWrapper.tsx ファイルの表示

@@ -52,6 +52,12 @@ export class ComponentWrapper {
52 52
         }
53 53
       }
54 54
 
55
+      onSearchBarUpdated(text, isFocused) {
56
+        if (this.originalComponentRef.onSearchBarUpdated) {
57
+          this.originalComponentRef.onSearchBarUpdated(text, isFocused);
58
+        }
59
+      }
60
+
55 61
       render() {
56 62
         return (
57 63
           <OriginalComponentClass

+ 29
- 1
lib/src/events/ComponentEventsObserver.test.ts ファイルの表示

@@ -18,7 +18,8 @@ describe(`ComponentEventsObserver`, () => {
18 18
     mockComponentRef = {
19 19
       componentDidAppear: jest.fn(),
20 20
       componentDidDisappear: jest.fn(),
21
-      onNavigationButtonPressed: jest.fn()
21
+      onNavigationButtonPressed: jest.fn(),
22
+      onSearchBarUpdated: jest.fn()
22 23
     };
23 24
 
24 25
     store = new Store();
@@ -42,6 +43,7 @@ describe(`ComponentEventsObserver`, () => {
42 43
     expect(mockComponentRef.componentDidAppear).toHaveBeenCalledTimes(0);
43 44
     expect(mockComponentRef.componentDidDisappear).toHaveBeenCalledTimes(0);
44 45
     expect(mockComponentRef.onNavigationButtonPressed).toHaveBeenCalledTimes(0);
46
+    expect(mockComponentRef.onSearchBarUpdated).toHaveBeenCalledTimes(0);
45 47
     uut.registerForAllComponents();
46 48
     eventRegistry.registerComponentDidAppearListener.mock.calls[0][0](refId);
47 49
     eventRegistry.registerComponentDidDisappearListener.mock.calls[0][0](refId);
@@ -49,6 +51,7 @@ describe(`ComponentEventsObserver`, () => {
49 51
     expect(mockComponentRef.componentDidAppear).toHaveBeenCalledTimes(1);
50 52
     expect(mockComponentRef.componentDidDisappear).toHaveBeenCalledTimes(1);
51 53
     expect(mockComponentRef.onNavigationButtonPressed).toHaveBeenCalledTimes(0);
54
+    expect(mockComponentRef.onSearchBarUpdated).toHaveBeenCalledTimes(0);
52 55
   });
53 56
 
54 57
   it('bubbles onNavigationButtonPressed to component by id', () => {
@@ -67,6 +70,31 @@ describe(`ComponentEventsObserver`, () => {
67 70
     expect(mockComponentRef.onNavigationButtonPressed).toHaveBeenCalledWith('theButtonId');
68 71
   });
69 72
 
73
+  it('bubbles onSearchUpdated to component by id', () => {
74
+    const params = {
75
+      componentId: refId,
76
+      text: 'query',
77
+      isFocused: true,
78
+    };
79
+    expect(mockComponentRef.onSearchBarUpdated).toHaveBeenCalledTimes(0);
80
+    uut.registerForAllComponents();
81
+
82
+    eventRegistry.registerNativeEventListener.mock.calls[0][0]('buttonPressed', params);
83
+    expect(mockComponentRef.onSearchBarUpdated).toHaveBeenCalledTimes(0);
84
+
85
+    eventRegistry.registerNativeEventListener.mock.calls[0][0]('searchBarUpdated', params);
86
+    expect(mockComponentRef.onSearchBarUpdated).toHaveBeenCalledTimes(1);
87
+    expect(mockComponentRef.onSearchBarUpdated).toHaveBeenCalledWith('query', true);
88
+    const paramsForUnexisted = {
89
+      componentId: 'NOT_EXISTED',
90
+      text: 'query',
91
+      isFocused: true,
92
+    };
93
+    eventRegistry.registerNativeEventListener.mock.calls[0][0]('searchBarUpdated', paramsForUnexisted);
94
+    expect(mockComponentRef.onSearchBarUpdated).toHaveBeenCalledTimes(1);
95
+    expect(mockComponentRef.onSearchBarUpdated).toHaveBeenCalledWith('query', true);
96
+  });
97
+
70 98
   it('defensive unknown id', () => {
71 99
     uut.registerForAllComponents();
72 100
     expect(() => {

+ 7
- 0
lib/src/events/ComponentEventsObserver.ts ファイルの表示

@@ -2,6 +2,7 @@ import { EventsRegistry } from './EventsRegistry';
2 2
 import { Store } from '../components/Store';
3 3
 
4 4
 const BUTTON_PRESSED_EVENT_NAME = 'buttonPressed';
5
+const ON_SEARCH_BAR_UPDATED = 'searchBarUpdated';
5 6
 
6 7
 export class ComponentEventsObserver {
7 8
   constructor(private eventsRegistry: EventsRegistry, private store: Store) {
@@ -37,5 +38,11 @@ export class ComponentEventsObserver {
37 38
         componentRef.onNavigationButtonPressed(params.buttonId);
38 39
       }
39 40
     }
41
+    if (name === ON_SEARCH_BAR_UPDATED) {
42
+      const componentRef = this.store.getRefForId(params.componentId);
43
+      if (componentRef && componentRef.onSearchBarUpdated) {
44
+        componentRef.onSearchBarUpdated(params.text, params.isFocused);
45
+      }
46
+    }
40 47
   }
41 48
 }

+ 97
- 0
playground/src/screens/SearchScreen.js ファイルの表示

@@ -0,0 +1,97 @@
1
+const React = require('react');
2
+const { Component } = require('react');
3
+
4
+const {
5
+  StyleSheet,
6
+  FlatList,
7
+  View,
8
+  Button,
9
+  Text,
10
+  Platform
11
+} = require('react-native');
12
+
13
+const { Navigation } = require('react-native-navigation');
14
+const testIDs = require('../testIDs');
15
+
16
+const ITEMS = [...Array(200).keys()].map(key => ({ key: `Item ${key}` }));
17
+
18
+class SearchControllerScreen extends Component {
19
+  static get options() {
20
+    return {
21
+      topBar: {
22
+        title: {
23
+          text: 'Search'
24
+        },
25
+        largeTitle: true,
26
+        searchBar: true,
27
+        searchBarHiddenWhenScrolling: true,
28
+        translucent: true,
29
+        searchBarPlaceholder: 'Start Typing'
30
+      }
31
+    };
32
+  }
33
+
34
+  constructor(props) {
35
+    super(props);
36
+    this.state = {
37
+      query: ''
38
+    };
39
+  }
40
+
41
+  filteredData() {
42
+    return ITEMS.filter(
43
+      item =>
44
+        this.state.query.length === 0 || item.key.indexOf(this.state.query) > -1
45
+    );
46
+  }
47
+
48
+  highlight(text, query) {
49
+    if (query.length > 0 && text.indexOf(query) > -1) {
50
+      const before = text.split(query)[0];
51
+      const after = text.split(query)[1];
52
+      return (
53
+        <Text>
54
+          <Text>{before}</Text>
55
+          <Text style={{ backgroundColor: 'yellow' }}>{query}</Text>
56
+          <Text>{after}</Text>
57
+        </Text>
58
+      );
59
+    }
60
+    return text;
61
+  }
62
+
63
+  render() {
64
+    return (
65
+      <FlatList
66
+        testID={testIDs.SCROLLVIEW_ELEMENT}
67
+        data={this.filteredData()}
68
+        contentContainerStyle={styles.contentContainer}
69
+        renderItem={({ item }) => (
70
+          <View style={styles.row}>
71
+            <Text style={styles.rowText} testID={testIDs.SEARCH_RESULT_ITEM}>
72
+              {this.highlight(item.key, this.state.query)}
73
+            </Text>
74
+          </View>
75
+        )}
76
+      />
77
+    );
78
+  }
79
+
80
+  onSearchBarUpdated(query, isFocused) {
81
+    this.setState({ query, isFocused });
82
+  }
83
+}
84
+
85
+module.exports = SearchControllerScreen;
86
+
87
+const styles = StyleSheet.create({
88
+  contentContainer: {},
89
+  row: {
90
+    height: 50,
91
+    padding: 20,
92
+    justifyContent: 'center'
93
+  },
94
+  rowText: {
95
+    fontSize: 18
96
+  }
97
+});

+ 8
- 0
playground/src/screens/WelcomeScreen.js ファイルの表示

@@ -53,6 +53,7 @@ class WelcomeScreen extends Component {
53 53
           <Button title='Orientation' testID={testIDs.ORIENTATION_BUTTON} onPress={this.onClickPushOrientationMenuScreen} />
54 54
           <Button title='Provided Id' testID={testIDs.PROVIDED_ID} onPress={this.onClickProvidedId} />
55 55
           <Button title='Complex Layout' testID={testIDs.COMPLEX_LAYOUT_BUTTON} onPress={this.onClickComplexLayout} />
56
+          <Button title='Push SearchBar' testID={testIDs.SHOW_TOPBAR_SEARCHBAR} onPress={this.onClickSearchBar} />
56 57
           <Text style={styles.footer}>{`this.props.componentId = ${this.props.componentId}`}</Text>
57 58
         </View>
58 59
         <View style={{ width: 2, height: 2, borderRadius: 1, backgroundColor: 'red', alignSelf: 'center' }} />
@@ -526,6 +527,13 @@ class WelcomeScreen extends Component {
526 527
       },
527 528
     });
528 529
   }
530
+  onClickSearchBar = () => {
531
+    Navigation.push(this.props.componentId, {
532
+      component: {
533
+        name: 'navigation.playground.SearchControllerScreen'
534
+      }
535
+    });
536
+  }
529 537
 }
530 538
 
531 539
 module.exports = WelcomeScreen;

+ 2
- 0
playground/src/screens/index.js ファイルの表示

@@ -23,6 +23,7 @@ const CustomTextButton = require('./CustomTextButton');
23 23
 const CustomRoundedButton = require('./CustomRoundedButton');
24 24
 const TopBarBackground = require('./TopBarBackground');
25 25
 const ComplexLayout = require('./ComplexLayout');
26
+const SearchScreen = require('./SearchScreen');
26 27
 
27 28
 function registerScreens() {
28 29
   Navigation.registerComponent(`navigation.playground.CustomTransitionDestination`, () => CustomTransitionDestination);
@@ -49,6 +50,7 @@ function registerScreens() {
49 50
   Navigation.registerComponent('CustomTextButton', () => CustomTextButton);
50 51
   Navigation.registerComponent('CustomRoundedButton', () => CustomRoundedButton);
51 52
   Navigation.registerComponent('TopBarBackground', () => TopBarBackground);
53
+  Navigation.registerComponent('navigation.playground.SearchControllerScreen', () => SearchScreen);
52 54
 }
53 55
 
54 56
 module.exports = {

+ 2
- 0
playground/src/testIDs.js ファイルの表示

@@ -65,6 +65,8 @@ module.exports = {
65 65
   MODAL_LIFECYCLE_BUTTON: `MODAL_LIFECYCLE_BUTTON`,
66 66
   SPLIT_VIEW_BUTTON: `SPLIT_VIEW_BUTTON`,
67 67
   SHOW_PREVIEW_BUTTON: `SHOW_PREVIEW_BUTTON`,
68
+  SHOW_TOPBAR_SEARCHBAR: `SHOW_TOPBAR_SEARCHBAR`,
69
+  SEARCH_RESULT_ITEM: `SEARCH_RESULT_ITEM`,
68 70
 
69 71
   // Elements
70 72
   SCROLLVIEW_ELEMENT: `SCROLLVIEW_ELEMENT`,