feat(android): ListView showSearch for search bar

This commit is contained in:
Nathan Walker
2025-08-03 18:45:13 -07:00
parent 8e81246b36
commit fb7a23e346

View File

@ -1,5 +1,5 @@
import { ItemEventData, ItemsSource } from '.'; import { ItemEventData, ItemsSource, SearchEventData } from '.';
import { ListViewBase, separatorColorProperty, itemTemplatesProperty, stickyHeaderProperty, stickyHeaderTemplateProperty, stickyHeaderHeightProperty, sectionedProperty } from './list-view-common'; import { ListViewBase, separatorColorProperty, itemTemplatesProperty, stickyHeaderProperty, stickyHeaderTemplateProperty, stickyHeaderHeightProperty, sectionedProperty, showSearchProperty } from './list-view-common';
import { View, KeyedTemplate } from '../core/view'; import { View, KeyedTemplate } from '../core/view';
import { unsetValue } from '../core/properties/property-shared'; import { unsetValue } from '../core/properties/property-shared';
import { CoreTypes } from '../../core-types'; import { CoreTypes } from '../../core-types';
@ -18,6 +18,7 @@ export * from './list-view-common';
const ITEMLOADING = ListViewBase.itemLoadingEvent; const ITEMLOADING = ListViewBase.itemLoadingEvent;
const LOADMOREITEMS = ListViewBase.loadMoreItemsEvent; const LOADMOREITEMS = ListViewBase.loadMoreItemsEvent;
const ITEMTAP = ListViewBase.itemTapEvent; const ITEMTAP = ListViewBase.itemTapEvent;
const SEARCHCHANGE = ListViewBase.searchChangeEvent;
// View type constants for sectioned lists // View type constants for sectioned lists
const ITEM_VIEW_TYPE = 0; const ITEM_VIEW_TYPE = 0;
@ -78,6 +79,14 @@ export class ListView extends ListViewBase {
private _scrollListener: android.widget.AbsListView.OnScrollListener; private _scrollListener: android.widget.AbsListView.OnScrollListener;
_hiddenHeaderPositions = new Set<number>(); // Track which headers to hide _hiddenHeaderPositions = new Set<number>(); // Track which headers to hide
// Search functionality
private _searchView: android.widget.SearchView;
private _searchListener: android.widget.SearchView.OnQueryTextListener;
public get hasSearchView(): boolean {
return !!this._searchView;
}
private _ensureAvailableViews(templateKey: string) { private _ensureAvailableViews(templateKey: string) {
if (!this._availableViews.has(templateKey)) { if (!this._availableViews.has(templateKey)) {
this._availableViews.set(templateKey, new Set()); this._availableViews.set(templateKey, new Set());
@ -161,6 +170,8 @@ export class ListView extends ListViewBase {
this._androidViewId = android.view.View.generateViewId(); this._androidViewId = android.view.View.generateViewId();
} }
nativeView.setId(this._androidViewId); nativeView.setId(this._androidViewId);
// Don't setup search here - wait for onLoaded when context is properly available
} }
public disposeNativeView() { public disposeNativeView() {
@ -173,6 +184,9 @@ export class ListView extends ListViewBase {
(<any>nativeView).adapter.owner = null; (<any>nativeView).adapter.owner = null;
} }
// Cleanup search
this._cleanupSearchView();
// Cleanup sticky header // Cleanup sticky header
this._cleanupStickyHeader(); this._cleanupStickyHeader();
@ -208,6 +222,11 @@ export class ListView extends ListViewBase {
if (this.stickyHeader && this.sectioned && this.stickyHeaderTemplate) { if (this.stickyHeader && this.sectioned && this.stickyHeaderTemplate) {
this._setupStickyHeader(); this._setupStickyHeader();
} }
// Setup search if enabled and not already set up
if (this.showSearch && !this._searchView && this.nativeViewProtected && this.nativeViewProtected.getAdapter()) {
this._setupSearchView();
}
} }
public refresh() { public refresh() {
@ -223,7 +242,16 @@ export class ListView extends ListViewBase {
} }
}); });
(<android.widget.BaseAdapter>nativeView.getAdapter()).notifyDataSetChanged(); // Safely refresh the adapter - no HeaderViewListAdapter issues since we don't use headers
const adapter = nativeView.getAdapter();
if (adapter instanceof android.widget.BaseAdapter) {
try {
adapter.notifyDataSetChanged();
} catch (error) {
console.log('Error refreshing adapter, recreating:', error);
nativeView.setAdapter(new ListViewAdapterClass(this));
}
}
} }
public scrollToIndex(index: number) { public scrollToIndex(index: number) {
@ -355,13 +383,47 @@ export class ListView extends ListViewBase {
this._stickyHeaderView.horizontalAlignment = 'stretch'; this._stickyHeaderView.horizontalAlignment = 'stretch';
// Add sticky header to the parent layout // Add sticky header to the parent layout
// Position it at the top, overlaying the ListView // If search view exists, position sticky header after it (index 1), otherwise at top (index 0)
this.parent._addView(this._stickyHeaderView); const parentLayout = this.parent;
const hasSearchView = this.showSearch && this._searchView && (this._searchView as any)._wrapper;
// Make sure it's positioned correctly if (parentLayout instanceof StackLayout) {
const insertIndex = hasSearchView ? 1 : 0;
parentLayout.insertChild(this._stickyHeaderView, insertIndex);
} else {
parentLayout._addView(this._stickyHeaderView);
}
// When search is enabled, position sticky header below search view with proper top margin
if (this.showSearch && this._searchView) {
// Add top margin to push sticky header below search view
this._stickyHeaderView.marginTop = 0; // Reset any previous margin
// Position sticky header with proper offset using native positioning
if (this._stickyHeaderView.nativeViewProtected) { if (this._stickyHeaderView.nativeViewProtected) {
// Bring to front
this._stickyHeaderView.nativeViewProtected.setZ(1000); this._stickyHeaderView.nativeViewProtected.setZ(1000);
// Use a timeout to ensure search view is measured first
setTimeout(() => {
if (this._searchView && (this._searchView as any)._wrapper) {
const searchWrapper = (this._searchView as any)._wrapper;
if (searchWrapper.nativeViewProtected) {
const searchHeight = searchWrapper.nativeViewProtected.getMeasuredHeight() || 50;
// Position sticky header below search view using translation
if (this._stickyHeaderView.nativeViewProtected) {
this._stickyHeaderView.nativeViewProtected.setTranslationY(searchHeight);
}
}
}
}, 100);
}
} else {
// No search view - position at top
if (this._stickyHeaderView.nativeViewProtected) {
this._stickyHeaderView.nativeViewProtected.setZ(1000);
this._stickyHeaderView.nativeViewProtected.setTranslationY(0);
}
} }
} }
@ -370,9 +432,21 @@ export class ListView extends ListViewBase {
return; return;
} }
// Apply immediate padding with a reasonable default to prevent content hiding // Calculate total top padding: search view height + sticky header height
let searchViewHeight = 0;
if (this.showSearch && this._searchView && (this._searchView as any)._wrapper) {
const searchWrapper = (this._searchView as any)._wrapper;
if (searchWrapper.nativeViewProtected && searchWrapper.nativeViewProtected.getMeasuredHeight() > 0) {
searchViewHeight = searchWrapper.nativeViewProtected.getMeasuredHeight();
} else {
searchViewHeight = 50; // Default search view height
}
}
// Apply immediate padding with defaults to prevent content hiding
const defaultHeaderHeight = 50; // Reasonable default height in dp const defaultHeaderHeight = 50; // Reasonable default height in dp
this.nativeViewProtected.setPadding(0, defaultHeaderHeight, 0, 0); const totalPadding = searchViewHeight + defaultHeaderHeight;
this.nativeViewProtected.setPadding(0, totalPadding, 0, 0);
this._stickyHeaderHeight = defaultHeaderHeight; this._stickyHeaderHeight = defaultHeaderHeight;
// Request layout to ensure proper measurement // Request layout to ensure proper measurement
@ -384,18 +458,26 @@ export class ListView extends ListViewBase {
// Get the actual measured height from the native view // Get the actual measured height from the native view
const nativeView = this._stickyHeaderView.nativeViewProtected; const nativeView = this._stickyHeaderView.nativeViewProtected;
if (nativeView && nativeView.getMeasuredHeight() > 0) { if (nativeView && nativeView.getMeasuredHeight() > 0) {
const measuredHeight = nativeView.getMeasuredHeight(); const measuredHeaderHeight = nativeView.getMeasuredHeight();
const paddingHeight = measuredHeight + 4; let finalSearchHeight = searchViewHeight;
// Only update if significantly different // Re-measure search view if needed
if (Math.abs(paddingHeight - this._stickyHeaderHeight) > 5) { if (this.showSearch && this._searchView && (this._searchView as any)._wrapper) {
this._stickyHeaderHeight = paddingHeight; const searchWrapper = (this._searchView as any)._wrapper;
this.nativeViewProtected.setPadding(0, paddingHeight, 0, 0); if (searchWrapper.nativeViewProtected && searchWrapper.nativeViewProtected.getMeasuredHeight() > 0) {
finalSearchHeight = searchWrapper.nativeViewProtected.getMeasuredHeight();
} }
}
// Calculate final padding: search height + sticky header height + small buffer
const totalPaddingHeight = finalSearchHeight + measuredHeaderHeight + 4;
this._stickyHeaderHeight = measuredHeaderHeight;
this.nativeViewProtected.setPadding(0, totalPaddingHeight, 0, 0);
this.scrollToIndex(0); this.scrollToIndex(0);
} }
} }
}, 100); // Slightly longer delay for more reliable measurement }, 150); // Slightly longer delay for more reliable measurement after positioning
} }
private _setupScrollListener() { private _setupScrollListener() {
@ -521,9 +603,11 @@ export class ListView extends ListViewBase {
this._itemTemplatesInternal = this._itemTemplatesInternal.concat(value); this._itemTemplatesInternal = this._itemTemplatesInternal.concat(value);
} }
if (this.nativeViewProtected) {
this.nativeViewProtected.setAdapter(new ListViewAdapterClass(this)); this.nativeViewProtected.setAdapter(new ListViewAdapterClass(this));
this.refresh(); this.refresh();
} }
}
// Sticky header property handlers // Sticky header property handlers
[stickyHeaderProperty.setNative](value: boolean) { [stickyHeaderProperty.setNative](value: boolean) {
@ -566,6 +650,183 @@ export class ListView extends ListViewBase {
this._cleanupStickyHeader(); this._cleanupStickyHeader();
} }
} }
// Search methods
private _setupSearchView() {
if (this._searchView || !this.showSearch || !this.nativeViewProtected) {
return;
}
// Create SearchView using the ListView's context
this._searchView = new android.widget.SearchView(this.nativeViewProtected.getContext());
this._searchView.setQueryHint('Search...');
this._searchView.setIconifiedByDefault(false);
this._searchView.setSubmitButtonEnabled(false);
// Setup search listener
const owner = this;
this._searchListener = new android.widget.SearchView.OnQueryTextListener({
onQueryTextChange(newText: string): boolean {
const args: SearchEventData = {
eventName: SEARCHCHANGE,
object: owner,
text: newText,
android: owner._searchView,
};
owner.notify(args);
return true;
},
onQueryTextSubmit(query: string): boolean {
const args: SearchEventData = {
eventName: SEARCHCHANGE,
object: owner,
text: query,
android: owner._searchView,
};
owner.notify(args);
return true;
},
});
this._searchView.setOnQueryTextListener(this._searchListener);
// Add search view to the parent container above the ListView
this._addSearchToParent();
// Add padding to ListView if no sticky header (otherwise sticky header method handles it)
if (!this.stickyHeader || !this._stickyHeaderView) {
this._addSearchPadding();
}
}
private _addSearchPadding() {
if (!this._searchView) {
return;
}
// Add basic padding for search view
const defaultSearchHeight = 50; // Default search view height
this.nativeViewProtected.setPadding(0, defaultSearchHeight, 0, 0);
// Measure and adjust if needed
setTimeout(() => {
if (this._searchView && (this._searchView as any)._wrapper) {
const searchWrapper = (this._searchView as any)._wrapper;
if (searchWrapper.nativeViewProtected && searchWrapper.nativeViewProtected.getMeasuredHeight() > 0) {
const measuredHeight = searchWrapper.nativeViewProtected.getMeasuredHeight();
this.nativeViewProtected.setPadding(0, measuredHeight + 4, 0, 0);
}
}
}, 100);
}
private _addSearchToParent() {
if (!this._searchView || !this.parent) {
return;
}
// Get the parent layout
const parentLayout = this.parent;
// Create a simple NativeScript wrapper for the native SearchView
const searchView = this._searchView;
const searchViewWrapper = new (class extends View {
createNativeView() {
return searchView;
}
})();
// Set layout properties - ensure it's at the top
searchViewWrapper.height = 'auto';
searchViewWrapper.width = { unit: '%', value: 100 };
searchViewWrapper.verticalAlignment = 'top';
searchViewWrapper.horizontalAlignment = 'stretch';
// Always insert at position 0 (top) regardless of ListView position
if (parentLayout instanceof StackLayout) {
parentLayout.insertChild(searchViewWrapper, 0);
} else {
// For other layout types, add as first child
parentLayout._addView(searchViewWrapper);
}
// Ensure search view appears above everything else
if (searchViewWrapper.nativeViewProtected) {
searchViewWrapper.nativeViewProtected.setZ(2000); // Higher than sticky header (1000)
}
// Store reference for cleanup
(this._searchView as any)._wrapper = searchViewWrapper;
}
private _cleanupSearchView() {
if (this._searchView) {
// Remove search view wrapper from parent
const wrapper = (this._searchView as any)._wrapper;
if (wrapper && wrapper.parent) {
wrapper.parent._removeView(wrapper);
}
// Clear listener
if (this._searchListener) {
this._searchView.setOnQueryTextListener(null);
this._searchListener = null;
}
this._searchView = null;
// Reset ListView padding if no sticky header
if (!this.stickyHeader || !this._stickyHeaderView) {
this.nativeViewProtected.setPadding(0, 0, 0, 0);
}
}
}
[showSearchProperty.setNative](value: boolean) {
if (value) {
if (this.isLoaded && this.nativeViewProtected && this.nativeViewProtected.getAdapter()) {
this._setupSearchView();
// Reposition sticky header if it exists
if (this._stickyHeaderView) {
this._repositionStickyHeader();
}
}
} else {
this._cleanupSearchView();
// Reposition sticky header if it exists
if (this._stickyHeaderView) {
this._repositionStickyHeader();
}
}
}
private _repositionStickyHeader() {
if (!this._stickyHeaderView || !this._stickyHeaderView.nativeViewProtected) {
return;
}
// Reset positioning
this._stickyHeaderView.nativeViewProtected.setTranslationY(0);
// If search is enabled, position below search view
if (this.showSearch && this._searchView && (this._searchView as any)._wrapper) {
setTimeout(() => {
const searchWrapper = (this._searchView as any)._wrapper;
if (searchWrapper.nativeViewProtected) {
const searchHeight = searchWrapper.nativeViewProtected.getMeasuredHeight() || 50;
this._stickyHeaderView.nativeViewProtected.setTranslationY(searchHeight);
}
}, 100);
}
// Update ListView padding
if (this.stickyHeader && this._stickyHeaderView) {
this._addListViewPadding();
}
}
} }
let ListViewAdapterClass; let ListViewAdapterClass;
@ -587,18 +848,26 @@ function ensureListViewAdapterClass() {
return 0; return 0;
} }
// Ensure we always have at least the items array length, even if empty
let count = 0;
if (this.owner.sectioned) { if (this.owner.sectioned) {
// Count items + section headers // Count items + section headers
let totalCount = 0;
const sectionCount = this.owner._getSectionCount(); const sectionCount = this.owner._getSectionCount();
for (let i = 0; i < sectionCount; i++) { for (let i = 0; i < sectionCount; i++) {
totalCount += 1; // Section header const itemsInSection = this.owner._getItemsInSection(i);
totalCount += this.owner._getItemsInSection(i).length; // Items in section // Only add header if section has items or we want to show empty sections
if (itemsInSection.length > 0) {
count += 1; // Section header
count += itemsInSection.length; // Items in section
}
} }
return totalCount;
} else { } else {
return this.owner.items.length; count = this.owner.items.length;
} }
// Return the count, ensuring it's never negative
return Math.max(0, count);
} }
public getItem(i: number) { public getItem(i: number) {
@ -606,6 +875,12 @@ function ensureListViewAdapterClass() {
return null; return null;
} }
// Safety check for index bounds
const totalCount = this.getCount();
if (i < 0 || i >= totalCount) {
return null;
}
if (this.owner.sectioned) { if (this.owner.sectioned) {
const positionInfo = this._getPositionInfo(i); const positionInfo = this._getPositionInfo(i);
if (positionInfo.isHeader) { if (positionInfo.isHeader) {
@ -633,6 +908,13 @@ function ensureListViewAdapterClass() {
const sectionCount = this.owner._getSectionCount(); const sectionCount = this.owner._getSectionCount();
for (let section = 0; section < sectionCount; section++) { for (let section = 0; section < sectionCount; section++) {
const itemsInSection = this.owner._getItemsInSection(section);
// Skip sections with no items (they won't have headers in our count)
if (itemsInSection.length === 0) {
continue;
}
// Check if this position is the section header // Check if this position is the section header
if (currentPosition === position) { if (currentPosition === position) {
return { isHeader: true, section: section, itemIndex: -1 }; return { isHeader: true, section: section, itemIndex: -1 };
@ -640,15 +922,14 @@ function ensureListViewAdapterClass() {
currentPosition++; // Move past header currentPosition++; // Move past header
// Check if position is within this section's items // Check if position is within this section's items
const itemsInSection = this.owner._getItemsInSection(section).length; if (position < currentPosition + itemsInSection.length) {
if (position < currentPosition + itemsInSection) {
const itemIndex = position - currentPosition; const itemIndex = position - currentPosition;
return { isHeader: false, section: section, itemIndex: itemIndex }; return { isHeader: false, section: section, itemIndex: itemIndex };
} }
currentPosition += itemsInSection; // Move past items currentPosition += itemsInSection.length; // Move past items
} }
// Fallback // Fallback - should not reach here with proper bounds checking
return { isHeader: false, section: 0, itemIndex: 0 }; return { isHeader: false, section: 0, itemIndex: 0 };
} }
@ -666,6 +947,23 @@ function ensureListViewAdapterClass() {
return true; return true;
} }
public isEnabled(position: number): boolean {
// Safety check to prevent crashes when adapter is empty
const totalCount = this.getCount();
if (totalCount === 0 || position < 0 || position >= totalCount) {
return false;
}
// For sectioned lists, check if this is a header position
if (this.owner.sectioned) {
const positionInfo = this._getPositionInfo(position);
// Headers are typically not clickable, items are
return !positionInfo.isHeader;
}
return true;
}
public getViewTypeCount() { public getViewTypeCount() {
let count = this.owner._itemTemplatesInternal.length; let count = this.owner._itemTemplatesInternal.length;
@ -702,6 +1000,16 @@ function ensureListViewAdapterClass() {
return null; return null;
} }
// Safety check for empty adapter
const totalCount = this.getCount();
if (index < 0 || index >= totalCount) {
// Return a minimal empty view to prevent crashes
const emptyView = new android.view.View(this.owner._context);
const layoutParams = new android.view.ViewGroup.LayoutParams(android.view.ViewGroup.LayoutParams.MATCH_PARENT, 0);
emptyView.setLayoutParams(layoutParams);
return emptyView;
}
if (this.owner.sectioned) { if (this.owner.sectioned) {
const positionInfo = this._getPositionInfo(index); const positionInfo = this._getPositionInfo(index);