| // Copyright 2015 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:math' as math; |
| import 'dart:sky' as sky; |
| |
| import 'package:newton/newton.dart'; |
| import 'package:sky/animation/animated_simulation.dart'; |
| import 'package:sky/animation/animation_performance.dart'; |
| import 'package:sky/animation/animated_value.dart'; |
| import 'package:sky/animation/curves.dart'; |
| import 'package:sky/animation/scroll_behavior.dart'; |
| import 'package:sky/rendering/box.dart'; |
| import 'package:sky/rendering/viewport.dart'; |
| import 'package:sky/theme/view_configuration.dart' as config; |
| import 'package:sky/widgets/basic.dart'; |
| import 'package:sky/widgets/framework.dart'; |
| import 'package:sky/widgets/mixed_viewport.dart'; |
| import 'package:sky/widgets/scrollable.dart'; |
| |
| export 'package:sky/widgets/mixed_viewport.dart' show MixedViewportLayoutState; |
| |
| // The GestureEvent velocity properties are pixels/second, config min,max limits are pixels/ms |
| const double _kMillisecondsPerSecond = 1000.0; |
| const double _kMinFlingVelocity = -config.kMaxFlingVelocity * _kMillisecondsPerSecond; |
| const double _kMaxFlingVelocity = config.kMaxFlingVelocity * _kMillisecondsPerSecond; |
| |
| typedef void ScrollListener(); |
| |
| /// A base class for scrollable widgets that reacts to user input and generates |
| /// a scrollOffset. |
| abstract class Scrollable extends StatefulComponent { |
| |
| Scrollable({ |
| Key key, |
| this.initialScrollOffset, |
| this.scrollDirection: ScrollDirection.vertical |
| }) : super(key: key) { |
| assert(scrollDirection == ScrollDirection.vertical || |
| scrollDirection == ScrollDirection.horizontal); |
| } |
| |
| double initialScrollOffset; |
| ScrollDirection scrollDirection; |
| |
| AnimatedSimulation _toEndAnimation; // See _startToEndAnimation() |
| ValueAnimation<double> _toOffsetAnimation; // Started by scrollTo() |
| |
| void initState() { |
| if (initialScrollOffset is double) |
| _scrollOffset = initialScrollOffset; |
| _toEndAnimation = new AnimatedSimulation(_setScrollOffset); |
| _toOffsetAnimation = new ValueAnimation<double>() |
| ..addListener(() { |
| AnimatedValue<double> offset = _toOffsetAnimation.variable; |
| _setScrollOffset(offset.value); |
| }); |
| } |
| |
| void syncConstructorArguments(Scrollable source) { |
| scrollDirection = source.scrollDirection; |
| } |
| |
| double _scrollOffset = 0.0; |
| double get scrollOffset => _scrollOffset; |
| |
| Offset get scrollOffsetVector { |
| if (scrollDirection == ScrollDirection.horizontal) |
| return new Offset(scrollOffset, 0.0); |
| return new Offset(0.0, scrollOffset); |
| } |
| |
| ScrollBehavior _scrollBehavior; |
| ScrollBehavior createScrollBehavior(); |
| ScrollBehavior get scrollBehavior { |
| if (_scrollBehavior == null) |
| _scrollBehavior = createScrollBehavior(); |
| return _scrollBehavior; |
| } |
| |
| Widget buildContent(); |
| |
| Widget build() { |
| return new Listener( |
| child: buildContent(), |
| onPointerDown: _handlePointerDown, |
| onPointerUp: _handlePointerUpOrCancel, |
| onPointerCancel: _handlePointerUpOrCancel, |
| onGestureFlingStart: _handleFlingStart, |
| onGestureFlingCancel: _handleFlingCancel, |
| onGestureScrollUpdate: _handleScrollUpdate, |
| onWheel: _handleWheel |
| ); |
| } |
| |
| Future _startToOffsetAnimation(double newScrollOffset, Duration duration, Curve curve) { |
| _stopAnimations(); |
| _toOffsetAnimation |
| ..variable = new AnimatedValue<double>(scrollOffset, |
| end: newScrollOffset, |
| curve: curve |
| ) |
| ..progress = 0.0 |
| ..duration = duration; |
| return _toOffsetAnimation.play(); |
| } |
| |
| void _stopAnimations() { |
| if (_toOffsetAnimation.isAnimating) |
| _toOffsetAnimation.stop(); |
| if (_toEndAnimation.isAnimating) |
| _toEndAnimation.stop(); |
| } |
| |
| void _startToEndAnimation({ double velocity: 0.0 }) { |
| _stopAnimations(); |
| Simulation simulation = scrollBehavior.release(scrollOffset, velocity); |
| if (simulation != null) |
| _toEndAnimation.start(simulation); |
| } |
| |
| void didUnmount() { |
| _stopAnimations(); |
| super.didUnmount(); |
| } |
| |
| void _setScrollOffset(double newScrollOffset) { |
| if (_scrollOffset == newScrollOffset) |
| return; |
| setState(() { |
| _scrollOffset = newScrollOffset; |
| }); |
| if (_listeners.length > 0) |
| _notifyListeners(); |
| } |
| |
| Future scrollTo(double newScrollOffset, { Duration duration, Curve curve: ease }) { |
| if (newScrollOffset == _scrollOffset) |
| return new Future.value(); |
| |
| if (duration == null) { |
| _stopAnimations(); |
| _setScrollOffset(newScrollOffset); |
| return new Future.value(); |
| } |
| |
| return _startToOffsetAnimation(newScrollOffset, duration, curve); |
| } |
| |
| Future scrollBy(double scrollDelta, { Duration duration, Curve curve }) { |
| double newScrollOffset = scrollBehavior.applyCurve(_scrollOffset, scrollDelta); |
| return scrollTo(newScrollOffset, duration: duration, curve: curve); |
| } |
| |
| void settleScrollOffset() { |
| _startToEndAnimation(); |
| } |
| |
| // Return the event's velocity in pixels/second. |
| double _eventVelocity(sky.GestureEvent event) { |
| double velocity = scrollDirection == ScrollDirection.horizontal |
| ? -event.velocityX |
| : -event.velocityY; |
| return velocity.clamp(_kMinFlingVelocity, _kMaxFlingVelocity) / _kMillisecondsPerSecond; |
| } |
| |
| EventDisposition _handlePointerDown(_) { |
| _stopAnimations(); |
| return EventDisposition.processed; |
| } |
| |
| EventDisposition _handleScrollUpdate(sky.GestureEvent event) { |
| scrollBy(scrollDirection == ScrollDirection.horizontal ? event.dx : -event.dy); |
| return EventDisposition.processed; |
| } |
| |
| EventDisposition _handleFlingStart(sky.GestureEvent event) { |
| _startToEndAnimation(velocity: _eventVelocity(event)); |
| return EventDisposition.processed; |
| } |
| |
| void _maybeSettleScrollOffset() { |
| if (!_toEndAnimation.isAnimating && |
| (_toOffsetAnimation == null || !_toOffsetAnimation.isAnimating)) |
| settleScrollOffset(); |
| } |
| |
| EventDisposition _handlePointerUpOrCancel(_) { |
| _maybeSettleScrollOffset(); |
| return EventDisposition.processed; |
| } |
| |
| EventDisposition _handleFlingCancel(sky.GestureEvent event) { |
| _maybeSettleScrollOffset(); |
| return EventDisposition.processed; |
| } |
| |
| EventDisposition _handleWheel(sky.WheelEvent event) { |
| scrollBy(-event.offsetY); |
| return EventDisposition.processed; |
| } |
| |
| final List<ScrollListener> _listeners = new List<ScrollListener>(); |
| void addListener(ScrollListener listener) { |
| _listeners.add(listener); |
| } |
| |
| void removeListener(ScrollListener listener) { |
| _listeners.remove(listener); |
| } |
| |
| void _notifyListeners() { |
| List<ScrollListener> localListeners = new List<ScrollListener>.from(_listeners); |
| for (ScrollListener listener in localListeners) |
| listener(); |
| } |
| } |
| |
| Scrollable findScrollableAncestor({ Widget target }) { |
| Widget ancestor = target; |
| while (ancestor != null && ancestor is! Scrollable) |
| ancestor = ancestor.parent; |
| return ancestor; |
| } |
| |
| Future ensureWidgetIsVisible(Widget target, { Duration duration, Curve curve }) { |
| assert(target.mounted); |
| assert(target.renderObject is RenderBox); |
| |
| Scrollable scrollable = findScrollableAncestor(target: target); |
| if (scrollable == null) |
| return new Future.value(); |
| |
| Size targetSize = (target.renderObject as RenderBox).size; |
| Point targetCenter = target.localToGlobal( |
| scrollable.scrollDirection == ScrollDirection.vertical |
| ? new Point(0.0, targetSize.height / 2.0) |
| : new Point(targetSize.width / 2.0, 0.0) |
| ); |
| |
| Size scrollableSize = (scrollable.renderObject as RenderBox).size; |
| Point scrollableCenter = scrollable.localToGlobal( |
| scrollable.scrollDirection == ScrollDirection.vertical |
| ? new Point(0.0, scrollableSize.height / 2.0) |
| : new Point(scrollableSize.width / 2.0, 0.0) |
| ); |
| double scrollOffsetDelta = scrollable.scrollDirection == ScrollDirection.vertical |
| ? targetCenter.y - scrollableCenter.y |
| : targetCenter.x - scrollableCenter.x; |
| ExtentScrollBehavior scrollBehavior = scrollable.scrollBehavior; |
| double scrollOffset = (scrollable.scrollOffset + scrollOffsetDelta) |
| .clamp(scrollBehavior.minScrollOffset, scrollBehavior.maxScrollOffset); |
| |
| if (scrollOffset != scrollable.scrollOffset) |
| return scrollable.scrollTo(scrollOffset, duration: duration, curve: curve); |
| |
| return new Future.value(); |
| } |
| |
| /// A simple scrollable widget that has a single child. Use this component if |
| /// you are not worried about offscreen widgets consuming resources. |
| class ScrollableViewport extends Scrollable { |
| ScrollableViewport({ |
| Key key, |
| this.child, |
| double initialScrollOffset, |
| ScrollDirection scrollDirection: ScrollDirection.vertical |
| }) : super( |
| key: key, |
| scrollDirection: scrollDirection, |
| initialScrollOffset: initialScrollOffset |
| ); |
| |
| Widget child; |
| |
| void syncConstructorArguments(ScrollableViewport source) { |
| child = source.child; |
| super.syncConstructorArguments(source); |
| } |
| |
| ScrollBehavior createScrollBehavior() => new OverscrollWhenScrollableBehavior(); |
| OverscrollWhenScrollableBehavior get scrollBehavior => super.scrollBehavior; |
| |
| double _viewportSize = 0.0; |
| double _childSize = 0.0; |
| void _handleViewportSizeChanged(Size newSize) { |
| _viewportSize = scrollDirection == ScrollDirection.vertical ? newSize.height : newSize.width; |
| _updateScrollBehaviour(); |
| } |
| void _handleChildSizeChanged(Size newSize) { |
| _childSize = scrollDirection == ScrollDirection.vertical ? newSize.height : newSize.width; |
| _updateScrollBehaviour(); |
| } |
| void _updateScrollBehaviour() { |
| scrollTo(scrollBehavior.updateExtents( |
| contentExtent: _childSize, |
| containerExtent: _viewportSize, |
| scrollOffset: scrollOffset)); |
| } |
| |
| Widget buildContent() { |
| return new SizeObserver( |
| callback: _handleViewportSizeChanged, |
| child: new Viewport( |
| scrollOffset: scrollOffsetVector, |
| scrollDirection: scrollDirection, |
| child: new SizeObserver( |
| callback: _handleChildSizeChanged, |
| child: child |
| ) |
| ) |
| ); |
| } |
| } |
| |
| /// A mashup of [ScrollableViewport] and [BlockBody]. Useful when you have a small, |
| /// fixed number of children that you wish to arrange in a block layout and that |
| /// might exceed the height of its container (and therefore need to scroll). |
| class Block extends Component { |
| Block(this.children, { |
| Key key, |
| this.initialScrollOffset, |
| this.scrollDirection: ScrollDirection.vertical |
| }) : super(key: key); |
| |
| final List<Widget> children; |
| final double initialScrollOffset; |
| final ScrollDirection scrollDirection; |
| |
| BlockDirection get _direction { |
| if (scrollDirection == ScrollDirection.vertical) |
| return BlockDirection.vertical; |
| return BlockDirection.horizontal; |
| } |
| |
| Widget build() { |
| return new ScrollableViewport( |
| initialScrollOffset: initialScrollOffset, |
| scrollDirection: scrollDirection, |
| child: new BlockBody(children, direction: _direction) |
| ); |
| } |
| } |
| |
| /// An optimized scrollable widget for a large number of children that are all |
| /// the same size (extent) in the scrollDirection. For example for |
| /// ScrollDirection.vertical itemExtent is the height of each item. Use this |
| /// widget when you have a large number of children or when you are concerned |
| // about offscreen widgets consuming resources. |
| abstract class ScrollableWidgetList extends Scrollable { |
| ScrollableWidgetList({ |
| Key key, |
| double initialScrollOffset, |
| ScrollDirection scrollDirection: ScrollDirection.vertical, |
| this.itemExtent, |
| this.padding |
| }) : super(key: key, initialScrollOffset: initialScrollOffset, scrollDirection: scrollDirection) { |
| assert(itemExtent != null); |
| } |
| |
| EdgeDims padding; |
| double itemExtent; |
| Size containerSize = Size.zero; |
| |
| /// Subclasses must implement `get itemCount` to tell ScrollableWidgetList |
| /// how many items there are in the list. |
| int get itemCount; |
| int _previousItemCount; |
| |
| void syncConstructorArguments(ScrollableWidgetList source) { |
| bool scrollBehaviorUpdateNeeded = |
| padding != source.padding || |
| itemExtent != source.itemExtent || |
| scrollDirection != source.scrollDirection; |
| |
| padding = source.padding; |
| itemExtent = source.itemExtent; |
| super.syncConstructorArguments(source); // update scrollDirection |
| |
| if (itemCount != _previousItemCount) { |
| scrollBehaviorUpdateNeeded = true; |
| _previousItemCount = itemCount; |
| } |
| |
| if (scrollBehaviorUpdateNeeded) |
| _updateScrollBehavior(); |
| } |
| |
| ScrollBehavior createScrollBehavior() => new OverscrollBehavior(); |
| ExtentScrollBehavior get scrollBehavior => super.scrollBehavior; |
| |
| double get _containerExtent { |
| return scrollDirection == ScrollDirection.vertical |
| ? containerSize.height |
| : containerSize.width; |
| } |
| |
| void _handleSizeChanged(Size newSize) { |
| setState(() { |
| containerSize = newSize; |
| _updateScrollBehavior(); |
| }); |
| } |
| |
| double get _leadingPadding { |
| if (scrollDirection == ScrollDirection.vertical) |
| return padding != null ? padding.top : 0.0; |
| return padding != null ? padding.left : -.0; |
| } |
| |
| double get _trailingPadding { |
| if (scrollDirection == ScrollDirection.vertical) |
| return padding != null ? padding.bottom : 0.0; |
| return padding != null ? padding.right : 0.0; |
| } |
| |
| EdgeDims get _crossAxisPadding { |
| if (padding == null) |
| return null; |
| if (scrollDirection == ScrollDirection.vertical) |
| return new EdgeDims.only(left: padding.left, right: padding.right); |
| return new EdgeDims.only(top: padding.top, bottom: padding.bottom); |
| } |
| |
| void _updateScrollBehavior() { |
| double contentExtent = itemExtent * itemCount; |
| if (padding != null) |
| contentExtent += _leadingPadding + _trailingPadding; |
| |
| scrollTo(scrollBehavior.updateExtents( |
| contentExtent: contentExtent, |
| containerExtent: _containerExtent, |
| scrollOffset: scrollOffset)); |
| } |
| |
| Offset _toOffset(double value) { |
| return scrollDirection == ScrollDirection.vertical |
| ? new Offset(0.0, value) |
| : new Offset(value, 0.0); |
| } |
| |
| Widget buildContent() { |
| if (itemCount != _previousItemCount) { |
| _previousItemCount = itemCount; |
| _updateScrollBehavior(); |
| } |
| |
| double paddedScrollOffset = scrollOffset - _leadingPadding; |
| int itemShowIndex = 0; |
| int itemShowCount = 0; |
| Offset viewportOffset = Offset.zero; |
| |
| if (_containerExtent != null && _containerExtent > 0.0 && itemCount > 0) { |
| if (paddedScrollOffset < scrollBehavior.minScrollOffset) { |
| // Underscroll |
| double visibleExtent = _containerExtent + paddedScrollOffset; |
| itemShowCount = (visibleExtent / itemExtent).ceil(); |
| viewportOffset = _toOffset(paddedScrollOffset); |
| } else { |
| itemShowCount = (_containerExtent / itemExtent).ceil() + 1; |
| itemShowIndex = (paddedScrollOffset / itemExtent).floor(); |
| viewportOffset = _toOffset(paddedScrollOffset - itemShowIndex * itemExtent); |
| itemShowIndex %= itemCount; // Wrap index for when itemWrap is true. |
| } |
| } |
| |
| List<Widget> items = buildItems(itemShowIndex, itemShowCount); |
| assert(items.every((item) => item.key != null)); |
| |
| BlockDirection blockDirection = scrollDirection == ScrollDirection.vertical |
| ? BlockDirection.vertical |
| : BlockDirection.horizontal; |
| |
| // TODO(ianh): Refactor this so that it does the building in the |
| // same frame as the size observing, similar to MixedViewport, but |
| // keeping the fixed-height optimisations. |
| return new SizeObserver( |
| callback: _handleSizeChanged, |
| child: new Viewport( |
| scrollDirection: scrollDirection, |
| scrollOffset: viewportOffset, |
| child: new Container( |
| padding: _crossAxisPadding, |
| child: new BlockBody(items, direction: blockDirection) |
| ) |
| ) |
| ); |
| } |
| |
| List<Widget> buildItems(int start, int count); |
| |
| } |
| |
| typedef Widget ItemBuilder<T>(T item); |
| |
| /// A wrapper around [ScrollableWidgetList] that helps you translate a list of |
| /// model objects into a scrollable list of widgets. Assumes all the widgets |
| /// have the same height. |
| class ScrollableList<T> extends ScrollableWidgetList { |
| ScrollableList({ |
| Key key, |
| double initialScrollOffset, |
| ScrollDirection scrollDirection: ScrollDirection.vertical, |
| this.items, |
| this.itemBuilder, |
| this.itemsWrap: false, |
| double itemExtent, |
| EdgeDims padding |
| }) : super( |
| key: key, |
| initialScrollOffset: initialScrollOffset, |
| scrollDirection: scrollDirection, |
| itemExtent: itemExtent, |
| padding: padding); |
| |
| List<T> items; |
| ItemBuilder<T> itemBuilder; |
| bool itemsWrap; |
| |
| void syncConstructorArguments(ScrollableList<T> source) { |
| items = source.items; |
| itemBuilder = source.itemBuilder; |
| itemsWrap = source.itemsWrap; |
| super.syncConstructorArguments(source); |
| } |
| |
| ScrollBehavior createScrollBehavior() { |
| return itemsWrap ? new UnboundedBehavior() : super.createScrollBehavior(); |
| } |
| |
| int get itemCount => items.length; |
| |
| List<Widget> buildItems(int start, int count) { |
| List<Widget> result = new List<Widget>(); |
| int begin = itemsWrap ? start : math.max(0, start); |
| int end = itemsWrap ? begin + count : math.min(begin + count, items.length); |
| for (int i = begin; i < end; ++i) |
| result.add(itemBuilder(items[i % itemCount])); |
| return result; |
| } |
| } |
| |
| class PageableList<T> extends ScrollableList<T> { |
| PageableList({ |
| Key key, |
| double initialScrollOffset, |
| ScrollDirection scrollDirection: ScrollDirection.horizontal, |
| List<T> items, |
| ItemBuilder<T> itemBuilder, |
| bool itemsWrap: false, |
| double itemExtent, |
| EdgeDims padding, |
| this.duration: const Duration(milliseconds: 200), |
| this.curve: ease |
| }) : super( |
| key: key, |
| initialScrollOffset: initialScrollOffset, |
| scrollDirection: scrollDirection, |
| items: items, |
| itemBuilder: itemBuilder, |
| itemsWrap: itemsWrap, |
| itemExtent: itemExtent, |
| padding: padding |
| ); |
| |
| Duration duration; |
| Curve curve; |
| |
| void syncConstructorArguments(PageableList<T> source) { |
| duration = source.duration; |
| curve = source.curve; |
| super.syncConstructorArguments(source); |
| } |
| |
| double _snapScrollOffset(double newScrollOffset) { |
| double scaledScrollOffset = newScrollOffset / itemExtent; |
| double previousScrollOffset = scaledScrollOffset.floor() * itemExtent; |
| double nextScrollOffset = scaledScrollOffset.ceil() * itemExtent; |
| double delta = newScrollOffset - previousScrollOffset; |
| return (delta < itemExtent / 2.0 ? previousScrollOffset : nextScrollOffset) |
| .clamp(scrollBehavior.minScrollOffset, scrollBehavior.maxScrollOffset); |
| } |
| |
| EventDisposition _handlePointerDown(_) { |
| return EventDisposition.ignored; |
| } |
| |
| EventDisposition _handleFlingStart(sky.GestureEvent event) { |
| double velocity = _eventVelocity(event); |
| double newScrollOffset = _snapScrollOffset(scrollOffset + velocity.sign * itemExtent) |
| .clamp(_snapScrollOffset(scrollOffset - itemExtent / 2.0), |
| _snapScrollOffset(scrollOffset + itemExtent / 2.0)); |
| scrollTo(newScrollOffset, duration: duration, curve: curve); |
| return EventDisposition.processed; |
| } |
| |
| void settleScrollOffset() { |
| scrollTo(_snapScrollOffset(scrollOffset), duration: duration, curve: curve); |
| } |
| } |
| |
| /// A general scrollable list for a large number of children that might not all |
| /// have the same height. Prefer [ScrollableWidgetList] when all the children |
| /// have the same height because it can use that property to be more efficient. |
| /// Prefer [ScrollableViewport] with a single child. |
| class ScrollableMixedWidgetList extends Scrollable { |
| ScrollableMixedWidgetList({ |
| Key key, |
| double initialScrollOffset, |
| this.builder, |
| this.token, |
| this.layoutState |
| }) : super(key: key, initialScrollOffset: initialScrollOffset); |
| |
| IndexedBuilder builder; |
| Object token; |
| MixedViewportLayoutState layoutState; |
| |
| // When the token changes the scrollable's contents may have |
| // changed. Remember as much so that after the new contents |
| // have been laid out we can adjust the scrollOffset so that |
| // the last page of content is still visible. |
| bool _contentChanged = true; |
| |
| void initState() { |
| assert(layoutState != null); |
| super.initState(); |
| } |
| |
| void didMount() { |
| layoutState.addListener(_handleLayoutChanged); |
| super.didMount(); |
| } |
| |
| void didUnmount() { |
| layoutState.removeListener(_handleLayoutChanged); |
| super.didUnmount(); |
| } |
| |
| void syncConstructorArguments(ScrollableMixedWidgetList source) { |
| builder = source.builder; |
| if (token != source.token) |
| _contentChanged = true; |
| token = source.token; |
| if (layoutState != source.layoutState) { |
| // Warning: this is unlikely to be what you intended. |
| assert(source.layoutState != null); |
| layoutState.removeListener(_handleLayoutChanged); |
| layoutState = source.layoutState; |
| layoutState.addListener(_handleLayoutChanged); |
| } |
| super.syncConstructorArguments(source); |
| } |
| |
| ScrollBehavior createScrollBehavior() => new OverscrollBehavior(); |
| OverscrollBehavior get scrollBehavior => super.scrollBehavior; |
| |
| void _handleSizeChanged(Size newSize) { |
| scrollBy(scrollBehavior.updateExtents( |
| containerExtent: newSize.height, |
| scrollOffset: scrollOffset |
| )); |
| } |
| |
| void _handleLayoutChanged() { |
| double newScrollOffset = scrollBehavior.updateExtents( |
| contentExtent: layoutState.didReachLastChild ? layoutState.contentsSize : double.INFINITY, |
| scrollOffset: scrollOffset); |
| if (_contentChanged) { |
| _contentChanged = false; |
| scrollTo(newScrollOffset); |
| } |
| } |
| |
| Widget buildContent() { |
| return new SizeObserver( |
| callback: _handleSizeChanged, |
| child: new MixedViewport( |
| builder: builder, |
| layoutState: layoutState, |
| startOffset: scrollOffset, |
| token: token |
| ) |
| ); |
| } |
| } |