mirror of
https://github.com/NativeScript/NativeScript.git
synced 2025-11-05 13:26:48 +08:00
feat(ios): ListView showSearch for built-in search behavior
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Observable, Dialogs, DialogStrings, View, EventData } from '@nativescript/core';
|
||||
|
||||
import { Observable, Dialogs, DialogStrings, View, EventData, SearchEventData } from '@nativescript/core';
|
||||
type CountryListType = Array<{ title: string; items: Array<{ name: string; code: string; flag: string; isVisible?: boolean }> }>;
|
||||
export class ListPageModel extends Observable {
|
||||
components: Array<any> = [
|
||||
countries: CountryListType = [
|
||||
{
|
||||
title: 'A',
|
||||
items: [
|
||||
@@ -1373,9 +1373,10 @@ export class ListPageModel extends Observable {
|
||||
],
|
||||
},
|
||||
];
|
||||
private _originalCountries: CountryListType;
|
||||
|
||||
selectItemTemplate(item: any, index: number, items: Array<any>) {
|
||||
return index == items.length - 1 ? 'last' : 'not-last';
|
||||
return 'main'; // index == items.length - 1 ? 'last' : 'not-last';
|
||||
}
|
||||
|
||||
componentsItemTap(args): void {
|
||||
@@ -1389,4 +1390,40 @@ export class ListPageModel extends Observable {
|
||||
itemLoading(args: EventData): void {
|
||||
(args.object as View).backgroundColor = 'transparent';
|
||||
}
|
||||
|
||||
onSearchTextChange(evt: SearchEventData): void {
|
||||
if (!this._originalCountries) {
|
||||
this._originalCountries = this.countries;
|
||||
}
|
||||
const searchText = evt.text.toLowerCase();
|
||||
console.log('Search text:', searchText);
|
||||
if (searchText) {
|
||||
this.countries = this.filterCountryGroups(this._originalCountries, searchText);
|
||||
} else {
|
||||
this.countries = this._originalCountries; // reset to original if no search text
|
||||
}
|
||||
this.notifyPropertyChange('countries', this.countries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a grouped array of countries by search query.
|
||||
* @param {Array<{ title: string; items: { name: string; code: string; flag: string; }[] }>} groups
|
||||
* @param {string} query
|
||||
* @returns Filtered groups with the same shape, omitting any with no matches.
|
||||
*/
|
||||
filterCountryGroups(groups: CountryListType, query: string): CountryListType {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return groups; // no query → all groups
|
||||
|
||||
return (
|
||||
groups
|
||||
.map((group) => {
|
||||
// keep only items whose name includes the query
|
||||
const items = group.items.filter((item) => item.name.toLowerCase().includes(q));
|
||||
return { ...group, items };
|
||||
})
|
||||
// drop any group that ended up with 0 items
|
||||
.filter((group) => group.items.length > 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
</Page.actionBar>
|
||||
|
||||
<GridLayout backgroundColor="#efefef">
|
||||
<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 }}">
|
||||
<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 }}"
|
||||
showSearch="true"
|
||||
searchChange="{{ onSearchTextChange }}">
|
||||
<ListView.itemTemplates>
|
||||
<template key="not-last">
|
||||
<template key="main">
|
||||
<!-- <StackLayout class="list-row-item"> -->
|
||||
<GridLayout columns="auto,auto,*" padding="12" margin="4 6 4 6" borderRadius="4" backgroundColor="#fff">
|
||||
<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)">
|
||||
<!-- <FlexboxLayout flexDirection="row" class="list-view-row" verticalAlignment="center"> -->
|
||||
<!-- <visionos>
|
||||
<Label text="{{ iconText }}" class="icon-around icon-label"/>
|
||||
@@ -24,8 +26,8 @@
|
||||
</StackLayout>
|
||||
</android> -->
|
||||
<!-- <StackLayout class="va-middle"> -->
|
||||
<Label class="component-select component-select-fix" text="{{ flag }}" marginLeft="4"></Label>
|
||||
<Label col="1" text="{{ name }}" marginLeft="6"></Label>
|
||||
<Label color="black" text="{{ flag }}" marginLeft="4"></Label>
|
||||
<Label col="1" text="{{ name }}" marginLeft="6" color="black"></Label>
|
||||
<Label col="2" text="{{ code }}" marginLeft="4" color="#999"></Label>
|
||||
<!-- </StackLayout> -->
|
||||
<!-- </FlexboxLayout> -->
|
||||
@@ -34,31 +36,6 @@
|
||||
<!-- <StackLayout class="listview-separator"/> -->
|
||||
<!-- </StackLayout> -->
|
||||
</template>
|
||||
<template key="last">
|
||||
<!-- <StackLayout class="list-row-item"> -->
|
||||
<GridLayout columns="auto,auto,*" padding="12" margin="4 6 4 6" borderRadius="4" backgroundColor="#fff">
|
||||
<!-- <FlexboxLayout flexDirection="row" class="list-view-row" verticalAlignment="center"> -->
|
||||
<!-- <visionos>
|
||||
<Label text="{{ iconText }}" class="icon-around icon-label"/>
|
||||
</visionos>
|
||||
<ios>
|
||||
<Label text="{{ iconText }}" class="icon-around icon-label"/>
|
||||
</ios>
|
||||
<android>
|
||||
<StackLayout class="icon-around">
|
||||
<Label text="{{ iconText }}" class="icon-label"/>
|
||||
</StackLayout>
|
||||
</android> -->
|
||||
<!-- <StackLayout class="va-middle"> -->
|
||||
<Label class="component-select component-select-fix" text="{{ flag }}" marginLeft="4"></Label>
|
||||
<Label col="1" text="{{ name }}" marginLeft="6"></Label>
|
||||
<Label col="2" text="{{ code }}" marginLeft="4" color="#999"></Label>
|
||||
<!-- </StackLayout>
|
||||
</FlexboxLayout> -->
|
||||
|
||||
</GridLayout>
|
||||
<!-- </StackLayout> -->
|
||||
</template>
|
||||
</ListView.itemTemplates>
|
||||
</ListView>
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export * from './layouts'; // barrel export
|
||||
|
||||
export { ListPicker } from './list-picker';
|
||||
export { ListView } from './list-view';
|
||||
export type { ItemEventData, TemplatedItemsView, ItemsSource } from './list-view';
|
||||
export type { ItemEventData, TemplatedItemsView, ItemsSource, SearchEventData } from './list-view';
|
||||
export { Page, PageBase } from './page';
|
||||
export type { NavigatedData } from './page';
|
||||
export { Placeholder } from './placeholder';
|
||||
|
||||
44
packages/core/ui/list-view/index.d.ts
vendored
44
packages/core/ui/list-view/index.d.ts
vendored
@@ -30,6 +30,12 @@ export class ListView extends View {
|
||||
* @nsEvent itemLoading
|
||||
*/
|
||||
public static loadMoreItemsEvent: string;
|
||||
/**
|
||||
* String value used when hooking to searchChange event.
|
||||
*
|
||||
* @nsEvent {SearchEventData} searchChange
|
||||
*/
|
||||
public static searchChangeEvent: string;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
*/
|
||||
sectioned: boolean;
|
||||
|
||||
/**
|
||||
* Gets or sets a value indicating whether the ListView should show a search bar.
|
||||
* When enabled on iOS, uses native UISearchController for optimal performance.
|
||||
*
|
||||
* @nsProperty
|
||||
*/
|
||||
showSearch: boolean;
|
||||
|
||||
/**
|
||||
* Forces the ListView to reload all its items.
|
||||
*/
|
||||
@@ -195,6 +209,11 @@ export class ListView extends View {
|
||||
* Raised when the ListView is scrolled so that its last item is visible.
|
||||
*/
|
||||
on(event: 'loadMoreItems', callback: (args: EventData) => void, thisArg?: any): void;
|
||||
|
||||
/**
|
||||
* Raised when the search text in the search bar changes.
|
||||
*/
|
||||
on(event: 'searchChange', callback: (args: SearchEventData) => void, thisArg?: any): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,6 +241,26 @@ export interface ItemEventData extends EventData {
|
||||
android: any /* android.view.ViewGroup */;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data containing information for the search text change event.
|
||||
*/
|
||||
export interface SearchEventData extends EventData {
|
||||
/**
|
||||
* The current search text value.
|
||||
*/
|
||||
text: string;
|
||||
|
||||
/**
|
||||
* Gets the native [iOS UISearchController](https://developer.apple.com/documentation/uikit/uisearchcontroller) that represents the search controller. Valid only when running on iOS.
|
||||
*/
|
||||
ios?: any /* UISearchController */;
|
||||
|
||||
/**
|
||||
* Gets the native Android search view. Valid only when running on Android OS.
|
||||
*/
|
||||
android?: any;
|
||||
}
|
||||
|
||||
export interface ItemsSource {
|
||||
length: number;
|
||||
getItem(index: number): any;
|
||||
@@ -295,3 +334,8 @@ export const stickyHeaderTopPaddingProperty: Property<ListView, boolean>;
|
||||
* Represents the observable property backing the sectioned property of each ListView instance.
|
||||
*/
|
||||
export const sectionedProperty: Property<ListView, boolean>;
|
||||
|
||||
/**
|
||||
* Represents the observable property backing the showSearch property of each ListView instance.
|
||||
*/
|
||||
export const showSearchProperty: Property<ListView, boolean>;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ItemEventData } from '.';
|
||||
import { ListViewBase, separatorColorProperty, itemTemplatesProperty, iosEstimatedRowHeightProperty, stickyHeaderProperty, stickyHeaderTemplateProperty, stickyHeaderHeightProperty, sectionedProperty } from './list-view-common';
|
||||
import { ItemEventData, SearchEventData, ItemsSource } from '.';
|
||||
import { ListViewBase, separatorColorProperty, itemTemplatesProperty, iosEstimatedRowHeightProperty, stickyHeaderProperty, stickyHeaderTemplateProperty, stickyHeaderHeightProperty, sectionedProperty, showSearchProperty } from './list-view-common';
|
||||
import { CoreTypes } from '../../core-types';
|
||||
import { View, KeyedTemplate, Template } from '../core/view';
|
||||
import { Length } from '../styling/length-shared';
|
||||
import { Observable, EventData } from '../../data/observable';
|
||||
import { Color } from '../../color';
|
||||
import { layout } from '../../utils';
|
||||
import { SDK_VERSION } from '../../utils/constants';
|
||||
import { StackLayout } from '../layouts/stack-layout';
|
||||
import { ProxyViewContainer } from '../proxy-view-container';
|
||||
import { profile } from '../../profiling';
|
||||
@@ -393,6 +394,43 @@ class UITableViewRowHeightDelegateImpl extends NSObject implements UITableViewDe
|
||||
}
|
||||
}
|
||||
|
||||
@NativeClass
|
||||
class UISearchResultsUpdatingImpl extends NSObject implements UISearchResultsUpdating {
|
||||
public static ObjCProtocols = [UISearchResultsUpdating];
|
||||
|
||||
private _owner: WeakRef<ListView>;
|
||||
|
||||
public static initWithOwner(owner: WeakRef<ListView>): UISearchResultsUpdatingImpl {
|
||||
const handler = <UISearchResultsUpdatingImpl>UISearchResultsUpdatingImpl.new();
|
||||
handler._owner = owner;
|
||||
|
||||
return handler;
|
||||
}
|
||||
|
||||
public updateSearchResultsForSearchController(searchController: UISearchController) {
|
||||
const owner = this._owner ? this._owner.get() : null;
|
||||
if (!owner) {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchText = searchController.searchBar.text || '';
|
||||
|
||||
// Track search state
|
||||
owner._isSearchActive = searchController.active;
|
||||
|
||||
// Create SearchEventData
|
||||
const eventData: SearchEventData = {
|
||||
eventName: ListViewBase.searchChangeEvent,
|
||||
object: owner,
|
||||
text: searchText,
|
||||
ios: searchController,
|
||||
};
|
||||
|
||||
// Fire the searchChange event
|
||||
owner.notify(eventData);
|
||||
}
|
||||
}
|
||||
|
||||
export class ListView extends ListViewBase {
|
||||
public nativeViewProtected: UITableView;
|
||||
// tslint:disable-next-line
|
||||
@@ -405,6 +443,9 @@ export class ListView extends ListViewBase {
|
||||
private _headerMap: Map<ListViewHeaderCell, View>;
|
||||
private _preparingHeader: boolean;
|
||||
private _headerTemplateCache: View;
|
||||
private _searchController: UISearchController;
|
||||
private _searchDelegate: UISearchResultsUpdatingImpl;
|
||||
_isSearchActive: boolean = false;
|
||||
widthMeasureSpec = 0;
|
||||
|
||||
constructor() {
|
||||
@@ -440,11 +481,88 @@ export class ListView extends ListViewBase {
|
||||
}
|
||||
|
||||
disposeNativeView() {
|
||||
this._cleanupSearchController();
|
||||
this._delegate = null;
|
||||
this._dataSource = null;
|
||||
super.disposeNativeView();
|
||||
}
|
||||
|
||||
private _setupSearchController() {
|
||||
if (!this.showSearch || this._searchController) {
|
||||
return; // Already setup or not needed
|
||||
}
|
||||
|
||||
// 1. Create UISearchController with nil (show results in this table)
|
||||
this._searchController = UISearchController.alloc().initWithSearchResultsController(null);
|
||||
this._searchDelegate = UISearchResultsUpdatingImpl.initWithOwner(new WeakRef(this));
|
||||
|
||||
// 2. Tell it who will update results
|
||||
this._searchController.searchResultsUpdater = this._searchDelegate;
|
||||
|
||||
// 3. Don't dim or obscure your table by default
|
||||
this._searchController.obscuresBackgroundDuringPresentation = false;
|
||||
|
||||
// 4. Placeholder text
|
||||
this._searchController.searchBar.placeholder = 'Search';
|
||||
this._searchController.searchBar.searchBarStyle = UISearchBarStyle.Minimal;
|
||||
|
||||
// 5. CRITICAL: Make sure the search bar doesn't remain on screen if the user navigates
|
||||
const viewController = this._getViewController();
|
||||
if (viewController) {
|
||||
viewController.definesPresentationContext = true;
|
||||
|
||||
// 6a. If we're in a UINavigationController...
|
||||
if (SDK_VERSION >= 11.0 && viewController.navigationItem) {
|
||||
viewController.navigationItem.searchController = this._searchController;
|
||||
viewController.navigationItem.hidesSearchBarWhenScrolling = false;
|
||||
} else {
|
||||
// 6b. Or just put it at the top of our table
|
||||
this.nativeViewProtected.tableHeaderView = this._searchController.searchBar;
|
||||
}
|
||||
} else {
|
||||
// Fallback: no view controller found, use table header
|
||||
this.nativeViewProtected.tableHeaderView = this._searchController.searchBar;
|
||||
}
|
||||
|
||||
// Ensure search bar is properly sized
|
||||
this._searchController.searchBar.sizeToFit();
|
||||
|
||||
if (Trace.isEnabled()) {
|
||||
Trace.write(`ListView: UISearchController setup complete`, Trace.categories.Debug);
|
||||
}
|
||||
}
|
||||
|
||||
private _cleanupSearchController() {
|
||||
if (!this._searchController) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove search controller from navigation item or table header
|
||||
const viewController = this._getViewController();
|
||||
if (viewController && viewController.navigationItem && viewController.navigationItem.searchController === this._searchController) {
|
||||
viewController.navigationItem.searchController = null;
|
||||
} else if (this.nativeViewProtected.tableHeaderView === this._searchController.searchBar) {
|
||||
this.nativeViewProtected.tableHeaderView = null;
|
||||
}
|
||||
|
||||
// Cleanup references
|
||||
this._searchController.searchResultsUpdater = null;
|
||||
this._searchController = null;
|
||||
this._searchDelegate = null;
|
||||
}
|
||||
|
||||
private _getViewController(): UIViewController {
|
||||
// Helper to get the current view controller
|
||||
let parent = this.parent;
|
||||
while (parent) {
|
||||
if (parent.viewController) {
|
||||
return parent.viewController;
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_setNativeClipToBounds() {
|
||||
// Always set clipsToBounds for list-view
|
||||
const view = this.nativeViewProtected;
|
||||
@@ -460,6 +578,11 @@ export class ListView extends ListViewBase {
|
||||
this.refresh();
|
||||
}
|
||||
this.nativeViewProtected.delegate = this._delegate;
|
||||
|
||||
// Setup search controller if enabled
|
||||
if (this.showSearch) {
|
||||
this._setupSearchController();
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
@@ -949,4 +1072,19 @@ export class ListView extends ListViewBase {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
[showSearchProperty.getDefault](): boolean {
|
||||
return false;
|
||||
}
|
||||
[showSearchProperty.setNative](value: boolean) {
|
||||
if (Trace.isEnabled()) {
|
||||
Trace.write(`ListView: showSearch set to ${value}`, Trace.categories.Debug);
|
||||
}
|
||||
|
||||
if (value) {
|
||||
this._setupSearchController();
|
||||
} else {
|
||||
this._cleanupSearchController();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ListView as ListViewDefinition, ItemsSource, ItemEventData, TemplatedItemsView } from '.';
|
||||
import { ListView as ListViewDefinition, ItemsSource, ItemEventData, TemplatedItemsView, SearchEventData } from '.';
|
||||
import { View, ContainerView, Template, KeyedTemplate, CSSType } from '../core/view';
|
||||
import { Property, CoercibleProperty, CssProperty } from '../core/properties';
|
||||
import { Length } from '../styling/length-shared';
|
||||
@@ -21,6 +21,7 @@ export abstract class ListViewBase extends ContainerView implements ListViewDefi
|
||||
public static itemLoadingEvent = 'itemLoading';
|
||||
public static itemTapEvent = 'itemTap';
|
||||
public static loadMoreItemsEvent = 'loadMoreItems';
|
||||
public static searchChangeEvent = 'searchChange';
|
||||
// TODO: get rid of such hacks.
|
||||
public static knownFunctions = ['itemTemplateSelector', 'itemIdGenerator']; //See component-builder.ts isKnownFunction
|
||||
|
||||
@@ -56,6 +57,7 @@ export abstract class ListViewBase extends ContainerView implements ListViewDefi
|
||||
public stickyHeaderHeight: CoreTypes.LengthType;
|
||||
public stickyHeaderTopPadding: boolean;
|
||||
public sectioned: boolean;
|
||||
public showSearch: boolean;
|
||||
|
||||
get separatorColor(): Color {
|
||||
return this.style.separatorColor;
|
||||
@@ -217,6 +219,7 @@ export interface ListViewBase {
|
||||
on(event: 'itemLoading', callback: (args: ItemEventData) => void, thisArg?: any): void;
|
||||
on(event: 'itemTap', callback: (args: ItemEventData) => void, thisArg?: any): void;
|
||||
on(event: 'loadMoreItems', callback: (args: EventData) => void, thisArg?: any): void;
|
||||
on(event: 'searchChange', callback: (args: SearchEventData) => void, thisArg?: any): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,3 +342,10 @@ export const sectionedProperty = new Property<ListViewBase, boolean>({
|
||||
valueConverter: (v) => !!v,
|
||||
});
|
||||
sectionedProperty.register(ListViewBase);
|
||||
|
||||
export const showSearchProperty = new Property<ListViewBase, boolean>({
|
||||
name: 'showSearch',
|
||||
defaultValue: false,
|
||||
valueConverter: booleanConverter,
|
||||
});
|
||||
showSearchProperty.register(ListViewBase);
|
||||
|
||||
Reference in New Issue
Block a user