Création de vues personnalisées

Ce contenu est obsolète. Il peut contenir des informations intéressantes mais soyez prudent avec celles-ci.

Vous savez désormais l'essentiel pour développer de belles interfaces graphiques fonctionnelles, et en théorie vous devriez être capables de faire tout ce que vous désirez. Cependant, il vous manque encore l'outil ultime qui vous permettra de donner vie à tous vos fantasmes les plus extravagants : être capables de produire vos propres vues et ainsi avoir le contrôle total sur leur aspect, leur taille, leurs réactions et leur fonction.

On différencie typiquement trois types de vues personnalisées :

  • Si vous souhaitez faire une vue qui ressemble à une vue standard que vous connaissez déjà, vous pourrez partir de cette vue et modifier son fonctionnement pour le faire coïncider avec vos besoins.
  • Autrement, vous pourriez exploiter des vues qui existent déjà et les réunir de façon à produire une nouvelle vue qui exploite le potentiel de ses vues constitutives.
  • Ou bien encore, si vous souhaitez forger une vue qui n'existe pas du tout, il est toujours possible de la fabriquer en partant de zéro. C'est la solution la plus radicale, la plus exigeante, mais aussi la plus puissante.

Règles avancées concernant les vues

Cette section est très théorique, je vous conseille de la lire une fois, de la comprendre, puis de continuer dans le cours et d'y revenir au besoin. Et vous en aurez sûrement besoin, d'ailleurs.

Si vous deviez instancier un objet de type View et l'afficher dans une interface graphique, vous vous retrouveriez devant un carré blanc qui mesure 100 pixels de côté. Pas très glamour, j'en conviens. C'est pourquoi, quand on crée une vue, on doit jouer sur au moins deux tableaux : les dimensions de la vue, et son dessin.

Dimensions et placement d'une vue

Les dimensions d'une vue sont deux entiers qui représentent la taille que prend la vue sur les deux axes de l'écran : la largeur et la hauteur. Toute vue ne possède pas qu'une paire de dimensions, mais bien deux : celles que vous connaissez et qui vous sembleront logiques sont les dimensions réelles occupées par la vue sur le terrain. Cependant, avant que les coordonnées réelles soient déterminées, une vue passe par une phase de calcul où elle s'efforce de déterminer les dimensions qu'elle souhaiterait occuper, sans garantie qu'il s'agira de ses dimensions finales.

Par exemple, si vous dites que vous disposez d'une vue qui occupe toute seule son layout parent et que vous lui donnez l'instruction FILL_PARENT, alors les dimensions réelles seront identiques aux dimensions demandées puisque la vue peut occuper tout le parent. En revanche, s'il y a plusieurs vues qui utilisent FILL_PARENT pour un même layout, alors les dimensions réelles seront différentes de celles demandées, puisque le layout fera en sorte de répartir les dimensions entre chacun de ses enfants.

Un véritable arbre généalogique

Vous le savez déjà, on peut construire une interface graphique dans le code ou en XML. Je vais vous demander de réfléchir en XML ici, pour simplifier le raisonnement. Un fichier XML contient toujours un premier élément unique qui n'a pas de frère, cet élément s'appelle la racine, et dans le contexte du développement d'interfaces graphiques pour Android cette racine sera très souvent un layout. Dans le code suivant, la racine est un RelativeLayout.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="fill_parent"
  android:layout_height="fill_parent" >

  <Button
    android:id="@+id/passerelle"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_centerHorizontal="true"
    android:layout_centerVertical="true" />

</RelativeLayout>

Ce layout peut avoir des enfants, qui seront des widgets ou d'autres layouts. Dans l'éventualité où un enfant serait un layout, alors il peut aussi avoir des enfants à son tour. On peut donc affirmer que, comme pour une famille, il est possible de construire un véritable arbre généalogique qui commence par la racine et s'étend sur plusieurs générations, comme à la figure suivante.

Dans cet exemple, on peut voir que toutes les vues sont des enfants ou petits-enfants du « LinearLayout » et que les autres layouts peuvent aussi avoir des enfants, tandis que les widgets n'ont pas d'enfant

Ce que vous ne savez pas, c'est que la racine de notre application n'est pas la racine de la hiérarchie des vues et qu'elle sera forcément l'enfant d'une autre vue qu'a créée Android dans notre dos et à laquelle nous n'avons pas accès. Ainsi, chaque vue que nous utiliserons sera directement l'enfant d'un layout.

Le placement

Le placement — qui se dit aussi layout en anglais (à ne pas confondre avec les layouts qui sont des vues qui contiennent des vues, et le layout correspondant à la mise en page de l'interface graphique) — est l'opération qui consiste à placer les vues dans l'interface graphique. Ce processus s'effectue en deux étapes qui s’exécuteront dans l'ordre chronologique. Tout d'abord et en partant de la racine, chaque layout va donner à ses enfants des instructions quant à la taille qu'ils devraient prendre. Cette étape se fait dans la méthode void measure(int widthMeasureSpec, int heightMeasureSpec), ne vous préoccupez pas trop de cette méthode, on ne l'implémentera pas. Puis vient la seconde étape, qui débute elle aussi par la racine et où chaque layout transmettra à ses enfants leurs dimensions finales en fonction des mesures déterminées dans l'étape précédente. Cette manœuvre se déroule durant l'exécution de void layout(int bord_gauche, int plafond, int bord_droit, int plancher), mais on ne l'implémentera pas non plus.

Si à un quelconque moment vous rencontrez une vue dont les limites ne lui correspondent plus, vous pouvez essayer de la faire se redimensionner en lançant sa méthode void requestLayout () — ainsi le calcul se fera sur la vue et sur toutes les autres vues qui pourraient être influencées par les nouvelles dimensions de la vue.

Récupérer les dimensions

De manière à récupérer les instructions de dimensions, vous pouvez utiliser int getMeasuredWidth () pour la largeur et int getMeasuredHeight () pour la hauteur, cependant uniquement après qu'un appel à measure(int, int) a été effectué, sinon ces valeurs n'ont pas encore été attribuées. Enfin, vous pouvez les attribuer vous-mêmes avec la méthode void setMeasuredDimension (int measuredWidth, int measuredHeight).

Ces instructions doivent vous sembler encore mystérieuses puisque vous ne devez pas du tout savoir quoi insérer. En fait, ces entiers sont… un code. :waw: En effet, vous pouvez à partir de ce code déterminer un mode de façonnage et une taille.

  • La taille se récupère avec la fonction statique int MeasureSpec.getSize (int measureSpec).
  • Le mode se récupère avec la fonction statique int MeasureSpec.getMode (int measureSpec). S'il vaut MeasureSpec.UNSPECIFIED, alors le parent n'a pas donné d'instruction particulière sur la taille à prendre. S'il vaut MeasureSpec.EXACTLY, alors la taille donnée est la taille exacte à adopter. S'il vaut MeasureSpec.AT_MOST, alors la taille donnée est la taille maximale que peut avoir la vue.

Par exemple pour obtenir le code qui permet d'avoir un cube qui fait 10 pixels au plus, on peut faire :

1
2
int taille = MeasureSpec.makeMeasureSpec(10, MeasureSpec.AT_MOST);
setMeasuredDimension(taille, taille);

De plus, il est possible de connaître la largeur finale d'une vue avec int getWidth () et sa hauteur finale avec int getHeight ().

Enfin, on peut récupérer la position d'une vue par rapport à son parent à l'aide des méthodes int getTop () (position du haut de cette vue par rapport à son parent), int getBottom () (en bas), int getLeft () (à gauche) et int getRight () (à droite). C'est pourquoi vous pouvez demander très simplement à n'importe quelle vue ses dimensions en faisant :

1
2
vue.getWidth();
vue.getLeft();

Le dessin

C'est seulement une fois le placement effectué qu'on peut dessiner notre vue (vous imaginez bien qu'avant Android ne saura pas où dessiner :p ). Le dessin s'effectue dans la méthode void draw (Canvas canvas), qui ne sera pas non plus à implémenter. Le Canvas passé en paramètre est la surface sur laquelle le dessin sera tracé.

Obsolescence régionale

Tout d'abord, une vue ne décide pas d'elle-même quand elle doit se dessiner, elle en reçoit l'ordre, soit par le Context, soit par le programmeur. Par exemple, le contexte indique à la racine qu'elle doit se dessiner au lancement de l'application. Dès qu'une vue reçoit cet ordre, sa première tâche sera de déterminer ce qui doit être dessiné parmi les éléments qui composent la vue.

Si la vue comporte un nouveau composant ou qu'un de ses composants vient d'être modifié, alors la vue déclare que ces éléments sont dans une zone qu'il faut redessiner, puisque leur état actuel ne correspond plus à l'ancien dessin de la vue. La surface à redessiner consiste en un rectangle, le plus petit possible, qui inclut tous les éléments à redessiner, mais pas plus. Cette surface s'appelle la dirty region. L'action de délimiter la dirty region s'appelle l'invalidation (c'est pourquoi on appelle aussi la dirty region la région d'invalidation) et on peut la provoquer avec les méthodes void invalidate (Rect dirty) (où dirty est le rectangle qui délimite la dirty region) ou void invalidate (int gauche, int haut, int droite, int bas) avec gauche la limite gauche du rectangle, haut le plafond du rectangle, etc., les coordonnées étant exprimées par rapport à la vue. Si vous souhaitez que toute la vue se redessine, utilisez la méthode void invalidate (), qui est juste un alias utile de void invalidate (0, 0, largeur_de_la_vue, hauteur_de_la_vue). Enfin, évitez de trop le faire puisque dessiner est un processus exigeant. :-°

Par exemple, quand on passe d'une TextView vide à une TextView avec du texte, la seule chose qui change est le caractère « i » qui apparaît, la région la plus petite est donc un rectangle qui entoure tout le « i », comme le montre la figure suivante.

La seule chose qui change est le caractère « i » qui apparaît

En revanche, quand on a un Button normal et qu'on appuie dessus, le texte ne change pas, mais toute la couleur du fond change, comme à la figure suivante. Par conséquent la région la plus petite qui contient tous les éléments nouveaux ou qui auraient changé englobe tout l'arrière-plan et subséquemment englobe toute la vue.

La couleur du fond change

Ainsi, en utilisant un rectangle, on peut très bien demander à une vue de se redessiner dans son intégralité de cette manière :

1
vue.invalidate(new Rect(vue.getLeft(), vue.getTop(), vue.getRight(), vue.getDown());

La propagation

Quand on demande à une vue de se dessiner, elle lance le processus puis transmet la requête à ses enfants si elle en a. Cependant, elle ne le transmet pas à tous ses enfants, seulement à ceux qui se trouvent dans sa région d'invalidation. Ainsi, le parent sera dessiné en premier, puis les enfants le seront dans l'ordre dans lequel ils sont placés dans l'arbre hiérarchique, mais uniquement s'ils doivent être redessinés.

Pour demander à une vue qu'elle se redessine, utilisez une des méthodes invalidate vues précédemment, pour qu'elle détermine sa région d'invalidité, se redessine puis propage l'instruction.

Méthode 1 : à partir d'une vue préexistante

Le principe ici sera de dériver d'un widget ou d'un layout qui est fourni par le SDK d'Android. Nous l'avons déjà fait par le passé, mais nous n'avions manipulé que le comportement logique de la vue, pas le comportement visuel.

De manière générale, quand on développe une vue, on fait en sorte d'implémenter les trois constructeurs standards. Petit rappel à ce sujet :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Il y a un constructeur qui est utilisé pour instancier la vue depuis le code :
View(Context context);

// Un pour l'inflation depuis le XML :
View(Context context, AttributeSet attrs);
// Le paramètre attrs contenant les attributs définis en XML

// Et un dernier pour l'inflation en XML et dont un style est associé à la vue :
View(Context context, AttributeSet attrs, int defStyle);
// Le paramètre defStyle contenant une référence à une ressource, ou 0 si aucun style n'a été défini

De plus, on développe aussi les méthodes qui commencent par on…. Ces méthodes sont des fonctions de callback et elles sont appelées dès qu'une méthode au nom identique (mais sans on…) est utilisée. Je vous ai par exemple parlé de void measure (int widthMeasureSpec, int heightMeasureSpec), à chacune de ses exécutions, la fonction de callback void onMeasure (int widthMeasureSpec, int heightMeasureSpec) est lancée. Vous voyez, c'est simple comme bonjour.

Vous trouverez à cette adresse une liste intégrale des méthodes que vous pouvez implémenter.

Par exemple, j'ai créé un bouton qui permet de visualiser plusieurs couleurs. Tout d'abord, j'ai déclaré une ressource qui contient une liste de couleurs :

1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="utf-8"?>
<resources>
  <array name="colors">
    <item>#FF0000</item>
    <item>#0FF000</item>
    <item>#000FF0</item>
    <item>#FFFFFF</item>
  </array>
</resources>

Ce type de ressources s'appelle un TypedArray, c'est-à-dire un tableau qui peut contenir n'importe quelles autres ressources. Une fois ce tableau désérialisé, je peux récupérer les éléments qui le composent avec la méthode appropriée, dans notre cas, comme nous manipulons des couleurs, int getColor (int position, int defaultValue) (position étant la position de l'élément voulu et defaultValue la valeur renvoyée si l'élément n'est pas trouvé).

 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
import android.content.Context;
import android.content.res.Resources;
import android.content.res.TypedArray;

import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;

import android.util.AttributeSet;

import android.view.MotionEvent;

import android.widget.Button;

public class ColorButton extends Button {
  /** Liste des couleurs disponibles **/
  private TypedArray mCouleurs = null;
  /** Position dans la liste des couleurs **/
  private int position = 0;

  /**
   * Constructeur utilisé pour inflater avec un style
   */
  public ColorButton(Context context, AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);
    init();
  }

  /**
   * Constructeur utilisé pour inflater sans style
   */
  public ColorButton(Context context, AttributeSet attrs) {
    super(context, attrs);
    init();
  }

  /**
   * Constructeur utilisé pour construire dans le code
   */
  public ColorButton(Context context) {
    super(context);
    init();
  }

  private void init() {
    // Je récupère mes ressources
    Resources res = getResources();
    // Et dans ces ressources je récupère mon tableau de couleurs
    mCouleurs = res.obtainTypedArray(R.array.colors);

    setText("Changer de couleur");
  }

  /* … */

}

Je redéfinis void onLayout (boolean changed, int left, int top, int right, int bottom) pour qu'à chaque fois que la vue est redimensionnée je puisse changer la taille du carré qui affiche les couleurs de manière à ce qu'il soit toujours conforme au reste du bouton.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/** Rectangle qui délimite le dessin */
private Rect mRect = null;

@Override
protected void onLayout (boolean changed, int left, int top, int right, int bottom)
{
  //Si le layout a changé
  if(changed)
    //On redessine un nouveau carré en fonction des nouvelles dimensions
      mRect = new Rect(Math.round(0.5f * getWidth() - 50), 
                       Math.round(0.75f * getHeight() - 50), 
                       Math.round(0.5f * getWidth() + 50), 
                       Math.round(0.75f * getHeight() + 50));
  //Ne pas oublier
  super.onLayout(changed, left, top, right, bottom);
}

J'implémente boolean onTouchEvent (MotionEvent event) pour qu'à chaque fois que l'utilisateur appuie sur le bouton la couleur qu'affiche le carré change. Le problème est que cet évènement se lance à chaque toucher, et qu'un toucher ne correspond pas forcément à un clic, mais aussi à n'importe quelle fois où je bouge mon doigt sur le bouton, ne serait-ce que d'un pixel. Ainsi, la couleur change constamment si vous avez le malheur de bouger le doigt quand vous restez appuyé sur le bouton. C'est pourquoi j'ai rajouté une condition pour que le dessin ne réagisse que quand on appuie sur le bouton, pas quand on bouge ou qu'on lève le doigt. Pour cela, j'ai utilisé la méthode int getAction () de MotionEvent. Si la valeur retournée est MotionEvent.ACTION_DOWN, c'est que l'évènement qui a déclenché le lancement de la méthode est un clic.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
/** Outil pour peindre */
private Paint mPainter = new Paint(Paint.ANTI_ALIAS_FLAG);

@Override
public boolean onTouchEvent(MotionEvent event) {
  // Uniquement si on appuie sur le bouton
  if(event.getAction() == MotionEvent.ACTION_DOWN) {
    // On passe à la couleur suivante
    position ++;
    // Évite de dépasser la taille du tableau
    // (dès qu'on arrive à la longueur du tableau, on repasse à 0)
    position %= mCouleurs.length();

    // Change la couleur du pinceau
    mPainter.setColor(mCouleurs.getColor(position, -1));

    // Redessine la vue
    invalidate();
  }
  // Ne pas oublier
  return super.onTouchEvent(event);
}

Enfin, j'écris ma propre version de void onDraw(Canvas canvas) pour dessiner le carré dans sa couleur actuelle. L'objet Canvas correspond à la fois à la toile sur laquelle on peut dessiner et à l'outil qui permet de dessiner, alors qu'un objet Paint indique juste au Canvas comment il faut dessiner, mais pas ce qu'il faut dessiner.

1
2
3
4
5
6
7
@Override
protected void onDraw(Canvas canvas) {
  // Dessine le rectangle à l'endroit voulu avec la couleur voulue
  canvas.drawRect(mRect, mPainter);
  // Ne pas oublier
  super.onDraw(canvas);
}

Vous remarquerez qu'à la fin de chaque méthode de type on…, je fais appel à l'équivalent de la superclasse de cette méthode. C'est tout simplement parce que les superclasses effectuent des actions pour la classe Button qui doivent être faites sous peine d'un comportement incorrect du bouton.

Ce qui donne la figure suivante.

On a un petit carré en bas de notre bouton (écran de gauche) et dès qu'on appuie sur le bouton le carré change de couleur (écran de droite)

Méthode 2 : une vue composite

On peut très bien se contenter d'avoir une vue qui consiste en un assemblage de vues qui existent déjà. D'ailleurs vous connaissez déjà au moins deux vues composites ! Pensez à Spinner, c'est un Button avec une ListView, non ? Et AutoCompleteTextView, c'est un EditText associé à une ListView aussi !

Logiquement, cette vue sera un assemblage d'autres vues et par conséquent ne sera pas un widget — qui ne peut pas contenir d'autres vues — mais bien un layout, elle devra donc dériver de ViewGroup ou d'une sous-classe de ViewGroup.

Je vais vous montrer une vue qui permet d'écrire du texte en HTML et d'avoir le résultat en temps réel. J'ai appelé ce widget ToHtmlView. Je n'explique pas le code ligne par ligne puisque vous connaissez déjà tous ces concepts.

 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
79
80
81
82
83
84
85
86
import android.content.Context;
import android.text.Editable;
import android.text.Html;
import android.text.InputType;
import android.text.TextWatcher;
import android.util.AttributeSet;

import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.TextView;

public class ToHtmlView extends LinearLayout {
  /** Pour insérer du texte */
  private EditText mEdit = null;
  /** Pour écrire le résultat */
  private TextView mText = null;

  /**
   * Constructeur utilisé quand on construit la vue dans le code
   * @param context
   */
  public ToHtmlView(Context context) {
    super(context);
    init();
  }

  /**
   * Constructeur utilisé quand on inflate la vue depuis le XML
   * @param context
   * @param attrs
   */
  public ToHtmlView(Context context, AttributeSet attrs) {
    super(context, attrs);
    init();
  }

  public void init() {
    // Paramètres utilisés pour indiquer la taille voulue pour la vue
    int wrap = LayoutParams.WRAP_CONTENT;
    int fill = LayoutParams.FILL_PARENT;

    // On veut que notre layout soit de haut en bas
    setOrientation(LinearLayout.VERTICAL);
    // Et qu'il remplisse tout le parent.
    setLayoutParams(new LayoutParams(fill, fill));

    // On construit les widgets qui sont dans notre vue
    mEdit = new EditText(getContext());
    // Le texte sera de type web et peut être long
    mEdit.setInputType(InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE);
    // Il fera au maximum dix lignes
    mEdit.setMaxLines(10);
    // On interdit le scrolling horizontal pour des questions de confort
    mEdit.setHorizontallyScrolling(false);

    // Listener qui se déclenche dès que le texte dans l'EditText change
    mEdit.addTextChangedListener(new TextWatcher() {

      // À chaque fois que le texte est édité
      @Override
      public void onTextChanged(CharSequence s, int start, int before, int count) {
        // On change le texte en Spanned pour que les balises soient interprétées    
        mText.setText(Html.fromHtml(s.toString()));
      }

      // Après que le texte a été édité
      @Override
      public void beforeTextChanged(CharSequence s, int start, int count, int after) {

      }

      // Après que le texte a été édité
      @Override
      public void afterTextChanged(Editable s) {

      }
    });

    mText = new TextView(getContext());
    mText.setText("");

    // Puis on rajoute les deux widgets à notre vue
    addView(mEdit, new LinearLayout.LayoutParams(fill, wrap));
    addView(mText, new LinearLayout.LayoutParams(fill, wrap));
  }
}

Ce qui donne, une fois intégré, la figure suivante.

Le rendu du code

Mais j'aurais très bien pu passer par un fichier XML aussi ! Voici comment j'aurais pu faire :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="fill_parent"
  android:layout_height="wrap_content"
  android:orientation="vertical" >

  <EditText
    android:id="@+id/edit"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    android:inputType="textWebEditText|textMultiLine"
    android:maxLines="10"
    android:scrollHorizontally="false">
    <requestFocus />
  </EditText>

  <TextView
    android:id="@+id/text"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    android:text="" />

</LinearLayout>

L'avantage par rapport aux deux autres méthodes, c'est que cette technique est très facile à mettre en place (pas de méthodes onDraw ou de onMeasure à redéfinir) et puissante. En revanche, on a beaucoup moins de contrôle.

Méthode 3 : créer une vue en partant de zéro

Il vous faut penser à tout ici, puisque votre vue dérivera directement de View et que cette classe ne gère pas grand-chose. Ainsi, vous savez que par défaut une vue est un carré blanc de 100 pixels de côté, il faudra donc au moins redéfinir void onMeasure (int widthMeasureSpec, int heightMeasureSpec) et void onDraw (Canvas canvas). De plus, vous devez penser aux différents évènements (est-ce qu'il faut réagir au toucher, et si oui comment ? et à l'appui sur une touche ?), aux attributs de votre vue, aux constructeurs, etc.

Dans mon exemple, j'ai décidé de faire un échiquier.

La construction programmatique

Tout d'abord, j'implémente tous les constructeurs qui me permettront d'instancier des objets depuis le code. Pour cela, je redéfinis le constructeur standard et je développe un autre constructeur qui me permet de déterminer quelles sont les couleurs que je veux attribuer pour les deux types de case.

 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
/** Pour la première couleur */
private Paint mPaintOne = null;
/** Pour la seconde couleur */
private Paint mPaintTwo = null;

public ChessBoardView(Context context) {
  super(context);
  init(-1, -1);
}

public ChessBoardView(Context context, int one, int two) {
  super(context);
  init(one, two);
}

private void init(int one, int two) {
  mPaintTwo = new Paint(Paint.ANTI_ALIAS_FLAG);
  if(one == -1)
    mPaintTwo.setColor(Color.LTGRAY);
  else
    mPaintTwo.setColor(one);

  mPaintOne = new Paint(Paint.ANTI_ALIAS_FLAG);
  if(two == -1)
    mPaintOne.setColor(Color.WHITE);
  else
    mPaintOne.setColor(two);
}

La construction par inflation

J'exploite les deux constructeurs destinés à l'inflation pour pouvoir récupérer les attributs que j'ai pu passer en attributs. En effet, il m'est possible de définir mes propres attributs pour ma vue. Pour cela, il me faut créer des ressources de type attr dans un tableau d'attributs. Ce tableau est un nœud de type declare-styleable. J'attribue un nom à chaque élément qui leur servira d'identifiant. Enfin, je peux dire pour chaque attr quel type d'informations il contiendra.

1
2
3
4
5
6
7
<resources>
  <declare-styleable name="ChessBoardView">
    <!-- L'attribut d'identifiant "colorOne" est de type "color" -->
    <attr name="colorOne" format="color"/>
    <attr name="colorTwo" format="color"/>
  </declare-styleable>
</resources>

Pour utiliser ces attributs dans le layout, il faut tout d'abord déclarer utiliser un namespace, comme on le fait pour pouvoir utiliser les attributs qui appartiennent à Android : xmlns:android="http://schemas.android.com/apk/res/android".

Cette déclaration nous permet d'utiliser les attributs qui commencent par android: dans notre layout, elle nous permettra donc d'utiliser nos propres attributs de la même manière.

Pour cela, on va se contenter d'agir d'une manière similaire en remplaçant xmlns:android par le nom voulu de notre namespace et http://schemas.android.com/apk/res/android par notre package actuel. Dans mon cas, j'obtiens :

1
xmlns:sdzName="http://schemas.android.com/apk/res/sdz.chapitreDeux.chessBoard"

Ce qui me donne ce XML :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:sdzName="http://schemas.android.com/apk/res/sdz.chapitreDeux.chessBoard"

  android:layout_width="fill_parent"
  android:layout_height="fill_parent"
  android:orientation="vertical" >

  <sdz.chapitreDeux.chessBoard.ChessBoardView
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    sdzName:colorOne="#FF0000"
    sdzName:colorTwo="#00FF00" />
</LinearLayout>

Il me suffit maintenant de récupérer les attributs comme nous l'avions fait précédemment :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// attrs est le paramètre qui contient les attributs de notre objet en XML
public ChessBoardView(Context context, AttributeSet attrs, int defStyle) {
  super(context, attrs, defStyle);
  init(attrs);
}

// idem
public ChessBoardView(Context context, AttributeSet attrs) {
  super(context, attrs);
  init(attrs);
}

private void init(AttributeSet attrs) {
  // Je récupère mon tableau d'attributs depuis le paramètre que m'a donné le constructeur
  TypedArray attr = getContext().obtainStyledAttributes(attrs, R.styleable.ChessBoardView);
  // Il s'agit d'un TypedArray qu'on sait déjà utiliser, je récupère la valeur de la couleur, 1 ou -1 si on ne la trouve pas
  int tmpOne = attr.getColor(R.styleable.ChessBoardView_colorOne, -1);
  // Je récupère la valeur de la couleur, 2 ou -1 si on ne la trouve pas
  int tmpTwo = attr.getColor(R.styleable.ChessBoardView_colorTwo, -1);
  init(tmpOne, tmpTwo);
}

onMeasure

La taille par défaut de 100 pixels est ridicule et ne conviendra jamais à un échiquier. Je vais faire en sorte que, si l'application me l'autorise, je puisse exploiter le carré le plus grand possible, et je vais faire en sorte qu'au pire notre vue prenne au moins la moitié de l'écran.

Pour cela, j'ai écrit une méthode qui calcule la dimension la plus grande entre la taille que me demande de prendre le layout et la taille qui correspond à la moitié de l'écran. Puis je compare en largeur et en hauteur quelle est la plus petite taille accordée, et mon échiquier s'accorde à cette taille.

Il existe plusieurs méthodes pour calculer la taille de l'écran. De mon côté, j'ai fait en sorte de l'intercepter depuis les ressources avec la méthode DisplayMetrics getDisplayMetrics (). Je récupère ensuite l'attribut heightPixels pour avoir la hauteur de l'écran et widthPixels pour sa largeur.

 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
/**
 * Calcule la bonne mesure sur un axe uniquement
 * @param spec - Mesure sur un axe
 * @param screenDim - Dimension de l'écran sur cet axe
 * @return la bonne taille sur cet axe
 */
private int singleMeasure(int spec, int screenDim) {
  int mode = MeasureSpec.getMode(spec);
  int size = MeasureSpec.getSize(spec);

  // Si le layout n'a pas précisé de dimensions, la vue prendra la moitié de l'écran
  if(mode == MeasureSpec.UNSPECIFIED)
    return screenDim/2;
  else
    // Sinon, elle prendra la taille demandée par le layout
    return size;
}

@Override
protected void onMeasure (int widthMeasureSpec, int heightMeasureSpec) {
  // On récupère les dimensions de l'écran
  DisplayMetrics metrics = getContext().getResources().getDisplayMetrics();
  // Sa largeur…
  int screenWidth = metrics.widthPixels;
  // … et sa hauteur
  int screenHeight = metrics.heightPixels;

  int retourWidth = singleMeasure(widthMeasureSpec, screenWidth);
  int retourHeight = singleMeasure(heightMeasureSpec, screenHeight);

  // Comme on veut un carré, on n'aura qu'une taille pour les deux axes, la plus petite possible
  int retour = Math.min(retourWidth, retourHeight);

  setMeasuredDimension(retour, retour);
}

Toujours avoir dans son implémentation de onMeasure un appel à la méthode void setMeasuredDimension (int measuredWidth, int measuredHeight), sinon votre vue vous renverra une exception.

onDraw

Il ne reste plus qu'à dessiner notre échiquier ! Ce n'est pas grave si vous ne comprenez pas l'algorithme, du moment que vous avez compris toutes les étapes qui me permettent d'afficher cet échiquier tant voulu.

 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
@Override
protected void onDraw(Canvas canvas) {
  // Largeur de la vue
  int width = getWidth();
  // Hauteur de la vue
  int height = getHeight();

  int step = 0, min = 0;
  // La taille minimale entre la largeur et la hauteur
  min = Math.min(width, height);

  // Comme on ne veut que 8 cases par ligne et 8 lignes, on divise la taille par 8
  step = min / 8;

  // Détermine quand on doit changer la couleur entre deux cases
  boolean switchColor = true;
  for(int i = 0 ; i < min ; i += step) {
    for(int j = 0 ; j < min ; j += step) {
      if(switchColor)
        canvas.drawRect(i, j, i + step, j + step, mPaintTwo);
      else
        canvas.drawRect(i, j, i + step, j + step, mPaintOne);
      // On change de couleur à chaque ligne…
      switchColor = !switchColor;
    }
    // … et à chaque case
    switchColor = !switchColor;
  }
}

Ce qui peut donner la figure suivante.

Le choix des couleurs est discutable


  • Lors de la déclaration de nos interfaces graphiques, la hiérarchie des vues que nous déclarons aura toujours un layout parent qu'Android place sans nous le dire.
  • Le placement est l'opération pendant laquelle Android placera les vues dans l'interface graphique. Elle se caractérise par l'appel de la méthode void measure(int widthMeasureSpec, int heightMeasureSpec) pour déterminer les dimensions réelles de votre vue et ensuite de la méthode void layout(int bord_gauche, int plafond, int bord_droit, int plancher) pour la placer à l'endroit demandé.
  • Toutes les vues que vous avez déclarées dans vos interfaces offrent la possibilité de connaitre leurs dimensions une fois qu'elles ont été dessinées à l'écran.
  • Une vue ne redessine que les zones qui ont été modifiées. Ces zones définissent ce qu'on appelle l'obsolescence régionale. Il est possible de demander à une vue de se forcer à se redessiner par le biais de la méthode void invalidate () et toutes ses dérivées.
  • Vous pouvez créer une nouvelle vue personnalisée à partir d'une vue préexistante que vous décidez d'étendre dans l'une de vos classes Java.
  • Vous pouvez créer une nouvelle vue personnalisée à partir d'un assemblage d'autres vues préexistantes comme le ferait un Spinner qui est un assemblage entre un Button et une ListView.
  • Vous pouvez créer une nouvelle vue personnalisée à partir d'une feuille blanche en dessinant directement sur le canvas de votre vue.