How to write a Mootools Class
Here's a line-by-line, step-by-step walk-through of me writing a class. The actual class in this example, a slideshow, is in our common library and has more functionality than this example.
NOTE: Be sure to click the show comments link in each step to see detailed commentary on what's happening in each section.
Here we have the start of nearly every class I ever write. The class will have options, events, and an initialization phase. The options and events are optional of course, but unless I'm writing something that has only one use, there will be options and events.
Providing these hooks into your classes, even if you're only writing them for yourself, will make them much more reusable.
- var SimpleSlideShowDemo = new Class({
- Implements: [Options, Events],
- options: {
- },
- initialize: function(options){
- this.setOptions(options);
- }
- });
Ok, we're writing a slideshow, so what do we know from the start we're going to have in such a class? Well, slides for sure, right? Additionally, let's also give the user (i.e. the person creating an instance of this class, not the person looking at it in their browser who I'll refer to as the "viewer") the option to specify where in the list the slideshow should start and if the slideshow should wrap around and go back to the start when it reaches the end of the list.
These options specify the default state of these values, but the user can overwrite them with their own. This functionality is provided by the Options class. When we do Implements: Options we add this convention and functionality to our class. We can then execute this.setOptions(options) and the user's values will overwrite the default state, but if the user only specifies a portion of these, then the remaining defaults will still apply.
It's important to note that Options are just that: optional. If you have content that is required for your class to work, you should probably make these arguments that are passed to the initialization phase of your class. Here I could have made the slides required, except that I want to be able to add slides later, which means that I could create a slideshow with no slides and then add more at some later point. This makes the slides optional.
- var SimpleSlideShowDemo = new Class({
- Implements: [Options, Events],
- options: {
- slides: [],
- startIndex: 0,
- wrap: true
- },
- initialize: function(options){
- this.setOptions(options);
- this.addSlides(this.options.slides);
- if(this.slides.length) this.showSlide(this.options.startIndex);
- },
- slides: []
- });
Next we set the options. Even if the user didn't pass an argument, it's ok to pass a null or undefined value to setOptions. You can also pass more than one object in, but we don't need to here.
Now I'm going to add slides for the array of slides that the user passed in. If the user didn't pass any in, then it's an empty array (the default) so nothing will happen.
If there are any slides, we're going to show the first one specified in the options.
Here I set up a member of this class for all the slides. I could just set it to this.options.slides but in this case I want to actually process the slides passed in through a method that I'll write in the next step. By having this logic in a seperate method, the user can add slides at any point.
- var SimpleSlideShowDemo = new Class({
- Implements: [Options, Events],
- options: {
- slides: [],
- startIndex: 0,
- wrap: true
- },
- initialize: function(options){
- this.setOptions(options);
- this.addSlides(this.options.slides);
- if(this.slides.length) this.showSlide(this.options.startIndex);
- },
- slides: [],
- addSlides: function(slides){
- $$(slides).each(function(slide){
- this.slides.include($(slide));
- }, this);
- },
- addSlide: function(slide){
- this.addSlides($splat($(slide)));
- }
- });
Ok. So we pass addSlides an array of dom elements to add. We loop through this array using .each and, for each one, we add it to the array of slides we're maintaining as this.slides. We use Array.include so that we don't have any duplicates.
Note that I run the array through $$. If it's already a collection nothing will happen - it just returns the collection, but if it's not a collection, it'll add all the Moo functions to the elements. This means that users also have the option of passing in a selector if they choose.
Using Array.each, we iterate over the slides. Here is an important thing to understand about binding: the this monicker. Inside a class, this refers to the instance of the class. So, for instance, "var SomeClass = new Class({...});" is a class. Then "var instanceOfSomeClass = new SomeClass();" creates an instance of it.
To refer to members of that instance, we use the this keyword. this doesn't refer to SomeClass, it refers to instanceOfSomeClass.
Inside a function, any function, this refers to that function, except when that function is a method (i.e. the member of an object, as with our class). So with Array.each, you pass it two arguments, the first is the function you want to execute for each item in the array, and the second thing is the object you want to bind this to.
You need this binding because otherwise the function you are executing would have this bound to itself, not your class. As a side note, you only need to specify this binding when you refer to this in the function; if you don't reference your Class's methods or properties, then you don't need to bind anything (it won't hurt anything if you do). More on binding.
I include this slide into the array of slides for the class. Using Array.include I add the slide to the array only if it's not already there.
Here's where we specify the this for binding.
Because I like to provide such things, I also include a method to add a single slide. All this does is wrap the element in an array (with $splat) and call .addSlides.
- var SimpleSlideShowDemo = new Class({
- Implements: [Options, Events],
- options: {
- slides: [],
- startIndex: 0,
- wrap: true
- },
- initialize: function(options){
- this.setOptions(options);
- this.addSlides(this.options.slides);
- if(this.slides.length) this.showSlide(this.options.startIndex);
- },
- slides: [],
- addSlides: function(slides){
- $$(slides).each(function(slide){
- this.slides.include($(slide));
- slide.addEvent('click', this.cycleForward.bind(this));
- }, this);
- },
- addSlide: function(slide){
- this.addSlides($splat($(slide)));
- }
- });
So the user has to have a way to go through the slideshow. Now, in the real example of this class (SimpleSlideShow in CNET's common library), I let the user specify dom elements for next and previous behavior. For this example, let's just attach the behavior to the slide. If the user clicks the slide, we cycle forward.
Element.addEvent attaches your function to the element's event handler, but by default it binds that function to the element, so the this in the attached function is the element, but we need it to be this class (our slide show class), so when we add the event we have to bind our class to the function.
Note that, unlike Array.each, we must use the .bind method here, as addEvent does not accept a third argument for binding.
- var SimpleSlideShowDemo = new Class({
- Implements: [Options, Events],
- options: {
- slides: [],
- startIndex: 0,
- wrap: true
- },
- initialize: function(options){
- this.setOptions(options);
- this.addSlides(this.options.slides);
- if(this.slides.length) this.showSlide(this.options.startIndex);
- },
- slides: [],
- addSlides: function(slides){
- $$(slides).each(function(slide){
- this.slides.include($(slide));
- slide.addEvent('click', this.cycleForward.bind(this));
- }, this);
- },
- addSlide: function(slide){
- this.addSlides($splat($(slide)));
- },
- cycleForward: function(){
- if($chk(this.now) && this.now < this.slides.length-1) this.showSlide(this.now+1);
- else if ((this.now) && this.options.wrap) this.showSlide(0);
- else if(!$defined(this.now)) this.showSlide(this.options.startIndex);
- },
- cycleBack: function(){
- if(this.now > 0) this.showSlide(this.now-1);
- else if(this.options.wrap) this.showSlide(this.slides.length-1);
- }
- });
Cycle forward, cycle back. In this short example I actually don't define a way for the user to cycle back - they can only cycle forward, but in real life I use the next/previous links specified in the options to allow the user to go either way.
...else, if this.now is "truthy" (i.e. defined and not null and not zero) and we're allowed to wrap, go back to zero...
...else, in theory, this.now isn't defined because we've never shown a slide before, so let's just show the first one set in the options.
This class doesn't make use of it, but I went ahead and wrote a method to cycle in the other direction. It assumes that cycleForward has been called at least once (as it's called when you initialize the class) and, thus, that this.now has a value. In the real class (SimpleSlideShow) I let the user pass in references to dom elements for next/previous links.
- var SimpleSlideShowDemo = new Class({
- Implements: [Options, Events],
- options: {
- slides: [],
- startIndex: 0,
- wrap: true
- },
- initialize: function(options){
- this.setOptions(options);
- this.addSlides(this.options.slides);
- if(this.slides.length) this.showSlide(this.options.startIndex);
- },
- slides: [],
- addSlides: function(slides){
- $$(slides).each(function(slide){
- this.slides.include($(slide));
- slide.addEvent('click', this.cycleForward.bind(this));
- }, this);
- },
- addSlide: function(slide){
- this.addSlides($splat($(slide)));
- },
- cycleForward: function(){
- if($chk(this.now) && this.now < this.slides.length-1) this.showSlide(this.now+1);
- else if ((this.now) && this.options.wrap) this.showSlide(0);
- else if(!$defined(this.now)) this.showSlide(this.options.startIndex);
- },
- cycleBack: function(){
- if(this.now > 0) this.showSlide(this.now-1);
- else if(this.options.wrap) this.showSlide(this.slides.length-1);
- },
- showSlide: function(iToShow){
- if(this.slides[iToShow]) {
- if(this.slides[this.now]) this.slides[this.now].setStyle('display','none');
- this.slides[iToShow].setStyle('display','block');
- this.now = iToShow;
- }
- }
- }
- });
Showing a slide just means we set the css property "display" to "block" for the slide to show and set display to "none" for everything else.
If the slide index passed in is in our list of available slides, then show this slide. In case this is the first attempt to show a slide, this.now won't be defined, so only hide the current slide if it's defined (if we didn't test for this, we'd try and execute .setStyle on undefined and we'd get an error).
- var SimpleSlideShowDemo = new Class({
- Implements: [Options, Events],
- options: {
- slides: [],
- startIndex: 0,
- wrap: true
- },
- initialize: function(options){
- this.setOptions(options);
- this.addSlides(this.options.slides);
- if(this.slides.length) this.showSlide(this.options.startIndex);
- },
- slides: [],
- addSlides: function(slides){
- $$(slides).each(function(slide){
- this.slides.include($(slide));
- slide.addEvent('click', this.cycleForward.bind(this));
- }, this);
- },
- addSlide: function(slide){
- this.addSlides($splat($(slide)));
- },
- cycleForward: function(){
- if($chk(this.now) && this.now < this.slides.length-1) this.showSlide(this.now+1);
- else if ((this.now) && this.options.wrap) this.showSlide(0);
- else if(!$defined(this.now)) this.showSlide(this.options.startIndex);
- },
- cycleBack: function(){
- if(this.now > 0) this.showSlide(this.now-1);
- else if(this.options.wrap) this.showSlide(this.slides.length-1);
- },
- showSlide: function(iToShow){
- var now = this.now;
- var currentSlide = this.slides[now];
- var slide = this.slides[iToShow];
- var fadeIn = function (s){
- s.setStyles({
- display:'block',
- visibility: 'visible',
- opacity: 0
- });
- s.get('tween').start('opacity', 1);
- }.bind(this);
- if(slide) {
- if($chk(now) && now != iToShow){
- currentSlide.get('tween').start('opacity', 0).chain(function(){
- currentSlide.setStyle('display', 'none');
- fadeIn(slide);
- }.bind(this));
- } else fadeIn(slide);
- this.now = iToShow;
- }
- }
- });
Of course, just showing the thing is no fun. We want to cross-fade these things. There are numerous ways to handle this transition, but let's try and keep it simple. We want the visible slide to fade out and, when its finished, we want the next slide to fade in.
When we get down a few lines you'll see that we set this.now to the slide we're about to show (iToShow). This way our class knows which slide is visible at any time.
But we've got a new wrinkle here: the transition. Because the transition takes time, we can't refer to this.now because it will change immediately, while our transition takes time. More on this in a sec, but this is why I declare this variable now within this method, to keep it from being poluted.
Here's an example of a closure. This method, fadeIn, is going to reference values already declared here, but we want to fade in the next slide only after the current one has faded out. By declaring the function now, at execution time, the function has access to all the variables in scope (within this method). So even though the function is called later (after the fade out of the previous slide), it still has references to all the values defined here.
All this function does is change the styles so that the element is displayed as block (so it's in the layout flow) but hidden (opacity: 0), then tweens the opacity from 0 to 1.
Note that this function didn't have to be named - I could have just executed it anonymously below. The only reason I declared it here is because I reference it twice and I didn't want to duplicate the code.
Note that in the line above I bind this to my function here to ensure that any references to this point to my instance of this class.
The first time this method is called there isn't a slide visible, so there's nothing to fade out. If that's not the case (because this isn't the first time) we're going to fade out the current one and then fade in the next one.
We fade out the existing slide and then, using Chain, execute our functions to fade in the next slide.
Now that the current slide is hidden, we can set its display to "none". Our opacity effect just makes it transparent, but before we show the next slide, we need to take the current one out of the page flow.
Now we fade in the next slide using the fadeIn function above.
Note that we need to use .bind(this) here because, within this function (that we're passing to .chain) we need to reference the instance of this class. Some methods will let you pass in a bind object - like Array.each or Function.apply. When this isn't available, you must use .bind(this) to acheive this effect.
If we're here either now is undefined, or the slide is already visible. In either case, calling fadeIn immediately will either fade in the slide, or, if it's already visible, have no effect.
And then we save this.now to reflect which item is now visible. Because we reference now in the chain above (in other words, we reference now after the visible slide finishes hiding), we can't reference this.now, because this next line is executed immediately, while the chained code is executed when the slide finishes fading out.
- var SimpleSlideShowDemo = new Class({
- Implements: [Options, Events],
- options: {
- slides: [],
- startIndex: 0,
- wrap: true
- //onShow: $empty
- },
- initialize: function(options){
- this.setOptions(options);
- this.addSlides(this.options.slides);
- if(this.slides.length) this.showSlide(this.options.startIndex);
- },
- slides: [],
- addSlides: function(slides){
- $$(slides).each(function(slide){
- this.slides.include($(slide));
- slide.addEvent('click', this.cycleForward.bind(this));
- }, this);
- },
- addSlide: function(slide){
- this.addSlides($splat($(slide)));
- },
- cycleForward: function(){
- if($chk(this.now) && this.now < this.slides.length-1) this.showSlide(this.now+1);
- else if ((this.now) && this.options.wrap) this.showSlide(0);
- else if(!$defined(this.now)) this.showSlide(this.options.startIndex);
- },
- cycleBack: function(){
- if(this.now > 0) this.showSlide(this.now-1);
- else if(this.options.wrap) this.showSlide(this.slides.length-1);
- },
- showSlide: function(iToShow){
- if (this.fading) return;
- var now = this.now;
- var currentSlide = this.slides[now];
- var slide = this.slides[iToShow];
- var fadeIn = function (s){
- this.fading = true;
- s.setStyles({
- display:'block',
- visibility: 'visible',
- opacity: 0
- });
- s.get('tween').start('opacity', 1).chain(function(){
- this.fading = false;
- this.fireEvent('onShow', [slide, iToShow]);
- }.bind(this));
- }.bind(this);
- if(slide) {
- if($chk(now) && now != iToShow){
- this.fading = true;
- currentSlide.get('tween').start('opacity', 0).chain(function(){
- currentSlide.setStyle('display', 'none');
- fadeIn(slide);
- }.bind(this));
- } else fadeIn(slide);
- this.now = iToShow;
- }
- }
- });
The Events class let's you add custom events to your classes. These are really powerful and make your code a lot more reusable. I recommend adding these events at any point in your code that the viewer or some other system can effect a change. onShow, onHide, onComplete, onStart, onError, etc. These hooks let others (and you, too) tie the behavior of this class in with their own in a non-obtrusive manner. You can't have too many of these events.
Because we implemented Options into this class, we inherit some help from that class with regard to Events. If you have any options that start with "on", when you call this.setOptions(options) - a function defined by the Options class - they will automatically be set up as Events. Here I create an event for onShow. In the actual class that I wrote for production, I have onNext, onPrev, and onSlideClick. Note that you can also attach methods to these events using addEvent.
Just having this event in the options doesn't do anything. We still have to execute it. See line 45.
For the default value we hand it an empty function. This can be executed and nothing will happen. It's basically the same as setting this value to function(){}. The setOptions method finds any options that begin with "on" with values that are functions and adds them as events to your class. It also then removes that value from the options entirely, so instances of this class will not have a value for this.options.onShow after the options are set. Because of this, it's common practice to comment out the line (since it's just an empty function that's going to be removed anyway). The commented line is left only so that it's easy to read the code itself and know what options are available instead of having to look through the rest of the class for any fireEvent calls. This commented line is then removed when the file is compressed to be delivered to the browser (another common practice).
Hmmm. We have one little problem with our class thus far. By adding the delay that occurs when we fade out the previous image and fade in the current one, we introduce the possibility that the user might click either of them (the image fading out or the one fading in) while that's in progress.
There are two ways to deal with this problem. The first is to use the Chain class to stack up cycle requests. If the user clicks while we're in the middle of showing a slide, we add the new request to the chain and, when the current transition completes we call the next request on the chain. I do this in a lot of places (Fx.Reveal, IconMenu, etc) but I don't want to introduce that complexity here in this tutorial.
The other way to deal with this is to just ignore requests that occur during the transition. This means that if we're in the middle of showing a slide and the user clicks, we don't do anything. Because this solution is much less complex, I'm going to use it here.
This just means we must set a flag that we're in the middle of a transition (this.fading = true) and then set it to false when the transition is complete. Any requests to show a slide that occurs while we're in transition is ignored with this next line.
We're going to have to tell the class when to fire the "onShow" event we added to the options. I want this event to be called after the image fades in. Why? Because any code that makes use of this event expects the user to be able to see the next slide. I might also add an event that is fired before the effect, in case someone wants to execute something then, but for our example, we'll just use this one event.
To execute a method after an effect (like Fx.Tween) we use the Chain class methods that Fx implements.
The Events class, which we implemented into this one, provides us with a method to fire events. It takes as arguments the event name to fire (with the "on" removed - eg. "onShow" becomes just "show"), any arguments to pass to the callback function (more than one argument should be in an array) - this argument is optional, and an optional delay (in milleseconds). Here I pass along the slide DOM element and the index. I should note this in my documentation so others will know what arguments are sent to this event.
- var SimpleSlideShowDemo = new Class({
- Implements: [Options, Events],
- options: {
- slides: [],
- startIndex: 0,
- wrap: true
- //onShow: $empty
- },
- initialize: function(options){
- this.setOptions(options)
- this.addSlides(this.options.slides);
- if(this.slides.length) this.showSlide(this.options.startIndex);
- },
- slides: [],
- addSlides: function(slides){
- $$(slides).each(function(slide){
- this.slides.include($(slide));
- slide.addEvent('click', this.cycleForward.bind(this));
- }, this);
- },
- addSlide: function(slide){
- this.addSlides($splat($(slide)));
- },
- cycleForward: function(){
- if($chk(this.now) && this.now < this.slides.length-1) this.showSlide(this.now+1);
- else if ((this.now) && this.options.wrap) this.showSlide(0);
- else if(!$defined(this.now)) this.showSlide(this.options.startIndex);
- },
- cycleBack: function(){
- if(this.now > 0) this.showSlide(this.now-1);
- else if(this.options.wrap) this.showSlide(this.slides.length-1);
- },
- showSlide: function(iToShow){
- if (this.fading) return;
- var now = this.now;
- var currentSlide = this.slides[now];
- var slide = this.slides[iToShow];
- var fadeIn = function (s){
- this.fading = true;
- s.setStyles({
- display:'block',
- visibility: 'visible',
- opacity: 0
- });
- s.get('tween').start('opacity', 1).chain(function(){
- this.fading = false;
- this.fireEvent('onShow', [slide, iToShow]);
- }.bind(this));
- }.bind(this);
- if(slide) {
- if($chk(now) && now != iToShow){
- this.fading = true;
- currentSlide.get('tween').start('opacity', 0).chain(function(){
- currentSlide.setStyle('display', 'none');
- fadeIn(slide);
- }.bind(this));
- } else fadeIn(slide);
- this.now = iToShow;
- }
- }
- });
- var SimpleImageSlideShowDemo = new Class({
- Extends: SimpleSlideShowDemo,
- options: {
- imgUrls: [],
- container: false
- },
- initialize: function(options){
- this.parent(options);
- this.container = $(this.options.container);
- if(!this.container) return;
- this.options.imgUrls.each(this.addImg.bind(this));
- this.showSlide(this.options.startIndex);
- },
- addImg: function(url){
- var img = new Element('img', {
- src: url,
- styles: {
- display: 'none'
- }
- }).inject($(this.options.container))
- this.addSlide(img);
- }
- });
We now have a complete and working slideshow, but let's say later we come back and we want to make a slide show that focues on images in particular. Let's say we want to be able to just hand it an array of urls to images and have it do the rest. Rather than copying all the code above we can extend it to add more functionality.
This is good for a lot of obvious reasons, but it helps to go out of your way to think this way. Try and make classes (and functions and methods) do one thing well. Then build more functions and classes to add more functionality. By doing this we not only cut down on code bloat, but you open up possibilities for other uses that you may not foresee now.
Here I create a new class called SimpleImageSlideShowDemo. This new class uses as it's foundation - or "parent" - the class we wrote above. We use Class.extend to do this.
Options declared here get blended in to the options for the parent class, so in addition to these options here, this class also has all the options defined in SimpleSlideShowDemo.
We have to be careful here, because we're using the same namespace as a method defined in our parent class. This will overwrite that parent class's method (initialize) and we need that to work.
We can execute the functionality contained in the parent's version of this method by calling this.parent(). This can happen near the beginning, end, or in the middle of our method here. In this case, I want the parent's version of initialize to fire before the rest of my code, so I call it first. Also note that I don't call this.setOptions(options) here, because that is executed in the parent method.
When you call this.parent() you need to pass to it the arguments that it expects. So if our initialize method here took several arguments, but the parent initialize method only expects one, we must pass the appropriate arguments along up to it.
In the parent version of this method we already tell it to show the first slide (defined in the options as startIndex). We have to do it again here because it's possible (if not probable) that the user didn't pass in any images or slides already in the DOM but rather passed in an array of urls. Thus, the parent method wouldn't have any slides when we executed this.parent(options). After we added all the images in the line above, we'd still need to show one.
We have two classes, but that doesn't actually, you know, do anything. We need to create instances of those classes with the content on our page.
Because we need to reference content in the DOM, we have to wait for that content to have loaded. This is where the custom event "domready" comes in to play. This is an event Mootools adds to the window element that fires as soon as the DOM is ready, but before all the images and stuff load (that event is "onload"). By using domready, our code should execute at about the same time as the browser starts to draw the page.
- window.addEvent('domready', function(){
- new SimpleSlideShowDemo({
- slides: $$('div.slide')
- });
- new SimpleImageSlideShowDemo({
- imgUrls: [
- "http://download.com/i/dl/media/dlimage/10/87/78/108778_medium.jpeg",
- "http://download.com/i/dl/media/dlimage/10/87/79/108779_medium.jpeg",
- "http://download.com/i/dl/media/dlimage/10/87/81/108781_medium.jpeg"
- ],
- container: $('imgContainer')
- });
- });
Here's a new instance of our basic slide show. I pass it the dom elements returned when I search for all divs with the class "slide" - in this case, there are 5 of them.
And here's an image slideshow. I pass it a handful of urls and a container into which the images should be placed.
mootorial/09-howtowriteamootoolsclass.txt · Last modified: 2009/07/17 05:20 by oskar-krawczyk