Pour les besoins d’un client, j’ai été confronté à la problématique suivante: afficher du texte au format HTML dans une ListView. Ça semble tout simple, et pourtant…
La première chose à ne pas essayer, c’est de fournir des WebViews à votre ListView. C’est d’ailleurs déconseillé un peu partout (ici par exemple). Les problèmes rencontrés vont aller de la confusion du scroll entre la WebView et la ListView (si je déplace mon doigt verticalement, est-ce que je vais scroller dans la WebView ou dans la ListView?) à des problèmes bien plus graves: comment gérer le redimensionnement de ma cellule lors de la réutilisation de la vue?
Bref, le plus simple, c’est encore de s’en passer.
L’objet de cet article est justement de se passer des WebViews et d’utiliser uniquement des TextViews. On peut utiliser cette méthode magique
public static Spanned fromHtml (String source)
Elle permet de convertir une String qui représente du HTML en « Spanned ». Et les Spanned sont utilisables directement dans une TextView (setText(CharSequence cs)) car Spanned hérite de CharSequence.
C’est magique, mais souvent, ça ne suffit pas. En effet, si cette méthode permet bien de convertir les balises A en Spanned « lien » (cliquables, donc), elle ne gère que très peu d’autres balises. Voir ici la liste des balises « supportées ». Attention, car même les balises supportées ne sont pas forcément gérées comme on le voudrait. Par exemple, les balises DIV et P sont juste remplacées par un « \n ». Les alignements, marges sont moyennement gérées.
La solution, c’est comme d’habitude d’aller jeter un oeil sur les sources d’Android, notamment la méthode qui nous intéresse et de recopier/overrider.
On voit que cette méthode fait appel à un parseur XML basé sur TagSoup et qu’elle gère elle même ce qu’il faut faire pour chaque balise.
Par exemple, on voit qu’elle insère dans la méthode handleStartTag dans un objet de type SpannableStringBuilder un tas d’objets de types Bold, Italic, Underline etc.
Le SpannableStringBuilder, sans entrer trop dans les détails, est un constructeur de SpannableString, c’est-à-dire un constructeur de String « spannables », ce qui veut dire que c’est un objet qui contient une String et une liste d’attributs avec des indices.
Concrètement, si on prend l’exemple de cette phrase: « Bonjour »:
la SpannableString contiendra
- la String « Bonjour »
- ainsi qu’un objet de type StyleSpan(Bold)
- mais aussi les indices 0 et 2, pour signifier qu’on doit appliquer le style « bold » aux 3 premiers caractères de la String.
Et la TextView d’Android permet d’afficher cette SpannableString directement!
Dans mon cas particulier, il fallait entre autres afficher quelque chose pour les balises HR. Piqûre de rappel: la balise HR, c’est une ligne horizontale, une balise qui est de plus en plus rarement utilisée.
Comme ma balise HR n’est pas gérée par les méthodes handleStartTag et handleEndTag, j’ai rajouté mon cas à la liste des « if »:
if (tag.equalsIgnoreCase("hr")) {
startHR(mSpannableStringBuilder, new HorizontalLine());
}
et la méthode startHR (dont j’ai largement pompé le modèle sur la façon dont sont écrites les autres méthodes startXXXX)
private void startHR(SpannableStringBuilder text, HorizontalLine horizontalLine) {
text.append("\n");
text.append("\uFFFC");
text.setSpan(new HorizontalLineSpan(Color.BLACK, 0), text.length() - 1, text.length(),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
text.append("\n");
}
Le principe ici, c’est d’ajouter des « \n », mais surtout un objet de type Spanned (plus précisément de type DynamicDrawableSpan) dans notre SpannableString pour indiquer à la TextView, lorsqu’elle aura besoin d’afficher ce texte, qu’il y’aura un dessin à faire ici.
Voici donc ma classe HorizontalLineSpan, très largement pompée d’ici:
public class HorizontalLineSpan extends DynamicDrawableSpan {
HorizontalLineDrawable mDrawable;
private int width;
public HorizontalLineSpan(int color, int width) {
super(ALIGN_BOTTOM);
mDrawable = new HorizontalLineDrawable(color, width);
this.width = width;
}
@Override
public Drawable getDrawable() {
return mDrawable;
}
public void resetWidth(int width) {
this.width = width;
mDrawable.renewBounds(width);
}
@Override
public int getSize(Paint paint, CharSequence text, int start, int end, FontMetricsInt fm) {
return width;
}
public int getColor() {
return Color.BLACK;
}
}
Son principe à elle, c’est de contenir un Drawable, qui sera affiché par la TextView quand cette dernière lui demandera (en appelant la méthode getDrawable()).
Dans mon cas particulier, j’avais besoin d’afficher une ligne horizontale, j’ai donc créé (enfin plutôt pompé et modifié) une classe qui hérite de ShapeDrawable qui dessine une ligne. La voici:
public class HorizontalLineDrawable extends ShapeDrawable {
private static final String LOG_TAG = "HorizontalLineDrawable";
private int mWidth;
private Paint paint;
public HorizontalLineDrawable(int color, int width) {
super(new RectShape());
mWidth = width;
paint = new Paint();
paint.setColor(color);
renewBounds(width);
}
@Override
public void draw(Canvas canvas) {
Rect rect = new Rect(0, 9, mWidth, 10);
canvas.drawRect(rect, paint);
}
public void renewBounds(int width) {
int MARGIN = 20;
int HEIGHT = 20;
if (width > MARGIN) {
width -= MARGIN;
}
mWidth = width;
setBounds(0, 0, width, HEIGHT);
}
}
Sans entrer trop dans les détails, là où ça se joue, c’est sur le onDraw(Canvas c). C’est là qu’on va dessiner un rectangle à x = 0, y = 9, d’une largeur variable et d’une hauteur de 1 pixel (car 10 – 9 =1!).
Sauf que justement, quand on est au stade de parser le HTML et de créer un SpannableString, on ne sait pas quelle sera la largeur de notre TextView. Cette SpannableString peut être ajoutée à une TextView gigantesque ou minuscule. D’où cette deuxième méthode, « renewBounds », qui permet justement de setter la largeur de la TextView.
Vous aurez peut-être remarqué que cette méthode est appelée par la méthode resetWidth(int w) de l’HorizontalLineSpan !
Et voici l’appel à la méthode resetWidth(int w) sur l’HorizontalLineSpan:
/*Attention, dans mon exemple, on fait tout le parsing HTML dans le getView, ce qui est fortement déconseillé.
C'est une opération qui peut être coûteuse et rendre l'affichage tout saccadé,
voire même causer un ANR (Application not responding).
Donc, dans un premier temps, on parse la String HTML et on remplit un objet de type Spanned.*/
Spanned fromHtml = Html.fromHtml(tmp);
// on récupère la largeur de notre TextView
int width = contenuTxt.getWidth();
// On récupère tous les Spans de type "HorizontalLineSpan"
HorizontalLineSpan[] lines = fromHtml.getSpans(0, fromHtml.length(), HorizontalLineSpan.class);
//Pour chacun, on appelle resetWidth avec la largeur de la TextView
for (HorizontalLineSpan line : lines) {
line.resetWidth(width);
}
//et enfin, on setText sur notre TextView!
contenuTxt.setText(fromHtml);
Et voici le résultat!