Hostingheaderbarlogoj
Join InMotion Hosting for $3.49/mo & get a year on Tuts+ FREE (worth $180). Start today.
Advertisement

Create a Resizable AS3 ToolTip with OOP

Gift

Want a free year on Tuts+ (worth $180)? Start an InMotion Hosting plan for $3.49/mo.

In this tutorial we will create a ToolTip that automatically resizes itself. Our ToolTip will update its contents according to its position on stage, so that they are always visible. As part of this task, we'll also create our own code to handle 9-slice scaling.


Preview

Let's first take a quick look at our tooltip in action:


Step 1: Set Up FLA file

Let's begin by creating a new folder called "ToolTip". Inside the ToolTip folder, create an ActionScript 3.0 FLA.


Step 2: Creating the DisplayOjects (Sprites):

Now, create a rounded square of 150x77px with the following properties:

Select the rounded square and press F8 key to convert the square into a Sprite. Apply the config below:

Draw a picture (17x17px) similar to the figure below:

Select the second drawing, press F8 key and apply the config below:

Save your FLA file.


Step 3: Set Up FlashDevelop

FlashDevelop is an ActionScript editor with many resources. You can download FlashDevelop at http://www.flashdevelop.org/community/viewforum.php?f=11.

FlashDevelop documentation can be found at: http://www.flashdevelop.org/wikidocs/index.php?title=Main_Page

Open FlashDevelop, then select: Project > New Project, to create a new project.

In the dialog box, set the options as in the next image.


Step 4: Creating the ToolTip Class

First, select the Project tab (if the Project tab is not visible, select: view > Project Manager).

In this tab you can see all files and folders of the project. Right-click the ToolTip folder, then select: Add > New Folder and create a folder called tooltip (lowercase).

Now, right-click the tooltip folder and choose: Add > New Folder and create a folder called display. Right-click the display folder and select: Add > New class.

In the dialog box, insert ToolTip as the class name and browse to flash.display.Sprite in Base class field.

Now, our project has the following structure:

And this is our ToolTip class (generated automatically):

package tooltip.display 
{
    import flash.display.Sprite;
    
    public class ToolTip extends Sprite
    {
        public function ToolTip() 
        {
            
        }
    }
}

Step 5: Creating the Utils Package

The utils package will help us in features related to Bitmap, Sprite and TextField. The idea is to follow OOP practices, making this package reusable.

So, let's create the utils package. Right-click the tooltip folder, then select: Add > New Folder and create a folder called utils.

Create the BitmapUtils class inside this folder:

package tooltip.utils 
{
    public final class BitmapUtils
    {
        public function BitmapUtils() 
        {
            throw new Error("BitmapUtils must not be instantiated");
        }
    }
}

Create too, the SpriteUtils class:

package tooltip.utils 
{
    public final class SpriteUtils
    {
        public function SpriteUtils() 
        {
            throw new Error("SpriteUtils must not be instantiated");
        }
    }
}

Finally, create the TextFieldUtils class:

package tooltip.utils 
{
    public final class TextFieldUtils
    {
        public function TextFieldUtils() 
        {
            throw new Error("TextFieldUtils must not be instantiated");
        }
    }
}

Step 6: BitmapUtils Class

The BitmapUtils have a single static method that take a snapshot of a IBitmapDrawable instance.

Here is the code:

package tooltip.utils 
{
    import flash.display.Bitmap;
    import flash.display.BitmapData;
    import flash.display.IBitmapDrawable;
    import flash.display.PixelSnapping;
    import flash.geom.Matrix;	
    
    public final class BitmapUtils
    {
        
        public function BitmapUtils() 
        {
            throw new Error("BitmapUtils must not be instantiated");
        }
        /**
         * Create a snapshot of an IBitmapDrawable instance
         * @param	source IBitmapDrawable instance to be used as source
         * @param	width Final width
         * @param	height Final height
         * @param	matrix Matrix instance to manipulate the part of source that will be drawed
         * @param	smoothing Smooth the result
         * @param	cacheAsBitmap Stores the bitmap in memory
         * @return The snapshot
         */
        public static function snapShot(source:IBitmapDrawable, width:int, height:int, matrix:Matrix = null, smoothing:Boolean = false, cacheAsBitmap:Boolean = false):Bitmap
        {
            var b:Bitmap;
            var bd:BitmapData = new BitmapData(width, height, true, 0x000000);
            
            bd.draw(source, matrix, null, null, null, smoothing);
    
            b = new Bitmap(bd, PixelSnapping.ALWAYS, smoothing);
            b.cacheAsBitmap = cacheAsBitmap;
            return b;
        }
    }
}

Step 7: SpriteUtils Class

This class adds a Sprite to the display list:

package tooltip.utils 
{
    import flash.display.DisplayObjectContainer;
    import flash.display.Sprite;
    
    public final class SpriteUtils
    {
        
        public function SpriteUtils() 
        {
            throw new Error("SpriteUtils must not be instantiated");
        }
        /**
         * Attach a Sprite instance into a DisplayObjectContainer instance
         * @param	linkage The linkage of Sprite that will be attached
         * @param	parent The parent of Sprite that will be attached
         * @return
         */
        public static function attachSprite(linkage:String, parent:DisplayObjectContainer):Sprite
        {
            var s:Object = parent.loaderInfo.applicationDomain.getDefinition(linkage);
            return parent.addChild(new s()) as Sprite;
        }
    }
}

Step 8: TextFieldUtils Class

This class easily creates a TextField instance.

I highly recommend reading the TextField class description to understand all the properties used.

package tooltip.utils 
{
    import flash.display.DisplayObjectContainer;
    import flash.text.AntiAliasType;
    import flash.text.TextField;
    import flash.text.TextFieldAutoSize;
    import flash.text.TextFieldType;
    import flash.text.TextFormat;
    import flash.text.TextFormatAlign;
    
    public final class TextFieldUtils
    {
        
        public function TextFieldUtils() 
        {
            throw new Error("TextFieldUtils must not be instantiated");
        }
        /**
         * Create a textField instance
         * @param	parent Parent of textField
         * @param	text Text of textField (htmlText)
         * @param	font Font name to be used in textField
         * @param	embed
         * @param	size
         * @param	color
         * @param	width
         * @param	height
         * @param	autoSize
         * @param	multiline
         * @param	wordWrap
         * @param	cacheAsBitmap
         * @param	align
         * @param	leading
         * @param	letterSpacing
         * @param	type
         * @param	selectable
         * @param	sharpness
         * @param	border
         * @return
         */
        public static function textField(parent:DisplayObjectContainer, text:String, font:*, embed:Boolean = true, size:Number = NaN, color:Number = 0xFFFFFF, width:Number = NaN, height:Number = NaN, autoSize:String = "none", multiline:Boolean = false, wordWrap:Boolean = false, cacheAsBitmap:Boolean = false, align:String = "left", leading:Number = NaN, letterSpacing:Number = NaN, type:String = "dynamic", selectable:Boolean = false, sharpness:Number = NaN, border:Boolean = false):TextField
        {
            var t:TextField = new TextField();
            var tf:TextFormat = new TextFormat();
            
            parent.addChild(t);
            
            tf.align = TextFormatAlign.LEFT;
            tf.font = font;
            if(size) tf.size = size;
            tf.color = color;
            tf.leading = leading;
            if (letterSpacing) tf.letterSpacing = letterSpacing;
                    
            switch(align.toLowerCase())
            {
                case "left":
                    tf.align = TextFormatAlign.LEFT;
                break;
                case "center":
                    tf.align = TextFormatAlign.CENTER;
                break;
                case "right":
                    tf.align = TextFormatAlign.RIGHT;
                break;
                case "justify":
                    tf.align = TextFormatAlign.JUSTIFY;
                break;
                default:
                    tf.align = TextFormatAlign.LEFT;
                break;
            }
            
            t.antiAliasType = AntiAliasType.ADVANCED;
            t.type = (type == "dynamic") ? TextFieldType.DYNAMIC : TextFieldType.INPUT; 
            t.defaultTextFormat = tf;
            t.embedFonts = embed;
            t.cacheAsBitmap = cacheAsBitmap;
            t.mouseEnabled = selectable;
            t.selectable = selectable;
            t.multiline = multiline;
            t.border = border;
            t.wordWrap = wordWrap;
            if (sharpness) t.sharpness = sharpness;
            t.htmlText = text;
            t.width = (width) ? width : t.textWidth + 5;
            t.height = (height) ? height : t.textHeight + 5;
            
            switch(autoSize.toLowerCase())
            {
                case "left":
                    t.autoSize = TextFieldAutoSize.LEFT;
                break;
                case "center":
                    t.autoSize = TextFieldAutoSize.CENTER;
                break;
                case "right":
                    t.autoSize = TextFieldAutoSize.RIGHT;
                break;
            }
            
            return t;
        }
    }
}

Step 9: Resizable Background Concept

To create a resizable background, we will be using the concept of 9-slice, similar to the 9-slice scaling built into Flash. Sadly, the native 9-slice within Flash does not meet our needs.

The idea is to slice the source (Sprite) in 9 parts (see the image:)

When the width of background is altered, the parts 2, 5 and 8 are stretched horizontally, while the other parts stay the same.

Likewise, when the height of the background is altered, parts 4, 5 and 6 are stretched vertically, while the others don't change.


Step 10: The Matrix Class

The Matrix class creates a points map to be used in many forms. For more information, please read the documentation of the Matrix class from Adobe's site.

We will use the Matrix class to translate the source of background and so, draw the parts. In fact, the Matrix class does not affect the movement of translation in the object automatically, it only stores the values that the object would have if the motion was carried. This enables you to use an instance of Matrix in various ways without being locked into a DisplayObject.

For example, to draw the second image, we must take into account the position (x = 0 y = 0) and size (10 x 10px) of the image 1. It will apply the translation (without affecting the source) and only after that draw the second part. Example:

    var source:Sprite = new Sprite();
    
    var m:Matrix = new Matrix();
    m.translate(-10, 0);
    
    var bd:BitmapData = new BitmapData(source.width, source.height, true, 0x000000);
    bd.draw(source, m);

The identity() method can be used to reset the Matrix. If we do not reset, it will perform new calculations based on values previously stored.


Step 11: The CustomBg Class

To create the CustomBg class, create a new package called bg inside the display package. Create new class inside this folder called CustomBg. This class must extend Sprite.

Here is the class:

package tooltip.display.bg 
{
    import flash.display.Sprite;
    
    public final class CustomBg extends Sprite
    {
        public function CustomBg() 
        {
    
        }
    }
}

Step 12: The Constructor

Let's now deal with the Constructor parameters, class properties and imports. We need to import all the classes below to complete our class:

import flash.display.Bitmap;
import flash.display.DisplayObjectContainer;
import flash.display.Sprite;
import flash.geom.Matrix;
import flash.utils.getDefinitionByName;
import tooltip.utils.BitmapUtils;

Now, create two properties:

private var _parts:Vector.<Bitmap>;
private var _boundaries:int;

The constructor must contain the following parameters:

/**
 * Create a resizable Background
 * @param	linkage Linkage of a Sprite to be drawed
 * @param	parent Parent of Background
 * @param	width Initial width
 * @param	height Initial height
 * @param	boundaries Boundaries to slice the image
 * @param	smoothing Smooth the Background
 * @param	cacheAsBitmap Stores the Background in memory
 */
public function CustomBg(linkage:String, parent:DisplayObjectContainer, width:Number = NaN, height:Number = NaN, boundaries:int = 10, smoothing:Boolean = true, cacheAsBitmap:Boolean = false) 
{

}

In the constructor, we will slice the image.

First, we declare the variables. With the "Instance" and "source" vars, we attach our background (Sprite) to the CustomBg class. In the "parts" variable, we store the drawn parts of the background. Finally, with the "m" variable, we get the translation values and use them to draw the parts of our CustomBg.

Here is the code:

var Instance:Class = getDefinitionByName(linkage) as Class; 
var source:Sprite =  new Instance() as Sprite;
var parts:Vector.<Bitmap> = new Vector.<Bitmap> ();
var m:Matrix = source.transform.matrix;

parts[0] = BitmapUtils.snapShot(source, boundaries, boundaries, null, smoothing);

m.translate( -boundaries, 0);
parts[1] = BitmapUtils.snapShot(source, source.width - boundaries * 2, boundaries, m, smoothing);

m.identity();
m.translate( -source.width + boundaries, 0);
parts[2] = BitmapUtils.snapShot(source, boundaries, boundaries, m, smoothing);

m.identity();
m.translate( 0, -boundaries);
parts[3] = BitmapUtils.snapShot(source, boundaries, source.height - boundaries * 2, m, smoothing);

m.identity();
m.translate( -boundaries, -boundaries);
parts[4] = BitmapUtils.snapShot(source, source.width - boundaries * 2, source.height - boundaries * 2, m, smoothing);

m.identity();
m.translate( -source.width + boundaries, -boundaries);
parts[5] = BitmapUtils.snapShot(source, boundaries, source.height - boundaries * 2, m, smoothing);

m.identity();
m.translate(0, -source.height + boundaries);
parts[6] = BitmapUtils.snapShot(source, boundaries, boundaries, m, smoothing);	

m.identity();
m.translate(-boundaries, -source.height + boundaries);
parts[7] = BitmapUtils.snapShot(source, source.width - boundaries * 2, boundaries, m, smoothing);	

m.identity();
m.translate(-source.width + boundaries, -source.height + boundaries);
parts[8] = BitmapUtils.snapShot(source, boundaries, boundaries, m, smoothing);

this.addChild(parts[0]);
this.addChild(parts[1]);
this.addChild(parts[2]);
this.addChild(parts[3]);
this.addChild(parts[4]);
this.addChild(parts[5]);
this.addChild(parts[6]);
this.addChild(parts[7]);
this.addChild(parts[8]);

this._parts = parts;
this._boundaries = boundaries;

this.width = (isNaN(width)) ? source.width : width;
this.height = (isNaN(height)) ? source.height : height;

parent.addChild(this);

Step 13: Update the Parts' Positions

Every time the background is resized, the position of the parts must be updated. Let's create the arrange() method, to update the position of all the parts of background:

private function arrange():void
{
    var parts:Vector.<Bitmap> = this._parts;
    var boundaries:int = this._boundaries;
    
    parts[0].x = 0;
    parts[0].y = 0;
    
    parts[1].x = boundaries;
    parts[1].y = 0;
    
    parts[2].x = parts[0].width + parts[1].width;
    parts[2].y = 0;
    
    parts[3].x = 0;
    parts[3].y = boundaries;
    
    parts[4].x = boundaries;
    parts[4].y = boundaries;
    
    parts[5].x = parts[3].width + parts[4].width;
    parts[5].y = boundaries;
    
    parts[6].x = 0;
    parts[6].y = parts[0].height + parts[3].height;
    
    parts[7].x = boundaries;
    parts[7].y = parts[6].y;
    
    parts[8].x = parts[6].width + parts[7].width;
    parts[8].y = parts[6].y;
}

Step 14: Width and Height

Finally, we override the width and height methods of Sprite class, to resize and update the positions of the parts:

    public override function set width(v:Number):void
    {
        var parts:Vector.<Bitmap> = this._parts;
        var boundaries:int = this._boundaries;
        
        parts[1].width = v - boundaries * 2;
        parts[4].width = v - boundaries * 2;
        parts[7].width = v - boundaries * 2;
        
        this.arrange();
    }
    public override function set height(v:Number):void
    {
        var parts:Vector.<Bitmap> = this._parts;
        var boundaries:int = this._boundaries;
        
        parts[3].height = v - boundaries * 2;
        parts[4].height = v - boundaries * 2;
        parts[5].height = v - boundaries * 2;			
        
        this.arrange();
    }

Now, we have a resizable background that does not suffer distortion when it is resized. See the preview:


Step 15: The Singleton Design Pattern

Design patterns are programming methodologies that offer solutions to common problems in software design.

We will create a ToolTip class under the aspects of the Singleton design pattern, which gives us a class that has just one global instance across the entire project. Think: you want to use the ToolTip in a menu with several buttons. It would be unnecessary and impractical to create an instance of the ToolTip class for each menu button, since we can only display one ToolTip at a time. The best approach in this case would create a global instance for the entire project and use methods show() and hide() to control the display.

The Singleton design pattern will prevent calling the class constructor; it creates an instance of the class within itself, and always returns via a specific method. Please note our Singleton implementation:

package tooltip.display 
{
    import flash.display.Sprite;
    
    public class ToolTip extends Sprite
    {
    
        private static var _instance:ToolTip;
        
        public static function getInstance():ToolTip
        {
            if (!ToolTip._instance) ToolTip._instance = new ToolTip(new Singleton());
            return ToolTip._instance;
        }
        
        public function ToolTip(s:Singleton) 
        {
        
        }
    }
}
internal class Singleton{}

In the example above, we can see the declaration of a static instance. It will always be returned by the getInstance() method.

In the constructor, we require a parameter that can only be declared within this class, since this parameter is of type Singleton and this data type only exists inside the class. Thus, if we try to instantiate the class through the constructor, an error is generated.

The getInstance() method checks whether the variable was declared; if it has not been declared, the method getInstance() declares the instance and then returns it. If the instance has already been declared, the getInstance() just returns the instance.

var toolTip:ToolTip = new ToolTip();//Error
var toolTip:ToolTip = ToolTip.getInstance();//Ok

Step 16: TweenMax Class

Jack Doyle's TweenMax is a tween engine which is often mentioned on Activetuts+. It allows you make tween animation easily.

The TweenMax library and its documentation can be found on GreenSock.com.

In our case, we will use the TweenMax class to add shadow and also to display and hide our ToolTip. Here's a brief example of the syntax of the class TweenMax:

TweenMax.to(displayObjectInstance, duration, { property:value );

Now look at a simple use of the class TweenMax:

See the example code used to achieve this:

import com.greensock.TweenMax;
bt.label = "ROLL OVER TO ADD SHADOW";
bt.addEventListener(MouseEvent.MOUSE_OVER, onOver);
bt.addEventListener(MouseEvent.MOUSE_OUT, onOut);

function onOver(e:MouseEvent):void
{
    bt.label = "ROLL OUT TO REMOVE SHADOW";
    TweenMax.to(bt, 0.5, { dropShadowFilter: { color:0x000000, alpha:0.7, blurX:4, blurY:4, angle:45, distance:7 }} );
}
function onOut(e:MouseEvent):void
{
    bt.label = "ROLL OVER TO ADD SHADOW";
    TweenMax.to(bt, 0.5, { dropShadowFilter: { color:0x000000, alpha:0, blurX:0, blurY:0, angle:0, distance:0 }} );
}

Step 17: Adding Instances to Stage

We will add the objects on stage with the aid of our previously created classes and two methods. We must add a listener for the event Event.ADDED_TO_STAGE to avoid null references to the stage.

Update the constructor and add the two methods below:

public function ToolTip(s:Singleton) 
{
    this.addEventListener(Event.ADDED_TO_STAGE, this.onAddedToStage);
}

private function onAddedToStage(e:Event):void 
{
    this.removeEventListener(Event.ADDED_TO_STAGE, this.onAddedToStage);
    
    this.draw();
}
private function draw():void
{
    this.alpha = 0;
    this._bg = new CustomBg("ToolTipBg", this);
    this._tail = SpriteUtils.attachSprite("ToolTipTail", this);
    this._tipField = TextFieldUtils.textField(this, "", "Arial", false, 13, 0x000000);
    
    TweenMax.to(this, 0, { dropShadowFilter: { color:0x000000, alpha:0.7, blurX:4, blurY:4, angle:45, distance:7 }} );
    
    this.removeChild(this._bg);
    this.removeChild(this._tail);
    this.removeChild(this._tipField);
}

Step 18: Event.ENTER_FRAME listener

Our ToolTip will always appear near the mouse cursor.

In order to know which aspect the ToolTip should appear, I divided the stage into a grid of nine squares (using calculations; I have not used DisplayObjects). Only six squares would be enough, but I created nine squares so that you can change the behavior of square 3, 4 and 5. In this case the count is zero-based.

The mouse cursor will always be in touch with one of these squares. Based on this, I know how I should draw the ToolTip. Roll the mouse in the squares below:

In the onFrame() method, I check which square your mouse cursor is in and then make a call to arrange(style:int), passing as parameter the number of the imaginary square, so that it draws the ToolTip as I wish it to. I used one-line statements because they are faster.

private function onFrame(e:Event):void 
{
    var sW:Number = this.stage.stageWidth;
    var sH:Number = this.stage.stageHeight;
    
    var rW:Number = sW / 3;
    var rH:Number = sH / 3;
    
    var mX:Number = this.stage.mouseX;
    var mY:Number = this.stage.mouseY;
    
    if (mX < rW && mY < rH) this.arrange(0);
    else if (mX > rW && mX < rW * 2 && mY < rH) this.arrange(1);
    else if (mX > rW * 2 && mY < rH) this.arrange(2);
    else if (mX < rW && mY > rH && mY < rH * 2) this.arrange(3);
    else if (mX > rW && mX < rW * 2 && mY > rH && mY < rH * 2) this.arrange(4);
    else if (mX > rW * 2 && mY > rH && mY < rH * 2) this.arrange(5);
    else if (mX < rW && mY > rH * 2) this.arrange(6);
    else if (mX > rW && mX < rW * 2 && mY > rH * 2) this.arrange(7);
    else this.arrange(8);
}

Step 19: Arrange Function

The arrange() method updates all elements of the ToolTip based on the value received in the parameter.

private function arrange(style:int):void
{
    var b:CustomBg = this._bg;
    var t:Sprite = this._tail;
    var tF:TextField = this._tipField;
    
    t.scaleY = 1;
    t.x = 0;
    t.y = 0;
    
    tF.width = tF.textWidth + 5;
    tF.height = tF.textHeight + 5;
    tF.x = 0;
    tF.y = 0;
    
    b.width = tF.width + 10;
    b.height = tF.height + 10;
    
    b.x = 0;
    b.y = 0;
    
    var mX:Number = this.stage.mouseX;
    var mY:Number = this.stage.mouseY;
    
    if (style == 0)
    {
        t.scaleY = -1;
        t.x = mX;
        t.y = mY + 40;
    
        b.x = mX - 10;
        b.y = mY + t.height + b.height - 5;
    }		
    else if (style == 1)
    {
        t.scaleY = -1;
        t.x = mX;
        t.y = mY + 40;
    
        b.x = mX - b.width * 0.5 + t.width * 0.5;
        b.y = mY + t.height + b.height - 5;
    }
    else if(style == 2)
    {
        t.scaleY = -1;
        t.x = mX;
        t.y = mY + 40;
    
        b.x = mX - b.width + t.width + 10;
        b.y = mY + t.height + b.height - 5;
    }	
    else if(style == 3 || style == 6)
    {
        t.x = mX;
        t.y = mY - t.height;
        
        b.x = t.x - 10;
        b.y = t.y - b.height + 2;
    }		
    else if(style == 4 || style == 7)
    {
        t.x = mX;
        t.y = mY - t.height;
        
        b.x = t.x - b.width * 0.5 + t.width * 0.5;
        b.y = t.y - b.height + 2;					
    }			
    else if(style == 5 || style == 8)
    {
        t.x = mX;
        t.y = mY - t.height;
        
        b.x = t.x - b.width + t.width + 10;
        b.y = t.y - b.height + 2;	
    }
                    
    tF.x = b.x + 5;
    tF.y = b.y + 5;
}

Step 20: Show Function

The method below does not need much explanation; it's self explanatory.

public function show(message:String):void
{
    this._tipField.htmlText = message;
    this.parent.setChildIndex(this, this.parent.numChildren - 1);
    
    this.addChild(this._bg);
    this.addChild(this._tail);
    this.addChild(this._tipField);
    
    TweenMax.to(this, 0.25, { alpha:1 } );
    
    this.addEventListener(Event.ENTER_FRAME, this.onFrame);
}

Step 21: Hide Function

I created the onCompleteHide() method which will be executed after the end of instruction TweenMax (this is done through the following statement: onComplete: this.onCompleteHide). It will remove all elements from the stage.

public function hide():void
{
	this.removeEventListener(Event.ENTER_FRAME, this.onFrame);
	TweenMax.to(this, 0.25, { alpha:0, onComplete:this.onCompleteHide } );
}

private function onCompleteHide():void
{
	this.removeChild(this._bg);
	this.removeChild(this._tail);
	this.removeChild(this._tipField);
}

Step 22: Instantiation and Use

To use our ToolTip, I suggest you add it to the DisplayObject that is at the highest level (eg DocumentClass). Having done this, simply call the methods show() and hide() when needed. Here are two simple examples of using the ToolTip:

var t:ToolTip = ToolTip.getInstance();
this.addChild(t);
t.show("Some tip");

Alternatively:

this.addChild(ToolTip.getInstance());
ToolTip.getInstance().show("Some tip");

Conclusion

I Hope you liked this; I created this tool using best object-oriented programming practices with the objective of processing speed, reusing classes and low system consumption. See you in the next tutorial! Thanks!

Advertisement