| // Copyright (c) 2011, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| part of swarmlib; |
| |
| // This file contains View framework classes. |
| // As it grows, it may need to be split into multiple files. |
| |
| /// A factory that creates a view from a data model. */ |
| abstract class ViewFactory<D> { |
| View newView(D item); |
| |
| /// The width of the created view or null if the width is not fixed. */ |
| int? get width; |
| |
| /// The height of the created view or null if the height is not fixed. */ |
| int? get height; |
| } |
| |
| abstract class VariableSizeViewFactory<D> { |
| View newView(D item); |
| |
| /// The width of the created view for a specific data model. */ |
| int getWidth(D? item); |
| |
| /// The height of the created view for a specific data model. */ |
| int getHeight(D? item); |
| } |
| |
| /// A collection of event listeners. */ |
| class EventListeners { |
| var listeners; |
| EventListeners() { |
| listeners = []; |
| } |
| |
| void addListener(listener) { |
| listeners.add(listener); |
| } |
| |
| void fire(var event) { |
| for (final listener in listeners) { |
| listener(event); |
| } |
| } |
| } |
| |
| /// Private view class used to store placeholder views for detached ListView |
| /// elements. |
| class _PlaceholderView extends View { |
| _PlaceholderView(); |
| |
| @override |
| Element render() => Element.tag('div'); |
| } |
| |
| /// Class providing all metrics required to layout a data driven list view. |
| abstract class ListViewLayout<D> { |
| void onDataChange(); |
| |
| // TODO(jacobr): placing the newView member function on this class seems like |
| // the wrong design. |
| View newView(int index); |
| |
| /// Get the height of the view. Possibly expensive to compute. */ |
| int? getHeight(int viewLength); |
| |
| /// Get the width of the view. Possibly expensive to compute. */ |
| int? getWidth(int viewLength); |
| |
| /// Get the length of the view. Possible expensive to compute. */ |
| int getLength(int viewLength); |
| |
| /// Estimated height of the view. Guaranteed to be fast to compute. */ |
| int? getEstimatedHeight(int viewLength); |
| |
| /// Estimated with of the view. Guaranteed to be fast to compute. */ |
| int? getEstimatedWidth(int viewLength); |
| |
| /// Returns the offset in px that the ith item in the view should be placed |
| /// at. |
| int getOffset(int index); |
| |
| /// The page the ith item in the view should be placed in. |
| int getPage(int index, int viewLength); |
| int getPageStartIndex(int index, int viewLength); |
| |
| int getEstimatedLength(int viewLength); |
| |
| /// Snap a specified index to the nearest visible view given the [viewLength]. |
| int getSnapIndex(num offset, int viewLength); |
| |
| /// Returns an interval specifying what views are currently visible given a |
| /// particular [:offset:]. |
| Interval<int> computeVisibleInterval( |
| num offset, |
| num viewLength, |
| num bufferLength, |
| ); |
| } |
| |
| /// Base class used for the simple fixed size item [:ListView:] classes and more |
| /// complex list view classes such as [:VariableSizeListView:] using a |
| /// [:ListViewLayout:] class to drive the actual layout. |
| class GenericListView<D> extends View { |
| /// Minimum throw distance in pixels to trigger snapping to the next item. */ |
| static const SNAP_TO_NEXT_THROW_THRESHOLD = 15; |
| |
| static const INDEX_DATA_ATTRIBUTE = 'data-index'; |
| |
| final bool _scrollable; |
| final bool _showScrollbar; |
| final bool _snapToItems; |
| Scroller? scroller; |
| Scrollbar? _scrollbar; |
| final List<D> _data; |
| final ObservableValue<D?>? _selectedItem; |
| final Map<int, View> _itemViews; |
| late Element _containerElem; |
| final bool _vertical; |
| |
| /// Length of the scrollable dimension of the view in px. */ |
| int _viewLength = 0; |
| Interval<int> _activeInterval; |
| final bool _paginate; |
| final bool _removeClippedViews; |
| final ListViewLayout<D> _layout; |
| D? _lastSelectedItem; |
| final PageState? _pages; |
| |
| /// Creates a new GenericListView with the given layout and data. If [:_data:] |
| /// is an [:ObservableList<T>:] then it will listen to changes to the list |
| /// and update the view appropriately. |
| GenericListView( |
| this._layout, |
| this._data, |
| this._scrollable, |
| this._vertical, |
| this._selectedItem, |
| this._snapToItems, |
| this._paginate, |
| this._removeClippedViews, |
| this._showScrollbar, |
| this._pages, |
| ) : _activeInterval = Interval(0, 0), |
| _itemViews = <int, View>{} { |
| // TODO(rnystrom): Move this into enterDocument once we have an exitDocument |
| // that we can use to unregister it. |
| if (_scrollable) { |
| window.onResize.listen((Event event) { |
| if (isInDocument) { |
| onResize(); |
| } |
| }); |
| } |
| } |
| |
| void onSelectedItemChange() { |
| // TODO(rnystrom): use Observable to track the last value of _selectedItem |
| // rather than tracking it ourselves. |
| _select(findIndex(_lastSelectedItem), false); |
| _select(findIndex(_selectedItem!.value), true); |
| _lastSelectedItem = _selectedItem.value; |
| } |
| |
| @override |
| Iterable<View> get childViews { |
| return _itemViews.values.toList(); |
| } |
| |
| void _onClick(MouseEvent e) { |
| int? index = _findAssociatedIndex(e.target as Node?); |
| if (index != null) { |
| _selectedItem!.value = _data[index]; |
| } |
| } |
| |
| int? _findAssociatedIndex(Node? leafNode) { |
| Node? node = leafNode; |
| while (node != null && node != _containerElem) { |
| if (node.parent == _containerElem) { |
| return _nodeToIndex(node as Element); |
| } |
| node = node.parent; |
| } |
| return null; |
| } |
| |
| int? _nodeToIndex(Element node) { |
| // TODO(jacobr): use data attributes when available. |
| String? index = node.attributes[INDEX_DATA_ATTRIBUTE]; |
| if (index != null && index.isNotEmpty) { |
| return int.parse(index); |
| } |
| return null; |
| } |
| |
| @override |
| Element render() { |
| final node = Element.tag('div'); |
| if (_scrollable) { |
| _containerElem = Element.tag('div'); |
| _containerElem.tabIndex = -1; |
| node.nodes.add(_containerElem); |
| } else { |
| _containerElem = node; |
| } |
| |
| if (_scrollable) { |
| scroller = Scroller( |
| _containerElem, |
| _vertical /* verticalScrollEnabled */, |
| !_vertical /* horizontalScrollEnabled */, |
| true /* momentumEnabled */, |
| () { |
| num? width = _layout.getWidth(_viewLength); |
| num? height = _layout.getHeight(_viewLength); |
| width = width ?? 0; |
| height = height ?? 0; |
| return Size(width, height); |
| }, |
| _paginate && _snapToItems ? Scroller.FAST_SNAP_DECELERATION_FACTOR : 1, |
| ); |
| scroller!.onContentMoved.listen((e) => renderVisibleItems(false)); |
| if (_pages != null) { |
| watch(_pages.target, (s) => _onPageSelected()); |
| } |
| |
| if (_snapToItems) { |
| scroller!.onDecelStart.listen((e) => _decelStart()); |
| scroller!.onScrollerDragEnd.listen((e) => _decelStart()); |
| } |
| if (_showScrollbar) { |
| _scrollbar = Scrollbar(scroller!, true); |
| } |
| } else { |
| _reserveArea(); |
| renderVisibleItems(true); |
| } |
| |
| return node; |
| } |
| |
| @override |
| void afterRender(Element node) { |
| // If our data source is observable, observe it. |
| final data = _data; |
| if (data is ObservableList<D>) { |
| attachWatch(data, (EventSummary e) { |
| if (e.target == data) { |
| onDataChange(); |
| } |
| }); |
| } |
| |
| if (_selectedItem != null) { |
| addOnClick((Event e) { |
| _onClick(e as MouseEvent); |
| }); |
| } |
| |
| if (_selectedItem != null) { |
| watch(_selectedItem, (EventSummary summary) => onSelectedItemChange()); |
| } |
| } |
| |
| void onDataChange() { |
| _layout.onDataChange(); |
| _renderItems(); |
| } |
| |
| void _reserveArea() { |
| final style = _containerElem.style; |
| int? width = _layout.getWidth(_viewLength); |
| int? height = _layout.getHeight(_viewLength); |
| if (width != null) { |
| style.width = '${width}px'; |
| } |
| if (height != null) { |
| style.height = '${height}px'; |
| } |
| // TODO(jacobr): this should be specified by the default CSS for a |
| // GenericListView. |
| style.overflow = 'hidden'; |
| } |
| |
| void onResize() { |
| int lastViewLength = _viewLength; |
| scheduleMicrotask(() { |
| _viewLength = (_vertical ? node.offset.height : node.offset.width) as int; |
| if (_viewLength != lastViewLength) { |
| if (_scrollbar != null) { |
| _scrollbar!.refresh(); |
| } |
| renderVisibleItems(true); |
| } |
| }); |
| } |
| |
| @override |
| void enterDocument() { |
| if (scroller != null) { |
| onResize(); |
| |
| if (_scrollbar != null) { |
| _scrollbar!.initialize(); |
| } |
| } |
| } |
| |
| int getNextIndex(int index, bool forward) { |
| int delta = forward ? 1 : -1; |
| if (_paginate) { |
| int newPage = Math.max(0, _layout.getPage(index, _viewLength) + delta); |
| index = _layout.getPageStartIndex(newPage, _viewLength); |
| } else { |
| index += delta; |
| } |
| return GoogleMath.clamp(index, 0, _data.length - 1); |
| } |
| |
| void _decelStart() { |
| final scroll = scroller!; |
| num currentTarget = scroll.verticalEnabled |
| ? scroll.currentTarget.y |
| : scroll.currentTarget.x; |
| num current = scroll.verticalEnabled |
| ? scroll.contentOffset.y |
| : scroll.contentOffset.x; |
| int targetIndex = _layout.getSnapIndex(currentTarget, _viewLength); |
| if (current != currentTarget) { |
| // The user is throwing rather than statically releasing. |
| // For this case, we want to move them to the next snap interval |
| // as long as they made at least a minimal throw gesture. |
| num currentIndex = _layout.getSnapIndex(current, _viewLength); |
| if (currentIndex == targetIndex && |
| (currentTarget - current).abs() > SNAP_TO_NEXT_THROW_THRESHOLD && |
| -_layout.getOffset(targetIndex) != currentTarget) { |
| num snappedCurrentPosition = -_layout.getOffset(targetIndex); |
| targetIndex = getNextIndex(targetIndex, currentTarget < current); |
| } |
| } |
| num targetPosition = -_layout.getOffset(targetIndex); |
| if (currentTarget != targetPosition) { |
| if (scroll.verticalEnabled) { |
| scroll.throwTo(scroll.contentOffset.x, targetPosition); |
| } else { |
| scroll.throwTo(targetPosition, scroll.contentOffset.y); |
| } |
| } else { |
| // Update the target page only after we are all done animating. |
| if (_pages != null) { |
| _pages.target.value = _layout.getPage(targetIndex, _viewLength); |
| } |
| } |
| } |
| |
| void _renderItems() { |
| for (int i = _activeInterval.start; i < _activeInterval.end; i++) { |
| _removeView(i); |
| } |
| _itemViews.clear(); |
| _activeInterval = Interval(0, 0); |
| if (scroller == null) { |
| _reserveArea(); |
| } |
| renderVisibleItems(false); |
| } |
| |
| void _onPageSelected() { |
| if (_pages!.target != _layout.getPage(_activeInterval.start, _viewLength)) { |
| _throwTo( |
| _layout.getOffset( |
| _layout.getPageStartIndex(_pages.target.value, _viewLength), |
| ), |
| ); |
| } |
| } |
| |
| num get _offset { |
| return scroller!.verticalEnabled |
| ? scroller!.getVerticalOffset() |
| : scroller!.getHorizontalOffset(); |
| } |
| |
| /// Calculates visible interval, based on the scroller position. |
| Interval<int> getVisibleInterval() { |
| return _layout.computeVisibleInterval(_offset, _viewLength, 0); |
| } |
| |
| void renderVisibleItems(bool lengthChanged) { |
| Interval<int> targetInterval; |
| if (scroller != null) { |
| targetInterval = getVisibleInterval(); |
| } else { |
| // If the view is not scrollable, render all elements. |
| targetInterval = Interval(0, _data.length); |
| } |
| |
| if (_pages != null) { |
| _pages.current.value = _layout.getPage(targetInterval.start, _viewLength); |
| } |
| if (_pages != null) { |
| _pages.length.value = _data.isNotEmpty |
| ? _layout.getPage(_data.length - 1, _viewLength) + 1 |
| : 0; |
| } |
| |
| if (!_removeClippedViews) { |
| // Avoid removing clipped views by extending the target interval to |
| // include the existing interval of rendered views. |
| targetInterval = targetInterval.union(_activeInterval); |
| } |
| |
| if (lengthChanged == false && targetInterval == _activeInterval) { |
| return; |
| } |
| |
| // TODO(jacobr): add unittests that this code behaves correctly. |
| |
| // Remove views that are not needed anymore |
| for ( |
| int i = _activeInterval.start, |
| end = Math.min(targetInterval.start, _activeInterval.end); |
| i < end; |
| i++ |
| ) { |
| _removeView(i); |
| } |
| for ( |
| int i = Math.max(targetInterval.end, _activeInterval.start); |
| i < _activeInterval.end; |
| i++ |
| ) { |
| _removeView(i); |
| } |
| |
| // Add new views |
| for ( |
| int i = targetInterval.start, |
| end = Math.min(_activeInterval.start, targetInterval.end); |
| i < end; |
| i++ |
| ) { |
| _addView(i); |
| } |
| for ( |
| int i = Math.max(_activeInterval.end, targetInterval.start); |
| i < targetInterval.end; |
| i++ |
| ) { |
| _addView(i); |
| } |
| |
| _activeInterval = targetInterval; |
| } |
| |
| void _removeView(int index) { |
| // Do not remove placeholder views as they need to stay present in case |
| // they scroll out of view and then back into view. |
| final nodeView = _itemViews[index]!; |
| if (nodeView is! _PlaceholderView) { |
| // Remove from the active DOM but don't destroy. |
| nodeView.node.remove(); |
| childViewRemoved(nodeView); |
| } |
| } |
| |
| View _newView(int index) { |
| final view = _layout.newView(index); |
| view.node.attributes[INDEX_DATA_ATTRIBUTE] = index.toString(); |
| return view; |
| } |
| |
| View _addView(int index) { |
| if (_itemViews.containsKey(index)) { |
| final view = _itemViews[index]!; |
| _addViewHelper(view, index); |
| childViewAdded(view); |
| return view; |
| } |
| |
| final view = _newView(index); |
| _itemViews[index] = view; |
| // TODO(jacobr): its ugly to put this here... but its needed |
| // as typical even-odd css queries won't work as we only display some |
| // children at a time. |
| if (index == 0) { |
| view.addClass('first-child'); |
| } |
| _selectHelper(view, _data[index] == _lastSelectedItem); |
| // The order of the child elements doesn't matter as we use absolute |
| // positioning. |
| _addViewHelper(view, index); |
| childViewAdded(view); |
| return view; |
| } |
| |
| void _addViewHelper(View view, int index) { |
| _positionSubview(view.node, index); |
| // The view might already be attached. |
| if (view.node.parent != _containerElem) { |
| _containerElem.nodes.add(view.node); |
| } |
| } |
| |
| /// Detach a subview from the view replacing it with an empty placeholder view. |
| /// The detached subview can be safely reparented. |
| View detachSubview(D itemData) { |
| int index = findIndex(itemData)!; |
| View? view = _itemViews[index]; |
| if (view == null) { |
| // Edge case: add the view so we can detach as the view is currently |
| // outside but might soon be inside the visible area. |
| assert(!_activeInterval.contains(index)); |
| _addView(index); |
| view = _itemViews[index]; |
| } |
| final placeholder = _PlaceholderView(); |
| view!.node.replaceWith(placeholder.node); |
| _itemViews[index] = placeholder; |
| return view; |
| } |
| |
| /// Reattach a subview from the view that was detached from the view |
| /// by calling detachSubview. [callback] is called once the subview is |
| /// reattached and done animating into position. |
| void reattachSubview(D data, View view, bool animate) { |
| int index = findIndex(data)!; |
| // TODO(jacobr): perform some validation that the view is |
| // really detached. |
| late Coordinate currentPosition; |
| if (animate) { |
| currentPosition = FxUtil.computeRelativePosition( |
| view.node, |
| _containerElem, |
| ); |
| } |
| assert(_itemViews[index] is _PlaceholderView); |
| view.enterDocument(); |
| _itemViews[index]!.node.replaceWith(view.node); |
| _itemViews[index] = view; |
| if (animate) { |
| FxUtil.setTranslate(view.node, currentPosition.x, currentPosition.y, 0); |
| // The view's position is unchanged except now re-parented to |
| // the list view. |
| Timer.run(() { |
| _positionSubview(view.node, index); |
| }); |
| } else { |
| _positionSubview(view.node, index); |
| } |
| } |
| |
| int? findIndex(D? targetItem) { |
| // TODO(jacobr): move this to a util library or modify this class so that |
| // the data is an List not a Collection. |
| int i = 0; |
| for (D item in _data) { |
| if (item == targetItem) { |
| return i; |
| } |
| i++; |
| } |
| return null; |
| } |
| |
| void _positionSubview(Element node, int index) { |
| if (_vertical) { |
| FxUtil.setTranslate(node, 0, _layout.getOffset(index), 0); |
| } else { |
| FxUtil.setTranslate(node, _layout.getOffset(index), 0, 0); |
| } |
| node.style.zIndex = index.toString(); |
| } |
| |
| void _select(int? index, bool selected) { |
| if (index != null) { |
| final subview = getSubview(index); |
| if (subview != null) { |
| _selectHelper(subview, selected); |
| } |
| } |
| } |
| |
| void _selectHelper(View view, bool selected) { |
| if (selected) { |
| view.addClass('sel'); |
| } else { |
| view.removeClass('sel'); |
| } |
| } |
| |
| View? getSubview(int index) { |
| return _itemViews[index]; |
| } |
| |
| void showView(D targetItem) { |
| int? index = findIndex(targetItem); |
| if (index != null) { |
| if (_layout.getOffset(index) < -_offset) { |
| _throwTo(_layout.getOffset(index)); |
| } else if (_layout.getOffset(index + 1) > (-_offset + _viewLength)) { |
| // TODO(jacobr): for completeness we should check whether |
| // the current view is longer than _viewLength in which case |
| // there are some nasty edge cases. |
| _throwTo(_layout.getOffset(index + 1) - _viewLength); |
| } |
| } |
| } |
| |
| void _throwTo(num offset) { |
| if (_vertical) { |
| scroller!.throwTo(0, -offset); |
| } else { |
| scroller!.throwTo(-offset, 0); |
| } |
| } |
| } |
| |
| class FixedSizeListViewLayout<D> implements ListViewLayout<D> { |
| final ViewFactory<D> itemViewFactory; |
| final bool _vertical; |
| final List<D> _data; |
| final bool _paginate; |
| |
| FixedSizeListViewLayout( |
| this.itemViewFactory, |
| this._data, |
| this._vertical, |
| this._paginate, |
| ); |
| |
| @override |
| void onDataChange() {} |
| |
| @override |
| View newView(int index) { |
| return itemViewFactory.newView(_data[index]); |
| } |
| |
| int get _itemLength { |
| return (_vertical ? itemViewFactory.height : itemViewFactory.width)!; |
| } |
| |
| @override |
| int? getWidth(int viewLength) { |
| return _vertical ? itemViewFactory.width : getLength(viewLength); |
| } |
| |
| @override |
| int? getHeight(int viewLength) { |
| return _vertical ? getLength(viewLength) : itemViewFactory.height; |
| } |
| |
| @override |
| int? getEstimatedHeight(int viewLength) { |
| // Returns the exact height as it is trivial to compute for this layout. |
| return getHeight(viewLength); |
| } |
| |
| @override |
| int? getEstimatedWidth(int viewLength) { |
| // Returns the exact height as it is trivial to compute for this layout. |
| return getWidth(viewLength); |
| } |
| |
| @override |
| int getEstimatedLength(int viewLength) { |
| // Returns the exact length as it is trivial to compute for this layout. |
| return getLength(viewLength); |
| } |
| |
| @override |
| int getLength(int viewLength) { |
| int itemLength = (_vertical |
| ? itemViewFactory.height |
| : itemViewFactory.width)!; |
| if (viewLength == null || viewLength == 0) { |
| return itemLength * _data.length; |
| } else if (_paginate) { |
| if (_data.isNotEmpty) { |
| final pageLength = getPageLength(viewLength); |
| return getPage(_data.length - 1, viewLength) * pageLength + |
| Math.max(viewLength, pageLength); |
| } else { |
| return 0; |
| } |
| } else { |
| return itemLength * (_data.length - 1) + Math.max(viewLength, itemLength); |
| } |
| } |
| |
| @override |
| int getOffset(int index) { |
| return index * _itemLength; |
| } |
| |
| int getPageLength(int viewLength) { |
| final itemsPerPage = viewLength ~/ _itemLength; |
| return Math.max(1, itemsPerPage) * _itemLength; |
| } |
| |
| @override |
| int getPage(int index, int viewLength) { |
| return getOffset(index) ~/ getPageLength(viewLength); |
| } |
| |
| @override |
| int getPageStartIndex(int page, int viewLength) { |
| return getPageLength(viewLength) ~/ _itemLength * page; |
| } |
| |
| @override |
| int getSnapIndex(num offset, int viewLength) { |
| int index = (-offset / _itemLength).round(); |
| if (_paginate) { |
| index = getPageStartIndex(getPage(index, viewLength), viewLength); |
| } |
| return GoogleMath.clamp(index, 0, _data.length - 1); |
| } |
| |
| @override |
| Interval<int> computeVisibleInterval( |
| num offset, |
| num viewLength, |
| num bufferLength, |
| ) { |
| int targetIntervalStart = Math.max( |
| 0, |
| (-offset - bufferLength) ~/ _itemLength, |
| ); |
| num targetIntervalEnd = GoogleMath.clamp( |
| ((-offset + viewLength + bufferLength) / _itemLength).ceil(), |
| targetIntervalStart, |
| _data.length, |
| ); |
| return Interval(targetIntervalStart, targetIntervalEnd.toInt()); |
| } |
| } |
| |
| /// Simple list view class where each item has fixed width and height. |
| class ListView<D> extends GenericListView<D> { |
| /// Creates a new ListView for the given data. If [:_data:] is an |
| /// [:ObservableList<T>:] then it will listen to changes to the list and |
| /// update the view appropriately. |
| ListView( |
| List<D> data, |
| ViewFactory<D> itemViewFactory, |
| bool scrollable, |
| bool vertical, |
| ObservableValue<D>? selectedItem, [ |
| bool snapToItems = false, |
| bool paginate = false, |
| bool removeClippedViews = false, |
| bool showScrollbar = false, |
| PageState? pages, |
| ]) : super( |
| FixedSizeListViewLayout<D>(itemViewFactory, data, vertical, paginate), |
| data, |
| scrollable, |
| vertical, |
| selectedItem, |
| snapToItems, |
| paginate, |
| removeClippedViews, |
| showScrollbar, |
| pages, |
| ); |
| } |
| |
| /// Layout where each item may have variable size along the axis the list view |
| /// extends. |
| class VariableSizeListViewLayout<D> implements ListViewLayout<D> { |
| final List<D> _data; |
| List<int> _itemOffsets; |
| List<int> _lengths; |
| int _lastOffset = 0; |
| final bool _vertical; |
| final bool _paginate; |
| VariableSizeViewFactory<D> itemViewFactory; |
| Interval<int> _lastVisibleInterval; |
| |
| VariableSizeListViewLayout( |
| this.itemViewFactory, |
| data, |
| this._vertical, |
| this._paginate, |
| ) : _data = data, |
| _lastVisibleInterval = Interval(0, 0), |
| _itemOffsets = <int>[0], |
| _lengths = <int>[]; |
| |
| @override |
| void onDataChange() { |
| _itemOffsets.clear(); |
| _itemOffsets.add(0); |
| _lengths.clear(); |
| } |
| |
| @override |
| View newView(int index) => itemViewFactory.newView(_data[index]); |
| |
| @override |
| int getWidth(int viewLength) { |
| if (_vertical) { |
| return itemViewFactory.getWidth(null); |
| } else { |
| return getLength(viewLength); |
| } |
| } |
| |
| @override |
| int getHeight(int viewLength) { |
| if (_vertical) { |
| return getLength(viewLength); |
| } else { |
| return itemViewFactory.getHeight(null); |
| } |
| } |
| |
| @override |
| int getEstimatedHeight(int viewLength) { |
| if (_vertical) { |
| return getEstimatedLength(viewLength); |
| } else { |
| return itemViewFactory.getHeight(null); |
| } |
| } |
| |
| @override |
| int getEstimatedWidth(int viewLength) { |
| if (_vertical) { |
| return itemViewFactory.getWidth(null); |
| } else { |
| return getEstimatedLength(viewLength); |
| } |
| } |
| |
| // TODO(jacobr): this logic is overly complicated. Replace with something |
| // simpler. |
| @override |
| int getEstimatedLength(int viewLength) { |
| if (_lengths.length == _data.length) { |
| // No need to estimate... we have all the data already. |
| return getLength(viewLength); |
| } |
| if (_itemOffsets.length > 1 && _lengths.isNotEmpty) { |
| // Estimate length by taking the average of the lengths |
| // of the known views. |
| num lengthFromAllButLastElement = 0; |
| if (_itemOffsets.length > 2) { |
| lengthFromAllButLastElement = |
| (getOffset(_itemOffsets.length - 2) - getOffset(0)) * |
| (_data.length / (_itemOffsets.length - 2)); |
| } |
| return (lengthFromAllButLastElement + |
| Math.max(viewLength, _lengths[_lengths.length - 1])) |
| .toInt(); |
| } else { |
| if (_lengths.length == 1) { |
| return Math.max(viewLength, _lengths[0]); |
| } else { |
| return viewLength; |
| } |
| } |
| } |
| |
| @override |
| int getLength(int viewLength) { |
| if (_data.isEmpty) { |
| return viewLength; |
| } else { |
| // Hack so that _lengths[length - 1] is available. |
| getOffset(_data.length); |
| return (getOffset(_data.length - 1) - getOffset(0)) + |
| Math.max(_lengths[_lengths.length - 1], viewLength); |
| } |
| } |
| |
| @override |
| int getOffset(int index) { |
| if (index >= _itemOffsets.length) { |
| int offset = _itemOffsets[_itemOffsets.length - 1]; |
| for (int i = _itemOffsets.length; i <= index; i++) { |
| int length = _vertical |
| ? itemViewFactory.getHeight(_data[i - 1]) |
| : itemViewFactory.getWidth(_data[i - 1]); |
| offset += length; |
| _itemOffsets.add(offset); |
| _lengths.add(length); |
| } |
| } |
| return _itemOffsets[index]; |
| } |
| |
| @override |
| int getPage(int index, int viewLength) { |
| // TODO(jacobr): implement. |
| throw 'Not implemented'; |
| } |
| |
| @override |
| int getPageStartIndex(int page, int viewLength) { |
| // TODO(jacobr): implement. |
| throw 'Not implemented'; |
| } |
| |
| @override |
| int getSnapIndex(num offset, num viewLength) { |
| for (int i = 1; i < _data.length; i++) { |
| if (getOffset(i) + getOffset(i - 1) > -offset * 2) { |
| return i - 1; |
| } |
| } |
| return _data.length - 1; |
| } |
| |
| @override |
| Interval<int> computeVisibleInterval( |
| num offset, |
| num viewLength, |
| num bufferLength, |
| ) { |
| final offsetInt = offset.toInt(); |
| int start = _findFirstItemBefore( |
| -offsetInt - bufferLength, |
| _lastVisibleInterval != null ? _lastVisibleInterval.start : 0, |
| ); |
| int end = _findFirstItemAfter( |
| -offsetInt + viewLength + bufferLength, |
| _lastVisibleInterval != null ? _lastVisibleInterval.end : 0, |
| ); |
| _lastVisibleInterval = Interval(start, Math.max(start, end)); |
| _lastOffset = offsetInt; |
| return _lastVisibleInterval; |
| } |
| |
| int _findFirstItemAfter(num target, int hint) { |
| for (int i = 0; i < _data.length; i++) { |
| if (getOffset(i) > target) { |
| return i; |
| } |
| } |
| return _data.length; |
| } |
| |
| // TODO(jacobr): use hint. |
| int _findFirstItemBefore(num target, int hint) { |
| // We go search this direction delaying computing the actual view size |
| // as long as possible. |
| for (int i = 1; i < _data.length; i++) { |
| if (getOffset(i) >= target) { |
| return i - 1; |
| } |
| } |
| return Math.max(_data.length - 1, 0); |
| } |
| } |
| |
| class VariableSizeListView<D> extends GenericListView<D> { |
| VariableSizeListView( |
| List<D> data, |
| VariableSizeViewFactory<D> itemViewFactory, |
| bool scrollable, |
| bool vertical, |
| ObservableValue<D?> selectedItem, [ |
| bool snapToItems = false, |
| bool paginate = false, |
| bool removeClippedViews = false, |
| bool showScrollbar = false, |
| PageState? pages, |
| ]) : super( |
| VariableSizeListViewLayout(itemViewFactory, data, vertical, paginate), |
| data, |
| scrollable, |
| vertical, |
| selectedItem, |
| snapToItems, |
| paginate, |
| removeClippedViews, |
| showScrollbar, |
| pages, |
| ); |
| } |
| |
| /// A back button that is equivalent to clicking "back" in the browser. */ |
| class BackButton extends View { |
| BackButton(); |
| |
| @override |
| Element render() => Element.html('<div class="back-arrow button"></div>'); |
| |
| @override |
| void afterRender(Element node) { |
| addOnClick((e) => window.history.back()); |
| } |
| } |
| |
| // TODO(terry): Maybe should be part of ButtonView class in appstack/view? |
| /// OS button. */ |
| class PushButtonView extends View { |
| final String _text; |
| final String _cssClass; |
| final _clickHandler; |
| |
| PushButtonView(this._text, this._cssClass, this._clickHandler); |
| |
| @override |
| Element render() { |
| return Element.html('<button class="$_cssClass">$_text</button>'); |
| } |
| |
| @override |
| void afterRender(Element node) { |
| addOnClick(_clickHandler); |
| } |
| } |
| |
| // TODO(terry): Add a drop shadow around edge and corners need to be rounded. |
| // Need to support conveyor for contents of dialog so it's not |
| // larger than the parent window. |
| /// A generic dialog view supports title, done button and dialog content. */ |
| class DialogView extends View { |
| final String _title; |
| final String _cssName; |
| final View _content; |
| late Element container; |
| late PushButtonView _done; |
| |
| DialogView(this._title, this._cssName, this._content); |
| |
| @override |
| Element render() { |
| final node = Element.html(''' |
| <div class="dialog-modal"> |
| <div class="dialog $_cssName"> |
| <div class="dialog-title-area"> |
| <span class="dialog-title">$_title</span> |
| </div> |
| <div class="dialog-body"></div> |
| </div> |
| </div>'''); |
| |
| _done = PushButtonView( |
| 'Done', |
| 'done-button', |
| EventBatch.wrap((e) => onDone()), |
| ); |
| final titleArea = node.querySelector('.dialog-title-area')!; |
| titleArea.nodes.add(_done.node); |
| |
| container = node.querySelector('.dialog-body')!; |
| container.nodes.add(_content.node); |
| |
| return node; |
| } |
| |
| /// Override to handle dialog done. */ |
| void onDone() {} |
| } |