アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

Android の ImageView をスクロールさせる

画像ビューアー系アプリなどでよくみる画面に収まらないサイズの画像をスクロールする方法について調べてみた。

  • 追記: 2011/11/24

    • アクセス解析を見るに、この記事はけっこう多くの方に読まれているようだ
    • しかし私として好ましいスクロール方法は続編記事のほうなので、そのリンクを掲載しておく
    • Android の ImageView をスクロールさせる 2

スクロール

Android には ScrollView という標準コントロールがある。これは入れ子にしたコントロールのサイズに応じて適切なスクロール機能を提供してくれる。ScrollView に ImageView を入れれば今回の目的を達成できそうだ。しかし ScrollView は縦スクロール専用なので横に大きな画像には対応できない。

横方向には HorizontalScrollView が用意されているのでこれと ScrollView を入れ子にすれば大丈夫そうだけど斜めスクロールが動作しない。縦横 2 軸を個別に組み合わせているため連携することができないのだ。

というわけで自前でスクロールを処理する方法を検討する。既に実装例があるかもしれないため「Android ImageView Scroll」といった感じの語句でググってみたら目的に近い記事を見つけた。

ここで cV2 氏が回答している内容を実装してみるとスワイプにより画像のスクロールがおこなえる。ただし移動範囲をチェックしていないため、どこまでもスクロールできてしまう。これは画面から画像がはみ出た分だけ移動できる方が自然なのでサンプルをベースに修正してゆく。

サンプル アプリ

まず仕様。

  • ImageView の表示モードは原寸 ScaleType.CENTER とフィット ScaleType.FIT_CENTER の 2 種類とする
  • 原寸モードの時だけ ImageView をスクロールさせる
  • スクロール範囲は画像が画面からはみ出た分とする
  • ImageView の上に表示モード切り替え用のコントロールを用意する
  • モードが表示されているバーをタップすると表示モードが切り替わる

画面のレイアウトは以下のように定義した。

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:background="#0094FF"
    android:gravity="center"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent">

    <!-- 画像 -->
    <ImageView
        android:id="@+id/image_view"
        android:src="@drawable/picture"
        android:scaleType="center"
        android:layout_centerInParent="true"
        android:layout_gravity="center"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent" />

    <!-- 表示モード切り替え -->
    <TextView
        android:id="@+id/display_mode"
        android:background="#88000000"
        android:textColor="#FFFFFF"
        android:textStyle="bold"
        android:textSize="18sp"
        android:shadowColor="#000000"
        android:shadowRadius="1.2"
        android:shadowDx="0"
        android:shadowDy="2"
        android:gravity="center"
        android:padding="4dp"
        android:layout_alignParentTop="true"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content" />
</RelativeLayout>

これに対する実装は以下のようになる。

/**
 * 画像のスクロールを試すための画面を表します。
 */
public class TestImageScrollActivity extends Activity implements OnClickListener, OnTouchListener {
    /**
     * 画像の表示モードが CENTER であることを示すテキスト。
     */
    private static final String DISPLAY_MODE_CENTER = "Center";

    /**
     * 画像の表示モードが FIT_CENTER であることを示すテキスト。
     */
    private static final String DISPLAY_MODE_FIT_CENTER = "Fit Center";

    /**
     * 画像の表示方法。
     */
    private ScaleType mImageScaleType = ScaleType.CENTER;

    /**
     * 画像が表示領域における、X 軸方向の一辺からはみ出る量。
     * 例えば画像の幅が 1080、表示領域は 480 の場合、「( 1080 - 480 ) / 2 = 300」とする。
     * 画像より表示領域が大きいならば、この値はゼロとなる。
     */
    private int mOverX;

    /**
     * 画像が表示領域における、Y 軸方向の一辺からはみ出る量。
     * 例えば画像の高さが 720、表示領域は 480 の場合、「( 720 - 480 ) / 2 = 120」とする。
     * 画像より表示領域が大きいならば、この値はゼロとなる。
     */
    private int mOverY;

    /**
     * 画像を表示するための View。
     */
    private ImageView mImageView;

    /**
     * 画像の表示モードとなるテキスト。
     */
    private TextView mDisplayModeTextView;

    /**
     * タッチの始点となる X 座標。
     */
    private float mTouchBeginX;

    /**
     * タッチの始点となる Y 座標。
     */
    private float mTouchBeginY;

    /**
     * 画面と画像のサイズを元に、一辺からはみ出る量を算出します。
     *
     * @param display 画面のサイズ。
     * @param image   画像のサイズ。
     *
     * @return 一辺からはみ出る量。画面に画像が収まる場合はゼロ。
     */
    private static int calcOverValue( int display, int image ) {
        return ( display < image ? ( image - display ) / 2 : 0 );
    }

    /**
     * スクロール量を算出します。
     *
     * @param move 移動する予定の量。
     * @param pos  現在のスクロール座標
     * @param over 画像が表示領域の一辺からはみ出る量。
     *
     * @return スクロール量。
     */
    private static int calcScrollValue( int move, int pos, int over ) {
        int newPos = pos + move;
        if( newPos < -over ) {
            move = -( over + pos );

        } else if( over < newPos ) {
            move = over - pos;
        }

        return move;
    }

    /**
     * 画面の設定が変更された時に発生します。
     *
     * @param newConfig 新しい設定。
     */
    @Override
    public void onConfigurationChanged( Configuration newConfig ) {
        super.onConfigurationChanged( newConfig );

        this.updateOverSize();
        this.mImageView.scrollTo( 0, 0 );
    }

    /**
     * 画面が作成された時に発生します。
     *
     * @param savedInstanceState 保存されたインスタンスの状態。
     */
    @Override
    public void onCreate( Bundle savedInstanceState ) {
        super.onCreate( savedInstanceState );
        this.setContentView( R.layout.main );

        this.mDisplayModeTextView = ( TextView )this.findViewById( R.id.display_mode );
        this.mDisplayModeTextView.setText( DISPLAY_MODE_CENTER );
        this.mDisplayModeTextView.setOnClickListener( this );

        this.mImageView = ( ImageView )this.findViewById( R.id.image_view );
        this.mImageView.setScaleType( this.mImageScaleType );
        this.mImageView.setOnTouchListener( this );

        this.updateOverSize();
    }

    /**
     * View がクリックされた時に発生します。
     *
     * @param v クリックされた View。
     */
    public void onClick( View v ) {
        if( this.mImageScaleType == ScaleType.CENTER ) {
            this.mImageScaleType = ScaleType.FIT_CENTER;
            this.mDisplayModeTextView.setText( DISPLAY_MODE_FIT_CENTER );

        } else {
            this.mImageScaleType = ScaleType.CENTER;
            this.mDisplayModeTextView.setText( DISPLAY_MODE_CENTER );
        }

        this.mImageView.setScaleType( this.mImageScaleType );
        this.mImageView.scrollTo( 0, 0 );
    }

    /**
     * View がタッチされた時に発生します。
     *
     * @param v     タッチされた View。
     * @param event イベント データ。
     *
     * @return タッチ操作を他の View へ伝搬しないなら true。する場合は false。
     */
    public boolean onTouch( View v, MotionEvent event ) {
        if( this.mImageScaleType == ScaleType.FIT_CENTER ) { return false; }

        switch( event.getAction() ) {
        case MotionEvent.ACTION_DOWN:
            this.mTouchBeginX = event.getX();
            this.mTouchBeginY = event.getY();
            break;

        case MotionEvent.ACTION_MOVE:
            float x = event.getX(), y = event.getY();
            this.scrollImage( x, y );

            this.mTouchBeginX = x;
            this.mTouchBeginY = y;
            break;

        case MotionEvent.ACTION_UP:
            this.scrollImage( event.getX(), event.getY() );
            break;
        }

        return true;
    }

    /**
     * 画像をスクロールさせます。
     *
     * @param x 移動先の基準となる画面内の X 軸の座標。
     * @param y 移動先の基準となる画面内の Y 軸の座標。
     */
    private void scrollImage( float x, float y ) {
        int moveX = ( this.mOverX == 0 ? 0 : calcScrollValue( ( int )( this.mTouchBeginX - x ), this.mImageView.getScrollX(), this.mOverX ) );
        int moveY = ( this.mOverY == 0 ? 0 : calcScrollValue( ( int )( this.mTouchBeginY - y ), this.mImageView.getScrollY(), this.mOverY ) );
        this.mImageView.scrollBy( moveX, moveY );
    }

    /**
     * 画像と表示領域を比較し、はみ出る量を算出します。
     */
    private void updateOverSize() {
        Display  display = ( ( WindowManager )this.getSystemService( Context.WINDOW_SERVICE ) ).getDefaultDisplay();
        Drawable image   = this.mImageView.getDrawable();

        this.mOverX = calcOverValue( display.getWidth(),  image.getIntrinsicWidth()  );
        this.mOverY = calcOverValue( display.getHeight(), image.getIntrinsicHeight() );
    }
}

画面と画像のサイズを比較してはみ出た分を記録するために updateOverSize メソッドを定義しておく。これを呼び出すことで縦横にはみ出たサイズが更新される。

次に ImageView スクロールだが、原寸表示を ScaleType.CENTER としているため初期状態のスクロール座標は X = 0Y = 0 となる。この数値を加算したら右と下、減算の場合は左と上へ画像が移動される。この範囲をはみ出た分量にすれば仕様どおりの動きとなる。

スクロール操作は画像のスワイプによっておこないたいので ImageView に関連づけた onTouch イベントでタッチ操作の分量を記録、都度 scrollImage メソッドを呼び出す。画面から画像がはみ出ているなら上限を超えた移動量を算出して ImageView.scrollBy によってスクロールさせる。

scrollByView に定義されたメソッドで現在のスクロール座標から指定された量だけ位置を動かす。View のスクロールは scrollByscrollTo の 2 種類の方法が用意されている。今回使用する前者は相対値、後者は絶対値で位置を指定する。これらを組み合わせたサンプル アプリを実行すると以下のようになる。

サンプル アプリ

この例では 800x480 の画面に 1200x900 の画像を表示、スワイプ操作によってスクロール位置をずらしている。最後にサンプル アプリのプロジェクトを公開しておく。

Android 2.1 update 1 (API Level 7) でビルド、エミュレータと初代 Xperia (SO-01B) にて動作確認した。

以下、余談。

サンプルに含んでいる画像は川崎市立日本民家園で私が撮影した写真をリサイズしたもの。この施設には日本各地の貴重な家屋が移設され、それらに触れることができるという家屋マニアにはたまらない場所である。

近くに岡本太郎美術館もあるので両方まわると一日たっぷり楽しめる。日本民家園の中には食堂もあり、ここもまた貴重な家屋内となっている。写真を撮影した日はくらくらするような暑さだったため、注文したとろろ蕎麦がやけにおいしく感じたのを思い出した。