haxe/ui/containers/ListView.hx
package haxe.ui.containers;
import haxe.ui.Toolkit;
import haxe.ui.actions.ActionType;
import haxe.ui.behaviours.Behaviour;
import haxe.ui.behaviours.DataBehaviour;
import haxe.ui.behaviours.DefaultBehaviour;
import haxe.ui.behaviours.LayoutBehaviour;
import haxe.ui.components.Label;
import haxe.ui.components.VerticalScroll;
import haxe.ui.constants.SelectionMode;
import haxe.ui.containers.ScrollView.ScrollViewBuilder;
import haxe.ui.containers.ScrollView.ScrollViewEvents;
import haxe.ui.core.Component;
import haxe.ui.core.IDataComponent;
import haxe.ui.core.InteractiveComponent;
import haxe.ui.core.ItemRenderer;
import haxe.ui.data.ArrayDataSource;
import haxe.ui.data.DataSource;
import haxe.ui.events.ActionEvent;
import haxe.ui.events.ItemEvent;
import haxe.ui.events.MouseEvent;
import haxe.ui.events.ScrollEvent;
import haxe.ui.events.UIEvent;
import haxe.ui.layouts.VerticalVirtualLayout;
import haxe.ui.styles.Style;
import haxe.ui.util.MathUtil;
import haxe.ui.util.Variant;
@:composite(ListViewEvents, ListViewBuilder, VerticalVirtualLayout)
class ListView extends ScrollView implements IDataComponent implements IVirtualContainer {
//***********************************************************************************************************
// Public API
//***********************************************************************************************************
@:clonable @:behaviour(DataSourceBehaviour) public var dataSource:DataSource<Dynamic>;
@:clonable @:behaviour(LayoutBehaviour, -1) public var itemWidth:Float;
@:clonable @:behaviour(LayoutBehaviour, -1) public var itemHeight:Float;
@:clonable @:behaviour(LayoutBehaviour, -1) public var itemCount:Int;
@:clonable @:behaviour(LayoutBehaviour, false) public var variableItemSize:Bool;
@:clonable @:behaviour(SelectedIndexBehaviour, -1) public var selectedIndex:Int;
@:clonable @:behaviour(SelectedItemBehaviour) public var selectedItem:Dynamic;
@:clonable @:behaviour(SelectedIndicesBehaviour) public var selectedIndices:Array<Int>;
@:clonable @:behaviour(SelectedItemsBehaviour) public var selectedItems:Array<Dynamic>;
@:clonable @:behaviour(SelectionModeBehaviour, SelectionMode.ONE_ITEM) public var selectionMode:SelectionMode;
@:clonable @:behaviour(DefaultBehaviour, 500) public var longPressSelectionTime:Int; //ms
@:clonable @:value(selectedIndex) public var value:Dynamic;
@:event(ItemEvent.COMPONENT_EVENT) public var onComponentEvent:ItemEvent->Void;
public function selectItemBy(fn:Dynamic->Bool, allowUnselection:Bool = false) {
var indexToSelect = -1;
for (i in 0...this.dataSource.size) {
var item = this.dataSource.get(i);
if (fn(item)) {
indexToSelect = i;
break;
}
}
if (allowUnselection && indexToSelect != this.selectedIndex) {
this.selectedIndex = indexToSelect;
} else if (indexToSelect != -1 && indexToSelect != this.selectedIndex) {
this.selectedIndex = indexToSelect;
}
}
private var _itemRendererClass:Class<ItemRenderer>;
@:clonable public var itemRendererClass(get, set):Class<ItemRenderer>;
private function get_itemRendererClass():Class<ItemRenderer> {
return _itemRendererClass;
}
private function set_itemRendererClass(value:Class<ItemRenderer>):Class<ItemRenderer> {
if (_itemRendererClass != value) {
_itemRendererClass = value;
invalidateComponentLayout();
}
return value;
}
private var _itemRenderer:ItemRenderer;
@:clonable public var itemRenderer(get, set):ItemRenderer;
private function get_itemRenderer():ItemRenderer {
return _itemRenderer;
}
private function set_itemRenderer(value:ItemRenderer):ItemRenderer {
if (_itemRenderer != value) {
_itemRenderer = value;
invalidateComponentLayout();
}
return value;
}
}
//***********************************************************************************************************
// Events
//***********************************************************************************************************
@:dox(hide) @:noCompletion
@:access(haxe.ui.core.Component)
class ListViewEvents extends ScrollViewEvents {
private var _listview:ListView;
public var lastEvent:UIEvent;
public function new(listview:ListView) {
super(listview);
_listview = listview;
}
public override function register() {
super.register();
registerEvent(ScrollEvent.CHANGE, onScrollChange);
registerEvent(UIEvent.RENDERER_CREATED, onRendererCreated);
registerEvent(UIEvent.RENDERER_DESTROYED, onRendererDestroyed);
}
public override function unregister() {
super.unregister();
unregisterEvent(ScrollEvent.CHANGE, onScrollChange);
unregisterEvent(UIEvent.RENDERER_CREATED, onRendererCreated);
unregisterEvent(UIEvent.RENDERER_DESTROYED, onRendererDestroyed);
}
private function onScrollChange(e:ScrollEvent) {
if (_listview.virtual == true) {
_listview.invalidateComponentLayout();
}
}
private function onRendererCreated(e:UIEvent) {
var instance:ItemRenderer = cast(e.data, ItemRenderer);
instance.registerEvent(MouseEvent.MOUSE_DOWN, onRendererMouseDown);
instance.registerEvent(MouseEvent.CLICK, onRendererClick);
instance.registerEvent(MouseEvent.RIGHT_CLICK, onRendererClick);
if (_listview.selectedIndices.indexOf(instance.itemIndex) != -1) {
var builder:ListViewBuilder = cast(_listview._compositeBuilder, ListViewBuilder);
builder.addItemRendererClass(instance, ":selected");
}
}
private function onRendererDestroyed(e:UIEvent) {
var instance:ItemRenderer = cast(e.data, ItemRenderer);
instance.unregisterEvent(MouseEvent.MOUSE_DOWN, onRendererMouseDown);
instance.unregisterEvent(MouseEvent.CLICK, onRendererClick);
instance.unregisterEvent(MouseEvent.RIGHT_CLICK, onRendererClick);
if (_listview.selectedIndices.indexOf(instance.itemIndex) != -1) {
var builder:ListViewBuilder = cast(_listview._compositeBuilder, ListViewBuilder);
builder.addItemRendererClass(instance, ":selected", false);
}
}
private function onRendererMouseDown(e:MouseEvent) {
_listview.focus = true;
switch (_listview.selectionMode) {
case SelectionMode.MULTIPLE_LONG_PRESS:
if (_listview.selectedIndices.length == 0) {
startLongPressSelection(e);
}
default:
if (_listview.hasClass(":mobile") == false) {
e.target.addClass(":hover");
}
}
}
private function startLongPressSelection(e:MouseEvent) {
var timerClick:Timer = null;
var currentMouseX:Float = e.screenX;
var currentMouseY:Float = e.screenY;
var renderer:ItemRenderer = cast(e.target, ItemRenderer);
var __onMouseMove:MouseEvent->Void = null;
var __onMouseUp:MouseEvent->Void = null;
var __onMouseClick:MouseEvent->Void = null;
__onMouseMove = function (_e:MouseEvent) {
currentMouseX = _e.screenX;
currentMouseY = _e.screenY;
}
__onMouseUp = function (_e:MouseEvent) {
if (timerClick != null) {
timerClick.stop();
timerClick = null;
}
renderer.screen.unregisterEvent(MouseEvent.MOUSE_MOVE, __onMouseMove);
renderer.screen.unregisterEvent(MouseEvent.MOUSE_UP, __onMouseUp);
}
__onMouseClick = function(_e:MouseEvent) {
_e.cancel(); //Avoid toggleSelection onRendererClick method
renderer.unregisterEvent(MouseEvent.CLICK, __onMouseClick);
}
renderer.screen.registerEvent(MouseEvent.MOUSE_MOVE, __onMouseMove);
renderer.screen.registerEvent(MouseEvent.MOUSE_UP, __onMouseUp);
timerClick = Timer.delay(function(){
if (timerClick != null) {
timerClick = null;
if (renderer.hitTest(currentMouseX, currentMouseY) &&
MathUtil.distance(e.screenX, e.screenY, currentMouseX, currentMouseY) < 2 * Toolkit.pixelsPerRem) {
toggleSelection(renderer);
renderer.registerEvent(MouseEvent.CLICK, __onMouseClick, 1);
}
}
}, _listview.longPressSelectionTime);
}
private override function onContainerEventsStatusChanged() {
super.onContainerEventsStatusChanged();
if (_containerEventsPaused == true) {
_scrollview.findComponent("listview-contents", Component, true, "css").removeClass(":hover", true, true);
} else if (_lastMousePos != null) {
/* TODO: may be ill concieved, doesnt look good on mobile
var items = _scrollview.findComponentsUnderPoint(_lastMousePos.x, _lastMousePos.y, ItemRenderer);
for (i in items) {
i.addClass(":hover", true, true);
}
*/
}
}
private function onRendererClick(e:MouseEvent) {
if (_containerEventsPaused == true) {
return;
}
var components = e.target.findComponentsUnderPoint(e.screenX, e.screenY);
for (component in components) {
if (component != e.target && (component is InteractiveComponent) && cast(component, InteractiveComponent).allowInteraction == true) {
return;
}
}
lastEvent = e;
var renderer:ItemRenderer = cast(e.target, ItemRenderer);
switch (_listview.selectionMode) {
case SelectionMode.DISABLED:
case SelectionMode.ONE_ITEM:
_listview.selectedIndex = renderer.itemIndex;
case SelectionMode.ONE_ITEM_REPEATED:
_listview.selectedIndices = [renderer.itemIndex];
case SelectionMode.MULTIPLE, SelectionMode.MULTIPLE_MODIFIER_KEY, SelectionMode.MULTIPLE_CLICK_MODIFIER_KEY:
if (e.ctrlKey == true) {
toggleSelection(renderer);
} else if (e.shiftKey == true) {
var selectedIndices:Array<Int> = _listview.selectedIndices;
var fromIndex:Int = selectedIndices.length > 0 ? selectedIndices[selectedIndices.length - 1]: 0;
var toIndex:Int = renderer.itemIndex;
if (fromIndex < toIndex) {
for (i in selectedIndices) {
if (i < fromIndex) {
fromIndex = i;
}
}
} else {
var tmp:Int = fromIndex;
fromIndex = toIndex;
toIndex = tmp;
}
selectRange(fromIndex, toIndex);
} else if (_listview.selectionMode == SelectionMode.MULTIPLE || _listview.selectionMode == SelectionMode.MULTIPLE_CLICK_MODIFIER_KEY) {
_listview.selectedIndex = renderer.itemIndex;
}
case SelectionMode.MULTIPLE_LONG_PRESS:
var selectedIndices:Array<Int> = _listview.selectedIndices;
if (selectedIndices.length > 0) {
toggleSelection(renderer);
}
default:
//Nothing
}
}
private function toggleSelection(renderer:ItemRenderer) {
var itemIndex:Int = renderer.itemIndex;
var selectedIndices = _listview.selectedIndices.copy();
var index:Int;
if ((index = selectedIndices.indexOf(itemIndex)) == -1) {
selectedIndices.push(itemIndex);
} else {
selectedIndices.splice(index, 1);
}
_listview.selectedIndices = selectedIndices;
}
private function selectRange(fromIndex:Int, toIndex:Int) {
_listview.selectedIndices = [for (i in fromIndex...toIndex + 1) i];
}
private override function onActionStart(event:ActionEvent) {
lastEvent = event;
switch (event.action) {
case ActionType.DOWN:
if (_listview.selectedIndex < 0) {
_listview.selectedIndex = 0;
} else {
var n:Int = _listview.selectedIndex;
n++;
if (n > _listview.dataSource.size - 1) {
n = 0;
}
_listview.selectedIndex = n;
}
event.repeater = true;
case ActionType.UP:
if (_listview.selectedIndex < 0) {
_listview.selectedIndex = _listview.dataSource.size - 1;
} else {
var n:Int = _listview.selectedIndex;
n--;
if (n < 0) {
n = _listview.selectedIndex = _listview.dataSource.size - 1;
}
_listview.selectedIndex = n;
}
event.repeater = true;
case _:
}
}
}
//***********************************************************************************************************
// Composite Builder
//***********************************************************************************************************
@:dox(hide) @:noCompletion
private class ListViewBuilder extends ScrollViewBuilder {
private var _listview:ListView;
public function new(listview:ListView) {
super(listview);
_listview = listview;
}
public override function create() {
createContentContainer(_listview.virtual ? "absolute" : "vertical");
}
private override function createContentContainer(layoutName:String) {
if (_contents == null) {
super.createContentContainer(layoutName);
_contents.addClass("listview-contents");
}
}
@:access(haxe.ui.backend.ComponentImpl)
public override function addComponent(child:Component):Component {
var r = null;
if ((child is ItemRenderer) && (_listview.itemRenderer == null && _listview.itemRendererClass == null)) {
_listview.itemRenderer = cast(child, ItemRenderer);
_listview.itemRenderer.ready();
_listview.itemRenderer.handleVisibility(false);
r = child;
} else {
r = super.addComponent(child);
}
return r;
}
public override function onVirtualChanged() {
_contents.layoutName = _listview.virtual ? "absolute" : "vertical";
}
public function addItemRendererClass(child:Component, className:String, add:Bool = true) {
child.walkComponents(function(c) {
if ((c is ItemRenderer)) {
if (add == true) {
c.addClass(className);
Toolkit.callLater(function() {
ensureVisible(cast(c, ItemRenderer));
});
} else {
c.removeClass(className);
}
} else {
c.invalidateComponentStyle(); // we do want to invalidate the other components incase the css rule applies indirectly
}
return true;
});
}
private function ensureVisible(itemToEnsure:ItemRenderer) {
if (itemToEnsure != null && _listview.virtual == false) {
var vscroll:VerticalScroll = _listview.findComponent(VerticalScroll);
if (vscroll != null) {
var vpos:Float = vscroll.pos;
var contents:Component = _listview.findComponent("listview-contents", "css");
if (itemToEnsure.top + itemToEnsure.height > vpos + contents.componentClipRect.height) {
vscroll.pos = ((itemToEnsure.top + itemToEnsure.height) - contents.componentClipRect.height);
} else if (itemToEnsure.top < vpos) {
vscroll.pos = itemToEnsure.top;
}
}
}
}
@:access(haxe.ui.layouts.VerticalVirtualLayout)
private function ensureVirtualItemVisible(index:Int) {
var vscroll:VerticalScroll = _listview.findComponent(VerticalScroll);
if (vscroll != null) {
var layout = cast(_listview.layout, VerticalVirtualLayout);
var itemHeight = layout.itemHeight;
var itemTop = index * itemHeight;
var vpos:Float = vscroll.pos;
var contents:Component = _listview.findComponent("listview-contents", "css");
if (itemTop + itemHeight > vpos + contents.componentClipRect.height) {
vscroll.pos = ((itemTop + itemHeight) - contents.componentClipRect.height);
} else if (itemTop < vpos) {
vscroll.pos = itemTop;
}
}
}
public override function applyStyle(style:Style) {
super.applyStyle(style);
haxe.ui.macros.ComponentMacros.cascadeStylesToList(Label, [color, fontName, fontSize, cursor, textAlign]);
}
}
//***********************************************************************************************************
// Behaviours
//***********************************************************************************************************
@:dox(hide) @:noCompletion
private class DataSourceBehaviour extends DataBehaviour {
private var _firstPass:Bool = true; // may not have any any children at first, so a height of 1 causes loads of renderers to be created
public override function set(value:Variant) {
super.set(value);
var dataSource:DataSource<Dynamic> = _value;
if (dataSource != null) {
dataSource.onDataSourceChange = function() {
_component.invalidateComponentLayout();
if (_firstPass == true) {
//_component.syncComponentValidation();
_firstPass = false;
_component.invalidateComponentLayout();
}
dispatchChanged();
}
_component.invalidateComponentLayout();
} else {
_component.invalidateComponentLayout();
}
dispatchChanged();
}
public override function get():Variant {
if (_value == null || _value.isNull) {
_value = new ArrayDataSource<Dynamic>();
set(_value);
}
return _value;
}
private function dispatchChanged() {
Toolkit.callLater(function() {
_component.dispatch(new UIEvent(UIEvent.PROPERTY_CHANGE, false, "dataSource"));
});
}
}
@:dox(hide) @:noCompletion
private class SelectedIndexBehaviour extends Behaviour {
public override function get():Variant {
var listView:ListView = cast(_component, ListView);
var selectedIndices:Array<Int> = listView.selectedIndices;
return selectedIndices != null && selectedIndices.length > 0 ? selectedIndices[selectedIndices.length - 1] : -1;
}
public override function set(value:Variant) {
var listView:ListView = cast(_component, ListView);
listView.selectedIndices = value != -1 ? [value] : null;
}
}
@:dox(hide) @:noCompletion
private class SelectedItemBehaviour extends Behaviour {
public override function getDynamic():Dynamic {
var listView:ListView = cast(_component, ListView);
var selectedIndices:Array<Int> = listView.selectedIndices;
return selectedIndices.length > 0 ? listView.dataSource.get(selectedIndices[selectedIndices.length - 1]) : null;
}
public override function set(value:Variant) {
var listView:ListView = cast(_component, ListView);
var index:Int = listView.dataSource.indexOf(value);
if (index != -1 && listView.selectedIndices.indexOf(index) == -1) {
listView.selectedIndices = [index];
}
}
}
@:dox(hide) @:noCompletion
@:access(haxe.ui.core.Component)
private class SelectedIndicesBehaviour extends DataBehaviour {
public override function get():Variant {
return _value.isNull ? [] : _value;
}
private override function validateData() {
var listView:ListView = cast(_component, ListView);
var selectedIndices:Array<Int> = listView.selectedIndices;
var contents:Component = _component.findComponent("scrollview-contents", false, "css");
var builder:ListViewBuilder = cast(_component._compositeBuilder, ListViewBuilder);
var events:ListViewEvents = cast(_component._internalEvents, ListViewEvents);
for (child in contents.childComponents) {
if (selectedIndices.indexOf(cast(child, ItemRenderer).itemIndex) != -1) {
builder.addItemRendererClass(child, ":selected");
} else {
builder.addItemRendererClass(child, ":selected", false);
}
}
if (listView.virtual == true) {
for (i in selectedIndices) {
@:privateAccess builder.ensureVirtualItemVisible(i);
}
}
if (listView.selectedIndex != -1 && listView.selectedIndices.length != 0) {
var event = new UIEvent(UIEvent.CHANGE);
event.relatedEvent = events.lastEvent;
_component.dispatch(event);
}
}
}
@:dox(hide) @:noCompletion
private class SelectedItemsBehaviour extends Behaviour {
public override function get():Variant {
var listView:ListView = cast(_component, ListView);
var selectedIndices:Array<Int> = listView.selectedIndices;
if (selectedIndices != null && selectedIndices.length > 0) {
var selectedItems:Array<Dynamic> = [];
for (i in selectedIndices) {
if ((i < 0) || (i >= listView.dataSource.size)) {
continue;
}
var data:Dynamic = listView.dataSource.get(i);
selectedItems.push(data);
}
return selectedItems;
} else {
return [];
}
}
public override function set(value:Variant) {
var listView:ListView = cast(_component, ListView);
var selectedItems:Array<Dynamic> = value;
if (selectedItems != null && selectedItems.length > 0) {
var selectedIndices:Array<Int> = [];
var index:Int;
for (item in selectedItems) {
if ((index = listView.dataSource.indexOf(item)) != -1) {
selectedIndices.push(index);
}
}
listView.selectedIndices = selectedIndices;
} else {
listView.selectedIndices = [];
}
}
}
@:dox(hide) @:noCompletion
private class SelectionModeBehaviour extends DataBehaviour {
private override function validateData() {
var listView:ListView = cast(_component, ListView);
var selectedIndices:Array<Int> = listView.selectedIndices;
if (selectedIndices == null || selectedIndices.length == 0) {
return;
}
var selectionMode:SelectionMode = _value.toString();
switch (selectionMode) {
case SelectionMode.DISABLED:
listView.selectedIndices = null;
case SelectionMode.ONE_ITEM:
if (selectedIndices.length > 1) {
listView.selectedIndices = [selectedIndices[0]];
}
default:
}
}
}