PhotoViewとViewPager2を使って、Twitterの画像ビューワを再現する

# この記事は

AndroidのTwitter公式アプリの複数枚の画像を閲覧する画面を自分のAndroidアプリで再現する方法を紹介します。[PhotoView](https://github.com/Baseflow/PhotoView)を使いますが、操作性を同じにするために、少し改造しています。

# Twitterアプリの画像ビューワの要件

まず、Twitterアプリの画像ビューワの要件を整理します。

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/c39d63a7-8c8e-6815-2992-1bbec9fe4602.gif)

1. 左右スワイプで表示画像を切り替える
1. ピンチイン/ピンチアウト操作で表示を拡大縮小できる
1. 画像を拡大しているときは、ドラッグ操作で表示位置を変更できる

他にも、画像を拡大している時の表示位置変更と表示画像切り替えについての要件があるのですが、そちらは実装方法を解説していく中で説明します。

# 使用ライブラリ

## PhotoView

https://github.com/Baseflow/PhotoView
ピンチイン/ピンチアウト操作による拡大縮小、ドラッグによる表示位置変更に対応した画像ビューワViewです。ImageViewを継承しています。

## Coil

https://github.com/coil-kt/coil
HTTPSで配信されている画像を非同期でダウンロードしてImageViewに設定します。

## ViewPager2

https://developer.android.com/jetpack/androidx/releases/viewpager2
左右スワイプでページを切り替えることができるAndroid公式UI部品です。

## groupie

https://github.com/lisawray/groupie
ViewPager2は[RecyclerView.Adapter](https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView.Adapter)を使用できますが、それをより直感的に書けるRecyclerViewの補助ライブラリであるgroupieを使用しました。

# 実装する

このプロジェクトでは[View Binding](https://developer.android.com/topic/libraries/view-binding)が有効になっています。

## ライブラリを取り込む

Android Gradle Plugin 7.0.0を使用している場合は、PhotoViewとGroupieのために、こちらの設定が必要でした。

```settings.gradle
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        jcenter() // Warning: this repository is going to shut down soon
        maven { url "https://www.jitpack.io" } // 追加
    }
}
// 略
```

```app/build.gradle
dependencies {
    implementation 'com.github.chrisbanes:PhotoView:2.3.0'
    implementation 'io.coil-kt:coil:1.3.1'
    implementation 'androidx.viewpager2:viewpager2:1.0.0'
    implementation "com.github.lisawray.groupie:groupie:2.9.0"
    implementation "com.github.lisawray.groupie:groupie-viewbinding:2.9.0"
}
```

## UI構築

ViewPager2をActvityのレイアウトに設置します。

```activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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">

    <androidx.viewpager2.widget.ViewPager2
        android:id="@+id/viewPager"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
```

横スワイプで切り替え可能な各ページのレイアウトを作成します。
そこにPhotoViewを設置します。

```list_item_image.xml
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.github.chrisbanes.photoview.PhotoView
        android:id="@+id/photoView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
```

GroupieのBindableItemを作成します。

```ImageGroupieItem.kt
/**
 * 画像表示ページ
 * @param imageUrl 画像URL
 */
class ImageGroupieItem(private val imageUrl: String) : BindableItem<ListItemImageBinding>() {
    override fun bind(viewBinding: ListItemImageBinding, position: Int) {
        // loadはcoilによってImageViewに追加されたメソッド
        viewBinding.photoView.load(imageUrl) {
            crossfade(true)
        }
    }

    override fun getLayout() = R.layout.list_item_image

    override fun initializeViewBinding(view: View) = ListItemImageBinding.bind(view)

    override fun isSameAs(other: Item<*>): Boolean {
        return if (other is ImageGroupieItem) {
            imageUrl == other.imageUrl
        } else {
            false
        }
    }

    override fun hasSameContentAs(other: Item<*>): Boolean {
        return if (other is ImageGroupieItem) {
            imageUrl == other.imageUrl
        } else {
            false
        }
    }
}
```

ActivityでViewPager2の設定を行います。

```MainActivity.kt
class MainActivity : AppCompatActivity() {

    companion object {
        /**
         * 表示する画像リスト。
         * 画像はいらすとやさんより。
         */
        private val IMAGE_URLS = listOf(
            "https://2.bp.blogspot.com/-4SSFZUa0ab4/Vg57ivCMfhI/AAAAAAAAyzQ/Pm4eBFxAaOc/s800/sweets_fruit_pafe.png",
            "https://1.bp.blogspot.com/-F1EaVL5t2zM/XZR9ftwga3I/AAAAAAABVTY/t174NxuHwvghxBTg4Q31qNVon6FKuSBywCNcBGAsYHQ/s1600/landmark_hokkaidou_kyuuhonchousya.png",
            "https://2.bp.blogspot.com/-PtiUwbxAtNc/U5hUb0EU7UI/AAAAAAAAhJY/V0XJK0hKoEk/s800/food_jingisukan_genghis_khan.png",
            "https://4.bp.blogspot.com/-dN9u5FvIFg4/UrEgH7TCr_I/AAAAAAAAb4I/Z_mQivZxrjI/s800/suizokukan_jinbeizame.png"
        )
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        val adapter = GroupieAdapter()
        binding.viewPager.adapter = adapter
        adapter.update(IMAGE_URLS.map {
            ImageGroupieItem(it)
        })
        // 3件隣までViewを持っておく。
        binding.viewPager.offscreenPageLimit = 3
    }
}
```

# いったんは作成完了。しかし難あり。

いったんは動く物ができました。しかし操作性に難がある箇所があり、そこはTwitterアプリとは違う部分になります。
画像を拡大してから右側を見ようとして左方向にドラッグしたら、行き過ぎて次の画像への切り替えが発動してしまいました。

![output9.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/6e098885-ebae-622b-413a-6fb884ae1bf6.gif)


一方Twitterアプリの方は、画像を拡大してから右側を見ようとして左方向にドラッグしたら、いったん画像の端で止まります。そして再度左方向にドラッグすると、次の画像への切り替えになります。こちらの動きの方がユーザが意図しない表示画像切り替えが起こらなそうです。

![output4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/d40d8182-823e-5303-5734-3b2f84bfac45.gif)

# PhotoViewを改造する

前節で紹介したTwitterアプリの挙動になるように、PhotoViewを改造します。

## PhotoViewをライブラリモジュールとしてプロジェクトに追加する

### ソースコードをコピーする

PhotoViewのソースコードを[こちら](https://github.com/Baseflow/PhotoView/releases)からダウンロードします。執筆時点の最新版は[2.3.0](https://github.com/Baseflow/PhotoView/releases/tag/2.3.0)です。
次にダウンロードした.zipファイルまたは.tar.gzファイルを展開します。
そしてphotoview/ディレクトリをプロジェクト直下にコピーします。

### ライブラリモジュールとしてプロジェクトに追加する

ライブラリモジュールとして認識されるように、settings.gradleにphotoviewモジュールを追加します。

```settings.gradle
rootProject.name = "TryPhotoView"
include ':app'
include ':photoview' // 追加
```

photoviewモジュールのbuild.gradleをappモジュールのそれと合う形に修正します。

```photoview/build.gradle
apply plugin: 'com.android.library'

android {
    compileSdk 30

    defaultConfig {
        minSdk 21
        targetSdk 30
        versionCode 1
        versionName "1.0"
    }
}

dependencies {
    implementation "androidx.appcompat:appcompat:1.3.1"
}
```

appモジュールからphotoviewモジュールを使えるようにします。

```app/build.gradle
// 略
dependencies {
    // 略

    // implementation 'com.github.chrisbanes:PhotoView:2.3.0'
    implementation project(":photoview") // 追加

    // 略
}
```

## PhotoViewのソースコードを変更する

PhotoViewのソースコードを眺めてみたら、PhotoViewAttacherクラスに、このような箇所がありました。

```PhotoViewAttacher.java
  private OnGestureListener onGestureListener = new OnGestureListener() {
        @Override
        public void onDrag(float dx, float dy) {

            // 略

            ViewParent parent = mImageView.getParent();
            if (mAllowParentInterceptOnEdge && !mScaleDragDetector.isScaling() && !mBlockParentIntercept) {
                if (mHorizontalScrollEdge == HORIZONTAL_EDGE_BOTH
                        || (mHorizontalScrollEdge == HORIZONTAL_EDGE_LEFT && dx >= 1f)
                        || (mHorizontalScrollEdge == HORIZONTAL_EDGE_RIGHT && dx <= -1f)
                        || (mVerticalScrollEdge == VERTICAL_EDGE_TOP && dy >= 1f)
                        || (mVerticalScrollEdge == VERTICAL_EDGE_BOTTOM && dy <= -1f)) {
                    if (parent != null) {
                        parent.requestDisallowInterceptTouchEvent(false);
                    }
                }
            } else {
                if (parent != null) {
                    parent.requestDisallowInterceptTouchEvent(true);
                }
            }
        }

        // 略
    };
```

mAllowParentInterceptOnEdgeフィールドがtrueかつ、画像の表示位置が端になっていて、その端の方向へドラッグ操作が行われているときは、親のView - 今回のケースではViewPager2に対して、タッチイベントの阻止を許可しています。

試しに、mAllowParentInterceptOnEdgeフィールドをfalseにしてみたら、ViewPager2に対してタッチイベントが送られず、別の画像への表示切り替えができなくなってしまいました。

```ImageGroupieItem.kt
class ImageGroupieItem(private val imageUrl: String) : BindableItem<ListItemImageBinding>() {
    override fun bind(viewBinding: ListItemImageBinding, position: Int) {
        viewBinding.photoView.setAllowParentInterceptOnEdge(false) // 追加
        viewBinding.photoView.load(imageUrl) {
            crossfade(true)
        }
    }
    // 略
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/222abb5a-7997-2a56-9a3c-ca68ab0c7138.gif">

そこでPhotoViewAttacherクラスをこのように変更しました。
タッチ操作を始めたときは、mAllowParentInterceptOnEdgeフィールドをtrueにします。

```PhotoViewAttacher.java
    @Override
    public boolean onTouch(View v, MotionEvent ev) {
        boolean handled = false;
        if (mZoomEnabled && Util.hasDrawable((ImageView) v)) {
            switch (ev.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    setAllowParentInterceptOnEdge(true); // 追加
                    // 略
            }
        }
    }
```

mHorizontalScrollEdgeフィールドがHORIZONTAL_EDGE_NONEになったときは、mAllowParentInterceptOnEdgeフィールドをfalseにします。

```PhotoViewAttacher.java
    private boolean checkMatrixBounds() {
        // 略
        if (width <= viewWidth) {
            // 略
            mHorizontalScrollEdge = HORIZONTAL_EDGE_BOTH;
        } else if (rect.left > 0) {
            mHorizontalScrollEdge = HORIZONTAL_EDGE_LEFT;
            // 略
        } else if (rect.right < viewWidth) {
            // 略
            mHorizontalScrollEdge = HORIZONTAL_EDGE_RIGHT;
        } else {
            mHorizontalScrollEdge = HORIZONTAL_EDGE_NONE;
            setAllowParentInterceptOnEdge(false); // 追加
        }
        // 略
        return true;
    }
```

この2カ所の変更の意味するところは、ドラッグ操作によって、一度画像の表示位置が横方向に移動したときは、いったん指を離すまではViewPager2に対してタッチイベントを送らないということです。

# できあがり

これでTwitterアプリのように、拡大してから右側を見ようとして左方向にドラッグしたら、いったん画像の端で止まり、再度左方向にドラッグすると、右側の画像への切り替えになるようになりました。

![output7.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/03c4f5f3-b212-90e6-236b-f6f69566270e.gif)


# 全体ソースコード

今回の全体のソースコードはこちらに置いています。
[tfandkusu/TryPhotoView](https://github.com/tfandkusu/TryPhotoView)