EU向けの同意バナーを停止しました
【Androidアプリ開発】ステップバイステップで学ぶ 第一回:図形描画編

【Androidアプリ開発】ステップバイステップで学ぶ 第一回:図形描画編

2020年11月24日

こんにちは、コンテンツクリエイターのともすけです。

(ごめんなさいね、続きがなくて。こういうのを企画倒れといいます…。でも、そのうち続きを作りたいです)

「ステップバイステップで学ぶ」シリーズを始めます。このシリーズは、いきなりギュッと詰め込んだ話をしない(つもり)で、第一段階、第二段階…と順に進めていき、試しながら理解度を深めていただこうというシリーズです。

コードはコピペで動きます。各ステップのコードは「単体」で動きます。ただし、以下はご自分で対応してください。

  • Android Studioで、適当なプロジェクトを自分で作成する
  • 作成したら、ひな形のMainActivity.javaが生成されているはずなので、その「1行目」以外を削除して以下に掲載したコードを貼り付ける
  • Runボタンを押して、AVD(Android Virtual Device)もしくは自前のAndroid端末にインストールする

↑の内容がわからない方は、この記事ではサポートできませんが、別記事で説明できれば良いなぁと思っています。

以下の記事の元は、ぼくがGoogleサイトで公開しているモノなのですが、コードが見づらいので修正して更新しようとすると、Googleが「変更差分がない」と勘違いして上げられなくて、仕方なく少しずつこちらへ引っ越そうと思います。

図形描画について

知っている範囲では、描画には2通りあります。

  • 1. Viewクラスを使う方法
  • 2. SurfaceViewクラスを使う方法

高速な描画処理を行いたい場合は2番を、それ以外は1番を使うのがいいようです。今回はお絵かきをターゲットとしているので、1番のViewクラスを使います。

ステップ1.Viewの基本

Viewは、文字を書いたり、線を書いたり、塗りつぶしたりすることのできるオブジェクトです。Viewクラスを継承したclassを定義(ここではCustomView)して

  • 1. コンストラクタ(※1)の中で背景色(ここでは白)を定義
  • 2. onDrawメソッドをオーバーライドする記述の中で、書き込み情報格納オブジェクト(paint)を用意
  • 3. そこに属性(アンチエイリアス、文字の大きさ、色)をセット
  • 4. canvasに書き込みます

以下のコード例では、

  • テキスト
  • 直線
  • 四角形(塗りつぶし)

を描画しています。setStylesetColorは、同じであれば再設定せずにcanvasへ書き込みできます。

import androidx.appcompat.app.AppCompatActivity;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.os.Bundle;
import android.view.View;
import android.view.Window;

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        setContentView(new CustomView(this));
    }

    class CustomView extends View {
        public CustomView(Context context) {
            super(context);
            setBackgroundColor(Color.WHITE);
        }
        @Override
        protected void onDraw(Canvas canvas){
            Paint paint = new Paint();
            // テキスト書き込み
            paint.setAntiAlias(true);
            paint.setTextSize(24);
            paint.setColor(Color.rgb(0, 0, 0));
            canvas.drawText("どろーてきすと", 0, 60, paint);
            // ラインの書き込み
            paint.setStrokeWidth(1);
            paint.setStyle(Paint.Style.STROKE);
            paint.setColor(Color.BLUE);
            canvas.drawLine(0, 100, 100, 200, paint);
            // 四角形の描画(塗りつぶし)
            paint.setStyle(Paint.Style.FILL);
            paint.setColor(Color.GREEN);
            canvas.drawRect(0, 200, 100, 300, paint);
            // 円の描画(線)
            paint.setStyle(Paint.Style.STROKE);
            paint.setColor(Color.RED);
            canvas.drawCircle(50, 350, 50, paint);
        }
    }
}

ステップ2.タッチ操作で書き込み

ではさっそく、タッチ操作で書き込みしてみたいと思います。画面をタッチすると、その座標を取得できます。1座標だけなら上記例の通り、drawTextやdrawCircleで書けます。やってみましょう。

  • class Pointerは、画面をタッチすると呼び出される onTouchEvent メソッドの中で、タッチした座標を保持して onDraw 側に渡すためのクラスです。座標情報は浮動小数で受け取るので、クラスのメンバも float です。
  • onTouchEventメソッドでは、MotionEventから「指を動かした」条件でのみ座標を取り出しています。returnする前にinvalidate()しているのは、invalidate()を叩くとonDrawが呼び出されるからです。invalidate()してあげることで、意図的にAndroidに画面を更新させています。
  • このコードを実行するとわかるのですが、小さな丸が動くけれども「軌跡」が残りません。これは、onDraw時に画面をイチから更新するためです。前の画面を上書きしてくれません。
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.os.Bundle;
import android.view.MotionEvent;
import android.view.View;
import android.view.Window;

import androidx.appcompat.app.AppCompatActivity;

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        setContentView(new CustomView(this));
    }
    class Pointer {
        float x,y;
        public Pointer(float x, float y){
            this.x = x;
            this.y = y;
        }
    }
    class CustomView extends View {
        protected Pointer pointer = null;
        public CustomView(Context context) {
            super(context);
            setBackgroundColor(Color.WHITE);
        }
        @Override
        protected void onDraw(Canvas canvas){
            Paint paint = new Paint();
            // 円の描画(線)
            paint.setStyle(Paint.Style.STROKE);
            paint.setColor(Color.RED);
            if(pointer!=null){
                canvas.drawCircle(pointer.x, pointer.y, 30, paint);
            }
        }
        @Override
        public boolean onTouchEvent(MotionEvent event){
            int action = event.getAction();
            switch(action & MotionEvent.ACTION_MASK){
                case MotionEvent.ACTION_MOVE:
                    pointer = new Pointer(event.getX(), event.getY());
            }
            invalidate();
            return true;
        }
    }
}

ステップ3.軌跡を残す

軌跡を残すには、座標情報をすべて保持して、常にすべて再描画する方法があります。ちょっとしたお絵かきなら、パフォーマンス的な問題をほとんど感じません。ですが、いっぱいいっぱい描いてあげると、座標情報が多すぎるため、処理がもたつき始めます。この「もたつく」ところは面白いので、実現してみようと思います。

変更点ですが、

  • すこしだけパフォーマンスを考慮して、Paintオブジェクトの初期化処理を最小限にしました。(1)
  • 座標情報を保持するため、ArrayListを用意しました。(2)
    • onDraw内で、ArrayListを展開するためにfor文を入れました
    • onTouchEventでは、ArrayListに座標を追加する処理にしています
  • 画面更新処理(invalidate)を、ACTION_MOVEのときだけにしました。(3)
    • 前のコードでは、画面を触ったら必ずinvalidateしていたので、丸の軌跡は全く残りませんでしたが、このinvalidateの位置を変えるだけで、前のコードでも「画面を触っている間」だけなら軌跡が残ります

画面に軌跡が保持されますが、何度も軌跡を描いていると、次第に丸の表示速度が遅くなることがわかると思います。逆に言えば、数百個程度の座標であれば瞬時に描画してしまうパワーをAndroid端末は持っていることもわかると思います。(端末によって差異があります)

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.os.Bundle;
import android.view.MotionEvent;
import android.view.View;
import android.view.Window;

import androidx.appcompat.app.AppCompatActivity;

import java.util.ArrayList;

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        setContentView(new CustomView(this));
    }

    class Pointer {
        float x, y;
        public Pointer(float x, float y) {
            this.x = x;
            this.y = y;
        }
    }

    class CustomView extends View {
        protected ArrayList<Pointer> pointerList = null;  //(2)
        Paint paint = null;  //(1)

        public CustomView(Context context) {
            super(context);
            setBackgroundColor(Color.WHITE);
            pointerList = new ArrayList<Pointer>();  //(2)
            paint = new Paint();  //(1)
            // 円の描画(線)
            paint.setStyle(Paint.Style.STROKE);  //(1)
            paint.setColor(Color.RED);  //(1)
        }

        @Override
        protected void onDraw(Canvas canvas) {
            if (pointerList != null) {
                for (Pointer pointer : pointerList) {  //(2)
                    canvas.drawCircle(pointer.x, pointer.y, 30, paint);
                }
            }
        }

        @Override
        public boolean onTouchEvent(MotionEvent event) {
            int action = event.getAction();
            switch (action & MotionEvent.ACTION_MASK) {
                case MotionEvent.ACTION_MOVE:
                    pointerList.add(new Pointer(event.getX(), event.getY()));  //(2)
                    invalidate(); //(3)
            }
            return true;
        }
    }
}

ステップ4.描画処理を軽減する

ステップ3では、ポインターの情報を配列に格納して、それを「毎回はじめから」描き直しています。Undo実装なら、まあかなり目をつむって「アリ」なのかもしれませんが、とりあえずお絵かきするにはかなり冗長な処理です。ここでは処理方法を変えたいと思います。

これまでの考え方は、

  • onTouchEventを拾って、座標情報を配列に入れて、画面更新(invalidate)
  • invalidateで呼び出されたonDrawで、配列の全座標情報を描画

でした。

で、今回は以下のようになります。

  • 仮空間に描画用キャンバスを用意
  • このキャンバスにonTouchEventで描画する。このキャンバスは、勝手にクリアはされません(ここ大事)
  • 端末の表示用キャンバスに、上で描いたキャンバス情報を描画する

今回も、onTouchEventごとに画面を更新していきますが、ステップ3の「全配列描画」と異なり、描画にかかる処理負荷は一定になります。毎回「仮空間のキャンバスを、端末の表示用キャンバスに書き写す」わけですから。

変更箇所としては

  • class Pointerを使うのをやめたので、これに付随する箇所はすべてコメントアウトしています
  • ①仮空間にキャンバスを用意します。画面が開かれた最初だけ呼ばれるメソッド onSizeChanged を使って
    • ビットマップを生成して(bmp)
    • それをキャンバスにセットします
  • MotionsEvent発生時に、①のキャンバスに丸を描きます
  • ②画面描画時に、①のキャンバスで描いていた内容を、画面表示用キャンバスに描き写します
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.os.Bundle;
import android.view.MotionEvent;
import android.view.View;
import android.view.Window;

import androidx.appcompat.app.AppCompatActivity;

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        setContentView(new CustomView(this));
    }
    /*class Pointer {                        ここが不要になって
    float x,y;
    public Pointer(float x, float y){
    this.x = x;
    this.y = y;
    }
    }*/
    class CustomView extends View {
        //protected ArrayList<Pointer> pointerList = null;      これも不要になって
        Paint paint = null;
        Bitmap bmp;
        Canvas bmpCanvas;
        public CustomView(Context context) {
            super(context);
            setBackgroundColor(Color.WHITE);
            //pointerList = new ArrayList<Pointer>();         これも不要になって
            paint = new Paint();
            // 円の描画(線)
            paint.setStyle(Paint.Style.STROKE);
            paint.setColor(Color.RED);
        }
        @Override
        protected void onSizeChanged(int w, int h, int oldw, int oldh){                 //①
            super.onSizeChanged(w, h, oldw, oldh);
            bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
            bmpCanvas = new Canvas(bmp);
        }
        @Override
        protected void onDraw(Canvas canvas){
            /*if(pointerList!=null){          これも不要になって
            for(Pointer pointer : pointerList){
            canvas.drawCircle(pointer.x, pointer.y, 5, paint);
            }
            }*/
            canvas.drawBitmap(bmp, 0, 0, null);            //②
        }
        @Override
        public boolean onTouchEvent(MotionEvent event){
            int action = event.getAction();
            switch(action & MotionEvent.ACTION_MASK){
                case MotionEvent.ACTION_MOVE:
                    //pointerList.add(new Pointer(event.getX(), event.getY()));      これも不要に
                    bmpCanvas.drawCircle(event.getX(), event.getY(), 30, paint);        //③
                    invalidate();
            }
            return true;
        }
    }
}

おまけ:上記4つのモードを切り替えて動かせるコード

説明しませんが、よかったらお試しでコピペして遊んでみてください。どんな風に動くものか、動画も撮りましたので合わせてみてみてください。

import androidx.appcompat.app.AppCompatActivity;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.os.Bundle;
import android.view.MotionEvent;
import android.view.View;
import android.view.Window;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.TextView;

import java.util.ArrayList;

public class MainActivity extends AppCompatActivity {
    private final int WC = LinearLayout.LayoutParams.WRAP_CONTENT;
    private final int MP = LinearLayout.LayoutParams.MATCH_PARENT;

    private TextView selectViewMsg = null;
    private LinearLayout viewLayout = null;
    private Custom0View cv0 = null;
    private Custom1View cv1 = null;
    private Custom2View cv2 = null;
    private Custom3View cv3 = null;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(Window.FEATURE_NO_TITLE);

        LinearLayout baseLayout = new LinearLayout(this);
        baseLayout.setOrientation(LinearLayout.VERTICAL);

        selectViewMsg = new TextView(this);
        selectViewMsg.setText("指定なし");
        selectViewMsg.setLayoutParams(new LinearLayout.LayoutParams(MP, WC));

        LinearLayout upperLayout = new LinearLayout(this);
        upperLayout.setOrientation(LinearLayout.HORIZONTAL);
        upperLayout.addView(genButton("ステップ1", 0));
        upperLayout.addView(genButton("ステップ2", 1));
        upperLayout.addView(genButton("ステップ3", 2));
        upperLayout.addView(genButton("ステップ4", 3));

        viewLayout = new LinearLayout(this);
        cv0 = new Custom0View(this);
        cv1 = new Custom1View(this);
        cv2 = new Custom2View(this);
        cv3 = new Custom3View(this);

        baseLayout.addView(selectViewMsg);
        baseLayout.addView(upperLayout);
        baseLayout.addView(viewLayout);
        setContentView(baseLayout);
    }

    class Custom0View extends View {
        public Custom0View(Context context) {
            super(context);
            setBackgroundColor(Color.WHITE);
        }
        @Override
        protected void onDraw(Canvas canvas){
            Paint paint = new Paint();
            // テキスト書き込み
            paint.setAntiAlias(true);
            paint.setTextSize(72);
            paint.setColor(Color.rgb(0, 0, 0));
            canvas.drawText("どろーてきすと", 0, 60, paint);
            // ラインの書き込み
            paint.setStrokeWidth(1);
            paint.setStyle(Paint.Style.STROKE);
            paint.setColor(Color.BLUE);
            canvas.drawLine(0, 100, 500, 600, paint);
            // 四角形の描画(塗りつぶし)
            paint.setStyle(Paint.Style.FILL);
            paint.setColor(Color.GREEN);
            canvas.drawRect(0, 200, 300, 500, paint);
            // 円の描画(線)
            paint.setStyle(Paint.Style.STROKE);
            paint.setColor(Color.RED);
            canvas.drawCircle(550, 350, 200, paint);
        }
    }

    class Pointer {
        float x,y;
        public Pointer(float x, float y){
            this.x = x;
            this.y = y;
        }
    }
    class Custom1View extends View {
        protected Pointer pointer = null;

        public Custom1View(Context context) {
            super(context);
            setBackgroundColor(Color.WHITE);
        }

        @Override
        protected void onDraw(Canvas canvas) {
            Paint paint = new Paint();
            // 円の描画(線)
            paint.setStyle(Paint.Style.STROKE);
            paint.setColor(Color.RED);
            if (pointer != null) {
                canvas.drawCircle(pointer.x, pointer.y, 30, paint);
            }
        }

        @Override
        public boolean onTouchEvent(MotionEvent event) {
            int action = event.getAction();
            switch (action & MotionEvent.ACTION_MASK) {
                case MotionEvent.ACTION_MOVE:
                    pointer = new Pointer(event.getX(), event.getY());
            }
            invalidate();
            return true;
        }
    }

    class Custom2View extends View {
        protected ArrayList<Pointer> pointerList = null;  //(2)
        Paint paint = null;  //(1)

        public Custom2View(Context context) {
            super(context);
            setBackgroundColor(Color.WHITE);
            pointerList = new ArrayList<Pointer>();  //(2)
            paint = new Paint();  //(1)
            // 円の描画(線)
            paint.setStyle(Paint.Style.STROKE);  //(1)
            paint.setColor(Color.RED);  //(1)
        }

        @Override
        protected void onDraw(Canvas canvas) {
            if (pointerList != null) {
                for (Pointer pointer : pointerList) {  //(2)
                    canvas.drawCircle(pointer.x, pointer.y, 30, paint);
                }
            }
        }

        @Override
        public boolean onTouchEvent(MotionEvent event) {
            int action = event.getAction();
            switch (action & MotionEvent.ACTION_MASK) {
                case MotionEvent.ACTION_MOVE:
                    pointerList.add(new Pointer(event.getX(), event.getY()));  //(2)
                    invalidate(); //(3)
            }
            return true;
        }
    }

    class Custom3View extends View {
        //protected ArrayList<Pointer> pointerList = null;      これも不要になって
        Paint paint = null;
        Bitmap bmp;
        Canvas bmpCanvas;
        public Custom3View(Context context) {
            super(context);
            setBackgroundColor(Color.WHITE);
            //pointerList = new ArrayList<Pointer>();         これも不要になって
            paint = new Paint();
            // 円の描画(線)
            paint.setStyle(Paint.Style.STROKE);
            paint.setColor(Color.RED);
        }
        @Override
        protected void onSizeChanged(int w, int h, int oldw, int oldh){                 //①
            super.onSizeChanged(w, h, oldw, oldh);
            bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
            bmpCanvas = new Canvas(bmp);
        }
        @Override
        protected void onDraw(Canvas canvas){
            /*if(pointerList!=null){          これも不要になって
            for(Pointer pointer : pointerList){
            canvas.drawCircle(pointer.x, pointer.y, 5, paint);
            }
            }*/
            canvas.drawBitmap(bmp, 0, 0, null);            //②
        }
        @Override
        public boolean onTouchEvent(MotionEvent event){
            int action = event.getAction();
            switch(action & MotionEvent.ACTION_MASK){
                case MotionEvent.ACTION_MOVE:
                    //pointerList.add(new Pointer(event.getX(), event.getY()));      これも不要に
                    bmpCanvas.drawCircle(event.getX(), event.getY(), 30, paint);        //③
                    invalidate();
            }
            return true;
        }
    }

    private Button genButton(String msg, int id){
        Button button = new Button(this);
        button.setText(msg);
        button.setLayoutParams(new LinearLayout.LayoutParams(0, WC, 1));
        button.setTag(id);
        button.setOnClickListener(new ButtonClickListener());
        return button;
    }

    private class ButtonClickListener implements View.OnClickListener {
        @Override
        public void onClick(View v) {
            viewLayout.removeAllViews();
            switch ((Integer) v.getTag()){
                case 0:
                    viewLayout.addView(cv0);
                    selectViewMsg.setText("ステップ1");
                    break;
                case 1:
                    viewLayout.addView(cv1);
                    selectViewMsg.setText("ステップ2");
                    break;
                case 2:
                    viewLayout.addView(cv2);
                    selectViewMsg.setText("ステップ3");
                    break;
                case 3:
                    viewLayout.addView(cv3);
                    selectViewMsg.setText("ステップ4");
                    break;
                default :
                    break;
            }
        }
    }
}

以上になります。ここに示したサンプルは最適なコードではありませんが、こうやってコードを書いたら絵を描けるんだね、じゃあブラシとか自分で作ってみて、それを描くのに使えたりするのかな?とかいう試行錯誤を通してプログラミングのスキルアップに繋がればと思います。

それではまた。