Tuesday, 15 November 2011

AS2 | Long Click Detection

This article deals with Long Click (aka Long Press aka Press and Hold) detection implementation using ActionScript 2.
Prerequisite: AS2 | Object.addProperty


When working with a touch screen UI, one has to often consider what someone else would intuitively do when dealing with the UI. Naturally, people tend to fall back to experience from older analogue devices which provide tactile feedback.

Think back to a few years ago, when the first Walkmans were becoming hip. You'd see teenagers walking down the street with a pair of earphones in their ears, clutching onto a small grey device. Looking closer, you would notice the spinning wheels of a cassette tape. These devices were in no way complex but they did what they were built to do: play music from cassette tapes.

Part of the playback relied on seeking which was done by pressing the fast forward or rewind keys. If you pressed the keys for a short time, the tape would rewind or fast forward just a little bit. If you held on for a longer moment, hearing the whirring in your earphones, you would be sent further back or forwards in the cassette.


The Need for better Long Click Detection

When designing a music UI, it is often expected that pressing the fast forward or rewind keys briefly will change the track being played. It is also expected that holding onto the forward or rewind keys will let you seek within the current track.

This feature is by no means difficult to write with ActionScript 2. In fact, I will do that now for a MovieClip button named mcPrev:
var count:Number = 0;

mcPrev.onPress = function (Void):Void
{
    this.onEnterFrame = function (Void):Void
    {
        if ( count < 10 ) {
            ext_fscommand("Rewind");
        }
        count++;
    }
}

mcPrev.onRelease = function (Void):Void
{
    if ( count <= 10 )
    {
        ext_fscommand("PreviousTrack");
    }
    count = 0;
    delete this.onEnterFrame;
}
The code above starts counting up to 10 once you press the button. When it reaches 10, it starts rewinding the current track. When the button is released, the code checks if the counting had been done above 10 and if not, changes the current track to the previously played track.

It's all very simple and Just Works.

What happens though when you want to get that working for 10 different clips?

  • First off, you can't use the variable count like I do, it must be tied to the MovieClip since it would be over-written by other actions.
  • Secondly, you would end up with hundreds of un-necessary lines of code.
  • Thirdly, you would get confused about how and when you should be using MovieClip.onEnterFrame. This is because the code above relies on the said event.
  • Fourthly, you would get tired of writing this piece of code, having to alter it ever so slightly as you increase the usage of it.

Would it not simply rock for you to be able to do the following?
mcPrev.onLongClick = function (Void):Void
{
    ext_fscommand("Rewind");
}

mcPrev.onShortClick = function (Void):Void
{
    ext_fscommand("PreviousTrack");
}




The Long Click Detection

To start off, we need to find a way to listen to the onEnterFrame event which occurs for every rendered frame in your flash movie. This is so that we can wait for a certain amount of time (delay) until we start announcing that a long click is occuring. This is also so that we can periodically announce when to run the onLongClick function.

The solution is to use a hidden Flash MX class called OnEnterFrameBeacon. This class has a single method called init() which starts listening to a ghost MovieClip's onEnterFrame event.
It relays the event to all listening Objects, allowing you to have an onEnterFrame listener anywhere in your code. OnEnterFrameBeacon is initiated in the following way:
import mx.transitions.OnEnterFrameBeacon;
OnEnterFrameBeacon.init();

Now let's consider what sort of events we have to listen to. There's the onPress, onRelease events as well as the onDragOut event. Afterall, the UI should stop rewinding once your fingers leave the button! Let's hijack these events and replace it with our own handler object.
This can be done via MovieClip.prototype.EVENT which lets you add methods to the default MovieClip class.
var LongClickHandler:Object = {};

MovieClip.prototype.addProperty( "onPress",
                                 function ():Function {
                                     return LongClickHandler.onPress;
                                 },
                                 function ( func:Function ):Void {
                                     this.onPress_ = func;
                                 } );

MovieClip.prototype.addProperty( "onRelease",
                                 function ():Function {
                                     return LongClickHandler.onRelease;
                                 },
                                 function ( func:Function ):Void {
                                     this.onRelease_ = func;
                                 } );

MovieClip.prototype.addProperty( "onDragOut",
                                 function ():Function {
                                     return LongClickHandler.onDragOut;
                                 },
                                 function ( func:Function ):Void {
                                     this.onDragOut_ = func;
                                 } );
This code basically creates a proxy for the mentioned events, making ActionScript go through our own methods first (LongClickHandler.EVENT). Compatibility is kept by putting all user-defined functions to the side (onPress_) so that we can run them after our own handler is done.

We can now add those onLongClick and onShortClick events:
MovieClip.prototype.addProperty( "onLongClick",
                                 function ():Function {
                                     return this.LongClick.func_long;
                                 },
                                 function ( func:Function ):Void {
                                     LongClickHandler.Init.call( this );
                                     this.LongClick.func_long = func;
                                 } );

MovieClip.prototype.addProperty( "onShortClick",
                                 function ():Function {
                                     return this.LongClick.func_short;
                                 },
                                 function ( func:Function ):Void {
                                     LongClickHandler.Init.call( this );
                                     this.LongClick.func_short = func;
                                 } );
These methods cache the functions which we set to them, and initialises the MovieClip's Long Click handler.

Let's start writing the handler now.
LongClickHandler.Init = function ( func ):Void {
    // NOTE: this == MovieClip
    if ( this.LongClick != undefined ) return;

    this.LongClick = {};
    this.LongClick.mc = this;
    this.LongClick.onEnterFrame = LongClickHandler.onEnterFrame;
}
We cache some data into a new Object which we need to use to listen to the onEnterFrame event. We also register the onEnterFrame event that we will write for our LongClickHandler.

We now write the proxy function for the event onPress which lets us start the Long Click handling.
LongClickHandler.onPress = function ():Void {
    // NOTE: this == MovieClip
    if ( this.LongClick.func_long ) {
        this.LongClick.start_t = getTimer();
        MovieClip.addListener( this.LongClick );
    }
    this.onPress_();
}
As you can see, the handling is initiated only if a function is defined for the long click handling (onLongClick). If it is defined, a starting ticks value is saved and we start listening to the onEnterFrame event which fires the function assigned to MovieClip.LongClick.onEnterFrame which is equal to LongClickHandler.onEnterFrame.

Now let's write the real deal.
// DELAY: Delay until LongClick handling is started
// INTERVAL: Interval at which LongClick should be called
// Both values are in milliseconds
LongClickHandler.DELAY = 350;
LongClickHandler.INTERVAL = 150;

LongClickHandler.onEnterFrame = function ():Void {
    // NOTE: this == MovieClip.LongClick
    // Calculate elapsed time (ms) since onPress
    var dt:Number = getTimer() - this.start_t;

    // this.last_t: last registered LongClick event
    //              exists once
    if ( this.last_t ) {
        var ivl:Number = LongClickHandler.INTERVAL;

        // this.trail_done: tells whether the trail-off for
        //                  the interval has been finished.
        if ( !this.trail_done ) {

            // Calculate trail-off
            var inc:Number = (ivl * 100000 / dt / dt) >> 0;

            // If trail-off changes are less than 10ms,
            // consider it done
            if ( inc < 10 ) this.trail_done = true;

            // Increase interval by trail-off change
            ivl += inc;
        }

        // Calculate elapsed time (ms) since last LongClick
        dt = getTimer() - this.last_t;

        // If greater than calculated interval
        if ( dt >= ivl ) {

            // Remember this time
            this.last_t = getTimer();

            // Call the function registered to onLongClick
            this.func_long.call( this.mc );
        }
        return;
    }

    // If LongClick not called before, and time elapsed
    // larger than defined DELAY
    else if ( dt > LongClickHandler.DELAY ) {
        this.last_t = getTimer();
        this.func_long.call( this.mc );
    }
}
As you can see in the chunk of code above, I have coded a 'trail-off' which lets your interval decrease to your target interval defined in LongClickHandler.INTERVAL. This gives an effect of a speed-up due to the longer duration of pressing-the-button.

We can't perpetually detect LongClicks though and need to end it at some point. The following code does that as well as adding onShortClick handling:
LongClickHandler.cleanUp = function ():Void {
    // NOTE: this == MovieClip
    delete this.LongClick.last_t;
    delete this.LongClick.trail_done;
    MovieClip.removeListener( this.LongClick );
}

LongClickHandler.onRelease = function ():Void {
    // NOTE: this == MovieClip
    if ( !this.LongClick.last_t ) this.LongClick.func_short();
    LongClickHandler.cleanUp.call( this );
    this.onRelease_();
}

LongClickHandler.onDragOut = function ():Void {
    // NOTE: this == MovieClip
    LongClickHandler.cleanUp.call( this );
    this.onDragOut_();
}



Usage

I have been very rough with explaining how it all works but there's a reason for it. You don't need to know! Like I mentioned before, you simply have to do something like the following to make use of my code (full code provided at the end).
mcPrev.onLongClick = function (Void):Void
{
    ext_fscommand("Rewind");
}

mcPrev.onShortClick = function (Void):Void
{
    ext_fscommand("PreviousTrack");
}

How convenient is that!



Full Code

var LongClickHandler:Object = {};
LongClickHandler.DELAY = 350;
LongClickHandler.INTERVAL = 150;

LongClickHandler.Init = function ( func ):Void {
    // NOTE: this == MovieClip
    if ( this.LongClick != undefined ) return;

    this.LongClick = {};
    this.LongClick.mc = this;
    this.LongClick.onEnterFrame = LongClickHandler.onEnterFrame;
}

LongClickHandler.cleanUp = function ():Void {
    // NOTE: this == MovieClip
    delete this.LongClick.last_t;
    delete this.LongClick.trail_done;
    MovieClip.removeListener( this.LongClick );
}

LongClickHandler.onEnterFrame = function ():Void {
    // NOTE: this == MovieClip.LongClick
    var dt:Number = getTimer() - this.start_t;
    if ( this.last_t ) {
        var ivl:Number = LongClickHandler.INTERVAL;
        if ( !this.trail_done ) {
            var inc:Number = ( ivl * 100000 / dt / dt ) >> 0;
            if ( inc < 10 ) this.trail_done = true;
            ivl += inc;
        }

        dt = getTimer() - this.last_t;
        if ( dt >= ivl ) {
            this.last_t = getTimer();
            this.func_long.call( this.mc );
        }
        return;
    }
    else if ( dt > LongClickHandler.DELAY ) {
        this.last_t = getTimer();
        this.func_long.call( this.mc );
    }
}


// Replace onPress
LongClickHandler.onPress = function ():Void {
    // NOTE: this == MovieClip
    if ( this.LongClick.func_long ) {
        this.LongClick.start_t = getTimer();
        MovieClip.addListener( this.LongClick );
    }
    this.onPress_();
}

MovieClip.prototype.addProperty( "onPress",
                                 function ():Function {
                                     return LongClickHandler.onPress;
                                 },
                                 function ( func:Function ):Void {
                                     this.onPress_ = func;
                                 } );


// Replace onDragOut
LongClickHandler.onDragOut = function ():Void {
    // NOTE: this == MovieClip
    LongClickHandler.cleanUp.call( this );
    this.onDragOut_();
}

MovieClip.prototype.addProperty( "onDragOut",
                                 function ():Function {
                                     return LongClickHandler.onDragOut;
                                 },
                                 function ( func:Function ):Void {
                                     this.onDragOut_ = func;
                                 } );


// Replace onRelease
LongClickHandler.onRelease = function ():Void {
    // NOTE: this == MovieClip
    if ( !this.LongClick.last_t ) this.LongClick.func_short();
    LongClickHandler.cleanUp.call( this );
    this.onRelease_();
}

MovieClip.prototype.addProperty( "onRelease",
                                 function ():Function {
                                     return LongClickHandler.onRelease;
                                 },
                                 function ( func:Function ):Void {
                                     this.onRelease_ = func;
                                 } );


MovieClip.prototype.addProperty( "onLongClick",
                                 function ():Function {
                                     return this.LongClick.func_long;
                                 },
                                 function ( func:Function ):Void {
                                     LongClickHandler.Init.call( this );
                                     this.LongClick.func_long = func;
                                 } );

MovieClip.prototype.addProperty( "onShortClick",
                                 function ():Function {
                                     return this.LongClick.func_short;
                                 },
                                 function ( func:Function ):Void {
                                     LongClickHandler.Init.call( this );
                                     this.LongClick.func_short = func;
                                 } );

Please reply below if you need help with understanding the code!


Edit: This code does not work. Click here for an updated version of the code.