// ========================================================================== // Project: SproutCore - JavaScript Application Framework // Copyright: ©2006-2010 Sprout Systems, Inc. and contributors. // Portions ©2008-2010 Apple Inc. All rights reserved. // License: Licensed under MIT license (see license.js) // ========================================================================== /** @class Displays a horizontal or vertical scroller. You will not usually need to work with scroller views directly, but you may override this class to implement your own custom scrollers. Because the scroller uses the dimensions of its constituent elements to calculate layout, you may need to override the default display metrics. You can either create a subclass of ScrollerView with the new values, or provide your own in your theme: {{{ SC.mixin(SC.ScrollerView.prototype, { scrollbarThickness: 14, capLength: 18, capOverlap: 14, buttonOverlap: 11, buttonLength: 41 }); }}} You can change whether scroll buttons are displayed by setting the hasButtons property. @extends SC.View @since SproutCore 1.0 */ SC.ScrollerView = SC.View.extend( /** @scope SC.ScrollerView.prototype */ { classNames: ['sc-scroller-view'], // .......................................................... // PROPERTIES // /** If YES, a click on the track will cause the scrollbar to scroll to that position. Otherwise, a click on the track will cause a page down. In either case, alt-clicks will perform the opposite behavior. */ shouldScrollToClick: NO, /** @private The in-touch-scroll value. */ _touchScrollValue: NO, /** The value of the scroller. The value represents the position of the scroller's thumb. @property {Number} */ value: function(key, val) { var minimum = this.get('minimum'); if (val !== undefined) { this._scs_value = val; } val = this._scs_value || minimum; // default value is at top/left return Math.max(Math.min(val, this.get('maximum')), minimum) ; }.property('maximum', 'minimum').cacheable(), displayValue: function() { var ret; if (this.get("_touchScrollValue")) ret = this.get("_touchScrollValue"); else ret = this.get("value"); return ret; }.property("value", "_touchScrollValue").cacheable(), /** The portion of the track that the thumb should fill. Usually the proportion will be the ratio of the size of the scroll view's content view to the size of the scroll view. Should be specified as a value between 0.0 (minimal size) and 1.0 (fills the slot). Note that if the proportion is 1.0 then the control will be disabled. @property {Number} */ proportion: 0, /** The maximum offset value for the scroller. This will be used to calculate the internal height/width of the scroller itself. When set less than the height of the scroller, the scroller is disabled. @property {Number} */ maximum: 100, /** The minimum offset value for the scroller. This will be used to calculate the internal height/width of the scroller itself. @property {Number} */ minimum: 0, /** YES to enable scrollbar, NO to disable it. Scrollbars will automatically disable if the maximum scroll width does not exceed their capacity. @property */ isEnabled: YES, /** Determine the layout direction. Determines whether the scrollbar should appear horizontal or vertical. This must be set when the view is created. Changing this once the view has been created will have no effect. @property */ layoutDirection: SC.LAYOUT_VERTICAL, /** Whether or not the scroller should display scroll buttons @property {Boolean} @default YES */ hasButtons: YES, // .......................................................... // DISPLAY METRICS // /** The width (if vertical scroller) or height (if horizontal scroller) of the scrollbar. @property {Number} */ scrollbarThickness: 14, /** The width or height of the cap that encloses the track. @property {Number} */ capLength: 18, /** The amount by which the thumb overlaps the cap. @property {Number} */ capOverlap: 14, /** The width or height of the up/down or left/right arrow buttons. If the scroller is not displaying arrows, this is the width or height of the end cap. @property {Number} */ buttonLength: 41, /** The amount by which the thumb overlaps the arrow buttons. If the scroller is not displaying arrows, this is the amount by which the thumb overlaps the end cap. @property {Number} */ buttonOverlap: 11, // .......................................................... // INTERNAL SUPPORT // displayProperties: 'thumbPosition thumbLength isEnabled controlsHidden'.w(), /** Generates the HTML that gets displayed to the user. The first time render is called, the HTML will be output to the DOM. Successive calls will reposition the thumb based on the value property. @param {SC.RenderContext} context the render context @param {Boolean} firstTime YES if this is creating a layer @private */ render: function(context, firstTime) { var classNames = {}, buttons = '', thumbPosition, thumbLength, thumbCenterLength, thumbElement, value, max, scrollerLength, length, pct; // We set a class name depending on the layout direction so that we can // style them differently using CSS. switch (this.get('layoutDirection')) { case SC.LAYOUT_VERTICAL: classNames['sc-vertical'] = YES; break; case SC.LAYOUT_HORIZONTAL: classNames['sc-horizontal'] = YES; break; } // The appearance of the scroller changes if disabled classNames['disabled'] = !this.get('isEnabled'); // Whether to hide the thumb and buttons classNames['controls-hidden'] = this.get('controlsHidden'); // Change the class names of the DOM element all at once to improve // performance context.setClass(classNames); // Calculate the position and size of the thumb thumbLength = this.get('thumbLength'); thumbPosition = this.get('thumbPosition'); // If this is the first time, generate the actual HTML if (firstTime) { if (this.get('hasButtons')) { buttons = '
'; } else { buttons = '
'; } switch (this.get('layoutDirection')) { case SC.LAYOUT_VERTICAL: context.push('
', '
', buttons, '
', '
', '
', '
'); break; case SC.LAYOUT_HORIZONTAL: context.push('
', '
', buttons, '
', '
', '
', '
'); } } else { // The HTML has already been generated, so all we have to do is // reposition and resize the thumb // If we aren't displaying controls don't bother if (this.get('controlsHidden')) return; thumbElement = this.$('.thumb'); this.adjustThumb(thumbElement, thumbPosition, thumbLength); } }, /** @private */ touchScrollDidStart: function(value) { this.set("_touchScrollValue", value); }, touchScrollDidEnd: function(value) { this.set("_touchScrollValue", NO); }, touchScrollDidChange: function(value) { this.set("_touchScrollValue", value); }, // .......................................................... // THUMB MANAGEMENT // /** Adjusts the thumb (for backwards-compatibility calls adjustThumbPosition+adjustThumbSize by default) */ adjustThumb: function(thumb, position, length) { this.adjustThumbPosition(thumb, position); this.adjustThumbSize(thumb, length); }, /** Updates the position of the thumb DOM element. @param {Number} position the position of the thumb in pixels @private */ adjustThumbPosition: function(thumb, position) { // Don't touch the DOM if the position hasn't changed if (this._thumbPosition === position) return; switch (this.get('layoutDirection')) { case SC.LAYOUT_VERTICAL: thumb.css('top', position); break; case SC.LAYOUT_HORIZONTAL: thumb.css('left', position); break; } this._thumbPosition = position; }, adjustThumbSize: function(thumb, size) { // Don't touch the DOM if the size hasn't changed if (this._thumbSize === size) return; switch (this.get('layoutDirection')) { case SC.LAYOUT_VERTICAL: thumb.css('height', Math.max(size, 20)); break; case SC.LAYOUT_HORIZONTAL: thumb.css('width', Math.max(size,20)); break; } this._thumbSize = size; }, // .......................................................... // SCROLLER DIMENSION COMPUTED PROPERTIES // /** Returns the total length of the track in which the thumb sits. The length of the track is the height or width of the scroller, less the cap length and the button length. This property is used to calculate the position of the thumb relative to the view. @property @private */ trackLength: function() { var scrollerLength = this.get('scrollerLength'); // Subtract the size of the top/left cap scrollerLength -= this.capLength - this.capOverlap; // Subtract the size of the scroll buttons, or the end cap if they are // not shown. scrollerLength -= this.buttonLength - this.buttonOverlap; return scrollerLength; }.property('scrollerLength').cacheable(), /** Returns the height of the view if this is a vertical scroller or the width of the view if this is a horizontal scroller. This is used when scrolling up and down by page, as well as in various layout calculations. @property {Number} @private */ scrollerLength: function() { switch (this.get('layoutDirection')) { case SC.LAYOUT_VERTICAL: return this.get('frame').height; case SC.LAYOUT_HORIZONTAL: return this.get('frame').width; } return 0; }.property('frame').cacheable(), /** The total length of the thumb. The size of the thumb is the length of the track times the content proportion. @property @private */ thumbLength: function() { var length; length = Math.floor(this.get('trackLength') * this.get('proportion')); length = isNaN(length) ? 0 : length; return Math.max(length,20); }.property('trackLength', 'proportion').cacheable(), /** The position of the thumb in the track. @property {Number} @isReadOnly @private */ thumbPosition: function() { var value = this.get('displayValue'), max = this.get('maximum'), trackLength = this.get('trackLength'), thumbLength = this.get('thumbLength'), capLength = this.get('capLength'), capOverlap = this.get('capOverlap'), position; position = (value/max)*(trackLength-thumbLength); position += capLength - capOverlap; // account for the top/left cap return Math.floor(isNaN(position) ? 0 : position); }.property('displayValue', 'maximum', 'trackLength', 'thumbLength').cacheable(), /** YES if the maximum value exceeds the frame size of the scroller. This will hide the thumb and buttons. @property {Boolean} @isReadOnly @private */ controlsHidden: function() { return this.get('proportion') >= 1; }.property('proportion').cacheable(), // .......................................................... // MOUSE EVENTS // /** Returns the value for a position within the scroller's frame. @private */ valueForPosition: function(pos) { var max = this.get('maximum'), trackLength = this.get('trackLength'), thumbLength = this.get('thumbLength'), capLength = this.get('capLength'), capOverlap = this.get('capOverlap'), value; value = pos - (capLength - capOverlap); value = value / (trackLength - thumbLength); value = value * max; return value; }, /** Handles mouse down events and adjusts the value property depending where the user clicked. If the control is disabled, we ignore all mouse input. If the user clicks the thumb, we note the position of the mouse event but do not take further action until they begin to drag. If the user clicks the track, we adjust the value a page at a time, unless alt is pressed, in which case we scroll to that position. If the user clicks the buttons, we adjust the value by a fixed amount, unless alt is pressed, in which case we adjust by a page. If the user clicks and holds on either the track or buttons, those actions are repeated until they release the mouse button. @param evt {SC.Event} the mousedown event @private */ mouseDown: function(evt) { if (!this.get('isEnabled')) return NO; // keep note of altIsDown for later. this._altIsDown = evt.altKey; this._shiftIsDown = evt.shiftKey; var target = evt.target, thumbPosition = this.get('thumbPosition'), value, clickLocation, clickOffset, scrollerLength = this.get('scrollerLength'); // Determine the subcontrol that was clicked if (target.className.indexOf('thumb') >= 0) { // Convert the mouseDown coordinates to the view's coordinates clickLocation = this.convertFrameFromView({ x: evt.pageX, y: evt.pageY }); clickLocation.x -= thumbPosition; clickLocation.y -= thumbPosition; // Store the starting state so we know how much to adjust the // thumb when the user drags this._thumbDragging = YES; this._thumbOffset = clickLocation; this._mouseDownLocation = { x: evt.pageX, y: evt.pageY }; this._thumbPositionAtDragStart = this.get('thumbPosition'); this._valueAtDragStart = this.get("value"); } else if (target.className.indexOf('button-top') >= 0) { // User clicked the up/left button // Decrement the value by a fixed amount or page size this.decrementProperty('value', (this._altIsDown ? scrollerLength : 30)); this.makeButtonActive('.button-top'); // start a timer that will continue to fire until mouseUp is called this.startMouseDownTimer('scrollUp'); this._isScrollingUp = YES; } else if (target.className.indexOf('button-bottom') >= 0) { // User clicked the down/right button // Increment the value by a fixed amount this.incrementProperty('value', (this._altIsDown ? scrollerLength : 30)); this.makeButtonActive('.button-bottom'); // start a timer that will continue to fire until mouseUp is called this.startMouseDownTimer('scrollDown'); this._isScrollingDown = YES; } else { // User clicked in the track var scrollToClick = this.get("shouldScrollToClick"); if (evt.altKey) scrollToClick = !scrollToClick; var trackLength = this.get('trackLength'), thumbLength = this.get('thumbLength'), frame = this.convertFrameFromView({ x: evt.pageX, y: evt.pageY }), mousePosition; switch (this.get('layoutDirection')) { case SC.LAYOUT_VERTICAL: this._mouseDownLocation = mousePosition = frame.y; break; case SC.LAYOUT_HORIZONTAL: this._mouseDownLocation = mousePosition = frame.x; break; } if (scrollToClick) { this.set('value', this.valueForPosition(mousePosition - (thumbLength / 2))); // and start a normal mouse down thumbPosition = this.get('thumbPosition'); this._thumbDragging = YES; this._thumbOffset = {x: frame.x - thumbPosition, y: frame.y - thumbPosition }; this._mouseDownLocation = {x:evt.pageX, y:evt.pageY}; this._thumbPositionAtDragStart = thumbPosition; this._valueAtDragStart = this.get("value"); } else { // Move the thumb up or down a page depending on whether the click // was above or below the thumb if (mousePosition < thumbPosition) { this.decrementProperty('value',scrollerLength); this.startMouseDownTimer('page'); } else { this.incrementProperty('value', scrollerLength); this.startMouseDownTimer('page'); } } } return YES; }, /** When the user releases the mouse button, remove any active state from the button controls, and cancel any outstanding timers. @param evt {SC.Event} the mousedown event @private */ mouseUp: function(evt) { var active = this._scs_buttonActive, ret = NO, timer; // If we have an element that was set as active in mouseDown, // remove its active state if (active) { active.removeClass('active'); ret = YES; } // Stop firing repeating events after mouseup timer = this._mouseDownTimer; if (timer) { timer.invalidate(); this._mouseDownTimer = null; } this._thumbDragging = NO; this._isScrollingDown = NO; this._isScrollingUp = NO; return ret; }, /** If the user began the drag on the thumb, we calculate the difference between the mouse position at click and where it is now. We then offset the thumb by that amount, within the bounds of the track. If the user began scrolling up/down using the buttons, this will track what component they are currently over, changing the scroll direction. @param evt {SC.Event} the mousedragged event @private */ mouseDragged: function(evt) { var value, length, delta, thumbPosition, target = evt.target, thumbPositionAtDragStart = this._thumbPositionAtDragStart, isScrollingUp = this._isScrollingUp, isScrollingDown = this._isScrollingDown, active = this._scs_buttonActive, timer; // Only move the thumb if the user clicked on the thumb during mouseDown if (this._thumbDragging) { switch (this.get('layoutDirection')) { case SC.LAYOUT_VERTICAL: delta = (evt.pageY - this._mouseDownLocation.y); break; case SC.LAYOUT_HORIZONTAL: delta = (evt.pageX - this._mouseDownLocation.x); break; } // if we are in alt now, but were not before, update the old thumb position to the new one if (evt.altKey) { if (!this._altIsDown || (this._shiftIsDown !== evt.shiftKey)) { thumbPositionAtDragStart = this._thumbPositionAtDragStart = thumbPositionAtDragStart+delta; delta = 0; this._mouseDownLocation = { x: evt.pageX, y: evt.pageY }; this._valueAtDragStart = this.get("value"); } // because I feel like it. Probably almost no one will find this tiny, buried feature. // Too bad. if (evt.shiftKey) delta = -delta; this.set('value', Math.round(this._valueAtDragStart + delta * 2)); } else { thumbPosition = thumbPositionAtDragStart + delta; length = this.get('trackLength') - this.get('thumbLength'); this.set('value', Math.round( (thumbPosition/length) * this.get('maximum'))); } } else if (isScrollingUp || isScrollingDown) { var nowScrollingUp = NO, nowScrollingDown = NO; var topButtonRect = this.$('.button-top')[0].getBoundingClientRect(); var bottomButtonRect = this.$('.button-bottom')[0].getBoundingClientRect(); switch (this.get('layoutDirection')) { case SC.LAYOUT_VERTICAL: if (evt.pageY < topButtonRect.bottom) nowScrollingUp = YES; else nowScrollingDown = YES; break; case SC.LAYOUT_HORIZONTAL: if (evt.pageX < topButtonRect.right) nowScrollingUp = YES; else nowScrollingDown = YES; break; } if ((nowScrollingUp || nowScrollingDown) && nowScrollingUp !== isScrollingUp){ // // STOP OLD // // If we have an element that was set as active in mouseDown, // remove its active state if (active) { active.removeClass('active'); } // Stop firing repeating events after mouseup this._mouseDownTimerAction = nowScrollingUp ? "scrollUp" : "scrollDown"; if (nowScrollingUp) { this.makeButtonActive('.button-top'); } else if (nowScrollingDown) { this.makeButtonActive('.button-bottom'); } this._isScrollingUp = nowScrollingUp; this._isScrollingDown = nowScrollingDown; } } this._altIsDown = evt.altKey; this._shiftIsDown = evt.shiftKey; return YES; }, /** Starts a timer that fires after 300ms. This is called when the user clicks a button or inside the track to move a page at a time. If they continue holding the mouse button down, we want to repeat that action after a small delay. This timer will be invalidated in mouseUp. Specify "immediate" as YES if it should not wait. @private */ startMouseDownTimer: function(action, immediate) { var timer; this._mouseDownTimerAction = action; this._mouseDownTimer = SC.Timer.schedule({ target: this, action: this.mouseDownTimerDidFire, interval: immediate ? 0 : 300 }); }, /** Called by the mousedown timer. This method determines the initial user action and repeats it until the timer is invalidated in mouseUp. @private */ mouseDownTimerDidFire: function() { var scrollerLength = this.get('scrollerLength'), mouseLocation = SC.device.get('mouseLocation'), thumbPosition = this.get('thumbPosition'), thumbLength = this.get('thumbLength'), timerInterval = 50; switch (this.get('layoutDirection')) { case SC.LAYOUT_VERTICAL: mouseLocation = this.convertFrameFromView(mouseLocation).y; break; case SC.LAYOUT_HORIZONTAL: mouseLocation = this.convertFrameFromView(mouseLocation).x; break; } switch (this._mouseDownTimerAction) { case 'scrollDown': this.incrementProperty('value', this._altIsDown ? scrollerLength : 30); break; case 'scrollUp': this.decrementProperty('value', this._altIsDown ? scrollerLength : 30); break; case 'page': timerInterval = 150; if (mouseLocation < thumbPosition) { this.decrementProperty('value', scrollerLength); } else if (mouseLocation > thumbPosition+thumbLength) { this.incrementProperty('value', scrollerLength); } } this._mouseDownTimer = SC.Timer.schedule({ target: this, action: this.mouseDownTimerDidFire, interval: timerInterval }); }, /** Given a selector, finds the corresponding DOM element and adds the 'active' class name. Also stores the returned element so that the 'active' class name can be removed during mouseup. @param {String} the selector to find @private */ makeButtonActive: function(selector) { this._scs_buttonActive = this.$(selector).addClass('active'); } }); // TO BE EVENTUALLY REPLACED W/RENDERERS FROM QUILMES SC.TouchScrollerView = SC.ScrollerView.extend({ classNames: ['sc-touch-scroller-view'], scrollbarThickness: 12, capLength: 5, capOverlap: 0, hasButtons: NO, buttonOverlap: 36, adjustThumb: function(thumb, position, length) { var thumbInner = this.$('.thumb-inner'); var max = this.get("scrollerLength") - this.capLength, min = this.get("minimum") + this.capLength; if (position + length > max) { position = Math.min(max - 20, position); length = max - position; } if (position < min) { length -= min - position; position = min; } switch (this.get('layoutDirection')) { case SC.LAYOUT_VERTICAL: if (this._thumbPosition !== position) thumb.css('-webkit-transform', 'translate3d(0px,' + position + 'px,0px)'); if (this._thumbSize !== length) { thumbInner.css('-webkit-transform', 'translate3d(0px,' + Math.round(length - 1044) + 'px,0px)'); } break; case SC.LAYOUT_HORIZONTAL: if (this._thumbPosition !== position) thumb.css('-webkit-transform', 'translate3d(' + position + 'px,0px,0px)'); if (this._thumbSize !== length) { thumbInner.css('-webkit-transform', 'translate3d(' + Math.round(length - 1044) + 'px,0px,0px)'); } break; } this._thumbPosition = position; this._thumbSize = length; }, render: function(context, firstTime) { var classNames = [], buttons = '', thumbPosition, thumbLength, thumbCenterLength, thumbElement, value, max, scrollerLength, length, pct; // We set a class name depending on the layout direction so that we can // style them differently using CSS. switch (this.get('layoutDirection')) { case SC.LAYOUT_VERTICAL: classNames.push('sc-vertical'); break; case SC.LAYOUT_HORIZONTAL: classNames.push('sc-horizontal'); break; } // The appearance of the scroller changes if disabled if (!this.get('isEnabled')) classNames.push('disabled'); // Whether to hide the thumb and buttons if (this.get('controlsHidden')) classNames.push('controls-hidden'); // Change the class names of the DOM element all at once to improve // performance context.addClass(classNames); // Calculate the position and size of the thumb thumbLength = this.get('thumbLength'); thumbPosition = this.get('thumbPosition'); // If this is the first time, generate the actual HTML if (firstTime) { if (this.get('hasButtons')) { buttons = '
'; } else { buttons = '
'; } switch (this.get('layoutDirection')) { case SC.LAYOUT_VERTICAL: context.push('
', '
', buttons, '
', '
', '
', '
', '
', '
'); break; case SC.LAYOUT_HORIZONTAL: context.push('
', '
', buttons, '
', '
', '
', '
', '
', '
'); } } else { // The HTML has already been generated, so all we have to do is // reposition and resize the thumb // If we aren't displaying controls don't bother if (this.get('controlsHidden')) return; thumbElement = this.$('.thumb'); this.adjustThumb(thumbElement, thumbPosition, thumbLength); } } });