この記事は
AndroidのTwitter公式アプリの複数枚の画像を閲覧する画面を自分のAndroidアプリで再現する方法を紹介します。PhotoViewを使いますが、操作性を同じにするために、少し改造しています。
Twitterアプリの画像ビューワの要件
まず、Twitterアプリの画像ビューワの要件を整理します。
- 左右スワイプで表示画像を切り替える
- ピンチイン/ピンチアウト操作で表示を拡大縮小できる
- 画像を拡大しているときは、ドラッグ操作で表示位置を変更できる
他にも、画像を拡大している時の表示位置変更と表示画像切り替えについての要件があるのですが、そちらは実装方法を解説していく中で説明します。
使用ライブラリ
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を使用できますが、それをより直感的に書けるRecyclerViewの補助ライブラリであるgroupieを使用しました。
実装する
このプロジェクトではView Bindingが有効になっています。
ライブラリを取り込む
Android Gradle Plugin 7.0.0を使用している場合は、PhotoViewとGroupieのために、こちらの設定が必要でした。
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" } // 追加
}
}
// 略
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のレイアウトに設置します。
<?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を設置します。
<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を作成します。
/**
* 画像表示ページ
* @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の設定を行います。
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アプリとは違う部分になります。
画像を拡大してから右側を見ようとして左方向にドラッグしたら、行き過ぎて次の画像への切り替えが発動してしまいました。
一方Twitterアプリの方は、画像を拡大してから右側を見ようとして左方向にドラッグしたら、いったん画像の端で止まります。そして再度左方向にドラッグすると、次の画像への切り替えになります。こちらの動きの方がユーザが意図しない表示画像切り替えが起こらなそうです。
PhotoViewを改造する
前節で紹介したTwitterアプリの挙動になるように、PhotoViewを改造します。
PhotoViewをライブラリモジュールとしてプロジェクトに追加する
ソースコードをコピーする
PhotoViewのソースコードをこちらからダウンロードします。執筆時点の最新版は2.3.0です。
次にダウンロードした.zipファイルまたは.tar.gzファイルを展開します。
そしてphotoview/ディレクトリをプロジェクト直下にコピーします。
ライブラリモジュールとしてプロジェクトに追加する
ライブラリモジュールとして認識されるように、settings.gradleにphotoviewモジュールを追加します。
rootProject.name = "TryPhotoView"
include ':app'
include ':photoview' // 追加
photoviewモジュールのbuild.gradleをappモジュールのそれと合う形に修正します。
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モジュールを使えるようにします。
// 略
dependencies {
// 略
// implementation 'com.github.chrisbanes:PhotoView:2.3.0'
implementation project(":photoview") // 追加
// 略
}
PhotoViewのソースコードを変更する
PhotoViewのソースコードを眺めてみたら、PhotoViewAttacherクラスに、このような箇所がありました。
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に対してタッチイベントが送られず、別の画像への表示切り替えができなくなってしまいました。
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)
}
}
// 略
}
そこでPhotoViewAttacherクラスをこのように変更しました。
タッチ操作を始めたときは、mAllowParentInterceptOnEdgeフィールドをtrueにします。
@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にします。
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アプリのように、拡大してから右側を見ようとして左方向にドラッグしたら、いったん画像の端で止まり、再度左方向にドラッグすると、右側の画像への切り替えになるようになりました。
全体ソースコード
今回の全体のソースコードはこちらに置いています。
tfandkusu/TryPhotoView