Skip to content

Commit 8e81246

Browse files
committed
feat(ios): ListView showSearch for built-in search behavior
1 parent 6e17818 commit 8e81246

File tree

6 files changed

+244
-38
lines changed

6 files changed

+244
-38
lines changed

apps/toolbox/src/pages/list-page-model.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { Observable, Dialogs, DialogStrings, View, EventData } from '@nativescript/core';
2-
1+
import { Observable, Dialogs, DialogStrings, View, EventData, SearchEventData } from '@nativescript/core';
2+
type CountryListType = Array<{ title: string; items: Array<{ name: string; code: string; flag: string; isVisible?: boolean }> }>;
33
export class ListPageModel extends Observable {
4-
components: Array<any> = [
4+
countries: CountryListType = [
55
{
66
title: 'A',
77
items: [
@@ -1373,9 +1373,10 @@ export class ListPageModel extends Observable {
13731373
],
13741374
},
13751375
];
1376+
private _originalCountries: CountryListType;
13761377

13771378
selectItemTemplate(item: any, index: number, items: Array<any>) {
1378-
return index == items.length - 1 ? 'last' : 'not-last';
1379+
return 'main'; // index == items.length - 1 ? 'last' : 'not-last';
13791380
}
13801381

13811382
componentsItemTap(args): void {
@@ -1389,4 +1390,40 @@ export class ListPageModel extends Observable {
13891390
itemLoading(args: EventData): void {
13901391
(args.object as View).backgroundColor = 'transparent';
13911392
}
1393+
1394+
onSearchTextChange(evt: SearchEventData): void {
1395+
if (!this._originalCountries) {
1396+
this._originalCountries = this.countries;
1397+
}
1398+
const searchText = evt.text.toLowerCase();
1399+
console.log('Search text:', searchText);
1400+
if (searchText) {
1401+
this.countries = this.filterCountryGroups(this._originalCountries, searchText);
1402+
} else {
1403+
this.countries = this._originalCountries; // reset to original if no search text
1404+
}
1405+
this.notifyPropertyChange('countries', this.countries);
1406+
}
1407+
1408+
/**
1409+
* Filter a grouped array of countries by search query.
1410+
* @param {Array<{ title: string; items: { name: string; code: string; flag: string; }[] }>} groups
1411+
* @param {string} query
1412+
* @returns Filtered groups with the same shape, omitting any with no matches.
1413+
*/
1414+
filterCountryGroups(groups: CountryListType, query: string): CountryListType {
1415+
const q = query.trim().toLowerCase();
1416+
if (!q) return groups; // no query → all groups
1417+
1418+
return (
1419+
groups
1420+
.map((group) => {
1421+
// keep only items whose name includes the query
1422+
const items = group.items.filter((item) => item.name.toLowerCase().includes(q));
1423+
return { ...group, items };
1424+
})
1425+
// drop any group that ended up with 0 items
1426+
.filter((group) => group.items.length > 0)
1427+
);
1428+
}
13921429
}

apps/toolbox/src/pages/list-page.xml

Lines changed: 7 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
</Page.actionBar>
77

88
<GridLayout backgroundColor="#efefef">
9-
<ListView class="list-group" items="{{ components }}" itemTap="{{ componentsItemTap }} " separatorColor="#00000000" itemTemplateSelector="{{ selectItemTemplate }}" stickyHeader="true" sectioned="true" stickyHeaderTopPadding="false" stickyHeaderTemplate="<GridLayout><Label text='{{ title }}' fontSize='18' fontWeight='bold' color='#009bff' padding='2 0 2 12' borderBottomWidth='1' borderBottomColor='#ccc' borderTopWidth='1' borderTopColor='#ccc' backgroundColor='#fff' /></GridLayout>" stickyHeaderHeight="45" itemLoading="{{ itemLoading }}">
9+
<ListView class="list-group" items="{{ countries }}" itemTap="{{ componentsItemTap }} " separatorColor="#00000000" itemTemplateSelector="{{ selectItemTemplate }}" stickyHeader="true" sectioned="true" stickyHeaderTopPadding="false" stickyHeaderTemplate="<GridLayout><Label text='{{ title }}' fontSize='18' fontWeight='bold' color='#009bff' padding='8 0 8 12' borderBottomWidth='1' borderBottomColor='#ccc' borderTopWidth='1' borderTopColor='#ccc' backgroundColor='#fff' /></GridLayout>" stickyHeaderHeight="45" itemLoading="{{ itemLoading }}"
10+
showSearch="true"
11+
searchChange="{{ onSearchTextChange }}">
1012
<ListView.itemTemplates>
11-
<template key="not-last">
13+
<template key="main">
1214
<!-- <StackLayout class="list-row-item"> -->
13-
<GridLayout columns="auto,auto,*" padding="12" margin="4 6 4 6" borderRadius="4" backgroundColor="#fff">
15+
<GridLayout columns="auto,auto,*" padding="14 12 14 12" margin="2 6 2 6" borderRadius="10" backgroundColor="#fff" boxShadow="0px 1px 2px rgba(0,0,0,0.2)">
1416
<!-- <FlexboxLayout flexDirection="row" class="list-view-row" verticalAlignment="center"> -->
1517
<!-- <visionos>
1618
<Label text="{{ iconText }}" class="icon-around icon-label"/>
@@ -24,8 +26,8 @@
2426
</StackLayout>
2527
</android> -->
2628
<!-- <StackLayout class="va-middle"> -->
27-
<Label class="component-select component-select-fix" text="{{ flag }}" marginLeft="4"></Label>
28-
<Label col="1" text="{{ name }}" marginLeft="6"></Label>
29+
<Label color="black" text="{{ flag }}" marginLeft="4"></Label>
30+
<Label col="1" text="{{ name }}" marginLeft="6" color="black"></Label>
2931
<Label col="2" text="{{ code }}" marginLeft="4" color="#999"></Label>
3032
<!-- </StackLayout> -->
3133
<!-- </FlexboxLayout> -->
@@ -34,31 +36,6 @@
3436
<!-- <StackLayout class="listview-separator"/> -->
3537
<!-- </StackLayout> -->
3638
</template>
37-
<template key="last">
38-
<!-- <StackLayout class="list-row-item"> -->
39-
<GridLayout columns="auto,auto,*" padding="12" margin="4 6 4 6" borderRadius="4" backgroundColor="#fff">
40-
<!-- <FlexboxLayout flexDirection="row" class="list-view-row" verticalAlignment="center"> -->
41-
<!-- <visionos>
42-
<Label text="{{ iconText }}" class="icon-around icon-label"/>
43-
</visionos>
44-
<ios>
45-
<Label text="{{ iconText }}" class="icon-around icon-label"/>
46-
</ios>
47-
<android>
48-
<StackLayout class="icon-around">
49-
<Label text="{{ iconText }}" class="icon-label"/>
50-
</StackLayout>
51-
</android> -->
52-
<!-- <StackLayout class="va-middle"> -->
53-
<Label class="component-select component-select-fix" text="{{ flag }}" marginLeft="4"></Label>
54-
<Label col="1" text="{{ name }}" marginLeft="6"></Label>
55-
<Label col="2" text="{{ code }}" marginLeft="4" color="#999"></Label>
56-
<!-- </StackLayout>
57-
</FlexboxLayout> -->
58-
59-
</GridLayout>
60-
<!-- </StackLayout> -->
61-
</template>
6239
</ListView.itemTemplates>
6340
</ListView>
6441

packages/core/ui/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export * from './layouts'; // barrel export
5656

5757
export { ListPicker } from './list-picker';
5858
export { ListView } from './list-view';
59-
export type { ItemEventData, TemplatedItemsView, ItemsSource } from './list-view';
59+
export type { ItemEventData, TemplatedItemsView, ItemsSource, SearchEventData } from './list-view';
6060
export { Page, PageBase } from './page';
6161
export type { NavigatedData } from './page';
6262
export { Placeholder } from './placeholder';

packages/core/ui/list-view/index.d.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ export class ListView extends View {
3030
* @nsEvent itemLoading
3131
*/
3232
public static loadMoreItemsEvent: string;
33+
/**
34+
* String value used when hooking to searchChange event.
35+
*
36+
* @nsEvent {SearchEventData} searchChange
37+
*/
38+
public static searchChangeEvent: string;
3339

3440
/**
3541
* Gets the native [android widget](http://developer.android.com/reference/android/widget/ListView.html) that represents the user interface for this component. Valid only when running on Android OS.
@@ -139,6 +145,14 @@ export class ListView extends View {
139145
*/
140146
sectioned: boolean;
141147

148+
/**
149+
* Gets or sets a value indicating whether the ListView should show a search bar.
150+
* When enabled on iOS, uses native UISearchController for optimal performance.
151+
*
152+
* @nsProperty
153+
*/
154+
showSearch: boolean;
155+
142156
/**
143157
* Forces the ListView to reload all its items.
144158
*/
@@ -195,6 +209,11 @@ export class ListView extends View {
195209
* Raised when the ListView is scrolled so that its last item is visible.
196210
*/
197211
on(event: 'loadMoreItems', callback: (args: EventData) => void, thisArg?: any): void;
212+
213+
/**
214+
* Raised when the search text in the search bar changes.
215+
*/
216+
on(event: 'searchChange', callback: (args: SearchEventData) => void, thisArg?: any): void;
198217
}
199218

200219
/**
@@ -222,6 +241,26 @@ export interface ItemEventData extends EventData {
222241
android: any /* android.view.ViewGroup */;
223242
}
224243

244+
/**
245+
* Event data containing information for the search text change event.
246+
*/
247+
export interface SearchEventData extends EventData {
248+
/**
249+
* The current search text value.
250+
*/
251+
text: string;
252+
253+
/**
254+
* Gets the native [iOS UISearchController](https://developer.apple.com/documentation/uikit/uisearchcontroller) that represents the search controller. Valid only when running on iOS.
255+
*/
256+
ios?: any /* UISearchController */;
257+
258+
/**
259+
* Gets the native Android search view. Valid only when running on Android OS.
260+
*/
261+
android?: any;
262+
}
263+
225264
export interface ItemsSource {
226265
length: number;
227266
getItem(index: number): any;
@@ -295,3 +334,8 @@ export const stickyHeaderTopPaddingProperty: Property<ListView, boolean>;
295334
* Represents the observable property backing the sectioned property of each ListView instance.
296335
*/
297336
export const sectionedProperty: Property<ListView, boolean>;
337+
338+
/**
339+
* Represents the observable property backing the showSearch property of each ListView instance.
340+
*/
341+
export const showSearchProperty: Property<ListView, boolean>;

packages/core/ui/list-view/index.ios.ts

Lines changed: 140 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { ItemEventData } from '.';
2-
import { ListViewBase, separatorColorProperty, itemTemplatesProperty, iosEstimatedRowHeightProperty, stickyHeaderProperty, stickyHeaderTemplateProperty, stickyHeaderHeightProperty, sectionedProperty } from './list-view-common';
1+
import { ItemEventData, SearchEventData, ItemsSource } from '.';
2+
import { ListViewBase, separatorColorProperty, itemTemplatesProperty, iosEstimatedRowHeightProperty, stickyHeaderProperty, stickyHeaderTemplateProperty, stickyHeaderHeightProperty, sectionedProperty, showSearchProperty } from './list-view-common';
33
import { CoreTypes } from '../../core-types';
44
import { View, KeyedTemplate, Template } from '../core/view';
55
import { Length } from '../styling/length-shared';
66
import { Observable, EventData } from '../../data/observable';
77
import { Color } from '../../color';
88
import { layout } from '../../utils';
9+
import { SDK_VERSION } from '../../utils/constants';
910
import { StackLayout } from '../layouts/stack-layout';
1011
import { ProxyViewContainer } from '../proxy-view-container';
1112
import { profile } from '../../profiling';
@@ -393,6 +394,43 @@ class UITableViewRowHeightDelegateImpl extends NSObject implements UITableViewDe
393394
}
394395
}
395396

397+
@NativeClass
398+
class UISearchResultsUpdatingImpl extends NSObject implements UISearchResultsUpdating {
399+
public static ObjCProtocols = [UISearchResultsUpdating];
400+
401+
private _owner: WeakRef<ListView>;
402+
403+
public static initWithOwner(owner: WeakRef<ListView>): UISearchResultsUpdatingImpl {
404+
const handler = <UISearchResultsUpdatingImpl>UISearchResultsUpdatingImpl.new();
405+
handler._owner = owner;
406+
407+
return handler;
408+
}
409+
410+
public updateSearchResultsForSearchController(searchController: UISearchController) {
411+
const owner = this._owner ? this._owner.get() : null;
412+
if (!owner) {
413+
return;
414+
}
415+
416+
const searchText = searchController.searchBar.text || '';
417+
418+
// Track search state
419+
owner._isSearchActive = searchController.active;
420+
421+
// Create SearchEventData
422+
const eventData: SearchEventData = {
423+
eventName: ListViewBase.searchChangeEvent,
424+
object: owner,
425+
text: searchText,
426+
ios: searchController,
427+
};
428+
429+
// Fire the searchChange event
430+
owner.notify(eventData);
431+
}
432+
}
433+
396434
export class ListView extends ListViewBase {
397435
public nativeViewProtected: UITableView;
398436
// tslint:disable-next-line
@@ -405,6 +443,9 @@ export class ListView extends ListViewBase {
405443
private _headerMap: Map<ListViewHeaderCell, View>;
406444
private _preparingHeader: boolean;
407445
private _headerTemplateCache: View;
446+
private _searchController: UISearchController;
447+
private _searchDelegate: UISearchResultsUpdatingImpl;
448+
_isSearchActive: boolean = false;
408449
widthMeasureSpec = 0;
409450

410451
constructor() {
@@ -440,11 +481,88 @@ export class ListView extends ListViewBase {
440481
}
441482

442483
disposeNativeView() {
484+
this._cleanupSearchController();
443485
this._delegate = null;
444486
this._dataSource = null;
445487
super.disposeNativeView();
446488
}
447489

490+
private _setupSearchController() {
491+
if (!this.showSearch || this._searchController) {
492+
return; // Already setup or not needed
493+
}
494+
495+
// 1. Create UISearchController with nil (show results in this table)
496+
this._searchController = UISearchController.alloc().initWithSearchResultsController(null);
497+
this._searchDelegate = UISearchResultsUpdatingImpl.initWithOwner(new WeakRef(this));
498+
499+
// 2. Tell it who will update results
500+
this._searchController.searchResultsUpdater = this._searchDelegate;
501+
502+
// 3. Don't dim or obscure your table by default
503+
this._searchController.obscuresBackgroundDuringPresentation = false;
504+
505+
// 4. Placeholder text
506+
this._searchController.searchBar.placeholder = 'Search';
507+
this._searchController.searchBar.searchBarStyle = UISearchBarStyle.Minimal;
508+
509+
// 5. CRITICAL: Make sure the search bar doesn't remain on screen if the user navigates
510+
const viewController = this._getViewController();
511+
if (viewController) {
512+
viewController.definesPresentationContext = true;
513+
514+
// 6a. If we're in a UINavigationController...
515+
if (SDK_VERSION >= 11.0 && viewController.navigationItem) {
516+
viewController.navigationItem.searchController = this._searchController;
517+
viewController.navigationItem.hidesSearchBarWhenScrolling = false;
518+
} else {
519+
// 6b. Or just put it at the top of our table
520+
this.nativeViewProtected.tableHeaderView = this._searchController.searchBar;
521+
}
522+
} else {
523+
// Fallback: no view controller found, use table header
524+
this.nativeViewProtected.tableHeaderView = this._searchController.searchBar;
525+
}
526+
527+
// Ensure search bar is properly sized
528+
this._searchController.searchBar.sizeToFit();
529+
530+
if (Trace.isEnabled()) {
531+
Trace.write(`ListView: UISearchController setup complete`, Trace.categories.Debug);
532+
}
533+
}
534+
535+
private _cleanupSearchController() {
536+
if (!this._searchController) {
537+
return;
538+
}
539+
540+
// Remove search controller from navigation item or table header
541+
const viewController = this._getViewController();
542+
if (viewController && viewController.navigationItem && viewController.navigationItem.searchController === this._searchController) {
543+
viewController.navigationItem.searchController = null;
544+
} else if (this.nativeViewProtected.tableHeaderView === this._searchController.searchBar) {
545+
this.nativeViewProtected.tableHeaderView = null;
546+
}
547+
548+
// Cleanup references
549+
this._searchController.searchResultsUpdater = null;
550+
this._searchController = null;
551+
this._searchDelegate = null;
552+
}
553+
554+
private _getViewController(): UIViewController {
555+
// Helper to get the current view controller
556+
let parent = this.parent;
557+
while (parent) {
558+
if (parent.viewController) {
559+
return parent.viewController;
560+
}
561+
parent = parent.parent;
562+
}
563+
return null;
564+
}
565+
448566
_setNativeClipToBounds() {
449567
// Always set clipsToBounds for list-view
450568
const view = this.nativeViewProtected;
@@ -460,6 +578,11 @@ export class ListView extends ListViewBase {
460578
this.refresh();
461579
}
462580
this.nativeViewProtected.delegate = this._delegate;
581+
582+
// Setup search controller if enabled
583+
if (this.showSearch) {
584+
this._setupSearchController();
585+
}
463586
}
464587

465588
// @ts-ignore
@@ -949,4 +1072,19 @@ export class ListView extends ListViewBase {
9491072
this.refresh();
9501073
}
9511074
}
1075+
1076+
[showSearchProperty.getDefault](): boolean {
1077+
return false;
1078+
}
1079+
[showSearchProperty.setNative](value: boolean) {
1080+
if (Trace.isEnabled()) {
1081+
Trace.write(`ListView: showSearch set to ${value}`, Trace.categories.Debug);
1082+
}
1083+
1084+
if (value) {
1085+
this._setupSearchController();
1086+
} else {
1087+
this._cleanupSearchController();
1088+
}
1089+
}
9521090
}

0 commit comments

Comments
 (0)