SeekBarを題材にAOSPのソースコードを読んでみる

本投稿は TECOTEC Advent Calendar 2022 の19日目の記事です。

証券フロンティア事業部の割子田です。2022年の4月に新卒として入社し、Androidアプリの開発を主に行っております。

最近、Androidのアプリ開発でシークバーの部品を使う機会がありました。動画の再生位置を調整するときによく使われるあれです。

Androidではandroid.widgetパッケージにSeekBarというウィジェット部品があるのでそれを使って開発を行ったのですが、開発者向けドキュメントを読んだ際に、SeekBarクラスがProgressBarクラスを継承しているという点が目に入りました。

プログレスバーは処理の進捗度合いを表すときによく見るものですね。たしかに、プログレスバーを拡張すればシークバーも実現できそうです。

具体的にどのように実装されているのかが気になったので、そのソースコードを読んでみることにしました。

この記事はその過程のメモ的な記事になります。

Android Open Source Project (AOSP) とは

Androidはオープンソースですので、誰でもソースコードを見ることができます。 Android Open Source Project(AOSP)と呼ばれています。ソースコードのライセンスはApache License Version 2.0です。

ソースコードを読む手段はいくつかありますが、今回はGoogle GitというGoogleのGitリポジトリ上からソースコードを見ることにします。

SeekBarクラスとProgressBarクラスについて

android.widgetパッケージのSeekBarクラスはAbsSeekBarというクラスを継承していて、それがさらにProgressBarクラスを継承しています。

ちなみになぜAbsSeekBarクラスを挟んでいるのかという点ですが、SeekBarの他に、RatingBarというグルメサイトの星とかに使われそうな部品もAbsSeekBarクラスを継承しているためだと思われます。

今回は一番重要なAbsSeekBar.javaの実装を見てみます。

android.googlesource.com

今回読むのはIOや難しいグラフィック処理等の部分ではなく、Viewウィジェットという高レイヤで表層的、かつ既存のものを拡張したクラスなので、OSやハードウェアに関する深い知識がなくても追えそうです。

コードを読む前にプログレスバーとシークバーの違いを考えてみる

一般論として、プログレスバーとシークバーの違いとして思いつくのが次の2点でしょうか。

  • プログレスバーはタッチで操作できないが、シークバーはタッチでユーザーが操作できる
  • プログレスバーは●が必要ないが、シークバーは●がある(●のことをAndroidではthumbと呼んでいる)

この2点に注目しながらソースコードを追ってみたいと思います。

実際にソースコードを読む

  • フィールドを眺める

まずフィールド回りを眺めてみます。

    // AbsSeekBar.java
    private final Rect mTempRect = new Rect();
    @UnsupportedAppUsage
    private Drawable mThumb;
    private ColorStateList mThumbTintList = null;
    private BlendMode mThumbBlendMode = null;
    private boolean mHasThumbTint = false;
    private boolean mHasThumbBlendMode = false;
    private Drawable mTickMark;
    private ColorStateList mTickMarkTintList = null;
    private BlendMode mTickMarkBlendMode = null;
    private boolean mHasTickMarkTint = false;
    private boolean mHasTickMarkBlendMode = false;
    private int mThumbOffset;
    @UnsupportedAppUsage
    private boolean mSplitTrack;
    /**
     * On touch, this offset plus the scaled value from the position of the
     * touch will form the progress value. Usually 0.
     */
    @UnsupportedAppUsage
    float mTouchProgressOffset;
    /**
     * Whether this is user seekable.
     */
    @UnsupportedAppUsage
    boolean mIsUserSeekable = true;
    /**
     * On key presses (right or left), the amount to increment/decrement the
     * progress.
     */
    private int mKeyProgressIncrement = 1;
    private static final int NO_ALPHA = 0xFF;
    @UnsupportedAppUsage
    private float mDisabledAlpha;
    private int mThumbExclusionMaxSize;
    private int mScaledTouchSlop;
    private float mTouchDownX;
    @UnsupportedAppUsage
    private boolean mIsDragging;
    private float mTouchThumbOffset = 0.0f;
    private List<Rect> mUserGestureExclusionRects = Collections.emptyList();
    private final List<Rect> mGestureExclusionRects = new ArrayList<>();
    private final Rect mThumbRect = new Rect();

mTempRectやthumb、mThumbRectがシークバーの●に使われていそうです。

mIsUserSeekableはシークの可否、mIsDraggingはドラッグの判定に使われていそうですね。

ちなみに@UnsupportedAppUsageというアノテーションですが、Android 9(API レベル 28)以降に追加されたもので、非SDKインターフェースの制限というものを意味するようです。リフレクションやJNIを使って対象のフィールドやメソッドに直接アクセスしていなければ問題ありません。

  • シークバーの●について

続いてシークバーの●(thumb)について見てみます。

        // AbsSeekBar.java
        final TypedArray a = context.obtainStyledAttributes(
                attrs, R.styleable.SeekBar, defStyleAttr, defStyleRes);
        saveAttributeDataForStyleable(context, R.styleable.SeekBar, attrs, a, defStyleAttr,
                defStyleRes);
        final Drawable thumb = a.getDrawable(R.styleable.SeekBar_thumb);
        setThumb(thumb);

AbsSeekBarのコンストラクタでthumbとして使うdrawableを設定しています。 TypedArrayとしてXMLの属性値を取り出し、そこからthumbのdrawableを取り出しているようです。

    // AbsSeekBar.java
    @Override
    void onVisualProgressChanged(int id, float scale) {
        super.onVisualProgressChanged(id, scale);
        if (id == R.id.progress) {
            final Drawable thumb = mThumb;
            if (thumb != null) {
                setThumbPos(getWidth(), thumb, scale, Integer.MIN_VALUE);
                // Since we draw translated, the drawable's bounds that it signals
                // for invalidation won't be the actual bounds we want invalidated,
                // so just invalidate this whole view.
                invalidate();
            }
        }
    }

継承元のProgressBarクラスからonVisualProgressChanged()メソッドをオーバーライドしています。 progressの値が変わったタイミングでthumbの位置も変えているのがわかります。

// AbsSeekBar.java
private void setThumbPos(int w, Drawable thumb, float scale, int offset) {
        int available = w - mPaddingLeft - mPaddingRight;
        final int thumbWidth = thumb.getIntrinsicWidth();
        final int thumbHeight = thumb.getIntrinsicHeight();
        available -= thumbWidth;
        // The extra space for the thumb to move on the track
        available += mThumbOffset * 2;
        final int thumbPos = (int) (scale * available + 0.5f);

setThumbPos()メソッドでは、シークバーの幅、padding、scale(進捗率)から座標を求めていました。

ここまでがシークバーの●(thumb)に関する処理です。

  • タッチ操作について

次にProgressBarにはないタッチ操作について見ていきます。

    // AbsSeekBar.java
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (!mIsUserSeekable || !isEnabled()) {
            return false;
        }
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (mThumb != null) {
                    final int availableWidth = getWidth() - mPaddingLeft - mPaddingRight;
                    mTouchThumbOffset = (getProgress() - getMin()) / (float) (getMax()
                        - getMin()) - (event.getX() - mPaddingLeft) / availableWidth;
                    if (Math.abs(mTouchThumbOffset * availableWidth) > getThumbOffset()) {
                        mTouchThumbOffset = 0;
                    }
                }
                if (isInScrollingContainer()) {
                    mTouchDownX = event.getX();
                } else {
                    startDrag(event);
                }
                break;
            case MotionEvent.ACTION_MOVE:
                if (mIsDragging) {
                    trackTouchEvent(event);
                } else {
                    final float x = event.getX();
                    if (Math.abs(x - mTouchDownX) > mScaledTouchSlop) {
                        startDrag(event);
                    }
                }
                break;
            case MotionEvent.ACTION_UP:
                if (mIsDragging) {
                    trackTouchEvent(event);
                    onStopTrackingTouch();
                    setPressed(false);
                } else {
                    // Touch up when we never crossed the touch slop threshold should
                    // be interpreted as a tap-seek to that location.
                    onStartTrackingTouch();
                    trackTouchEvent(event);
                    onStopTrackingTouch();
                }
                // ProgressBar doesn't know to repaint the thumb drawable
                // in its inactive state when the touch stops (because the
                // value has not apparently changed)
                invalidate();
                break;
            case MotionEvent.ACTION_CANCEL:
                if (mIsDragging) {
                    onStopTrackingTouch();
                    setPressed(false);
                }
                invalidate(); // see above explanation
                break;
        }
        return true;
    }

onTouchEvent()メソッドをオーバーライドしてタッチイベントの種類ごとにSwitch文で分岐させています。

想像していたよりもシンプルでした!

mIsDraggingでドラッグ中か否かを管理して、trackTouchEvent()メソッドを呼んでいます。

    // AbsSeekBar.java
    private void trackTouchEvent(MotionEvent event) {
        final int x = Math.round(event.getX());
        final int y = Math.round(event.getY());
        final int width = getWidth();
        final int availableWidth = width - mPaddingLeft - mPaddingRight;
        final float scale;
        float progress = 0.0f;
        if (isLayoutRtl() && mMirrorForRtl) {
            if (x > width - mPaddingRight) {
                scale = 0.0f;
            } else if (x < mPaddingLeft) {
                scale = 1.0f;
            } else {
                scale = (availableWidth - x + mPaddingLeft) / (float) availableWidth
                    + mTouchThumbOffset;
                progress = mTouchProgressOffset;
            }
        } else {
            if (x < mPaddingLeft) {
                scale = 0.0f;
            } else if (x > width - mPaddingRight) {
                scale = 1.0f;
            } else {
                scale = (x - mPaddingLeft) / (float) availableWidth + mTouchThumbOffset;
                progress = mTouchProgressOffset;
            }
        }
        final int range = getMax() - getMin();
        progress += scale * range + getMin();
        setHotspot(x, y);
        setProgressInternal(Math.round(progress), true, false);
    }

trackTouchEvent()メソッド内でタッチイベントの座標やシークバーの座標から進捗率を算出し、setProgressInternal()メソッドを呼んでいます。

setProgressInternal()メソッドはProgressBarクラスに実装されているもので、実際に進捗率を変更します。

    // ProgressBar.java
    synchronized boolean setProgressInternal(int progress, boolean fromUser, boolean animate) {
        if (mIndeterminate) {
            // Not applicable.
            return false;
        }
        progress = MathUtils.constrain(progress, mMin, mMax);
        if (progress == mProgress) {
            // No change from current.
            return false;
        }
        mProgress = progress;
        refreshProgress(R.id.progress, mProgress, fromUser, animate);
        return true;
    }

感想

想像していたよりもシンプルな実装でした。

最初に予想したプログレスバーとシークバーの2点の違いについて、予想通りの機能が追加実装されているのが確認できました。

今後、継承関係の設計をするときやオリジナルのシークバーを実装するときに役立ちそうですね。アプリを開発する時に、その土台となるOSのソースコードを読むことができるのはありがたいです。

今回はViewのウィジェットという最も表層にあり、かつ既存のものを拡張したクラスだったためかなり理解しやすいものでした。

今後はもっと難しい部分のAOSPコードリーディングにもチャレンジしていきたいと思います。