feat(ios): ListView showSearch for built-in search behavior

This commit is contained in:
Nathan Walker
2025-08-03 12:04:53 -07:00
parent 6e17818aea
commit 8e81246b36
6 changed files with 244 additions and 38 deletions

View File

@@ -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';

View File

@@ -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>;

View File

@@ -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();
}
}
}

View File

@@ -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);