├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── github │ │ └── iielse │ │ └── imageviewer │ │ └── demo │ │ ├── business │ │ ├── CustomTransitionHelper.kt │ │ ├── FullVideoActivity.kt │ │ ├── ItemType.kt │ │ ├── MainActivity.kt │ │ ├── SimpleViewerCustomizer.kt │ │ ├── TestActivity.kt │ │ ├── TestDataAdapter.kt │ │ ├── TestDataViewHolder.kt │ │ ├── TestDataViewModel.kt │ │ └── ViewerHelper.kt │ │ ├── core │ │ ├── BasePagedAdapter.kt │ │ ├── Collections.kt │ │ ├── ObserverAdapter.kt │ │ ├── PagingCollections.kt │ │ ├── XPageKeyedDataSource.kt │ │ └── viewer │ │ │ ├── FullScreenImageViewerDialogFragment.kt │ │ │ ├── SimpleImageLoader.kt │ │ │ └── SimpleTransformer.kt │ │ ├── data │ │ ├── Api.kt │ │ ├── MyData.kt │ │ ├── Service.kt │ │ └── TestRepository.kt │ │ └── utils │ │ ├── VideoUtils.kt │ │ ├── extensions.kt │ │ └── utils.kt │ └── res │ ├── layout │ ├── full_video_activity.xml │ ├── item_image.xml │ ├── item_photo_custom_layout.xml │ ├── item_video_custom_layout.xml │ ├── layout_indicator.xml │ ├── layout_recycler_empty.xml │ ├── layout_recycler_error.xml │ ├── layout_recycler_loading.xml │ ├── main_activity.xml │ └── test_activity.xml │ ├── mipmap-xxhdpi │ └── ic_launcher.png │ └── values │ ├── colors.xml │ ├── ids.xml │ ├── strings.xml │ └── styles.xml ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── imageviewer ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── github │ │ └── iielse │ │ └── imageviewer │ │ └── ExampleInstrumentedTest.kt │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── github │ │ └── iielse │ │ └── imageviewer │ │ ├── BaseDialogFragment.kt │ │ ├── ImageViewerActionViewModel.kt │ │ ├── ImageViewerAdapterListener.kt │ │ ├── ImageViewerBuilder.kt │ │ ├── ImageViewerDialogFragment.kt │ │ ├── ImageViewerViewModel.kt │ │ ├── ViewerActions.kt │ │ ├── adapter │ │ ├── ImageViewerAdapter.kt │ │ ├── ItemType.kt │ │ └── Repository.kt │ │ ├── core │ │ ├── Components.kt │ │ ├── DataProvider.kt │ │ ├── ImageLoader.kt │ │ ├── OverlayCustomizer.kt │ │ ├── Photo.kt │ │ ├── SimpleDataProvider.kt │ │ ├── Transformer.kt │ │ ├── VHCustomizer.kt │ │ └── ViewerCallback.kt │ │ ├── utils │ │ ├── Config.kt │ │ ├── Extensions.kt │ │ ├── TransitionEndHelper.kt │ │ ├── TransitionStartHelper.kt │ │ └── ViewModelUtils.kt │ │ ├── viewholders │ │ ├── PhotoViewHolder.kt │ │ ├── SubsamplingViewHolder.kt │ │ ├── UnknownViewHolder.kt │ │ └── VideoViewHolder.kt │ │ └── widgets │ │ ├── BackgroundView.kt │ │ ├── InterceptLayout.kt │ │ ├── PhotoView2.kt │ │ ├── SubsamplingScaleImageView2.kt │ │ └── video │ │ ├── ExoVideoView.kt │ │ └── ExoVideoView2.kt │ └── res │ ├── anim │ └── anim_keep.xml │ ├── layout │ ├── fragment_image_viewer_dialog.xml │ ├── item_imageviewer_photo.xml │ ├── item_imageviewer_subsampling.xml │ └── item_imageviewer_video.xml │ └── values │ ├── ids.xml │ └── styles.xml ├── jitpack.yml └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | local.properties 4 | .idea 5 | .idea/caches/build_file_checksums.ser 6 | .idea/libraries 7 | .idea/modules.xml 8 | .idea/workspace.xml 9 | .DS_Store 10 | /build 11 | /captures 12 | .externalNativeBuild 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 else 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Imageviewer 2 | 3 | 提供查看缩略视图到原视图的无缝过渡转变的视觉效果,优雅的浏览普通图、长图、动图. 4 | 5 | #### 主要特征 6 | 7 | - **过渡动画** 缩略图到大图或大图到缩略图时提供无缝衔接动画 8 | - **浏览手势** 浏览大图时可使用常势操用手.如缩放图片等.([PhotoView](https://github.com/chrisbanes/PhotoView)) 9 | - **超大图** 图片区块加载 ([SubsamplingScaleImageView](https://github.com/davemorrissey/subsampling-scale-image-view)) 10 | - **Video** 支持Video加载 ([ExoPlayer](https://github.com/google/ExoPlayer)) 11 | - **拖拽关闭** 对大图进行上/下滑操作退出浏览. 12 | - **数据分页加载** 在浏览大图的情况下异步加载数据. 13 | - **数据删除** 14 | - **自定义UI** 对预览页的UI元素自定义追加 15 | - **已适配RTL** 16 | 17 | ![](https://github.com/iielse/res/blob/master/imageviewer/1.gif) 18 | 19 | ### 引入 [![](https://jitpack.io/v/iielse/imageviewer.svg)](https://jitpack.io/#iielse/imageviewer) 20 | 21 | ``` 22 | implementation 'com.github.iielse:imageviewer:x.y.z' 23 | ``` 24 | 25 | ### 最简单的调用代码 26 | 27 | ``` 28 | fun show() { // 29 | val dataList: List = // 将要展示的图片集合列表 30 | val clickedData: Photo = // 被点击的其中的那个图片元素信息 31 | val builder = ImageViewerBuilder( 32 | context = view.context, 33 | dataProvider = SimpleDataProvider(clickedData, dataList), // 一次性全量加载 // 实现DataProvider接口支持分页加载 34 | imageLoader = SimpleImageLoader(), // 可使用demo固定写法 // 实现对数据源的加载.支持自定义加载数据类型,加载方案 35 | transformer = SimpleTransformer(), // 可使用demo固定写法 // 以photoId为标示,设置过渡动画的'配对'. 36 | ) 37 | builder.show() 38 | } 39 | ``` 40 | 41 | ``` 42 | // 基本是固定写法. Glide 可以换成别的. demo代码中有video的写法. 43 | class SimpleImageLoader : ImageLoader { 44 | /** 根据自身photo数据加载图片.可以使用其它图片加载框架. */ 45 | override fun load(view: ImageView, data: Photo, viewHolder: RecyclerView.ViewHolder) { 46 | val it = (data as? MyData?)?.url ?: return 47 | Glide.with(view).load(it) 48 | .placeholder(view.drawable) 49 | .into(view) 50 | } 51 | 52 | /** 53 | * 根据自身photo数据加载超大图.subsamplingView数据源需要先将内容完整下载到本地. 54 | */ 55 | override fun load(subsamplingView: SubsamplingScaleImageView, data: Photo, viewHolder: RecyclerView.ViewHolder) { 56 | val it = (data as? MyData?)?.url ?: return 57 | subsamplingDownloadRequest(it) 58 | .subscribeOn(Schedulers.io()) 59 | .observeOn(AndroidSchedulers.mainThread()) 60 | .doOnSubscribe { findLoadingView(viewHolder)?.visibility = View.VISIBLE } 61 | .doFinally { findLoadingView(viewHolder)?.visibility = View.GONE } 62 | .doOnNext { subsamplingView.setImage(ImageSource.uri(Uri.fromFile(it))) } 63 | .doOnError { toast(it.message) } 64 | .subscribe().bindLifecycle(subsamplingView) 65 | } 66 | 67 | private fun subsamplingDownloadRequest(url: String): Observable { 68 | return Observable.create { 69 | try { 70 | it.onNext(Glide.with(appContext).downloadOnly().load(url).submit().get()) 71 | it.onComplete() 72 | } catch (e: java.lang.Exception) { 73 | if (!it.isDisposed) it.onError(e) 74 | } 75 | } 76 | } 77 | 78 | private fun findLoadingView(viewHolder: RecyclerView.ViewHolder): View? { 79 | return viewHolder.itemView.findViewById(R.id.loadingView) 80 | } 81 | 82 | ...... 83 | } 84 | ``` 85 | 86 | ``` 87 | // 基本是可以作为固定写法. 88 | class SimpleTransformer : Transformer { 89 | override fun getView(key: Long): ImageView? = provide(key) 90 | 91 | companion object { 92 | private val transition = HashMap() 93 | fun put(photoId: Long, imageView: ImageView) { 94 | require(isMainThread()) 95 | if (!imageView.isAttachedToWindow) return 96 | imageView.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { 97 | override fun onViewAttachedToWindow(p0: View?) = Unit 98 | override fun onViewDetachedFromWindow(p0: View?) { 99 | transition.remove(imageView) 100 | imageView.removeOnAttachStateChangeListener(this) 101 | } 102 | }) 103 | transition[imageView] = photoId 104 | } 105 | 106 | private fun provide(photoId: Long): ImageView? { 107 | transition.keys.forEach { 108 | if (transition[it] == photoId) 109 | return it 110 | } 111 | return null 112 | } 113 | } 114 | } 115 | ``` 116 | 117 | 到此简单的集成已经完毕. 118 | 119 | 120 | ## 进阶使用. 121 | 122 | (实现以下3个方法.可以追加自定义的展示和功能) 123 | 124 | * 自定义'每一页'上的UI.比如可显示图片的更多信息.提供存储分享等更多功能等 `builder.setVHCustomizer(MyCustomViewHolderUI())` 125 | * 自定义'覆盖(最上)层'上的UI.比如添加指示器等 `builder.setOverlayCustomizer(MyCustomIndicatorUI())` 126 | * 监听viewer的各种状态变化.包括页面的切换(显示当前在第几页).;过渡动画的执行状态;维护video的播放状态等 `builder.setViewerCallback(MyViewerStateChangedListener())` 127 | 128 | ``` 129 | // 一般监听翻页onPageSelected可以控制 video播放的状态 130 | // viewer 各状态监听回调 131 | interface ViewerCallback : ImageViewerAdapterListener { 132 | // 当点击缩略图变化大图的瞬间 133 | override fun onInit(viewHolder: RecyclerView.ViewHolder) {} 134 | // 当图片被拖动时 135 | override fun onDrag(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) {} 136 | // 当图片被拖动但不至于退出浏览 137 | override fun onRestore(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) {} 138 | // 当图片被拖动执行退出浏览 139 | override fun onRelease(viewHolder: RecyclerView.ViewHolder, view: View) {} 140 | // 翻页中状态变化 141 | fun onPageScrollStateChanged(state: Int) {} 142 | // 翻页中 143 | fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {} 144 | // 当某大图页面被选中 145 | fun onPageSelected(position: Int, viewHolder: RecyclerView.ViewHolder) {} 146 | } 147 | ``` 148 | 149 | #### 参数配置. 一般不用调整 150 | 151 | 属性 | 作用说明 152 | ------------- | ------------- 153 | OFFSCREEN_PAGE_LIMIT | viewer预加载条数 154 | VIEWER_ORIENTATION | viewer滑动方向 155 | VIEWER_BACKGROUND_COLOR | 大图预览时背景色(默认纯黑) 156 | DURATION_TRANSITION | 过渡动画时长 157 | DURATION_BG | 过渡动画背景变化时长 158 | SWIPE_DISMISS | 是否支持拖拽返回 159 | SWIPE_TOUCH_SLOP | 拖拽触摸感知阈值 160 | DISMISS_FRACTION | 拖拽返回边界阈值 161 | TRANSITION_OFFSET_Y | 修正透明状态栏下过渡动画的起始位置 162 | 163 | ### 数据源的定义 164 | 165 | ``` 166 | interface Photo { 167 | fun id(): Long // 每条图片数据的唯一标示. 主要用于分页数据加载. 定位过渡动画的对应关系 168 | fun itemType(): @ItemType.Type Int // 是否启用SubsamplingScaleImageView实现图片区块加载或ExoVideoView实现Video加载 169 | } 170 | ``` 171 | 172 | ### FAQ 173 | 174 | - 如何手动关闭退出整个页面? 175 | - 如何删除一条数据? 176 | 177 | 通过 `ViewModelProvider(activity).get(ImageViewerActionViewModel::class.java)`获取`viewer` 对象引用. 178 | 之后可使用 `setCurrentItem(pos: Int)`切换大图位置到指定位置; `dismiss()`退出浏览大图; `remove(item: List)`删除其中的元素 179 | 180 | - 如何实现Video的展示? 181 | 可参考demo实现 demo代码位置 `SimpleViewerCustomizer` 182 | - 为什么没有过渡动画? 183 | 需要正确的配置 `Transformer`。需要保证`getView` 返回不为null. 184 | - 为什么动画的执行和原图有高度偏差? 185 | 注意状态栏的影响。配置`Config.TRANSITION_OFFSET_Y` 186 | 187 | ### 其它重要说明 188 | 189 | demo可运行. demo可运行. demo可运行 .demo代码已重构. 190 | 191 | 都看到这里了,不点下`Star`吗 [旺柴] 192 | 193 | ### Thanks 194 | 195 | 如果您觉得我的开源库帮你节省了大量的开发时间,可扫描下方的二维码随意打赏。你的鼓励是我维护项目最大的动力 196 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-kapt' 4 | 5 | android { 6 | buildToolsVersion rootProject.ext.buildToolsVersion 7 | compileSdkVersion rootProject.ext.compileSdkVersion 8 | 9 | defaultConfig { 10 | minSdkVersion rootProject.ext.minSdkVersion 11 | targetSdkVersion rootProject.ext.targetSdkVersion 12 | versionCode rootProject.ext.versionCode 13 | versionName rootProject.ext.versionName 14 | applicationId "com.github.iielse.imageviewer.demo" 15 | } 16 | buildFeatures.viewBinding = true 17 | compileOptions { 18 | sourceCompatibility JavaVersion.VERSION_1_8 19 | targetCompatibility JavaVersion.VERSION_1_8 20 | } 21 | buildTypes { 22 | release { 23 | minifyEnabled false 24 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 25 | } 26 | } 27 | } 28 | 29 | dependencies { 30 | implementation 'androidx.appcompat:appcompat:1.3.1' 31 | implementation 'androidx.core:core-ktx:1.6.0' 32 | implementation 'androidx.activity:activity-ktx:1.2.4' 33 | implementation 'androidx.constraintlayout:constraintlayout:2.1.4' 34 | implementation 'androidx.recyclerview:recyclerview:1.2.1' 35 | implementation 'androidx.viewpager2:viewpager2:1.0.0' 36 | //noinspection GradleDependency 37 | implementation 'androidx.paging:paging-runtime-ktx:2.1.2' 38 | 39 | implementation 'com.github.bumptech.glide:glide:4.13.1' 40 | kapt 'com.github.bumptech.glide:compiler:4.13.1' 41 | implementation 'com.github.chrisbanes:PhotoView:2.2.0' 42 | implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' 43 | implementation 'com.google.android.exoplayer:exoplayer:2.15.0' 44 | 45 | implementation project(':imageviewer') 46 | // implementation 'com.github.iielse:imageviewer:2.1.16' 47 | implementation 'io.reactivex.rxjava2:rxandroid:2.1.1' 48 | implementation 'io.reactivex.rxjava2:rxjava:2.2.19' 49 | } 50 | 51 | repositories { 52 | google() 53 | mavenCentral() 54 | maven { url 'https://maven.google.com' } 55 | maven { url "https://jitpack.io" } 56 | } 57 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in D:\a_work\DevelopEnvironment\sdk/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | 12 | # If your project uses WebView with JS, uncomment the following 13 | # and specify the fully qualified class name to the JavaScript interface 14 | # class: 15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 16 | # public *; 17 | #} 18 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 15 | 16 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 28 | 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/business/CustomTransitionHelper.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.business 2 | 3 | import android.view.View 4 | import android.widget.ImageView 5 | import com.github.iielse.imageviewer.ImageViewerBuilder 6 | import com.github.iielse.imageviewer.core.SimpleDataProvider 7 | import com.github.iielse.imageviewer.core.Transformer 8 | import com.github.iielse.imageviewer.demo.R 9 | import com.github.iielse.imageviewer.demo.core.viewer.SimpleImageLoader 10 | import com.github.iielse.imageviewer.demo.data.Service 11 | 12 | // 自定义Transition startView 尺寸/位置/加载模式 13 | object CustomTransitionHelper { 14 | fun show(view: View) { 15 | val dataList = Service.houMen.toMutableList() 16 | val clickedData = dataList[dataList.size - 1 - (System.currentTimeMillis() % 10).toInt()] 17 | val builder = ImageViewerBuilder( 18 | context = view.context, 19 | dataProvider = SimpleDataProvider(clickedData, dataList), 20 | imageLoader = SimpleImageLoader(), 21 | transformer = object : Transformer { 22 | override fun getView(key: Long): ImageView { 23 | return fakeStartView(view) 24 | } 25 | } 26 | ) 27 | builder.show() 28 | } 29 | 30 | // 提供原图尺寸/位置/加载模式 31 | private fun fakeStartView(view: View): ImageView { 32 | val customWidth = view.width 33 | val customHeight = view.height 34 | val customLocation = IntArray(2).also { view.getLocationOnScreen(it) } 35 | val customScaleType = ImageView.ScaleType.CENTER_CROP 36 | return ImageView(view.context).apply { 37 | left = 0 38 | right = customWidth 39 | top = 0 40 | bottom = customHeight 41 | scaleType = customScaleType 42 | setTag(R.id.viewer_start_view_location_0, customLocation[0]) 43 | setTag(R.id.viewer_start_view_location_1, customLocation[1]) 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/business/FullVideoActivity.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.business 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import android.view.ViewGroup 7 | import androidx.appcompat.app.AppCompatActivity 8 | import com.github.iielse.imageviewer.demo.databinding.FullVideoActivityBinding 9 | import com.github.iielse.imageviewer.demo.utils.setOnClickCallback 10 | import com.github.iielse.imageviewer.widgets.video.ExoVideoView 11 | import java.lang.ref.WeakReference 12 | 13 | class FullVideoActivity : AppCompatActivity() { 14 | private val binding by lazy { FullVideoActivityBinding.inflate(layoutInflater) } 15 | 16 | override fun onCreate(savedInstanceState: Bundle?) { 17 | window.setBackgroundDrawableResource(android.R.color.transparent) 18 | super.onCreate(savedInstanceState) 19 | println("FullVideoActivity onCreate $videoView") 20 | val view = videoView ?: return Unit.also { finish() } 21 | view.setAutoRelease(false) 22 | val parent = view.parent as ViewGroup 23 | parent.removeView(view) 24 | videoParentRef = WeakReference(parent) 25 | binding.videoLayout.addView(view) 26 | setContentView(binding.root) 27 | 28 | binding.back.setOnClickCallback { 29 | finish() 30 | } 31 | } 32 | 33 | override fun onResume() { 34 | super.onResume() 35 | videoView?.resume() 36 | println("FullVideoActivity onResume") 37 | } 38 | 39 | override fun onPause() { 40 | super.onPause() 41 | videoView?.pause() 42 | println("FullVideoActivity onPause $isFinishing") 43 | if (isFinishing) release() 44 | } 45 | 46 | override fun onDestroy() { 47 | super.onDestroy() 48 | println("FullVideoActivity onDestroy") 49 | release() 50 | } 51 | 52 | override fun finish() { 53 | super.finish() 54 | println("FullVideoActivity finish") 55 | release() 56 | } 57 | 58 | private var released = false 59 | private fun release() { 60 | println("FullVideoActivity release $released") 61 | if (released) return 62 | val view = videoView ?: return 63 | (view.parent as ViewGroup?)?.removeView(view) 64 | 65 | val parent = videoParentRef?.get() 66 | if (parent != null) { 67 | parent.addView(view) 68 | view.setAutoRelease(true) 69 | } else view.release() 70 | 71 | videoParentRef = null 72 | videoView = null 73 | released = true 74 | } 75 | 76 | companion object { 77 | private var videoParentRef: WeakReference? = null 78 | private var videoView: ExoVideoView? = null 79 | 80 | fun start(context: Context, view: ExoVideoView) { 81 | videoView = view 82 | context.startActivity(Intent(context, FullVideoActivity::class.java)) 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/business/ItemType.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.business 2 | 3 | import com.github.iielse.imageviewer.demo.core.itemTypeProvider 4 | 5 | object ItemType { 6 | val TestData = itemTypeProvider ++ 7 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/business/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.business 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.activity.viewModels 6 | import androidx.appcompat.app.AppCompatActivity 7 | import androidx.recyclerview.widget.GridLayoutManager 8 | import androidx.viewpager2.widget.ViewPager2 9 | import com.github.iielse.imageviewer.demo.core.ITEM_CLICKED 10 | import com.github.iielse.imageviewer.demo.data.MyData 11 | import com.github.iielse.imageviewer.demo.databinding.MainActivityBinding 12 | import com.github.iielse.imageviewer.demo.utils.App 13 | import com.github.iielse.imageviewer.demo.utils.statusBarHeight 14 | import com.github.iielse.imageviewer.utils.Config 15 | import com.github.iielse.imageviewer.widgets.video.ExoVideoView 16 | 17 | class MainActivity : AppCompatActivity() { 18 | private val binding by lazy { MainActivityBinding.inflate(layoutInflater) } 19 | private val viewModel by viewModels { TestDataViewModel.Factory() } 20 | private val adapter by lazy { TestDataAdapter() } 21 | 22 | override fun onDestroy() { 23 | super.onDestroy() 24 | binding.orientation.setOnClickListener(null) 25 | binding.fullScreen.setOnClickListener(null) 26 | binding.loadAllAtOnce.setOnClickListener(null) 27 | binding.customTransition.setOnClickListener(null) 28 | binding.recyclerView.adapter = null 29 | adapter.setListener(null) 30 | } 31 | 32 | override fun onCreate(savedInstanceState: Bundle?) { 33 | super.onCreate(savedInstanceState) 34 | App.context = this.applicationContext // 随便找位置借个全局context用用. 35 | Config.TRANSITION_OFFSET_Y = statusBarHeight() 36 | // Config.TRANSITION_OFFSET_X = statusBarHeight() // android:screenOrientation="landscape" 37 | setContentView(binding.root) 38 | initialViews() 39 | viewModel.dataList.observe(this, androidx.lifecycle.Observer(adapter::submitList)) 40 | 41 | viewModel.request() 42 | } 43 | 44 | private fun handleAdapterListener(action: String, item: Any?) { 45 | when (action) { 46 | ITEM_CLICKED -> showViewer(item as? MyData?) 47 | } 48 | } 49 | 50 | private fun showViewer(item: MyData?) { 51 | if (item == null) return 52 | // if (item.id == 10L) { 53 | // startActivity(Intent(this, TestActivity::class.java)) 54 | // return 55 | // } 56 | ViewerHelper.provideImageViewerBuilder(this, item) 57 | .show() 58 | } 59 | 60 | private fun initialViews() { 61 | binding.orientation.setOnClickListener { 62 | val orientationH = ViewerHelper.orientationH 63 | ViewerHelper.orientationH = !orientationH 64 | binding.orientation.text = if (!orientationH) "Horizontal" else "Vertical" 65 | Config.VIEWER_ORIENTATION = if (!orientationH) ViewPager2.ORIENTATION_HORIZONTAL else ViewPager2.ORIENTATION_VERTICAL 66 | } 67 | binding.fullScreen.setOnClickListener { 68 | val isFullScreen = ViewerHelper.fullScreen 69 | ViewerHelper.fullScreen = !isFullScreen 70 | binding.fullScreen.text = if (!isFullScreen) "FullScreen(on)" else "FullScreen(off)" 71 | Config.TRANSITION_OFFSET_Y = if (!isFullScreen) 0 else statusBarHeight() 72 | } 73 | binding.loadAllAtOnce.setOnClickListener { 74 | val isLoadAllAtOnce = ViewerHelper.loadAllAtOnce 75 | ViewerHelper.loadAllAtOnce = !isLoadAllAtOnce 76 | binding.loadAllAtOnce.text = if (!isLoadAllAtOnce) "LoadAllAtOnce(on)" else "LoadAllAtOnce(off)" 77 | } 78 | binding.simplePlayVideo.setOnClickListener { 79 | val isSimplePlayVideo = ViewerHelper.simplePlayVideo 80 | ViewerHelper.simplePlayVideo = !isSimplePlayVideo 81 | binding.simplePlayVideo.text = if (!isSimplePlayVideo) "Video(simple)" else "Video(controlView)" 82 | } 83 | binding.videoScaleType.setOnClickListener { 84 | when(ViewerHelper.videoScaleType) { 85 | ExoVideoView.SCALE_TYPE_FIT_XY -> { 86 | ViewerHelper.videoScaleType = ExoVideoView.SCALE_TYPE_CENTER_CROP 87 | binding.videoScaleType.text = "videoScaleType(centerCrop)" 88 | Config.VIDEO_SCALE_TYPE = ExoVideoView.SCALE_TYPE_CENTER_CROP 89 | } 90 | ExoVideoView.SCALE_TYPE_CENTER_CROP -> { 91 | ViewerHelper.videoScaleType = ExoVideoView.SCALE_TYPE_FIT_CENTER 92 | binding.videoScaleType.text = "videoScaleType(fitCenter)" 93 | Config.VIDEO_SCALE_TYPE = ExoVideoView.SCALE_TYPE_FIT_CENTER 94 | } 95 | ExoVideoView.SCALE_TYPE_FIT_CENTER -> { 96 | ViewerHelper.videoScaleType = ExoVideoView.SCALE_TYPE_FIT_XY 97 | binding.videoScaleType.text = "videoScaleType(fitXY)" 98 | Config.VIDEO_SCALE_TYPE = ExoVideoView.SCALE_TYPE_FIT_XY 99 | } 100 | } 101 | } 102 | binding.customTransition.setOnClickListener { 103 | CustomTransitionHelper.show(it) 104 | } 105 | binding.recyclerView.layoutManager = GridLayoutManager(this, 3) 106 | binding.recyclerView.adapter = adapter 107 | adapter.setListener(::handleAdapterListener) 108 | } 109 | } 110 | 111 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/business/SimpleViewerCustomizer.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.business 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import android.widget.TextView 7 | import androidx.fragment.app.FragmentActivity 8 | import androidx.lifecycle.Lifecycle 9 | import androidx.lifecycle.LifecycleEventObserver 10 | import androidx.lifecycle.LifecycleOwner 11 | import androidx.lifecycle.ViewModelProvider 12 | import androidx.recyclerview.widget.RecyclerView 13 | import com.github.iielse.imageviewer.ImageViewerActionViewModel 14 | import com.github.iielse.imageviewer.ImageViewerBuilder 15 | import com.github.iielse.imageviewer.core.OverlayCustomizer 16 | import com.github.iielse.imageviewer.core.Photo 17 | import com.github.iielse.imageviewer.core.VHCustomizer 18 | import com.github.iielse.imageviewer.core.ViewerCallback 19 | import com.github.iielse.imageviewer.demo.R 20 | import com.github.iielse.imageviewer.demo.core.ObserverAdapter 21 | import com.github.iielse.imageviewer.demo.data.MyData 22 | import com.github.iielse.imageviewer.demo.data.Service 23 | import com.github.iielse.imageviewer.demo.databinding.ItemVideoCustomLayoutBinding 24 | import com.github.iielse.imageviewer.demo.databinding.LayoutIndicatorBinding 25 | import com.github.iielse.imageviewer.demo.utils.inflate 26 | import com.github.iielse.imageviewer.demo.utils.lifecycleOwner 27 | import com.github.iielse.imageviewer.demo.utils.setOnClickCallback 28 | import com.github.iielse.imageviewer.demo.utils.toast 29 | import com.github.iielse.imageviewer.utils.Config 30 | import com.github.iielse.imageviewer.viewholders.PhotoViewHolder 31 | import com.github.iielse.imageviewer.viewholders.SubsamplingViewHolder 32 | import com.github.iielse.imageviewer.viewholders.VideoViewHolder 33 | import com.github.iielse.imageviewer.widgets.video.ExoVideoView 34 | import com.google.android.exoplayer2.ui.PlayerControlView 35 | import io.reactivex.Observable 36 | import io.reactivex.android.schedulers.AndroidSchedulers 37 | import io.reactivex.disposables.Disposable 38 | import io.reactivex.schedulers.Schedulers 39 | import java.util.concurrent.TimeUnit 40 | 41 | /** 42 | * viewer 自定义业务&UI 43 | */ 44 | class SimpleViewerCustomizer : LifecycleEventObserver, VHCustomizer, OverlayCustomizer, ViewerCallback { 45 | private var activity: FragmentActivity? = null 46 | private var testDataViewModel: TestDataViewModel? = null 47 | private var viewerViewModel: ImageViewerActionViewModel? = null 48 | private var videoTask: Disposable? = null 49 | private var lastVideoVH: VideoViewHolder? = null 50 | private var binding: LayoutIndicatorBinding? = null 51 | private var currentPosition = -1 52 | 53 | /** 54 | * 对viewer进行自定义封装. 添加自定义指示器.video绑定.图片说明等自定义配置 55 | */ 56 | fun process(activity: FragmentActivity, builder: ImageViewerBuilder) { 57 | this.activity = activity 58 | testDataViewModel = ViewModelProvider(activity).get(TestDataViewModel::class.java) 59 | viewerViewModel = ViewModelProvider(activity).get(ImageViewerActionViewModel::class.java) 60 | activity.lifecycle.addObserver(this) 61 | builder.setVHCustomizer(this) 62 | builder.setOverlayCustomizer(this) 63 | builder.setViewerCallback(this) 64 | } 65 | 66 | override fun initialize(type: Int, viewHolder: RecyclerView.ViewHolder) { 67 | (viewHolder.itemView as? ViewGroup?)?.let { 68 | it.addView(it.inflate(R.layout.item_photo_custom_layout)) 69 | } 70 | when (viewHolder) { 71 | is SubsamplingViewHolder -> viewHolder.binding.subsamplingView.setOnClickCallback { viewerViewModel?.dismiss() } 72 | is PhotoViewHolder -> viewHolder.binding.photoView.setOnClickCallback { viewerViewModel?.dismiss() } 73 | is VideoViewHolder -> { 74 | val customBinding = ItemVideoCustomLayoutBinding.inflate(LayoutInflater.from(viewHolder.binding.root.context)) 75 | viewHolder.binding.root.addView(customBinding.root) 76 | customBinding.playerControlView.visibility = if (ViewerHelper.simplePlayVideo) View.GONE else View.VISIBLE 77 | viewHolder.binding.videoView.let { videoView -> 78 | videoView.setOnClickCallback { 79 | toast("video clicked") 80 | FullVideoActivity.start(videoView.context, videoView) 81 | } 82 | videoView.setOnLongClickListener { 83 | toast("video long clicked") 84 | true 85 | } 86 | } 87 | } 88 | } 89 | } 90 | 91 | override fun bind(type: Int, data: Photo, viewHolder: RecyclerView.ViewHolder) { 92 | val myData = data as MyData 93 | viewHolder.itemView.findViewById(R.id.exText).text = myData.desc 94 | viewHolder.itemView.findViewById(R.id.remove).setOnClickListener { 95 | if (ViewerHelper.loadAllAtOnce) { 96 | val target = listOf(data) 97 | Service.api.asyncDelete(target) { 98 | testDataViewModel?.remove(target) 99 | viewerViewModel?.remove(target) 100 | } 101 | } 102 | else { 103 | val target = listOf(data) 104 | Service.api.asyncDelete(target) { 105 | testDataViewModel?.remove(target) 106 | viewerViewModel?.remove(target) 107 | } 108 | } 109 | } 110 | } 111 | 112 | override fun provideView(parent: ViewGroup): View { 113 | return LayoutIndicatorBinding.inflate(LayoutInflater.from(parent.context), parent, false).also { 114 | binding = it 115 | it.pre.setOnClickCallback { viewerViewModel?.setCurrentItem(currentPosition - 1) } 116 | it.next.setOnClickCallback { viewerViewModel?.setCurrentItem(currentPosition + 1) } 117 | it.dismiss.setOnClickCallback { viewerViewModel?.dismiss() } 118 | }.root 119 | } 120 | 121 | override fun onRelease(viewHolder: RecyclerView.ViewHolder, view: View) { 122 | viewHolder.itemView.findViewById(R.id.customizeDecor) 123 | ?.animate()?.setDuration(200)?.alpha(0f)?.start() 124 | binding?.indicatorDecor?.animate()?.setDuration(200)?.alpha(0f)?.start() 125 | release() 126 | } 127 | 128 | override fun onPageSelected(position: Int, viewHolder: RecyclerView.ViewHolder) { 129 | currentPosition = position 130 | binding?.indicator?.text = position.toString() 131 | processSelectVideo(viewHolder) 132 | } 133 | 134 | private fun processSelectVideo(viewHolder: RecyclerView.ViewHolder) { 135 | videoTask?.dispose() 136 | lastVideoVH?.binding?.videoView?.reset() 137 | 138 | when (viewHolder) { 139 | is VideoViewHolder -> { 140 | val videoView = viewHolder.binding.videoView 141 | val task = object : ObserverAdapter(videoView.lifecycleOwner.lifecycle) { 142 | override fun onNext(t: Long) { 143 | if (ViewerHelper.simplePlayVideo) { 144 | videoView.resume() 145 | } else { 146 | val playerControlView = viewHolder.itemView.findViewById(R.id.playerControlView) 147 | playerControlView?.player = videoView.player() 148 | } 149 | } 150 | } 151 | Observable.timer(Config.DURATION_TRANSITION + 50, TimeUnit.MILLISECONDS) 152 | .observeOn(AndroidSchedulers.mainThread()) 153 | .subscribeOn(Schedulers.io()) 154 | .subscribe(task) 155 | videoTask = task 156 | lastVideoVH = viewHolder 157 | } 158 | } 159 | } 160 | 161 | override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { 162 | val videoView = lastVideoVH?.itemView?.findViewById(R.id.videoView) 163 | when (event) { 164 | Lifecycle.Event.ON_RESUME -> videoView?.resume() 165 | Lifecycle.Event.ON_PAUSE -> videoView?.pause() 166 | Lifecycle.Event.ON_DESTROY -> { 167 | videoView?.release() 168 | videoTask?.dispose() 169 | videoTask = null 170 | } 171 | else -> {} 172 | } 173 | } 174 | 175 | private fun release() { 176 | activity?.lifecycle?.removeObserver(this) 177 | activity = null 178 | videoTask?.dispose() 179 | videoTask = null 180 | lastVideoVH = null 181 | binding = null 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/business/TestActivity.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.business 2 | 3 | import android.net.Uri 4 | import android.os.Bundle 5 | import androidx.appcompat.app.AppCompatActivity 6 | import com.github.iielse.imageviewer.demo.databinding.TestActivityBinding 7 | import com.github.iielse.imageviewer.demo.utils.toast 8 | import com.github.iielse.imageviewer.widgets.video.ExoVideoView 9 | import com.google.android.exoplayer2.MediaItem 10 | import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory 11 | import com.google.android.exoplayer2.source.MediaSource 12 | import com.google.android.exoplayer2.source.ProgressiveMediaSource 13 | import com.google.android.exoplayer2.upstream.DataSource 14 | import com.google.android.exoplayer2.upstream.DataSpec 15 | import com.google.android.exoplayer2.upstream.FileDataSource 16 | import java.io.File 17 | 18 | class TestActivity : AppCompatActivity() { 19 | private val binding by lazy { TestActivityBinding.inflate(layoutInflater) } 20 | 21 | override fun onCreate(savedInstanceState: Bundle?) { 22 | super.onCreate(savedInstanceState) 23 | setContentView(binding.root) 24 | 25 | // binding.exoVideoView.setOnClickListener { 26 | // toast("video click") 27 | // } 28 | // binding.exoVideoView.setOnLongClickListener { 29 | // toast("video long clicked") 30 | // true 31 | // } 32 | // // val url = "https://9890.vod.myqcloud.com/9890_4e292f9a3dd011e6b4078980237cc3d3.f20.mp4" 33 | // val url = File("/storage/emulated/0/DCIM/Camera/trailer.mp4").absolutePath 34 | // binding.exoVideoView.prepare(url) 35 | // binding.exoVideoView.resume(localMediaSourceProvider) 36 | } 37 | 38 | override fun onDestroy() { 39 | super.onDestroy() 40 | // binding.exoVideoView.release() 41 | } 42 | 43 | } 44 | 45 | 46 | //val localMediaSourceProvider = object : ExoVideoView.MediaSourceProvider { 47 | // override fun provide(playUrl: String): List? { 48 | // return try { 49 | //// val dataSpec = DataSpec(Uri.fromFile(File(playUrl))) 50 | //// val fileDataSource = FileDataSource() 51 | //// fileDataSource.open(dataSpec) 52 | //// val factory = DataSource.Factory { fileDataSource } 53 | //// val mediaSource = 54 | //// ProgressiveMediaSource.Factory(factory, DefaultExtractorsFactory()) 55 | //// .createMediaSource(MediaItem.Builder().setUri(fileDataSource.uri).build()) 56 | //// listOf(mediaSource) 57 | // null 58 | // } catch (e: Exception) { 59 | // e.printStackTrace() 60 | // null 61 | // } 62 | // } 63 | //} 64 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/business/TestDataAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.business 2 | 3 | import android.view.ViewGroup 4 | import androidx.recyclerview.widget.RecyclerView 5 | import com.github.iielse.imageviewer.demo.core.BasePagedAdapter 6 | import com.github.iielse.imageviewer.demo.core.viewer.SimpleTransformer 7 | import com.github.iielse.imageviewer.demo.data.MyData 8 | 9 | class TestDataAdapter : BasePagedAdapter() { 10 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { 11 | return when (viewType) { 12 | ItemType.TestData -> TestDataViewHolder(parent, callback) 13 | else -> super.onCreateViewHolder(parent, viewType) 14 | } 15 | } 16 | 17 | override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { 18 | val item = getItem(position) 19 | when (holder) { 20 | is TestDataViewHolder -> item?.data()?.let { holder.bind(it, position) } 21 | } 22 | } 23 | 24 | override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) { 25 | super.onViewAttachedToWindow(holder) 26 | if (holder is TestDataViewHolder) { 27 | val photoId = (holder.itemView.tag as? MyData?)?.id ?: return 28 | SimpleTransformer.put(photoId, holder.binding.imageView) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/business/TestDataViewHolder.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.business 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import android.widget.ImageView 6 | import androidx.recyclerview.widget.RecyclerView 7 | import com.bumptech.glide.Glide 8 | import com.github.iielse.imageviewer.demo.core.AdapterCallback 9 | import com.github.iielse.imageviewer.demo.core.ITEM_CLICKED 10 | import com.github.iielse.imageviewer.demo.data.MyData 11 | import com.github.iielse.imageviewer.demo.databinding.ItemImageBinding 12 | import com.github.iielse.imageviewer.demo.utils.setOnClickCallback 13 | 14 | class TestDataViewHolder( 15 | parent: ViewGroup, 16 | callback: AdapterCallback, 17 | val binding: ItemImageBinding = 18 | ItemImageBinding.inflate(LayoutInflater.from(parent.context), parent, false) 19 | ) : RecyclerView.ViewHolder(binding.root) { 20 | init { 21 | itemView.setOnClickCallback { 22 | callback.invoke(ITEM_CLICKED, itemView.tag) // 初始化点击回调 23 | } 24 | } 25 | 26 | fun bind(item: MyData, pos: Int) { 27 | itemView.tag = item 28 | binding.type.text = when { 29 | item.subsampling -> "subsampling" 30 | item.url.endsWith(".gif") -> "gif" 31 | item.url.endsWith(".mp4") -> "video" 32 | else -> "image" 33 | } 34 | 35 | // 测试 fitXY 的过渡动画效果 36 | binding.imageView.scaleType = if (pos == 19) ImageView.ScaleType.FIT_XY else ImageView.ScaleType.CENTER_CROP 37 | Glide.with(binding.imageView).load(item.url).into(binding.imageView) 38 | } 39 | } 40 | 41 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/business/TestDataViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.business 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.ViewModelProvider 6 | import androidx.paging.PagedList 7 | import com.github.iielse.imageviewer.demo.core.Cell 8 | import com.github.iielse.imageviewer.demo.data.* 9 | 10 | class TestDataViewModel( 11 | private val repository: TestRepository 12 | ) : ViewModel() { 13 | val dataList: LiveData> = repository.dataList 14 | fun remove(item: List) = repository.localDelete(item) 15 | fun request() = repository.request(true) 16 | 17 | class Factory : ViewModelProvider.Factory { 18 | override fun create(modelClass: Class): T { 19 | return TestDataViewModel( TestRepository.get()) as T 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/business/ViewerHelper.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.business 2 | 3 | import androidx.fragment.app.FragmentActivity 4 | import com.github.iielse.imageviewer.ImageViewerBuilder 5 | import com.github.iielse.imageviewer.ImageViewerDialogFragment 6 | import com.github.iielse.imageviewer.core.DataProvider 7 | import com.github.iielse.imageviewer.core.Photo 8 | import com.github.iielse.imageviewer.core.SimpleDataProvider 9 | import com.github.iielse.imageviewer.demo.core.viewer.FullScreenImageViewerDialogFragment 10 | import com.github.iielse.imageviewer.demo.core.viewer.SimpleImageLoader 11 | import com.github.iielse.imageviewer.demo.core.viewer.SimpleTransformer 12 | import com.github.iielse.imageviewer.demo.data.MyData 13 | import com.github.iielse.imageviewer.demo.data.Service 14 | import com.github.iielse.imageviewer.demo.utils.PAGE_SIZE 15 | import com.github.iielse.imageviewer.widgets.video.ExoVideoView 16 | 17 | /** 18 | * viewer的自定义初始化方案 19 | */ 20 | object ViewerHelper { 21 | var orientationH: Boolean = true 22 | var loadAllAtOnce: Boolean = false 23 | var fullScreen: Boolean = false 24 | var simplePlayVideo: Boolean = true 25 | var videoScaleType: Int = ExoVideoView.SCALE_TYPE_FIT_CENTER 26 | 27 | fun provideImageViewerBuilder(context: FragmentActivity, clickedData: MyData): ImageViewerBuilder { 28 | // 数据提供者 一次加载 or 分页 29 | fun myDataProvider(clickedData: MyData): DataProvider { 30 | return if (loadAllAtOnce) { 31 | SimpleDataProvider(clickedData, Service.houMen) 32 | } else { 33 | object : DataProvider { 34 | override fun loadInitial(): List = listOf(clickedData) 35 | override fun loadAfter(key: Long, callback: (List) -> Unit) 36 | = Service.api.asyncQueryAfter(key, PAGE_SIZE, callback) 37 | override fun loadBefore(key: Long, callback: (List) -> Unit) 38 | = Service.api.asyncQueryBefore(key, PAGE_SIZE, callback) 39 | } 40 | } 41 | } 42 | 43 | // viewer 构造的基本元素 44 | val builder = ImageViewerBuilder( 45 | context = context, 46 | dataProvider = myDataProvider(clickedData), // 数据提供者. 和调用者业务强绑定 47 | imageLoader = SimpleImageLoader(), // 自定义实现 48 | transformer = SimpleTransformer() // 固定写法. 实现 ViewerTransitionHelper 确定 进场退场动画 49 | ) 50 | 51 | SimpleViewerCustomizer().process(context, builder) // 添加自定义业务逻辑和UI处理 52 | 53 | if (fullScreen) { // 54 | builder.setViewerFactory(object : ImageViewerDialogFragment.Factory() { 55 | override fun build() = FullScreenImageViewerDialogFragment() 56 | }) // 对弹窗增加自定义内容 57 | } 58 | return builder 59 | } 60 | 61 | } 62 | 63 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/core/BasePagedAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.core 2 | 3 | import android.view.View 4 | import android.view.ViewGroup 5 | import androidx.paging.PagedListAdapter 6 | import androidx.recyclerview.widget.DiffUtil 7 | import androidx.recyclerview.widget.RecyclerView 8 | import com.github.iielse.imageviewer.demo.R 9 | import com.github.iielse.imageviewer.demo.utils.inflate 10 | import java.util.* 11 | 12 | const val ID_EMPTY = "id_empty" 13 | const val ID_MORE_RETRY = "id_more_retry" 14 | const val ID_ERROR = "id_error" 15 | const val ID_MORE_LOADING = "id_more_loading" // 其实可以用 负数int类型的 16 | const val ID_NO_MORE = "id_no_more" 17 | const val PAGE_INITIAL = 1 18 | const val ITEM_CLICKED = "item_clicked" 19 | typealias AdapterCallback = (action: String, item: Any?) -> Unit 20 | 21 | var itemTypeProvider = 1 22 | 23 | data class Cell( 24 | val type: Int, 25 | val id: String, 26 | val extra: Any? = null 27 | ) { 28 | inline fun data(): T? { 29 | return extra as? T? 30 | } 31 | } 32 | 33 | 34 | abstract class BasePagedAdapter : PagedListAdapter(DIFF) { 35 | 36 | private var listener: AdapterCallback? = null 37 | 38 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { 39 | return when (viewType) { 40 | BaseItemType.Error -> ErrorViewHolder(parent.inflate(R.layout.layout_recycler_error)) 41 | BaseItemType.Empty -> EmptyViewHolder(parent.inflate(R.layout.layout_recycler_empty)) 42 | BaseItemType.Loading -> MoreLoadingViewHolder(parent.inflate(R.layout.layout_recycler_loading)) 43 | else -> UnknownViewHolder(View(parent.context).apply { 44 | layoutParams = ViewGroup.LayoutParams(0, 0) 45 | }) 46 | } 47 | } 48 | 49 | fun setListener(callback: AdapterCallback?) { 50 | listener = callback 51 | } 52 | 53 | protected val callback: AdapterCallback = { item, action -> 54 | listener?.invoke(item, action) 55 | } 56 | 57 | override fun getItemViewType(position: Int): Int { 58 | val item = getItem(position) 59 | return item?.type ?: BaseItemType.Unknown 60 | } 61 | } 62 | 63 | private val DIFF = object : DiffUtil.ItemCallback() { 64 | override fun areItemsTheSame(oldItem: Cell, newItem: Cell): Boolean { 65 | return newItem.type == oldItem.type && newItem.id == oldItem.id 66 | } 67 | 68 | override fun areContentsTheSame(oldItem: Cell, newItem: Cell): Boolean { 69 | return newItem.type == oldItem.type && 70 | Objects.equals(newItem.data(), oldItem.data()) 71 | } 72 | } 73 | 74 | fun superProcessCell(itemTypeId: String): Cell? { 75 | return when (itemTypeId) { 76 | ID_EMPTY -> Cell(BaseItemType.Empty, ID_EMPTY) 77 | ID_ERROR -> Cell(BaseItemType.Error, ID_ERROR) 78 | ID_MORE_LOADING -> Cell(BaseItemType.Loading, ID_MORE_LOADING) 79 | ID_MORE_RETRY -> Cell(BaseItemType.Retry, ID_MORE_RETRY) 80 | ID_NO_MORE -> Cell(BaseItemType.NoMore, ID_NO_MORE) 81 | else -> null 82 | } 83 | } 84 | 85 | class MoreRetryViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) 86 | class UnknownViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) 87 | class ErrorViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) 88 | class MoreLoadingViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) 89 | class NoMoreViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) 90 | class EmptyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) 91 | 92 | object BaseItemType { 93 | const val Unknown = -1 94 | val Empty = itemTypeProvider++ 95 | val Error = itemTypeProvider++ 96 | val Loading = itemTypeProvider++ 97 | val Retry = itemTypeProvider++ 98 | val NoMore = itemTypeProvider++ 99 | val SlideTryMore = itemTypeProvider++ 100 | } 101 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/core/Collections.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.core 2 | 3 | fun List.add(item: T?): List { 4 | if (item == null) return this 5 | return toMutableList().apply { add(item) } 6 | } 7 | 8 | fun List.add(index: Int, item: T?): List { 9 | if (item == null) return this 10 | return toMutableList().apply { add(index, item) } 11 | } 12 | 13 | fun List.addAll(items: List?): List { 14 | if (items == null) return this 15 | return toMutableList().apply { addAll(items) } 16 | } 17 | 18 | fun List.insertOrReplace(item: T?): List { 19 | if (item == null) return this 20 | return toMutableList().apply { 21 | if (contains(item)) remove(item) 22 | add(item) 23 | } 24 | } 25 | 26 | fun List.insertOrReplace(index: Int, item: T?): List { 27 | if (item == null) return this 28 | return toMutableList().apply { 29 | if (contains(item)) remove(item) 30 | add(index, item) 31 | } 32 | } 33 | 34 | fun Map.insertOrReplace( 35 | key: T, 36 | value: K 37 | ): Map { 38 | return toMutableMap().apply { put(key, value) } 39 | } 40 | 41 | fun Map.insertOrReplace(items: List>?): Map { 42 | if (items == null) return this 43 | return toMutableMap().apply { putAll(items) } 44 | } 45 | 46 | fun List.replaceIf(block: (T) -> T?): List { 47 | return toMutableList().apply { 48 | forEachIndexed { index, t -> 49 | block(t)?.let { set(index, it) } 50 | } 51 | } 52 | } 53 | 54 | fun Map.replaceIf(block: (K) -> K?): Map { 55 | return toMutableMap().apply { 56 | forEach { item -> block(item.value)?.let { set(item.key, it) } } 57 | } 58 | } 59 | 60 | fun List.remove(item: T?): List { 61 | if (item == null) return this 62 | return toMutableList().apply { remove(item) } 63 | } 64 | 65 | fun List.removeAll(item: List?): List { 66 | if (item == null) return this 67 | return toMutableList().apply { removeAll(item) } 68 | } 69 | 70 | fun List.removeIf( 71 | condition: (T) -> Boolean 72 | ): List { 73 | val list = toMutableList() 74 | val i = list.iterator() 75 | while (i.hasNext()) { 76 | if (condition(i.next())) i.remove() 77 | } 78 | return list 79 | } 80 | 81 | 82 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/core/ObserverAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.core 2 | 3 | import androidx.lifecycle.Lifecycle 4 | import com.github.iielse.imageviewer.demo.utils.bindLifecycle 5 | import io.reactivex.observers.DisposableObserver 6 | 7 | open class ObserverAdapter(lifecycle: Lifecycle?) : DisposableObserver() { 8 | init { 9 | bindLifecycle(lifecycle) 10 | } 11 | 12 | override fun onNext(t: T) { 13 | } 14 | 15 | override fun onError(e: Throwable) { 16 | } 17 | 18 | override fun onComplete() { 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/core/PagingCollections.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.core 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.paging.DataSource 5 | import androidx.paging.PagedList 6 | import androidx.paging.toLiveData 7 | import com.github.iielse.imageviewer.demo.utils.PAGE_SIZE 8 | 9 | data class ListState( 10 | val page: Int = PAGE_INITIAL, 11 | val nextKey: String? = null, 12 | val list: List = listOf(), 13 | val data: Map = mapOf() 14 | ) 15 | 16 | inline fun ListState.reduceOnError( 17 | initial: Boolean 18 | ): ListState { 19 | return if (initial) { 20 | this.copy(page = PAGE_INITIAL, list = listOf(ID_EMPTY)) 21 | } else { 22 | this.copy(list = this.list.queryOver()) 23 | } 24 | } 25 | 26 | inline fun ListState.reduceOnNext( 27 | initial: Boolean, 28 | data: List, 29 | crossinline id: (T) -> String 30 | ): ListState { 31 | val result: ListState 32 | if (initial) { 33 | val count = data.size 34 | result = if (count == 0) { 35 | this.copy(page = PAGE_INITIAL, list = listOf(ID_EMPTY), data = mapOf()) 36 | } else { 37 | this.copy( 38 | page = PAGE_INITIAL, 39 | nextKey = data.lastOrNull()?.let(id), 40 | list = listOf().appendData(data) { id(it) }, 41 | data = mapOf().appendData(data) { id(it) } 42 | ) 43 | } 44 | } else { 45 | val count = data.size 46 | result = if (count == 0) { 47 | this.copy(page = this.page, list = this.list.queryOver(), data = this.data) 48 | } else { 49 | this.copy( 50 | page = this.page + 1, 51 | nextKey = data.lastOrNull()?.let(id), 52 | list = this.list.appendData(data) { id(it) }, 53 | data = this.data.appendData(data) { id(it) } 54 | ) 55 | } 56 | } 57 | return result 58 | } 59 | 60 | fun List.appendData( 61 | data: List, 62 | fetchMore: ()->Boolean = { data.size < PAGE_SIZE }, 63 | id: (T) -> String 64 | ): List { 65 | val dataList = data.map(id) 66 | return remove(ID_MORE_LOADING).addAll(dataList).let { 67 | if (fetchMore()) it.add(ID_NO_MORE) else it.add(ID_MORE_LOADING) 68 | } 69 | } 70 | 71 | inline fun Map.appendData( 72 | data: List?, 73 | id: (K) -> T 74 | ): Map { 75 | if (data == null) return this 76 | return toMutableMap().apply { putAll(data.map { Pair(id(it), it) }) } 77 | } 78 | 79 | 80 | fun List.queryOver(): List { 81 | return this.remove(ID_MORE_LOADING).add(ID_NO_MORE) 82 | } 83 | 84 | fun List.mapToCell(start: Int, count: Int, childCell: (String) -> Cell): List { 85 | return this.safelySubList(kotlin.math.max(0, start), kotlin.math.min(this.size, start + count)) 86 | .map { 87 | superProcessCell(it) ?: childCell(it) 88 | } 89 | } 90 | 91 | fun List.safelySubList( 92 | fromIndex: Int, 93 | toIndex: Int 94 | ): List { 95 | if (fromIndex > toIndex) return emptyList() 96 | if (fromIndex > size - 1) return emptyList() 97 | if (toIndex > size) return this.subList(fromIndex, size) 98 | return this.subList(fromIndex, toIndex) 99 | } 100 | 101 | fun DataSource.Factory.toLiveData( 102 | cellId: (Value) -> String, 103 | requestMore: () -> Unit 104 | ): LiveData> { 105 | return this.toLiveData(pageSize = PAGE_SIZE, 106 | boundaryCallback = object : PagedList.BoundaryCallback() { 107 | override fun onItemAtEndLoaded(itemAtEnd: Value) { 108 | if (ID_MORE_LOADING == cellId(itemAtEnd)) { 109 | requestMore() 110 | } 111 | } 112 | }) 113 | } 114 | 115 | ////////////////////////////////////////////////////////////////// -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/core/XPageKeyedDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.core 2 | 3 | import androidx.paging.PageKeyedDataSource 4 | import kotlin.math.min 5 | 6 | abstract class XPageKeyedDataSource : PageKeyedDataSource() { 7 | abstract fun totalCount(): Int 8 | abstract fun loadRange(start: Int, count: Int): List 9 | 10 | override fun loadInitial( 11 | params: LoadInitialParams, 12 | callback: LoadInitialCallback 13 | ) { 14 | val totalSize = totalCount() 15 | if (totalSize == 0) { 16 | callback.onResult(emptyList(), 0, 0, null, 0) 17 | return 18 | } 19 | 20 | val initialStart = 0 21 | val result = loadRange(initialStart, totalSize) 22 | if (result.size == totalSize) { 23 | callback.onResult(result, initialStart, totalSize, null, result.size) 24 | } else { 25 | // null list, or size doesn't match request - DataStore modified between count and load 26 | invalidate() 27 | } 28 | } 29 | 30 | override fun loadBefore( 31 | params: LoadParams, 32 | callback: LoadCallback 33 | ) { 34 | // ignore 35 | } 36 | 37 | override fun loadAfter( 38 | params: LoadParams, 39 | callback: LoadCallback 40 | ) { 41 | val totalCount = totalCount() 42 | val startPosition = min(totalCount, params.key) 43 | val loadSize = computeLoadSize(totalCount, startPosition, params.requestedLoadSize) 44 | val result = loadRange(startPosition, loadSize) 45 | callback.onResult(result, startPosition + result.size) 46 | } 47 | 48 | private fun computeLoadSize( 49 | totalSize: Int, 50 | loadPosition: Int, 51 | requestedLoadSize: Int 52 | ): Int = if (loadPosition > totalSize) 0 else min(totalSize - loadPosition, requestedLoadSize) 53 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/core/viewer/FullScreenImageViewerDialogFragment.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.core.viewer 2 | 3 | import android.graphics.Color 4 | import android.view.Window 5 | import android.view.WindowManager 6 | import androidx.core.content.ContextCompat 7 | import com.github.iielse.imageviewer.ImageViewerDialogFragment 8 | import com.github.iielse.imageviewer.demo.R 9 | 10 | /** 11 | * 自定义ImageViewerDialogFragment 12 | * 此类主要对于 window 进行个性化再定制 13 | */ 14 | class FullScreenImageViewerDialogFragment : ImageViewerDialogFragment() { 15 | override fun setWindow(win: Window) { 16 | super.setWindow(win) 17 | win.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS or WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) 18 | activity?.window?.statusBarColor = Color.BLACK 19 | } 20 | 21 | override fun onDestroyView() { 22 | super.onDestroyView() 23 | activity?.window?.statusBarColor = context?.let { ContextCompat.getColor(it, R.color.colorPrimaryDark) } 24 | ?: Color.TRANSPARENT 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/core/viewer/SimpleImageLoader.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.core.viewer 2 | 3 | import android.net.Uri 4 | import android.util.LruCache 5 | import android.view.View 6 | import android.widget.ImageView 7 | import android.widget.ProgressBar 8 | import android.widget.TextView 9 | import androidx.recyclerview.widget.RecyclerView 10 | import com.bumptech.glide.Glide 11 | import com.davemorrissey.labs.subscaleview.ImageSource 12 | import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView 13 | import com.github.iielse.imageviewer.core.ImageLoader 14 | import com.github.iielse.imageviewer.core.Photo 15 | import com.github.iielse.imageviewer.demo.R 16 | import com.github.iielse.imageviewer.demo.business.ViewerHelper 17 | import com.github.iielse.imageviewer.demo.core.ObserverAdapter 18 | import com.github.iielse.imageviewer.demo.data.MyData 19 | import com.github.iielse.imageviewer.demo.utils.appContext 20 | import com.github.iielse.imageviewer.demo.utils.lifecycleOwner 21 | import com.github.iielse.imageviewer.demo.utils.toast 22 | import com.github.iielse.imageviewer.utils.Config 23 | import com.github.iielse.imageviewer.widgets.video.ExoVideoView 24 | import com.github.iielse.imageviewer.widgets.video.ExoVideoView2 25 | import com.google.android.exoplayer2.Player 26 | import com.google.android.exoplayer2.analytics.AnalyticsListener 27 | import com.google.android.exoplayer2.source.LoadEventInfo 28 | import com.google.android.exoplayer2.source.MediaLoadData 29 | import com.google.android.exoplayer2.ui.PlayerControlView 30 | import io.reactivex.Observable 31 | import io.reactivex.android.schedulers.AndroidSchedulers 32 | import io.reactivex.schedulers.Schedulers 33 | import java.io.File 34 | import java.io.IOException 35 | 36 | class SimpleImageLoader : ImageLoader { 37 | /** 38 | * 根据自身photo数据加载图片.可以使用其它图片加载框架. 39 | */ 40 | override fun load(view: ImageView, data: Photo, viewHolder: RecyclerView.ViewHolder) { 41 | val it = (data as? MyData?)?.url ?: return 42 | Glide.with(view).load(it) 43 | .placeholder(view.drawable) 44 | .into(view) 45 | } 46 | 47 | override fun load(exoVideoView: ExoVideoView2, data: Photo, viewHolder: RecyclerView.ViewHolder) { 48 | val it = (data as? MyData?)?.url ?: return 49 | val cover = viewHolder.itemView.findViewById(R.id.imageView) 50 | cover.visibility = View.VISIBLE 51 | val loadingTask = Runnable { 52 | findLoadingView(viewHolder)?.visibility = View.VISIBLE 53 | } 54 | cover.postDelayed(loadingTask, Config.DURATION_TRANSITION + 1500) 55 | when(exoVideoView.scaleType) { 56 | ExoVideoView.SCALE_TYPE_FIT_XY -> cover.scaleType = ImageView.ScaleType.FIT_XY 57 | ExoVideoView.SCALE_TYPE_CENTER_CROP -> cover.scaleType = ImageView.ScaleType.CENTER_CROP 58 | ExoVideoView.SCALE_TYPE_FIT_CENTER -> cover.scaleType = ImageView.ScaleType.FIT_CENTER 59 | } 60 | Glide.with(exoVideoView).load(it) 61 | .placeholder(cover.drawable) 62 | .into(cover) 63 | 64 | exoVideoView.addAnalyticsListener(object : AnalyticsListener { 65 | override fun onLoadError(eventTime: AnalyticsListener.EventTime, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData, error: IOException, wasCanceled: Boolean) { 66 | findLoadingView(viewHolder)?.visibility = View.GONE 67 | viewHolder.itemView.findViewById(R.id.errorPlaceHolder)?.text = error.message 68 | } 69 | }) 70 | exoVideoView.setVideoRenderedCallback(object : ExoVideoView.VideoRenderedListener { 71 | override fun onRendered(view: ExoVideoView) { 72 | cover.visibility = View.GONE 73 | cover.removeCallbacks(loadingTask) 74 | findLoadingView(viewHolder)?.visibility = View.GONE 75 | } 76 | }) 77 | 78 | val playerControlView = viewHolder.itemView.findViewById(R.id.playerControlView) 79 | exoVideoView.addListener(object : ExoVideoView2.Listener { 80 | override fun onDrag(view: ExoVideoView2, fraction: Float) { 81 | if (!ViewerHelper.simplePlayVideo) { 82 | playerControlView?.visibility = View.GONE 83 | } 84 | } 85 | 86 | override fun onRestore(view: ExoVideoView2, fraction: Float) { 87 | if (!ViewerHelper.simplePlayVideo) { 88 | playerControlView?.visibility = View.VISIBLE 89 | } 90 | } 91 | 92 | override fun onRelease(view: ExoVideoView2) { 93 | } 94 | }) 95 | 96 | exoVideoView.prepare(it) 97 | exoVideoView.player()?.repeatMode = Player.REPEAT_MODE_ONE 98 | } 99 | 100 | /** 101 | * 根据自身photo数据加载超大图.subsamplingView数据源需要先将内容完整下载到本地.需要注意生命周期 102 | */ 103 | override fun load(subsamplingView: SubsamplingScaleImageView, data: Photo, viewHolder: RecyclerView.ViewHolder) { 104 | val url = (data as? MyData?)?.url ?: return 105 | val cache = subsamplingCache.get(url) 106 | if (cache != null) subsamplingView.setImage(cache) 107 | else subsamplingDownloadRequest(url) 108 | .subscribeOn(Schedulers.io()) 109 | .observeOn(AndroidSchedulers.mainThread()) 110 | .doOnSubscribe { findLoadingView(viewHolder)?.visibility = View.VISIBLE } 111 | .doFinally { findLoadingView(viewHolder)?.visibility = View.GONE } 112 | .doOnNext { subsamplingView.setImage(ImageSource.uri(Uri.fromFile(it)).also { source -> subsamplingCache.put(url, source) }) } 113 | .doOnError { toast(it.message) } 114 | .subscribe(ObserverAdapter(subsamplingView.lifecycleOwner.lifecycle)) 115 | } 116 | 117 | private fun subsamplingDownloadRequest(url: String): Observable { 118 | return Observable.create { 119 | try { 120 | it.onNext(Glide.with(appContext).downloadOnly().load(url).submit().get()) 121 | it.onComplete() 122 | } catch (e: Throwable) { 123 | if (!it.isDisposed) it.onError(e) 124 | } 125 | } 126 | } 127 | 128 | private fun findLoadingView(viewHolder: RecyclerView.ViewHolder): View? { 129 | return viewHolder.itemView.findViewById(R.id.loadingView) 130 | } 131 | 132 | companion object { 133 | val subsamplingCache = LruCache(3) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/core/viewer/SimpleTransformer.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.core.viewer 2 | 3 | import android.view.View 4 | import android.widget.ImageView 5 | import com.github.iielse.imageviewer.core.Transformer 6 | import com.github.iielse.imageviewer.demo.utils.isMainThread 7 | 8 | class SimpleTransformer : Transformer { 9 | override fun getView(key: Long): ImageView? = provide(key) 10 | 11 | companion object { 12 | private val transition = HashMap() 13 | fun put(photoId: Long, imageView: ImageView) { 14 | require(isMainThread()) 15 | if (!imageView.isAttachedToWindow) return 16 | imageView.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { 17 | override fun onViewAttachedToWindow(p0: View?) = Unit 18 | override fun onViewDetachedFromWindow(p0: View?) { 19 | transition.remove(imageView) 20 | imageView.removeOnAttachStateChangeListener(this) 21 | } 22 | }) 23 | transition[imageView] = photoId 24 | } 25 | 26 | private fun provide(photoId: Long): ImageView? { 27 | transition.keys.forEach { 28 | if (transition[it] == photoId) 29 | return it 30 | } 31 | return null 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/data/Api.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.data 2 | 3 | import android.os.Handler 4 | import android.os.Looper 5 | import com.github.iielse.imageviewer.demo.core.remove 6 | import com.github.iielse.imageviewer.demo.core.removeAll 7 | import com.github.iielse.imageviewer.demo.utils.isMainThread 8 | import java.lang.IllegalStateException 9 | import kotlin.math.max 10 | import kotlin.math.min 11 | 12 | // 模拟数据源仓库 操作管理 13 | class Api( 14 | private val data: MutableList // 源数据 15 | ) { 16 | // 模拟网络请求或者本地db查询 上一页 17 | fun asyncQueryBefore(id: Long, pageSize: Int, callback: (List) -> Unit) { 18 | val result = data.map { it.copy() } 19 | val idx = result.indexOfFirst { it.id == id } 20 | Handler(Looper.getMainLooper()).postDelayed({ 21 | if (idx < 0) callback(emptyList()) 22 | else callback(result.subList(max(idx - pageSize, 0), idx)) 23 | }, 100) 24 | } 25 | 26 | // 模拟网络请求或者本地db查询 下一页 27 | fun asyncQueryAfter(id: Long?, pageSize: Int, callback: (List) -> Unit) { 28 | val result = data.map { it.copy() } 29 | val idx = result.indexOfFirst { it.id == id } 30 | Handler(Looper.getMainLooper()).postDelayed({ 31 | when { 32 | id == -1L -> callback(result.subList(0, min(pageSize, result.size))) // 第一页 33 | id == null -> callback(emptyList()) // 查完了 34 | idx < 0 -> callback(emptyList()) 35 | else -> callback(result.subList(idx + 1, max(idx + 1, min(idx + 1 + pageSize, result.size - 1)))) 36 | } 37 | }, 100) 38 | } 39 | 40 | // 模拟删除 41 | fun asyncDelete(item: List, callback: ()->Unit) { 42 | require( isMainThread()) 43 | data.removeAll(item) // 模拟把数据删掉了 44 | Handler(Looper.getMainLooper()).postDelayed({ 45 | callback() 46 | }, 200) 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/data/MyData.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.data 2 | 3 | import com.github.iielse.imageviewer.adapter.ItemType 4 | import com.github.iielse.imageviewer.core.Photo 5 | import com.github.iielse.imageviewer.demo.utils.VideoUtils 6 | 7 | data class MyData(val id: Long, 8 | val url: String, 9 | val subsampling: Boolean = false, 10 | val desc: String = "[$id] Caption or other information for this picture [$id]") : Photo { 11 | override fun id(): Long = id 12 | override fun itemType(): Int { 13 | return when { 14 | VideoUtils.isVideoSource(url) -> ItemType.VIDEO 15 | subsampling -> ItemType.SUBSAMPLING 16 | else -> ItemType.PHOTO 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/data/Service.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.data 2 | 3 | 4 | // 服务器 5 | object Service { 6 | // 用于测试的图片数据源 7 | private var id = 1L 8 | private val originMyData: List by lazy { 9 | mutableListOf( 10 | // long horizontal 11 | // long vertical 12 | MyData( subsampling = true, id = id++, url = "https://imgkepu.gmw.cn/attachement/jpg/site2/20200417/94c69122e51c2003c2e220.jpg"), 13 | // video 14 | MyData(id = id++, url = "https://media.w3.org/2010/05/sintel/trailer.mp4"), 15 | MyData(id = id++, url = "https://gp-dev.cdn.bcebos.com/gp-dev/upload/file/source/d68ca66212adb6bad819678a7c9a4fb9.mp4"), 16 | MyData(id = id++, url = "https://webstatic.mihoyo.com/upload/op-public/2020/09/27/fd431739ff26ceeb3010ac561d68446b_345688670889091949.mp4"), 17 | ).let { 18 | it.apply { addAll(image.map { MyData(id = id++, url = it) }) } 19 | }.toList() 20 | } 21 | 22 | // 图片源数据 源自网络随缘摘取 23 | private val image = arrayOf( 24 | // gif 25 | "https://img.nmgfic.com:90/uploadimg/image/20190305/15517786485c7e43584732a4.11936910.gif", 26 | // normal 27 | "https://images.pexels.com/photos/45170/kittens-cat-cat-puppy-rush-45170.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500", 28 | "https://images.pexels.com/photos/145939/pexels-photo-145939.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260", 29 | "https://images.unsplash.com/photo-1503066211613-c17ebc9daef0?ixlib=rb-1.2.1&dpr=1&auto=format&fit=crop&w=416&h=312&q=60", 30 | "https://images.unsplash.com/photo-1520848315518-b991dd16a625?ixlib=rb-1.2.1&dpr=1&auto=format&fit=crop&w=416&h=312&q=60", 31 | "https://images.unsplash.com/photo-1539418561314-565804e349c0?ixlib=rb-1.2.1&dpr=1&auto=format&fit=crop&w=416&h=312&q=60", 32 | "https://images.unsplash.com/photo-1539418561314-565804e349c0?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60", 33 | "https://images.unsplash.com/photo-1524272332618-3a94122bb0c1?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjU3NTIxfQ&auto=format&fit=crop&w=500&q=60", 34 | "https://images.unsplash.com/photo-1524293191286-59ec719556d4?ixlib=rb-1.2.1&auto=format&fit=crop&w=654&q=80", 35 | "https://images.unsplash.com/photo-1478005344131-44da2ded3415?ixlib=rb-1.2.1&auto=format&fit=crop&w=634&q=80", 36 | "https://images.unsplash.com/photo-1484406566174-9da000fda645?ixlib=rb-1.2.1&auto=format&fit=crop&w=635&q=80", 37 | "https://images.unsplash.com/photo-1462953491269-9aff00919695?ixlib=rb-1.2.1&auto=format&fit=crop&w=634&q=80", 38 | "https://images.unsplash.com/photo-1494256997604-768d1f608cac?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60", 39 | "https://images.unsplash.com/photo-1543183344-acd290d5142e?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60", 40 | "https://images.unsplash.com/photo-1452001603782-7d4e7d931173?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60", 41 | "https://images.unsplash.com/photo-1539692858702-5cc9e1e40c17?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60", 42 | "https://images.unsplash.com/photo-1563409236340-c174b51cbb81?ixlib=rb-1.2.1&auto=format&fit=crop&w=634&q=80", 43 | "https://images.unsplash.com/photo-1486723312829-f32b4a25211b?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60", 44 | "https://images.unsplash.com/photo-1486518714050-b97edb7fcfa9?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjExMjU4fQ&auto=format&fit=crop&w=500&q=60", 45 | "https://images.unsplash.com/photo-1554226114-f7ae1de16f55?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60", 46 | "https://images.unsplash.com/photo-1550699566-83f93df24072?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60", 47 | "https://images.unsplash.com/photo-1418405752269-40caf13f90ad?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60", 48 | "https://images.unsplash.com/photo-1486365227551-f3f90034a57c?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60", 49 | "https://images.unsplash.com/photo-1568435363428-2474799f37c3?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjE3MzYxfQ&auto=format&fit=crop&w=500&q=60", 50 | "https://images.unsplash.com/photo-1553338258-24fe91e8baf3?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60", 51 | "https://images.unsplash.com/photo-1491604612772-6853927639ef?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60", 52 | "https://images.unsplash.com/photo-1565416448218-e59ef8b4f03a?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60", 53 | "https://images.unsplash.com/photo-1516728778615-2d590ea1855e?ixlib=rb-1.2.1&auto=format&fit=crop&w=634&q=80", 54 | "https://images.unsplash.com/photo-1574260288371-7b63f7e3f186?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60", 55 | "https://images.unsplash.com/photo-1550684863-a70a48d476d5?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60", 56 | "https://images.unsplash.com/photo-1496963729609-7d408fa580b5?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60", 57 | "https://images.unsplash.com/photo-1531959870249-9f9b729efcf4?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60" 58 | ) 59 | // private val myData by lazy { listOf(originMyData.toMutableList().first()).toMutableList() } 60 | private val myData by lazy { originMyData.toMutableList() } 61 | val api = Api(myData) 62 | 63 | val houMen = myData.map { it.copy() } // 全数据列表 64 | } 65 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/data/TestRepository.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.data 2 | 3 | import androidx.paging.DataSource 4 | import com.github.iielse.imageviewer.core.Photo 5 | import com.github.iielse.imageviewer.demo.business.ItemType 6 | import com.github.iielse.imageviewer.demo.core.* 7 | import com.github.iielse.imageviewer.demo.utils.PAGE_SIZE 8 | import com.github.iielse.imageviewer.demo.utils.runOnWorkThread 9 | 10 | 11 | // 主页面的数据 12 | class TestRepository { 13 | private var dataSource: XPageKeyedDataSource? = null 14 | private val dataSourceFactory = object : DataSource.Factory() { 15 | override fun create() = object : XPageKeyedDataSource() { 16 | override fun totalCount() = state.list.size 17 | override fun loadRange(start: Int, count: Int): List { 18 | return state.list.mapToCell(start, count) { 19 | Cell(ItemType.TestData, it, state.data[it]) 20 | } 21 | } 22 | }.also { dataSource = it } 23 | } 24 | private var state = ListState() 25 | val dataList = dataSourceFactory.toLiveData( 26 | cellId = { it.id }, 27 | requestMore = { request(false) } 28 | ) 29 | 30 | // 分页加载 31 | fun request(initial: Boolean) { 32 | val requestKey = if (initial) -1 else state.nextKey?.toLong() 33 | Service.api.asyncQueryAfter(requestKey, PAGE_SIZE) { 34 | runOnWorkThread { 35 | state = state.reduceOnNext(initial, it) { it.id().toString() } 36 | dataSource?.invalidate() 37 | } 38 | } 39 | } 40 | 41 | // 清除本地数据 42 | fun localDelete(item: List) = runOnWorkThread { 43 | state = state.copy( 44 | list = state.list.removeAll(item.map { it.id.toString() }) 45 | ) 46 | dataSource?.invalidate() 47 | } 48 | 49 | companion object { 50 | private val inst by lazy { TestRepository() } 51 | fun get() = inst 52 | } 53 | } 54 | 55 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/utils/VideoUtils.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.utils 2 | 3 | import java.util.regex.Pattern 4 | 5 | object VideoUtils { 6 | fun isVideoSource(sourceUrl: String): Boolean { 7 | return Pattern.compile(".+(://).+\\.(mp4|wmv|avi|mpeg|rm|rmvb|flv|3gp|mov|mkv|mod|)") 8 | .matcher(sourceUrl).matches() 9 | } 10 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/utils/extensions.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.utils 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.ContextWrapper 6 | import android.view.LayoutInflater 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import androidx.fragment.app.FragmentActivity 10 | import androidx.lifecycle.* 11 | import com.github.iielse.imageviewer.demo.R 12 | import io.reactivex.disposables.Disposable 13 | 14 | fun View.setOnClickCallback(interval: Long = 500L, callback: (View) -> Unit) { 15 | if (!isClickable) isClickable = true 16 | if (!isFocusable) isFocusable = true 17 | setOnClickListener(object : View.OnClickListener { 18 | override fun onClick(v: View?) { 19 | v ?: return 20 | val lastClickedTimestamp = v.getTag(R.id.view_last_click_timestamp)?.toString()?.toLongOrNull() ?: 0L 21 | val currTimestamp = System.currentTimeMillis() 22 | if (currTimestamp - lastClickedTimestamp < interval) return 23 | v.setTag(R.id.view_last_click_timestamp, currTimestamp) 24 | callback(v) 25 | } 26 | }) 27 | } 28 | 29 | fun Disposable.bindLifecycle(lifecycle: Lifecycle?) { 30 | lifecycle?.observeOnDestroy { dispose() } 31 | } 32 | 33 | val View.activity: FragmentActivity? 34 | get() = getActivity(context) as FragmentActivity? 35 | 36 | // https://stackoverflow.com/questions/9273218/is-it-always-safe-to-cast-context-to-activity-within-view/45364110 37 | private fun getActivity(context: Context?): Activity? { 38 | if (context == null) return null 39 | if (context is Activity) return context 40 | if (context is ContextWrapper) return getActivity(context.baseContext) 41 | return null 42 | } 43 | 44 | //val View.lifecycleOwner: LifecycleOwner? get() { 45 | // val activity = activity as? FragmentActivity? ?: return null 46 | // val fragment = findSupportFragment(this, activity) 47 | // return fragment?.viewLifecycleOwner ?: activity 48 | //} 49 | //private val tempViewToSupportFragment = ArrayMap() 50 | //private fun findSupportFragment(target: View, activity: FragmentActivity): Fragment? { 51 | // tempViewToSupportFragment.clear() 52 | // findAllSupportFragmentsWithViews( 53 | // activity.supportFragmentManager.fragments, tempViewToSupportFragment 54 | // ) 55 | // var result: Fragment? = null 56 | // val activityRoot = activity.findViewById(android.R.id.content) 57 | // var current = target 58 | // while (current != activityRoot) { 59 | // result = tempViewToSupportFragment[current] 60 | // if (result != null) { 61 | // break 62 | // } 63 | // current = if (current.parent is View) { 64 | // current.parent as View 65 | // } else { 66 | // break 67 | // } 68 | // } 69 | // tempViewToSupportFragment.clear() 70 | // return result 71 | //} 72 | // 73 | //private fun findAllSupportFragmentsWithViews( 74 | // topLevelFragments: Collection?, result: MutableMap 75 | //) { 76 | // if (topLevelFragments == null) { 77 | // return 78 | // } 79 | // for (fragment in topLevelFragments) { 80 | // // getFragment()s in the support FragmentManager may contain null values, see #1991. 81 | // if (fragment?.view == null) { 82 | // continue 83 | // } 84 | // result[fragment.view] = fragment 85 | // findAllSupportFragmentsWithViews(fragment.childFragmentManager.fragments, result) 86 | // } 87 | //} 88 | 89 | val View.lifecycleOwner: LifecycleOwner get() { 90 | val self = this 91 | var owner = self.getTag(R.id.view_lifecycle_owner) as? LifecycleOwner? 92 | if (owner == null) { 93 | val lifecycleOwner = object : LifecycleOwner { 94 | private val registry = LifecycleRegistry(this) 95 | override fun getLifecycle() = registry 96 | } 97 | self.setTag(R.id.view_lifecycle_owner, lifecycleOwner) 98 | val viewLifecycle = lifecycleOwner.lifecycle 99 | owner = lifecycleOwner 100 | self.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { 101 | override fun onViewAttachedToWindow(v: View?) { 102 | viewLifecycle.currentState = Lifecycle.State.CREATED 103 | viewLifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START) 104 | viewLifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) 105 | } 106 | 107 | override fun onViewDetachedFromWindow(v: View?) { 108 | viewLifecycle.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) 109 | viewLifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP) 110 | viewLifecycle.currentState = Lifecycle.State.DESTROYED 111 | self.setTag(R.id.view_lifecycle_owner, null) 112 | self.removeOnAttachStateChangeListener(this) 113 | } 114 | }) 115 | } 116 | return owner 117 | } 118 | 119 | fun LiveData.observe(view: View, observer: Observer) { 120 | this.observe(view.lifecycleOwner, observer) 121 | } 122 | 123 | fun Lifecycle.observeOnDestroy(block: () -> Unit) { 124 | val self = this 125 | self.addObserver(object : LifecycleEventObserver { 126 | override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { 127 | if (event == Lifecycle.Event.ON_DESTROY) { 128 | block() 129 | self.removeObserver(this) 130 | } 131 | } 132 | }) 133 | } 134 | 135 | fun Lifecycle.observeOnResume(once: Boolean = true, block: () -> Unit) { 136 | val self = this 137 | self.addObserver(object : LifecycleEventObserver { 138 | override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { 139 | if (event == Lifecycle.Event.ON_RESUME) { 140 | block() 141 | if (once) self.removeObserver(this) 142 | } else if (event == Lifecycle.Event.ON_DESTROY) { 143 | self.removeObserver(this) 144 | } 145 | } 146 | }) 147 | } 148 | 149 | 150 | fun ViewGroup.inflate(resId: Int): View { 151 | return LayoutInflater.from(context).inflate(resId, this, false) 152 | } 153 | 154 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/iielse/imageviewer/demo/utils/utils.kt: -------------------------------------------------------------------------------- 1 | package com.github.iielse.imageviewer.demo.utils 2 | 3 | import android.content.Context 4 | import android.os.Handler 5 | import android.os.Looper 6 | import android.widget.Toast 7 | import java.util.concurrent.Executors 8 | 9 | const val PAGE_SIZE = 5 // 分页size 10 | 11 | val appContext get() = App.context!! 12 | fun toast(message: String?) { 13 | if (message.isNullOrEmpty()) return 14 | runOnUIThread { Toast.makeText(appContext, message, Toast.LENGTH_SHORT).show() } 15 | } 16 | 17 | 18 | fun isMainThread() = Looper.myLooper() == Looper.getMainLooper() 19 | fun runOnUIThread(block: () -> Unit) { 20 | if (isMainThread()) block() else Handler(Looper.getMainLooper()).post(block) 21 | } 22 | 23 | private val workThreadPool by lazy { Executors.newSingleThreadExecutor()!! } 24 | fun runOnWorkThread(block: () -> Unit) { 25 | workThreadPool.execute(block) 26 | } 27 | 28 | fun statusBarHeight(): Int { 29 | var height = 0 30 | val resourceId = appContext.resources.getIdentifier("status_bar_height", "dimen", "android") 31 | if (resourceId > 0) { 32 | height = appContext.resources.getDimensionPixelSize(resourceId) 33 | } 34 | return height 35 | } 36 | 37 | object App { 38 | var context: Context? = null 39 | } 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /app/src/main/res/layout/full_video_activity.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 11 | 12 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/layout/item_image.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 19 | 20 | 32 | -------------------------------------------------------------------------------- /app/src/main/res/layout/item_photo_custom_layout.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 18 | 19 |