Android
TextView
AndroidDay 1

Android Advanced TextView

More than 1 year has passed since last update.

自己紹介

こんにちは @wasabeef_jp です
去年に引き続き、今年も12/1を担当させて頂きます。

今回は、TextViewをもっとHackしようということで、Advanced TextViewを紹介します。

Advanced TextView

Compound Drawable

animated_compound_drawable.png

これは、とてもポピュラーな使い方で
TextViewのテキスト本文の上下左右にアイコンを表示したい場合などに有効です

<TextView
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:text="@string/animation"
  android:drawableLeft="@drawable/rotating_loading"
  android:drawableRight="@drawable/animated_wifi"
  android:drawableBottom="@drawable/animated_clock"/>

<!--
  android:drawableLeft
  android:drawableRight
  android:drawableTop
  android:drawableBottom
-->

Drawable XML (Animatable)

animated_compound_drawable.gif

上記で説明した android:drawable... にはアイコンは勿論ですが、Animation XMLなども定義が出来ます

  • AnimatedRotateDrawableで、1つの画像を回転させる
<!-- res/drawable/rotating_loading.xml -->
<animated-rotate
  android:pivotX="50%"
  android:pivotY="50%"
  android:drawable="@drawable/ic_loading"
  android:duration="500" />
  • AnimationDrawableで、画像をGifアニメーションように動かす
<!-- res/drawable/animated_wifi.xml -->
<animation-list>
  <item android:drawable="@drawable/ic_wifi_0" android:duration="250" />
  <item android:drawable="@drawable/ic_wifi_1" android:duration="250" />
  <item android:drawable="@drawable/ic_wifi_2" android:duration="250" />
  <item android:drawable="@drawable/ic_wifi_3" android:duration="250" />
</animation-list>
  • AnimatedVectorDrawableで、細かいアニメーションを設定する (Introduced in Lollipop)
<!-- res/drawable/animated_clock.xml -->
<animated-vector android:drawable="@drawable/clock">
  <target android:name="hours" android:animation="@anim/hours_rotation" />
  <target android:name="minutes" android:animation="@anim/minutes_rotation" />
</animated-vector>
<!-- anim-v21/hours_rotation.xml -->
<objectAnimator
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="12000"
    android:propertyName="rotation"
    android:valueFrom="0"
    android:valueTo="360"
    android:repeatCount="-1"
    android:interpolator="@android:anim/linear_interpolator"/>
<!-- anim-v21/minutes_rotation.xml -->
<objectAnimator
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="1000"
    android:propertyName="rotation"
    android:valueFrom="0"
    android:valueTo="360"
    android:repeatCount="-1"
    android:interpolator="@android:anim/linear_interpolator"/>

先ほど、説明したAnimatable XMLを利用し、アニメーションの開始をします

<TextView
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:text="@string/animation"
  android:drawableLeft="@drawable/rotating_loading"
  android:drawableRight="@drawable/animated_wifi"
  android:drawableBottom="@drawable/animated_clock"/>
private void startAnimation(TextView textView) {
  Drawable[] drawables = textView.getCompoundDrawables();
  for (Drawable drawable : drawables) {
    if (drawable != null && drawable instanceof Animatable) {
      ((Animatable) drawable).start();
    }
  }
}

Shadow

shadow.png

TextViewは、Canvasや9patchなどを使わずに影を簡単に描くことが出来ます

  • 設定するプロパティは、shadowColor, shadowDx, shadowDy, shadowRadius
  • dpで設定するのではなく、pxの単位になります
  • また、paddingを入れてあげましょう
<TextView
  xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:padding="12dp"
  android:text="@string/shadow"
  android:textSize="80sp"
  android:textStyle="bold"
  android:shadowColor="#7000"
  android:shadowDx="12"
  android:shadowDy="12"
  android:shadowRadius="8"/>

Shadow, abused

shadow_blocky_glow.png

単純な影だけではなく、応用することでこのように幅が広がります

<TextView
  xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:padding="12dp"
  android:text="@string/blocky"
  android:textColor="@color/purple"
  android:textSize="@dimen/80sp"
  android:textStyle="bold"
  android:shadowColor="@color/green"
  android:shadowDx="4"
  android:shadowDy="-4"
  android:shadowRadius="1"/>
<TextView
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:padding="12dp"
  android:text="@string/glow"
  android:textSize="80sp"
  android:textStyle="bold"
  android:textColor="@android:color/white"
  android:background="@android:color/black"
  android:shadowColor="@color/yellow"
  android:shadowDx="0"
  android:shadowDy="0"
  android:shadowRadius="24"/>

Custom font

custom_font.png

  • カスタムフォント(日本語もOK)も簡単に適用させることが出来ます
  • アプリ全体にカスタムフォントを適用させたい場合などは CalligraphyなどのFont Libraryを使いましょう
Typeface typeface = Typeface.createFromAsset(getAssets(), "Ruthie.ttf");
textView.setTypeface(typeface);

Gradient

gradient.png

  • Shaderを用いることで、単純な色だけではなくグラデーションをあてることもできます
Shader shader = new LinearGradient(
    0, 0, 0, textView.getTextSize(), Color.RED, Color.BLUE, Shader.TileMode.CLAMP);
textView.getPaint().setShader(shader);

Pattern

patterned.png

  • グラデーションだけでなく、文字をリピート画像でリッチに仕上げることも出来ます
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.cheetah_tile);
Shader shader = new BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT);
textView.getPaint().setShader(shader);

HTML

from_html.png

  • TextViewは、Plain文字列だけでなく、HTML文字列も対応しています
  • ※全てのHTML Tagに対応してる訳ではありません。
<!-- 簡単なHTMLの例 -->
<h1>Hello World</h1>
Here is an <img src="octopus"><i>octopus</i>.<br>
And here is a 
<a href="http://d.android.com/reference/android/text/Html.html">
link</a>.
  • 上記のHTMLをstrings.xmlに設定します。
<string name="from_html_text">
<![CDATA[
<h1>Hello World</h1>
Here is an <img src="octopus"><i>octopus</i>.<br>
And here is a 
<a href="http://d.android.com/reference/android/text/Html.html">
link</a>.
]]>
</string>

HTML.fromHtml

  • getStringで取得したデータをそのままTextView#setTextするのではなく HTML.fromHtmlを使います
String html = getString(R.string.from_html_text);
  • imgタグに対応していても画像を表示してくれないので、Html.ImageGetterを継承して画像を処理します。
String html = getString(R.string.from_html_text);
textView.setText(Html.fromHtml(html, new ResouroceImageGetter(this), null));
private static class ResouroceImageGetter implements Html.ImageGetter {
  private final Context context;

  public ResouroceImageGetter(Context context) {
    this.context = context;
  }

  public Drawable getDrawable(String source) {
    int path = context.getResources().getIdentifier(source, "drawable", BuildConfig.APPLICATION_ID);
    Drawable drawable = context.getResources().getDrawable(path);
    drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight());
    return drawable;
  }
}

Clickable link

from_html.gif

  • aタグのリンクは、デフォルトで無効になっているので、有効にしたい場合は下記のようにします
textView.setMovementMethod(LinkMovementMethod.getInstance());

Supported tags

  • 下記以外のタグは、TagHandlerを使って自分で対応していく必要があります
<!-- Supported Html tags -->
<a href="...">
<b>,  <big>, <blockquote>, <br>, <cite>, <dfn>
<div align="...">,  <em>, <font size="..." color="..." face="...">
<h1>,  <h2>, <h3>, <h4>,  <h5>, <h6>
<i>,  <img src="...">,  <p>, <small>
<strike>,  <strong>, <sub>, <sup>, <tt>, <u>

SPAN

TextViewのSpan, TagHandlerは、とても強力で、カスタムしやすくなっています

by Flavien Laurent
http://flavienlaurent.com/blog/2014/01/31/spans/

One <u>two</u> three

"One two three" + Underline from position 4 to 6

spannableString.setSpan(new UnderlineSpan(), 4, 6, flags);

CharacterStyle

  • 文字のフォーマットで使うキーワード
  • e.g. ForegroundColorSpan, BackgroundColorSpan, UnderlineSpan

ParagraphStyle

  • 段落のフォーマットで使うキーワード
  • e.g. BulletSpan, IconMarginSpan, QuoteSpan

Stacked fractions

fraction_equation.png

  • 画像ではなく、分数の括線のように文字を積み重ねて表示することもできます
  • ただ、setFontFeatureSettingsを使う場合は、Lollipop以上である必要があります
TextPaint#setFontFeatureSettings
@TargetApi(LOLLIPOP)
<string name="fraction_text">
<![CDATA[
1<afrc>1/2</afrc> + <afrc>11/16</afrc> = 2<afrc>3/16</afrc>
]]>
</string>
Typeface typeface = Typeface.createFromAsset(getAssets(), "Nutso2.otf");
textView.setTypeface(typeface);
  • HTML.fromHtmlをする際にTagHandlerも指定います
String html = getString(R.string.fraction_text);
textView.setText(Html.fromHtml(html, null, new FractionTagHandler())
  • Html.TagHandlerを継承してhandleTagの中のoutputに対して処理します
private static class FractionTagHandler implements Html.TagHandler {
  @Override 
  public void handleTag(boolean opening, String tag, Editable output, XMLReader xmlReader) {
  if (!"afrc".equalsIgnoreCase(tag)) return;

  int len = output.length();
  if (opening) {
    output.setSpan(new FractionSpan(), len, len, Spannable.SPAN_MARK_MARK);
  } else {
    Object obj = getLast(output, FractionSpan.class);
    int where = output.getSpanStart(obj);
    output.removeSpan(obj);
    if (where != len) {
      output.setSpan(new FractionSpan(), where, len, 0);
    }
  }
}
private Object getLast(Editable text, Class kind) {
  Object[] objs = text.getSpans(0, text.length(), kind);
  if (objs.length == 0) return null;
  for (int i = objs.length - 1; i >= 0; --i) {
    if(text.getSpanFlags(objs[i]) == Spannable.SPAN_MARK_MARK) {
      return objs[i];
    }
  }
  return null;
}
  • Html.TagHandlerとSpan系は、相互に利用することが多く、ここではMetricAffectingSpanを使います
private static class FractionSpan extends MetricAffectingSpan {
  public void updateMeasureState(TextPaint textPaint) {
    textPaint.setFontFeatureSettings("afrc");
  }
  public void updateDrawState(TextPaint textPaint) {
    textPaint.setFontFeatureSettings("afrc");
  }
}

Arbitrary fractions

fractions.png

Styled string

styled_string.png

  • SpannableStringを使うとテキストに細かいスタイルを当てる事ができます
private static SpannableString formatString(Context context, int textId, int styleId) {
  String text = context.getString(textId);
  SpannableString spannableString = new SpannableString(text);
  spannableString.setSpan(new TextAppearanceSpan(context, styleId), 0, text.length(), 0);
  return spannableString;
}
  • 一行毎に違うスタイルを当てていきます
SpannableStringBuilder builder = new SpannableStringBuilder()
  .append(formatString(this, R.string.big_red, R.style.BigRedTextAppearance))
  .append("\n")
  .append(formatString(this, R.string.medium_green, R.style.MediumGreenTextAppearance))
  .append("\n")
  .append(formatString(this, R.string.small_blue, R.style.SmallBlueTextAppearance));
textView.setText(builder.subSequence(0, builder.length()));
  • この例では、テキストの一部分を赤色太字するために下記のようなstyleを使います
<style name="BigRedTextAppearance" parent="@android:style/TextAppearance">
  <item name="android:textSize">56sp</item>
  <item name="android:textColor">#c00</item>
</style>

AlignmentSpan

alignment_span.png

AlignmentSpanを使うと指定したテキスト範囲の揃え位置を変更できます

public void click(View button) {
  String text = editText.getText().toString();
  Layout.Alignment align = button.getId() == R.id.add_to_right_button ?
      Layout.Alignment.ALIGN_OPPOSITE : Layout.Alignment.ALIGN_NORMAL;
  appendText(text, align);
  editText.setText(null);
}
private void appendText(CharSequence text, Layout.Alignment align) {
  AlignmentSpan span = new AlignmentSpan.Standard(align);
  SpannableString spannableString = new SpannableString(text);
  spannableString.setSpan(span, 0, text.length(), 0);

  if (textView.length() > 0) {
    textView.append("\n\n");
  }
  textView.append(spannableString);
}

Rainbow span

rainbow.png

  • LinearGradientを応用して、テキストの一部分にGradientを当てます
String text = textView.getText().toString();
SpannableString spannableString = new SpannableString(text);
int start = text.toLowerCase().indexOf(substring);
int end = start + substring.length();
spannableString.setSpan(new RainbowSpan(this), start, end, 0);
private static class RainbowSpan extends CharacterStyle implements UpdateAppearance {
  private final int[] colors;
  public RainbowSpan(Context context) {
    colors = context.getResources().getIntArray(R.array.rainbow);
  }

  public void updateDrawState(TextPaint paint) {
    paint.setStyle(Paint.Style.FILL);
    Shader shader = new LinearGradient(0, 0, 0, paint.getTextSize() * colors.length, colors, 
        null, Shader.TileMode.MIRROR);
    Matrix matrix = new Matrix();
    matrix.setRotate(90);
    shader.setLocalMatrix(matrix);
    paint.setShader(shader);
  }
}

Animated rainbow span

animated_rainbow.gif

実際、Spanは本当に強力で、自作さえすれば何でも出来ます
ここでは、テキストの一部にアニメーションを当ててみます

  • spanに対してPropertyの作成ObjectAnimatorを設定します
private static final Property<AnimatedColorSpan, Float> 
    ANIMATED_COLOR_SPAN_FLOAT_PROPERTY
    = new Property<AnimatedColorSpan, Float>(Float.class, "ANIMATED_COLOR_SPAN_FLOAT_PROPERTY") {
  public void set(AnimatedColorSpan span, Float value) {
    span.setTranslateXPercentage(value);
  }
  public Float get(AnimatedColorSpan span) {
    return span.getTranslateXPercentage();
  }
};
ObjectAnimator objectAnimator = 
    ObjectAnimator.ofFloat(span, ANIMATED_COLOR_SPAN_FLOAT_PROPERTY, 0, 100);
objectAnimator.setEvaluator(new FloatEvaluator());
objectAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
  public void onAnimationUpdate(ValueAnimator animation) {
    textView.setText(spannableString);
  }
});
objectAnimator.setInterpolator(new LinearInterpolator());
objectAnimator.setDuration(DateUtils.MINUTE_IN_MILLIS * 3);
objectAnimator.setRepeatCount(ValueAnimator.INFINITE);
objectAnimator.start();
private static class AnimatedColorSpan extends CharacterStyle implements UpdateAppearance {

  public AnimatedColorSpan(Context context) {
    colors = context.getResources().getIntArray(R.array.rainbow);
  }

  public void setTranslateXPercentage(float value) {
    translateXPercentage = value;
  }
  public float getTranslateXPercentage() {
    return translateXPercentage;
  }

  public void updateDrawState(TextPaint paint) {
    paint.setStyle(Paint.Style.FILL);
    float width = paint.getTextSize() * colors.length;
    if (this.shader == null) {
      this.shader = new LinearGradient(0, 0, 0, width, colors, null, Shader.TileMode.MIRROR);
    }
    this.matrix.reset();
    this.matrix.setRotate(90);
    this.matrix.postTranslate(width * translateXPercentage, 0);
    this.shader.setLocalMatrix(this.matrix);
    paint.setShader(this.shader);
  }
}

ClickableSpan

clickable_span.gif

  • TextView.setMovementMethodを応用すると、クリックのハンドリングも行えます
  • SpannableString#setSpanで、適用するSpanクラス、開始から終了地点を指定します
String text = textView.getText().toString();

String goToSettings = getString(R.string.go_to_settings);
int start = text.indexOf(goToSettings);
int end = start + goToSettings.length();

SpannableString spannableString = new SpannableString(text);
spannableString.setSpan(new GoToSettingsSpan(), start, end, 0);
textView.setText(spannableString);

textView.setMovementMethod(new LinkMovementMethod());
  • 設定画面にIntent使います
private static class GoToSettingsSpan extends ClickableSpan {
  public void onClick(View view) {
    view.getContext().startActivity(new Intent(android.provider.Settings.ACTION_SETTINGS));
  }
}
<TextView
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:text="@string/clickable_span_text"
  android:textColorLink="@color/go_to_settings"
  android:textColorHighlight="@color/light_green"/>

Lined paper

lined_paper.gif lined_paper_huge.png

  • 紙のノートのように行にEditTextのベースラインに線を引くも出来ます
public class LinedEditText extends EditText {
  // Called from constructors
  private void init() {
    this.paint = new Paint();
    this.paint.setStyle(Paint.Style.STROKE);
    this.paint.setColor(getResources().getColor(R.color.paper_line));
    this.paint.setStrokeWidth(getLineHeight() / 10);
    this.paint.setStrokeCap(Paint.Cap.ROUND);
  }
}

Emoji

emoji_raw.png emoji.png

  • "\u26F7"に該当する文字列の部分に自作のIconFontSpanを当てます
String text = textView.getText().toString();
SpannableString spannableString = new SpannableString(text);

IconFontSpan iconFontSpan = new IconFontSpan(textView.getContext());
Pattern pattern = Pattern.compile("\u26F7");    // skier
Matcher matcher = pattern.matcher(text);
while (matcher.find()) {
  spannableString.setSpan(iconFontSpan, matcher.start(), matcher.end(), 0);
}
  • カスタムフォントを用いてアイコンを設定します
private static class IconFontSpan extends MetricAffectingSpan {
  private static Typeface typeface = null;
    public IconFontSpan(Context context) {
      if (typeface == null) {
        typeface = Typeface.createFromAsset(context.getAssets(), "icomoon.ttf");
      }
    }
    public void updateMeasureState(TextPaint textPaint) {
       textPaint.setTypeface(typeface);
    }
    public void updateDrawState(TextPaint textPaint) {
      textPaint.setTypeface(typeface);
    }
}

ImageSpan from resources

emoji.png font_metrics.png

  • カスタムフォントではなく、該当のところにBitmapを当てることもできます
Pattern pattern = Pattern.compile(":octopus:");
Matcher matcher = pattern.matcher(text);

Bitmap octopus = null;
int size = (int) (-textView.getPaint().ascent());
while (matcher.find()) {
  if (octopus == null) {
    Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.octopus);
    octopus = Bitmap.createScaledBitmap(bitmap, size, size, true);
    bitmap.recycle();
  }
  ImageSpan span = new ImageSpan(this, octopus, ImageSpan.ALIGN_BASELINE);
  spannableString.setSpan(span, matcher.start(), matcher.end(), 0);
}

Dynamic ImageSpan (SpeedSignDrawable)

emoji.png

  • ":speed_50:"や":speed_110:"などにPatternで正規表現を用いて、動的にCanvasで描いていきます
// :speed_50: :speed_110:
Pattern pattern = Pattern.compile(":speed_(\\d\\d\\d?):");
Pattern matcher = pattern.matcher(text);

while (matcher.find()) {
  SpeedSignDrawable drawable = new SpeedSignDrawable(textView, matcher.group(1));
  ImageSpan span = new ImageSpan(drawable, ImageSpan.ALIGN_BASELINE);
  spannableString.setSpan(span, matcher.start(), matcher.end(), 0);
}
  • カスタムフォントでもBitmapでもなく、Drawable(Paint, Canvas)で描画します
private static class SpeedSignDrawable extends Drawable {
  public SpeedSignDrawable(TextView textView, String number) {
    this.ascent = textView.getPaint().ascent();
    this.descent = textView.getPaint().descent();
    this.textSize = textView.getTextSize();
    this.strokeWidth = textView.getPaint().getStrokeWidth();
    this.number = number;
    int size = (int) -ascent;
    this.setBounds(0, 0, size, size);
  }
  • 黄色の丸
  private void drawYellowCircle(Canvas canvas) {
    int size = (int) -ascent;
    Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    paint.setStyle(Paint.Style.FILL);
    paint.setColor(Color.YELLOW);
    canvas.drawCircle(size/2, size/2, size/2, paint);
  }
}
  • 外周の赤枠
private void drawRedRing(Canvas canvas) {
  Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
  paint.setStyle(Paint.Style.STROKE);
  paint.setColor(Color.RED);
  float ringWidth = size / 10;
  paint.setStrokeWidth(ringWidth);
  canvas.drawCircle(size/2, size/2, size/2 - ringWidth/2, paint);
}
  • 数字部分
private void drawBlackNumber(Canvas canvas) {
  float ratio = 0.4f;
  Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
  paint.setStyle(Paint.Style.FILL);
  paint.setColor(Color.BLACK);
  paint.setTextAlign(Paint.Align.CENTER);
  paint.setTextSize(textSize * ratio);
  paint.setStrokeWidth(strokeWidth);
  paint.setTypeface(Typeface.defaultFromStyle(Typeface.BOLD));
  int x = size / 2;
  int y = (int) (size/2 - ((descent + ascent)/2) * ratio);
  canvas.drawText(number, x, y, paint);
}
  • 上記で定義したメソッドを呼びます
  public void draw(Canvas canvas) {
    drawYellowCircle(canvas);
    drawRedRing(canvas);
    drawBlackNumber(canvas);
  }

原文:https://github.com/chiuki/advanced-textview

雑感

なんか面白そうな勉強会がありましたら、呼んで下さい。

@wasabeef_jpまで(੭ु๑•ૅω•´)੭ु⁾⁾