mirror of
https://github.com/NativeScript/NativeScript.git
synced 2025-11-05 13:26:48 +08:00
feat(android): ListView sticky header options 2
This commit is contained in:
@@ -72,6 +72,12 @@ export class ListView extends ListViewBase {
|
|||||||
public _availableViews = new Map<string, Set<android.view.View>>();
|
public _availableViews = new Map<string, Set<android.view.View>>();
|
||||||
public _realizedTemplates = new Map<string, Map<android.view.View, View>>();
|
public _realizedTemplates = new Map<string, Map<android.view.View, View>>();
|
||||||
|
|
||||||
|
// Sticky header support
|
||||||
|
private _stickyHeaderView: View;
|
||||||
|
private _stickyHeaderHeight: number = 0;
|
||||||
|
private _scrollListener: android.widget.AbsListView.OnScrollListener;
|
||||||
|
_hiddenHeaderPositions = new Set<number>(); // Track which headers to hide
|
||||||
|
|
||||||
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());
|
||||||
@@ -166,14 +172,42 @@ export class ListView extends ListViewBase {
|
|||||||
if ((<any>nativeView).adapter) {
|
if ((<any>nativeView).adapter) {
|
||||||
(<any>nativeView).adapter.owner = null;
|
(<any>nativeView).adapter.owner = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup sticky header
|
||||||
|
this._cleanupStickyHeader();
|
||||||
|
|
||||||
this.clearRealizedCells();
|
this.clearRealizedCells();
|
||||||
super.disposeNativeView();
|
super.disposeNativeView();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _cleanupStickyHeader() {
|
||||||
|
// Remove scroll listener
|
||||||
|
if (this._scrollListener) {
|
||||||
|
this.nativeViewProtected.setOnScrollListener(null);
|
||||||
|
this._scrollListener = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove sticky header from parent
|
||||||
|
if (this._stickyHeaderView && this._stickyHeaderView.parent) {
|
||||||
|
this._stickyHeaderView.parent._removeView(this._stickyHeaderView);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._stickyHeaderView = null;
|
||||||
|
this._stickyHeaderHeight = 0;
|
||||||
|
|
||||||
|
// Clear hidden headers
|
||||||
|
this._hiddenHeaderPositions.clear();
|
||||||
|
}
|
||||||
|
|
||||||
public onLoaded() {
|
public onLoaded() {
|
||||||
super.onLoaded();
|
super.onLoaded();
|
||||||
// Without this call itemClick won't be fired... :(
|
// Without this call itemClick won't be fired... :(
|
||||||
this.requestLayout();
|
this.requestLayout();
|
||||||
|
|
||||||
|
// Setup sticky header if enabled
|
||||||
|
if (this.stickyHeader && this.sectioned && this.stickyHeaderTemplate) {
|
||||||
|
this._setupStickyHeader();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public refresh() {
|
public refresh() {
|
||||||
@@ -259,6 +293,196 @@ export class ListView extends ListViewBase {
|
|||||||
return index >= start && index <= end;
|
return index >= start && index <= end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sticky header methods
|
||||||
|
private _setupStickyHeader() {
|
||||||
|
if (!this.stickyHeader || !this.sectioned || !this.stickyHeaderTemplate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the sticky header view
|
||||||
|
this._createStickyHeaderView();
|
||||||
|
|
||||||
|
// Add it as an overlay to the parent
|
||||||
|
this._addStickyHeaderToParent();
|
||||||
|
|
||||||
|
// Add padding to ListView so content doesn't hide behind sticky header
|
||||||
|
this._addListViewPadding();
|
||||||
|
|
||||||
|
// Setup scroll listener to update header content
|
||||||
|
this._setupScrollListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _createStickyHeaderView() {
|
||||||
|
if (this._stickyHeaderView) {
|
||||||
|
return; // Already created
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create header view using the same template as section headers
|
||||||
|
if (typeof this.stickyHeaderTemplate === 'string') {
|
||||||
|
try {
|
||||||
|
this._stickyHeaderView = Builder.parse(this.stickyHeaderTemplate, this);
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback to simple label
|
||||||
|
this._stickyHeaderView = new Label();
|
||||||
|
(this._stickyHeaderView as Label).text = 'Header Error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._stickyHeaderView) {
|
||||||
|
// Default header
|
||||||
|
this._stickyHeaderView = new Label();
|
||||||
|
(this._stickyHeaderView as Label).text = 'Section 0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial binding context (section 0)
|
||||||
|
this._updateStickyHeader(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _addStickyHeaderToParent() {
|
||||||
|
if (!this._stickyHeaderView || !this.parent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from current parent if it has one (likely the ListView from Builder.parse)
|
||||||
|
if (this._stickyHeaderView.parent) {
|
||||||
|
this._stickyHeaderView.parent._removeView(this._stickyHeaderView);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set proper sizing - don't stretch to fill parent
|
||||||
|
this._stickyHeaderView.width = { unit: '%', value: 100 };
|
||||||
|
this._stickyHeaderView.height = 'auto'; // Let it size to content
|
||||||
|
this._stickyHeaderView.verticalAlignment = 'top';
|
||||||
|
this._stickyHeaderView.horizontalAlignment = 'stretch';
|
||||||
|
|
||||||
|
// Add sticky header to the parent layout
|
||||||
|
// Position it at the top, overlaying the ListView
|
||||||
|
this.parent._addView(this._stickyHeaderView);
|
||||||
|
|
||||||
|
// Make sure it's positioned correctly
|
||||||
|
if (this._stickyHeaderView.nativeViewProtected) {
|
||||||
|
// Bring to front
|
||||||
|
this._stickyHeaderView.nativeViewProtected.setZ(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _addListViewPadding() {
|
||||||
|
if (!this._stickyHeaderView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply immediate padding with a reasonable default to prevent content hiding
|
||||||
|
const defaultHeaderHeight = 50; // Reasonable default height in dp
|
||||||
|
this.nativeViewProtected.setPadding(0, defaultHeaderHeight, 0, 0);
|
||||||
|
this._stickyHeaderHeight = defaultHeaderHeight;
|
||||||
|
|
||||||
|
// Request layout to ensure proper measurement
|
||||||
|
this._stickyHeaderView.requestLayout();
|
||||||
|
|
||||||
|
// Then measure and adjust padding if needed
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this._stickyHeaderView) {
|
||||||
|
// Get the actual measured height from the native view
|
||||||
|
const nativeView = this._stickyHeaderView.nativeViewProtected;
|
||||||
|
if (nativeView && nativeView.getMeasuredHeight() > 0) {
|
||||||
|
const measuredHeight = nativeView.getMeasuredHeight();
|
||||||
|
const paddingHeight = measuredHeight + 4;
|
||||||
|
|
||||||
|
// Only update if significantly different
|
||||||
|
if (Math.abs(paddingHeight - this._stickyHeaderHeight) > 5) {
|
||||||
|
this._stickyHeaderHeight = paddingHeight;
|
||||||
|
this.nativeViewProtected.setPadding(0, paddingHeight, 0, 0);
|
||||||
|
}
|
||||||
|
this.scrollToIndex(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100); // Slightly longer delay for more reliable measurement
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setupScrollListener() {
|
||||||
|
if (this._scrollListener) {
|
||||||
|
return; // Already setup
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = this;
|
||||||
|
this._scrollListener = new android.widget.AbsListView.OnScrollListener({
|
||||||
|
onScrollStateChanged(view: android.widget.AbsListView, scrollState: number) {
|
||||||
|
// Not needed for sticky headers
|
||||||
|
},
|
||||||
|
|
||||||
|
onScroll(view: android.widget.AbsListView, firstVisibleItem: number, visibleItemCount: number, totalItemCount: number) {
|
||||||
|
if (owner.sectioned && owner._stickyHeaderView) {
|
||||||
|
const currentSection = owner._getCurrentSection(firstVisibleItem);
|
||||||
|
owner._updateStickyHeader(currentSection);
|
||||||
|
|
||||||
|
// Hide section headers when they would appear right below sticky header
|
||||||
|
owner._updateHiddenHeaders(firstVisibleItem);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.nativeViewProtected.setOnScrollListener(this._scrollListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getCurrentSection(firstVisibleItem: number): number {
|
||||||
|
if (!this.sectioned) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the first visible list position to section number
|
||||||
|
let currentPosition = 0;
|
||||||
|
const sectionCount = this._getSectionCount();
|
||||||
|
|
||||||
|
for (let section = 0; section < sectionCount; section++) {
|
||||||
|
// Check if firstVisibleItem is in this section (header or items)
|
||||||
|
const itemsInSection = this._getItemsInSection(section).length;
|
||||||
|
const sectionEndPosition = currentPosition + 1 + itemsInSection; // +1 for header
|
||||||
|
|
||||||
|
if (firstVisibleItem < sectionEndPosition) {
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPosition = sectionEndPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, sectionCount - 1); // Fallback to last section
|
||||||
|
}
|
||||||
|
|
||||||
|
private _updateStickyHeader(section: number) {
|
||||||
|
if (!this._stickyHeaderView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update binding context to match the current section
|
||||||
|
const sectionData = this._getSectionData(section);
|
||||||
|
if (sectionData) {
|
||||||
|
this._stickyHeaderView.bindingContext = sectionData;
|
||||||
|
} else {
|
||||||
|
this._stickyHeaderView.bindingContext = { title: `Section ${section}`, section: section };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _updateHiddenHeaders(firstVisibleItem: number) {
|
||||||
|
const previousHiddenHeaders = new Set(this._hiddenHeaderPositions);
|
||||||
|
this._hiddenHeaderPositions.clear();
|
||||||
|
|
||||||
|
// If we're at the very top (first item is position 0, which is the first section header),
|
||||||
|
// hide that section header to avoid duplication with sticky header
|
||||||
|
if (firstVisibleItem === 0) {
|
||||||
|
this._hiddenHeaderPositions.add(0); // Hide the first section header position
|
||||||
|
}
|
||||||
|
|
||||||
|
// If hidden headers changed, refresh the adapter
|
||||||
|
const hiddenHeadersChanged = previousHiddenHeaders.size !== this._hiddenHeaderPositions.size || [...previousHiddenHeaders].some((pos) => !this._hiddenHeaderPositions.has(pos));
|
||||||
|
|
||||||
|
if (hiddenHeadersChanged) {
|
||||||
|
// Refresh adapter to update visibility
|
||||||
|
const adapter = this.nativeViewProtected.getAdapter();
|
||||||
|
if (adapter instanceof android.widget.BaseAdapter) {
|
||||||
|
adapter.notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[separatorColorProperty.getDefault](): {
|
[separatorColorProperty.getDefault](): {
|
||||||
dividerHeight: number;
|
dividerHeight: number;
|
||||||
divider: android.graphics.drawable.Drawable;
|
divider: android.graphics.drawable.Drawable;
|
||||||
@@ -301,12 +525,19 @@ export class ListView extends ListViewBase {
|
|||||||
this.refresh();
|
this.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sticky header property handlers (for now just trigger refresh)
|
// Sticky header property handlers
|
||||||
[stickyHeaderProperty.setNative](value: boolean) {
|
[stickyHeaderProperty.setNative](value: boolean) {
|
||||||
// Refresh adapter to handle sectioned vs non-sectioned display
|
// Refresh adapter to handle sectioned vs non-sectioned display
|
||||||
if (this.nativeViewProtected && this.nativeViewProtected.getAdapter()) {
|
if (this.nativeViewProtected && this.nativeViewProtected.getAdapter()) {
|
||||||
this.nativeViewProtected.setAdapter(new ListViewAdapterClass(this));
|
this.nativeViewProtected.setAdapter(new ListViewAdapterClass(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup or cleanup sticky header
|
||||||
|
if (value && this.sectioned && this.stickyHeaderTemplate && this.isLoaded) {
|
||||||
|
this._setupStickyHeader();
|
||||||
|
} else {
|
||||||
|
this._cleanupStickyHeader();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[stickyHeaderTemplateProperty.setNative](value: string) {
|
[stickyHeaderTemplateProperty.setNative](value: string) {
|
||||||
@@ -314,6 +545,12 @@ export class ListView extends ListViewBase {
|
|||||||
if (this.nativeViewProtected && this.nativeViewProtected.getAdapter()) {
|
if (this.nativeViewProtected && this.nativeViewProtected.getAdapter()) {
|
||||||
this.nativeViewProtected.setAdapter(new ListViewAdapterClass(this));
|
this.nativeViewProtected.setAdapter(new ListViewAdapterClass(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recreate sticky header with new template
|
||||||
|
this._cleanupStickyHeader();
|
||||||
|
if (value && this.stickyHeader && this.sectioned && this.isLoaded) {
|
||||||
|
this._setupStickyHeader();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[sectionedProperty.setNative](value: boolean) {
|
[sectionedProperty.setNative](value: boolean) {
|
||||||
@@ -321,6 +558,13 @@ export class ListView extends ListViewBase {
|
|||||||
if (this.nativeViewProtected && this.nativeViewProtected.getAdapter()) {
|
if (this.nativeViewProtected && this.nativeViewProtected.getAdapter()) {
|
||||||
this.nativeViewProtected.setAdapter(new ListViewAdapterClass(this));
|
this.nativeViewProtected.setAdapter(new ListViewAdapterClass(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup or cleanup sticky header based on sectioned state
|
||||||
|
if (value && this.stickyHeader && this.stickyHeaderTemplate && this.isLoaded) {
|
||||||
|
this._setupStickyHeader();
|
||||||
|
} else {
|
||||||
|
this._cleanupStickyHeader();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,8 +706,8 @@ function ensureListViewAdapterClass() {
|
|||||||
const positionInfo = this._getPositionInfo(index);
|
const positionInfo = this._getPositionInfo(index);
|
||||||
|
|
||||||
if (positionInfo.isHeader) {
|
if (positionInfo.isHeader) {
|
||||||
// Create section header view
|
// Create section header view (pass index for hiding logic)
|
||||||
return this._createHeaderView(positionInfo.section, convertView, parent);
|
return this._createHeaderView(positionInfo.section, convertView, parent, index);
|
||||||
} else {
|
} else {
|
||||||
// Create regular item view with adjusted index
|
// Create regular item view with adjusted index
|
||||||
return this._createItemView(positionInfo.section, positionInfo.itemIndex, convertView, parent);
|
return this._createItemView(positionInfo.section, positionInfo.itemIndex, convertView, parent);
|
||||||
@@ -474,7 +718,19 @@ function ensureListViewAdapterClass() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createHeaderView(section: number, convertView: android.view.View, parent: android.view.ViewGroup): android.view.View {
|
private _createHeaderView(section: number, convertView: android.view.View, parent: android.view.ViewGroup, index: number): android.view.View {
|
||||||
|
// Check if this header should be hidden to avoid duplication with sticky header
|
||||||
|
if (this.owner._hiddenHeaderPositions.has(index)) {
|
||||||
|
// Return an empty view with zero height
|
||||||
|
const emptyView = new android.view.View(this.owner._context);
|
||||||
|
const layoutParams = new android.view.ViewGroup.LayoutParams(
|
||||||
|
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
0, // Zero height
|
||||||
|
);
|
||||||
|
emptyView.setLayoutParams(layoutParams);
|
||||||
|
return emptyView;
|
||||||
|
}
|
||||||
|
|
||||||
let headerView: View = null;
|
let headerView: View = null;
|
||||||
const headerViewType = this.owner._itemTemplatesInternal.length; // Same as getItemViewType for headers
|
const headerViewType = this.owner._itemTemplatesInternal.length; // Same as getItemViewType for headers
|
||||||
|
|
||||||
@@ -585,18 +841,12 @@ function ensureListViewAdapterClass() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!args.view.parent) {
|
if (!args.view.parent) {
|
||||||
// Proxy containers should not get treated as layouts.
|
// Android ListView doesn't properly respect margins on direct child views.
|
||||||
// Wrap them in a real layout as well.
|
// Always wrap item views in a StackLayout container to ensure margins work correctly.
|
||||||
if (args.view instanceof LayoutBase && !(args.view instanceof ProxyViewContainer)) {
|
const container = new StackLayout();
|
||||||
this.owner._addView(args.view);
|
container.addChild(args.view);
|
||||||
convertView = args.view.nativeViewProtected;
|
this.owner._addView(container);
|
||||||
} else {
|
convertView = container.nativeViewProtected;
|
||||||
const sp = new StackLayout();
|
|
||||||
sp.addChild(args.view);
|
|
||||||
this.owner._addView(sp);
|
|
||||||
|
|
||||||
convertView = sp.nativeViewProtected;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.owner._registerViewToTemplate(template.key, convertView, args.view);
|
this.owner._registerViewToTemplate(template.key, convertView, args.view);
|
||||||
|
|||||||
Reference in New Issue
Block a user