import { ItemEventData } from "."; import { ListViewBase, View, KeyedTemplate, Length, Observable, Color, separatorColorProperty, itemTemplatesProperty, iosEstimatedRowHeightProperty, layout, EventData } from "./list-view-common"; import { StackLayout } from "../layouts/stack-layout"; import { ProxyViewContainer } from "../proxy-view-container"; import { profile } from "../../profiling"; import * as trace from "../../trace"; export * from "./list-view-common"; const ITEMLOADING = ListViewBase.itemLoadingEvent; const LOADMOREITEMS = ListViewBase.loadMoreItemsEvent; const ITEMTAP = ListViewBase.itemTapEvent; const DEFAULT_HEIGHT = 44; const infinity = layout.makeMeasureSpec(0, layout.UNSPECIFIED); interface ViewItemIndex { _listViewItemIndex?: number; } type ItemView = View & ViewItemIndex; class ListViewCell extends UITableViewCell { public static initWithEmptyBackground(): ListViewCell { const cell = ListViewCell.new(); // Clear background by default - this will make cells transparent cell.backgroundColor = null; return cell; } initWithStyleReuseIdentifier(style: UITableViewCellStyle, reuseIdentifier: string): this { const cell = super.initWithStyleReuseIdentifier(style, reuseIdentifier); // Clear background by default - this will make cells transparent cell.backgroundColor = null; return cell; } public willMoveToSuperview(newSuperview: UIView): void { let parent = (this.view ? this.view.parent : null); // When inside ListView and there is no newSuperview this cell is // removed from native visual tree so we remove it from our tree too. if (parent && !newSuperview) { parent._removeContainer(this); } } public get view(): View { return this.owner ? this.owner.get() : null; } public owner: WeakRef; } function notifyForItemAtIndex(listView: ListViewBase, cell: any, view: View, eventName: string, indexPath: NSIndexPath) { let args = { eventName: eventName, object: listView, index: indexPath.row, view: view, ios: cell, android: undefined }; listView.notify(args); return args; } class DataSource extends NSObject implements UITableViewDataSource { public static ObjCProtocols = [UITableViewDataSource]; private _owner: WeakRef; public static initWithOwner(owner: WeakRef): DataSource { let dataSource = DataSource.new(); dataSource._owner = owner; return dataSource; } public tableViewNumberOfRowsInSection(tableView: UITableView, section: number) { let owner = this._owner.get(); return (owner && owner.items) ? owner.items.length : 0; } public tableViewCellForRowAtIndexPath(tableView: UITableView, indexPath: NSIndexPath): UITableViewCell { // We call this method because ...ForIndexPath calls tableViewHeightForRowAtIndexPath immediately (before we can prepare and measure it). let owner = this._owner.get(); let cell: ListViewCell; if (owner) { let template = owner._getItemTemplate(indexPath.row); cell = (tableView.dequeueReusableCellWithIdentifier(template.key) || ListViewCell.initWithEmptyBackground()); owner._prepareCell(cell, indexPath); let cellView: View = cell.view; if (cellView && cellView.isLayoutRequired) { // Arrange cell views. We do it here instead of _layoutCell because _layoutCell is called // from 'tableViewHeightForRowAtIndexPath' method too (in iOS 7.1) and we don't want to arrange the fake cell. let width = layout.getMeasureSpecSize(owner.widthMeasureSpec); let rowHeight = owner._effectiveRowHeight; let cellHeight = rowHeight > 0 ? rowHeight : owner.getHeight(indexPath.row); cellView.iosOverflowSafeAreaEnabled = false; View.layoutChild(owner, cellView, 0, 0, width, cellHeight); } } else { cell = ListViewCell.initWithEmptyBackground(); } return cell; } } class UITableViewDelegateImpl extends NSObject implements UITableViewDelegate { public static ObjCProtocols = [UITableViewDelegate]; private _owner: WeakRef; private _measureCellMap: Map; public static initWithOwner(owner: WeakRef): UITableViewDelegateImpl { const delegate = UITableViewDelegateImpl.new(); delegate._owner = owner; delegate._measureCellMap = new Map(); return delegate; } public tableViewWillDisplayCellForRowAtIndexPath(tableView: UITableView, cell: UITableViewCell, indexPath: NSIndexPath) { const owner = this._owner.get(); if (owner && (indexPath.row === owner.items.length - 1)) { owner.notify({ eventName: LOADMOREITEMS, object: owner }); } } public tableViewWillSelectRowAtIndexPath(tableView: UITableView, indexPath: NSIndexPath): NSIndexPath { const cell = tableView.cellForRowAtIndexPath(indexPath); const owner = this._owner.get(); if (owner) { notifyForItemAtIndex(owner, cell, cell.view, ITEMTAP, indexPath); } return indexPath; } public tableViewDidSelectRowAtIndexPath(tableView: UITableView, indexPath: NSIndexPath): NSIndexPath { tableView.deselectRowAtIndexPathAnimated(indexPath, true); return indexPath; } public tableViewHeightForRowAtIndexPath(tableView: UITableView, indexPath: NSIndexPath): number { const owner = this._owner.get(); if (!owner) { return tableView.estimatedRowHeight; } let height = owner.getHeight(indexPath.row); if (height === undefined) { // in iOS8+ after call to scrollToRowAtIndexPath:atScrollPosition:animated: this method is called before tableViewCellForRowAtIndexPath so we need fake cell to measure its content. const template = owner._getItemTemplate(indexPath.row); let cell = this._measureCellMap.get(template.key); if (!cell) { cell = (tableView.dequeueReusableCellWithIdentifier(template.key)) || ListViewCell.initWithEmptyBackground(); this._measureCellMap.set(template.key, cell); } height = owner._prepareCell(cell, indexPath); } return layout.toDeviceIndependentPixels(height); } } class UITableViewRowHeightDelegateImpl extends NSObject implements UITableViewDelegate { public static ObjCProtocols = [UITableViewDelegate]; private _owner: WeakRef; public static initWithOwner(owner: WeakRef): UITableViewRowHeightDelegateImpl { let delegate = UITableViewRowHeightDelegateImpl.new(); delegate._owner = owner; return delegate; } public tableViewWillDisplayCellForRowAtIndexPath(tableView: UITableView, cell: UITableViewCell, indexPath: NSIndexPath) { let owner = this._owner.get(); if (owner && (indexPath.row === owner.items.length - 1)) { owner.notify({ eventName: LOADMOREITEMS, object: owner }); } } public tableViewWillSelectRowAtIndexPath(tableView: UITableView, indexPath: NSIndexPath): NSIndexPath { let cell = tableView.cellForRowAtIndexPath(indexPath); let owner = this._owner.get(); if (owner) { notifyForItemAtIndex(owner, cell, cell.view, ITEMTAP, indexPath); } return indexPath; } public tableViewDidSelectRowAtIndexPath(tableView: UITableView, indexPath: NSIndexPath): NSIndexPath { tableView.deselectRowAtIndexPathAnimated(indexPath, true); return indexPath; } public tableViewHeightForRowAtIndexPath(tableView: UITableView, indexPath: NSIndexPath): number { let owner = this._owner.get(); if (!owner) { return tableView.estimatedRowHeight; } return layout.toDeviceIndependentPixels(owner._effectiveRowHeight); } } export class ListView extends ListViewBase { public nativeViewProtected: UITableView; // tslint:disable-next-line private _dataSource; private _delegate; private _heights: Array; private _preparingCell: boolean; private _isDataDirty: boolean; private _map: Map; widthMeasureSpec: number = 0; constructor() { super(); this._map = new Map(); this._heights = new Array(); } createNativeView() { return UITableView.new(); } initNativeView() { super.initNativeView(); const nativeView = this.nativeViewProtected; nativeView.registerClassForCellReuseIdentifier(ListViewCell.class(), this._defaultTemplate.key); nativeView.estimatedRowHeight = DEFAULT_HEIGHT; nativeView.rowHeight = UITableViewAutomaticDimension; nativeView.dataSource = this._dataSource = DataSource.initWithOwner(new WeakRef(this)); this._delegate = UITableViewDelegateImpl.initWithOwner(new WeakRef(this)); this._setNativeClipToBounds(); } disposeNativeView() { this._delegate = null; this._dataSource = null; super.disposeNativeView(); } _setNativeClipToBounds() { // Always set clipsToBounds for list-view this.ios.clipsToBounds = true; } @profile public onLoaded() { super.onLoaded(); if (this._isDataDirty) { this.refresh(); } this.ios.delegate = this._delegate; } public onUnloaded() { this.ios.delegate = null; super.onUnloaded(); } get ios(): UITableView { return this.nativeViewProtected; } get _childrenCount(): number { return this._map.size; } public eachChildView(callback: (child: View) => boolean): void { this._map.forEach((view, key) => { callback(view); }); } public scrollToIndex(index: number) { this._scrollToIndex(index, false); } public scrollToIndexAnimated(index: number) { this._scrollToIndex(index); } private _scrollToIndex(index: number, animated: boolean = true) { if (!this.ios) { return; } const itemsLength = this.items ? this.items.length : 0; // mimic Android behavior that silently coerces index values within [0, itemsLength - 1] range if (itemsLength > 0) { if (index < 0) { index = 0; } else if (index >= itemsLength) { index = itemsLength - 1; } this.ios.scrollToRowAtIndexPathAtScrollPositionAnimated(NSIndexPath.indexPathForItemInSection(index, 0), UITableViewScrollPosition.Top, animated); } else if (trace.isEnabled()) { trace.write(`Cannot scroll listview to index ${index} when listview items not set`, trace.categories.Binding); } } public refresh() { // clear bindingContext when it is not observable because otherwise bindings to items won't reevaluate this._map.forEach((view, nativeView, map) => { if (!(view.bindingContext instanceof Observable)) { view.bindingContext = null; } }); if (this.isLoaded) { this.ios.reloadData(); this.requestLayout(); this._isDataDirty = false; } else { this._isDataDirty = true; } } public isItemAtIndexVisible(itemIndex: number): boolean { const indexes: NSIndexPath[] = Array.from(this.ios.indexPathsForVisibleRows); return indexes.some(visIndex => visIndex.row === itemIndex); } public getHeight(index: number): number { return this._heights[index]; } public setHeight(index: number, value: number): void { this._heights[index] = value; } public _onRowHeightPropertyChanged(oldValue: Length, newValue: Length) { const value = layout.toDeviceIndependentPixels(this._effectiveRowHeight); const nativeView = this.ios; if (value < 0) { nativeView.rowHeight = UITableViewAutomaticDimension; nativeView.estimatedRowHeight = DEFAULT_HEIGHT; this._delegate = UITableViewDelegateImpl.initWithOwner(new WeakRef(this)); } else { nativeView.rowHeight = value; nativeView.estimatedRowHeight = value; this._delegate = UITableViewRowHeightDelegateImpl.initWithOwner(new WeakRef(this)); } if (this.isLoaded) { nativeView.delegate = this._delegate; } super._onRowHeightPropertyChanged(oldValue, newValue); } public requestLayout(): void { // When preparing cell don't call super - no need to invalidate our measure when cell desiredSize is changed. if (!this._preparingCell) { super.requestLayout(); } } public measure(widthMeasureSpec: number, heightMeasureSpec: number): void { this.widthMeasureSpec = widthMeasureSpec; const changed = this._setCurrentMeasureSpecs(widthMeasureSpec, heightMeasureSpec); super.measure(widthMeasureSpec, heightMeasureSpec); if (changed) { this.ios.reloadData(); } } public onMeasure(widthMeasureSpec: number, heightMeasureSpec: number): void { super.onMeasure(widthMeasureSpec, heightMeasureSpec); this._map.forEach((childView, listViewCell) => { View.measureChild(this, childView, childView._currentWidthMeasureSpec, childView._currentHeightMeasureSpec); }); } public onLayout(left: number, top: number, right: number, bottom: number): void { super.onLayout(left, top, right, bottom); this._map.forEach((childView, listViewCell) => { let rowHeight = this._effectiveRowHeight; let cellHeight = rowHeight > 0 ? rowHeight : this.getHeight(childView._listViewItemIndex); if (cellHeight) { let width = layout.getMeasureSpecSize(this.widthMeasureSpec); childView.iosOverflowSafeAreaEnabled = false; View.layoutChild(this, childView, 0, 0, width, cellHeight); } }); } private _layoutCell(cellView: View, indexPath: NSIndexPath): number { if (cellView) { const rowHeight = this._effectiveRowHeight; const heightMeasureSpec: number = rowHeight >= 0 ? layout.makeMeasureSpec(rowHeight, layout.EXACTLY) : infinity; const measuredSize = View.measureChild(this, cellView, this.widthMeasureSpec, heightMeasureSpec); const height = measuredSize.measuredHeight; this.setHeight(indexPath.row, height); return height; } return this.ios.estimatedRowHeight; } public _prepareCell(cell: ListViewCell, indexPath: NSIndexPath): number { let cellHeight: number; try { this._preparingCell = true; let view: ItemView = cell.view; if (!view) { view = this._getItemTemplate(indexPath.row).createView(); } let args = notifyForItemAtIndex(this, cell, view, ITEMLOADING, indexPath); view = args.view || this._getDefaultItemContent(indexPath.row); // Proxy containers should not get treated as layouts. // Wrap them in a real layout as well. if (view instanceof ProxyViewContainer) { let sp = new StackLayout(); sp.addChild(view); view = sp; } // If cell is reused it have old content - remove it first. if (!cell.view) { cell.owner = new WeakRef(view); } else if (cell.view !== view) { this._removeContainer(cell); (cell.view.nativeViewProtected).removeFromSuperview(); cell.owner = new WeakRef(view); } this._prepareItem(view, indexPath.row); view._listViewItemIndex = indexPath.row; this._map.set(cell, view); // We expect that views returned from itemLoading are new (e.g. not reused). if (view && !view.parent) { this._addView(view); cell.contentView.addSubview(view.nativeViewProtected); } cellHeight = this._layoutCell(view, indexPath); } finally { this._preparingCell = false; } return cellHeight; } public _removeContainer(cell: ListViewCell): void { let view: ItemView = cell.view; // This is to clear the StackLayout that is used to wrap ProxyViewContainer instances. if (!(view.parent instanceof ListView)) { this._removeView(view.parent); } // No need to request layout when we are removing cells. const preparing = this._preparingCell; this._preparingCell = true; view.parent._removeView(view); view._listViewItemIndex = undefined; this._preparingCell = preparing; this._map.delete(cell); } [separatorColorProperty.getDefault](): UIColor { return this.ios.separatorColor; } [separatorColorProperty.setNative](value: Color | UIColor) { this.ios.separatorColor = value instanceof Color ? value.ios : value; } [itemTemplatesProperty.getDefault](): KeyedTemplate[] { return null; } [itemTemplatesProperty.setNative](value: KeyedTemplate[]) { this._itemTemplatesInternal = new Array(this._defaultTemplate); if (value) { for (let i = 0, length = value.length; i < length; i++) { this.ios.registerClassForCellReuseIdentifier(ListViewCell.class(), value[i].key); } this._itemTemplatesInternal = this._itemTemplatesInternal.concat(value); } this.refresh(); } [iosEstimatedRowHeightProperty.getDefault](): Length { return DEFAULT_HEIGHT; } [iosEstimatedRowHeightProperty.setNative](value: Length) { const nativeView = this.ios; const estimatedHeight = Length.toDevicePixels(value, 0); nativeView.estimatedRowHeight = estimatedHeight < 0 ? DEFAULT_HEIGHT : estimatedHeight; } }