Cyril Mottier

“It’s the little details that are vital. Little things make big things happen.” – John Wooden

Tutorial Android #5 : Réaliser Une Vue Personnalisée

Le framework Android fournit, de base, un ensemble de composants graphiques appelés widgets. Ces widgets, à l'aide de l'éditeur de layout intégré à Eclipse, permettent de créer des interfaces graphiques facilement et sans trop perdre de temps. On retrouve dans la bibliothèque d'Android (android.widget) des composants simples tels que Button, TextView ou ImageView mais également d'autres widgets considérés comme plus sophistiqués : Gallery, SlidingDrawer, DatePicker, etc.

Le développement sur Android peut parfois nécessiter l'utilisation de composants non disponibles, de base, dans android.widget. Imaginons par exemple que vous souhaitiez définir un widget de type barre de progression circulaire. Le composant ProgressBar ne permet pas d'afficher une progression de façon circulaire. Il n'existe aucun moyen d'afficher ce genre de composants et il est donc nécessaire soit d'en trouver une implémentation libre sur le web soit de le créer vous-même. L'objectif de ce tutorial est de s'initier à la création d'une View personnalisée (custom view en anglais).

Note : La définition d'une vue personnalisée aborde un ensemble de points techniques. Expliquer de façon exhaustive l'ensemble de ces points nécessite plusieurs paragraphes de description. Pour éviter de surcharger et donc de rendre illisible cette partie, j'ai préféré séparer la création de la vue en 2 parties. Cette première partie se concentrera sur la création de la vue (par le code uniquement), la gestion des évènements tactiles et du “dessin” de la vue. La seconde partie s'attachera à optimiser et surtout rendre générique (et donc réutilisable) la vue. On pourra par exemple instancier la vue via XML. Au vu de cette séparation, il est donc important de conserver à l'esprit que cette première partie n'est pas “finie”. Ne prenez pas cette partie pour modèle exact mais attendez plutôt la version finale de notre vue.

L'objectif de cette partie sera de créer une vue nommée TrashView. Cette dernière affichera simplement une poubelle et un fichier. Le fichier pourra être “draggué” dans la poubelle qui passera alors de l'état vide à l'état plein. Une première capture d'écran est donnée ci-dessous. Il est également possible de télécharger les sources de l'application grâce à ce zip

Comme mentionné ci-dessus, nous souhaitons créer une vue personnalisée instanciable par le code uniquement, gérant le dessin et les évènements tactiles. La gestion de ces possibilités se fait en créant une classe héritant de android.view.View puis en redéfinissant void onDraw(Canvas canvas) (gestion du dessin de la vue) et boolean onTouchEvent(MotionEvent event) (gestion des évènements tactiles). Le squelette ci-dessous montre la forme globale de notre vue personnalisée et inclut le constructeur par le code TrashView(Context context) :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class TrashView extends View {

    public TrashView(Context context) {
        super(context);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        return super.onTouchEvent(event);
    }
}

Notre vue nécessite, bien évidemment, plusieurs variables définies en private car elles ne reflètent aucunement l'état de la vue et permette de conserver l'encapsulation des données. Notez que les variables LOG_ENABLED et LOG_TAG sont déclarées dans une optique de déboguage optimisé et sont utilisées dans la méthode static void log(String log). Pour mieux comprendre cette technique de déboguage, je vous conseille de lire cet article rédigé par mes soins et détaillant l'astuce utilisée.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
 * Variable pour le débugguage
 */
private static final boolean LOG_ENABLED = true;
private static final String LOG_TAG = "TrashView";
private final int ICON_SIZE = 100;

/**
 * Seuil au delà duquel le fichier est considéré comme jeté à la poubelle.
 */
private static final float SURFACE_THRESHOLD = 0.6f;

/**
 * Bitmaps contenant les différentes images utilisées dans la vue
 */
private Bitmap mEmptyTrash;
private Bitmap mFullTrash;
private Bitmap mTextFile;

/**
 * Contient l'état de la poubelle : pleine ou vide
 */
private boolean mIsTrashFull;

/**
 * Taille des icones
 */
private int mIconsSize;

/**
 * Coordonnées de départ du fichier texte
 */
private float mFileStartX;
private float mFileStartY;

/**
 * Coordonnées précédente du fichier, actuelle du fichier et de la poubelle.
 */
private float mPreviousFileX;
private float mPreviousFileY;
private float mFileX;
private float mFileY;
private float mTrashX;
private float mTrashY;

/**
 * Différence sur X et Y entre le premier point appuyé et la position de l'image
 * représentant le fichier.
 */
private float mDeltaX;
private float mDeltaY;

private static void log(String log) {
    if (LOG_ENABLED) {
        Log.d(LOG_TAG, log);
    }
}

Les variables sont maintenant prêtes et nous pouvons donc créer le constructeur de notre vue. La vue est instanciable, pour l'instant, via le code uniquement. Nous implémentons donc le constructeur de la forme public View(Context context) créant les différentes Bitmap utilisées dans la vue :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public TrashView(Context context) {
    super(context);
    mIconsSize = ICON_SIZE;
    mEmptyTrash = prepareBitmap(getResources().getDrawable(R.drawable.empty_trash), mIconsSize,
            mIconsSize);
    mFullTrash = prepareBitmap(getResources().getDrawable(R.drawable.full_trash), mIconsSize,
            mIconsSize);
    mTextFile = prepareBitmap(getResources().getDrawable(R.drawable.txt_file), mIconsSize,
            mIconsSize);
    resetTrashView();
}

public void resetTrashView() {
    mIsTrashFull = false;
    mFileStartX = 10;
    mFileStartY = 20;
    mFileX = mFileStartX;
    mFileY = mFileStartY;
    mTrashX = 200;
    mTrashY = 20;
}

private static Bitmap prepareBitmap(Drawable drawable, int width, int height) {
    Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
    drawable.setBounds(0, 0, width, height);
    Canvas canvas = new Canvas(bitmap);
    drawable.draw(canvas);
    return bitmap;
}

Ce code n'a rien de compliqué mais la méthode static Bitmap prepareBitmap(Drawable drawable, int width, int height) peut paraitre obscure pour les débutants. Android permet de récupérer facilement des ressources graphiques présentes dans le dossier res/drawable via le fichier R.java et sous forme d'un objet de type Drawable. Ce type, extrêmement utile puisqu'il généralise totalement la notion d'objets “dessinables” dispose en contre partie d'inconvénients : son utilisation dans un Canvas (surface sur laquelle nous dessinons notre vue) est un peu particulière et son affichage est assez lent (utilisation d'une interface et redimensionnement à la volée de l'image). Pour optimiser le dessin, il convient de déclarer des objets de type Bitmap qui sont plus “naturels” à dessiner sur un Canvas et sont également plus rapide à afficher puisque leur taille est définie de façon statique à l'initialisation. Cette méthode consiste donc simplement à mettre le contenu de drawable à la taille (width, height) dans un objet Bitmap qui sera retourné comme résultat de la méthode statique.

Modifions maintenant notre vue afin qu'elle puisse afficher les images. On corrige donc la méthode onDraw(Canvas canvas) qui est appelée par le système à la suite d'un invalidate() (méthode informant le système que la vue est “sale” et nécessite d'être mise à jour). Le contenu de cette méthode est très succinct et se passe, à mon avis, de commentaires puisqu'elle ne consiste qu'à afficher les différentes images représentée dans la vue suivant l'état actuel de la poubelle :

1
2
3
4
5
6
7
8
9
@Override
protected void onDraw(Canvas canvas) {
    if (mIsTrashFull) {
        canvas.drawBitmap(mFullTrash, mTrashX, mTrashY, null);
    } else {
        canvas.drawBitmap(mEmptyTrash, mTrashX, mTrashY, null);
        canvas.drawBitmap(mTextFile, mFileX, mFileY, null);
    }
}

L'objectif de cette partie est maintenant de gérer les évènements tactiles afin de faire en sorte que l'utilisateur puisse glisser le fichier sur la corbeille. Cette partie est probablement la partie la plus technique de ce tutorial. C'est probablement la raison pour laquelle il y a de nombreux points à détailler. J'ai donc préféré détailler les points techniques en commentant directement le code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
@Override
public boolean onTouchEvent(MotionEvent event) {

    /*
     * Récupère l'action effectuée et sa position
     */
    final int action = event.getAction();
    final float x = event.getX();
    final float y = event.getY();

    switch (action) {
        case MotionEvent.ACTION_DOWN:
            /*
             * L'utilisateur vient d'appuyer sur la vue. Si l'appui
             * s'effectue sur le fichier et que la poubelles est vide on
             * peut démarrer le "dragging" en initialisant les variables
             * utiles par la suite
             */
            if (!mIsTrashFull && x >= mFileX && x <= mFileX + mIconsSize && y >= mFileY
                    && y <= mFileY + mIconsSize) {
                log("Start moving text file");
                mDeltaX = x - mFileX;
                mDeltaY = y - mFileY;
                mPreviousFileX = mFileX;
                mPreviousFileY = mFileY;
                /*
                 * On retourne true afin que l'ensemble des évènements
                 * suivants nous parviennent
                 */
                return true;
            }
            break;

        case MotionEvent.ACTION_MOVE:
            /*
             * L'utilisateur est en train de bouger son doigt sur l'écran.
             * On effectue simplement le déplacement de l'image du fichier
             * texte à la position du doigt
             */
            mFileX = x - mDeltaX;
            mFileY = y - mDeltaY;
            /*
             * On invalide la vue afin de dessiner l'image du fichier au
             * nouvel endroit. La méthode appellée est expliquée plus bas.
             */
            optimizedInvalidate();
            return true;

        case MotionEvent.ACTION_UP:
            /*
             * L'utilisateur vient de relacher la pression sur l'écran.
             */
            log("Stop moving text file");
            if (intersect(mFileX, mIconsSize, mTrashX, mIconsSize)
                    * intersect(mFileY, mIconsSize, mTrashY, mIconsSize) > mIconsSize
                    * mIconsSize * SURFACE_THRESHOLD) {
                /*
                 * Si la surface recouverte de la corbeille par le fichier
                 * est suffisante, on change l'état de la corbeille
                 */
                mIsTrashFull = true;
                log("File trashed");
            } else {
                /*
                 * Sinon on repositionne le fichier à son emplacement
                 * d'origine
                 */
                mFileX = mFileStartX;
                mFileY = mFileStartY;
            }
            /*
             * On réactualise la vue pour afficher les changements.
             */
            invalidate();
            return true;
    }
    return super.onTouchEvent(event);
}

Dans le code donné ci-dessus, une méthode optimizedInvalidate() est appelée. Cette méthode permet d'optimiser le rafraichissement de la vue. Il aurait, en effet, été possible d'utiliser invalidate() en lieu et place d'optimizedInvalidate(). Malheureusement, invalidate() oblige le système à redessiner l'intégralité de la vue. Travaillant sur un terminal mobile, nous devons respecter des règles d'optimisations strictes. Une des principales règles, bien qu'évidente, mentionne qu'il ne faut rien faire d'inutile. Il serait donc stupide de redessiner l'intégralité de la vue alors qu'une faible partie seulement a besoin d'être actualisée. La méthode optimizedInvalidate() s'occupe d'optimiser la surface invalidée :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private void optimizedInvalidate() {
    /*
     * On met en tampon les variables de classes souvent utilisées
     * pour accélérer l'exécution
     */
    final int iconSize = mIconsSize;
    final float fileX = mFileX;
    final float fileY = mFileY;
    /*
     * On trouve les coordonnées du point supérieur gauche de la zone à
     * invalider
     */
    final int l = (int)Math.min(fileX, mPreviousFileX);
    final int t = (int)Math.min(fileY, mPreviousFileY);
    /*
     * On trouve les coordonnées du point inférieur droit de la zone à
     * invalider
     */
    final int b = (int)Math.max(fileX + iconSize, mPreviousFileX + iconSize);
    final int r = (int)Math.max(fileY + iconSize, mPreviousFileY + iconSize);
    mPreviousFileX = fileX;
    mPreviousFileY = fileY;
    /*
     * Invalide la vue en utilisant invalidate(int, int, int, int)
     */
    invalidate(l, t, b, r);
}

Voilà, la première ébauche de notre vue personnalisée est maintenant terminée. Vous êtes maintenant prêt(e)s à enrichir formidablement le framework Android et vos interfaces graphiques. Tous à vos SDK !