feat(android): ListView sticky header options 1

This commit is contained in:
Nathan Walker
2025-08-02 19:53:41 -07:00
parent 783f8ed691
commit 4b51e0b558

View File

@@ -1,5 +1,5 @@
import { ItemEventData, ItemsSource } from '.'; import { ItemEventData, ItemsSource } from '.';
import { ListViewBase, separatorColorProperty, itemTemplatesProperty } from './list-view-common'; import { ListViewBase, separatorColorProperty, itemTemplatesProperty, stickyHeaderProperty, stickyHeaderTemplateProperty, stickyHeaderHeightProperty, sectionedProperty } 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';
@@ -9,6 +9,9 @@ import { StackLayout } from '../layouts/stack-layout';
import { ProxyViewContainer } from '../proxy-view-container'; import { ProxyViewContainer } from '../proxy-view-container';
import { LayoutBase } from '../layouts/layout-base'; import { LayoutBase } from '../layouts/layout-base';
import { profile } from '../../profiling'; import { profile } from '../../profiling';
import { Builder } from '../builder';
import { Template } from '../core/view';
import { Label } from '../label';
export * from './list-view-common'; export * from './list-view-common';
@@ -16,6 +19,10 @@ const ITEMLOADING = ListViewBase.itemLoadingEvent;
const LOADMOREITEMS = ListViewBase.loadMoreItemsEvent; const LOADMOREITEMS = ListViewBase.loadMoreItemsEvent;
const ITEMTAP = ListViewBase.itemTapEvent; const ITEMTAP = ListViewBase.itemTapEvent;
// View type constants for sectioned lists
const ITEM_VIEW_TYPE = 0;
// HEADER_VIEW_TYPE will be dynamically calculated as the last index
interface ItemClickListener { interface ItemClickListener {
new (owner: ListView): android.widget.AdapterView.OnItemClickListener; new (owner: ListView): android.widget.AdapterView.OnItemClickListener;
} }
@@ -293,6 +300,28 @@ export class ListView extends ListViewBase {
this.nativeViewProtected.setAdapter(new ListViewAdapterClass(this)); this.nativeViewProtected.setAdapter(new ListViewAdapterClass(this));
this.refresh(); this.refresh();
} }
// Sticky header property handlers (for now just trigger refresh)
[stickyHeaderProperty.setNative](value: boolean) {
// Refresh adapter to handle sectioned vs non-sectioned display
if (this.nativeViewProtected && this.nativeViewProtected.getAdapter()) {
this.nativeViewProtected.setAdapter(new ListViewAdapterClass(this));
}
}
[stickyHeaderTemplateProperty.setNative](value: string) {
// Refresh adapter when template changes
if (this.nativeViewProtected && this.nativeViewProtected.getAdapter()) {
this.nativeViewProtected.setAdapter(new ListViewAdapterClass(this));
}
}
[sectionedProperty.setNative](value: boolean) {
// Refresh adapter to handle sectioned vs non-sectioned data
if (this.nativeViewProtected && this.nativeViewProtected.getAdapter()) {
this.nativeViewProtected.setAdapter(new ListViewAdapterClass(this));
}
}
} }
let ListViewAdapterClass; let ListViewAdapterClass;
@@ -310,19 +339,75 @@ function ensureListViewAdapterClass() {
} }
public getCount() { public getCount() {
return this.owner && this.owner.items && this.owner.items.length ? this.owner.items.length : 0; if (!this.owner || !this.owner.items) {
return 0;
}
if (this.owner.sectioned) {
// Count items + section headers
let totalCount = 0;
const sectionCount = this.owner._getSectionCount();
for (let i = 0; i < sectionCount; i++) {
totalCount += 1; // Section header
totalCount += this.owner._getItemsInSection(i).length; // Items in section
}
return totalCount;
} else {
return this.owner.items.length;
}
} }
public getItem(i: number) { public getItem(i: number) {
if (this.owner && this.owner.items && i < this.owner.items.length) { if (!this.owner || !this.owner.items) {
const getItem = (<ItemsSource>this.owner.items).getItem; return null;
}
return getItem ? getItem.call(this.owner.items, i) : this.owner.items[i]; if (this.owner.sectioned) {
const positionInfo = this._getPositionInfo(i);
if (positionInfo.isHeader) {
return this.owner._getSectionData(positionInfo.section);
} else {
return this.owner._getDataItemInSection(positionInfo.section, positionInfo.itemIndex);
}
} else {
if (i < this.owner.items.length) {
const getItem = (<ItemsSource>this.owner.items).getItem;
return getItem ? getItem.call(this.owner.items, i) : this.owner.items[i];
}
} }
return null; return null;
} }
// Helper method to determine if position is header and get section/item info
private _getPositionInfo(position: number): { isHeader: boolean; section: number; itemIndex: number } {
if (!this.owner.sectioned) {
return { isHeader: false, section: 0, itemIndex: position };
}
let currentPosition = 0;
const sectionCount = this.owner._getSectionCount();
for (let section = 0; section < sectionCount; section++) {
// Check if this position is the section header
if (currentPosition === position) {
return { isHeader: true, section: section, itemIndex: -1 };
}
currentPosition++; // Move past header
// Check if position is within this section's items
const itemsInSection = this.owner._getItemsInSection(section).length;
if (position < currentPosition + itemsInSection) {
const itemIndex = position - currentPosition;
return { isHeader: false, section: section, itemIndex: itemIndex };
}
currentPosition += itemsInSection; // Move past items
}
// Fallback
return { isHeader: false, section: 0, itemIndex: 0 };
}
public getItemId(i: number) { public getItemId(i: number) {
const item = this.getItem(i); const item = this.getItem(i);
let id = i; let id = i;
@@ -338,14 +423,31 @@ function ensureListViewAdapterClass() {
} }
public getViewTypeCount() { public getViewTypeCount() {
return this.owner._itemTemplatesInternal.length; let count = this.owner._itemTemplatesInternal.length;
// Add 1 for header view type when sectioned
if (this.owner.sectioned && this.owner.stickyHeaderTemplate) {
count += 1;
}
return count;
} }
public getItemViewType(index: number) { public getItemViewType(index: number) {
const template = this.owner._getItemTemplate(index); if (this.owner.sectioned) {
const itemViewType = this.owner._itemTemplatesInternal.indexOf(template); const positionInfo = this._getPositionInfo(index);
if (positionInfo.isHeader) {
return itemViewType; // Header view type is the last index (after all item template types)
return this.owner._itemTemplatesInternal.length;
} else {
// Get template for the actual item
const template = this.owner._getItemTemplate(positionInfo.itemIndex);
return this.owner._itemTemplatesInternal.indexOf(template);
}
} else {
const template = this.owner._getItemTemplate(index);
return this.owner._itemTemplatesInternal.indexOf(template);
}
} }
@profile @profile
@@ -356,17 +458,90 @@ function ensureListViewAdapterClass() {
return null; return null;
} }
const totalItemCount = this.owner.items ? this.owner.items.length : 0; if (this.owner.sectioned) {
if (index === totalItemCount - 1) { const positionInfo = this._getPositionInfo(index);
this.owner.notify({
eventName: LOADMOREITEMS, if (positionInfo.isHeader) {
object: this.owner, // Create section header view
return this._createHeaderView(positionInfo.section, convertView, parent);
} else {
// Create regular item view with adjusted index
return this._createItemView(positionInfo.section, positionInfo.itemIndex, convertView, parent);
}
} else {
// Non-sectioned - use original logic
return this._createItemView(0, index, convertView, parent);
}
}
private _createHeaderView(section: number, convertView: android.view.View, parent: android.view.ViewGroup): android.view.View {
let headerView: View = null;
const headerViewType = this.owner._itemTemplatesInternal.length; // Same as getItemViewType for headers
// Try to reuse convertView if it's the right type
if (convertView) {
const existingData = this.owner._realizedItems.get(convertView);
if (existingData && existingData.templateKey === `header_${headerViewType}`) {
headerView = existingData.view;
}
}
// Create new header view if we can't reuse
if (!headerView) {
if (this.owner.stickyHeaderTemplate) {
if (typeof this.owner.stickyHeaderTemplate === 'string') {
try {
headerView = Builder.parse(this.owner.stickyHeaderTemplate, this.owner);
} catch (error) {
// Fallback to simple label
headerView = new Label();
(headerView as Label).text = 'Header Error';
}
}
}
if (!headerView) {
// Default header
headerView = new Label();
(headerView as Label).text = `Section ${section}`;
}
// Add to parent and get native view
if (!headerView.parent) {
if (headerView instanceof LayoutBase && !(headerView instanceof ProxyViewContainer)) {
this.owner._addView(headerView);
convertView = headerView.nativeViewProtected;
} else {
const sp = new StackLayout();
sp.addChild(headerView);
this.owner._addView(sp);
convertView = sp.nativeViewProtected;
}
}
// Register the header view for recycling
this.owner._realizedItems.set(convertView, {
view: headerView,
templateKey: `header_${headerViewType}`,
}); });
} }
// Recycle an existing view or create a new one if needed. // Set binding context to section data (always update, even for recycled views)
const template = this.owner._getItemTemplate(index); const sectionData = this.owner._getSectionData(section);
if (sectionData) {
headerView.bindingContext = sectionData;
} else {
headerView.bindingContext = { title: `Section ${section}`, section: section };
}
return convertView;
}
private _createItemView(section: number, itemIndex: number, convertView: android.view.View, parent: android.view.ViewGroup): android.view.View {
// Use existing item creation logic but with sectioned data
const template = this.owner._getItemTemplate(itemIndex);
let view: View; let view: View;
// convertView is of the wrong type // convertView is of the wrong type
if (convertView && this.owner._getKeyFromView(convertView) !== template.key) { if (convertView && this.owner._getKeyFromView(convertView) !== template.key) {
this.owner._markViewUnused(convertView); // release this view this.owner._markViewUnused(convertView); // release this view
@@ -383,7 +558,7 @@ function ensureListViewAdapterClass() {
const args: ItemEventData = { const args: ItemEventData = {
eventName: ITEMLOADING, eventName: ITEMLOADING,
object: this.owner, object: this.owner,
index: index, index: itemIndex,
view: view, view: view,
android: parent, android: parent,
ios: undefined, ios: undefined,
@@ -392,7 +567,7 @@ function ensureListViewAdapterClass() {
this.owner.notify(args); this.owner.notify(args);
if (!args.view) { if (!args.view) {
args.view = this.owner._getDefaultItemContent(index); args.view = this.owner._getDefaultItemContent(itemIndex);
} }
if (args.view) { if (args.view) {
@@ -402,7 +577,13 @@ function ensureListViewAdapterClass() {
args.view.height = <CoreTypes.LengthType>unsetValue; args.view.height = <CoreTypes.LengthType>unsetValue;
} }
this.owner._prepareItem(args.view, index); // Use sectioned item preparation
if (this.owner.sectioned) {
this.owner._prepareItemInSection(args.view, section, itemIndex);
} else {
this.owner._prepareItem(args.view, itemIndex);
}
if (!args.view.parent) { if (!args.view.parent) {
// Proxy containers should not get treated as layouts. // Proxy containers should not get treated as layouts.
// Wrap them in a real layout as well. // Wrap them in a real layout as well.