是非に及ばず

プログラミングに関する話題などを書いていきます(主にRailsやAndroidアプリ開発について)

Androidでテキストをマーキー表示するカスタムビューの作り方

前置き

Androidアプリでテキストをマーキー表示したいって事あるよね?
でも、そんなに簡単には出来なかったりする(汗
ここでいうマーキーの動作は、HTMLのmarqueeタグのイメージ。
標準のTextViewのandroid:ellipsize="marquee"で実現できるかな?と思ったら全然ダメだった。

TextViewのダメなところ
  • テキストが短いとスクロールしない
  • 文字のスクロール速度を自由に調整できない
  • フォーカスが当たっていないとスクロールしない(つまり、複数のTextViewで同時にマーキー表示とか無理)

以上の欠点により、TextViewでは自分のやりたい事は出来ない事が分かった。
次に、アニメーション機能でTextViewを画面の右から左へ移動させたらどうだろうと考えて試してみた。
見事に玉砕したけど(後述

アニメーション(TranslateAnimation)でTextViewを移動するのがダメな理由
  • TextViewは画面サイズに収まらない長いテキストを設定した場合、自動的に画面サイズに収まる長さにテキストがカットされてしまう仕様となっている

この時点で終了(笑
そこで、最後の切り札。カスタムビューですよ!(無事解決)
Androidアプリの経験が浅い事もあり、マーキー表示のためだけに1週間くらい試行錯誤していたorz
でも、ようやく解決してうれしいので、ブログにまとめておく。
結論からいうと、独自のビューを定義してonDraw()で好きなようにしろって事なんだけど、
たどり着くまでが長かった。分かってしまえば、簡単なんだけどね(当たり前)

ソース

マーキー表示用のビューという事で、シンプルにMarqueeViewという名前にした。
カスタムビューの作り方については、ここが分かりやすい。
また、ここに載せているMaqrueeViewの画面サイズの取得とか初期化のやり方は
リファレンスのLabelViewの例を参考にした。

res/values/attrs.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- name : marquee view class name -->
    <declare-styleable name="MarqueeView">
        <attr name="text" format="string" />
        <attr name="textColor" format="color" />
        <attr name="background" format="color" />
        <attr name="textSize" format="dimension" />
        <attr name="repeatLimit" format="integer" />
        <attr name="textMoveSpeed" format="integer" />
    </declare-styleable>
</resources>
res/layout/main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/LinearLayout"
    android:layout_height="fill_parent"
    android:layout_width="fill_parent"
    android:background="@color/white"
    android:orientation="vertical"
>
     <net.easyjp.android.widget.MarqueeView
        xmlns:app="http://schemas.android.com/apk/res/net.easyjp.marquee_view"
        android:id="@+id/marqueeView1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:text="test"
        app:textSize="25sp"
        app:textColor="#FFFFFFFF"
        app:background="#FF000000"
        app:textMoveSpeed="15"
        app:repeatLimit="3"
    />
    
    <net.easyjp.android.widget.MarqueeView
        xmlns:marquee="http://schemas.android.com/apk/res/net.easyjp.marquee_view"
        android:id="@+id/marqueeView2"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:paddingTop="30dp"
        marquee:text="test"
        marquee:textSize="30sp"
        marquee:textColor="#FFFFFFFF"
        marquee:background="#FFFF0000"
        marquee:textMoveSpeed="15"
        marquee:repeatLimit="1"
    />
</LinearLayout>
MainActivity.java
package net.easyjp.marquee_view;

import net.easyjp.android.widget.MarqueeView;
import net.easyjp.marquee_view.R;
import android.app.Activity;
import android.os.Bundle;

public class MainActivity extends Activity {
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        String text1 = "マーキーのテスト(wrap_content)";
        MarqueeView marqueeView1 = (MarqueeView)findViewById(R.id.marqueeView1);
        marqueeView1.setText(text1);
        marqueeView1.startMarquee();

        String text2 = "少し長めのテキスト(fill_parent)。あいうえおかきくけこさしすせそ123456790";
        MarqueeView marqueeView2 = (MarqueeView)findViewById(R.id.marqueeView2);
        marqueeView2.setText(text2);
        marqueeView2.startMarquee();
    }
}
MarqueeView.java
package net.easyjp.android.widget;

/**
============================================
  使用方法およびドキュメント
============================================

(1) res/values/attrs.xmlに以下を記述
ここでMarqueeViewにXMLから設定できるパラメータ名とデータの形式を定義している
実際にこの値を使用しているかどうかは、コンストラクタの
MarqueeView(Context context, AttributeSet attrs)の内容を見れば分かる
---------------------------------
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- name : marquee view class name -->
    <declare-styleable name="MarqueeView">
        <attr name="text" format="string" />
        <attr name="textColor" format="color" />
        <attr name="background" format="color" />
        <attr name="textSize" format="dimension" />
        <attr name="repeatLimit" format="integer" />
        <attr name="textMoveSpeed" format="integer" />
    </declare-styleable>
</resources>
---------------------------------

(2) res/layout/xxx.xml
使用したいアクティビティのレイアウトファイルに以下を記述。
xmlns:app=の行は必須。以下の例のnet.easyjp.marquee_viewは
使用するプロジェクトのパッケージ名になるので、注意する事。
またxmlns:appの部分は任意で良い。xmlns:marquee=""とした場合は、
各属性の指定もmarquee:textSize="25sp"に置き換える必要がある
---------------------------------
<net.easyjp.android.widget.MarqueeView
    xmlns:app="http://schemas.android.com/apk/res/net.easyjp.marquee_view"
    android:id="@+id/marqueeView"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:text="test"
    app:textSize="25sp"
    app:textColor="#FFFFFFFF"
    app:background="#FF000000"
    app:textMoveSpeed="15"
    app:repeatLimit="3"
/>
---------------------------------

(3) アクティビティ内でMarqueeViewを取得し、startMarquee()でマーキー開始
---------------------------------
MarqueeView marqueeView = (MarqueeView)findViewById(R.id.marqueeView);
marqueeView.setText("マーキーさせる文字列を設定");
marqueeView.startMarquee();
---------------------------------

*/

import net.easyjp.marquee_view.R;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.view.Display;
import android.view.View;
import android.view.WindowManager;
import android.util.Log;


/**
 * テキストのマーキー表示を行うカスタムビュー
 * 
 */
public class MarqueeView extends View {
    private Paint mTextPaint;
    private String mText;
    private int mAscent;
    
    private int mRepeatCount;      // リピートした回数
    private int mRepeatLimit;      // 最大リピート回数
    private int mCurrentX;         // 現在のテキストの位置
    private int mTextMoveSpeed;    // 1フレームで動く距離
    private Thread mThread = null; // テキストを移動させるスレッド

    // マーキー表示処理(テキストの移動+表示)
    private Runnable runnable = new Runnable() {
        public void run() {
            // 左端と判断するX座標
            int lastX = getLastX();

            while(mRepeatCount < mRepeatLimit) {
                mCurrentX = getMarqueeStartX(); // テキスト位置を戻す

                long beforeTime = System.currentTimeMillis();
                long afterTime = beforeTime;
                int fps = 30;
                long frameTime = 1000 / fps;

                // 1回のマーキー処理
                while(true) {               
                    // 左端まで到達したらリピート1回としてカウント
                    if(mCurrentX <= lastX) {
                        mRepeatCount += 1;
                        break;
                    }

                    mCurrentX -= mTextMoveSpeed;
                    postInvalidate();
                
                    afterTime = System.currentTimeMillis();
                    long pastTime = afterTime - beforeTime;
                    
                    long sleepTime = frameTime - pastTime;
                    
                    if(sleepTime > 0) {
                        try {
                            Thread.sleep(sleepTime);
                        }catch(Exception e){}
                    }
                    beforeTime = System.currentTimeMillis();
                }
                
            }
        }
    };
    
    /**
     * マーキー処理を停止する
     */
    public void clearMarquee() {
        mCurrentX = getMarqueeStartX();
        mRepeatCount = 0;
        mThread = null;
    }

    /**
     * マーキー処理を開始する
     */
    public void startMarquee() {
        clearMarquee();
        mThread = new Thread(runnable);
        mThread.start();
    }

    /**
     * コンストラクタ(XMLを使用しない場合)
     * 
     * @param context
     */
    public MarqueeView(Context context) {
        super(context);
        initMarqueeView();
    }

    /**
     * コンストラクタ(XMLを使用する場合)
     * 
     * @param context
     * @param attrs XMLで定義した属性
     */
    public MarqueeView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initMarqueeView();

        // XMLから属性を取得
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MarqueeView);

        String s = a.getString(R.styleable.MarqueeView_text);
        if(s != null) {
            setText(s);
        }

        int textSize = a.getDimensionPixelOffset(R.styleable.MarqueeView_textSize, 0);
        if (textSize > 0) {
            setTextSize(textSize);
        }

        setTextColor(a.getColor(R.styleable.MarqueeView_textColor, 0xFFFFFFFF));
        setBackgroundColor(a.getColor(R.styleable.MarqueeView_background, 0xFF000000));
        setRepeatLimit(a.getInteger(R.styleable.MarqueeView_repeatLimit, 1));
        setTextMoveSpeed(a.getInteger(R.styleable.MarqueeView_textMoveSpeed, 5));

        a.recycle();
    }

    /**
     * 初期化処理
     * このメソッドは必ずコンストラクタ内で呼び出す必要がある
     */
    private final void initMarqueeView() {
        mTextPaint = new Paint();
        mTextPaint.setAntiAlias(true);
        mTextPaint.setTextSize(16);
        mTextPaint.setColor(0xFFFFFFFF);
        mTextMoveSpeed = 5;
        mRepeatCount = 0;
        setRepeatLimit(1);
        setText("");
        setPadding(0, 0, 0, 0);
        setBackgroundColor(0xFF000000);
    }
    
    /**
     * リピート回数を設定する
     * @param repeatLimit
     */
    public void setRepeatLimit(int repeatLimit) {
        if(repeatLimit > 0) {
            mRepeatLimit = repeatLimit;
        }else {
            mRepeatLimit = 1;
        }
    }
    
    /**
     * テキストの移動速度(px)を設定する
     * 
     * @param speed 移動速度(ピクセルで指定)
     */
    public void setTextMoveSpeed(int speed) {
        if(speed > 0) {
            mTextMoveSpeed = speed;
        }
    }

    /**
     * テキストを設定する
     * 
     * @param text 表示するテキスト
     */
    public void setText(String text) {
        mText = text;
        requestLayout();
        invalidate();
    }

    /**
     * テキストサイズを設定する
     * 
     * @param size フォントサイズ
     */
    public void setTextSize(int size) {
        mTextPaint.setTextSize(size);
        requestLayout();
        invalidate();
    }

    /**
     * テキストカラーを設定する
     * 
     * @param color
     */
    public void setTextColor(int color) {
        mTextPaint.setColor(color);
        invalidate();
    }

    /**
     * ビューのサイズを設定する
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(
            measureWidth(widthMeasureSpec),
            measureHeight(heightMeasureSpec)
        );
    }

    /**
     * ビューの幅を返す
     */
    private int measureWidth(int measureSpec) {
        int result = 0;
        int specMode = MeasureSpec.getMode(measureSpec);
        int specSize = MeasureSpec.getSize(measureSpec);

        if (specMode == MeasureSpec.EXACTLY) {
            // We were told how big to be
            result = specSize;
        } else {
            // Measure the text
            result = (int) mTextPaint.measureText(mText) + getPaddingLeft()
                    + getPaddingRight();
            if (specMode == MeasureSpec.AT_MOST) {
                // Respect AT_MOST value if that was what is called for by measureSpec
                result = Math.min(result, specSize);
            }
        }

        return result;
    }

    /**
     * ビューの高さを返す
     */
    private int measureHeight(int measureSpec) {
        int result = 0;
        int specMode = MeasureSpec.getMode(measureSpec);
        int specSize = MeasureSpec.getSize(measureSpec);

        mAscent = (int) mTextPaint.ascent();
        if (specMode == MeasureSpec.EXACTLY) {
            // We were told how big to be
            result = specSize;
        } else {
            // Measure the text (beware: ascent is a negative number)
            result = (int) (-mAscent + mTextPaint.descent()) + getPaddingTop()
                    + getPaddingBottom();
            if (specMode == MeasureSpec.AT_MOST) {
                // Respect AT_MOST value if that was what is called for by measureSpec
                result = Math.min(result, specSize);
            }
        }
        return result;
    }
    
    /**
     * マーキーの開始位置のX座標を返す
     */
    private int getMarqueeStartX() {
        WindowManager wm = (WindowManager)getContext().getSystemService(Context.WINDOW_SERVICE);
        Display display = wm.getDefaultDisplay();
        int measureText = (int)mTextPaint.measureText(mText);
        int measureWidth = getMeasuredWidth();

        if(display.getWidth() == measureWidth) {
            return measureWidth;
        }else if(measureText > display.getWidth()) {
            // テキストが画面サイズを超える場合
            return display.getWidth();
        }else if(measureWidth > measureText) {
            return measureWidth;
        }else {
            return measureText;
        }
    }
    
    /**
     * 左端と判定するX座標
     * @return
     */
    private int getLastX() {
        WindowManager wm = (WindowManager)getContext().getSystemService(Context.WINDOW_SERVICE);
        Display display = wm.getDefaultDisplay();
    
        int measureText = (int)mTextPaint.measureText(mText);
        int measureWidth = getMeasuredWidth();

        if(measureText >= display.getWidth()) {
            // テキストが画面サイズを超える場合
            return -measureText;
        }else if(measureWidth > measureText) {
            // テキストの幅がビューのサイズより小さい
            return -measureWidth;
        }else {
            return -measureText;
        }
    }
    
    /**
     * 描画処理
     */
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        
        int x = getPaddingLeft() + mCurrentX;
        int y = getPaddingTop() - mAscent;
        canvas.drawText(mText, x, y, mTextPaint);
    }
}