1////////////////////////////////////////////////////////////////////////////////
2//
3//  ADOBE SYSTEMS INCORPORATED
4//  Copyright 2011 Adobe Systems Incorporated
5//  All Rights Reserved.
6//
7//  NOTICE: Adobe permits you to use, modify, and distribute this file
8//  in accordance with the terms of the license agreement accompanying it.
9//
10////////////////////////////////////////////////////////////////////////////////
11
12package spark.effects
13{
14import flash.geom.Point;
15import mx.core.mx_internal;
16import spark.effects.animation.Keyframe;
17import spark.effects.animation.MotionPath;
18import spark.effects.animation.SimpleMotionPath;
19import spark.effects.easing.IEaser;
20import spark.effects.easing.Power;
21import spark.effects.easing.Sine;
22
23use namespace mx_internal;
24
25// TODO (eday): This class is currently intended only for use by Scroller.  It may not
26// support all of the functionality of the Effects system.  For example, it does not
27// have an associated AnimatetdInstance-derived class.
28
29
30[ExcludeClass]
31
32public class ThrowEffect extends Animate
33{
34
35    /**
36     *  @private
37     *  The duration of the overshoot effect when a throw "bounces" against the end of the list.
38     */
39    private static const THROW_OVERSHOOT_TIME:int = 200;
40
41    /**
42     *  @private
43     *  The duration of the settle effect when a throw "bounces" against the end of the list.
44     */
45    private static const THROW_SETTLE_TIME:int = 600;
46
47    /**
48     *  @private
49     *  The exponent used in the easer function for the main part of the throw animation.
50     *  NOTE: if you change this, you need to re-differentiate the easer
51     *  function and use the resulting derivative calculation in createThrowMotionPath.
52     */
53    private static const THROW_CURVE_EXPONENT:Number = 3.0;
54
55    /**
56     *  @private
57     *  The exponent used in the easer function for the "overshoot" portion
58     *  of the throw animation.
59     */
60    private static const OVERSHOOT_CURVE_EXPONENT:Number = 2.0;
61
62    /**
63     *  @private
64     *  The name of the property to be animated for each axis.
65     *  Setting to null indicates that there is to be no animation
66     *  along that axis.
67     */
68    mx_internal var propertyNameX:String = null;
69    mx_internal var propertyNameY:String = null;
70
71    /**
72     *  @private
73     *  The initial velocity of the throw animation.
74     */
75    mx_internal var startingVelocityX:Number = 0;
76    mx_internal var startingVelocityY:Number = 0;
77
78    /**
79     *  @private
80     *  The starting values for the animated properties.
81     */
82    mx_internal var startingPositionX:Number = 0;
83    mx_internal var startingPositionY:Number = 0;
84
85    /**
86     *  @private
87     *  The minimum values for the animated properties.
88     */
89    mx_internal var minPositionX:Number = 0;
90    mx_internal var minPositionY:Number = 0;
91
92    /**
93     *  @private
94     *  The maximum values for the animated properties.
95     */
96    mx_internal var maxPositionX:Number = 0;
97    mx_internal var maxPositionY:Number = 0;
98
99    /**
100     *  @private
101     *  The rate of deceleration to apply to the velocity.
102     */
103    mx_internal var decelerationFactor:Number;
104
105    /**
106     *  @private
107     *  The final calculated values for the animated properties.
108     */
109    mx_internal var finalPosition:Point;
110
111    /**
112     *  @private
113     *  This is a callback that, when installed by the client, will be invoked
114     *  with the final position of the throw in case the client needs to alter it
115     *  prior to the animation beginning.
116     */
117    mx_internal var finalPositionFilterFunction:Function;
118
119    /**
120     *  @private
121     *  Set to true when the effect is only being used to snap an element into position
122     *  and the initial velocity is zero.
123     */
124    mx_internal var isSnapping:Boolean = false;
125
126    /**
127     *  @private
128     *  The motion paths for X and Y axes
129     */
130    private var horizontalMP:SimpleMotionPath = null;
131    private var verticalMP:SimpleMotionPath = null;
132
133    /**
134     *  @private
135     */
136    private function calculateThrowEffectTime(velocityX:Number, velocityY:Number):int
137    {
138        // This calculates the effect duration based on a deceleration factor that is applied evenly over time.
139        // We decay the velocity by the deceleration factor until it is less than 0.01/ms, which is rounded to zero pixels.
140        // We want to solve for "time" in this equasion: velocity*(decel^time)-0.01 = 0.
141        // Note that we are only calculating an effect duration here.  The actual curve of our throw velocity is determined by
142        // the exponential easing function we use between animation keyframes.
143        var throwTimeX:int = velocityX == 0 ? 0 : (Math.log(0.01 / (Math.abs(velocityX)))) / Math.log(decelerationFactor);
144        var throwTimeY:int = velocityY == 0 ? 0 : (Math.log(0.01 / (Math.abs(velocityY)))) / Math.log(decelerationFactor);
145
146        return Math.max(throwTimeX, throwTimeY);
147    }
148
149    /**
150     *  @private
151     *  Once all the animation variables are set (velocity, position, etc.), call this
152     *  function to build the motion paths that describe the throw animation.
153     */
154    mx_internal function setup():Boolean
155    {
156        // Set the easer for the overall effect.
157        // TODO (eday): eliminate this and fix the curves to compensate.
158        var throwEaser:IEaser = new Power(0, THROW_CURVE_EXPONENT);
159        this.easer = throwEaser;
160
161        var effectTime:int = calculateThrowEffectTime(startingVelocityX, startingVelocityY);
162        var throwEffectMotionPaths:Vector.<MotionPath> = new Vector.<MotionPath>();
163
164        isSnapping = false;
165
166        var horizontalTime:Number = 0;
167        var horizontalFinalPosition:Number = 0;
168        horizontalMP = null;
169        if (propertyNameX)
170        {
171            horizontalMP = createThrowMotionPath(
172                propertyNameX,
173                startingVelocityX,
174                startingPositionX,
175                minPositionX,
176                maxPositionX,
177                effectTime);
178
179            if (horizontalMP)
180            {
181                throwEffectMotionPaths.push(horizontalMP);
182                horizontalTime = horizontalMP.keyframes[horizontalMP.keyframes.length-1].time;
183                horizontalFinalPosition = Number(horizontalMP.keyframes[horizontalMP.keyframes.length-1].value);
184            }
185        }
186
187        var verticalTime:Number = 0;
188        var verticalFinalPosition:Number = 0;
189        verticalMP = null;
190        if (propertyNameY)
191        {
192            verticalMP = createThrowMotionPath(
193                propertyNameY,
194                startingVelocityY,
195                startingPositionY,
196                minPositionY,
197                maxPositionY,
198                effectTime);
199
200            if (verticalMP)
201            {
202                throwEffectMotionPaths.push(verticalMP);
203                verticalTime = verticalMP.keyframes[verticalMP.keyframes.length-1].time;
204                verticalFinalPosition = Number(verticalMP.keyframes[verticalMP.keyframes.length-1].value);
205            }
206        }
207
208        if (throwEffectMotionPaths.length != 0)
209        {
210            this.duration = Math.max(horizontalTime, verticalTime);
211            this.motionPaths = throwEffectMotionPaths;
212            finalPosition = new Point(horizontalFinalPosition, verticalFinalPosition);
213            return true;
214        }
215        return false;
216    }
217
218    /**
219     *  @private
220     *  Helper function for getCurrentVelocity.
221     */
222    private function getMotionPathCurrentVelocity(mp:MotionPath, currentTime:Number, totalTime:Number):Number
223    {
224        // Determine the fraction of the effect that has already played.
225        var fraction:Number = currentTime / totalTime;
226
227        // Now we need to determine the effective velocity at the effect's current position.
228        // Here we use a "poor man's" approximation that doesn't require us to know any of the
229        // derivative functions associated with the motion path.  We sample the position at two
230        // time values very close together and assume the velocity slope is a straight line
231        // between them.  The smaller the distance between the two time values, the closer the
232        // result will be to the "instantaneous" velocity.
233        const TINY_DELTA_TIME:Number = 0.00001;
234        var value1:Number = Number(mp.getValue(fraction));
235        var value2:Number = Number(mp.getValue(fraction + (TINY_DELTA_TIME / totalTime)));
236        return (value2 - value1) / TINY_DELTA_TIME;
237    }
238
239    /**
240     *  @private
241     *  Calculates the current velocities of the in-progress throw animation
242     */
243    mx_internal function getCurrentVelocity():Point
244    {
245        // Get the current position of the existing throw animation
246        var effectTime:Number = this.playheadTime;
247
248        // It's possible for playheadTime to not be set if we're getting it
249        // before the first animation timer call.
250        if (isNaN(effectTime))
251            effectTime = 0;
252
253        var effectDuration:Number = this.duration;
254
255        var velX:Number = horizontalMP ? getMotionPathCurrentVelocity(horizontalMP, effectTime, effectDuration) : 0;
256        var velY:Number = verticalMP ? getMotionPathCurrentVelocity(verticalMP, effectTime, effectDuration) : 0;
257
258        return new Point(velX, velY);
259    }
260
261
262    /**
263     *  @private
264     *  A utility function to add a new keyframe to the motion path and return the frame time.
265     */
266    private function addKeyframe(motionPath:SimpleMotionPath, time:Number, position:Number, easer:IEaser):Number
267    {
268        var keyframe:Keyframe = new Keyframe(time, position);
269        keyframe.easer = easer;
270        motionPath.keyframes.push(keyframe);
271        return time;
272    }
273
274    /**
275     *  @private
276     *  This function builds a motion path that reflects the starting conditions (position, velocity)
277     *  and exhibits overshoot/settle/snap effects (aka bounce/pull) according to the min/max boundaries.
278     */
279    private function createThrowMotionPath(propertyName:String, velocity:Number, position:Number, minPosition:Number,
280                                           maxPosition:Number, throwEffectTime:Number):SimpleMotionPath
281    {
282        var motionPath:SimpleMotionPath = new SimpleMotionPath(propertyName);
283        motionPath.keyframes = Vector.<Keyframe>([new Keyframe(0, position)]);
284        var keyframe:Keyframe = null;
285        var nowTime:Number = 0;
286        var alignedPosition:Number;
287
288        // First, we handle the case where the velocity is zero (finger wasn't significantly moving when lifted).
289        // Ordinarily, we do nothing in this case, but if the list is currently scrolled past its end (i.e. "pulled"),
290        // we need to have the animation move it back so none of the empty space is visible.
291        if (velocity == 0)
292        {
293            if ((position < minPosition || position > maxPosition))
294            {
295                // Velocity is zero and we're past the end of the list.  We want the
296                // list to "snap" back to its resting position at the end.  We use a
297                // cubic easer curve so the snap has high initial velocity and
298                // gradually decelerates toward the resting point.
299                position = position < minPosition ? minPosition : maxPosition;
300
301                if (finalPositionFilterFunction != null)
302                    position = finalPositionFilterFunction(position, propertyName);
303
304                nowTime = addKeyframe(motionPath, nowTime + THROW_SETTLE_TIME, position, new Power(0, THROW_CURVE_EXPONENT));
305            }
306            else
307            {
308                // See if we need to snap into alignment
309                alignedPosition = position;
310                if (finalPositionFilterFunction != null)
311                    alignedPosition = finalPositionFilterFunction(position, propertyName);
312
313                if (alignedPosition == position)
314                    return null;
315
316                isSnapping = true;
317                nowTime = addKeyframe(motionPath, nowTime + THROW_SETTLE_TIME, alignedPosition, new Power(0, THROW_CURVE_EXPONENT));
318            }
319        }
320
321        // Each iteration of this loop adds one of more keyframes to the motion path and then
322        // updates the velocity and position values.  Once the velocity has decayed to zero,
323        // the motion path is complete.
324        while (velocity != 0.0)
325        {
326            if ((position < minPosition && velocity > 0) || (position > maxPosition && velocity < 0))
327            {
328                // We're past the end of the list and the velocity is directed further beyond
329                // the end.  In this case we want to overshoot the end of the list and then
330                // settle back to it.
331                var settlePosition:Number = position < minPosition ? minPosition : maxPosition;
332
333                if (finalPositionFilterFunction != null)
334                    settlePosition = finalPositionFilterFunction(settlePosition, propertyName);
335
336                // OVERSHOOT_CURVE_EXPONENT is the default initial slope of the easer function we use for the overshoot.
337                // This calculation scales the y axis (distance) of the overshoot so the actual slope matches the velocity.
338                var overshootPosition:Number = Math.round(position -
339                    ((velocity / OVERSHOOT_CURVE_EXPONENT) * THROW_OVERSHOOT_TIME));
340
341                nowTime = addKeyframe(motionPath, nowTime + THROW_OVERSHOOT_TIME,
342                    overshootPosition, new Power(0, OVERSHOOT_CURVE_EXPONENT));
343                nowTime = addKeyframe(motionPath, nowTime + THROW_SETTLE_TIME, settlePosition, new Sine(0.25));
344
345                // Clear the velocity to indicate that the motion path is complete.
346                velocity = 0;
347                position = settlePosition;
348            }
349            else
350            {
351                // Here we're going to do a "normal" throw.
352
353                var effectTime:Number = throwEffectTime;
354
355                var minVelocity:Number;
356                if (position < minPosition || position > maxPosition)
357                {
358                    // The throw is starting beyond the end of the list.  We need to enforce a minimum velocity
359                    // to make sure the throw makes it all the way back to the end (i.e. doesn't leave any blank area
360                    // exposed) and does so within THROW_SETTLE_TIME.  THROW_SETTLE_TIME needs to be consistently
361                    // adhered to in all cases where the tension of being beyond the end acts on the scroll position.
362
363                    // The minimum velocity is that which gets us back to the end position in exactly THROW_SETTLE_TIME milliseconds.
364                    minVelocity = ((position - (position < minPosition ? minPosition : maxPosition)) /
365                        THROW_SETTLE_TIME) * THROW_CURVE_EXPONENT;
366                    if (Math.abs(velocity) < Math.abs(minVelocity))
367                    {
368                        velocity = minVelocity;
369                        effectTime = THROW_SETTLE_TIME;
370                    }
371                }
372
373                // The easer function we use is 1-((1-x)^THROW_CURVE_EXPONENT), which has an initial slope of THROW_CURVE_EXPONENT.
374                // The x axis is scaled according to the throw duration we calculated above, so now we need
375                // to determine the correct y-axis scaling (i.e. throw distance) such that the initial
376                // slope matches the specified throw velocity.
377                var finalPosition:Number = Math.round(position - ((velocity / THROW_CURVE_EXPONENT) * effectTime));
378
379                if (finalPosition < minPosition || finalPosition > maxPosition)
380                {
381                    // The throw is going to hit the end of the list.  In this case we need to clip the
382                    // deceleration curve at the appropriate point.  We want the curve to look exactly as
383                    // it would if we were allowing the throw to go beyond the end of the list.  But the
384                    // keyframe we add here will stop exactly at the end.  The subsequent loop iteration
385                    // will add keyframes that describe the overshoot & settle behavior.
386
387                    var endPosition:Number = finalPosition < minPosition ? minPosition : maxPosition;
388
389                    // since easing function is f(t) = start + (final - start) * e(t)
390                    // e(t) = Math.pow(1 - t/throwEffectTime, 3)
391                    // We want to solve for t when e(t) = finalPosition
392                    // t = throwEffectTime*(1-(Math.pow(1-((endPosition-position)/(finalVSP-position)),1/3)));
393                    var partialTime:Number =
394                        effectTime*(1 - (Math.pow(1 - ((endPosition - position) / (finalPosition - position)), 1 / THROW_CURVE_EXPONENT)));
395
396                    // PartialExponentialCurve creates a portion of the throw easer curve, but scaled up to fill the
397                    // specified duration.
398                    nowTime = addKeyframe(motionPath, nowTime + partialTime, endPosition,
399                        new PartialExponentialCurve(THROW_CURVE_EXPONENT, partialTime / effectTime));
400
401                    // Set the position just past the end of the list for the next loop iteration.
402                    if (finalPosition < minPosition)
403                        position = minPosition - 1;
404                    if (finalPosition > maxPosition)
405                        position = maxPosition + 1;
406
407                    // Set the velocity for the next loop iteration.  Make sure it matches the actual velocity in effect when the
408                    // throw reaches the end of the list.
409                    //
410                    // The easer function we use for the throw is 1-((1-x)^3), the derivative of which is 3*x^2-6*x+3.
411                    // (I used http://www.numberempire.com/derivatives.php to differentiate the easer function).
412                    // Since the slope of a curve function at any point x (i.e. f(x)) is the value of the derivative at x (i.e. f'(x)),
413                    // we can use this to determine the velocity of the throw at the point it reached the beginning of the bounce.
414                    var x:Number = partialTime / effectTime;
415                    var y:Number =  3 * Math.pow(x, 2) - 6 * x + 3; // NOTE: This calculation must be matched to the THROW_CURVE_EXPONENT value.
416                    velocity = -y * (finalPosition - position) / effectTime;
417                }
418                else
419                {
420                    // This is the simplest case.  The throw both begins and ends on the list (i.e. not past the
421                    // end of the list).  We create a single keyframe and clear the velocity to indicate that the
422                    // motion path is complete.
423                    // Note that we only use the first 62% of the actual deceleration curve, and stop the motion
424                    // path at that point.  That's the point in time at which most throws animations get to within
425                    // a single pixel of their final destination.  Since scrolling is done at whole pixel
426                    // boundaries, there's no point in letting the rest of the animation play out, and stopping it
427                    // allows us to release the mouse capture earlier for a better user experience.
428
429                    if (finalPositionFilterFunction != null)
430                        finalPosition = finalPositionFilterFunction(finalPosition, propertyName);
431
432                    const CURVE_PORTION:Number = 0.62;
433                    nowTime = addKeyframe(
434                        motionPath, nowTime + (effectTime*CURVE_PORTION), finalPosition,
435                        new PartialExponentialCurve(THROW_CURVE_EXPONENT, CURVE_PORTION));
436                    velocity = 0;
437                }
438            }
439        }
440        return motionPath;
441    }
442
443
444}
445}
446
447import spark.effects.easing.EaseInOutBase;
448
449/**
450 *  @private
451 *  A custom ease-out-only easer class which animates along a specified
452 *  portion of an exponential curve.
453 */
454class PartialExponentialCurve extends EaseInOutBase
455{
456    public function PartialExponentialCurve(exponent:Number, xscale:Number)
457    {
458        super(0);
459        _exponent = exponent;
460        _xscale = xscale;
461        _ymult = 1 / (1 - Math.pow(1 - _xscale, _exponent));
462    }
463
464    override protected function easeOut(fraction:Number):Number
465    {
466        return _ymult * (1 - Math.pow(1 - fraction*_xscale, _exponent));
467    }
468    private var _xscale:Number;
469    private var _ymult:Number;
470    private var _exponent:Number;
471}
472
473