2013年3月18日月曜日

マーキーとTextView

現在の4.x系ではどうかちょっと調べてないのですが、 前に練習向けに作ってたアプリで、TextViewのマーキー動作が以下の仕様のため、思ったように動かせませんでした。

  • フォーカスが当たっていないと動作しない
  • テキストの長さがViewの幅以上でないと動かない

このため、マーキー動作を行うTextViewを自作したので、公開しておきます。 ・・・本当は1,2年位前に作ったのですが、書くのを怠けてました(´ω`)


で、最初はTextViewのソースから、そっくりそのままマーキー部分を改造したものを作ろうと したのですが、TextViewの内部のパッケージを利用している部分がどうにもならなかったので 断念しました。。。

そのため、TextViewを継承して、マーキー動作を行う部分をTextViewから移植することで なんとか思った動作が実現できました。 できたソースは以下になります。


import java.lang.ref.WeakReference;

import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Canvas;
import android.os.Handler;
import android.os.Message;
import android.util.AttributeSet;
import android.view.ViewTreeObserver;
import android.view.ViewTreeObserver.OnGlobalLayoutListener;
import android.widget.TextView;

public class MarqueeTextView extends TextView {
    private Marquee mMarquee;
    private int repeatLimit = 0;
    private boolean repeatInfy = false;
    private boolean repeatReverse = false;
    private float marqueeSpeed = 0;
    private boolean isConfigureChange = false;
    
    public MarqueeTextView(Context context, AttributeSet attrs) {
        super(context, attrs);
        ViewTreeObserver observer = this.getViewTreeObserver();
        observer.addOnGlobalLayoutListener(new OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                // 画面方向が変更された際にマーキーをやり直す
                if(isConfigureChange){
                    if(mMarquee!=null) {
                        stopMarquee();
                        startMarqueeAndRetry();
                    }
                    isConfigureChange = false;
                }
            }
        });
    }
    
    @Override
    protected void onDraw(Canvas canvas) {
        // Draw the background for this view
        // 描画時に座標をずらすことでマーキー動作を行っている
        if (mMarquee != null && mMarquee.isRunning()) {
            this.scrollTo((int) mMarquee.mScroll, 0);
        }
        super.onDraw(canvas);
    }

    @Override
    protected float getLeftFadingEdgeStrength() {
        if (mMarquee != null && !mMarquee.isStopped()) {
            final Marquee marquee = mMarquee;
            return marquee.mScroll / getHorizontalFadingEdgeLength();
        }
        return super.getLeftFadingEdgeStrength();
    }
    
    @Override
    protected float getRightFadingEdgeStrength() {
        if (mMarquee != null && !mMarquee.isStopped()) {
            final Marquee marquee = mMarquee;
            return (marquee.mMaxScroll - marquee.mScroll) / getHorizontalFadingEdgeLength();
        }
        return super.getRightFadingEdgeStrength();
    }
    
    @Override
    public void onConfigurationChanged(Configuration newConfig){
        // 画面方向の変更を検知
        super.onConfigurationChanged(newConfig);
        isConfigureChange = true;
    }
    
    /**
     * setTextがfinalのため、Textセット用のメソッドを配置
     */
    public void setMarqueeText(CharSequence text){
        super.setText(text);
        mMarquee.refreshMarqueeParams();
    }
    
    /**
     * テキストがView幅以上か識別する
     */
    public boolean isOverTextWidth(){
        if(this.getLayout()!=null){
            return (this.getLayout().getLineWidth(0) > this.getWidth());
        }
        else{
            return false;
        }
    }

    /**
     * マーキー動作をリピート回数を設定する
     */
    public void setRepeatLimit(int limit){
        if(limit < 0){
            limit = 0;
        }
        repeatLimit = limit;
    }
    
    /**
     * マーキー動作を無制限でリピートする
     */
    public void setRepeatInfy(boolean infy){
        repeatInfy = infy;
        if(mMarquee!=null){
            mMarquee.setRepeatInfy(repeatInfy);
        }
    }
    
    /**
     * マーキー動作を右端まできたら折り返すような動作にする
     */
    public void setRepeatReverse(boolean setReverse){
        repeatReverse = setReverse;
        if(mMarquee!=null){
            mMarquee.setRepeatReverse(setReverse);
        }
    }
    
    /**
     * マーキー動作の速度を設定する
     */
    void setMarqueeSpeed(float marqueeSpeed){
        this.marqueeSpeed = marqueeSpeed;
        if(mMarquee!=null){
            mMarquee.setMarqueeSpeed(marqueeSpeed);
        }
    }

    /**
     * マーキー動作を開始する
     */
    public void startMarquee() {
        if ((mMarquee == null || mMarquee.isStopped())) {
            if (mMarquee == null) mMarquee = new Marquee(this);
            mMarquee.setMarqueeSpeed(marqueeSpeed);
            mMarquee.setRepeatReverse(repeatReverse);
            mMarquee.setRepeatInfy(repeatInfy);
            if(repeatInfy){
                mMarquee.start(1);
            }
            else{
                mMarquee.start(repeatLimit);
            }
        }
    }
    
    /**
     * テキスト幅がView以上の場合にマーキー動作を開始する
     */
    public void startMarqueeIfTextWidthLarger(){
        if(isOverTextWidth()){
            startMarquee();
        }
    }

    /**
     * マーキー動作を開始する
     */
    private void startMarqueeAndRetry(){
        if ((mMarquee == null || mMarquee.isStopped())) {
            if (mMarquee == null) mMarquee = new Marquee(this);
            mMarquee.setMarqueeSpeed(marqueeSpeed);
            mMarquee.setRepeatReverse(repeatReverse);
            mMarquee.setRepeatInfy(repeatInfy);
            if(repeatInfy){
                mMarquee.startAndRetry(1);
            }
            else{
                mMarquee.startAndRetry(repeatLimit);
            }
        }
    }
    
    /**
     * マーキー動作を停止する
     */
    public void stopMarquee() {
        if (mMarquee != null && !mMarquee.isStopped()) {
            mMarquee.stop();
        }
    }
    
    /**
     * マーキー動作用の内部クラス
     * オリジナルのTextViewクラスから抜粋し、一部を改造しました。
     */
    final class Marquee extends Handler {
        // TODO: Add an option to configure this
        // オリジナルのソースから抜粋したのでいらない定数があるかも。。。
        // マーキーの最後の方で、テキストの頭がでてくる動作ができない。
        // コピーしたViewを用意して最後辺りで頭を流すとか?
//        private static final float MARQUEE_DELTA_MAX = 0.07f;
        private static final int MARQUEE_DELAY = 1200;
        private static final int MARQUEE_RESTART_DELAY = 1200;
        private static final int MARQUEE_RETRY_DELAY = 1200;
        private static final int MARQUEE_RESOLUTION = 1000 / 30;
        private static final int MARQUEE_PIXELS_PER_SECOND = 30;

        private static final byte MARQUEE_STOPPED = 0x0;
        private static final byte MARQUEE_STARTING = 0x1;
        private static final byte MARQUEE_RUNNING = 0x2;
        private static final byte MARQUEE_RETRYING = 0x3;

        private static final int MESSAGE_START = 0x1;
        private static final int MESSAGE_TICK = 0x2;
        private static final int MESSAGE_RESTART = 0x3;
        private static final int MESSAGE_RETRY = 0x4;

        private final WeakReference mView;

        private byte mStatus = MARQUEE_STOPPED;
        private float mScrollSpeed;
        private float mMaxScroll;
//        float mMaxFadeScroll;
//        private float mGhostStart;
//        private float mGhostOffset;
//        private float mFadeStop;
        private int mRepeatLimit;
        private boolean mRepeatReverse;
        private boolean mRepeatInfy;
        private boolean reverse;

        float mScroll;

        Marquee(TextView v) {
            final float density = v.getContext().getResources().getDisplayMetrics().density;
            mScrollSpeed = (MARQUEE_PIXELS_PER_SECOND * density) / MARQUEE_RESOLUTION;
            mView = new WeakReference(v);
        }

        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                case MESSAGE_START:
                    mStatus = MARQUEE_RUNNING;
                    tick();
                    break;
                case MESSAGE_TICK:
                    tick();
                    break;
                case MESSAGE_RETRY:
                    retry();
                    break;
                case MESSAGE_RESTART:
                    if (mStatus == MARQUEE_RUNNING) {
                        TextView text = mView.get();
                        if(text!=null){
                            text.scrollTo(0, 0);
                        }
                        if(mRepeatInfy){
                            start(1);
                        }
                        else{
                            if (mRepeatLimit >= 0) {
                                mRepeatLimit--;
                            }
                            start(mRepeatLimit);
                        }
                    }
                    break;
            }
        }

        void tick() {
            if (mStatus != MARQUEE_RUNNING) {
                return;
            }

            removeMessages(MESSAGE_TICK);

            TextView textView = mView.get();
            if (textView != null) {
                if(reverse){
                    mScroll -= mScrollSpeed;
                    if (mScroll < 0) {
                        mScroll = 0;
                        reverse = !reverse;
                        sendEmptyMessageDelayed(MESSAGE_RESTART, MARQUEE_RESTART_DELAY);
                    } else {
                        sendEmptyMessageDelayed(MESSAGE_TICK, MARQUEE_RESOLUTION);
                    }
                }
                else{
                    mScroll += mScrollSpeed;
                    if (mScroll > mMaxScroll) {
                        mScroll = mMaxScroll;
                        if(mRepeatReverse){
                            reverse = !reverse;
                            sendEmptyMessageDelayed(MESSAGE_TICK, MARQUEE_RESTART_DELAY);
                        }
                        else{
                            sendEmptyMessageDelayed(MESSAGE_RESTART, MARQUEE_RESTART_DELAY);
                        }
                    } else {
                        sendEmptyMessageDelayed(MESSAGE_TICK, MARQUEE_RESOLUTION);
                    }
                }
                textView.invalidate();
            }
            else {
                stop();
            }
        }

        void stop() {
            mStatus = MARQUEE_STOPPED;
            removeMessages(MESSAGE_START);
            removeMessages(MESSAGE_RESTART);
            removeMessages(MESSAGE_RETRY);
            removeMessages(MESSAGE_TICK);
            resetScroll();
        }

        private void resetScroll() {
            mScroll = 0.0f;
            final TextView textView = mView.get();
            if (textView != null) {
                textView.scrollTo(0, 0);
                textView.invalidate();
            }
        }
        
        private void retry(){
            removeMessages(MESSAGE_RETRY);
            final TextView textView = mView.get();
            if(textView.getLayout()==null){
                mStatus = MARQUEE_RETRYING;
                sendEmptyMessageDelayed(MESSAGE_RETRY, MARQUEE_RETRY_DELAY);
            }
            else if(textView.getLayout().getLineWidth(0) > textView.getWidth()){
                   start(mRepeatLimit);
            }
        }
        
        private void startAndRetry(int repeatLimit){
            mRepeatLimit = repeatLimit;
            retry();
        }

        void start(int repeatLimit) {
            if (repeatLimit == 0) {
                stop();
                return;
            }
            mRepeatLimit = repeatLimit;
            final TextView textView = mView.get();
            if (textView != null) {
                mStatus = MARQUEE_STARTING;
                mScroll = 0.0f;
                // テキストの幅
                final int textWidth = (int)textView.getLayout().getLineWidth(0);
                // TextViewの幅
                final float lineWidth = textView.getWidth();
                // スクロールする右端の座標
                // 以下のソースでは、
                // lineWidth/3 になっているのでView幅の 1/3 までテキストの右端がスクロールしたところでMarqueeが終了する
                final float gap = lineWidth / 3.0f;
//                mGhostStart = lineWidth - textWidth + gap;
//                mMaxScroll = mGhostStart + textWidth;
                mMaxScroll = textWidth - gap;
//                mGhostOffset = lineWidth + gap;
//                mFadeStop = lineWidth + textWidth / 6.0f;
//                mMaxFadeScroll = mGhostStart + lineWidth + lineWidth;
                textView.invalidate();
                sendEmptyMessageDelayed(MESSAGE_START, MARQUEE_DELAY);
            }
        }
        
        void refreshMarqueeParams(){
            TextView textView = mView.get();
            mStatus = MARQUEE_STARTING;
            mScroll = 0.0f;
            int textWidth = (int)textView.getLayout().getLineWidth(0);
            float lineWidth = textView.getWidth();
            float gap = lineWidth / 3.0f;
//            mGhostStart = lineWidth - textWidth + gap;
//            mMaxScroll = mGhostStart + textWidth;
            mMaxScroll = textWidth - gap;
//            mGhostOffset = lineWidth + gap;
//            mFadeStop = lineWidth + textWidth / 6.0f;
//            mMaxFadeScroll = mGhostStart + lineWidth + lineWidth;
        }

        // この辺のGhostXXXは役割がわからなかった。
//        float getGhostOffset() {
//            return mGhostOffset;
//        }
//
//        boolean shouldDrawLeftFade() {
//            return mScroll <= mFadeStop;
//        }
//
//        boolean shouldDrawGhost() {
//            return mStatus == MARQUEE_RUNNING && mScroll > mGhostStart;
//        }

        boolean isRunning() {
            return mStatus == MARQUEE_RUNNING;
        }

        boolean isStopped() {
            return mStatus == MARQUEE_STOPPED;
        }
        
        boolean isRetryed(){
            return mStatus == MARQUEE_RETRYING;
        }
        
        void setRepeatReverse(boolean setReverse){
            mRepeatReverse = setReverse;
        }
        
        void setRepeatInfy(boolean infy){
            mRepeatInfy = infy;
        }
        
        void setMarqueeSpeed(float marqueeSpeed){
            if(marqueeSpeed <= 0){
                final float density = mView.get().getContext().getResources().getDisplayMetrics().density;
                mScrollSpeed = (MARQUEE_PIXELS_PER_SECOND * density) / MARQUEE_RESOLUTION;
                return;
            }
            if(marqueeSpeed < 0.1){
                mScrollSpeed = 0.1f;
            }
            else {
                mScrollSpeed = marqueeSpeed;
            }
        }
    }

}


で、使用する場合はこんな感じ


MarqueeTextView mtext = (MarqueeTextView) view.findViewById(R.id.text);
mtext.setRepeatInfy(true);
mtext.startMarqueeIfTextWidthLarger();

上記はマーキー回数無制限でテキストがView幅より大きい場合マーキー動作が行われます。 注意しないといけないのが、onPauseされたときに止めないと裏で動き続けます。。。

あと、TextViewを継承しているので、 singleLine="true"、ellipsize="marquee"で使った方がいいです。
というか上記の設定以外で使ったことがない。。。

2013/3/30追記
そういえば上の使ってマーキー動かした状態でフォーカス与えて オリジナルのマーキー動作した場合ってどうなるんだ?
・・・そうだ、見なかったことにしよう。

久々の更新

結構長い間放置してたけど久々に更新。
もう半年くらい前になるけど、 google playにアプリを公開してみました。
https://play.google.com/store/apps/details?id=com.mfmf.playlisteditor
PlaylistEditor とかいうM3U形式のプレイリストを作成、編集するアプリです。
特徴はプレイリストを編集するときのソート機能が微妙に充実してるくらいです。 まぁ自分用に作成したアプリですが、よろしければお使いください。