[AndroidのViewを制する] ズーム、ドラッグアンドドロップを完全に理解する

## この記事の目標
ズームイン・ズームアウト・ドラッグアンドドロップのを実装方法を学びます。またView周りのイベントを実装する際に、個人的によく使う実装方法や、考え方を共有できればと思います。
実際に簡単なアプリを作りながら学んでいきましょう。

ソースコードは[GitHub](https://github.com/kosuke-nakazono/ZoomDragAndDrop)に上げております。

### 大前提
[OnDragListener](https://developer.android.com/reference/android/view/View.OnDragListener)を使用する事で、ドラッグアンドドロップは簡単に実装できるでしょう。
しかしView周りのイベントを実装する際の、より汎用性のある実装方法や考え方を身につけるため、今回は[onTouchEvent](https://developer.android.com/training/graphics/opengl/touch)を使用してドラッグアンドドロップを実装します。

## アプリの完成形

![2021-08-29_16_18_20.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1830512/4c7aecaf-ac0a-968c-2e4a-ab42f6509e61.gif)

## カスタムViewクラスを作成する

カスタムViewクラスを作成する、つまりViewクラスを継承したクラスを作成する事で、Viewの挙動を細かく設定できるようになります。
やり方は簡単です。
まずはViewクラスを継承します。

```kotlin:CustomView
class CustomView: View {
```

次にコンストラクタを作成します。

カスタムView作成時は最大で4つのコンストラクタを使用します。
カスタムViewクラスがインスタンス化される場面によって、使用されるコンストラクタが変わります。
よく使われるのは上二つのコンストラクタです。

```kotlin:CustomView
// コードから(kt/javaファイルから)インスタンス化される際に呼ばれる
 constructor(context: Context) : super(context) 
// レイアウトファイルからインスタンス化される際に呼ばれる
 constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
 constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
 constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int,defStyleRes: Int) : super(context, attrs, defStyleAttr,defStyleRes)
```

以上がカスタムViewの基本実装です。
Viewクラスを継承した事で、様々なViewクラスのメソッドにアクセスできるようになりました。
今回はViewのタッチイベントを検知するためにonTouchEventをオーバーロードし、このメソッド内でズームイン・ズームアウト・ドラッグアンドドロップを実装します。
onTouchEventメソッドの引数として渡されるMotionEventクラスで今回使用するメソッドは以下の3つです。

1. getX()  → タッチされたx座標を取得
1. getY()  → タッチされたy座標を取得
1. getActionMasked() → タッチイベントの種類を取得

流れとしては、画面がタッチされた時にonTouchEventが呼ばれ、呼ばれたタッチイベントをgetActionMasked()メソッドで特定し、各イベント内でズームイン・ズームアウトやドラッグアンドドロップを実現させるための処理を書きます。

```kotlin:CustomView
   override fun onTouchEvent(event: MotionEvent?): Boolean {
        event?:return false
        when(event.actionMasked){
             // 一本指のタッチ開始時。後述するドラッグアンドドロップで使用
            MotionEvent.ACTION_DOWN -> {}
            // マルチタッチ開始時。後述するズームイン・ズームアウトで使用
            MotionEvent.ACTION_POINTER_DOWN -> {}
            // 現在のタップ接触位置、圧力、またはサイズが変更される度に呼ばれる
            MotionEvent.ACTION_MOVE -> {}
            // 一本指で画面タッチ終了時(指が画面から離れる時)に呼ばれる
            MotionEvent.ACTION_UP -> {}
            // マルチタッチ終了時に呼ばれる
            MotionEvent.ACTION_POINTER_UP -> {}
            // 他にもタッチイベントはあるが今回は上記のみを使用する
            else -> {}
        }
    }
```

## 動かない円を描画する

ここで一旦上記のタッチイベントは忘れて、円を描いてみましょう。
円を描画するには、ViewクラスのonDraw()メソッドをオーバーロードし、引数として渡されるCanavasクラスのdrawCircle()メソッドを用いて描画します。
drawCircle()の引数に、円のx座標及びy座標、円の半径、Paintクラス(色等の指定ができる)を指定します。

```kotlin:CustomView
 canvas.drawCircle(x座標,y座標,円の半径,Paintクラスのインスタンス)
```

```kotlin:CustomView
class CustomView: View {
    // カスタムview作成用コンストラクタ
    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int,defStyleRes: Int) : super(context, attrs, defStyleAttr,defStyleRes)

    // 円の色はこのPaintクラスで指定する
    private val mPaint = Paint().apply {
        color = Color.BLACK
    }

    override fun onDraw(canvas: Canvas?) {
         if(canvas == null){
            super.onDraw(canvas)
            return
        }
        // drawCircle()前にcanvasの状態を保存する
        canvas.save()
        // 円を描画する
        canvas.drawCircle(100f,100f,50f,mPaint)
        // 円の描画後、canvas.save()時のcanvasの状態に戻し、drawCircle()の描画開始時のcanvasを統一させる
        canvas.restore()
        super.onDraw(canvas)
    }
}
```

これでx = 100、y = 100の座標に半径が50の黒い円を描画できました。
しかしこの円ではズームやドラッグアンドドロップはできません。
この円の座標や半径の大きさを動的に変える事で、ズームやドラッグアンドドロップが実現します。
ここで全体の流れを確認しましょう。

### 全体の流れ
1. onTouchEventにてスクロール量、スケール量を検知し、drawCircleの引数に渡す座標や半径を更新する。
1. invalidate()メソッドを呼ぶことで、onDraw()メソッドが呼ばれる。
1. onDraw()メソッド内で、更新された座標や半径を用いて円を最描画する。

**スクロール量、スケール量の変化をonTouchEventで検知し、その変化量に応じて円の座標を変えてあげる。**
これがポイントです。


# ドラッグアンドドロップを実装する
まずはドラッグアンドドロップから実装しましょう。
帯域に以下の変数を追加します。

```kotlin:CustomView
 // 円のx座標とy座標を管理する
 private var mXPosition = 0f
 private var mYPosition = 0f
 // タッチされた座標を管理し、スクロール量を算出する際に使用する
 private var mTouchPosition = Point()
```

次にタッチイベント時に円の座標を更新する処理を追加します。
ドラッグアンドドロップで使用するタッチイベントは以下の3つです。

1. MotionEvent.ACTION_DOWN 
1. MotionEvent.ACTION_MOVE
1. MotionEvent.ACTION_UP 

```kotlin:CustomView
   override fun onTouchEvent(event: MotionEvent?): Boolean {
        event?:return false
        when(event.actionMasked){
            MotionEvent.ACTION_DOWN -> {
                // タッチされたx座標及びy座標を保存
                mTouchPosition.set(event.x.toInt(), event.y.toInt())
                // falseを指定するとこれ以降のタッチイベントが呼ばれなくなる
                return true
            }
            MotionEvent.ACTION_MOVE -> {
                // translate()メソッドを呼ぶ
                translate(event)
                return true
            }
            MotionEvent.ACTION_UP -> {
                translate(event)
                // これ以降タッチイベントを呼ぶ必要がないでのfalseを指定する
                return false
            }
            else -> {
                return true
            }
        }
    }
  private fun translate(event: MotionEvent){
        // (スクロール後のタッチ座標 - スクロール前のタッチ座標) + 更新前の円の座標 → 更新された円の座標
        mXPosition += (event.x - mTouchPosition.x)
        mYPosition += (event.y - mTouchPosition.y)
        // スクロール後の座標を保存
        mTouchPosition.set(event.x.toInt(), event.y.toInt())
        // ここで更新された座標で円を最描画する
        invalidate()
    }

    // Viewが最描画される度に呼ばれる
    override fun onDraw(canvas: Canvas?) {
        canvas?:return
        canvas.save()
        // 半径は10fで固定。mXPositionやmYPositionが更新される事で円が移動する
        canvas.drawCircle(mXPosition,mYPosition,10f,mPaint)
        canvas.restore()
        super.onDraw(canvas)
    }
```

これでドラッグアンドドロップは完成です。

## ズームイン・ズームアウトを実装する

帯域に新たに変数を追加します。

```kotlin:CustomView    
    companion object{
        // 最大倍率
        private const val MAX_SCALE = 50f
        // 倍率係数
        private const val SCALE_GRADE = 10f
      }

    // 二つの指の間の距離を管理する
    private var mOldDistance = 0f
    // ズームの倍率(スケール)を管理する
    private var mScale = 10f
```

円の半径は以下のように定義します。
円の半径はmScaleに依存させます。

```
円の半径 =  mScale * SCALE_GRADE
```

次にタッチイベント時に円の半径(mScale)を更新する処理を追加します。
使用するタッチイベントは以下の3つです。

1. MotionEvent.ACTION_POINTER_DOWN 
1. MotionEvent.ACTION_MOVE
1. MotionEvent.ACTION_POINTER_UP 

タッチイベントは非常に敏感なので、ユーザーのアクションをタッチ開始時に下記のように特定します。

1本指でタッチ開始時 = ドラッグ
2本指でタッチ開始時 = ズーム


```kotlin:CustomView
 companion object{
       // ↓↓ 追加 ↓↓ 
        enum class MODE(val type:Int){
            // アクション未実行時
            NONE(0),
            // ドラッグ中
            DRAG(1),
            // ズーム中
            ZOOM(2)
        }
      }

  // ↓↓ 追加 ↓↓ 
  // ユーザーが進行中のアクションを管理する
  private var mMode = MODE.NONE

  override fun onTouchEvent(event: MotionEvent?): Boolean {
        event?:return false
        when(event.actionMasked){
            MotionEvent.ACTION_DOWN -> {
                // ドラッグ開始を記録
                 mMode = MODE.DRAG
                // タッチされたx座標及びy座標を保存
                mTouchPosition.set(event.x.toInt(), event.y.toInt())
                // falseを指定するとこれ以降のタッチイベントが呼ばれなくなる
                return true
            }
            MotionEvent.ACTION_POINTER_DOWN -> {
                // ズーム開始を記録
                mMode = MODE.ZOOM
                // タップされた2本の指の間の距離を記録
                mOldDistance = spacing(event)
                // falseを指定するとこれ以降のタッチイベントが呼ばれなくなる
                return true
            }
            // シングルタッチ、マルチタッチ双方で呼ばれるため、タッチ開始時のモードで処理内容を変える
            MotionEvent.ACTION_MOVE -> {
                if (mMode == MODE.DRAG) {
                    translate(event)
                } else if (mMode == MODE.ZOOM) {
                    scale(event)
                }
                return true
            }
            MotionEvent.ACTION_UP -> {
                if (mMode != MODE.DRAG) return false
                // 進行中アクションを終了とする
                mMode = MODE.NONE
                translate(event)
                return false
            }
            MotionEvent.ACTION_POINTER_UP -> {
                if (mMode != MODE.ZOOM) return false
                // 進行中アクションを終了とする
                mMode = MODE.NONE
                scale(event)
                return false
            }
            else -> {
                return true
            }
        }
    }

    // ↓↓ 追加 ↓↓ 
    // タップされた2本の指の間の距離を算出する
    private fun spacing(event: MotionEvent): Float {
        val x = event.getX(0) - event.getX(1)
        val y = event.getY(0) - event.getY(1)
        return sqrt(x * x + y * y)
    }

    // スケールを更新
    private fun scale(event: MotionEvent) {
        // 2本の指の間の距離の変化率からスケール率を取得
        val newDest = spacing(event)
        val newScale = mScale * (newDest.div(mOldDistance))
        // 最大倍率以上にスケールさせない
        mScale =  if(newScale > MAX_SCALE){
            MAX_SCALE
        }else{
            newScale
        }
        mOldDistance = spacing(event)
        invalidate()
    }
```

これでズームイン・ズームアウトは完成です。
最後に円上をタッチした時のみズーム及びドラッグアンドドロップさせたいので、タッチされた座標が円上かどうかをチェックする関数を追加します。

```kotlin:CustomView
    // タッチされた座標が円上の座標かどうかをチェックする
    private fun isTouchedPointOnCircle(eX: Float, eY: Float):Boolean{
        // 余裕を持って半径の1.2倍以内であれば円上として判断する
        val radius = mScale * SCALE_GRADE * 1.2f
       return eX > mXPosition - radius && eX < mXPosition + radius
               && eY > mYPosition - radius && eY < mYPosition + radius
    }
```

### 全体のコード


```kotlin:MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val parentLayout = findViewById<ConstraintLayout>(R.id.parentLayout)
        val myCustomView = findViewById<CustomView>(R.id.myCustomView)

        parentLayout.viewTreeObserver.addOnGlobalLayoutListener(object :ViewTreeObserver.OnGlobalLayoutListener{
            override fun onGlobalLayout() {
                // 親Viewのheightとwidthが確定次第、カスタムViewの位置をセットする
                myCustomView.initView(parentLayout.width.toFloat(),parentLayout.height.toFloat())
                parentLayout.viewTreeObserver.removeOnGlobalLayoutListener(this)
            }
        })
    }
}
```

```activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    android:id="@+id/parentLayout"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <パッケージ名.CustomView
        android:id="@+id/myCustomView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
```

```kotlin:CustomView.kt
class CustomView: View {
    // カスタムview作成用コンストラクタ
    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int,defStyleRes: Int) : super(context, attrs, defStyleAttr,defStyleRes)

    // ユーザーが進行中のアクションを管理
    private var mMode = MODE.NONE
    // 二つの指の間の距離を管理
    private var mOldDistance = 0f
    // ズームの倍率(スケール)を管理
    private var mScale = 10f
    // 円のx座標とy座標を管理
    private var mXPosition = 0f
    private var mYPosition = 0f
    // タッチされた座標を管理
    private var mTouchPosition = Point()
    // 円の色はこのPaintクラスで指定する
    private val mPaint = Paint().apply {
        color = Color.BLACK
    }

    // 初期表示位置は親Viewの中心にセットする
    fun initView(width: Float,height: Float){
        mXPosition = width / 2
        mYPosition = height / 2
    }

    // Viewが最描画される度に呼ばれる
    override fun onDraw(canvas: Canvas?) {
        if(canvas == null){
            super.onDraw(canvas)
            return
        }
        // drawCircle()前にcanvasの状態を保存する
        canvas.save()
        // 円を描画する
        canvas.drawCircle(mXPosition,mYPosition,mScale * SCALE_GRADE,mPaint)
        // 円の描画後、canvas.save()時のcanvasの状態に戻し、drawCircle()の描画開始時のcanvasを統一する
        canvas.restore()
        super.onDraw(canvas)
    }

    // タッチされた座標が円上の座標かどうかをチェックする
    private fun isTouchedPointOnCircle(eX: Float, eY: Float):Boolean{
        // 余裕を持って半径の1.2倍以内であれば円上として判断する
        val radius = mScale * SCALE_GRADE * 1.2f
       return eX > mXPosition - radius && eX < mXPosition + radius
               && eY > mYPosition - radius && eY < mYPosition + radius
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        event?:return false
        when(event.actionMasked){
            MotionEvent.ACTION_DOWN -> {
                // タッチされた座標が円上でなければこれ以降はスルー
                if(!isTouchedPointOnCircle(event.x,event.y)) return true
                // ドラッグ開始を記録
                mMode = MODE.DRAG
                // タッチされたx座標及びy座標を保存
                mTouchPosition.set(event.x.toInt(), event.y.toInt())
                // falseを指定するとこれ以降のタッチイベントが呼ばれなくなる
                return true
            }
            MotionEvent.ACTION_POINTER_DOWN -> {
                //  2本の指の内1本でも円上をタッチしていればスケールする
                if(!(isTouchedPointOnCircle(event.getX(0),event.getY(0)) || isTouchedPointOnCircle(event.getX(1),event.getY(1)))) return false
                // ズーム開始を記録
                mMode = MODE.ZOOM
                // タップされた2本の指の間の距離を記録
                mOldDistance = spacing(event)
                // falseを指定するとこれ以降のタッチイベントが呼ばれなくなる
                return true
            }
            // シングルタッチ、マルチタッチ双方で呼ばれるため、タッチ開始時のモードで場合わけ
            MotionEvent.ACTION_MOVE -> {
                if (mMode == MODE.DRAG) {
                    translate(event)
                } else if (mMode == MODE.ZOOM) {
                    scale(event)
                }
                return true
            }
            MotionEvent.ACTION_UP -> {
                if (mMode != MODE.DRAG) return false
                // 進行中アクションを終了とする
                mMode = MODE.NONE
                translate(event)
                return false
            }
            MotionEvent.ACTION_POINTER_UP -> {
                if (mMode != MODE.ZOOM) return false
                // 進行中アクションを終了とする
                mMode = MODE.NONE
                scale(event)
                return false
            }
            else -> {
                return true
            }
        }
    }

    // スケールを更新
    private fun scale(event: MotionEvent) {
        // 2本の指の間の距離の変化率からスケール率を取得
        val newDest = spacing(event)
        val newScale = mScale * (newDest.div(mOldDistance))
        // 最大倍率以上にスケールさせない
        mScale =  if(newScale > MAX_SCALE){
            MAX_SCALE
        }else{
            newScale
        }
        mOldDistance = spacing(event)
        invalidate()
    }

    private fun translate(event: MotionEvent){
        // (スクロール後のタッチ座標 - スクロール前のタッチ座標) + 更新前の円の座標 → 更新された円の座標
        mXPosition += (event.x - mTouchPosition.x)
        mYPosition += (event.y - mTouchPosition.y)
        // スクロール後の座標を保存
        mTouchPosition.set(event.x.toInt(), event.y.toInt())
        // ここで更新された座標で円を最描画する
        invalidate()
    }
    // タップされた2本の指の間の距離を算出する
    private fun spacing(event: MotionEvent): Float {
        val x = event.getX(0) - event.getX(1)
        val y = event.getY(0) - event.getY(1)
        return sqrt(x * x + y * y)
    }

    companion object{
        enum class MODE(val type:Int){
            // アクション未実行時
            NONE(0),
            // ドラッグ中
            DRAG(1),
            // ズーム中
            ZOOM(2),
        }
        // 最大倍率
        private const val MAX_SCALE = 50f
        // 倍率係数
        private const val SCALE_GRADE = 10f
    }
}
```

以上で完成です。

## 終わりに

**canvasの引数に与える座標や、半径等の大きさを動的に変化させることで、様々な表現が可能になります。**

canvasでは円以外にも、点や線、長方形等も描画できるため、同じように座標や大きさを変えて、遊んでみるのも良い勉強になると思います。

では! ^^